MCP Went Stateless: What the 2026-07-28 Spec Actually Changes The Model Context Protocol (MCP) became stateless at the protocol layer with the 2026-07-28 specification, removing the handshake and session ID header to enable horizontal scaling without sticky sessions. The revision introduces six Specification Enhancement Proposals, including cacheable list results, routing headers, and hardened authentication, while deprecating older transports and features. For eighteen months, running a remote MCP server meant fighting your own infrastructure. You had a bidirectional, stateful protocol sitting on top of HTTP, which meant sticky sessions, a shared Redis for session state, or a gateway doing packet inspection to route requests to the one box that held the connection. Every horizontal scaling story started with an apology. On July 28, 2026, that ended. The 2026-07-28 specification makes MCP stateless at the protocol layer — the largest revision since launch, and the first one that makes MCP behave like the rest of the web. Six SEPs Specification Enhancement Proposals work together here. The short version: The handshake is gone. initialize / initialized and the Mcp-Session-Id header have been removed SEP-2575, SEP-2567 . Every request is now self-describing: protocol version, client identity, and client capabilities ride inline in meta on each call. There's an optional server/discover RPC if a client wants capabilities up front, but nothing requires it. POST /mcp HTTP/1.1 MCP-Protocol-Version: 2026-07-28 Mcp-Method: tools/call Mcp-Name: search {"jsonrpc":"2.0","id":1,"method":"tools/call", "params":{"name":"search","arguments":{"q":"otters"}, " meta":{"io.modelcontextprotocol/clientInfo":{"name":"my-app","version":"1.0"}}}} Any request can land on any instance behind a plain round-robin load balancer. No shared storage, no ARR affinity, no sticky routing. Routing moved into headers. Mcp-Method and Mcp-Name are now required on Streamable HTTP requests SEP-2243 . Your gateway, WAF, or rate limiter can route and meter without parsing JSON bodies. If you've ever written a Lua script to peek inside an MCP payload at the edge, you can delete it. List results are cacheable. tools/list , prompts/list , resources/list , and resources/read now carry ttlMs and cacheScope SEP-2549 , with deterministic ordering. That last part matters more than it looks: a stable tool catalog keeps upstream prompt caches stable across reconnects, which is a real token-cost line item. Server→client calls became round trips. This is the clever bit. Elicitation and sampling used to require a held-open stream, which is exactly what a stateless protocol can't offer. Multi Round-Trip Requests SEP-2322 invert it: the server returns resultType: "input required" plus an opaque request-state token, the client gathers the answers, then calls the same tool again with inputResponses attached. Every leg is an ordinary client-to-server request. Auth got hardened. Authorization servers should return iss per RFC 9207 https://www.rfc-editor.org/rfc/rfc9207 and clients must validate it before redeeming a code SEP-2468 — that closes an AS mix-up hole. Client credentials are now bound to the issuer that minted them SEP-2352 , and Dynamic Client Registration is formally deprecated in favour of Client ID Metadata Documents. Deprecations. Roots, Sampling, and Logging are deprecated SEP-2577 , as is the legacy HTTP+SSE transport SEP-2596 . Both get a twelve-month minimum offramp under the new deprecation policy — which is itself the quiet good news here. This is a protocol that now plans its breakage. "Stateless protocol" does not mean "stateless application." If your server needs to carry state across calls, you mint an explicit handle from a tool and let the model pass it back as an ordinary argument. The maintainers are direct that this works better than state hidden in the transport, and I think they're right for a reason that isn't primarily architectural: the model can see the handle. It becomes a thing the agent reasons about and threads between tools, instead of an invisible coupling that breaks the moment a load balancer does its job. This is the same lesson REST landed on twenty-five years ago. Roy Fielding's dissertation argued the statelessness constraint buys you visibility, reliability, and scalability at the cost of repeated per-request data — Architectural Styles and the Design of Network-based Software Architectures https://ics.uci.edu/~fielding/pubs/dissertation/top.htm , Ch. 5. MCP just paid that tuition in public. The v2 line of the Python SDK renamed the in-SDK FastMCP to MCPServer and moved transport options off the constructor onto run . If you're still importing mcp.server.fastmcp , you're on v1.x and speaking the old handshake. uv init mcp-units && cd mcp-units uv add "mcp cli " python server.py from mcp.server.mcpserver import MCPServer mcp = MCPServer "units" @mcp.tool def to celsius fahrenheit: float - float: """Convert Fahrenheit to Celsius.""" return round fahrenheit - 32 5 / 9, 2 if name == " main ": stateless http + json response = plain HTTP request/response mcp.run transport="streamable-http", stateless http=True, json response=True That's it. No session manager, no event store, no affinity. Run it and point the inspector at http://localhost:8000/mcp : uv run server.py npx -y @modelcontextprotocol/inspector Now the interesting version — state without a session: python import uuid from mcp.server.mcpserver import MCPServer mcp = MCPServer "reports" In production this is Redis/Postgres, not a dict — but note that it's your store, keyed by a handle the model holds, not transport state. JOBS: dict str, dict = {} @mcp.tool def start export dataset: str - str: """Begin an export. Returns a job handle to pass to check export.""" job id = f"job {uuid.uuid4 .hex :8 }" JOBS job id = {"dataset": dataset, "status": "running"} return job id @mcp.tool def check export job id: str - dict str, str : """Check an export by its handle.""" return JOBS.get job id, {"status": "unknown"} Two calls, potentially two different machines, zero coordination. The handle is in the model's context, not in the transport. One gotcha worth internalising before you migrate: because MRTR removed the back-channel, ctx.elicit and ctx.session.create message raise NoBackChannelError on a modern connection. If your server asks the user mid-call, that code needs rewriting around the input-required round trip — it's the single most likely thing to break. Note This is v2 of the MCP Python SDK, the current stable release line. It is a major rework of the SDK, both to support the 2026-07-28 MCP specification https://modelcontextprotocol.io/specification/2026-07-28 and every earlier revision and to fix long-standing architectural issues. Coming from v1? See What's new in v2 https://py.sdk.modelcontextprotocol.io/whats-new/ for the tour of what changed and the migration guide https://py.sdk.modelcontextprotocol.io/migration/ for every breaking change. Not ready to migrate? v1.x lives on the v1.x branch https://github.com/modelcontextprotocol/python-sdk/tree/v1.x , continues to receive critical bug fixes and security patches, and is documented at pip install mcp now installs 2.x, keep a <2 upper bound on your requirement for example mcp =1.28,<2 until you've migrated.Something rough, confusing, or broken? Open an issue https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml or find us in python-sdk-dev on the MCP Contributors Discord https://discord.gg/6CSzBmMkjX . The documentation lives at https://py.sdk.modelcontextprotocol.io/. It has a Get https://py.sdk.modelcontextprotocol.io/get-started/ … The scaling story is the headline, but I think the deeper shift is that MCP stopped being a transport-flavoured protocol and became an HTTP-flavoured one. That means the boring, battle-tested layer of the web now applies to agent tooling: CDNs, edge workers, standard load balancers, cache-control semantics, header-based authorization at the gateway. It also narrows some real attack surface. The research on MCP security has been fairly damning, and a lot of it clusters around trust boundaries between independently operated components: Read those with a date-stamp in mind: they all predate the stateless core, so the session-layer threats they describe are partly answered by a protocol that no longer has a session layer. The prompt-level and supply-chain threats are entirely untouched. Stateless MCP is a scalability fix, not a security fix — treat any vendor claiming otherwise with suspicion. Migration checklist 2026-07-28 v2 for Python; pin exactly mcp.server.fastmcp.FastMCP → mcp.server.mcpserver.MCPServer stateless http / json response from the constructor to run / streamable http app ctx.elicit or create message around MRTR ttlMs / cacheScope to list responses iss RFC 9207 if you're a client; plan the DCR → CIMD move if you're a server structured content , next cursor , input schema If you've already migrated a production server, I'd like to hear what broke. My guess is it was the elicitation rewrite and not the sessions. Building AI integrations at Codiva. Comments and corrections welcome.