0

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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí