·, 6 min read·, mcp, distributed-systems, ai-agents
MCP dropped sessions: your server is just an HTTP service now
MCP drops sessions and stream resumability in its latest spec. Any instance can answer any request, but writes now need an idempotency key.
Comments
Loading comments
Until last month, an MCP server on Streamable HTTP could be stateful in the most ordinary way there is. The client sends initialize, the server answers with a session id in an Mcp-Session-Id header, and the client has to put that header on every request after it. Sessions were optional, but plenty of servers used them, and the moment you did, your load balancer had to remember who talked to which box.
The spec revision dated 2026-07-28 takes all of that out. No initialize, no session header, and no resuming a response stream that broke halfway. I think it's the right call. It also moves a problem from the protocol into your tool code, and that part is easy to miss.
What left the spec
Two proposals do most of the work. SEP-2567 removes protocol-level sessions and the Mcp-Session-Id header. SEP-2575 removes the initialize / notifications/initialized handshake, so every request now carries its own protocol version and client capabilities in _meta. The changelog also notes that list endpoints like tools/list "no longer vary per-connection." The announcement post puts the result in one line: "Any request can now land on any server instance behind a plain round-robin load balancer without needing shared storage."
That doesn't mean your server can't have state. It means the protocol stops carrying it for you. The changelog's wording is that servers needing cross-call state "use explicit, server-minted handles passed as ordinary tool arguments." So if a tool needs to remember something, it hands the model a value, and the model passes that value back next time like any other argument. Since there's no handshake left to learn what a server supports, there's a new server/discover RPC. Servers must implement it, and clients can call it if they want to.
Server-to-client requests changed too. roots/list, sampling/createMessage and elicitation/create used to be sent by the server down an open stream while it waited. SEP-2322 replaces them with a pattern called Multi Round-Trip Requests, and the MRTR page is direct about it: the old way "is no longer supported. This is a breaking change."
The retry is the connection now
MRTR is simple once you see it. A tool call that needs more input doesn't hold anything open. It just returns, with resultType: "input_required" instead of "complete", an inputRequests map saying what it needs, and optionally an opaque requestState string. The client collects the answers (from the user, or wherever), then retries the original request with a new JSON-RPC id, the answers in inputResponses, and requestState echoed back exactly. Clients "MUST NOT inspect, parse, modify, or make any assumptions about" that string. It's the server's note to itself.
The spec says the requests are "completely independent: the server processing the retry does not need any information beyond what is directly present in the retry request." Only tools/call, resources/read and prompts/get are allowed to answer this way.
I wanted to watch that happen, so I built a small toy. It's three plain Node HTTP servers on ports 8101 to 8103, no MCP SDK, speaking a stripped-down version of these shapes. The only thing they share is a JSON file standing in for an external database. A round-robin client calls a greet tool with no name, and instance 1 answers:
input_required from :8101
{"resultType": "input_required","inputRequests": {"user_name": {"method": "elicitation/create","params": { "mode": "form", "message": "What name should I greet?" }
}
},"requestState": "eyJ0b29sIjoiZ3JlZXQiLCJhcmdzIjp7fX0="
}
The client fills in "Ada" and retries with a new id. Round robin sends it to instance 2, which never saw the first call. It decodes requestState, merges the answer, and returns Hello, Ada!. The state that used to live in a connection now lives in a string the client carries around for you.
My toy's requestState is plain base64 JSON, which is fine for a greeting and not fine for much else:
That last sentence is the setup for the rest of this post.
No more resuming the stream
The other removal is smaller to describe and, I think, easier to get wrong. In 2025-11-25, a server could make its SSE streams resumable: the client reconnected with a Last-Event-ID header and the server could replay what it missed. That's gone. The changelog: "A broken response stream loses the in-flight request; clients MUST re-issue it as a new request with a new request ID."
For a read, that costs nothing: you just read again. For anything that writes, "re-issue it" is where things go wrong.
Where the cost lands
If the server finished the work before the stream died, the client can't tell "never ran" from "ran, but I never heard back." A plain retry runs it twice.
I tried this on the same toy with an append tool that pushes an item onto a list in the shared file. The list's name is the handle, passed as an ordinary argument, like SEP-2567 suggests. The client calls append("milk"), the server writes it, and then destroys the socket without sending a response. That's my stand-in for a stream that dies after the work but before the reply. (The toy sends plain JSON responses, not SSE, but the client sees the same thing: a hung-up socket and no result.) The client re-issues the call as a new request, round robin sends it to a different instance, and that instance appends again. The list ends up as ["milk","milk"].
Second run: same drop, same retry, but the call carries an idempotencyKey, a UUID the client keeps the same across the retry. Before writing, the server checks a table of used keys, kept in the same shared file and not in instance memory. The retry finds its key there and gets the stored result back instead of a second write. The list ends up as ["eggs"].
The protocol doesn't give you that key. CallToolRequestParams in the 2026-07-28 schema has _meta, name, arguments, inputResponses and requestState, and nothing for deduplication. The closest thing is the idempotentHint tool annotation, which says whether calling a tool again with the same arguments has no extra effect. It defaults to false, and the schema says annotations are hints that clients shouldn't trust from untrusted servers. It describes your tool. It doesn't make your tool safe to retry.
So this falls to the application. Retries are now a normal part of the protocol, not an edge case, and there's no connection left to quietly paper over a duplicate. If a tool changes anything, its input schema should take a key the client holds steady across retries, and the server needs somewhere every instance can reach to remember which keys it has already honored. That's one more table to run. In exchange, you stop caring which instance answers next.
Before you upgrade
If you maintain an MCP server, this is the list I'd work through:
Stop depending on Mcp-Session-Id. Move per-conversation state into handles your tools return and accept as arguments.
Rewrite any elicitation/create, sampling/createMessage or roots/list calls as MRTR responses.
Sign requestState if anything that matters depends on it, and bind it to the user and the request.
Give every tool that writes an idempotency key, and keep the record of used keys somewhere every instance can see.
Re-check your error handling, because some codes moved.1
None of these is hard alone. The protocol used to hide statefulness from you, and the price of fixing that is that duplicate writes are now your problem. For anything you plan to run as more than one replica, I'd take that deal.
The resource-not-found error moves from -32002 to -32602 (Invalid Params) to line up with JSON-RPC. Three codes introduced in this revision also move out of the -32000 to -32019 range, which stays implementation-defined, into the -32020 to -32099 block now reserved for the spec: HeaderMismatch becomes -32020, MissingRequiredClientCapability becomes -32021, and UnsupportedProtocolVersion becomes -32022. ↩
IST --:--Based in India. Works with teams in any time zone.
Theme
Motion
Spec revision
Clientno session
→
Load balancerplain round robin
Instance 1up
Instance 2up
Instance 3up
Elicitation
A write whose reply is lost
Shared store, the one thing every instance can reach
[]
Request log (round robin, every request)
Nothing sent yet.
Try it. Call greet, stop the instance that asked for a name, then answer. Then lose a write's reply, with and without a key. A simplified model of the two spec revisions, not an MCP SDK.
Comments