Nino Ventures · 2026-10-31

How to deploy a remote MCP server to Cloudflare Workers (stateless Streamable HTTP)

Local stdio MCP servers are great until you want a teammate, a customer or ChatGPT to use your tools. Then you need a remote MCP server: an HTTPS endpoint that speaks the Model Context Protocol over Streamable HTTP.

Cloudflare Workers is one of the nicest places to host one: global, cheap (the free plan covers 100k requests/day), no servers to patch, and deploys take seconds. The catch is that Workers are isolates: there is no long-lived process and no shared memory between requests. So the server has to be stateless.

This guide shows the whole path in about 15 minutes:

  1. Why stateless Streamable HTTP is the right design for Workers
  2. The server code (TypeScript, official MCP SDK + Hono)
  3. Run it locally and on the Workers runtime
  4. Deploy with Wrangler
  5. Connect Claude Code, Cursor and the MCP Inspector
  6. Verify the deployment with a preflight check, and the mistakes it catches

All the code is in a free, MIT-licensed template: ninonorden/mcp-server-starter. You can clone it and skip to step 4.

1. Stateless Streamable HTTP in one paragraph

Streamable HTTP replaced the old HTTP+SSE transport. The client sends every JSON-RPC message as a POST to one endpoint (usually /mcp). The server answers with plain JSON or with an SSE stream. A server may hand out an Mcp-Session-Id and keep state per session, but it doesn't have to.

On Workers you want the stateless mode:

Any isolate in any data center can now answer any request. Deploys never drop sessions, because there are none.

2. The code

Install the dependencies:

npm init -y
npm install @modelcontextprotocol/sdk hono zod
npm install -D typescript wrangler @types/node

src/server.ts: your tools

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export function createMcpServer(): McpServer {
  const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" });

  server.registerTool(
    "time_in_zone",
    {
      title: "Current time in a time zone",
      description:
        "Returns the current date and time in an IANA time zone (e.g. Europe/Amsterdam). Use it whenever the user asks what time it is somewhere.",
      inputSchema: { timeZone: z.string().min(1).max(64) },
      annotations: { readOnlyHint: true },
    },
    async ({ timeZone }) => {
      try {
        const local = new Intl.DateTimeFormat("en-GB", { timeZone, dateStyle: "full", timeStyle: "long" }).format(new Date());
        return { content: [{ type: "text", text: `${local} (${timeZone})` }] };
      } catch {
        return { isError: true, content: [{ type: "text", text: `Unknown time zone "${timeZone}".` }] };
      }
    },
  );

  return server;
}

Two details matter more than they look. First, write the description for the model: say what the tool does and when to use it, because that is how the model picks tools. Second, return isError: true with a helpful message instead of throwing, so the model can recover.

src/app.ts: the HTTP layer

The SDK ships a transport built on web-standard Request/Response, which is exactly what Workers use:

import { Hono } from "hono";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { createMcpServer } from "./server.js";

const rpcError = (status: number, message: string) =>
  new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }), {
    status,
    headers: { "content-type": "application/json" },
  });

export function createApp() {
  const app = new Hono();

  app.post("/mcp", async (c) => {
    const server = createMcpServer();
    const transport = new WebStandardStreamableHTTPServerTransport({
      sessionIdGenerator: undefined, // stateless
      enableJsonResponse: true,      // no long-lived SSE streams
    });
    await server.connect(transport);
    try {
      return await transport.handleRequest(c.req.raw);
    } finally {
      void server.close();
    }
  });

  // Stateless server: no GET stream, no session to delete.
  app.get("/mcp", () => rpcError(405, "Method not allowed"));
  app.delete("/mcp", () => rpcError(405, "Method not allowed"));

  // Never answer unknown paths (including /.well-known/*) with HTML.
  app.notFound(() => new Response(JSON.stringify({ error: "not_found" }), {
    status: 404,
    headers: { "content-type": "application/json" },
  }));

  return app;
}

The template also checks the Origin header (to block DNS-rebinding style attacks from browsers) and caps the request body size. Both take a few lines each.

src/worker.ts and wrangler.toml

import { createApp } from "./app.js";

const app = createApp();
export default { fetch: (request: Request) => app.fetch(request) };
name = "my-mcp-server"
main = "src/worker.ts"
compatibility_date = "2026-05-01"
compatibility_flags = ["nodejs_compat"]

nodejs_compat lets the MCP SDK use the Node built-ins it expects. Pick a recent compatibility_date, but not a future one. Wrangler refuses dates newer than its bundled runtime.

3. Run it locally

npx wrangler dev        # http://127.0.0.1:8787/mcp on the real Workers runtime (workerd)

Quick smoke test with curl:

curl -s http://127.0.0.1:8787/mcp \
  -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"}}}'

You should get a JSON result with serverInfo. Note the accept header: Streamable HTTP clients must accept both JSON and SSE, and the SDK rejects requests that don't.

4. Deploy

npx wrangler login
npx wrangler deploy
# → https://my-mcp-server.<your-subdomain>.workers.dev

Your MCP endpoint is https://my-mcp-server.<your-subdomain>.workers.dev/mcp. For a custom domain, add a route in the Cloudflare dashboard (Workers → your Worker → Settings → Domains & Routes).

5. Connect clients

Claude Code

claude mcp add --transport http my-server https://my-mcp-server.<you>.workers.dev/mcp

Cursor (.cursor/mcp.json or global settings)

{ "mcpServers": { "my-server": { "url": "https://my-mcp-server.<you>.workers.dev/mcp" } } }

MCP Inspector

npx @modelcontextprotocol/inspector

Pick transport "Streamable HTTP", paste the URL, then List Tools → time_in_zone → Run.

6. Verify before you share the URL

Most broken remote MCP servers aren't broken in the tool logic. They're broken at the HTTP edges, and the failure only shows up in some clients. The template includes a preflight script that tests a deployed URL like a picky client would:

npm run preflight -- https://my-mcp-server.<you>.workers.dev/mcp
HTTP methods
  ✓ GET -> 405
  ✓ DELETE -> 405
Discovery paths
  ✓ /.well-known/oauth-protected-resource/mcp -> 404
  ✓ /.well-known/oauth-protected-resource -> 404
  ✓ /.well-known/oauth-authorization-server -> 404
Auth challenge
  ! unauthenticated tools/call -> 200 (server is public; make sure that is intended)
MCP session
  ✓ initialize -> mcp-server-starter@1.0.0 in 27 ms
  ✓ tools/list -> 1 tools in 15 ms

PASSED: 0 failure(s), 1 warning(s)

What it catches, and why each one matters:

Symptom What clients do
GET /mcp returns your HTML page or SPA fallback The client thinks the SSE stream dropped and reconnects, sometimes hundreds of times a minute
/.well-known/oauth-protected-resource returns 200 HTML OAuth-capable clients try to parse it as JSON metadata and fail the connection
401 without a WWW-Authenticate header The client can't discover how to authenticate, so the user sees a generic error
Tools without descriptions or annotations Models pick the wrong tool, and clients can't ask for confirmation before destructive calls
Tool names with dots or spaces Several clients reject the whole tool list

Run it in CI after every deploy and you won't hear about these from users.

Going to production: auth, limits, tenants

The warning in the output above is the important one: this server is public. Before real users connect, you'll want:

If you'd rather not build that layer yourself, the MCP Server Starter Kit Pro ($39, 14-day money-back guarantee) adds API keys, OAuth 2.1, tenant isolation, rate limiting, metering, a Python version, deploy configs for Workers, Vercel, Fly, Docker and Apify, and 48 tests on top of the free template.

Either way, start with the free template: github.com/ninonorden/mcp-server-starter. Clone it, npm run cf:deploy, run preflight, and you have a remote MCP server in production today.