Short answer: make your MCP server stateless by constructing StreamableHTTPServerTransport without a sessionIdGenerator (leave it unset) and with enableJsonResponse: true, building a fresh McpServer per request, and reading protocol version, client info, and trace context from _meta on every call. Then any request can land on any instance, and a plain round-robin balancer is all you need. This is the concrete migration behind the spec change we covered in MCP goes stateless.
The 2026-07-28 revision is a release candidate now, final on 2026-07-28. Nothing forces your hand: existing 2025-11-25 servers keep working, and deprecated features live at least 12 months under the SEP-2577 Active → Deprecated → Removed lifecycle. You migrate to scale horizontally, not to beat a deadline.
1. Delete the session#
Stateful servers cache one transport per Mcp-Session-Id and pin the client to it. SEP-2567 removes that header and protocol-level sessions entirely. Rip out the map.
// BEFORE — one transport per session, pinned to an instance
const transports: Record<string, StreamableHTTPServerTransport> = {};
app.post("/mcp", async (req, res) => {
const sid = req.headers["mcp-session-id"] as string;
let transport = transports[sid]; // pinned: this session lives on one instance
if (!transport) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => { transports[id] = transport!; },
});
const server = new McpServer({ name: "acme", version: "1.0.0" });
registerTools(server);
await server.connect(transport);
}
await transport.handleRequest(req, res, req.body);
});
The after builds everything per request and shares nothing:
// AFTER — stateless: fresh server + transport every request
app.post("/mcp", async (req, res) => {
const server = new McpServer({ name: "acme", version: "1.0.0" });
registerTools(server);
const transport = new StreamableHTTPServerTransport({
// no sessionIdGenerator ⇒ stateless: no Mcp-Session-Id, no sticky routing
enableJsonResponse: true, // return JSON, never hold an SSE stream
});
res.on("close", () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
Leaving sessionIdGenerator unset while setting enableJsonResponse: true is the whole switch. Also delete the GET /mcp route that used to hold an SSE stream open and the DELETE /mcp route that tore sessions down — neither exists in a stateless world.
If two requests from the same client can safely land on two different instances, you're stateless. If they can't, you still have a session hiding somewhere.
2. Read context from _meta, not initialize#
SEP-2575 removes the initialize/initialized handshake. Protocol version, client info, and capabilities now ride in _meta on every request — so read them inside the handler instead of stashing them once at startup.
server.registerTool("get_invoice", { /* schema */ }, async (args, extra) => {
// 2026-07-28: per-request context lives in _meta, not a one-time handshake
const meta = extra?._meta ?? {};
// W3C Trace Context (SEP-414) rides here too: traceparent / tracestate / baggage
const traceparent = meta["traceparent"];
return { content: [{ type: "text", text: await lookup(args) }] };
});
The handler's second argument carries request metadata; _meta is where the per-request keys land. Treat every request as self-describing. (Field names are still settling in the RC — read them off the request rather than hardcoding a shape.) SEP-2549 lets you attach ttlMs/cacheScope to results so clients can cache without a session, and SEP-2243 adds Mcp-Method/Mcp-Name headers so a balancer can route without parsing the body.
3. Drop SSE — poll Tasks instead#
Long-running work used to mean a held-open SSE stream. That's the enemy of round-robin: the stream binds a client to one instance for minutes. The 2026-07-28 spec moves long work to a poll-based Tasks extension.
tools/call returns a task handle instead of blocking. The client then drives a state machine — working → input_required → completed / failed / cancelled — with tasks/get, tasks/update, and tasks/cancel. (tasks/list was removed.) Because each poll is an independent stateless request, poll #1 and poll #2 can hit different instances, as long as task state lives in a shared store (Redis, a DB), not instance RAM. For the full task lifecycle, see running a long MCP tool call as a stateless task.
// Return a task handle; persist state in a shared store keyed by task id
await tasks.put(taskId, { status: "working" });
// client polls tasks/get; you flip status to "completed" when the work lands
4. Replace Sampling with a direct provider call#
Sampling (server asking the host's LLM to complete, via createMessage) is deprecated. Call a provider yourself:
// BEFORE: server.server.createMessage({ messages, maxTokens: 512 })
// AFTER: your tool calls an LLM provider directly
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic();
const result = await anthropic.messages.create({
model: "claude-opus-4-8",
max_tokens: 512,
messages: [{ role: "user", content: userText }],
});
Same idea for Roots (deprecated → pass paths as tool params, resource URIs, or server config) and Logging (deprecated → stderr for stdio, OpenTelemetry for HTTP).
5. Put it behind round-robin#
With no sessions and no streams, the balancer is boring — exactly what you want. No ip_hash, no sticky cookie:
upstream mcp {
server 10.0.0.11:3000;
server 10.0.0.12:3000;
server 10.0.0.13:3000; # plain round-robin — every instance is identical
}
server {
listen 443 ssl;
location /mcp { proxy_pass http://mcp; proxy_set_header Host $host; }
}
If you're standing the fleet up for the first time, start with how to deploy an MCP server. And if you own the client side too, the mirror-image walkthrough is migrating your MCP client to the 2026-07-28 stateless spec.
Migration checklist#
- [ ] Delete the session map; omit
sessionIdGeneratorand setenableJsonResponse: true. - [ ] Build a fresh
McpServer+ transport per request; close both onresclose. - [ ] Remove the
GET/DELETESSE + session routes. - [ ] Read protocol version, client info, and trace context from
_metain each handler. - [ ] Move long-running tools to Tasks; keep task state in a shared store.
- [ ] Swap Sampling for a direct provider call; replace Roots and Logging.
- [ ] Drop sticky sessions; point traffic at plain round-robin.
Do these seven and any request can hit any instance. Say it and stop.



