Book a 30-min call
cd ../blogs
$ cat posts/migrating-to-the-stateless-mcp-spec.mdx

Migrating to the stateless MCP spec: what the 2026-07-28 revision actually costs

August 8, 2026 · ImmovableTech Team

  • MCP
  • Agentic AI
  • Production AI

The session is gone from the protocol, not from your system

The MCP revision dated 28 July 2026 removes protocol-level sessions outright. The initialize/notifications/initialized handshake is gone (SEP-2575), and so is the Mcp-Session-Id header on the Streamable HTTP transport (SEP-2567). Every request now carries its own protocol version, client identity and client capabilities in _meta, and a version the server does not support comes back as UnsupportedProtocolVersionError rather than failing at connect time.

That is the headline, and it is genuinely good. But “MCP is stateless now” is doing a lot of work, and only half of it is a requirement.

What the spec requires is narrow and checkable. Servers MUST implement a new server/discover RPC advertising supported versions, capabilities and identity; clients MAY call it up front but are free to invoke any method inline instead. Streamable HTTP POSTs MUST carry Mcp-Method and Mcp-Name headers mirroring the JSON-RPC body, and a disagreement between the two is rejected with error -32020. Every result MUST now carry a resultType. Results from tools/list, prompts/list, resources/list, resources/read and resources/templates/list MUST carry ttlMs and cacheScope. And list endpoints no longer vary per connection.

What the spec merely makes possible is everything in the marketing: round-robin routing, autoscaling without affinity, serverless deployment, cached tool catalogues. None of that is mandated. Your application can still be as stateful as it likes. What changed is that the transport will no longer hold that state for you, or pretend to.

That distinction matters because the last item in the “required” list is the one that quietly forces the rest. Once tools/list cannot vary per connection, a server that was returning a different tool set depending on who was connected has a modelling problem, not a routing problem. We had exactly one server doing that, and it was the only migration on which we changed the product behaviour rather than the plumbing.

Moving per-session state to somewhere you control

The spec’s own answer for cross-call state is server-minted handles passed as ordinary tool arguments. The maintainers are explicit that this is preferred to hidden transport state, on the grounds that the model can see the handle and thread it between tools deliberately. Having now done this three times, we agree, and not for the reason we expected.

The handle is not primarily a scaling device. It is an observability device. A session ID living in the transport is invisible to the model, invisible in the trace unless you go looking, and invisible in the tool schema. A handle that appears as a required str argument on the next tool shows up in the model’s reasoning, in the JSON-RPC body and in the eval fixture. Twice during migration we found tool sequences that were only ever correct by accident, because the server had been silently reusing the last thing that session touched.

from mcp.server import MCPServer

mcp = MCPServer("analytics", version="2.1.0")


@mcp.tool()
def open_query(sql: str) -> str:
    """Start a paged query and return a handle for fetching its results."""
    return handles.mint(sql, ttl_seconds=900)


@mcp.tool()
def next_page(handle: str, page: int) -> dict[str, object]:
    """Fetch one page of results from a query opened with open_query."""
    return handles.read(handle, page)

For state that genuinely has to survive across calls — a paged cursor, a multi-step approval, a half-built report — you now own the store explicitly. That means you also own the things the transport used to hide: the TTL, the eviction policy, the authorisation check that the caller presenting this handle is the principal it was minted for, and the behaviour when a handle expires mid-conversation. Treat handles as capabilities, not identifiers. An opaque, signed, expiring token is the right shape; an incrementing integer is an access-control bug waiting to be found by someone else.

The state you should not move is the state that only existed because the handshake existed. Negotiated capabilities, protocol version, client name: all of that now arrives on every request, so the per-connection struct most servers kept for it can simply be deleted rather than relocated.

The failure model is the part that bites

We treated our first migration as a routing change. Deleting the session store took an afternoon. The rest of the week went on two things we had not costed, and both of them are failure-model changes rather than API changes.

The first is that SSE stream resumability is gone. The Last-Event-ID header and SSE event IDs have been removed from Streamable HTTP, and the spec is blunt about the consequence: a broken response stream loses the in-flight request, and the client MUST re-issue it as a new request with a new request ID. Our retry path had been built on resumption. Under the new model a retry is a fresh call, which means every tool that writes anything needs an idempotency key supplied by the caller and enforced by you. We found this the honest way, by watching a staging run produce a duplicate write after a pod rollout, having assured ourselves the week before that the migration was “transport-level only”.

The second is elicitation. Server-initiated requests — elicitation/create, sampling/createMessage, roots/list — required a held-open stream, which is precisely what a stateless core cannot promise. They are replaced by the Multi Round-Trip Requests pattern: the server returns a result whose resultType is input_required, carrying the questions it needs answered in inputRequests, and the client retries the original call with the answers in inputResponses. Any instance can serve the retry, because the server encodes what it needs to resume in requestState.

{
  "resultType": "input_required",
  "inputRequests": {
    "confirm": {
      "type": "elicitation",
      "message": "Delete these 3 files?",
      "schema": { "type": "boolean" }
    }
  },
  "requestState": "<opaque, server-encoded resume context>"
}

Note the value: input_required, with an underscore. Google’s own launch post — the clearest published explanation of why the transports working group pushed for this — prints inputRequired in its example JSON, and calls the 28 July release a release candidate when the final revision shipped that day. Neither is serious. Both are a reminder that on protocol behaviour the spec text is the authority and everything else, including the vendor blog written by the people who drove the change, is context.

Restructuring confirmations was the expensive part of our migration, and it is expensive in product terms rather than engineering terms. A tool that pushes a confirmation mid-call is one function. A tool that can return partway through, be re-entered with an answer and resume from encoded state is a small state machine, and you have to decide what happens when the user never answers.

Authorisation: smaller than the headlines, still worth doing

The authorisation work in this revision is real and it is incremental. It appears under minor changes in the changelog, not major, and it is not a rewrite of MCP’s security model.

Three things changed. Authorisation servers SHOULD now return the iss parameter per RFC 9207, and clients MUST validate a present iss against the issuer they recorded before redeeming the authorisation code — which closes an authorisation-server mix-up hole that matters specifically because MCP clients routinely talk to many servers with many different issuers. Client credentials are now bound to the authorisation server that minted them, so a client MUST key persisted credentials by issuer and re-register when the authorisation server changes. And Dynamic Client Registration is formally deprecated in favour of Client ID Metadata Documents, though it keeps working for authorisation servers that have not caught up.

What did not change is the load-bearing part. Audience binding through RFC 8707 resource indicators, the requirement that servers validate tokens were issued for them specifically and MUST NOT accept or transit any other token, protected resource metadata discovery — all of that was already normative. If your MCP server’s threat model was sound in November, this revision tightens the client side of the flow rather than rescuing the server side.

We would still do the client work early, because the mix-up class of attack is exactly the kind that never shows up in your own testing. It requires a hostile or compromised authorisation server in a multi-server deployment, which is a shape you do not have in staging and do have the day an enterprise customer points your client at their own identity provider.

The deprecation policy is the item that changes planning

SEP-2596 is the change we would put first if we were ranking these by effect on how we plan work, and it got the least attention.

MCP now has a formal feature lifecycle: Active, Deprecated, Removed. Deprecating a feature requires a SEP that names it, documents a migration path and specifies a minimum deprecation window of at least twelve months, measured from the release of the revision in which the feature is first marked Deprecated. Everything in flight is tracked in a single deprecated registry rather than scattered across changelogs. Tier 1 SDKs must mark the corresponding API surface deprecated in their next release and should emit a runtime warning when it is exercised. The twelve-month floor can be shortened only for a vulnerability with a published advisory or documented exploitation and no in-place mitigation, and even then must leave ninety days.

The first cohort under the policy is substantial: Roots, Sampling and Logging are all Deprecated (SEP-2577), as is the legacy HTTP+SSE transport. They keep working for at least a year.

This is what makes it defensible to build a production dependency on MCP. Before the policy, “will this still work next year?” was answerable only by reading maintainer sentiment. Now it is a date you can put in a roadmap and a warning your CI can fail on. We have started treating the deprecated registry as an input to quarterly planning in the same way we treat runtime end-of-life dates, which is a sentence we could not have written about MCP six months ago.

What the Python migration costs

mcp 2.0.0 landed on PyPI on 28 July 2026. pip install mcp now resolves to 2.x, so any project that has not migrated needs an upper bound — mcp>=1.28,<2 — today rather than after the next unpinned rebuild. The v1.x line receives critical bug fixes and security patches only.

The high-level authoring experience is almost unchanged, which is why the migration is easy to underestimate. FastMCP is renamed MCPServer, and @mcp.tool(), @mcp.resource() and @mcp.prompt() take the same arguments and handler signatures they always did. Type hints are still the schema; plain return values are still wrapped for you. If you only read the quickstart you would conclude nothing happened.

The cost is everywhere else. Protocol model fields moved from camelCase to snake*case — inputSchema becomes input_schema, isError becomes is_error — so any code touching results breaks loudly. McpError is now MCPError, and raising it inside a tool now produces a top-level JSON-RPC error instead of a CallToolResult with isError set, which changes what the calling model sees. The lowlevel Server swaps its decorators for on*\*constructor parameters and stops auto-wrapping return values. Transport parameters moved off the constructor ontorun().

The one that costs the most per line of diff is the HTTP client: httpx and httpx-sse are replaced by httpx2, and the SDK no longer installs httpx at all. That is fine where it fails loudly, and it does fail loudly if you hand an httpx.AsyncClient an SDK auth provider. It is not fine in exception handlers. An except httpx.ConnectError: block keeps importing cleanly if anything else in your tree still depends on httpx, and simply stops matching. Grep for except httpx. before you grep for anything else.

Budget days, not hours, and budget them on tests and error paths rather than on tool definitions.

What we’d do differently

We would have migrated the confirmation flows first and the transport second. We did it the other way round on all three systems, because deleting the session store is the satisfying part and it makes the architecture diagram look finished. It also meant we discovered the MRTR restructuring after we had told people the migration was nearly done. The transport change is a day. The interaction-model change is the project.

We would have introduced idempotency keys before the migration rather than during it, as a change to our own tools under the old spec where retries were still rare. Adding them under the new failure model, where every dropped stream becomes a fresh request, meant changing the retry semantics and the write path in the same commit.

We would not have written our own dual-era compatibility shim. We built one for a server whose client we did not control, before reading the compatibility matrix in the versioning page, which specifies exactly how a dual-era server should behave and how clients detect a server’s era on each transport. Ours reimplemented most of that, slightly differently. We deleted it and used the SDK’s legacy mode.

And we would have said out loud, at the start, that this is a breaking change with a real bill attached. The protocol is better for it — our earlier write-up of MCP in production recommended stateless remote servers before the spec required them, and the operational annoyances it describes are genuinely gone. But “the spec went stateless” reads like a configuration flag, and on a server that holds per-session state today it is a week of work per system, most of it in places the changelog does not point at. Our restaurant intelligence platform was the cheapest of the three purely because its tools were already stateless by accident.

References


We migrate production MCP servers to the 2026-07-28 specification as part of our AI & Machine Learning Engineering practice. Talk to us if you are running an MCP server with a session store you would rather delete.