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:
- Why stateless Streamable HTTP is the right design for Workers
- The server code (TypeScript, official MCP SDK + Hono)
- Run it locally and on the Workers runtime
- Deploy with Wrangler
- Connect Claude Code, Cursor and the MCP Inspector
- 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:
- No session id. Each
POSTcreates a fresh MCP server, handles the request and is discarded. - JSON responses (
enableJsonResponse: true). No long-lived streams that a Worker would have to hold open. GET /mcpandDELETE /mcpanswer405. That is how a client learns there is no server-initiated stream. If they fall through to an HTML page instead, many clients reconnect in a tight loop.
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:
- Authentication. Use API keys for Claude Code and Cursor (they send an
Authorizationheader) and an OAuth 2.1 resource server for connector-style clients like ChatGPT and Claude.ai. That means serving protected-resource metadata, validating JWTs against the issuer's JWKS and checking the audience. - Per-caller isolation. Since you already create one server per request, bind it to the authenticated caller so customer A's tools can never see customer B's data.
- Rate limits and timeouts. Agents in a loop will call your tool thousands of times. On Workers, use the Rate Limiting binding or Durable Objects.
- Metering if you plan to charge per tool call.
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.