Bài này hướng dẫn cấu hình Cloudflare AI Controls — gồm MCP serversMCP 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:

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.com là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ạy cloudflared 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-origin

  • Session duration: 24 hours

  • Destination → Public hostname: Subdomain py-mcp, Domain example.com, Path để trống

  • Identity 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-health

  • Destination → Public hostname: Subdomain py-mcp, Domain example.com, Path: /healthz

  • Policy: Add policy → Action Bypass, Include Everyone

Verify: curl -I https://py-mcp.example.com/healthz200, 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 -1 rấ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-client

  • Cloudflare 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-token

  • Action: 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-upstream

  • Server 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 origin

  • py-mcp.example.com/healthz → destination của application health

  • https://py-mcp.example.com/mcpHTTP 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-portal

  • Hostname: 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_failed hoặc Session expired, please reauthenticate dù 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/mcp

  • OAuth 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-Id cho 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ận invalid_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.