MCP Goes Stateless: What 2026-07-28 Changes
If you've tried putting an MCP server behind a load balancer, you'll have hit the wall the rest of us did. The client does an initialize handshake, gets a session ID back, and every request after that has to land on the same replica. So you reach for sticky sessions, or shared session storage, or you give up and run one big instance. This revision gets rid of that problem by getting rid of sessions - simples right?
We're going to look at:
- What replaced the handshake, and where the metadata went
- MRTR, the new pattern for a server that needs something from the client
- What you lose (resumability, and it's a real loss)
- The changes aimed at gateways, proxies and auth
- Porting a server from
2025-11-25, with the wire format on both sides - What the trade buys you, as a developer, a user and an enterprise
What changed#
MCP 2026-07-28 is now a request/response protocol with no connection state. As it says in the spec thou shall ensure: "no state should be inferred from previous requests, even those on the same connection or stream".
The initialize and notifications/initialized handshake is gone (SEP-2575), and so is the Mcp-Session-Id header (SEP-2567). Everything the handshake used to set up now rides along on every request, in _meta:
io.modelcontextprotocol/protocolVersion(required)io.modelcontextprotocol/clientCapabilities(required)io.modelcontextprotocol/clientInfo(optional, but clients should send it)
Servers identify themselves the same way in each result, via io.modelcontextprotocol/serverInfo. Miss a required field and you get -32602 and a 400 back. Ask for a version the server can't speak and you get an UnsupportedProtocolVersionError listing the ones it can.
So your MCP server can now sit behind plain round-robin routing with no shared storage, because there's nothing left to share. tools/list, resources/list and prompts/list also stop varying per connection, which is what makes the caching changes further down possible.
There's a second point around this that's easy to miss. An open connection is no longer a conversation and a STDIO process isn't a session either. Clients can interleave unrelated requests down the same transport. If your server used process or connection identity as a stand-in for "this user, this conversation", that's now explicitly wrong or unsupported.
If your server does need to carry something between calls, the spec tells you where to put it: create a handle yourself and pass it as an ordinary tool argument.
How do you negotiate a version now?#
There's a new RPC for it. Every server must implement server/discover, which advertises the protocol versions it supports and talks, its capabilities and its identity. Clients may call it first, and on STDIO it doubles as a backwards-compatibility probe against older servers.
No more server-initiated requests#
This is the second big change and the one most likely to mean real work for existing MCP Authors really.
Previously a server that needed something mid-call just asked for it: sampling/createMessage to borrow the client's model, elicitation/create to put a question in front of the user, roots/list to see which directories it had. All of that needed a held-open bidirectional stream, which is exactly what a stateless protocol can't promise.
The replacement is Multi Round-Trip Requests, or MRTR (SEP-2322). The server returns an InputRequiredResult with resultType: "input_required" and an inputRequests field listing what it still needs. The client goes and gets it, then sends the original request again with inputResponses attached.
A tool that wants to confirm something before acting no longer needs a live channel. Supabase's Inian Parameshwaran gave the example most people will recognise in the announcement: showing someone the cost before you create their project.
Two details will catch you out.
Every result now carries a required resultType, either "complete" or "input_required". Clients must treat a missing resultType from an older server as "complete", so this stays compatible in the direction that matters.
There's also no completion notification any more. notifications/elicitation/complete and the elicitationId field, both of which only turned up in 2025-11-25, have gone. The client finds out how an out-of-band interaction went by retrying. If you need to correlate an elicitation across those retries, encode your own identifier in requestState.
Streams and subscriptions#
The HTTP GET endpoint and the resources/subscribe / resources/unsubscribe pair are replaced by a single subscriptions/listen stream. Clients opt in to the notification types they want (toolsListChanged, promptsListChanged, resourcesListChanged, resourceSubscriptions), the server acknowledges, and tags what it sends with io.modelcontextprotocol/subscriptionId.
Request-scoped notifications work differently, and the split matters: notifications/progress and notifications/message still come back on the response stream of the request they belong to, not on subscriptions/listen.
Also gone are ping, logging/setLevel and notifications/roots/list_changed. Log level is per-request now, set with io.modelcontextprotocol/logLevel in _meta, and servers must not emit notifications/message for a request that didn't ask for one. Anyone who's had a chatty server flood their logs will be pleased about that one.
Cancellation changed shape too. On Streamable HTTP, closing the SSE response stream is the cancellation signal and the server must stop work. notifications/cancelled is now a stdio-only message.
Tasks moved to an extension#
The experimental tasks feature has left the core protocol and become an official extension, io.modelcontextprotocol/tasks (SEP-2663).
It got a redesign on the way out. The blocking tasks/result method is replaced by polling tasks/get, there's a new tasks/update for client-to-server input, tasks/list is gone, and servers can hand back a task handle unsolicited without needing a per-request opt-in. Polling rather than blocking is the same instinct as the rest of the revision: nothing should depend on a connection staying up.
The extensions framework is formalised now too, with an extensions field on both ClientCapabilities and ServerCapabilities. Tasks, MCP Apps and Enterprise Managed Authorization are the worked examples.
Changes for gateways and proxies#
Two smaller items matter more than their word count suggests, because they're aimed at whoever runs infrastructure in front of your servers.
Headers now mirror the body. Three are required, and a server that reads the body must reject any request where a header disagrees with it, returning 400 and -32020 (HeaderMismatch):
| Header | Mirrors | Required for |
|---|---|---|
MCP-Protocol-Version | _meta protocol version | Every POST |
Mcp-Method | method | Every request |
Mcp-Name | params.name or params.uri | tools/call, resources/read, prompts/get |
The validation rule matters as much as the headers. It exists so a load balancer routing on a header and a server executing on the body can't be made to disagree, which is a real attack if you skip it. Servers may also mirror chosen tool parameters into Mcp-Param-{Name} headers by annotating them with x-mcp-header in the tool's inputSchema, and clients must support that when a server asks for it (SEP-2243).
The upshot is that a gateway, rate limiter or WAF can route, meter and police MCP traffic by reading headers, without parsing a JSON body to work out what the request was. If you've written a proxy you'll know how much cheaper that is.
List results are cacheable. tools/list, prompts/list, resources/list, resources/read and resources/templates/list now return ttlMs and cacheScope through a new CacheableResult interface (SEP-2549). ttlMs is a freshness hint in milliseconds. cacheScope is "public" or "private" and decides whether a shared intermediary may cache the response at all. Both complement the existing listChanged notifications rather than replacing them.
None of this was possible while list results still varied per connection, which is why it belongs with the session removal.
Gateways are first-class in the spec now
Between header routing, cache scopes and per-request log levels, this revision assumes something sits between the client and the server, and hands it the metadata to do its job. That matches what we see with customers: teams putting MCP into production want the same central control they already run for their LLM traffic. Our own gateway work in Alloy has the same shape, with key management and policy at the edge instead of in every client.
Auth changes#
Less headline-grabbing, more likely to be what bites you in a security review.
- An authorisation server should return the
issparameter per RFC 9207, and clients must validate it against the recorded issuer before redeeming an authorisation code (SEP-2468). That closes off a mix-up attack class. - Clients must specify an appropriate
application_typeduring Dynamic Client Registration (SEP-837). This is the fix for OpenID Connect rejectinglocalhostredirect URIs and breaking desktop and CLI apps. - Client credentials are explicitly bound to the authorisation server that issued them (SEP-2352). Key them by issuer, never reuse them against a different server, re-register when the server changes.
Another important change, Dynamic Client Registration is deprecated in favour of Client ID Metadata Documents. DCR still works against authorisation servers that don't support CIMD, but it's on the way out.
Porting a server from 2025-11-25#
Let's look at what the wire actually looks like on either side of the change, since that shows the size of the job faster than any description.
Before, three POSTs to make one tool call, with a session ID threaded through them:
POST /mcp HTTP/1.1
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-11-25",
"capabilities":{"elicitation":{}},
"clientInfo":{"name":"ExampleClient","version":"1.0.0"}}}
HTTP/1.1 200 OK
Mcp-Session-Id: 01JD8ZK3QW7YB4N2
POST /mcp (Mcp-Session-Id: 01JD8ZK3QW7YB4N2)
{"jsonrpc":"2.0","method":"notifications/initialized"}
POST /mcp (Mcp-Session-Id: 01JD8ZK3QW7YB4N2)
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{
"name":"get_weather","arguments":{"location":"Melbourne, VIC"}}} After, one POST, no handshake, everything the server needs in the request:
POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"get_weather",
"arguments":{"location":"Melbourne, VIC"},
"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"ExampleClient","version":"1.0.0"},
"io.modelcontextprotocol/clientCapabilities":{}
}}} The result carries its type and the server's identity:
{"jsonrpc":"2.0","id":1,"result":{
"resultType":"complete",
"content":[{"type":"text","text":"18C and raining, obviously."}],
"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"weather","version":"2.1.0"}}}} Work through it in this order and you stay shippable at every step:
- Add
server/discover. Mandatory, small, and it unblocks clients negotiating versions against you. Do it first so you're discoverable while the rest is in flight. - Read
_metaper request instead of remembering the handshake. Wherever you stashedprotocolVersion, capabilities and client info at initialize time, read them off each request instead. Reject a request missing a required field with-32602and a400. - Validate the three headers against the body, returning
-32020when they disagree. This is the one shortcut with a security cost. - Stop creating session IDs. For older clients still sending them, the spec is specific: ignore
Mcp-Session-Id, ignoreLast-Event-ID, and answer GET or DELETE on the MCP endpoint with405 Method Not Allowed. - Move connection state into handles. Anything in a per-session map becomes a server-created identifier passed back as a tool argument. This part needs design thinking, so start it early even if it lands late.
- Rework server-initiated calls to MRTR. The largest single job if you use elicitation. Your tool handler has to become resumable: return
input_required, then handle the retry that arrives withinputResponses, correlating on your own identifier inrequestState. - Declare the capabilities you rely on, and return
MissingRequiredClientCapabilityError(-32021) withdata.requiredCapabilitieswhen a client didn't send what you need. A server must not assume a capability the client didn't declare. - Move long-running work to the tasks extension, now a dropped stream can't be resumed.
- Do the auth items. RFC 9207 validation and issuer-bound credentials are small changes with real security value, and CIMD is where registration is heading.
Client-side there's a matching job, mostly smaller: send the three headers, put _meta on every request, handle resultType, and implement the MRTR retry loop. If you support both eras, try a modern request first and inspect the 400 body before falling back, since modern servers also answer 400 for unsupported versions and header mismatches.
What's deprecated#
The revision brings in a feature lifecycle and deprecation policy, with Active, Deprecated and Removed states and a minimum twelve-month deprecation window. It's a governance change, and a good one, because it turns the list below into a schedule you can plan around.
| Deprecated | Suggested migration |
|---|---|
| Roots | Pass directories or files as tool parameters, resource URIs, or server configuration |
| Sampling | Integrate directly with the LLM provider API |
| Logging | Log to stderr on stdio, or use OpenTelemetry |
| HTTP+SSE transport | Streamable HTTP (deprecated in prose since 2025-03-26, now formally so) |
| Dynamic Client Registration | Client ID Metadata Documents |
includeContext values "thisServer" / "allServers" | Omit the field, or use "none" |
Roots, Sampling and Logging all going at once (SEP-2577) is a bigger deal than it reads on the page. Those were three of the client-side capabilities that made MCP feel bidirectional, so the protocol is settling into a narrower job: a client calling tools on a server.
Smaller changes that'll break your tests#
- Resource-not-found moved from
-32002to-32602(Invalid Params), lining it up with JSON-RPC. Clients should still accept-32002from older servers. - There's an error code allocation policy now.
-32000to-32019stays implementation-defined with existing SDK usage grandfathered, and-32020to-32099is reserved for the spec. The codes introduced in this draft were renumbered to suit:HeaderMismatch-32001to-32020,MissingRequiredClientCapability-32003to-32021,UnsupportedProtocolVersion-32004to-32022. inputSchemaandoutputSchemaaccept any JSON Schema 2020-12 keywords now, andstructuredContentaccepts any JSON value (SEP-2106). Implementations must not auto-dereference a$refpointing at a network URI, which is sensible hardening.- OpenTelemetry trace context propagation is documented for
_meta, using the standardtraceparent,tracestateandbaggagekeys (SEP-414).
All the Tier 1 SDKs shipped support alongside the spec, TypeScript, Python, Go, C# and Rust in beta, each with migration notes for the breaking changes.
What you get out of it#
Migration work needs a reason, so here's what the trade buys depending on where you sit.
If you build MCP servers, the operational story gets much simpler. You scale out by adding replicas behind whatever load balancer you already run, with no session store, no sticky routing and no cross-replica coordination. A replica can die mid-conversation and the next request lands somewhere else and works. Cold starts matter less because there's no handshake to redo, and serverless becomes a reasonable target for the first time, since nothing needs to survive between invocations. Against that, you own the state you used to get for free, and you lose stream resumability.
If you use agents, most of this is invisible, which is the point. What you should notice is fewer dropped connections taking a tool call with them, faster first calls now the handshake round trip has gone, and tools that can ask you a question mid-run without a live channel. Deterministic tools/list ordering and cacheable lists also keep the prompt your model sees stable between calls, which helps its own caching.
If you run this in an enterprise, the useful changes are the boring ones. Header-based routing means your existing gateway, WAF and rate limiter can see MCP traffic properly and meter it per tool and per tenant, without anyone writing a body-parsing proxy. The header-body validation rule closes the gap where a router and a server could be tricked into disagreeing. cacheScope gives you a defensible answer on what a shared intermediary may hold. OpenTelemetry propagation puts MCP calls in the traces you already collect. RFC 9207 issuer validation and issuer-bound credentials remove two real attack classes, and CIMD gets you out of the DCR business. The twelve-month lifecycle policy is what lets you put any of it in a roadmap.
Where statelessness stops#
One more thing, because it changes how you build the rest of the stack.
The protocol layer removed session affinity because affinity was stopping MCP scaling sideways. One layer down, inference routing depends on affinity more every month, for the opposite reason.
A coding agent's session isn't a series of independent requests. Each turn resends a growing conversation, so by turn twenty you're pushing tens of thousands of tokens of prefix the model has already chewed through. Land that request on a backend still holding the KV cache for that prefix and the prefill is nearly free. Land it anywhere else and you pay to recompute the lot while your user watches a spinner.
That's why prefix caching is what modern inference servers compete on. SGLang's RadixAttention reuses KV cache across requests sharing a prefix, vLLM has its own prefix caching, and as we covered in our inference server comparison, persistent KV caching can drag time to first token on long contexts from 30 to 90 seconds down under 5. Those gains only turn up if the request reaches the machine holding the cache, which means sticky sessions and KV-cache affinity, the exact property MCP just spent a revision removing.
So building agent infrastructure today means running two routing disciplines in the one stack. Above the model, spread MCP traffic across replicas however you like, because the spec has gone out of its way to let you. Below it, send conversations back to the backend that already knows them. Get those the wrong way round and the thing scales fine but feels slow.
It's roughly why our own stack is put together the way it is. Olla does sticky sessions with KV-cache affinity across local inference backends because the model layer needs it, while the gateway above stays free to spread load however it likes.
Wrapping up#
If you're building a new MCP server, target 2026-07-28. There's no reason to adopt a session model on its way out, and the deprecated features have migrations that are mostly simpler than what they replace.
If you've got one in production, the twelve-month window is real, so use it rather than rushing. Start with server/discover and per-request _meta, then work down the list above.
Key takeaways
- MCP is stateless as of
2026-07-28. Noinitializehandshake, noMcp-Session-Id; every request self-describes via_meta, andserver/discoverhandles version negotiation. - Server-initiated requests are replaced by MRTR: the server returns
resultType: "input_required", the client retries withinputResponses. - SSE resumability has gone. A broken stream means re-issuing the request, which is the strongest argument for the tasks extension on long-running work.
- Roots, Sampling, Logging, HTTP+SSE and Dynamic Client Registration are deprecated with a minimum twelve-month window under the new lifecycle policy.
MCP-Protocol-Version,Mcp-MethodandMcp-Namemust mirror the body, and servers must reject mismatches. That plusttlMsandcacheScopemakes gateways first-class.- Statelessness stops at the protocol. Below it, KV-cache affinity still decides your time to first token, so agent stacks run stateless routing on top of a stateful inference layer.
Further reading#
- MCP 2026-07-28 announcement - the release post, with ecosystem commentary
- Full changelog - every change, with the SEP links
- Streamable HTTP transport - the header rules, message flows and backwards-compatibility guidance
- Multi Round-Trip Requests - the MRTR pattern in detail
- Feature lifecycle and deprecation policy - how the twelve-month window works
- What is an LLM proxy? - the routing layer this sits on top of, and how sticky sessions work
- LLM inference servers compared - prefix caching across vLLM, SGLang and llama.cpp
- Deploying LLMs on your own infrastructure - the wider self-hosted stack