DEV Community

@lukeocodes πŸ•ΉπŸ‘¨β€πŸ’»
@lukeocodes πŸ•ΉπŸ‘¨β€πŸ’»

Posted on • Originally published at lukeocodes.dev on

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.

Under 2025-11-25, the initialize handshake pins a client to whichever server instance issued its session ID, so horizontal deployments need sticky routing and a shared session store. Under 2026-07-28, every request is self-contained and a plain round-robin load balancer can send it anywhere.

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()

Enter fullscreen mode Exit fullscreen mode

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"}]}}

Enter fullscreen mode Exit fullscreen mode

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"}}

Enter fullscreen mode Exit fullscreen mode

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"}}

Enter fullscreen mode Exit fullscreen mode

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"}}

Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
sandrog profile image
Sandro Garcia

Great breakdown, Luke. This release validates an architecture I've been running in production for a while.

I built IRC-A (Internet Relay Chat for Agents) precisely because I saw the session-state problem coming the moment MCP servers went behind load balancers. My design makes the exact same bet the 2026-07-28 spec is now making: the protocol should not manage state for you.
A few things I did that this release now canonizes:

  1. No transport-level sessions. I never used Mcp-Session-Id or an initialize handshake. Instead, every request carries a cryptographically signed DET (Delegated Execution Token) minted by my BFA Gateway. The receiver validates it offline with the Gateway's public keyβ€”no round-trip, no shared session store, no sticky sessions.

  2. Explicit handles by default. The article mentions the "explicit-handle pattern" as something you should have been using anyway. That's literally my core mechanism: the Gateway returns a scoped token restricted to a specific function and parameters (e.g., fetch_customer_credit_score(customer_id="882")). The model threads the handle through, and the execution layer enforces it locally. State is visible to the model, not hidden in transport metadata.

  3. Stateless cognitive agents, isolated execution. My agents are purely reasoning nodes with no database credentials or API keys. All data access is delegated to FastMCP tool servers that run in isolated sandboxes. When MCP removes Roots and Sampling, it's converging toward the same boundary I drew from day one: the agent should not know the ecosystem it runs in.

  4. Task handling without protocol-level tasks. I never built on the experimental Tasks API.
    Instead, I use A2A delegation with TTL-bound DETs and a visited_nodes trace to kill circular delegation loops. The new Tasks extension (polling via tasks/get) is conceptually similar, but my approach keeps the Gateway out of the data path entirely after discovery.

The one place I diverge: IRC-A runs its own cryptographic registration and discovery layer (challenge-response over Ed25519, semantic routing via FAISS). So while I'll add Mcp-Method and Mcp-Name headers for compatibility with official MCP clients, my transport layer doesn't fundamentally change. The spec just caught up to what I learned the hard way: anything implicit that the caller can't see becomes a support ticket.

For anyone migrating this weekend: if you're hand-rolling your server, the explicit-handle pattern is not just a migration pathβ€”it's a better architecture.

I wrote a whitepaper on how I got there; happy to share if it helps.

Solid write-up. The changes are good, and the migration is real work, but the destination is worth it.