MCP went stateless — state just changed address
By THOMAS FOUTREIN · 2026-10-06
Revision 2026-07-28 of the Model Context Protocol removes sessions, the handshake and the Mcp-Session-Id header. Three pages later, the same specification writes “the MCP server must be stateful”. An architect's reading: what the protocol actually takes away, and the three places where state reappears immediately.
Tags : MCP, open protocols, architecture, agentic AI, stateless, AI standards
The Model Context Protocol specification now states, in as many words, in a normative section titled “Statelessness”:
The Model Context Protocol (MCP) is a stateless protocol: all the information needed to process a request is contained in the request itself.
Three pages later, the same specification writes, about URL mode elicitation:
The MCP server is responsible for storing and managing the third-party tokens obtained through the URL mode elicitation (in other words, the MCP server must be stateful).
Both sentences are accurate. That is the whole subject of this article.
Revision 2026-07-28, published on 28 July 2026, is the most brutal in MCP's history. It does not make statelessness possible — it already was. It makes it mandatory. Which is an architectural decision, not a configuration option. And like every architectural decision, it does not make complexity disappear: it moves it. Here is where.
First, a correction: statelessness is not new
The prevailing story says MCP is “finally going stateless”. That is wrong, and it needs saying in order to understand what actually changed.
A session-free MCP server over HTTP has been possible since revision 2025-03-26. The text at the time was clear: “A server using the Streamable HTTP transport MAY assign a session ID at initialization time.” A MAY — an option. And on the implementation side, proposal SEP-2567 says it plainly: “All official SDKs except PHP already provide a stateless mode”, via sessionIdGenerator: undefined in TypeScript and stateless_http=True in Python.
Those who needed it did not wait. Cloudflare ran its Code Mode MCP server in an unofficial stateless mode from February 2026: “it has scaled up to thousands of requests per second and served billions of tool calls”. GitHub migrated five days before the final specification was published, dropping its Redis sessions along the way: “Database writes on initialize are gone, and database reads are gone from every call.”
So what 2026-07-28 brings is not a capability, it is a guarantee. And that guarantee unlocks something the optional mode never could. The argument sits in SEP-2567, and it is subtler than it looks: for an orchestrator spawning sub-agents, “the O(subagents × servers) cost is caused by sessions being possible, not by sessions being used”. As long as a server might be session-scoped, no client could cache its tool list. Making statelessness optional would have fixed nothing.
Which is why the obvious alternative — keeping the handshake but making it optional — was considered and explicitly rejected in SEP-2575:
Supporting two parallel interaction models would have dramatically increased the complexity of the protocol and every implementation. […] It also violates the design principle of having one clear, obvious way to perform a core function.
The right framing, then, is not “MCP earned the right to be stateless”. It is: MCP lost the right not to be.
The rule, and what it forbids
The normative core fits in two sentences:
A server processes each request independently; no state should be inferred from previous requests, even those on the same connection or stream.
Servers MUST NOT rely on prior requests over the same connection to establish context (e.g., capabilities, protocol version, client identity). Every request supplies this metadata in its
_metafield.

Concretely, the following disappear from the protocol: the initialize / notifications/initialized handshake, the Mcp-Session-Id header, the GET endpoint, stream resumption via Last-Event-ID, ping, logging/setLevel, resources/subscribe and notifications/roots/list_changed. And Roots, Sampling, Logging plus Dynamic Client Registration are all deprecated in one stroke.
In their place: a server/discover method (which servers must implement, but clients may ignore), a _meta envelope carried on every request, and mandatory Mcp-Method and Mcp-Name HTTP headers — which the specification itself justifies by what intermediaries need, “to route and inspect requests without parsing the body”.
One detail many articles get wrong by overstating it: within that envelope, the protocol version and the client capabilities are required, but clientInfo is not. SEP-2575 made it mandatory; a later PR made it optional after the SEP was finalised.
Here is the functional balance sheet, without flattery:
| Function | Before (≤ 2025-11-25) | After (2026-07-28) |
|---|---|---|
| Version negotiation | initialize, once per connection |
_meta on every request, plus server/discover |
tools/list |
Could vary per connection | “MUST NOT vary per-connection”; only variation by authorization remains |
| Change notifications | Standalone GET SSE stream | Response stream of a subscriptions/listen, opt-in per type |
| Resource subscriptions | resources/subscribe |
A field of the subscriptions/listen filter |
| Sampling, Elicitation, Roots | Server → client request | MRTR pattern (Sampling and Roots are deprecated) |
Progress (progressToken) |
Notifications on the stream | Unchanged |
| Recovery after a break | Last-Event-ID and event replay |
Removed: the client must re-issue with a new id |
| Cancellation | notifications/cancelled |
Over HTTP, closing the stream is the cancellation |
| Application state (cart, transaction) | Carried by the session | Nothing left in the protocol |
| Long-running operations | Sessions and resumption | Tasks extension, outside the core |
The most underrated loss is on the second row. The pattern of calling connect_database() to make query and list_tables appear in subsequent tools/list calls is no longer permitted. Progressive tool disclosure, which many teams used to keep context size under control, moves out of the protocol's scope.
Where state reappears (1): in your code
The session's official replacement is the explicit handle: create_basket() returns a basket_id that subsequent calls pass back as an argument. This is already, in practice, what deployed remote servers do: Linear returns an issue id, Stripe a customer id, GitHub a PR number.
The most misunderstood point is this one, and SEP-2567 spells it out:
Explicit state handles are not a new protocol construct — there is no schema or wire format for them. […] There is no
handles/*method, no handle type in the schema, no wire-level concept of a handle at all.
In other words: the protocol removed a primitive and handed you the responsibility. The specification's “Tools” page even covers stateful tools in an explicitly non-normative section. What you sometimes read — “the spec mandates 128 bits of entropy on handles” — is false: it is a recommendation, and it is presented as one: “This is guidance, not a protocol requirement, since the protocol has no handle concept to enforce against”.
Exactly one obligation genuinely exists, and it sits elsewhere, in the Security Best Practices:
MCP servers MUST NOT treat possession of a state handle as authentication.
With the associated recommendation: bind the handle server-side to the authenticated principal, “for example by keying stored state as <user_id>:<handle> where the user ID is derived from the verified token rather than supplied by the client”.
It is only fair to acknowledge that this shift has losers. The best-argued criticism comes from a developer on Hacker News, whitew1994, and it deserves to be quoted without caricature:
I can see the engineering benefits of removing the ID field […] However, this is a regression for stateful tool calling. The workaround proposed is to have the model pass in an ID which is a waste of tokens if this could have been injected by the client.
The handle travels through the model's context. It costs tokens, it can be dropped by a conversation compactor, and it becomes visible to anything that can influence the model's input. The official answer is reasonable — models already routinely carry file paths, URLs and identifiers returned by earlier tool calls — but it does not erase the cost.
On the other side, the expressiveness argument is the strongest one in the favourable camp, and it is genuinely convincing. Picture an orchestrator spawning sub-agents that must share one shopping cart but each have their own browser: if they share the parent's session, the browser is overwritten; if they each get their own session, the cart is lost. “No session boundary satisfies both.” A session provides exactly one scope per connection; explicit identifiers provide as many as the model chooses to create.
Where state reappears (2): in a token you have to seal
Server → client requests are gone. A server that needs information from the client — an elicitation, a filesystem root — can no longer ask for it over an open stream. It has to go through the MRTR pattern (Multi Round-Trip Requests):
Servers MUST send server-to-client requests (such as
roots/list,sampling/createMessage, orelicitation/create) using the MRTR pattern. The previous pattern of server-initiated requests is no longer supported. This is a breaking change.
The mechanism: the server answers resultType: "input_required", the client gathers the answers, then replays the original request with a different JSON-RPC id. And since the server keeps nothing in between, it may attach a requestState field to its answer: an opaque token, “meaningful only to the server”, which the client hands straight back.

You have just reinvented the cookie. With one decisive difference:
Servers MUST treat
requestStateas an attacker-controlled input. IfrequestStateinfluences authorization, resource access, or business logic, servers MUST protect its integrity (e.g. HMAC or AEAD) and MUST reject state that fails verification.
Note the exact modality: the obligation to seal is conditional, and the specification provides for the exemption: “Integrity protection MAY be omitted only when tampering can cause nothing worse than request failure”. Binding the token to the authenticated principal, giving it a short TTL and recording the originating request id are all SHOULDs. Conversely, if a requestState must be consumed at most once — a payment confirmation, say — uniqueness is on you: “Servers […] MUST enforce that invariant server-side.”
And this is where the most concrete operational trap of the whole revision lives. The Python SDK documentation describes it better than any commentary:
Sealed under what key? By default, one the server generated with
os.urandom(32)at construction time. Under--workers 4that is four constructions, in four processes: four different keys, never written anywhere, never shared, gone on restart.
The symptom is a -32602 Invalid or expired requestState. The diagnosis fits in one sentence, and you will recognise it if it has already happened to you: “A multi-round-trip tool that worked with one worker and started failing some of the time at two is this.”
The fix has two halves, and forgetting the second is enough to fail: a sealing key shared across every process, and a stable server name, because it doubles as a strictly verified audience claim. Naming your instances billing-{POD} — which looks like good observability hygiene — makes every cross-instance replay fail, shared key or not.
There is session affinity coming back through the window: it no longer applies to the network route, but to the cryptographic material. That is objectively better — a key can be shared, an in-memory session cannot — but it is not nothing.
Where state reappears (3): in your infrastructure
The official story promises MCP servers turned, in AWS's words, into “standard HTTPS endpoints, which scale cleanly with enterprise workloads”. That holds for the bulk of the traffic. Three exceptions are worth knowing before you size anything.

Notification streams cross nodes badly. subscriptions/listen is a POST whose response is a long-lived SSE stream. Its lifetime is scoped to the request, not to the connection. But in practice, a subscribed client is very much pinned to one instance for as long as its stream stays open. And above all, publishing a change from another node does not just work. The TypeScript SDK is explicit: the default event bus is in-process, and “handler.notify.toolsChanged() on node A never reaches a subscriber whose stream node B holds”. The Python SDK is blunter still: “Across real processes, the SDK ships no bus that can help you.” It is on you to implement the interface over Redis, NATS or whatever you already run. The Python documentation files this, together with the loss of stream resumption, under “the two things that being stateless does not buy you”.
Long-running operations demand durability. Tasks, moved out of the protocol core into an official extension, rest on a durable identifier you poll. The written rule is: “The task is durably created before the response is sent.” The specification does not explicitly require storage shared across replicas — that is the practical consequence in a multi-instance deployment, not a normative requirement, and it is better presented that way.
And sometimes the platform reintroduces the session itself. The most instructive case is AWS Bedrock AgentCore Runtime, whose documentation recommends stateless mode for compatibility with AWS load balancing… while also stating:
Amazon Bedrock AgentCore uses the
Mcp-Session-Idheader to route requests to the same microVM instance. […] Without a consistent session ID, each request may be routed to a new microVM, which may result in additional latency due to cold starts.
And: “In both modes, Amazon Bedrock AgentCore always returns an Mcp-Session-Id header to clients.” The protocol freed itself from affinity; the infrastructure did not. It simply moved affinity up one level, in proprietary form.
The gap: replay became structural, idempotency does not exist
For an architect, this is the most actionable point in the entire revision — and it is almost absent from the surrounding conversation.
Add the mechanisms up: MRTR replays the original request after the answers are gathered. A broken response stream loses the in-flight request, and the client MUST re-issue it with a new id. Resumption via Last-Event-ID is gone, so every pod restart produces a burst of re-issues. Replay is no longer a degraded case: it is normal operation.
Yet, as Equixly's 5 August 2026 analysis points out:
The core protocol defines no idempotency key. Whether that retry duplicates a payment, a deployment, a message, or a deletion depends entirely on whether the tool underneath was written to notice.
The protocol made replay routine without shipping the primitive that makes it safe. That responsibility is entirely yours, and it is written nowhere in the specification.

Six design rules to apply
- A handle is not an authorization. Validate the (handle, authenticated principal) pair on every call, keying stored state on the verified user rather than on the client-supplied value alone. This is the only genuinely normative constraint on handles.
- Document the handle's lifetime in the tool description — “returns a
basket_id; baskets expire after 24h idle”. Also return an expiry error the model can read and recover from on its own. The handle lives in the model's context: the model is what you need to inform. - Share the
requestStatesealing key across every process, and freeze the server name. A per-worker key and a varying instance name produce exactly the same symptom: an intermittent failure that shows up the day you scale to two replicas. - Add your own idempotency key. The protocol has none and replay is now the norm. Any non-idempotent tool that touches money, a deployment or a deletion must protect itself.
- Plan for the bus if you serve
subscriptions/listenon more than one node. No SDK ships one. And remember that streams are neither resumable nor replayed: losing a replica drops its streams, and clients re-listen and re-fetch. - Keep the legacy path, and roll out on canary. The incompatibility runs both ways: a modern client fails against an old server, and a modern server against an old client. Serving both eras on one endpoint is not a transition phase — it is the steady state for a good while.
A seventh rule if you cache: the new ttlMs and cacheScope fields are mandatory on the list methods, but the specification warns they are not enough to protect access: “Caches MUST NOT be shared across authorization contexts”. An authenticated tools/list cached as public gets shared between different token holders.
The Pulse by Astek take
Our reading: this is neither a revolution nor a mere formalisation. It is a deliberate course correction, executed as a clean break — and its main benefit had already been banked by those who did not wait for permission.
Three practical consequences.
If you run a remote MCP server built on session affinity, this is a real architectural migration, not an SDK version bump. The change is wire-incompatible in both directions. Victor Dibia's phrasing is the right one: “existing servers need real refactors, not a version bump”. That said, nothing breaks for what already runs: the maintainers are clear on this, and the Tier 1 SDKs preserved backwards compatibility.
If you are designing a server today, design it stateless — but design explicitly where the state will live. MCPJam has the truest formulation we have read on this revision: “The complexity does not vanish. It moves to places where developers can see and control it.” That is a genuine design improvement: a handle readable in a tool result is vastly more legible and auditable than state hidden in transport metadata. But it is a relocation, not a disappearance.
And keep the right question in mind. Statelessness made the MCP server trivially scalable. As it happens, that was no longer the binding constraint. Three weeks after a specification promising ordinary HTTPS endpoints, Microsoft made its remote Azure DevOps MCP server generally available — and Claude Desktop, Claude Code, ChatGPT and Cursor cannot connect to it, because Microsoft Entra ID supports neither Dynamic Client Registration nor Client ID Metadata Documents. The blocker is neither in the protocol nor in the clients: it is in the identity provider.
The official roadmap does not miss it, and the observation is blunt: “MCP authorization assumes a person with a browser at consent time. Increasingly the caller is an agent.”
The real question of 2026 is no longer “how do I scale an MCP server”. It is “how does an agent authenticate to it”.
Further reading
- The normative core: Base Protocol, revision 2026-07-28 and the revision changelog
- The two proposals that removed state: SEP-2567, Sessionless MCP and SEP-2575, Stateless MCP
- The replacement pattern: Multi Round-Trip Requests
- The deprecation registry: Deprecated features
- The platform view: Cloudflare and Google Cloud
- The security angle: Equixly, “Stateless MCP”