Deploying Remote Servers
Stateless is the whole deployment story
Section titled “Stateless is the whole deployment story”Protocol version 2026-07-28 made MCP a stateless protocol: every request carries its version, capabilities, and identity in _meta, and a server MUST NOT rely on a prior request over the same connection. There is no Mcp-Session-Id, no handshake, no GET stream to keep open.
Operationally that means:
- Any load balancer works. No sticky sessions, no consistent hashing. Round-robin is fine.
- Autoscaling is trivial. A new instance can answer the next request cold. Long-running state lives behind an explicit handle in a database, not in the process.
- Restarts are safe. In-flight requests are lost and clients re-issue them with a new id;
subscriptions/listenstreams are re-opened by the client.
flowchart LR c1["client A"] --> lb["HTTPS load balancer"] c2["client B"] --> lb lb --> s1["instance 1"] lb --> s2["instance 2"] lb --> s3["instance 3"] s1 --> db["shared state store, keyed by handle"] s2 --> db s3 --> db
If you find yourself wanting sticky sessions, you have hidden state in the process. Move it behind a handle.
Pick a runtime shape
Section titled “Pick a runtime shape”The TypeScript SDK’s createMcpHandler produces a web-standard handler (a fetch-style function). That runs unchanged on Cloudflare Workers, Deno, and Bun; for Node you wrap it once.
// server.ts — shared between runtimesimport { createMcpHandler, McpServer } from '@modelcontextprotocol/server';import * as z from 'zod/v4';
export const handler = createMcpHandler(() => { const server = new McpServer({ name: 'notes', version: '1.4.0' }); server.registerTool( 'add-note', { description: 'Save a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }), ); return server;});
// worker.ts — Cloudflare Workers / Deno / Bun: the handler is the appexport default handler;
// node.ts — Node behind Expressimport { createMcpExpressApp } from '@modelcontextprotocol/express';import { toNodeHandler } from '@modelcontextprotocol/node';
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['mcp.example.com'] });const node = toNodeHandler(handler);app.all('/mcp', (req, res) => void node(req, res, req.body));app.get('/healthz', (_req, res) => res.send('ok'));app.listen(8080);from mcp.server import MCPServer
mcp = MCPServer("Notes")
@mcp.tool()def add_note(text: str) -> str: """Save a note.""" return f"Saved: {text}"
if __name__ == "__main__": # Streamable HTTP, stateless, one JSON object per response where possible. mcp.run( transport="streamable-http", host="0.0.0.0", port=8080, streamable_http_path="/mcp", stateless_http=True, json_response=True, )Two Python knobs matter in production. stateless_http=True is the modern per-request mode. json_response=True makes the server answer with a single JSON body instead of an SSE stream whenever it can; that is friendlier to proxies and simpler to log, at the cost of losing mid-call notifications/progress. Choose per server: a fast lookup service wants JSON, a long-running importer wants SSE.
The factory-per-request shape (createMcpHandler(() => ...)) is deliberate: the SDK builds a fresh McpServer for each request so nothing leaks between callers. Keep the factory cheap — construct clients and pools outside it and close over them.
What the proxy must do
Section titled “What the proxy must do”Streamable HTTP is plain POST, but some responses are SSE streams, and subscriptions/listen is a stream that stays open for as long as the client cares. Reverse proxies default to settings that break both:
| Setting | Why | Typical value |
|---|---|---|
| Response buffering off | Buffered SSE arrives in one burst at the end | Server sends X-Accel-Buffering: no; nginx proxy_buffering off |
| Read / idle timeout long | A listen stream idles for minutes between notifications | Minutes to hours on the /mcp route only |
| Keep-alives | Intermediaries close quiet connections | Server emits :\r\n SSE comment lines periodically; clients ignore them |
| HTTP/1.1 or HTTP/2 to upstream | HTTP/1.0 cannot stream chunked responses | proxy_http_version 1.1 on nginx |
| Request body limit | Tool arguments can be large but not unbounded | 1–10 MB, then 413 |
The spec recommends X-Accel-Buffering: no on every SSE response precisely because nginx-style proxies buffer by default. The SDKs set it for you; a proxy in the middle can still override it, so verify with a real stream.
Hardening
Section titled “Hardening”- Validate
Origin. Servers MUST respond403to a present-but-invalidOrigin; this is the DNS-rebinding defence.createMcpExpressApp({ allowedHosts })does this; set it to your real hostnames. - HTTPS only. Terminate TLS at the edge and bind the app to a private interface. When running locally, bind
127.0.0.1, never0.0.0.0. - Authenticate every request. The authorization module covered the OAuth resource-server pattern; in production, every
/mcpPOST carries a bearer token you verify for audience and scope. - Rate limit per token, not per IP; agents behind one NAT look like one IP.
- Bound everything: request size, tool execution time, schema complexity (deep
anyOftrees are a validator DoS vector), and the number of concurrent listen streams per principal. - Validate tool inputs yourself even though the schema does; the schema does not know your business rules.
Observability comes for free in the headers
Section titled “Observability comes for free in the headers”The transport mirrors body fields into HTTP headers exactly so that infrastructure can route and measure without parsing JSON:
POST /mcp HTTP/1.1MCP-Protocol-Version: 2026-07-28Mcp-Method: tools/callMcp-Name: execute_sqlMcp-Param-Region: us-west1Mcp-Methodgives you per-method latency and error dashboards at the load balancer.Mcp-Namegives you per-tool metrics and lets a WAF block one dangerous tool without touching the body.Mcp-Param-{Name}, driven byx-mcp-headerin a tool’sinputSchema, lets a gateway route by tenant or region before the request reaches an instance.
The server behind the proxy MUST still verify that header values match the body (-32020 on mismatch), so intermediaries and instances never disagree about what a request is. Never put secrets or PII in x-mcp-header parameters; headers are visible to every hop.
Versioning your server
Section titled “Versioning your server”Clients cache tools/list per ttlMs and models learn your tool names inside long conversations. Change carefully:
- Bump
serverInfo.versionon every release. Clients log it; you will want it in bug reports. - Make tool changes additive. New optional parameters with defaults, new tools, wider enums. Never rename a tool or make an optional parameter required in place.
- Deprecate in the description first:
"Deprecated: use search_issues_v2. Will be removed after 2026-12-01."The model reads it; so do humans. - Emit
notifications/tools/list_changedto clients listening fortoolsListChangedwhen the set actually changes, and lowerttlMsaround a migration window. - Support two protocol eras if you still have
2025-11-25clients: the TypeScript handler serves both by default; setlegacy: 'reject'only when you are sure.
A minimal container
Section titled “A minimal container”FROM node:22-alpineWORKDIR /appCOPY package*.json ./RUN npm ci --omit=devCOPY dist ./distENV NODE_ENV=production PORT=8080EXPOSE 8080USER nodeCMD ["node", "dist/node.js"]Add a plain GET /healthz route (outside /mcp) for the orchestrator’s liveness probe; the MCP endpoint itself only speaks POST, so probing it with GET yields a 405, which most health checkers treat as failure.