Article · August 2026 · 10 min

MCP just deleted its own session. Good.

The biggest change to the protocol since launch is a removal - and the interesting part is everything that had to be invented to replace it. Four figures below are live: change them and the system changes with you.

The headline of this release is a deletion. That tells you most of what you need to know about the first design.

You know the moment. The server works perfectly on your laptop. You deploy it, scale it to three instances behind a load balancer, and the second request from the same client comes back 404. Session not found.

You didn't write that bug. You inherited it.

The 2026-07-28 spec deletes the cause. SEP-2575 removes the initialize handshake. SEP-2567 removes the Mcp-Session-Id header and the protocol-level session behind it. Every request is now independent.

But here's the thing about a release whose headline is a removal. Everything else in it exists to replace what the removed thing was quietly doing for you.

That's the part the summaries skip. So that's most of this post.

The session was never a session. It was a pin.

The old flow: you post initialize, the server mints a session id, and every request after that carries it.

Read that again, because the defect is hiding in it. The id doesn't identify a conversation. It identifies the process that issued it.

Scale to three instances and the second request lands somewhere that never saw the first. Restart a pod and its sessions die with it. Neither of those is exotic. They are just Tuesday.

You already know the workarounds, because you have probably shipped one. Sticky sessions, so the client keeps hitting the same box. Or Redis, so any instance can look the session up.

Both work. Both mean you bought and operated a piece of infrastructure so that a tool call could work.

Where can the second request land?

It works. The second request happens to hit the instance that ran initialize, so the session is there. This is the case your laptop always exercises, which is exactly why the problem stays hidden until you scale.

With the session gone, plain round-robin is simply correct. Cloudflare put the sharper version of it: “MCP itself no longer requires a Durable Object to speak the protocol.” Servers can scale faster on request-scoped infrastructure instead.

My own extrapolation, not theirs: if you are not holding a connection open, nothing stops a server idling at zero between calls. On a request-billed platform that is the difference between a bill and no bill.

Where the handshake actually went.

The handshake was not noise. It was establishing two real things: which protocol version is in play, and what the client can do. Delete it and that information has to live somewhere else.

It now rides in _meta on every request, under io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities. Clients should identify themselves the same way. Servers answer in kind in every result.

For the other direction there is a new call, server/discover, returning supported versions, capabilities and identity.

Get this one the right way round, because a lot of the coverage has it backwards. Servers MUST implement it. Clients MAY call it. It exists for up-front version selection. It is not a handshake you are required to do first, which is the whole point.

POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": { "location": "Seattle, WA" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}
One request, carrying everything the handshake used to establish. Adapted from the Streamable HTTP transport spec.

What does one tool call have to carry?

Three round trips before any work. The client sends initialize, the server mints a session id, the client confirms with notifications/initialized, and only then can it call a tool - carrying the id on every request after.

_meta keys: io.modelcontextprotocol/protocolVersion, clientCapabilities, clientInfo. Results answer with serverInfo.

You can still have state. You just have to name it.

The first reaction to “stateless” is usually: fine, but my server needs to remember things.

It still can. Stateless means the protocol stopped having an opinion about how.

The replacement is the pattern HTTP APIs have used since forever. Have a tool mint an explicit handle - a cart id, a run id, a browser id - and return it. The model passes it back as an ordinary argument on the next call.

That is better in every way that matters. It is visible in your tool schema. It is yours to expire and authorize. And it survives a pod dying, which a protocol session never did.

What you lose is the illusion that something else was handling this for you. It wasn't. It was pinning you to a process and letting you not think about it.

How a server asks you a question now.

Careful here, because the easy version of this is wrong. A server can still push at you: subscriptions/listen opens a stream that stays open and carries change notifications for as long as you want them.

What a server can no longer do is send you a request. The spec is blunt about it - a server must not send independent JSON-RPC requests on that stream or any other. So the entire class of server-initiated requests goes: sampling, roots, elicitation.

The replacement is Multi Round-Trip Requests, and it is genuinely clever.

Every result now carries a resultType. When the server needs something, it answers input_required with inputRequests, and attaches a requestState payload holding the context of that call. The client collects the answers and re-issues the original request with inputResponses and that state echoed back.

How does a server ask a question with no open stream?

The call arrives. You call a destructive tool. The balancer happens to route it to instance A. Nothing has been deleted yet.

A prompt is always the answer to something the user asked for, which closes the old hole where a server could interrupt out of nowhere.

Two things fall out of this that are easy to miss.

The user cannot be ambushed any more. A prompt is always the answer to something they asked for. Previously a server could interrupt out of nowhere - a bad experience, and a real security hole.

The retry does not need the instance that asked. The context travels in the payload, so any pod can finish work another one started. Which is the same property the session removal bought you, showing up again.

Now the part I like less. requestStateis your server's own context, handed to a client and handed back. That makes it attacker-controlled input on the return leg. Sign it, bind it to the caller and the original request, and give it an expiry - or you have built a replay bug and called it a protocol feature.

It also is not free. Every extra round trip is another copy of that state on the wire, and a conversation that needs five questions pays for it five times.

Work that outlives the request.

A tool that takes four minutes should not hold a connection for four minutes. Now it especially should not: SSE resumability went too. A dropped stream loses the in-flight request outright, and the client has to re-issue it with a new id.

So tasks graduated from experimental to an official extension, io.modelcontextprotocol/tasks. Write the work down somewhere durable, return a handle immediately, and let the client poll tasks/get or wait on subscriptions/listen.

The blocking tasks/result is gone. tasks/update is new, for sending input into a task that is already running.

What about work that takes minutes?

The turn is blocked. Holding a request open for the length of the job ties the conversation to one connection and one instance. Lose either and you lose the work, because stream resumability was removed too.

Tasks graduated from experimental to the official io.modelcontextprotocol/tasks extension in this revision.

Two habits borrowed from HTTP.

The method is in a header now. Every Streamable HTTP POST carries MCP-Protocol-Version and Mcp-Method, plus Mcp-Name when the call names a target - tools/call, resources/read, prompts/get. Your gateway can route and rate-limit on those without parsing a JSON body. That is the difference between an edge rule and a service.

And the headers cannot lie to you: a server must reject any request whose headers disagree with its body, with -32020 HeaderMismatch. Without that rule, a balancer routing on the header and a server executing the body are two sources of truth waiting to diverge.

Lists tell you how long they are good for. tools/list and its siblings return ttlMs and cacheScope.

cacheScope is the one to pay attention to. Marking a tool list private instead of publicis what stops a shared intermediary serving one tenant's tools to another.

It looks like a performance field. It is a correctness field.

One habit to pair with it: return tools/list in the same order every time. The spec only says you should, but a shuffled list defeats the cache you just turned on and the prompt-cache hits it was meant to buy you.

List endpoints no longer vary per connection, which is what makes caching them safe in the first place. If your server returned different tools to different sessions, that logic has to move into authorization.

The expensive part is not the urgent part.

The session removal gets the headline. This is the line to read twice: Roots, Sampling and Logging are deprecated.

Not urgent. Expensive. The session change breaks you today and the fix is small - mint a handle. These three keep working for a year and then cost you an architecture.

Removed means gone in this revision. Deprecated means it still works, and the spec has adopted a minimum twelve-month window before anything in that state is removed. The pressure is real, but it has a date on it.

FeatureStateWhat to do instead
initialize / notifications/initializedRemovedSend protocol version and capabilities in _meta on every request
Mcp-Session-Id headerRemovedMint your own handle in a tool and take it as an ordinary argument
roots/list, sampling/createMessage, elicitation/createReplacedReturn an input_required result and read the answer off the retry
ping, logging/setLevel, notifications/roots/list_changedRemovedSet log level per request via _meta; health-check over plain HTTP
resources/subscribe, resources/unsubscribe, the GET streamReplacedOne subscriptions/listen stream you opt in to by notification type
Resource not found: error -32002Renumbered-32602, Invalid Params. -32020 to -32099 is now reserved for the spec
Last-Event-ID, SSE event idsRemovedA broken stream loses the request; re-issue it with a new id
Roots, Sampling, LoggingDeprecatedTool parameters, your provider's API directly, and stderr or OpenTelemetry
HTTP+SSE transportDeprecatedStreamable HTTP
Dynamic Client RegistrationDeprecatedClient ID Metadata Documents

Sampling is the one that stings. It was the protocol's answer to “let the server borrow the client's model”, and the suggested migration is to stop borrowing and call a provider yourself.

If you touch OAuth, read this bit twice.

The authorization changes got no headlines and they are the ones with teeth.

Authorization servers now return an iss parameter (RFC 9207), and your client must check it against the issuer it expected before trading the code for a token. Skip that and you are open to a mix-up attack where a malicious authorization server gets you to redeem a code somewhere else.

Credentials are also bound to the issuer that minted them now. Key them by issuer, never reuse them against a different authorization server, and re-register when the server changes.

And Dynamic Client Registration is deprecated in favour of Client ID Metadata Documents. It still works, but new clients should not reach for it.

The upgrade protocol.

All four Tier 1 SDKs support the revision. In TypeScript the monolithic @modelcontextprotocol/sdk is split into core, client and serverat 2.0, with thin adapters for Express, Hono, Fastify and node's http.

There is a codemod:

npx @modelcontextprotocol/codemod@latest v1-to-v2 .

It handles the imports and the shift from setRequestHandler(XSchema, …) to setRequestHandler('x/method', …). Run it first. It will save you an afternoon. Run it at the package root too, not ./src - it rewrites package.json as well.

Now the part that will catch you out. Moving to v2 does not make you speak 2026-07-28. A hand-built Client or Server keeps talking the 2025-era protocol until you opt in explicitly: versionNegotiation on the client, createMcpHandler() for an HTTP server, serveStdio() for stdio.

So the packages are step one and the opt-in is step two. Ship after step one and you have done a refactor, not an upgrade.

Then do the part it cannot do. A codemod has no idea where your state lives. It will happily hand you code that compiles and is wrong the moment you run two instances.

So do that bit on paper, before you touch the packages. List everything your session was holding. For each item, decide: explicit handle, ordinary argument, or authorization claim?

Answer that and the rest really is renames.

The verdict.

The version of this release that gets shared is “MCP is stateless now”. True, and not the point.

The point is that MCP stopped asking for infrastructure of its own. A round-robin load balancer, a cache, a header-aware gateway, a job queue. You already run all of those. More importantly, you already know how they fail.

The cost is honest and it is front-loaded: state you were getting by accident is state you now have to name.

That is a better problem to have. And it is the kind that only gets more expensive the longer you leave it.

The 2026-07-28 changelog is the primary source for everything above, and it is short enough to read in one sitting. Do that before you start.