← Back to blog

MCP 2.0: the protocol stopped pretending your agent lives on one machine


Protocol notes
mcp agents protocol infrastructure

TL;DR

MCP was designed for a server running on your laptop and then deployed to a datacenter, where every assumption it made about sessions turned into an ops problem. The 2026-07-28 revision — what everyone is calling MCP 2.0 — deletes the handshake, deletes the session ID, mirrors routing information into HTTP headers, and replaces server-initiated prompts with a retry pattern. The result is a protocol an ordinary load balancer can handle without knowing what MCP is.

If your server runs behind stdio on one machine, this barely touches you. If it runs remotely for more than one user, this is the difference between needing Redis and not.

First, the naming

There is no document anywhere called “MCP 2.0.” The spec is date-versioned: the current revision is 2026-07-28, and the one before it was 2025-11-25.

The “2.0” comes from the SDKs, which all had to break their public APIs to implement it. Python’s mcp went to 2.0, the C# ModelContextProtocol packages went to 2.0.0, and the TypeScript SDK exploded the monolithic @modelcontextprotocol/sdk into @modelcontextprotocol/server, /client, /core, and friends at 2.0.0. Go, notably, shipped the same spec as v1.7 with no major bump.

So “MCP 2.0” is shorthand for “the revision that forced every SDK to cut a major version.” That’s a reasonable thing for a name to mean.

What the original protocol assumed

MCP started as a way to plug a local process into a local client over stdio. In that world, a session is free. There is one server, one client, one pipe between them, and the pipe is the session. You can hold state in a variable.

So the protocol did what any protocol would: it opened with a handshake.

POST /mcp                          → initialize
                                   ← 200, Mcp-Session-Id: 7f3a...
POST /mcp  Mcp-Session-Id: 7f3a... → notifications/initialized
                                   ← 202
POST /mcp  Mcp-Session-Id: 7f3a... → tools/call
                                   ← 200, result

Three round trips, and the first two exist only to establish that the third one is allowed to happen. The server now holds negotiated capabilities and protocol version against 7f3a..., and every later request has to come back to the instance holding that memory.

Why that breaks the moment there are two servers

Put that behind a load balancer and the failure is immediate and stupid: a round-robin balancer has no idea which container is holding session 7f3a.... Google’s writeup on the migration puts it plainly — standard load balancers don’t know which instance holds which in-memory session, so reconnects fail at random.

Your options were all bad:

  • Sticky sessions. Now traffic is unevenly distributed and autoscaling fights you, because you can’t drain a pod that’s holding live sessions.
  • Shared session store. You just added Redis to a thing that answers “what’s the weather in Seattle.”
  • Deep packet inspection at the gateway. Your load balancer now parses JSON-RPC bodies to make routing decisions, which is exactly the layering violation load balancers were invented to avoid.

And none of it survives a pod restart. Cloudflare’s account of running MCP at scale is the same story from the other side: a well-behaved remote MCP server meant sticky routing, held-open streams, and message replay — strictly more complexity than a normal web server, for strictly less capability.

The protocol was making every deployment pay for an assumption that stopped being true the moment the server moved off your laptop.

Change 1: every request now carries its own paperwork

The handshake is gone. So is Mcp-Session-Id. A tools/call in 2026-07-28 is one self-describing POST:

POST /mcp HTTP/1.1
Content-Type: application/json
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": {}
    }
  }
}

Everything the handshake used to establish — protocol version, who’s calling, what the client can do — rides along in _meta on every single request. Servers identify themselves back the same way, in the result’s _meta.

That one change is the whole point. Any instance can answer any request. Scale to zero, restart a pod mid-conversation, round-robin across three regions — nothing to lose, because there was nothing being held.

There’s a new server/discover RPC if a client wants to ask about versions and capabilities up front, but it’s optional. You can go straight to calling a tool.

Change 2: the address moved to the outside of the envelope

The second change looks cosmetic and isn’t. Streamable HTTP POSTs now require two headers that mirror what’s in the body:

Mcp-Method: tools/call
Mcp-Name: execute_sql

Before this, “rate-limit execute_sql to 10/min but leave tools/list alone” meant your gateway had to parse the JSON body. Now it’s a header match — the same thing every WAF, rate limiter, and observability tool has done since forever.

Servers can go further and promote individual tool parameters into headers, by annotating the input schema:

{
  "name": "execute_sql",
  "inputSchema": {
    "type": "object",
    "properties": {
      "region": { "type": "string", "x-mcp-header": "Region" },
      "query":  { "type": "string" }
    }
  }
}

which produces Mcp-Param-Region: us-west1 on the wire, and lets an edge router send that call to the right region without ever opening the body.

The security detail I like here: two sources of truth is a vulnerability, not a convenience. So the spec requires servers to verify that headers match the body and reject mismatches with 400 and error -32020 (HeaderMismatch). Without that rule, you could route on one value and execute on another — which is a nice way to describe an entire class of bypass.

Change 3: mid-call prompts became retries

This is the change that will cost you the most work, and it’s the one worth understanding properly.

Sometimes a tool can’t finish without asking you something. Old MCP handled this by having the server send a request back down an open stream — elicitation/create for “what’s your GitHub username,” sampling/createMessage for “let the client’s model answer this.” That requires a live bidirectional connection, which requires a session, which is the thing we just deleted.

The replacement is Multi Round-Trip Requests, and it works like a web form that fails validation.

The server doesn’t ask a question. It returns a result that means not done yet:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "github_login": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "Please provide your GitHub username",
          "requestedSchema": {
            "type": "object",
            "properties": { "name": { "type": "string" } },
            "required": ["name"]
          }
        }
      }
    },
    "requestState": "AEAD-protected blob"
  }
}

The request is over. The connection can close. The client asks the user, then re-sends the original call — new JSON-RPC id, same arguments, plus the answers and that opaque requestState string echoed back untouched.

The server that handles the retry has never seen the first request and doesn’t need to. Everything it knew is in the blob it handed out. That’s the trick: the server’s memory is externalized into a ticket the client carries.

Two consequences worth internalizing:

requestState is attacker-controlled input. It round-trips through a client you don’t control. The spec is blunt about it: if it influences authorization or business logic, integrity-protect it (HMAC or AEAD), bind it to the authenticated principal, bind it to the originating request, and give it a short expiry. A signed blob you don’t verify is just a cookie with extra steps.

Retries and side effects need thought. The same tool call now arrives more than once by design. If step one already charged a card, you own the idempotency problem.

Also note: this only applies to tools/call, resources/read, and prompts/get. Servers must not return input_required for anything else.

The mental model that matters: stateless protocol ≠ stateless application

This is the sentence I’d tattoo on the migration guide. Your app can absolutely have state. The protocol just refuses to hold it for you.

The pattern is explicit handles. Instead of the server remembering “this session opened a cursor,” a tool mints an ID and returns it, and the model passes it back like any other argument:

tools/call open_query  { "sql": "select * from events" }
        →  { "cursor": "cur_9f21", "rows": [...], "hasMore": true }

tools/call fetch_more  { "cursor": "cur_9f21" }
        →  { "rows": [...], "hasMore": false }

Nothing about the transport changed between those two calls. They can hit different machines. The handle is a normal string in a normal argument, and where its state actually lives — Postgres, Redis, an encrypted blob — is now your decision, made once, in your application, instead of being smeared across the protocol.

Most “but I need state” objections dissolve into this pattern. It’s more typing and considerably less operational grief.

What else went in the box

The quieter changes, roughly in order of how likely they are to bite you:

  • No more SSE resumability. Last-Event-ID and event IDs are gone. A dropped stream loses the in-flight request, and the client must re-issue it as a new request with a new id. Replay buffers were a real source of complexity; now they’re the client’s retry loop.
  • Notifications consolidated. The standalone GET stream and resources/subscribe are replaced by one subscriptions/listen POST whose response stream stays open and carries only the notification types you opted into. Per-request notifications like notifications/progress still ride the response stream of the request they belong to.
  • Cacheable lists. tools/list, prompts/list, resources/list and resources/read now carry ttlMs and cacheScope. Servers are also asked to return tools in a deterministic order — because an unstable tool list silently busts the LLM’s prompt cache upstream, which is a real cost line.
  • Auth hardening. Authorization servers should return iss per RFC 9207 and clients must validate it before redeeming a code, which closes the AS mix-up hole. Client credentials are now bound to their issuing authorization server. Dynamic Client Registration is deprecated in favour of Client ID Metadata Documents.
  • Tasks left the core. Long-running work moved to the io.modelcontextprotocol/tasks extension, redesigned around polling (tasks/get, tasks/update) instead of a blocking result call.
  • Deprecated: Roots, Sampling, Logging, the old HTTP+SSE transport, and DCR. Suggested replacements are unglamorous and correct — pass paths as tool parameters instead of Roots, call your LLM provider directly instead of Sampling, log to stderr or OpenTelemetry instead of Logging.
  • ping, logging/setLevel, and roots list-changed notifications: removed. Log level is now a per-request _meta field.

There’s also a new feature lifecycle policy — Active, Deprecated, Removed, with a minimum twelve-month deprecation window. Given how fast this ecosystem has been rewriting itself, a written promise about removal timing might be the most valuable thing in the release.

Where you’d actually apply this

Upgrade now, real payoff:

  • Remote servers behind a load balancer. This is the whole point. You get to delete sticky-session config, session stores, and the “why did that reconnect 500” class of bug.
  • Serverless. Cloudflare’s position is that MCP servers no longer need Durable Objects and can run as ordinary request-scoped Workers; Google’s is that Cloud Run and Cloud Functions can now scale MCP servers to zero. Both were previously awkward-to-impossible, because scale-to-zero and held-open sessions are opposites.
  • Anything with a gateway in front. Header-based routing, metering, and WAF rules stop requiring MCP-aware middleware.
  • Multi-tenant / multi-region tools. x-mcp-header promoting a region or tenant parameter into a header is genuinely nice — edge routing on a tool argument, no body parsing.

Upgrade the SDK, but don’t rush the architecture:

  • Local stdio servers. The transport changes mostly don’t apply. Take the SDK bump for the deprecation runway and the auth fixes; there’s no scaling win to chase.
  • Anything leaning hard on Sampling or Roots. You have twelve months, and the replacements are straightforward, but they’re rewrites rather than renames.

The migration itself is mostly mechanical. TypeScript ships a codemod (npx @modelcontextprotocol/codemod@latest v1-to-v2 .) that handles the package split and the .tool().registerTool() rename. Python is mcp 2.x, C# is ModelContextProtocol 2.0.0 with Stateless = true as the HTTP default, Go is v1.7. Both major SDKs keep down-level protocol support, and Cloudflare’s guidance is to serve both eras from the same /mcp endpoint, let old sessions drain, and delete the legacy path later. There is no flag day.

The part that isn’t mechanical is the part you’d expect: anywhere you relied on session identity, and anywhere a tool now has to survive being called twice.

The honest cost

Worth reading the skepticism alongside the release notes. David Soria Parra, on the Anthropic technical staff, told The Register that “a lot of things that made MCP are gone,” that the new design “makes things on the wire a bit more complicated than they used to be,” and — the line I’d underline before scheduling this work — “if you built your own implementation, it’s going to be a lot of uplift.”

That’s the trade. The wire got busier: _meta on every request, mirrored headers, resultType on every result, retries where there used to be a live conversation. In exchange, the deployment got dramatically simpler, and the complexity moved from your infrastructure into a library that four SDK teams maintain on your behalf.

For anyone running MCP on one machine, that trade is neutral at best. For anyone running it as a service, it isn’t close.

What I take from it

The interesting thing here isn’t statelessness. It’s that MCP spent a year discovering it had shipped a local-first protocol into a remote-first world, and then did the expensive thing instead of the polite thing.

Most protocols in that position add a session-affinity extension and call it a day. This one deleted the session and made every request stand on its own, which is the same conclusion the web reached about cookies-versus-tokens, and REST reached about server-held state, and every distributed system eventually reaches: the thing you’re holding in memory is the thing preventing you from having two of anything.

Twelve months is a generous runway. Take the SDK bump early, and treat the architectural half as scheduled work rather than an emergency.

Sources

© 2026 Dr. Bin Liu