examples/MCP-tools Public
id: http-transport
type: concept
owner: justin
component: mcp-server
describes:
- src/index.ts
- src/worker.ts
reviewed: 2026-10-15
The HTTP transport
src/index.ts is the Worker entry: it points fetch at handle in src/worker.ts and exports the RateLimiter Durable Object class. Keeping the handler out of the entry file means the tests import it under plain Node, without cloudflare:workers.
Routes
| Route | What it does |
|---|---|
POST /mcp |
One JSON-RPC message in, one JSON response out. |
OPTIONS /mcp |
CORS preflight, 204. |
GET, DELETE /mcp |
405: there are no sessions and no standalone SSE stream. |
GET /stats |
Usage counts; see [[usage-counts]]. |
GET / |
A one-line note saying where the endpoint is. |
A request
For each POST /mcp, serveMcp builds a new McpServer with createServer, connects it to a new WebStandardStreamableHTTPServerTransport with sessionIdGenerator: undefined and enableJsonResponse: true, and returns whatever handleRequest produces. The transport checks the Accept header (clients must accept both application/json and text/event-stream), parses the body, and the response is plain JSON rather than an SSE stream. Nothing survives the request, so any isolate in any location can answer the next one. [[0001-stateless-streamable-http]] has the reasoning.
A stateless transport cannot be reused: the SDK throws if handleRequest is called twice on one. Building a server per request is cheap (three tool registrations), so we do not try to pool them.
CORS
Every /mcp and /stats response carries access-control-allow-origin: * and allows the MCP headers (mcp-protocol-version, mcp-session-id, last-event-id). The server has no cookies or credentials, so an open origin costs nothing and lets the MCP Inspector and web agents connect directly.
Rate limit
createServer takes an admit hook, asked once per tool call after the arguments pass the input schema. The Worker answers it from the caller's RateLimiter object, named by the cf-connecting-ip header. A refusal comes back to the model as an isError result saying how many seconds to wait; initialize and tools/list are never charged. If the Durable Object call throws, the call is admitted and the error logged. [[rate-limits]] covers the bucket itself.
Usage counting
createServer also takes an onCall hook, called only for calls that were admitted. The Worker hands it a function that passes the KV write to ctx.waitUntil, so the count happens after the response has gone and a KV error is logged rather than returned.