The 2026-07-28 Revision

10 min read

C11
Deep Dive · MCP

Everyone summarises the 2026-07-28 revision as "MCP went stateless" — but deleting the handshake did not delete the agreement, it billed the agreement to every single request.

A server that talks to a 2026-07-28 client the way it did under 2025-11-25 is not deprecated, it is malformed: no initialize, no Mcp-Session-Id, no server-initiated requests, and every result that omits resultType or a cache hint is now out of contract. The revision removed the one place where negotiation, identity and server-to-client questions used to live, and every one of them had to reappear somewhere — per request, in params._meta, in new mandatory headers, and in a retry pattern that replaces server-initiated calls entirely. The migration is not "delete your session code"; it is that a per-connection agreement became a per-request obligation.

STEP 1

The handshake was not overhead — it was the only place the agreement could live.

The deletion list for 2026-07-28 is long and every item on it is breaking. Gone: the initialize request and the notifications/initialized that acknowledged it; the Mcp-Session-Id header, which the server minted and the client could terminate with a DELETE; HTTP GET on the MCP endpoint, and with it the standalone SSE stream; resources/subscribe and resources/unsubscribe; ping; logging/setLevel; notifications/roots/list_changed; Last-Event-ID and SSE event IDs, and therefore resumability; and the three server-initiated requests — roots/list, sampling/createMessage, elicitation/create — along with notifications/elicitation/complete and elicitationId. Tasks left core entirely and now live in the extension io.modelcontextprotocol/tasks.

Read that list as a changelog and it looks like housekeeping. Read it as an architecture change and it says one thing: the connection stopped being a place where facts could be stored. Under 2025-11-25 the handshake was the event that established what protocol version both sides spoke, what capabilities each side had, who the client was, and what log level it wanted — and the session id was the handle to everything that agreement implied. That was one round trip per connection, amortised over thousands of calls, which is exactly why it was cheap and exactly why removing it is not.

What the revision bought is real: any instance can serve any request, load balancers stop needing sticky routing, and a replica dying mid-conversation stops being an incident. The Streamable HTTP transport essay describes the operational bill that 2025-11-25 sessions incurred — sticky hashing on Mcp-Session-Id, shared session stores, replay retention windows — and 2026-07-28 deletes most of that bill. It just sends a different one, itemised per request, to the people writing servers.

STEP 2

params._meta carries the handshake now, and a request without it is malformed.

Capability negotiation did not disappear; it became per-request metadata. Every request carries a _meta object inside params with reverse-DNS-namespaced keys. Two are REQUIRED: io.modelcontextprotocol/protocolVersion, a string, and io.modelcontextprotocol/clientCapabilities. Two are optional: io.modelcontextprotocol/clientInfo, which clients SHOULD send anyway, and io.modelcontextprotocol/logLevel, which is what survives of logging/setLevel — the level that used to be set once for a session is now stated on each call.

POST /mcp HTTP/1.1
Host: mcp.example.com
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search_issues

{"jsonrpc": "2.0", "id": 7, "method": "tools/call",
 "params": {
   "name": "search_issues",
   "arguments": {"query": "flaky login test"},
   // Not optional decoration: this IS the handshake, restated.
   "_meta": {
     "io.modelcontextprotocol/protocolVersion": "2026-07-28",
     "io.modelcontextprotocol/clientCapabilities": {"elicitation": {}},
     "io.modelcontextprotocol/clientInfo": {"name": "acme-client",
                                                "version": "2.0.0"},
     "io.modelcontextprotocol/logLevel": "info"}}}

The enforcement is what makes this an obligation rather than a convention. A request missing a required _meta field is malformed: the server MUST reject it with -32602 (Invalid params) and, over HTTP, status 400. A server MUST NOT rely on a capability the client has not declared on that request — if it needs one that is absent, the answer is MissingRequiredClientCapabilityError, code -32021, carrying data.requiredCapabilities, again with HTTP 400. And on Streamable HTTP the protocol version is duplicated into the MCP-Protocol-Version header, where it MUST match the _meta value; a mismatch is 400 plus HeaderMismatch (-32020). Two copies of the same string, on every request, and disagreeing about them is a protocol error.

STEP 3

Per-connection state has no replacement — your session handle is now a tool argument the model picks.

This is the part that surprises teams mid-migration, because there is no migration path to find: per-connection state simply has no successor. The spec's rule is that state spanning multiple requests "MUST be referenced by an explicit identifier the client passes on each request", and it forecloses the obvious workaround in one sentence — "an open connection, such as a STDIO process, is not a conversation or session." A long-lived stdio subprocess is not a licence to keep state on the side; the wire contract is the same whether your transport happens to be persistent or not.

In practice the shape that falls out is this: the server mints a handle for whatever it used to keep per session — a cursor, a transaction, a workspace, an auth-scoped context — and returns it as an ordinary tool result. The model reads that result, and the next call threads the handle back as a tool argument. Which means your state handle now lives in the context window, is visible to the model, and is chosen by the model. It can be dropped, truncated out of context, hallucinated, or swapped for one seen earlier in the same conversation. Every handle a 2026-07-28 server issues therefore needs server-side validation that it belongs to this principal and is still live — the checks a session id got for free from the transport now belong to your tool implementations. It also changes what you test: the interesting cases are no longer "does the session survive a reconnect" but "what does the server do when the model passes back a stale, foreign, or invented handle", which is exactly the kind of contract test the MCP testing essay argues for over vibe-checking through an agent loop.

STEP 4

MRTR: the server returns its questions instead of asking them.

Deleting server-initiated requests would have deleted elicitation, sampling and roots with them, so the revision replaces the mechanism rather than the feature. Multi Round-Trip Requests (MRTR) inverts the direction: the server puts its questions in the result, the client answers them and retries the original call. Because the answers travel in a fresh request, no two round trips need to reach the same instance — which is the whole point. The spec is blunt about the status of the old way: "Servers MUST send server-to-client requests (such as roots/list, sampling/createMessage, or elicitation/create) using the MRTR pattern. The previous pattern of server-initiated requests is no longer supported. This is a breaking change."

// 1. The call. The server needs a decision it is not allowed to make.
{"jsonrpc": "2.0", "id": 7, "method": "tools/call",
 "params": {"name": "delete_branch",
            "arguments": {"branch": "release/4.2"},
            "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
                      "io.modelcontextprotocol/clientCapabilities": {"elicitation": {}}}}}

// 2. Not an answer. resultType says: ask the user, then come back.
{"jsonrpc": "2.0", "id": 7, "result": {
  "resultType": "input_required",
  "inputRequests": {
    "confirm": {"method": "elicitation/create",
                "params": {"message": "Delete release/4.2? It is not merged.",
                           "requestedSchema": {"type": "object",
                             "properties": {"ok": {"type": "boolean"}},
                             "required": ["ok"]}}}},
  // Opaque to the client. Attacker-controlled input to the server.
  "requestState": "v1.eyJzdWIiOiJ1c2VyLTkxIiwiZXhwIjoxNzk5fQ.9f2c81ae"}}

// 3. The retry: same method, answers attached -- and a DIFFERENT id.
{"jsonrpc": "2.0", "id": 8, "method": "tools/call",
 "params": {"name": "delete_branch",
            "arguments": {"branch": "release/4.2"},
            "inputResponses": {
              "confirm": {"action": "accept", "content": {"ok": true}}},
            "requestState": "v1.eyJzdWIiOiJ1c2VyLTkxIiwiZXhwIjoxNzk5fQ.9f2c81ae",
            "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
                      "io.modelcontextprotocol/clientCapabilities": {"elicitation": {}}}}}

Four constraints make this work and each one bites someone. MRTR is allowed only on prompts/get, resources/read and tools/call — nowhere else. The values in inputRequests MUST be an ElicitRequest, a CreateMessageRequest or a ListRootsRequest, and the client's inputResponses must be keyed identically. The JSON-RPC id MUST differ between the original request and the retry: these are two independent requests that happen to be about the same work, and a client that reuses the id has sent a duplicate, not a continuation. And requestState must be echoed byte-exact — clients MUST NOT inspect, parse or modify it. The sampling and elicitation essay covers what these three requests are for; MRTR changes only how they arrive.

requestState is round-tripped through a party you do not control, so the spec requires servers to treat it as attacker-controlled. If it influences authorization or business logic, servers MUST integrity-protect it (HMAC or AEAD) and MUST reject state that fails verification, and SHOULD embed the principal, a TTL and an identifier for the originating request. The failure mode if you skip this is not subtle: a blob that says "this user already approved the delete" is a forgeable approval, and you handed the forger the template.

STEP 5

What every server now owes on every response: server/discover, two headers, resultType, cache hints.

With no handshake to answer "what is this server", the revision adds server/discover and does not make it optional — "Servers MUST implement it." Two request headers become mandatory for the same reason a reverse proxy needs to see the method without parsing a body: Mcp-Method on all requests, carrying the value of method, and Mcp-Name on tools/call, resources/read and prompts/get, carrying params.name or params.uri. Non-ASCII values MUST use the Base64 sentinel format rather than being stuffed into a header raw. Routing, rate-limiting and audit logging can now happen at the edge; the cost is that a request missing them is not a well-formed request.

On the response side, every result object carries resultType — on the result, note, not on the JSON-RPC envelope, so it is result.resultType. The values are "complete", "input_required", and whatever extensions add (Tasks contributes "task"). Clients MUST treat an unrecognised value as invalid — and MUST treat an absent one as "complete". That second rule is the whole backwards-compatibility story in one line: a 2025-11-25 server that has never heard of resultType emits results a 2026-07-28 client still reads correctly, because absence means the old behaviour.

Then the caching hints, which are easy to skip and quietly expensive to skip. Results with resultType: "complete" from tools/list, prompts/list, resources/list, resources/read and resources/templates/list must carry ttlMs and cacheScope. ttlMs is an integer count of milliseconds and MUST be >= 0; cacheScope is "public" or "private". The default is the trap: an absent ttlMs means the client assumes 0, i.e. immediately stale. A server that forgets the hint has not opted out of caching — it has told every client to re-fetch its tool list on every turn, which is the one thing statelessness was supposed to make cheap. Wiring these into the server helpers described in building MCP servers in practice is a one-time change; noticing you forgot takes a traffic bill.

In the server/discover response, serverInfo lives inside _meta under io.modelcontextprotocol/serverInfo — it is not a top-level field of DiscoverResult. It moved there shortly before release, the TypeScript SDK shipped the old shape, and the result was hard connect failures rather than a graceful degradation. Do not trust SEP-2575's text for the final shape; read the released spec. And note that both clientInfo and serverInfo are self-reported, never verified by the protocol, and SHOULD NOT be relied on for security decisions.

STEP 6

The migration bill: three new error codes, one renumbering, and what "Deprecated" actually promises.

Three error codes are new in this revision, allocated for situations that could not previously arise: -32020 HeaderMismatch, -32021 MissingRequiredClientCapability, -32022 UnsupportedProtocolVersion. None is a renumbering of anything that ever shipped, so no client can have a handler waiting for them. Exactly one genuine renumbering exists: resource-not-found moved from -32002 to -32602. An implementation of 2026-07-28 MUST NOT emit -32002, but clients SHOULD keep accepting it from older servers — the asymmetry is deliberate, and a client that hard-fails on the legacy code breaks servers that are still perfectly compliant with their own revision. -32042 (URL elicitation required, a 2025-11-25-only code) is reserved and removed. The allocation policy that governs all of this: -32000–-32019 is legacy and new codes MUST NOT be allocated there, -32020–-32099 is reserved to the spec, and application codes SHOULD sit outside -32768–-32000 entirely.

A modern-only server still meets legacy traffic, and the spec says what to do with it rather than leaving it to taste. A GET or DELETE on the MCP endpoint gets 405 Method Not Allowed — the standalone SSE stream and the session-termination verb are both gone. An incoming Mcp-Session-Id header is ignored, and the server neither mints nor echoes session IDs. A Last-Event-ID header is ignored too, because streams are no longer resumable: if an SSE stream breaks, the client MUST re-issue the work as a new request with a new request ID. That last rule is worth pausing on, because it turns a transport-level retry into an application-level one — anything non-idempotent that used to be protected by replay now needs an idempotency story of its own.

Finally, the deprecations, which are widely misreported. Roots, Sampling, Logging and OAuth Dynamic Client Registration (RFC 7591) are all marked Deprecated in 2026-07-28, with an earliest removal of "the first revision released on or after 2027-07-28". That is an eligibility date, not a removal date: the policy says the feature becomes eligible then, and actual removal is a Core Maintainer decision taken at release preparation, which may happen later. Nothing has been removed under this policy yet. Write "eligible for removal no earlier than 2027-07-28" in your migration ticket, not "removed in July 2027" — the second sentence will make you rip out a working DCR flow ahead of a deadline that does not exist, and the OAuth 2.1 profile essay already gives you the better reasons to move off DCR on your own schedule.

Put the six steps together and the shape of the revision is clear. 2026-07-28 did not make MCP simpler; it made MCP's servers cheap to operate by making each request more expensive to construct and more demanding to answer. The agreement that used to be established once and referenced implicitly is now restated, validated and re-authorized on every call — which is a genuinely better default for infrastructure, and a genuinely larger migration than "we deleted the session code" implies.