Skip to content

tutorials

How to build an MCP server on Cloudflare Workers

June 15, 2026

#mcp#cloudflare-workers#json-rpc#ai#tutorial
How to build an MCP server on Cloudflare Workers
Visual: Nyx Datacenter on okslop

Most MCP tutorials hand you the official Node SDK and a long-running process. Cloudflare Workers have neither, and the SDK fights the runtime. Here is the small, honest version that actually fits the edge.

An MCP server on Cloudflare Workers is a stateless JSON-RPC 2.0 HTTP endpoint that answers three methods: initialize, tools/list, and tools/call. You do not need the official SDK or a persistent session. You parse each POST, switch on the method, and return a JSON-RPC envelope. That is the whole shape, and it maps cleanly onto the Fetch Request/Response a Worker already speaks.

This walkthrough builds that server by hand. We will hit the real reason the SDK is awkward on the edge, write the JSON-RPC handler, wire a typed tool, add bearer auth, and test it with curl. At the end, I will show how the Tinbox /mcp route is the same pattern, already shipped, exposing a real inbox to Claude.

Why doesn't the official MCP SDK work well on Cloudflare Workers?

The official @modelcontextprotocol/sdk is built around a long-lived server with an in-memory session and a transport (stdio or SSE) that stays open. Cloudflare Workers invert that model: a Worker spins up per request, may not keep state between calls, and has no Node process to host a persistent Server instance. So the SDK's session lifecycle (open transport, handshake, hold state, stream notifications) does not have a place to live.

You can force the SSE transport to work, but you are now babysitting connection state on a runtime designed to be stateless. For a tools-only server (the common case: you expose typed functions, the model calls them) you do not need any of that. MCP's wire format is JSON-RPC 2.0, and a tools server is request/response. So the pragmatic path on Workers is to skip the SDK's Server class and write the JSON-RPC handler directly. It is about 100 lines, has zero Node shims, and you understand every byte that crosses the wire.

To be fair to the SDK: if you are on Node with long-lived connections and you want resources, prompts, sampling, and server-initiated notifications, it does real work for you. On the edge, for a tools server, it is the wrong shape.

What does an MCP server actually have to implement?

At minimum, three methods plus one notification. MCP is JSON-RPC 2.0, so every request is { jsonrpc: "2.0", id, method, params } and every response carries the same id back.

MethodDirectionWhat you return
initializerequestprotocolVersion, capabilities, serverInfo
notifications/initializednotificationnothing (HTTP 202, no body)
tools/listrequest{ tools: [...] } array of descriptors
tools/callrequest{ content, structuredContent, isError }

That is enough for Claude, Cursor, and any other tools-capable MCP client to discover your tools and call them. Resources and prompts are separate capabilities you can add later; you do not need them to be useful. If you are still deciding whether MCP is even the right surface for your agent versus a plain REST endpoint, the reasons MCP beats a REST API for email agents makes the case before you write any of this.

Step 1: Scaffold a Worker with Hono

Start with a normal Worker. Hono gives you clean routing and a Fetch-native Context, which is all we need.

npm create cloudflare@latest mcp-worker -- --type hello-world
cd mcp-worker
npm install hono

Replace src/index.ts with a single POST route. Everything MCP lives behind /mcp.

// src/index.ts
import { Hono } from "hono";
import { handleMcp } from "./mcp";

const app = new Hono();

app.post("/mcp", (c) => handleMcp(c));

export default app;

Step 2: Parse and validate the JSON-RPC envelope

Create src/mcp.ts. The first job of the handler is to reject anything that is not a well-formed JSON-RPC 2.0 request. Define small helpers for the result and error envelopes so the rest of the code stays readable.

// src/mcp.ts
import type { Context } from "hono";

const PROTOCOL_VERSION = "2025-06-18";

interface JsonRpcRequest {
  jsonrpc: "2.0";
  id?: string | number | null;
  method: string;
  params?: Record<string, unknown>;
}

function rpcResult(id: JsonRpcRequest["id"], result: unknown) {
  return { jsonrpc: "2.0" as const, id: id ?? null, result };
}

function rpcError(id: JsonRpcRequest["id"], code: number, message: string) {
  return { jsonrpc: "2.0" as const, id: id ?? null, error: { code, message } };
}

export async function handleMcp(c: Context): Promise<Response> {
  const body = (await c.req.json().catch(() => null)) as JsonRpcRequest | null;
  if (!body || body.jsonrpc !== "2.0" || typeof body.method !== "string") {
    return c.json(rpcError(body?.id ?? null, -32600, "Invalid JSON-RPC request."), 400);
  }

  switch (body.method) {
    // methods go here
    default:
      return c.json(rpcError(body.id, -32601, `Method not found: ${body.method}`));
  }
}

The -32600 (invalid request) and -32601 (method not found) codes are the standard JSON-RPC error numbers. Using the right ones matters: clients branch on them.

Step 3: Implement initialize

The handshake. The client sends its supported protocolVersion in params; you echo back a version you implement, declare your capabilities, and name yourself.

    case "initialize":
      return c.json(
        rpcResult(body.id, {
          protocolVersion: PROTOCOL_VERSION,
          capabilities: { tools: {} },
          serverInfo: { name: "my-mcp-worker", version: "1" },
        }),
      );

    case "notifications/initialized":
      // A notification has no id; ack with 202 and no body.
      return c.body(null, 202);

capabilities: { tools: {} } says "I serve tools." If you later add resources or prompts, you advertise them here. The notifications/initialized message is the client telling you the handshake is done; it is a notification (no id), so you do not send a JSON-RPC response, just a bare 202.

Step 4: Implement tools/list

Return the tools you offer, each as a descriptor with a name, a description, and a JSON Schema inputSchema. The schema is what the model reads to figure out how to call you, so it has to be real JSON Schema, not a hand-wave.

const TOOLS = [
  {
    name: "search_messages",
    title: "Search messages",
    description: "Full-text search the mailbox and return matching messages.",
    inputSchema: {
      type: "object",
      properties: {
        query: { type: "string", description: "Search terms." },
        limit: { type: "number", description: "Max results (default 20)." },
      },
      required: ["query"],
    },
    annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
  },
] as const;
    case "tools/list":
      return c.json(rpcResult(body.id, { tools: TOOLS }));

Writing JSON Schema by hand gets old fast. If your tools are defined with Zod, the Vercel AI SDK ships a zodSchema() helper that converts a Zod schema to JSON Schema with no extra dependency, so you define the schema once and reuse it for both runtime validation and the wire descriptor. That is exactly the trick Tinbox uses, more on that below.

Step 5: Implement tools/call

Look up the named tool, validate the arguments, run it, and return the result. MCP wants content (a model-facing text block) and optionally structuredContent (the full typed result for clients that consume it). Set isError when the tool fails so the client can react.

    case "tools/call": {
      const params = body.params ?? {};
      const name = String(params.name ?? "");
      const args = (params.arguments ?? {}) as Record<string, unknown>;

      if (name !== "search_messages") {
        return c.json(rpcError(body.id, -32601, `Unknown tool "${name}".`));
      }

      const query = typeof args.query === "string" ? args.query : "";
      if (!query.trim()) {
        return c.json(rpcError(body.id, -32602, "Invalid arguments: 'query' is required."));
      }

      // Replace this with a real D1 query against your inbox.
      const hits = [
        { id: "msg_1", subject: "Invoice dispute", from: "billing@acme.co" },
      ];

      return c.json(
        rpcResult(body.id, {
          content: [
            { type: "text", text: `${hits.length} result(s) for "${query}".` },
          ],
          structuredContent: { kind: "messages", query, hits },
          isError: false,
        }),
      );
    }

The -32602 (invalid params) code is the third JSON-RPC error you will use a lot: it is what you return when the arguments do not match the schema. In a real server you run schema.safeParse(args) here and turn a failure into -32602 with the validation message.

Step 6: Test it with curl before you touch a client

You do not need Claude to verify the server. Run it locally and drive the three methods by hand.

npx wrangler dev

Initialize:

curl -s http://localhost:8787/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"my-mcp-worker","version":"1"}}}

Call the tool:

curl -s http://localhost:8787/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_messages","arguments":{"query":"invoice"}}}'
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"1 result(s) for \"invoice\"."}],"structuredContent":{"kind":"messages","query":"invoice","hits":[{"id":"msg_1","subject":"Invoice dispute","from":"billing@acme.co"}]},"isError":false}}

Now the failure mode you will hit the moment you forget the schema check. Call a tool that does not exist:

curl -s http://localhost:8787/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"delete_everything","arguments":{}}}'
{"jsonrpc":"2.0","id":4,"error":{"code":-32601,"message":"Unknown tool \"delete_everything\"."}}

Good. An unknown tool is an error, not a silent no-op. If you skip the name check, a typo'd tool call returns a confusing success and the model gets nonsense back.

How do I add authentication to an MCP server on Workers?

Resolve an Authorization: Bearer <token> header to a caller at the top of the handler, before you dispatch any method. Anything unauthenticated gets a 401. The only nuance is supporting the Claude app's one-click OAuth, which expects a discovery pointer in the 401.

export async function handleMcp(c: Context): Promise<Response> {
  const auth = c.req.header("authorization") ?? "";
  const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
  const caller = token ? await resolveToken(token) : null; // your token lookup

  if (!caller) {
    // RFC 9728: point OAuth clients at the protected-resource metadata so an
    // unauthenticated call kicks off the OAuth connect flow. Static-bearer
    // clients (Claude Code, scripts) just read the message and send a token.
    const origin = new URL(c.req.url).origin;
    return c.json(rpcError(null, -32001, "Unauthorized: present a bearer token."), 401, {
      "WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
    });
  }

  // ...parse body, dispatch, but now scope every tool call to `caller`.
}

That WWW-Authenticate header is what makes the Claude desktop app offer to connect via OAuth instead of demanding a token you paste. Headless clients ignore it and send a static bearer token instead. You can support both with the same 401: the header is advisory.

Two things to keep honest here. First, full OAuth 2.1 with PKCE and dynamic client registration is a meaningful chunk of work beyond this 401 (authorization endpoint, token endpoint, consent, client registration). The 401 above only advertises where that machinery lives. Second, scope your tokens: a read-only token must not be able to call a tool that sends mail or deletes data. Enforce the scope in tools/list (do not advertise tools the token cannot use) and again in tools/call (do not run them). For the OAuth side, see connecting Claude to an inbox over MCP.

What the hard way taught you (and where it ends)

You now have a working MCP server on the edge: stateless JSON-RPC, three methods, typed tools, bearer auth, tested with curl. That is genuinely all an MCP tools server is, and building it once is the fastest way to stop treating MCP as magic.

The part the tutorial glosses is everything around the handler. The tools have to do something real, which means a D1 schema, queries, and result shaping. The schemas should be defined once and reused for validation, the wire descriptor, and any in-app chat, or they drift. And the OAuth flow that makes the Claude app one-click is a server in its own right.

Or skip all of this: the Tinbox /mcp route

If the goal is an MCP server over a real inbox rather than a teaching exercise, Tinbox ships exactly this handler, hardened. Its /mcp route is the same stateless JSON-RPC pattern you just built: initialize, notifications/initialized, tools/list, tools/call, advertising protocol version 2025-06-18, authed by a tbx_ bearer token, with the RFC 9728 WWW-Authenticate 401 wired for the Claude app's OAuth.

The differences are the parts that take real time:

  • The tools are a shared Zod manifest. The same definitions feed the in-app chat agent, the MCP surface, and the CLI, so there is one source of truth and zodSchema() converts each to JSON Schema for the wire. See one tool manifest across MCP, chat, and CLI.
  • The tools run against your own D1 inbox: search_messages over FTS5, read_thread, triage backed by email interpreters, send_email behind a scope grant.
  • A config ceiling narrows what any caller may use, so MCP can never widen the agent surface past what a deployment allows.
  • Full OAuth 2.1 with PKCE and dynamic client registration is implemented, so the Claude app connects in one click, while headless clients use static bearer tokens.

It runs on your own Cloudflare account: your Worker, your D1, your R2, no Tinbox-hosted hop in the middle, and no per-call pricing on the MCP endpoint. The whole stack ships from one command. For the agent and product side of this, see the Tinbox launch post. Before you put an inbox-backed MCP server in front of an assistant, the operational gotchas are worth skimming in things to know before you run an email MCP server.

Deploy this yourself: npx tinbox init yourdomain.com, then npx tinbox mcp status to confirm the endpoint. The full tool reference is in the docs.

Frequently asked questions

Can you run an MCP server on Cloudflare Workers?

Yes. MCP's transport is JSON-RPC 2.0 over HTTP, which is a plain request/response shape a Worker handles natively. The catch is that a Worker has no persistent process, so you write a stateless handler that treats each POST as a self-contained request instead of using the official Node SDK's session-oriented server.

Why not just use the official MCP SDK on Workers?

The @modelcontextprotocol/sdk Server class is built around a long-lived stdio or SSE transport with an in-memory session. Workers spin up per request and may not keep state between calls, so the SDK's session model fights the runtime. A direct JSON-RPC handler (about 100 lines) is smaller, has no Node shims, and maps cleanly onto the Fetch Request/Response a Worker already speaks.

What JSON-RPC methods does an MCP server need to implement?

At minimum: initialize (returns protocolVersion, capabilities, and serverInfo), tools/list (returns the array of tool descriptors with JSON Schema input), and tools/call (runs a named tool and returns content plus optional structuredContent). You also ack notifications/initialized with a 202 and no body, and return a -32601 error for unknown methods.

How does an MCP client authenticate to a Worker-hosted server?

Use an Authorization: Bearer token on each request. Resolve the token to a caller at the top of the handler before dispatching any method. To support the one-click OAuth flow in the Claude app, return a 401 with a WWW-Authenticate header pointing at /.well-known/oauth-protected-resource (RFC 9728); headless clients like Claude Code just send a static bearer token.

What MCP protocol version should I return from initialize?

Return the dated protocol version string your client expects, for example 2025-06-18. The client sends its supported version in the initialize params; echo back a version you implement. Tinbox's /mcp route currently advertises 2025-06-18.