Bài này hướng dẫn cấu hình Cloudflare AI Controls — gồm MCP servers và MCP Portal — để một MCP server tự viết chạy trong LAN (sau NAT, không public IP) có thể được Claude Web và Claude Mobile sử dụng, với xác thực đầy đủ ở mọi chặng.
Đây là phần 1 của series 2 bài:
Phần 1 (bài này) — cấu hình từ đầu đến khi kết nối được.
Phần 2 — 9 lỗi thường gặp khi setup Cloudflare MCP Portal và cách sửa.
Nếu bạn đang gặp lỗi cụ thể (403, HTTP -1, invalid_client, mcp_token_exchange_failed, No allowed servers available), nhảy thẳng sang phần 2.
Sau khi làm xong bài này bạn có gì
MCP server chạy trong LAN, không mở port nào ra internet.
Một endpoint duy nhất (
mcp.example.com) để Claude Web/Mobile kết nối.Xác thực ở mọi chặng: OAuth cho người dùng, service token cho máy.
Thêm MCP server thứ hai, thứ ba vào cùng portal mà client không phải cấu hình lại.
Thời gian: khoảng 45–90 phút nếu chưa từng làm.
Bạn cần có sẵn
Một domain đã đưa vào Cloudflare (bài này dùng
example.comlàm ví dụ — thay bằng domain của bạn).Tài khoản Cloudflare Zero Trust (gói free là đủ).
cloudflaredđã cài trên máy chạy MCP server, đã chạycloudflared tunnel login.Một MCP server nói được streamable-http, đang lắng ở loopback (ví dụ
127.0.0.1:8765), có sẵn một bearer token nội bộ để tự bảo vệ.
Bốn loại object và vai trò của chúng
Đây là chỗ gây nhầm lẫn nhiều nhất, và hiểu sai thì mọi bước sau đều sai. Cloudflare có bốn thứ khác nhau, tên nghe na ná:
| Object | Vai trò | Có tunnel không? | Có chạy MCP không? | | --- | --- | --- | --- | | Cloudflare Tunnel | Đưa traffic từ edge về máy nội bộ | Chính nó | Không | | Self-hosted application | Access policy bảo vệ một hostname/path | Không | Không | | MCP server object (AI Controls) | Đăng ký một upstream MCP, đồng bộ tool | Không | Không | | MCP Portal (AI Controls) | Gom nhiều MCP object thành một endpoint cho client | Không | Không |
Điểm quan trọng nhất: MCP Portal không phải là một tunnel. Nó là endpoint do Cloudflare host, không trỏ về máy nào của bạn. Rất nhiều người (kể cả tôi lúc đầu) tưởng portal là hostname trỏ tunnel về MCP server — cấu hình theo hướng đó sẽ không bao giờ chạy.
Với hai MCP server (Python + TypeScript), topology đúng là ba hostname, ba vai trò:
| Hostname | Vai trò | Tunnel | | --- | --- | --- | | py-mcp.example.com | Upstream Python MCP | Có → 127.0.0.1:8765 | | ts-mcp.example.com | Upstream TypeScript MCP | Có → 127.0.0.1:8766 | | mcp.example.com | Portal — nơi Claude kết nối | Không |
Luồng một request đi qua:
Claude Web/Mobile
→ mcp.example.com/mcp (MCP Portal — Cloudflare host)
→ MCP server object (AI Controls)
→ py-mcp.example.com/mcp (Self-hosted app bảo vệ)
→ cloudflared
→ MCP server 127.0.0.1:8765
Mỗi runtime một tunnel, một hostname. Đừng gộp hai runtime vào một hostname rồi phân biệt bằng path — Access policy gán ở cấp application, tách hostname sạch hơn nhiều.
Bước 1 — Tunnel và DNS
Làm bước này trước tiên và smoke test cho chạy, rồi mới đụng tới Access hay AI Controls. Nếu tạo MCP object trỏ vào hostname chưa có tunnel, nó sẽ báo Error và bạn sẽ ngồi debug nhầm tầng.
Tạo tunnel và DNS record:
cloudflared tunnel create mcp-py
cloudflared tunnel route dns mcp-py py-mcp.example.com
File config (config.yml):
tunnel: <TUNNEL_ID>
credentials-file: /home/user/.cloudflared/<TUNNEL_ID>.json
ingress:
- hostname: py-mcp.example.com
service: http://127.0.0.1:8765
- service: http_status:404
Chạy tunnel rồi test khi chưa có Access app:
curl -i -X POST https://py-mcp.example.com/mcp \
-H "Authorization: Bearer <BEARER_TOKEN>" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
Verify: nhận 200 kèm SSE event. Thử bỏ header Authorization đi — phải nhận 401. Nhận 401 ở bước này là dấu hiệu tốt: tunnel đã thông tới origin và chính MCP server đang từ chối, chứ không phải Cloudflare chặn.
Kiểm tra DNS record có bật Proxy status (đám mây màu cam) hay không. Nếu để DNS-only (màu xám), Access sẽ không chen vào được và mọi policy bạn cấu hình sau này đều vô tác dụng.
Bước 2 — Hai Access application cho mỗi hostname
Trước khi làm, cần hiểu một quy tắc: policy trong Access gán ở cấp application và áp cho mọi destination của application đó. Không có policy riêng theo từng path.
Hệ quả: /healthz cần Bypass (uptime monitor gọi không cần login), còn /mcp cần Service Auth. Hai bộ policy trái ngược nhau, nên không thể nhét chung một app. Nếu nhét chung rồi gắn Bypass, Bypass sẽ mở luôn /mcp — mất sạch bảo vệ.
Vậy nên với mỗi hostname, tạo hai application:
| Application | Destination | Policy | | --- | --- | --- | | mcp-py-origin | py-mcp.example.com (path để trống = cả host) | Service Auth | | mcp-py-health | py-mcp.example.com/healthz | Bypass Everyone |
Access luôn chọn application có destination cụ thể nhất. Request vào /healthz khớp app health (path dài hơn), mọi thứ còn lại rơi về app origin. Cơ chế most-specific-path lo phần định tuyến giùm bạn.
2a. Application origin
Zero Trust → Access → Applications → Add an application → Self-hosted
Application name:
mcp-py-originSession duration:
24 hoursDestination → Public hostname: Subdomain
py-mcp, Domainexample.com, Path để trốngIdentity providers: bật One-time PIN hoặc Google
Chưa thêm policy vội — làm ở bước 3.
2b. Application health
Add an application → Self-hosted
Application name:
mcp-py-healthDestination → Public hostname: Subdomain
py-mcp, Domainexample.com, Path:/healthzPolicy: Add policy → Action Bypass, Include Everyone
Verify: curl -I https://py-mcp.example.com/healthz → 200, không bị đẩy sang trang login.
Lưu ý quan trọng: đừng thêm policy Allow (theo email) vào application origin. Nghe có vẻ vô hại — "cho phép chính mình vào cũng được mà" — nhưng nó làm probe headless của AI Controls bị đẩy sang trang login và nhận về HTML, dẫn tới lỗi
HTTP -1rất khó lần. Application origin chỉ dành cho máy, không dành cho người browse.
Bước 3 — Service token và Service Auth policy
MCP object của Cloudflare gọi vào origin của bạn theo kiểu headless — không có ai ngồi bấm nút đăng nhập. Nên nó cần một danh tính máy: service token.
3a. Tạo service token
Zero Trust → Access → Service auth → Service Tokens → Create Service Token
Name:
mcp-clientCloudflare hiển thị Client ID (đuôi
.access) và Client Secret đúng một lần — copy ngay và cất vào nơi an toàn.
3b. Gắn Service Auth policy vào application origin
Quay lại mcp-py-origin → Policies → Add a policy:
Policy name:
mcp-service-tokenAction: Service Auth
Include: Service Token → chọn
mcp-client
Verify — gửi đủ ba header, phải nhận 200:
curl -i -X POST https://py-mcp.example.com/mcp \
-H "CF-Access-Client-Id: <CLIENT_ID>.access" \
-H "CF-Access-Client-Secret: <CLIENT_SECRET>" \
-H "Authorization: Bearer <BEARER_TOKEN>" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
Bỏ hai header CF-Access-* đi thì phải nhận 403 — nghĩa là Access đang chặn đúng như mong muốn.
Có một mục tên Linked App Token trong màn tạo policy Service Auth. Tên nghe rất giống thứ bạn cần, nhưng nó không phải credential mà AI Controls dùng khi sync. Tôi đã tick đủ mọi token trong đó và vẫn nhận 403. Bỏ qua mục này.
Bước 4 — Tạo MCP server object trong AI Controls
Zero Trust → Access → AI Controls → MCP servers → Add MCP server
Name:
python-mcp-upstreamServer ID:
python-mcp(định danh ổn định, đặt một lần rồi giữ nguyên)HTTP endpoint:
https://py-mcp.example.com/mcp
Chú ý ba giá trị dễ lẫn trên cùng một hostname:
py-mcp.example.com→ destination của application originpy-mcp.example.com/healthz→ destination của application healthhttps://py-mcp.example.com/mcp→ HTTP endpoint của MCP object
Chỉ MCP object mới dùng /mcp.
4a. Custom Headers — bước quan trọng nhất của cả bài
Trong phần Authentication / Custom Headers của object, thêm ba header:
| Header | Giá trị | Để làm gì | | --- | --- | --- | | CF-Access-Client-Id | <CLIENT_ID>.access | Qua Cloudflare Access | | CF-Access-Client-Secret | <CLIENT_SECRET> | Qua Cloudflare Access | | Authorization | Bearer <BEARER_TOKEN> | Qua chính MCP server |
Đây là chỗ đặt credential thật để object đi qua Access. Thiếu hai header CF-Access-*, object sẽ báo Error: HTTP 403 và bạn sẽ đi tìm nguyên nhân ở policy — sai chỗ.
Cách nhớ: hai header đầu để qua cổng, header thứ ba để vào nhà.
4b. Server-level policy
Object cũng có policy riêng, quyết định ai nhìn thấy server này trong portal. Thêm:
Action: Allow
Include: Emails → email của bạn
Policy này khác hoàn toàn với policy trên application: application quyết định đi qua được edge hay không, còn policy của object quyết định có hiện trong danh sách portal hay không. Thiếu cái sau, bạn đăng nhập portal thành công nhưng thấy danh sách trống.
Verify: bấm Sync capabilities. Status phải chuyển sang Ready và cột Tools hiện đúng số tool. Nếu thấy Error, xem phần 2 của series.
Lặp lại bước 1–4 cho MCP server thứ hai (ts-mcp.example.com) nếu bạn có nhiều runtime.
Bước 5 — Tạo MCP Portal
Zero Trust → Access → AI Controls → MCP Portals → Add portal
Name:
mcp-portalHostname:
mcp.example.com
Portal này Cloudflare tự host — bạn không tạo tunnel, không tạo DNS record thủ công, không trỏ nó về máy nào cả.
5a. Policy cho portal — chỉ một loại duy nhất
Portal chỉ nhận một policy:
Action: Allow
Include: Emails → email được phép dùng
Tuyệt đối không gắn policy Service Auth lên portal. Đây là cái bẫy tôi mất nhiều thời gian nhất. Nếu bạn đã tạo policy Service Auth ở bước 3 và tiện tay gán cho cả ba application, portal sẽ hỏng: người dùng đăng nhập OAuth xong sẽ nhận
mcp_token_exchange_failedhoặcSession expired, please reauthenticatedù thao tác liền mạch. Lý do: người vào portal là user thật với OAuth session, không phải máy. Policy Service Auth nằm đó sẽ shadow mất session OAuth.
Portal đã có sẵn OAuth endpoints và hỗ trợ Dynamic Client Registration — bạn không cần bật thêm toggle nào.
5b. Thêm MCP server vào portal
Add server → chọn python-mcp-upstream (và các object khác).
Với mỗi server, đặt Require user auth = off. Nghĩa là portal đi tới upstream bằng credential máy (chính là service token trong Custom Headers ở bước 4a), không đẩy danh tính người dùng xuống dưới. Đây là cách phù hợp cho hạ tầng tự quản.
5c. Chọn tool được expose
Tool đồng bộ về nên để disabled mặc định, chỉ bật những tool đã review. Với MCP có quyền ghi/xoá, đừng coi portal filtering là lớp authorization duy nhất — MCP server vẫn phải tự kiểm quyền ở phía nó.
Bước 6 — Kết nối Claude
Ở mọi client, bạn chỉ cần nhập URL. Không nhập OAuth Client ID, không nhập Client Secret, không nhập service token.
Claude Web / Mobile: Settings → Connectors → Add custom connector
Name: tên tuỳ ý
Remote MCP server URL:
https://mcp.example.com/mcpOAuth Client ID / Client Secret: để trống
Claude Desktop: Connect to a custom MCP
Type: Streamable HTTP (không phải STDIO)
URL:
https://mcp.example.com/mcp
Bấm Connect, bạn sẽ được đẩy qua trang đăng nhập Cloudflare (OTP hoặc Google), rồi tới màn authorize. Xong là tool xuất hiện.
Đừng dán service token vào ô OAuth Client ID. Nghe hiển nhiên, nhưng khi đã quen dùng
CF-Access-Client-Idcho phần upstream thì rất dễ tưởng đó cũng là client ID của OAuth. Không phải. Service token là danh tính headless cho Access edge, còn OAuth client do Claude tự đăng ký. Dán nhầm sẽ nhậninvalid_client: Client not found, và tạo service token mới cũng không sửa được.
Checklist verify toàn tuyến
Chạy lần lượt, mỗi bước phải đúng trước khi sang bước sau:
| # | Kiểm tra | Kết quả đúng | | --- | --- | --- | | 1 | curl origin không kèm header nào | 403 (Access chặn) | | 2 | curl origin kèm đủ 3 header | 200 + SSE | | 3 | curl -I https://py-mcp.example.com/healthz | 200, không redirect | | 4 | AI Controls → MCP servers | Status Ready, có số tool | | 5 | Mở https://mcp.example.com/mcp trên browser | Đẩy sang trang login Cloudflare | | 6 | Claude → Add connector → Connect | Login xong, tool hiện ra |
Bảng tổng hợp: policy nào gắn ở đâu
Đặt nhầm chỗ là nguyên nhân của gần như mọi lỗi. Bảng này là thứ tôi ước có từ đầu:
| Object | Policy đúng | Tuyệt đối không | | --- | --- | --- | | Application origin (/mcp) | Service Auth = service token | Allow theo email (→ HTTP -1) | | Application health (/healthz) | Bypass Everyone | Service Auth, Allow | | MCP server object | Allow theo email + Custom Headers | Bypass | | MCP Portal | Allow theo email | Service Auth (→ mcp_token_exchange_failed) |
Và ma trận credential từng chặng:
| Chặng | Credential | Cấu hình ở đâu | | --- | --- | --- | | Claude → Portal | OAuth DCR (Claude tự đăng ký) | Chỉ cần policy Allow trên portal | | User thấy server nào | Allow theo email | Server-level policy của MCP object | | Object → origin | Service token | Custom Headers của MCP object | | Origin nhận request | Bearer token | Custom Headers của MCP object | | Monitor → /healthz | (không cần) | Bypass trên application health |
Không dùng lại một secret cho hai chặng. Rotate độc lập: service token (edge), bearer (origin), secret nội bộ (API downstream).
Thêm MCP server thứ hai
Đây là lúc portal thể hiện giá trị. Lặp bước 1–4 cho hostname mới, rồi Add server vào portal đã có. Phía Claude không phải cấu hình lại gì cả — vẫn URL cũ, tool mới tự xuất hiện.
Nếu bạn gặp lỗi
Nguyên tắc quan trọng nhất khi debug: đọc mã lỗi để biết tầng nào đang chặn, đừng đoán.
Mẹo nhanh: nếu response header có cf-access-domain thì Access đang chặn, request chưa tới MCP server của bạn — đi sửa policy. Còn nếu nhận JSON-RPC error thì đã vào tới MCP server rồi — đừng sửa policy nữa, vấn đề nằm ở origin.
Bảng tra nhanh:
| Mã lỗi | Tầng đang chặn | | --- | --- | | 302 → /cdn-cgi/access/login | Access — chưa có credential nào khớp | | 403 + cf-access-domain | Access — Custom Headers thiếu CF-Access-* | | HTTP -1 | Access — application origin lỡ có policy Allow | | 401 JSON | Origin — thiếu/sai bearer token | | 404 "Session not found" | Origin — lỗi session stateful | | invalid_client | Portal OAuth — dán nhầm service token | | No allowed servers available | Visibility — object thiếu Allow policy | | mcp_token_exchange_failed | Portal — có Service Auth policy gắn nhầm |
Chi tiết từng lỗi, cách chẩn đoán và cách sửa nằm ở phần 2: 9 lỗi thường gặp khi setup Cloudflare MCP Portal và cách sửa.