Systems & Scale 4 September 2026 8 min read 1,657 words

A stateless protocol needs an unforgetting client

The Model Context Protocol's July revision deleted the session, the handshake and stream resumability, and moved cross-call state into handles the agent carries in its own tool arguments. It is the trade that made the web scale. The web had a client that remembered exactly; this one does not.

The argument

MCP did not remove state when it removed the session — it moved continuity into a model's context window and made re-running the tool the protocol's answer to a broken stream, relocating two guarantees from infrastructure an operator can inspect to a component nobody can.

On 24 August the Python package mcp published two releases on the same day: 1.29.1 and 2.1.0. Two days later, with 2.1.1 already out, a patch landed on the 2.0 line. The MCP Inspector did much the same thing in the same week — 1.0.2 on 24 August under a v1-latest tag, 2.4.0 on the 26th, 2.5.0 on 2 September. Read the registries rather than the announcements and the shape of the transition is visible in the timestamps: two major lines maintained in parallel, by the same people, in the same fortnight.

One idea separates those lines. In the 2026-07-28 revision of the Model Context Protocol, the session is gone.

The changelog is unusually blunt about it. The Mcp-Session-Id header and the protocol-level session are removed from the Streamable HTTP transport. The initialize/notifications/initialized handshake is removed, and every request now carries its own protocol version and client capabilities in _meta. Tool, prompt and resource lists "MUST NOT vary per-connection or as a side effect of other requests on the connection", because "credentials are per-request input, not connection state". Stream resumability goes too. And for servers that still need to remember something between two calls, the specification says exactly where that memory now lives: in "explicit, server-minted handles passed as ordinary tool arguments".

That last clause is the whole story, and it is worth reading as an operator rather than as a protocol designer.

A handle passed as an ordinary tool argument is a handle the model has to emit. It arrives in the transcript as a JSON value, sits in the context window beside everything else the agent is holding, and comes back on the next call only if the model reproduces it exactly. The multi-round-trip pattern that replaces server-initiated requests works the same way: when a server needs more input it returns an InputRequiredResult with an opaque requestState field, and the client "MUST echo back the exact value of that field" on a retry it is not obliged to make, without inspecting or modifying it.

So the state did not go away. It moved. It moved out of a process the operator runs, monitors, restarts and can dump, and into a context window that cannot be inspected at the moment it fails — a store with no eviction policy anyone controls, no durability guarantee, and a compaction strategy that belongs to the client runtime rather than to the protocol. Statelessness at the transport layer is real. Statelessness in the system is an accounting entry.

The revision itself half-admits the problem. Buried in the minor changes is a new recommendation that servers return tools from tools/list in a deterministic order, and the stated reason is not determinism for its own sake: stable ordering "enables clients to reliably cache the tool list and improves LLM prompt cache hit rates when tools are included in model context". This is a wire protocol adding an ordering requirement to suit the caching behaviour of a language model. The specification is already designing around the properties of model memory in one clause while, in another, treating that same memory as a safe place to keep continuity tokens.

The second relocation is harder to argue away. Resumable SSE streams via Last-Event-ID are, in the words of the transport chapter, "not supported"; a compliant server receiving that header is told to ignore it. A broken response stream, per the changelog, "loses the in-flight request", and clients "MUST re-issue it as a new request with a new request ID". Meanwhile the cancellation rules say a server MUST treat a client disconnect as cancellation of that request, SHOULD stop processing as soon as practical, and MAY ignore the cancellation altogether if processing has already completed.

Hold those three sentences together at the moment a connection drops in the middle of a tools/call. The client cannot tell whether the tool ran. The server is explicitly permitted to have finished it. The protocol's instruction to the client is to run it again under a new identifier, and there is no field anywhere in the re-issued request that would let the server recognise it as the same operation. I went looking for one, in the transport chapter, the tools chapter, the multi-round-trip pattern, the cancellation rules and the schema. The only notion of idempotency in the specification is idempotentHint: an optional annotation, defaulting to false, sitting beneath a warning that clients "MUST consider tool annotations to be untrusted unless they come from trusted servers".

The decision to re-run a non-idempotent operation therefore falls to the agent, on the strength of a hint it has been told not to trust.

For anyone maintaining a server, that has a concrete consequence this month rather than eventually. Idempotency has become the tool author's problem, and the protocol offers nowhere to put the mechanism except inside the tool's own input schema — which means a de-duplication key has to be modelled as a parameter, minted by the server, and described well enough that a model will carry it back unchanged. The same applies to the handles: a handle that identifies only a resource invites a duplicate operation on retry, while one that identifies an operation can be made to collapse it. And cacheScope deserves more deliberation than a default, because marking a list public is a statement that the tools it advertises are identical for every caller regardless of authorization — true for many servers, quietly untrue for any that filters by scope.

None of this is carelessness. It is the cost of what the revision is deliberately for. The Streamable HTTP transport now mirrors selected body fields into HTTP headers — Mcp-Method, Mcp-Name — so that "intermediaries (load balancers, gateways, observability tooling) can route and inspect requests without parsing the body". List results carry a required ttlMs freshness hint and a cacheScope of public or private, with public meaning that "any client or intermediary (e.g., shared gateway, caching proxy) MAY cache the response and serve it across authorization contexts". These are the moves of a protocol growing an infrastructure tier: MCP is being made routable, cacheable and horizontally scalable, and every one of those changes is defensible on its own terms.

But notice what has just been made cacheable. Not a document — a capability list. The set of things an agent believes it can do is now an artifact with a time-to-live, held for a stated interval, potentially by a proxy the tool's author has never heard of. A stale document renders old text. A stale tool list produces an agent reasoning confidently about affordances that no longer exist, or missing ones that do, and the failure surfaces as a bad decision rather than as a cache miss.

The strongest objection to all of this is that it has already been settled, decisively, by the web. Fielding's argument for a stateless server was not aesthetic: it is why any request can land on any instance, why intermediaries can cache, why load balancers do not need sticky routing, and why horizontal scaling is a configuration rather than a project. Session affinity and shared session stores were a genuine tax on every team running an MCP server behind more than one process. Resumable streams with event IDs and replay buffers were machinery that few implementations got right and many quietly skipped. Server-minted handles are continuation tokens, and continuation tokens are how every paginating API in production has worked for twenty years. On the engineering merits, the working group made the choice most experienced architects would have made.

I think they made it correctly and specified it against the wrong client.

The web's bargain had two halves. The server agreed to forget, and the client agreed to remember perfectly. A browser is very good at its half: it stores a cookie in a data structure, reproduces it byte-exact on every subsequent request for as long as the expiry allows, and never paraphrases it. The client MCP is actually deployed against does not have that property. It holds its state in a finite context that is summarised, truncated and compacted by a runtime with its own priorities; it decides, probabilistically, which fields of a prior result to carry forward; and when it drops a handle there is no error, only a subsequent call that quietly starts a new operation instead of continuing the old one. Statelessness moved the burden of remembering onto the one component in the stack that has no durability contract at all.

Everything above is drawn from the protocol's own documents and the public package registries, which were the records reachable in the writing of this piece; what implementers are finding as they migrate is not represented here, and their experience is the evidence that would confirm or refute it.

The accountability question is the one that will outlive the argument. When a tool runs twice, the fault is distributed by design: the server author who did not make the operation idempotent, the gateway that served a tool list within its stated TTL, the client runtime that compacted away a handle, and the model that chose to retry. Four parties, one incident, and no shared record — because the protocol that used to bind two calls together has been deliberately, and reasonably, dissolved.

The revision also introduced a feature lifecycle: Active, Deprecated, Removed, with a twelve-month minimum before anything deprecated can be taken away. It is the least discussed part of the release and possibly the most consequential, because it is the moment MCP stopped being designed and started being maintained. Sessions were overhead, and shedding them was right. They were also the last place in the stack where the system, rather than the model, remembered what had already been done.

What this is argued from

Reporting and primary material the piece rests on, dated at the time of writing. The interpretation is mine; the facts belong to these.

  1. MCP specification 2026-07-28, Key Changes Model Context Protocol · 2026-07-28
  2. MCP specification 2026-07-28, Streamable HTTP transport Model Context Protocol · 2026-07-28
  3. MCP specification 2026-07-28, Tools Model Context Protocol · 2026-07-28
  4. MCP schema 2026-07-28, CacheableResult and ToolAnnotations Model Context Protocol · 2026-07-28
  5. MCP specification 2026-07-28, Cancellation Model Context Protocol · 2026-07-28
  6. MCP specification 2026-07-28, Multi Round-Trip Requests Model Context Protocol · 2026-07-28
  7. Feature lifecycle and deprecation policy Model Context Protocol · 2026-07-28
  8. mcp release history PyPI · 2026-08-26
  9. @modelcontextprotocol/inspector release history npm · 2026-09-02

Editorials on this site are written to be argued with. If you think the reading is wrong, it probably is in some particular way, and that is the useful part.

protocol designstatelessnessagentsidempotencycaching