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#

Do these seven and any request can hit any instance. Say it and stop.