I Put My MCP Server Behind a Load Balancer. Half of It Died.
The 2026–07–28 spec didn’t just update MCP — it admitted the old architecture was fighting your infrastructure. Here’s the failure, live…
I Put My MCP Server Behind a Load Balancer. Half of It Died.
The 2026–07–28 spec didn’t just update MCP — it admitted the old architecture was fighting your infrastructure. Here’s the failure, live, and the rebuild.

I want to show you a bug that doesn’t look like a bug.
I built a small MCP server the way most of us built them in 2025. Three tools: select_project, get_status, deploy_production. SDK 1.26 — the version sitting in most lockfiles right now. Initialize handshake, Mcp-Session-Id header, per-session state in a Map. Textbook. It passed every test on my machine.
Then I did the thing every real deployment eventually does: I ran two copies of it behind a plain round-robin load balancer.
The initialize call landed on instance one. A session was born — inside that process's memory. The next request rotated to instance two, which had never heard of my session:
status: 400
"Bad Request: No valid session ID provided
(instance-3002 does not know session ff1328c8-…)"
Annoying, but at least it’s loud. The next part is the one that should worry you. The rotation swung back to the session’s home instance, and my destructive tool — the production deploy — went straight through:
{"deployed": true, "confirmedByUser": false}
No confirmation. No human. The deploy was supposed to ask first. It couldn’t — and my handler failed open.
That’s the worst kind of failure: intermittent. One instance, everything works. Add a second box because traffic grew, and every other request starts dying — while the destructive ones that survive fire without anyone in the loop.
This was never a bug. It was the protocol.
Nothing in my server was broken. The state lived in one process, and the load balancer never knew. That’s not an edge case — that’s what cloud infrastructure does. Serverless recycles instances mid-conversation. Autoscalers add and remove boxes. Load balancers spread requests by design.
Every workaround you’ve seen — sticky sessions, Redis session stores, affinity headers — is infrastructure apologizing for a protocol decision.
On July 28, the MCP team stopped apologizing. The 2026–07–28 specification moves the protocol core from stateful, bidirectional connections to plain request/response — the model the web settled on decades ago. The initialize handshake is gone from the new path. Mcp-Session-Id is gone. And the deprecations come with a real clock: roots, sampling, logging, and the legacy HTTP+SSE transport keep working for twelve months. Then they don't.
The rebuild: state moves out, handles move in
I rebuilt the same server on the new core — @modelcontextprotocol/server@2.0.0. (The "v2 SDK" is actually a package split: core, server, client, node, express, each published separately. Your server imports only what a server needs.)
Two things moved.
First, the state. Projects now live in shared storage — in my demo a JSON file, in your system a database — keyed by an explicit handle. select_project returns proj_checkout-api, and the model passes that handle back on every call, the way a browser carries a user ID to any server behind any balancer. The truth lives in storage, not in either process.
Second, the HTTP layer. The transport Map, the session Map, the three-branch request router — deleted. Roughly fifty lines of session plumbing collapsed into two:
const handler = createMcpHandler(() => buildServer());
http.createServer(toNodeHandler(handler)).listen(PORT);
Same load balancer, same round-robin. Instance 3002 answers one call, instance 3001 answers the next, same conversation, both correct. Nobody cares where the request lands anymore. That indifference is the entire point.
But what about tools that need to ask a human?
This was my real question going in. The old protocol handled mid-call confirmation through elicitation — a server-to-client request over a connection held open. Stateless request/response can’t hold anything open. So how does a destructive tool stop and ask?
Multi Round-Trip Requests. The flow is almost embarrassingly simple once you see it:
deploy_productionarrives with no answer attached.- The handler returns
input_required, with the question embedded as a form. That's a normal response — the connection closes. - The client shows the form, gets the answer, and re-sends the same call with
inputResponsesattached. - Second pass, the handler reads the answer and the deploy runs — confirmed.
Server-side it’s two primitives: inputRequired.elicit() to ask, acceptedContent() to read the reply on the retry. And here's the detail I didn't expect: in my runs, the retry itself got load-balanced. The question landed on one instance and the confirmation finished on the other. Nobody cared. Again — the point.
>> SERVER ASKS MID-CALL: "Deploy checkout-api to production?"
{"deployed": true, "confirmedByUser": true}
Same tool that fired blind in the old world now waits for a human — with no held connection anywhere.
The trap that cost me an hour
One thing will bite you, so let me hand it over: the v2 TypeScript client defaults to the legacy dialect. On purpose — the ecosystem is mid-transition and the SDK refuses to break old servers. My first MRTR run failed with a very precise error:
"the client on this 2025-era connection did not declare
the required capability … per-request legacy serving
cannot receive server-to-client requests"
The fix is one line in your Client options:
versionNegotiation: { mode: "auto" }
Mode auto probes the server with server/discover and upgrades on definitive evidence. Or pin the exact revision and fail loudly. Either way: if MRTR mysteriously refuses to work, this line is the first thing to check.
Your Monday plan
- Audit every remote MCP server you own for in-memory state — session Maps, per-connection caches, anything keyed by a session ID.
- Move that state into storage, keyed by an explicit handle the model can carry.
- Adopt the v2 packages and let the HTTP plumbing collapse.
- Put an MRTR form check on every destructive tool. Fail closed, not open.
Scope note so nobody panics: stdio servers on your own machine are mostly fine. The clock is for anything remote, anything behind HTTP — which is exactly the servers that matter in production.
The old MCP fought your infrastructure. The new one finally gets out of the way. Rebuild your servers before the clock does it for you.
I recorded the whole thing — the live failure, the rebuild, the MRTR flow, and the trap — with every number taken from real runs:
[embed]
The complete rig (both servers, the proxy, both clients, and the verification log) is on GitHub:
I build it. I test it.
메타데이터
- post_id
- defd7be816f0
- slug
- i-put-my-mcp-server-behind-a-load-balancer-half-of-it-died-defd7be816f0
- url
- https://medium.com/@atef.ataya/i-put-my-mcp-server-behind-a-load-balancer-half-of-it-died-defd7be816f0
- canonical_url
- https://medium.com/@atef.ataya/i-put-my-mcp-server-behind-a-load-balancer-half-of-it-died-defd7be816f0
- author_url
- https://medium.com/@atef.ataya
- status
- ok
- fetched_at
- 2026-08-17 05:24:26