The 2026-07-28 MCP revision removes protocol-level sessions and the Mcp-Session-Id header (SEP-2567), removes the initialize handshake (SEP-2575), and removes SSE resumability. A tool call that took three round trips to set up now takes one POST. Ship a dual-era server first: legacy clients have no fall-forward mechanism, so a modern-only cutover breaks every pinned internal client at once. Roots, Sampling, and Logging are deprecated with a 12-month floor, so the earliest removal is the first revision released on or after 2027-07-28. The two changes that fail silently are cacheScope defaulting the wrong way for entitlement-filtered tool lists, and per-request logLevel: omit it and your server emits no notifications/message at all, with no error.
Here is the entire migration, on the wire. Before the 2026-07-28 revision, a client needed three round trips before it could call a tool: POST initialize, POST notifications/initialized, and a GET to open the server-to-client stream, all pinned to one Mcp-Session-Id. After it, there is one POST. No handshake, no session header, no GET endpoint.
That compression is the headline of the MCP stateless migration, and it is genuinely good news for anyone running self-hosted MCP behind a load balancer. Protocol-level sessions were the reason you needed sticky routing and a shared session store in the first place. GitHub shipped support for the new revision on 2026-07-23 and stated in its own announcement that it removed its Redis sessions outright: database writes on initialize gone, database reads gone from every call. For a private-deployment fleet, that is a real reduction in blast radius. You can delete the affinity config and run round-robin.
The trap is that nothing breaks on day one. The specification supports dual-era servers, the Tier 1 SDKs ship compatibility shims, and your existing clients keep connecting. So the failure mode is not a loud outage on cutover day. It is three quiet regressions that land weeks later: entitlement-filtered tool lists that a shared cache is now authorized to serve across tenants, an audit correlation key that no longer exists, and a log stream that goes silent without emitting a single error. This post covers what actually breaks in a running server, in the order you should touch it.
One honesty note. At the time of writing, the dated specification URL had not gone live and the 2026-07-28 content still sat under the draft path in the spec repository, where LATEST_PROTOCOL_VERSION already reads 2026-07-28. Everything below is verified against that source, and if the final dated revision disagrees, it wins.
What changed on the wire
Before, on 2025-11-25:
# 1. handshake, server mints the session
POST /mcp HTTP/1.1
Content-Type: application/json
{"jsonrpc":"2.0","id":0,"method":"initialize","params":{...}}
HTTP/1.1 200 OK
Mcp-Session-Id: 1868a90c-ff5b-4a4b-9b0e-3f7c2e1d5a04
# 2. confirm
POST /mcp HTTP/1.1
Mcp-Session-Id: 1868a90c-ff5b-4a4b-9b0e-3f7c2e1d5a04
{"jsonrpc":"2.0","method":"notifications/initialized"}
# 3. open the server-to-client stream (resumable)
GET /mcp HTTP/1.1
Accept: text/event-stream
Mcp-Session-Id: 1868a90c-ff5b-4a4b-9b0e-3f7c2e1d5a04
Last-Event-ID: 42
# 4. finally, the work
POST /mcp HTTP/1.1
Mcp-Session-Id: 1868a90c-ff5b-4a4b-9b0e-3f7c2e1d5a04
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather",...}}After, on 2026-07-28, the whole thing is step 4:
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": {}
}
}
}Two new headers are required for compliance: Mcp-Method on every request, and Mcp-Name on tools/call, resources/read, and prompts/get, sourced from params.name or params.uri. The stated rationale is that intermediaries (load balancers, gateways, observability tooling) can route and inspect requests without parsing the body. The MCP-Protocol-Version header must equal the _meta value or the server must return 400 with HeaderMismatch.
Here is the full breaking-change set, with the SEP numbers so you can look each one up in the changelog:
Error codes were renumbered at the same time, which matters if you match on literals anywhere:
The allocation policy also changed: -32000 through -32019 stays implementation-defined with existing SDK usage grandfathered, and -32020 through -32099 is now reserved for the specification.
| SEP | What went away | What replaces it | Blast radius |
|---|---|---|---|
| 2567 | Protocol sessions, Mcp-Session-Id | Server-minted handles as ordinary tool arguments | Per-tenant list filtering, LB affinity, audit correlation |
| 2575 | initialize / notifications/initialized | Per-request _meta version and capabilities | Every client that pins the handshake |
| 2575 | (new) | server/discover, MUST implement | Every server |
| 2575 | HTTP GET, resources/subscribe | subscriptions/listen, one long-lived POST stream | Proxy timeouts, keep-alives |
| 2575 | ping, logging/setLevel, notifications/roots/list_changed | HTTP health checks, per-request logLevel | Liveness probes, silent log loss |
| 2575 | Last-Event-ID, SSE event IDs | Nothing. Re-issue with a new request ID | Long tool calls over flaky links |
| 2322 | roots/list, sampling/createMessage, elicitation/create | MRTR: InputRequiredResult plus client retry | Every non-idempotent tool that elicits |
| 2322 | (new) | Required resultType on all results | Client parsers |
| 2663 | tasks/result, tasks/list | io.modelcontextprotocol/tasks extension, tasks/get polling | Long-running job clients |
| 2549 | (new) | Required ttlMs and cacheScope on list results | Cross-tenant cache correctness |
| 2243 | (new) | Required Mcp-Method / Mcp-Name headers | Gateways, WAF header allow-lists |
| Old | New | Meaning |
|---|---|---|
-32001 | -32020 | HeaderMismatch, returned with HTTP 400 |
-32003 | -32021 | MissingRequiredClientCapability |
-32004 | -32022 | UnsupportedProtocolVersion, returned with HTTP 400 |
-32002 | -32602 | Resource not found, aligned to JSON-RPC Invalid Params |
Decide your era before you touch code
The specification names three states: Modern (2026-07-28 and later, per-request metadata), Legacy (2025-11-25 and earlier, initialize handshake), and Dual-era (both). The matrix has exactly one asymmetry, and it decides your rollout order.
Read the fifth row twice. A legacy client has no fall-forward path: it cannot detect a modern-only server and adapt. So a modern-only cutover breaks every pinned internal client simultaneously, and in a private deployment those clients are typically the ones you control least, embedded in desktop tooling, CI jobs, and scheduled batch runs on their own upgrade cadences.
Ship dual-era first. Always. A dual-era server may serve both eras concurrently on the same endpoint or process, selecting behavior from how the client opens: modern _meta versus an initialize request. Clients should cache the era determination for the lifetime of the server process on stdio, or the origin on HTTP.
For anything you publish as a library, add an upper bound now so a transitive resolve does not drag you across the major version: mcp>=1.27,<2 for the Python SDK. The Tier 1 SDKs (Python, TypeScript, Go, and C#) shipped 2.0 pre-releases ahead of the spec date, and the exact version strings moved repeatedly during the ten-week validation window that opened when the release candidate locked on 2026-05-21. Pin the exact version you tested. Other language SDKs were not named in the Tier 1 announcement, which is not the same as saying they lack support.
| Client | Server | Outcome |
|---|---|---|
| Modern | Modern | Works |
| Modern | Legacy | Fails |
| Dual-era | Modern | Works |
| Dual-era | Legacy | Works, falls back to initialize |
| Legacy | Modern | Fails. 400 Bad Request, no fall-forward mechanism |
| Legacy | Dual-era | Works |
Delete sticky sessions, keep the token cache
This is the one unambiguous win. The old transport required affinity because the session lived in one process's memory, or in a shared store every instance had to read on every call.
# BEFORE: affinity required by Mcp-Session-Id
upstream mcp {
ip_hash;
server mcp-1:8080;
server mcp-2:8080;
}
# AFTER: plain round-robin, no affinity, no shared session store
upstream mcp {
server mcp-1:8080;
server mcp-2:8080;
}
location /mcp {
proxy_pass http://mcp;
proxy_http_version 1.1;
proxy_buffering off; # SSE response streams
proxy_read_timeout 3600s; # subscriptions/listen is long-lived
proxy_set_header Connection "";
}Two things you must not delete along with it. The access-token validation cache is not the session store: it is keyed by token, not connection, and it stays. And any gateway header allow-list needs widening rather than trimming, because MCP-Protocol-Version, Mcp-Method, Mcp-Name, and Mcp-Param-* are now load-bearing. A WAF that strips unknown headers will 400 every single request after the cutover, and the error will look like a client bug.
That header mirroring introduces a new trust boundary worth naming. Mcp-Name exists so a gateway can route on it, but the server executes the body. If a gateway enforces policy on the header while the server acts on params.name, you have two sources of truth for one authorization decision. The spec closes this by requiring servers to validate the pair and return -32020 on mismatch, and by telling intermediaries enforcing policy on mirrored headers to first verify that MCP-Protocol-Version indicates a version requiring that validation, rejecting otherwise. Implement both halves. Our MCP gateway comparison covers where that policy layer belongs in a governed deployment.
Rebuilding per-tenant tool visibility
Here is the change that quietly breaks role-based access control. The changelog is flat about it: tools/list, resources/list, and prompts/list no longer vary per-connection. Before this revision the session was the only place per-caller context lived, so the natural implementation was to stamp entitlements onto the session at initialize and filter the tool list per connection. That implementation is now non-conformant.
The fix is straightforward in principle: filter on the authenticated principal in the access token, not on the connection. The footgun is one word in the new required fields. SEP-2549 makes ttlMs and cacheScope required on tools/list, prompts/list, resources/list, resources/read, and resources/templates/list. cacheScope takes "public" or "private".
**Any list whose contents vary by principal must be "private".** Marking an entitlement-filtered tool list "public" authorizes shared intermediaries to cache one tenant's filtered view and serve it to another. That is a cross-tenant disclosure created by a single string literal, and a conventional test suite will not catch it, because both responses are individually correct.
The opposite failure is a cost regression. Some SDK defaults are maximally conservative, emitting ttlMs: 0 with cacheScope: "private", and ttlMs: 0 means the response should be considered immediately stale and the client may re-fetch every time. The same revision asks servers to return tools/list in deterministic order specifically to improve LLM prompt cache hit rates. Ship the conservative default unchanged and you have a server telling every client never to cache its tool list, on a protocol that just removed the per-connection cache that made re-fetching cheap. Set the hints deliberately:
cacheScope: "public", ttlMs in the 5 to 60 minute range (300000 to 3600000).cacheScope: "private", ttlMs no longer than your entitlement propagation SLA. If a revocation must take effect in two minutes, ttlMs is at most 120000.cacheScope: "private", low ttlMs, plus toolsListChanged on the listen stream.If you are weighing the token cost of large tool lists more broadly, the code execution pattern for MCP token reduction is the other half of that budget.
Server-minted handles and signed requestState
"Stateless protocol" does not mean "stateless server." It means the state you kept in process memory now round-trips through a client you do not trust, and you are the one who has to sign it.
There are two distinct mechanisms and they are not interchangeable. Server-minted handles are for cross-call state: mint a basket_id, return it in the tool result, require it back as an ordinary tool argument. **requestState** is for mid-request continuation across an MRTR round, and it replaces the removed elicitationId correlation field.
Handles now travel through model context, landing in prompt transcripts, in whatever your observability stack captures, and in the client's conversation history. Treat every handle as published: unguessable (no sequential integers, no raw primary keys), scoped to the authenticated principal so a leaked handle is useless to another subject, short-TTL, and revocable.
requestState is stricter, because the spec mandates it: servers must treat it as attacker-controlled input, and if it influences authorization, resource access, or business logic, servers must protect its integrity with HMAC or AEAD and must reject state that fails verification. Bind three things inside the protected payload: the authenticated principal, a short expiry, and an identifier for the originating request such as the method name and a digest of its salient parameters.
import base64, hashlib, hmac, json, secrets, time
def mint_state(key: bytes, principal: str, method: str, params: dict, payload: dict) -> str:
body = {
"sub": principal, # cross-user replay
"m": method, # cross-request replay
"pd": hashlib.sha256( # salient-param digest
json.dumps(params, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()[:32],
"exp": int(time.time()) + 120, # 120s replay window
"jti": secrets.token_urlsafe(16), # single-use nonce
"kid": "mcp-state-2026-07", # key rotation
"d": payload,
}
raw = json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
sig = hmac.new(key, raw, hashlib.sha256).digest()
return f"{base64.urlsafe_b64encode(raw).decode()}.{base64.urlsafe_b64encode(sig).decode()}"Two properties of that snippet are the whole point. The payload is signed, not encrypted: the client can base64url-decode it and read every field, so never put a secret, an internal identifier you would not publish, or another tenant's data in d. And the jti exists because the spec says plainly that binding principal, expiry, and origin bounds the replay window but does not by itself guarantee single use. For anything that must be consumed at most once, a one-time redemption or a payment step, you must enforce that server-side.
So the honest accounting is: you deleted a session store and acquired a nonce table. The trade is still favorable. A nonce table only ever holds entries minted in the last two minutes, where a session store holds one record for the full lifetime of every open connection, so the working set is smaller by whatever ratio your median session length bears to 120 seconds. It also needs no affinity, and losing it degrades to over-permissive replay inside a two-minute window rather than dropping every connection. But it is not zero, and a migration plan that budgets zero shared state is wrong. Key rotation is new work too: kid in the payload is what lets you roll a key without invalidating in-flight rounds. These practices belong to the same control set as our MCP server security hardening checklist.
MRTR makes your tool handler re-entrant
Multi Round-Trip Requests replace every server-initiated request. Instead of the server calling back to the client mid-execution, the server returns an InputRequiredResult with resultType: "input_required" and an inputRequests map, and the client retries the original request with an inputResponses map keyed the same way.
The operational consequence: **the requests in each round are completely independent, and the JSON-RPC id must differ between the initial request and the retry.** The server processing the retry needs nothing beyond what is in that retry, which means your handler runs from the top, every round. Everything before the elicitation point executes again: authorization checks, rate-limit decrements, audit-log writes, billing meters, any side effect at all.
Three rules follow.
Move side effects after the last input round. If the handler cannot be restructured that way, gate each effect on a dedupe key derived from the same digest you put in requestState.
Do not branch on which response keys arrived. inputResponses carries only the current round's responses, never accumulated earlier rounds. A two-question flow must thread everything learned so far through requestState as an explicit phase, a discriminated union with a phase field, not an inference from map contents. Branching on which keys are present is the easiest version of this bug to ship, because it passes every single-round test and only misfires once a flow has two input rounds.
Assume the retry never comes. The spec states servers must not assume clients will fulfill inputRequests or retry at all. Anything you reserved, locked, or metered on the first pass needs a TTL that reclaims it.
Day-2: streams, timeouts, and silent failures
Resumability is gone. No Last-Event-ID, no SSE event IDs. A broken response stream loses the in-flight request, and the client must re-issue it as a new request with a new request ID. For an on-premise client reaching a datacenter server over a VPN, a common shape in private deployments, this is a straight downgrade. Every non-idempotent tool now needs a server-side idempotency key that it did not need before, because the client's retry is indistinguishable from a fresh call.
**ping is gone.** Anything doing liveness or idle detection on protocol ping moves to an HTTP health check on a separate path. And because subscriptions/listen is now the only long-lived connection and has no protocol keep-alive, quiet streams need an SSE comment line (a line beginning with a colon, :\r\n) on an interval below your shortest intermediary idle timeout. Servers should also set X-Accel-Buffering: no on SSE responses, or proxies accumulate messages and break real-time delivery. You traded sticky sessions for idle-timeout tuning on every hop.
Logging fails silent. logging/setLevel was removed, log level is now the per-request _meta key io.modelcontextprotocol/logLevel, and servers must not emit notifications/message for requests that did not include it. The default is silence, not a default level. Migrate the server, forget the client change, and your log stream stops with no error anywhere. Assert on this in your canary: fire a known request and require at least one notifications/message before routing traffic.
Your audit correlation key was deleted. Mcp-Session-Id was, in practice, what stitched a tool-call trail together in on-premise audit logs, and the spec offers no replacement identifier. The substitute is the authenticated subject from the token plus the OpenTelemetry trace context conventions now documented for the _meta keys traceparent, tracestate, and baggage. serverInfo cannot backfill it: the spec says it is self-reported, not verified by the protocol, and clients should not rely on it for security decisions. If you carry SOC 2, HIPAA, or DORA evidence obligations, redo the correlation story before you flip anything. This is an audit-pipeline change wearing an SDK-bump costume.
On the auth side, three hardening items land in the same revision. Clients must validate a present iss parameter against the recorded issuer before redeeming an authorization code, must key persisted credentials by issuer identifier and never reuse them across authorization servers, and must specify an appropriate application_type during dynamic client registration. Dynamic Client Registration itself is now deprecated in favor of Client ID Metadata Documents, which is the direction we argued for in agent identity and OAuth for agents.
The cutover order
Work it in this sequence. Steps 1 through 4 are reversible; step 7 is the one-way door.
Mcp-Session-Id, sessionId, initialize, notifications/initialized, ping, logging/setLevel, resources/subscribe, roots/list, sampling/createMessage, elicitationId, tasks/list, tasks/result, Last-Event-ID, and the literals -32001, -32002, -32003, -32004. Classify each hit blocker, deprecated-with-runway, or cosmetic.notifications/message arrives for a request carrying logLevel, cacheScope is "private" on every entitlement-varying list, and a Mcp-Name mismatch returns -32020.MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Param-*. Drop ip_hash. Set proxy_buffering off and a long proxy_read_timeout. Move liveness off ping.If your existing MCP work predates this revision, note that most 2025-era material (including our own MCP developer guide) teaches the initialize and ClientSession model, which is now the legacy path rather than the current one. More on building and governing agent tooling sits in our AI development tools pillar.
The one-line version: the protocol got simpler and your server got three new responsibilities. Sign the state, scope the cache, and rebuild the correlation. Do those in that order and the rest is an SDK upgrade.
FAQ
Quick answers to the questions this post tends to raise.




