writing
Platform Engineering··9 min read

MCP Went Stateless. Your Load Balancer Just Got Its Job Back

The 2026-07-28 spec removes sessions and the handshake from MCP. Here's what actually changes for the people who have to run these servers.

Share

Key Takeaways

  • The 2026-07-28 spec makes MCP stateless at the protocol layer: the initialize handshake and the Mcp-Session-Id header are both gone.
  • Mcp-Method and Mcp-Name are now required headers on Streamable HTTP, so a gateway can route, rate limit and authorize on headers alone, with no body parsing.
  • Stateless protocol does not mean stateless server: hidden session state becomes an explicit handle the model passes back, visible in every trace and log line.
  • List results are cacheable via ttlMs and cacheScope, and deterministic ordering stops client reconnects from invalidating upstream prompt caches.
  • Turn off session affinity once your servers speak the new version, because sticky routing now concentrates traffic and works against you.

If you have ever deployed a remote MCP server behind a load balancer, you know the shape of the problem. The client does an initialize handshake, gets back an Mcp-Session-Id, and from that moment every subsequent request has to land on the same process that issued it. So you turn on session affinity at the ingress. Or you stand up Redis to share session state between replicas. Or you write a gateway plugin that cracks open the JSON body to find out which session a request belongs to before it can route it.

None of that is MCP's fault, exactly. It is what happens when a protocol designed for a local process talking to a local process over stdio gets stretched across a network. But every one of those workarounds is a smell, and if you run this stuff on Kubernetes you have probably felt it: your HPA scales up and the new pods sit there useless because nothing new gets routed to them; a rolling update drops sessions mid-conversation; your API gateway is doing body inspection on every single call.

Here is the shape of the thing you have probably been running:

architecture-beta
    group k8s(logos:kubernetes)[Kubernetes]

    service client(internet)[Client]
    service ingress(logos:nginx)[Ingress session affinity]
    service redis(logos:redis)[Redis session store] in k8s
    service pod1(server)[MCP pod A hot] in k8s
    service pod2(server)[MCP pod B idle] in k8s
    service pod3(server)[MCP pod C idle] in k8s

    client:R --> L:ingress
    ingress:R --> L:pod1
    pod1:B --> T:redis
    redis:R --> L:pod2
    redis:B --> T:pod3

On 28 July 2026 that entire category of problem got deleted from the spec.

What actually shipped

The 2026-07-28 specification is the largest revision of MCP since it launched, and the headline is a single sentence: MCP is now stateless at the protocol layer. It went out as a release candidate first, ran for ten weeks so SDK maintainers could validate against real workloads, and then shipped final with all four Tier 1 SDKs (TypeScript, Python, Go and C#) speaking the new version on day one.

architecture-beta
    service spec(server)[Spec July 2026]
    service ts(logos:typescript-icon)[TypeScript]
    service py(logos:python)[Python]
    service go(logos:go)[Go]
    service cs(logos:c-sharp)[C sharp]

    ts:R --> L:spec
    py:R --> L:spec
    go:L --> R:spec
    cs:L --> R:spec

Two changes do the heavy lifting. The initialize/initialized exchange is retired (SEP-2575), and the Mcp-Session-Id header is gone (SEP-2567). In their place, every request carries what it needs to describe itself: protocol version, client identity, and client capabilities travel inline in _meta on each call. If a client genuinely wants to know a server's capabilities before it does anything, there is a new server/discover RPC, but it is optional, not a gate you have to pass through first.

A request now looks roughly like this:

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

Look at those two headers, because they are the part I think platform people should care about most. Mcp-Method and Mcp-Name are now required on Streamable HTTP requests (SEP-2243). Your gateway can route, rate limit, authorize and meter on headers alone. No body parsing. No Lua plugin reaching into JSON to figure out whether this is a tools/list or a tools/call on your most expensive tool.

If you run Kong, Envoy, or anything that does header-based matching, you just got first-class MCP support for free. Rate limit tools/call differently from tools/list. Route a specific Mcp-Name to a dedicated pool because that one tool hits a database that cannot take the traffic. Put a WAF rule on it. This is ordinary HTTP routing again, and that is a much bigger deal than it sounds.

architecture-beta
    group k8s(logos:kubernetes)[Kubernetes]

    service client(internet)[Client]
    service gw(logos:kong)[Gateway routes on headers]
    service pod1(server)[MCP pod A] in k8s
    service pod2(server)[MCP pod B] in k8s
    service pod3(server)[MCP pod C] in k8s

    client:R --> L:gw
    gw:R --> L:pod1
    gw:R --> L:pod2
    gw:R --> L:pod3

The three other changes worth knowing

List results are cacheable. Responses from tools/list, prompts/list, resources/list and resources/read now carry ttlMs and cacheScope (SEP-2549), with deterministic ordering. Clients can cache tool catalogues instead of re-fetching them, and, this is the subtle one, stable ordering keeps upstream prompt caches from being invalidated every time a client reconnects. If you have been watching token costs on an agent that reconnects often, that is real money.

Multi Round-Trip Requests replace held-open streams. The old server-initiated calls (elicitation/create, sampling/createMessage, roots/list) needed a bidirectional stream to stay open, which is exactly what you cannot guarantee when any request can land on any pod. MRTR (SEP-2322) inverts it: the server returns resultType: "input_required" with the things it needs answered, and the client re-issues the original call with the answers attached. All the state travels in the payload. Which means remote servers can finally ask the user "are you sure you want to delete this?" without pinning a connection open to do it.

sequenceDiagram
    participant C as Client
    participant S as MCP Server (any pod)
    C->>S: tools/call delete_record
    S-->>C: resultType "input_required" (confirm?)
    Note over C,S: user answers, all state stays in the payload
    C->>S: tools/call delete_record + answers in _meta
    S-->>C: result

Tasks moved out of the core. Long-running work is now the io.modelcontextprotocol/tasks extension, redesigned around polling with tasks/get and a new tasks/update. It shipped experimentally in the November 2025 revision, production use surfaced enough problems that it got reshaped, and the extensions framework is now the formal home for that kind of thing: capabilities can stabilise as opt-in extensions before they ever move into the spec proper.

The trap: stateless protocol does not mean stateless server

This is the part I expect to break the most migrations, and it is the part the announcement blogs skate past.

Dropping the session from the transport does not mean your application no longer has state. It means the protocol has stopped pretending to manage it for you. If your server needs to carry something across calls (an open cursor, a multi-step workflow, an authenticated upstream connection), the guidance is to mint an explicit handle from a tool and have the model pass it back as an argument on subsequent calls.

That is a genuinely different design, and it is better for a reason that is easy to miss: the handle is visible to the model. It is in the tool arguments, in the trace, in the logs. Session state hidden inside a transport is invisible to everything except the process holding it, which is precisely why it was so hard to debug and impossible to load balance.

So the migration question is not "does my SDK support the new version". The Tier 1 SDKs preserved backwards compatibility and shipped beta support during the RC window. The question is: where does my server remember things between calls? Go and find every one of those places. Each is either a hidden session dependency you need to turn into an explicit handle, or a genuine shared store you need to keep, but consciously, at the application layer, not smuggled in through transport affinity.

Authorization got stricter too

Less headline-grabbing, more likely to bite you. Authorization servers should return the iss parameter per RFC 9207 and clients must validate it before redeeming a code (SEP-2468), closing an authorization-server mix-up hole. Client credentials are now bound to the issuer that minted them, so no reuse across authorization servers (SEP-2352). And Dynamic Client Registration is formally deprecated in favour of Client ID Metadata Documents. DCR still works for backwards compatibility, but it is on the way out.

sequenceDiagram
    participant C as Client
    participant AS as Authorization Server
    participant M as MCP Server
    C->>AS: authorization request
    AS-->>C: code + iss (RFC 9207)
    Note over C: validate iss before redeeming the code
    C->>AS: redeem code (credentials bound to issuer)
    AS-->>C: access token
    C->>M: request + token

There is also a fix for something that has annoyed anyone building a CLI or desktop client: clients now set application_type during DCR, so authorization servers stop rejecting localhost redirects (SEP-837). If you have ever stared at a redirect_uri error in your terminal client's OAuth flow, that was probably why.

What is deprecated, not removed

Roots, sampling and logging are deprecated (SEP-2577), along with the legacy HTTP+SSE transport. Deprecated, not gone: there is now a formal feature lifecycle policy with a minimum twelve-month window between deprecation and the earliest possible removal. New implementations should not adopt them; existing ones have a year of runway at minimum.

Honestly, the deprecation policy might be the most underrated thing in this release. A protocol that tells you when things can disappear is a protocol you can plan an upgrade against instead of reacting to.

What I would do this week

If you operate MCP servers, in rough order:

  1. Audit for hidden session state. Grep for anything keyed on a session id. Every hit is either a handle to make explicit or a store to own deliberately.
  2. Turn off session affinity at your ingress or load balancer once your servers speak the new version, because sticky routing now actively works against you by concentrating traffic.
  3. Emit and use the new headers. Make sure Mcp-Method and Mcp-Name are set, then move your gateway rules off body inspection and onto headers.
  4. Set sensible ttlMs values on your list responses. Do not leave catalogue caching on the table.
  5. Plan the CIMD move before DCR removal shows up on a roadmap you did not write.

The bigger picture

The framing I keep coming back to: MCP has stopped being a special kind of workload. It is stateless, cacheable, header-routable, and it scales on ordinary infrastructure. Every piece of operational knowledge you already have (load balancing, HPA, caching, gateway policy, OpenTelemetry tracing) now applies directly, with no protocol-specific workaround in between.

The protocol was never going to be the hard part forever. Now that transport has stopped being interesting, the difficulty moves where it was always heading: authorization, governance, and knowing what your agents are actually doing in production. Which, if you have been doing platform engineering for a while, is a very familiar place to end up.


Running MCP servers in production? I would like to hear where the migration bit you, especially if you found hidden session state somewhere surprising.

Chamod Perera is a Software Engineer II at Circles, a CNCF and CDF Ambassador, and lead of Kubernetes Sri Lanka & KCD Sri Lanka.

Chamod Shehanka
Author

Chamod Shehanka

Software Engineer II at Circles building cloud-native systems with Go and Kubernetes. CNCF & CD Foundation Ambassador, Jenkins GSoC mentor, and lead of Kubernetes Sri Lanka & GDG Sri Lanka.

Keep reading