MCP goes stateless on Monday. Here's what breaks and what to do about it
The Model Context Protocol ships its biggest revision since launch on Monday. The 2026-07-28 release candidate went up this week and it removes the initialize handshake, removes the Mcp-Session-Id header, requires two new HTTP headers on every Streamable HTTP request, and deprecates Roots, Sampling, and Logging outright. If you maintain an MCP server, especially a hand-rolled one that isn’t riding an official SDK, this is a breaking release and you have a weekend to read it.
I spent yesterday going through the RC and the draft changelog, then wrote a tiny stateless handler to get a feel for the new shape. The code and its actual output are below. Short version: the changes are good, the migration is real work, and the explicit-handle pattern they’re pushing you towards is one you should probably have been using anyway.

What actually changed?
MCP was designed for one AI app talking to one local process over stdio. A persistent session made sense there. It stopped making sense the moment people put MCP servers behind load balancers, and the workarounds (sticky sessions, shared session stores, gateways doing deep packet inspection to route on the JSON body) were infrastructure problems the protocol created for itself. The Register’s coverage calls the fix “the biggest overhaul since launch” and warns that homebrew implementations face a slog. Both true.
The mechanics, from the changelog. The initialize/initialized handshake is gone (SEP-2575). Protocol version, client identity, and capabilities now travel in _meta on every request, under io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientInfo, and io.modelcontextprotocol/clientCapabilities. The Mcp-Session-Id header is gone too (SEP-2567). A new server/discover method, which servers MUST implement, replaces the capability exchange that used to happen at connection time.
Two headers become mandatory on Streamable HTTP POSTs: Mcp-Method and Mcp-Name (SEP-2243). The point is routing. A gateway or rate limiter can now see it’s a tools/call to search without parsing the body, and servers reject requests where headers and body disagree. List and read results grow required ttlMs and cacheScope fields (SEP-2549), so a client finally knows how long a tools/list response is fresh and whether an intermediary may cache it. Every result now carries a required resultType field, "complete" for ordinary results.
There’s also a proper error code allocation policy now: -32000 to -32019 stays implementation-defined, -32020 to -32099 is reserved for the spec. HeaderMismatch is -32020 and UnsupportedProtocolVersion is -32022. Small thing, but if you’ve ever debugged two servers using the same custom code for different failures, not small at all.
What the new wire format looks like, actually running
Reading a spec diff is one thing. I wanted to see the request shape, so I wrote a minimal stateless handler in Python. This is not an SDK and not production code, it’s ~100 lines of http.server implementing just enough of the RC semantics to poke at: self-contained requests, version checks from _meta, header/body mismatch rejection, and the new cache fields.
"""Minimal stateless MCP-style HTTP handler for the 2026-07-28 request shape."""
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
PROTOCOL_VERSION = "2026-07-28"
TOOLS = {
"echo": {
"name": "echo",
"description": "Echo back the input string",
"inputSchema": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
}
}
def error(rpc_id, code, message):
return {"jsonrpc": "2.0", "id": rpc_id, "error": {"code": code, "message": message}}
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
rpc_id = body.get("id")
# Every request is self-contained: version travels in _meta, not a handshake
meta = body.get("params", {}).get("_meta", {})
version = meta.get("io.modelcontextprotocol/protocolVersion") or self.headers.get(
"MCP-Protocol-Version"
)
if version != PROTOCOL_VERSION:
# -32022 UnsupportedProtocolVersion under the new allocation policy
return self.reply(error(rpc_id, -32022, f"Unsupported protocol version: {version}"))
# SEP-2243: Mcp-Method / Mcp-Name headers must agree with the body
if self.headers.get("Mcp-Method") != body.get("method"):
# -32020 HeaderMismatch
return self.reply(error(rpc_id, -32020, "Mcp-Method header does not match body"))
if body["method"] == "server/discover":
return self.reply(
{
"jsonrpc": "2.0",
"id": rpc_id,
"result": {
"resultType": "complete",
"protocolVersions": [PROTOCOL_VERSION],
"serverInfo": {"name": "demo", "version": "0.1.0"},
"capabilities": {"tools": {}},
},
}
)
if body["method"] == "tools/list":
# SEP-2549: list results now carry ttlMs and cacheScope
return self.reply(
{
"jsonrpc": "2.0",
"id": rpc_id,
"result": {
"resultType": "complete",
"tools": sorted(TOOLS.values(), key=lambda t: t["name"]),
"ttlMs": 300000,
"cacheScope": "public",
},
}
)
if body["method"] == "tools/call":
name = body["params"]["name"]
if self.headers.get("Mcp-Name") != name:
return self.reply(error(rpc_id, -32020, "Mcp-Name header does not match body"))
if name == "echo":
text = body["params"]["arguments"]["text"]
return self.reply(
{
"jsonrpc": "2.0",
"id": rpc_id,
"result": {
"resultType": "complete",
"content": [{"type": "text", "text": text}],
},
}
)
return self.reply(error(rpc_id, -32601, "Method not found"))
def reply(self, payload):
data = json.dumps(payload).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *args):
pass
if __name__ == "__main__":
HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()
A tool call is now one request. No handshake first, no session header, everything the server needs in the envelope:
curl -s http://127.0.0.1:8765/mcp \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/call" \
-H "Mcp-Name: echo" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"echo","arguments":{"text":"no handshake, no session"},
"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"curl-demo","version":"1.0"}}}}'
{"jsonrpc": "2.0", "id": 1, "result": {"resultType": "complete", "content": [{"type": "text", "text": "no handshake, no session"}]}}
Lie in the headers and the server throws it back. Here the header says tools/list while the body says tools/call:
{"jsonrpc": "2.0", "id": 2, "error": {"code": -32020, "message": "Mcp-Method header does not match body"}}
Show up with last year’s protocol version and you get the new dedicated error instead of something vendor-flavoured:
{"jsonrpc": "2.0", "id": 3, "error": {"code": -32022, "message": "Unsupported protocol version: 2025-11-25"}}
And tools/list now tells the client it can cache the answer for five minutes and that a shared cache may hold it too:
{"jsonrpc": "2.0", "id": 4, "result": {"resultType": "complete", "tools": [{"name": "echo", "description": "Echo back the input string", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}], "ttlMs": 300000, "cacheScope": "public"}}
All four of those responses are pasted from a real run, not typed from memory. The full RC has more to it (the subscriptions/listen stream that replaces the GET endpoint, the Multi Round-Trip Request pattern that replaces server-initiated requests), but the requests above are the day-to-day shape of the thing.
My server keeps state across calls. Am I stuck?
No, and this is the part of the RC I like most. The guidance is to do what HTTP APIs have done forever: mint an explicit handle from one tool call and have the model pass it back on the next. create_checkout returns a basket_id, add_shipping takes a basket_id. The spec authors argue this is better than hidden session state, not just a workable substitute, because the model can see the handle, reason about it, and compose it across tools. Session state buried in transport metadata was invisible to the model by design.
They’re right, and I’d go further: if your MCP server’s behaviour depended on protocol-level session state, the model never had the full picture of your tool’s semantics, and you were relying on the client SDK to paper over it. We went through a version of this at SpeechifyAI when we added date-pinned API versioning. Anything implicit that the caller can’t see ends up as a support ticket. Explicit beats implicit in API design roughly every time someone tests the question.
The Tasks story is the one to watch if you run long jobs. Tasks shipped experimental in 2025-11-25, and production use redesigned it into an extension (io.modelcontextprotocol/tasks): a server answers tools/call with a task handle, the client polls tasks/get and feeds input through tasks/update. The blocking tasks/result is gone and so is tasks/list, which couldn’t be scoped safely without sessions. If you built against the experimental API, that’s a migration, not a version bump.
What’s deprecated, and how long have you got?
Three core features enter deprecation under the new feature lifecycle policy: Roots, Sampling, and Logging. The suggested migrations are, in order, pass paths as tool parameters or server config, call your LLM provider’s API directly instead of asking the client to sample for you, and log to stderr or OpenTelemetry. The old HTTP+SSE transport (deprecated in practice since March 2025) is formally reclassified as deprecated, and RFC 7591 Dynamic Client Registration is deprecated in favour of Client ID Metadata Documents.
The lifecycle policy is honestly the sleeper feature of this release. Deprecated features keep working through a minimum window before removal and there’s a public registry of deprecated features you can check instead of diffing spec versions. Protocols that people build businesses on need boring, predictable change management more than they need new capabilities, and MCP just got some.
Weekend homework, if this is your codebase:
- read the RC announcement and the draft changelog against your implementation
- if you’re on an official SDK, check its tracking issue on the release milestone before writing any code yourself
- grep your server for session-dependent behaviour and sketch the explicit handles that replace it
- if you use Roots, Sampling, or protocol Logging, start the migration now while it’s a deprecation and not a removal
- check what your gateway or load balancer does with unknown
Mcp-*headers before your first stateless deploy
The final spec lands July 28. The RC is out now, which means the gap between “I read about it” and “it broke my integration” is exactly one weekend. Better use of a Friday than most things I’ll suggest this year.
FAQ
When does MCP 2026-07-28 take effect?
The release candidate is available now and the final specification ships on July 28, 2026. Existing servers on 2025-11-25 don’t stop working on that date, but the new version contains breaking changes, so clients and SDKs will move over time and the deprecation clock on Roots, Sampling, and Logging starts under the new feature lifecycle policy.
Does stateless MCP mean my server can’t keep any state?
No. The protocol stops managing state for you; it doesn’t stop you managing it yourself. The recommended pattern is explicit handles: return an identifier like a basket_id from one tool call and accept it as an argument on later calls. The model threads it through, which also makes the state visible to the model instead of hidden in transport metadata.
Do I have to rewrite my MCP server this weekend?
If you’re on an official SDK, mostly no. Wait for the SDK release that targets 2026-07-28 and follow its migration notes, tracked on the project’s release milestone. If you hand-rolled your transport layer, yes, budget real time: the handshake removal, the required Mcp-Method and Mcp-Name headers, resultType on every result, and the new error codes all touch code you own.
What replaces Roots, Sampling, and Logging?
Pass directories and files as tool parameters, resource URIs, or server configuration instead of Roots. Call LLM provider APIs directly from your server instead of Sampling. Write to stderr for stdio servers or adopt OpenTelemetry instead of protocol-level Logging. All three keep working during the deprecation window, but new implementations shouldn’t adopt them.
Why were the Mcp-Method and Mcp-Name headers added?
Routing and policy without body inspection. A load balancer, gateway, or rate limiter can act on the operation (say, throttling tools/call to one expensive tool) by reading a header instead of parsing the JSON-RPC body. Servers must reject requests where the headers and body disagree, which closes the gap where infrastructure routes on one value and the server executes another.