How to Deploy a Remote MCP Server on Cloudflare Workers (2026)

Oct 31, 2026 · 13 min · Ajay Kumar

This guide is for engineers who want a remote MCP server on the internet without running a fleet of containers for it. At the end you will have a stateless Streamable HTTP MCP server on Cloudflare Workers with two tools backed by KV, a per-user Durable Object variant, an OAuth layer that issues and checks tokens bound to your server's URL, and Claude Code and Cursor connected to it. I ran every local step on 31 October 2026 with agents 0.32.0 (published 29 October), @modelcontextprotocol/server 2.4.0, @cloudflare/workers-oauth-provider 1.2.4, Wrangler 4.156.0 and Node 24.20; the outputs below are from those runs against wrangler dev. I did not deploy to a real account for this post, so the deploy output in Step 5 is shape only. You need Node 22 or later (Wrangler 4.156.0 declares node >=22) and, for the deploy, a Cloudflare account. If you want the background on why remote servers look like this now, read the companion piece on remote vs local MCP servers and their transports first.

createMcpHandler or McpAgent: which one in 2026?

Use createMcpHandler. Cloudflare's MCP Server API page describes it as a stateless handler that "builds a fresh server for every request", so there is no Durable Object, protocol session or WebSocket involved. The older McpAgent class, which put every MCP session in its own Durable Object, is now marked deprecated and feature-frozen, kept only for servers that are still migrating.

The reason is the protocol, not Cloudflare. The 2026-07-28 MCP revision removed the initialize handshake and the Mcp-Session-Id header. Every request now carries its protocol version and client capabilities in _meta, so any isolate can answer any request. A Durable Object per session was a clever answer to a problem the spec no longer has. Most tutorials you will find still show McpAgent and MyMCP.serve("/sse"); those are the ones to stop copying.

Step 1: Create the project and pin versions

Cloudflare has a template (npm create cloudflare@latest -- my-server --template=cloudflare/ai/demos/remote-mcp-authless, per the remote MCP guide), but I prefer to start empty so every dependency is one I chose.

mkdir notes-mcp && cd notes-mcp
npm init -y

# agents pins the MCP SDK it supports as an exact peer: 0.32.0 wants server 2.4.0.
npm i agents@0.32.0 @modelcontextprotocol/server@2.4.0 zod@4
npm i -D wrangler@4.156.0 typescript@5

The exact pin matters. @modelcontextprotocol/server is an optional peer of agents, so npm will not install it for you, and the docs currently show 2.3.1 in their install line. With agents 0.32.0 that fails:

npm error ERESOLVE could not resolve
npm error While resolving: agents@0.32.0
npm error Found: @modelcontextprotocol/server@2.3.1
npm error peerOptional @modelcontextprotocol/server@"2.4.0" from agents@0.32.0

Check the peer before upgrading either package: npm view agents@0.32.0 peerDependencies.

Step 2: Configure wrangler.jsonc and a KV namespace

// wrangler.jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "notes-mcp",
  "main": "src/index.ts",
  // Must not be newer than the workerd bundled with your Wrangler (see Troubleshooting).
  "compatibility_date": "2026-10-30",
  "compatibility_flags": ["nodejs_compat"],
  "kv_namespaces": [{ "binding": "NOTES", "id": "<id from wrangler kv namespace create>" }],
  "observability": { "enabled": true }
}

Create the namespace, paste the ID, and generate the Env type from the config:

npx wrangler kv namespace create NOTES   # prints the id to paste into wrangler.jsonc
npx wrangler types                       # writes worker-configuration.d.ts with Env.NOTES

wrangler dev uses a local KV simulation, so you can leave a placeholder ID until you deploy.

Step 3: Write the tools with createMcpHandler

// src/index.ts
import { McpServer } from "@modelcontextprotocol/server";
import { createMcpHandler } from "agents/mcp/server";
import { env } from "cloudflare:workers";
import { z } from "zod";

// Called once per request. Never build one global McpServer.
function createServer() {
  const server = new McpServer({ name: "notes-mcp", version: "1.0.0" });

  server.registerTool(
    "add_note",
    {
      description: "Save a short note under a key. Overwrites an existing note with the same key.",
      inputSchema: z.object({
        key: z.string().min(1).max(64).describe("Note name, e.g. 'deploy-checklist'"),
        text: z.string().max(4000),
      }),
      annotations: { destructiveHint: false, idempotentHint: true },
    },
    async ({ key, text }) => {
      await env.NOTES.put(`note:${key}`, text);
      return { content: [{ type: "text", text: `Saved note '${key}' (${text.length} chars).` }] };
    },
  );

  server.registerTool(
    "get_note",
    {
      description: "Read a note previously saved with add_note.",
      inputSchema: z.object({ key: z.string().min(1).max(64) }),
      outputSchema: z.object({ key: z.string(), text: z.string().nullable() }),
      annotations: { readOnlyHint: true },
    },
    async ({ key }) => {
      const text = await env.NOTES.get(`note:${key}`);
      return {
        content: [{ type: "text", text: text ?? `No note named '${key}'.` }],
        structuredContent: { key, text },
      };
    },
  );

  return server;
}

// Default route is /mcp. Create the handler once so notify.* reaches open listen streams.
const handler = createMcpHandler(createServer);

export default {
  fetch(request, env, ctx) {
    return handler(request, env, ctx); // do not export the handler itself as default
  },
} satisfies ExportedHandler<Env>;

Three details from the handler docs are worth knowing before you add more tools. Pass the factory, not a constructed server. Do not make the callable your default export, because Wrangler treats a function default export as a WorkerEntrypoint class. And the SDK v1 options you may remember (sessionIdGenerator, enableJsonResponse, eventStore) are rejected; responseMode: "json" replaces the last one. Tool descriptions are what the model reads, so write them for a model, and see MCP tool design best practices if your list is growing.

Step 4: Run locally and test with curl and MCP Inspector

npx tsc --noEmit -p .        # typecheck against the generated Env
npx wrangler dev --port 8788
# [wrangler:info] Ready on http://localhost:8788

A 2026-07-28 request needs no handshake. It is a single POST with the method mirrored into headers:

META='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}'

curl -s -X POST http://localhost:8788/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/call' -H 'Mcp-Name: get_note' \
  -d "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",
       \"params\":{\"name\":\"get_note\",\"arguments\":{\"key\":\"deploy\"},$META}}"
{"result":{"content":[{"type":"text","text":"run wrangler deploy"}],
 "structuredContent":{"key":"deploy","text":"run wrangler deploy"},
 "resultType":"complete",
 "_meta":{"io.modelcontextprotocol/serverInfo":{"name":"notes-mcp","version":"1.0.0"}}},
 "jsonrpc":"2.0","id":3}

server/discover returned "supportedVersions":["2026-07-28"] with "cacheScope":"private", and an old-style initialize from a 2025-11-25 client still got a valid reply, because the default legacy: "stateless" lane answers ordinary tool calls from older clients. The negative cases are just as useful to see once, because you will meet them in client logs:

GET /mcp                               -> 405 {"code":-32000,"message":"Method not allowed."}
POST without _meta                     -> 400 -32602 "missing the required per-request envelope key(s): _meta"
Mcp-Name: get_note, body add_note      -> 400 -32020 "the request headers and body disagree"
protocolVersion 2025-01-01             -> 400 -32022 "Unsupported protocol version" (supported: 2026-07-28)
Origin: https://evil.example           -> 403 "Invalid Origin: evil.example"

For a UI, run npx @modelcontextprotocol/inspector@2.13.0 and connect to http://localhost:8788/mcp, or use its CLI mode, which printed the same structuredContent for me:

npx @modelcontextprotocol/inspector@2.13.0 --cli http://localhost:8788/mcp \
  --transport http --method tools/call --tool-name get_note --tool-arg key=deploy

The Inspector debugging guide covers the rest of that tool.

Step 5: Deploy with wrangler deploy

npx wrangler login
npx wrangler kv namespace create NOTES     # if you have not yet: paste the real id
npx wrangler deploy
# Uploads notes-mcp and prints https://notes-mcp.<your-subdomain>.workers.dev

The endpoint is https://notes-mcp.<your-subdomain>.workers.dev/mcp. Run the same curl against it. On a custom domain, set allowedHostnames: ["mcp.example.com"] in the handler options; the default Host and Origin allowlist only covers localhost and your workers.dev name. Right now this server accepts anyone, which is fine for a weather toy and wrong for notes. Fix that before you share the URL.

Cost is rarely the issue. Workers pricing bills CPU time, not wall time: the free plan gives 100,000 requests a day, and the $5 paid plan includes 10 million requests and 30 million CPU-milliseconds a month. The limits page puts the paid-plan CPU cap at 30 seconds per request by default (raisable to 5 minutes; the free plan gets 10 ms) and memory at 128 MB per isolate, so heavy work belongs in a Container, Workflow or another service the tool calls.

Step 6: Add OAuth with Workers OAuth Provider

The authorization docs now split OAuth into two Workers: an authorization server (OAuthAuthorizationServer) that signs users in and issues tokens, and your MCP server wrapped in OAuthResourceServer, which checks each token over a Service Binding. I put each in its own folder with its own wrangler.jsonc and ran npx wrangler types in each.

2 sign in at /authorize, token from /oauth/token MCP client Claude Code, Cursor, Inspector notes-mcp Worker OAuthResourceServer createMcpHandler on /mcp fresh McpServer per request auth Worker OAuthAuthorizationServer /authorize, /oauth/token CIMD and DCR 1 POST /mcp 401 + metadata 3 Bearer token 4 validateToken props, scopes App state KV, D1 or a Durable Object keyed by signed-in user OAUTH_KV grants, tokens, clients Local run: no token 401, good token 200, missing scope 403, other resource invalid_target
The two-Worker OAuth layout from Cloudflare's authorization docs as I ran it with wrangler dev on 31 October 2026; the footer lists the responses from the curl tests in this step.

The authorization server. The /authorize handler below auto-approves a fixed user so you can test the plumbing; replace it with a real sign-in and the library's consent helpers before anyone else touches it.

// auth/src/index.ts  (auth/wrangler.jsonc: OAUTH_KV binding, vars ISSUER and MCP_SERVER_URL,
// compatibility_flags ["nodejs_compat", "global_fetch_strictly_public"])
import { AuthorizationError, OAuthAuthorizationServer, type AuthRequest } from "@cloudflare/workers-oauth-provider";
import { env, WorkerEntrypoint } from "cloudflare:workers";

const SCOPES = ["notes:read", "notes:write", "offline_access"];

const authorizationServer = new OAuthAuthorizationServer<Env>({
  issuer: env.ISSUER,                     // this Worker's URL
  resources: [env.MCP_SERVER_URL],        // tokens are bound to this exact URL
  scopesSupported: SCOPES,
  clientIdMetadataDocumentEnabled: true,  // CIMD, preferred by the 2026-07-28 spec
  clientRegistrationEndpoint: "/oauth/register", // DCR, for older clients
});

async function authorize(request: Request, env: Env): Promise<Response> {
  const oauth = authorizationServer.getOAuthApi(env);
  let authRequest: AuthRequest;
  try {
    authRequest = await oauth.parseAuthRequest(request); // checks client, redirect URI, resource, PKCE
  } catch (error) {
    if (!(error instanceof AuthorizationError)) throw error;
    if (error.redirectTo) return Response.redirect(error.redirectTo, 302);
    return new Response(error.description, { status: 400 });
  }
  const user = { id: "user-123", name: "Ada" }; // DEMO ONLY: replace with real sign-in + consent
  const { redirectTo } = await oauth.completeAuthorization({
    request: authRequest,
    userId: user.id,
    metadata: {},
    scope: authRequest.scope.filter((s) => SCOPES.includes(s)),
    props: { userId: user.id, name: user.name }, // encrypted, handed to the MCP server per request
  });
  return Response.redirect(redirectTo, 302);
}

export default class AuthServer extends WorkerEntrypoint<Env> {
  fetch(request: Request) {
    if (new URL(request.url).pathname === "/authorize") return authorize(request, this.env);
    return authorizationServer.fetch(request, this.env, this.ctx);
  }
  validateToken(resource: string, token: string) { // called over the Service Binding
    return authorizationServer.validateToken(resource, token, this.env);
  }
}

The resource server wraps the same createMcpHandler:

// mcp/src/index.ts  (mcp/wrangler.jsonc: "services": [{ "binding": "AUTH_SERVER", "service": "notes-auth" }],
// vars AUTH_SERVER_URL and MCP_SERVER_URL)
import { OAuthResourceServer, insufficientScope } from "@cloudflare/workers-oauth-provider";
import { McpServer } from "@modelcontextprotocol/server";
import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server";
import { env } from "cloudflare:workers";

type AuthProps = { userId: string; name: string };

function createServer() {
  const server = new McpServer({ name: "notes-mcp", version: "1.1.0" });
  const props = getMcpAuthContext()?.props as AuthProps;
  server.registerTool("whoami", { description: "Return the signed-in user" }, async () => ({
    content: [{ type: "text", text: `Signed in as ${props.name} (${props.userId})` }],
  }));
  return server;
}

const mcpHandler = createMcpHandler(createServer);

export default new OAuthResourceServer<Env, AuthProps>({
  resourceMetadata: { resource: env.MCP_SERVER_URL, authorization_servers: [env.AUTH_SERVER_URL] },
  requiredScopes: ["notes:read"], // advertised only: enforce it below
  validateToken: (env) => env.AUTH_SERVER.validateToken,
  handler: {
    fetch(request, env, ctx) {
      if (!ctx.auth.scope.includes("notes:read")) return insufficientScope(ctx.auth, ["notes:read"]);
      return mcpHandler(request, env, ctx);
    },
  },
});

Run both locally on different ports (auth first, because the MCP Worker binds to it; Wrangler shows the binding as [connected]), with MCP_SERVER_URL set to http://localhost:8788/mcp. I walked the flow by hand with curl: dynamic registration, an S256 PKCE authorize request with resource=http://localhost:8788/mcp, then the token exchange. The results:

POST /mcp, no token        -> 401 WWW-Authenticate: Bearer realm="OAuth",
                              resource_metadata="http://localhost:8788/.well-known/oauth-protected-resource/mcp",
                              scope="notes:read"
GET  /.well-known/oauth-protected-resource/mcp
                           -> {"resource":"http://localhost:8788/mcp","authorization_servers":["http://localhost:8789"],...}
POST /oauth/token          -> {"token_type":"bearer","expires_in":3600,"scope":"notes:read",
                              "resource":"http://localhost:8788/mcp", ...}
tools/call whoami + token  -> 200 "Signed in as Ada (user-123)"
token with offline_access only -> 403 error="insufficient_scope", scope="notes:read"
authorize with resource=/other -> 302 ...error=invalid_target

Two things to note. The metadata lives at the path-suffixed URL; the bare /.well-known/oauth-protected-resource returned 404, which compliant clients handle because the 401 tells them where to look. And MCP_SERVER_URL must be byte-for-byte the URL clients use, because every token is bound to it. The concepts behind this are in MCP authorization explained, and the same pattern on Cloud Run with Keycloak is in the secure remote MCP server guide.

Step 7: Connect Claude Code, Cursor and other clients

# Claude Code: Streamable HTTP is --transport http. It opens a browser for OAuth.
claude mcp add --transport http notes https://notes-mcp.<your-subdomain>.workers.dev/mcp
claude mcp list
// .cursor/mcp.json (project) or ~/.cursor/mcp.json (global)
{
  "mcpServers": {
    "notes": { "url": "https://notes-mcp.<your-subdomain>.workers.dev/mcp" }
  }
}

For a client that only speaks stdio, bridge with npx mcp-remote@0.15.2 https://.../mcp, and keep that package current: versions before 0.1.16 had CVE-2025-6514, a 9.6 command injection triggered by a malicious authorization endpoint. Per-client details are in connecting MCP servers to Claude Code and Cursor.

Step 8: Keep state in KV, D1 or a Durable Object

A stateless handler does not mean a stateless product. The transport docs say to key durable data "by the authenticated user rather than an MCP session ID". KV is fine for notes. When you need strong consistency per user (counters, a game board, an ordered log), give each user a SQLite-backed Durable Object:

// src/do.ts  (wrangler.jsonc: durable_objects.bindings [{ name: "NOTEBOOK", class_name: "Notebook" }],
// migrations [{ tag: "v1", new_sqlite_classes: ["Notebook"] }])
import { DurableObject, env } from "cloudflare:workers";
import { McpServer } from "@modelcontextprotocol/server";
import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server";
import { z } from "zod";

export class Notebook extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS notes (k TEXT PRIMARY KEY, v TEXT NOT NULL)");
  }
  add(k: string, v: string): number {
    this.ctx.storage.sql.exec("INSERT OR REPLACE INTO notes (k, v) VALUES (?, ?)", k, v);
    return this.ctx.storage.sql.exec("SELECT COUNT(*) AS n FROM notes").one().n as number;
  }
}

function createServer() {
  const server = new McpServer({ name: "notes-mcp", version: "1.2.0" });
  const userId = (getMcpAuthContext()?.props?.userId as string | undefined) ?? "anonymous";
  server.registerTool(
    "add_note",
    { description: "Save a note in your private notebook.",
      inputSchema: z.object({ key: z.string().max(64), text: z.string().max(4000) }) },
    async ({ key, text }) => {
      const count = await env.NOTEBOOK.getByName(userId).add(key, text); // one DO per user, RPC call
      return { content: [{ type: "text", text: `Saved '${key}'. Notebook now has ${count} note(s).` }] };
    },
  );
  return server;
}

const handler = createMcpHandler(createServer);
export default { fetch: (req, env, ctx) => handler(req, env, ctx) } satisfies ExportedHandler<Env>;

Two calls returned "Notebook now has 1 note(s)" and then "2 note(s)", across separate stateless requests. If you are migrating an McpAgent server, the SDK v2 migration guide recommends serving both lanes until existing sessions drain, because McpAgent cannot host an SDK v2 server.

Troubleshooting

Symptom Likely cause Fix
npm error ERESOLVE ... peerOptional @modelcontextprotocol/server@"2.4.0" Server SDK version does not match the exact peer of your agents release npm view agents@<ver> peerDependencies and install that exact version
wrangler dev refuses the compatibility_date as newer than the runtime supports Compatibility date newer than the bundled workerd Set compatibility_date to the newest date the error names, or upgrade Wrangler
400 -32602 ... missing the required per-request envelope key(s): _meta Hand-written or old client sends 2026-07-28 header without _meta Send protocolVersion and clientCapabilities in params._meta, or use an SDK client
400 -32020 the request headers and body disagree Mcp-Name or Mcp-Method header differs from the body (often a proxy rewriting one) Make headers mirror the body exactly; check gateways that inject headers
400 -32022 Unsupported protocol version Client asked for a revision the server does not speak Upgrade the client, or keep legacy: "stateless" for 2025-era clients
405 Method not allowed on GET /mcp or 404 on /sse Client or config still uses the old HTTP+SSE transport Point the client at /mcp with Streamable HTTP (--transport http)
403 Invalid Origin from a browser client or custom domain Origin or Host not in the default allowlist Set allowedHostnames and corsOptions.origin or allowedOriginHostnames
401 with error="invalid_token" after sign-in Token bound to a different resource URL, or expired Make MCP_SERVER_URL equal the exact client URL, scheme and path included
403 error="insufficient_scope" Token lacks the scope your handler checks Add the scope to scopesSupported and let the client step up, or adjust the check
Error 1102 Worker exceeded resource limits CPU time or 128 MB memory limit hit inside a tool Raise limits.cpu_ms on paid plans, or move heavy work out of the Worker

FAQ

Is McpAgent deprecated?

Yes. Cloudflare's reference page marks McpAgent deprecated and feature-frozen, and the current guides build everything with createMcpHandler. Existing servers keep working, but new servers should not start on it, and an SDK v2 server cannot run inside it.

Do I still need Durable Objects for an MCP server on Workers?

Not for the protocol. Since the 2026-07-28 revision removed sessions, the handler needs no Durable Object. Use one only for application state that needs strong per-user consistency, keyed by the authenticated user ID.

Can I use Cloudflare Access instead of building an authorization server?

Yes. The authorization docs describe signing users in through Cloudflare Access with your identity provider and Access policies, and Stytch, Auth0 and WorkOS integrations are documented too. The two-Worker setup above is the self-contained option when you want to own the consent page.

Does a remote MCP server on Workers support older clients?

Mostly. The default legacy: "stateless" mode answered a 2025-11-25 initialize and plain tool calls in my test, but pushed sampling, roots and elicitation requests, GET streams and resumability are not available on that lane.

What I check first

When a Workers MCP server misbehaves I check three things before reading any code: that the agents and @modelcontextprotocol/server versions match the exact peer, that MCP_SERVER_URL is exactly the URL the client typed, and that a request with a token for some other resource gets rejected. That last test catches the mistakes that matter. Then I look at Workers Logs for CPU time per tool, because a tool that parses a large API response is usually the first thing to hit a limit. The references I keep open are Cloudflare's MCP server API, authorization and remote server guide, the Workers OAuth Provider repo, and the 2026-07-28 Streamable HTTP spec. If you have not built a server before, building an MCP server in Python and TypeScript starts one step earlier.

Related: MCP Security in 2026: The Protocol Got Hardened. The Ecosystem Didn't., Serverless in 2026: Lambda vs Cloud Run vs Workers, and When It Costs More and How to Deploy a Secure Remote MCP Server with OAuth (2026).

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related