Running a Remote MCP Server on Cloudflare Workers for Free: A Practical Guide from Zero to Production
Tuần này trên Hacker News có một bài Show HN nhỏ nhưng khá đáng chú ý: một bạn chạy WhatsApp MCP server miễn phí trên Cloudflare. Bài không nhiều điểm, nhưng nó cho thấy một hướng mà mình thấy dev Việt mình nên thử sớm. MCP (Model Context Protocol) server không cần nằm trên laptop rồi chạy qua stdio nữa, có thể deploy lên edge, gọi qua HTTP và dùng chung cho cả team. Mình đã chuyển vài tool nội bộ (tra cứu đơn hàng, đọc log, lưu ghi chú) từ local MCP sang Cloudflare Workers. Bài này ghi lại cách làm, kèm mấy chỗ dễ vấp.
Vì sao nên đưa MCP server lên remote?
Phần lớn tutorial MCP hướng dẫn chạy server local, client (Claude Desktop, Cursor, VS Code...) spawn một process rồi nói chuyện qua stdin/stdout. Cách này ổn khi làm một mình, nhưng sẽ gặp mấy vấn đề sau:
- Mỗi máy phải cài một lần: Node version khác nhau, thiếu env var, mỗi người một kiểu.
-
- Secret nằm rải rác: API key của database hay của bên thứ ba bị copy vào file config trên máy từng người.
-
- Không dùng được từ client chạy trên web: các agent chạy trên cloud không spawn được process trên máy bạn.
Remote MCP dùng transport Streamable HTTP (có trong spec MCP từ bản 2025-03-26, thay cho HTTP+SSE cũ). Client chỉ cần một URL và một token. Secret nằm trên server, khi cần đổi gì thì deploy lại một lần là cả team có bản mới.
Cloudflare Workers hợp với việc này vì free tier cho 100.000 request/ngày, cold start gần như bằng 0, và Durable Objects (bản SQLite) cũng đã có trên free plan. Mỗi session MCP được lưu state trong một Durable Object riêng.
flowchart LR
A[Claude / Cursor / Agent] -->|HTTPS + Bearer token| B[Cloudflare Worker]
B --> C{Auth check}
C -->|fail| D[401]
C -->|ok| E[McpAgent - Durable Object]
E --> F[(KV: notes)]
E --> G[External API]
```
## Dựng project trong 5 phút
Mình dùng `wrangler` 4.x, package `agents` của Cloudflare và `@modelcontextprotocol/sdk` chính chủ. Bạn cần Node 20 trở lên.
```bash
npm create cloudflare@latest mcp-notes -- --type=hello-world --lang=ts
cd mcp-notes
npm install agents @modelcontextprotocol/sdk zod
# Tạo KV namespace để lưu ghi chú
npx wrangler kv namespace create NOTES
# Secret token để client xác thực
npx wrangler secret put MCP_TOKEN
```
Sửa `wrangler.jsonc` để khai báo Durable Object và KV binding:
```json
{
"name": "mcp-notes",
"main": "src/index.ts",
"compatibility_date": "2025-09-01",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [{ "name": "MCP_OBJECT", "class_name": "NotesMCP" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["NotesMCP"] }],
"kv_namespaces": [{ "binding": "NOTES", "id": "<id-vừa-tạo>" }]
}
```
Chỗ nhiều người vấp: trên free plan bắt buộc dùng `new_sqlite_classes`. Nếu khai báo `new_classes` (Durable Object kiểu cũ), lúc deploy sẽ bị lỗi.
## Viết server: tool, validate input và auth
Đây là file `src/index.ts`. Server có hai tool: `save_note` lưu ghi chú vào KV và `search_notes` tìm ghi chú theo keyword.
```typescript
import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
interface Env {
NOTES: KVNamespace;
MCP_TOKEN: string;
MCP_OBJECT: DurableObjectNamespace;
}
export class NotesMCP extends McpAgent<Env> {
server = new McpServer({ name: "team-notes", version: "1.0.0" });
async init() {
this.server.tool(
"save_note",
"Lưu một ghi chú ngắn cho team",
{ title: z.string().max(100), body: z.string().max(5000) },
async ({ title, body }) => {
const key = `note:${Date.now()}`;
await this.env.NOTES.put(key, JSON.stringify({ title, body }));
return { content: [{ type: "text", text: `Đã lưu ${key}` }] };
}
);
this.server.tool(
"search_notes",
"Tìm ghi chú theo keyword (không phân biệt hoa thường)",
{ q: z.string().min(2) },
async ({ q }) => {
const list = await this.env.NOTES.list({ prefix: "note:", limit: 200 });
const hits: string[] = [];
for (const k of list.keys) {
const raw = await this.env.NOTES.get(k.name);
if (raw && raw.toLowerCase().includes(q.toLowerCase())) hits.push(raw);
if (hits.length >= 10) break;
}
return {
content: [{ type: "text", text: hits.length ? hits.join("\n") : "Không tìm thấy" }],
};
}
);
}
}
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext) {
const auth = req.headers.get("Authorization");
if (auth !== `Bearer ${env.MCP_TOKEN}`) {
return new Response("Unauthorized", { status: 401 });
}
const url = new URL(req.url);
if (url.pathname.startsWith("/mcp")) {
return NotesMCP.serve("/mcp").fetch(req, env, ctx);
}
return new Response("Not found", { status: 404 });
},
};
```
Một vài lưu ý rút ra sau khi chạy thật:
1. **Luôn đặt giới hạn bằng Zod** (`max(5000)`, `min(2)`). LLM có lúc gửi lên cả một đoạn text dài vài chục KB. Có schema thì request kiểu đó bị chặn ngay từ đầu, không lọt vào KV.
2. **Description của tool chính là prompt.** Model đọc description để quyết định có gọi tool hay không, nên hãy viết ngắn gọn và nói rõ tool làm gì.
3. **Giới hạn số kết quả trả về** (ở đây là 10). Trả về quá nhiều text sẽ tốn context window của model, và người trả tiền token là bạn.
4. Static bearer token đủ dùng cho tool nội bộ. Nếu public cho người ngoài thì nên chuyển sang OAuth bằng `@cloudflare/workers-oauth-provider`.
## Test, deploy và kết nối client
Chạy local trước, rồi dùng MCP Inspector để gọi thử từng tool:
```bash
npx wrangler dev # chạy ở http://localhost:8787
# Terminal khác
npx @modelcontextprotocol/inspector
# Chọn transport: Streamable HTTP
# URL: http://localhost:8787/mcp
# Thêm header Authorization: Bearer <token>
# Ổn rồi thì deploy
npx wrangler deploy
# Smoke test bằng curl
curl -s -X POST https://mcp-notes.<subdomain>.workers.dev/mcp \
-H "Authorization: Bearer $MCP_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
```
Header `Accept` phải có cả `text/event-stream`. Nếu thiếu, server sẽ trả lỗi 406 và bạn có thể mất cả buổi debug mà không hiểu vì sao.
Luồng xử lý của một lần gọi tool trông như sau:
```mermaid
sequenceDiagram
participant C as MCP Client
participant W as Worker
participant D as Durable Object
participant K as KV
C->>W: POST /mcp initialize (Bearer)
W->>D: route theo session
D-->>C: Mcp-Session-Id
C->>W: tools/call search_notes
W->>D: forward
D->>K: list + get
K-->>D: data
D-->>C: result (JSON / SSE)
```
Với client chỉ hỗ trợ stdio (ví dụ một số bản Claude Desktop cũ), bạn có thể dùng `mcp-remote` làm cầu nối: `npx mcp-remote https://.../mcp --header "Authorization: Bearer ${TOKEN}"`.
Về chi phí: team mình 6 người dùng khoảng vài nghìn request mỗi ngày, vẫn nằm trong free tier. Thứ cần theo dõi là số lần đọc KV (free tier cho 100.000 read/ngày). Vòng `get` trong `search_notes` ở trên khá tốn read. Khi số note lên đến vài nghìn, mình chuyển sang lưu vào SQLite ngay trong Durable Object (`this.sql`) và dùng `LIKE` để tìm.
## Kết luận
Remote MCP trên Cloudflare Workers là cách nhanh và rẻ để chia sẻ tool AI cho cả team. Tóm lại mấy việc nên làm:
- **Bắt đầu với 1-2 tool thật sự hữu ích** (tra cứu nội bộ, đọc log), đừng cố bọc toàn bộ API ngay từ đầu.
- **Dùng `new_sqlite_classes`** trong migrations để chạy được trên free plan.
- **Validate mọi input bằng Zod** và giới hạn kích thước output để tiết kiệm token.
- **Auth từ ngày đầu**: bearer token qua `wrangler secret`, khi public thì nâng lên OAuth.
- **Test bằng MCP Inspector** trước khi nối vào client thật, và nhớ header `Accept: application/json, text/event-stream`.
- **Theo dõi quota KV/DO** trong dashboard. Nếu read tăng nhanh thì chuyển phần tìm kiếm sang SQLite.
Bạn có thể làm xong bản đầu tiên chỉ trong một buổi chiều.
All rights reserved