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.
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:
{
"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.
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:
-
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. - 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ì.
- 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.
- 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:
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:
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_classestrong 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.
Top comments (1)
One edge case in save_note: note:${Date.now()} is not a unique key. Two calls in the same millisecond can overwrite each other in the shared KV namespace, even from different MCP sessions. A deterministic regression test can freeze the clock, save two different notes, and assert that both remain. crypto.randomUUID() avoids the timestamp collision; for a write retried after a timeout, use a separate stable request ID so the retry doesn't create another note. I haven't run this server; this is from the code shown.