Three days ago, on July 28, 2026, the Model Context Protocol shipped revision 2026-07-28. It is the largest breaking change in the protocol's history, and it is not an additive release you can adopt at your leisure.
It removes the initialize handshake. It removes protocol-level sessions and the Mcp-Session-Id header. It removes the HTTP GET endpoint. It removes SSE resumability and Last-Event-ID. It removes ping and logging/setLevel. It deprecates Roots, Sampling, Logging, and Dynamic Client Registration.
If you wrote an MCP server against 2025-06-18 or 2025-11-25, the parts of it you were proudest of are the parts that got deleted. If you learned MCP from a blog post, a course, or a model whose training data predates this week, everything you know about the transport layer is now wrong in its load-bearing sections.
I want to be precise about what changed, because most of the writing about this over the next month will be confidently wrong. Everything below is checked against the spec and the canonical schema as of July 30, 2026.
The shape of the change
The old model was a connection. You called initialize, negotiated capabilities once, got back an Mcp-Session-Id, and every subsequent request lived inside that session. The server held state. If the SSE stream dropped, you reconnected with Last-Event-ID and replayed.
The new model is a request. Every POST carries its own protocol version, its own capability declaration, its own identity. There is no session. There is no negotiation phase. The server holds nothing between calls.
That single reframing explains almost every specific change in the revision. Once you internalize "each request is complete on its own," the rest stops looking like a list of arbitrary removals and starts looking like one decision applied consistently.
The maintainers were explicit about why. Their December 2025 post laid out four problems with the stateful design: load balancers had to parse full JSON-RPC bodies to route traffic instead of using ordinary HTTP patterns; stateful connections forced sticky routing that pinned traffic to specific instances and defeated autoscaling; simple tool servers were forced to run backend storage just to support multi-turn interactions; and session semantics across distributed systems were never clearly defined.
Those are deployment complaints, not protocol-purity complaints. This revision was designed by people who were operating MCP at scale and losing.
What a request looks like now
Here is a tools/call under 2026-07-28. Read the headers carefully, because three of them are mandatory and two of them are new.
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_orders
Authorization: Bearer eyJhbGciOi...
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "search_orders",
"arguments": { "customer_id": "cus_931", "status": "open" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": { "elicitation": {} },
"io.modelcontextprotocol/clientInfo": { "name": "acme-agent", "version": "3.1.0" }
}
}
}MCP-Protocol-Version, Mcp-Method, and Mcp-Name are the interesting ones. Mcp-Method mirrors the JSON-RPC method. Mcp-Name mirrors params.name for tools/call and prompts/get, or params.uri for resources/read. Header names are case insensitive, header values are case sensitive.
The point of mirroring body fields into headers is that your edge can now do real work without deserializing anything. You can rate limit per tool name at the load balancer. You can route tools/call to a different fleet than tools/list. You can build per-tool RED metrics in a proxy that has never parsed a JSON-RPC envelope. AWS called this out specifically when AgentCore Gateway shipped support on the same day the spec landed: by removing sessions, remote MCP servers effectively become standard HTTPS endpoints, which scale cleanly with enterprise workloads.
But mirroring creates a trust problem, and the spec closes it with a hard requirement. If MCP-Protocol-Version does not match _meta.io.modelcontextprotocol/protocolVersion in the body, the server must reject with 400 and JSON-RPC error -32020 (HeaderMismatch). The stated rationale is worth pinning to your design docs:
"This prevents potential security vulnerabilities when different components in the network rely on different sources of truth (e.g., a load balancer routing on the header value while the MCP server executes based on the body value)."
That is a real attack class, not a formality. If you accept unvalidated mirrored headers, you have built a request smuggling primitive into your own gateway.
The removals, one at a time
Sessions are gone
Mcp-Session-Id no longer exists. tools/list must not vary per connection or as a side effect of another request.
There is one carve-out that matters enormously for multi-tenant servers: tools/list may vary by the authorization presented, because credentials are per-request input rather than connection state. That single sentence is the sanctioned way to expose different tools to different tenants or different scopes. You are not doing anything clever or off-spec by returning a different tool set for an admin token than for a read-only token. You are doing exactly what the spec describes.
If you genuinely need cross-call state, the pattern the spec now describes (in an explicitly non-normative section, since the protocol has no concept of a state handle) is an opaque, server-minted handle passed as an ordinary tool argument:
{
"name": "create_cart",
"description": "Creates a shopping cart and returns a cart handle. Carts expire after 24 hours of inactivity.",
"outputSchema": {
"type": "object",
"properties": { "cart_handle": { "type": "string" } },
"required": ["cart_handle"]
}
}Three things about that example are deliberate. The expiry policy is written into the description, so the model can see it and reason about it. The handle is opaque. And, per the spec's own guidance, a handle is a name and not a capability: you validate authorization on every call that presents it, because for an unauthenticated server the handle is necessarily a bearer token and needs UUIDv4-grade entropy and a bounded lifetime.
The revision also names the failure mode this creates, state handle hijacking, and the rule is blunt. Servers must not treat possession of a state handle as authentication. Bind it server side to the authenticated principal, for instance by keying state as <user_id>:<handle> where user_id comes from the verified token and never from the client.
The handshake is gone
No initialize. No notifications/initialized. Capability declaration moved into _meta on every request, as shown above.
In its place, servers must implement server/discover. Clients may call it. It returns the server's supported protocol versions, capabilities, identity, and a ttlMs freshness hint (the docs use one hour as the example). Those supported versions are what the whole negotiation model rests on.
The GET stream is gone, and so is resumability
There is one endpoint path and it supports POST. A server that supports only this revision should answer GET and DELETE with 405.
Long-lived change notifications moved to a dedicated request, subscriptions/listen, which replaces the GET stream along with resources/subscribe and resources/unsubscribe. Clients opt into specific notification classes, and the server acknowledges the subset it agreed to honor:
{
"jsonrpc": "2.0", "id": 7, "method": "subscriptions/listen",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
},
"notifications": {
"toolsListChanged": true,
"resourcesListChanged": true,
"resourceSubscriptions": ["file:///project/config.toml"]
}
}
}The server replies with notifications/subscriptions/acknowledged, then streams notifications tagged with io.modelcontextprotocol/subscriptionId in _meta.
The part that will cost you an incident: SSE resumability is gone. Last-Event-ID and SSE event IDs are removed. The spec says it plainly. A broken response stream loses the in-flight request, and clients must reissue it as a new request with a new request ID.
Read that twice if you run long tool calls over consumer networks. Before this revision, a dropped stream was recoverable. Now it is a retry, and a retry of a tools/call that already ran is a duplicate side effect unless your tool is safe to run twice. This is the moment where idempotency stops being a nice-to-have and becomes the thing standing between you and double-charging a customer. Mint an idempotency key in the tool arguments, dedupe on it server side, and set idempotentHint: true so hosts can reason about safe retries.
If your tool genuinely cannot be made safe to retry, the escape hatch is the Tasks extension (io.modelcontextprotocol/tasks), which moved out of core in this revision and now uses tasks/get polling instead of a blocking tasks/result. Start the work, return a task handle immediately, poll. That is the correct shape for anything that runs longer than a few seconds.
Server-initiated requests are gone
Servers can no longer send JSON-RPC requests to clients on a stream. On stdio, servers must not write requests to stdout at all.
The replacement is MRTR, the multi-round-trip pattern. Every result now carries a required resultType of either "complete" or "input_required". When a server needs something from the client, it returns input_required with inputRequests and/or an opaque requestState, and the client reissues the original request with the answers attached.
{
"jsonrpc": "2.0", "id": 12,
"result": {
"resultType": "input_required",
"inputRequests": {
"confirm_delete": {
"method": "elicitation/create",
"params": {
"message": "Delete 47 archived orders? This cannot be undone.",
"requestedSchema": {
"type": "object",
"properties": { "confirmed": { "type": "boolean" } },
"required": ["confirmed"]
}
}
}
},
"requestState": "v1.eyJvcCI6ImRlbGV0ZSIsIm4iOjQ3fQ.sig"
}
}Only three client requests can receive an InputRequiredResult: prompts/get, resources/read, and tools/call. The retry must use a different JSON-RPC id than the original.
The security rule here is the one people will skip. requestState is attacker-controlled data. The client echoes it back verbatim and must not inspect or modify it, which means a hostile client can echo back anything. If your requestState influences authorization, resource access, or business logic, you must integrity-protect it with an HMAC or AEAD and reject anything that fails verification. The spec further recommends binding the authenticated principal, a short TTL, and a digest of the originating request's salient parameters inside the protected payload, and it is explicit that these bound the replay window but do not guarantee single use. One-time redemption has to be enforced server side.
In other words: requestState is a cookie, and you should treat it with exactly the paranoia you would apply to a session cookie you hand to a browser.
Cancellation is now asymmetric
On HTTP, closing the SSE response stream is the cancellation signal. notifications/cancelled is not sent. The server must treat stream close as cancellation, stop work promptly, and send nothing further for that request.
On stdio, the opposite: the client must send notifications/cancelled, because stdio is one shared channel with no per-request stream to close.
If you ship one server binary that serves both transports, this asymmetry is a portability hazard and you should write a test for each side.
The status code table you should encode as tests
The revision is unusually testable. These are assertions, not vibes:
| Situation | Response |
|---|---|
| Request, single JSON reply | 200 + application/json |
| Request, streamed reply | 200 + text/event-stream |
| Notification accepted | 202, no body |
| Header does not match body | 400 + -32020 |
| Missing required client capability | 400 + -32021 |
| Unknown protocol version | 400 + -32022 |
Bad Origin | 403 |
| Insufficient OAuth scope | 403 + WWW-Authenticate |
| Missing or invalid token | 401 + WWW-Authenticate |
Missing a required _meta field | 400 + -32602 |
| Unimplemented RPC method | 404 with -32601 in the body |
| GET or DELETE to the MCP endpoint | 405 |
Two details in that table are easy to get wrong. The 404 must carry a JSON-RPC -32601 in the body, because the body is what distinguishes "this server does not implement that method" from "this is a legacy HTTP+SSE 404." And the error codes were renumbered in this revision: HeaderMismatch moved from -32001 to -32020, MissingRequiredClientCapability from -32003 to -32021, and UnsupportedProtocolVersion from -32004 to -32022. The range -32000 to -32019 is now implementation-defined and grandfathered; -32020 to -32099 is reserved for the spec. Separately, resource-not-found moved from -32002 to -32602.
While you are in there: servers should send X-Accel-Buffering: no on SSE responses, or nginx-class proxies will buffer your stream into uselessness. And empty string is a valid pagination cursor, so if (!cursor) return done is a bug that will silently truncate your tool list.
The cache rules are a performance feature disguised as a schema change
Two additions look like bookkeeping and are not.
First, ttlMs and cacheScope ("public" or "private") are now required on tools/list, prompts/list, resources/list, resources/templates/list, and resources/read. The docs use 5 minutes for tools/list and 1 hour for server/discover.
Second, servers should return tools/list in deterministic order, and the spec states the reasons outright: to let clients reliably cache the tool list, and to improve LLM prompt cache hit rates when tools are included in model context.
That reason deserves unpacking, because it is the single highest-leverage operational fact in the revision. Most providers cache the prompt prefix, and the tools array is part of that prefix. If your server returns tools in map-iteration order, the array reshuffles between calls, the prefix changes, and every conversation pays a full cache miss. The MCP client best-practices doc puts a number on the general problem: a naive upfront load of a large tool catalog runs about 150,000 tokens of definitions, versus roughly 2,000 tokens under progressive discovery. It also warns that adding or removing tool definitions mid-conversation can cost more in the resulting cache miss than the definitions you removed.
Sorting your tool list is a two-line change that costs nothing and stops a class of silent cache misses. Do it today, regardless of which revision you target.
Auth: what actually changed
Authorization is still optional overall, still OAuth 2.1, and still forbids token passthrough in the strongest terms the spec has. MCP servers must validate that access tokens were issued specifically for them as the audience, and must not pass through a client's token to an upstream API. If your server calls an upstream, it acts as an OAuth client to that upstream with a separate token.
Three things are new in this revision.
RFC 9207 issuer identification. Authorization servers should include iss in authorization responses, including error responses, and if they do they must advertise authorization_response_iss_parameter_supported: true. Clients record the issuer alongside the PKCE verifier and validate before redeeming the code. Comparison is simple string comparison under RFC 3986, and clients must not normalize case, default ports, trailing slashes, or percent-encoding before comparing. The spec is direct about why this matters: PKCE alone does not prevent mix-up attacks, because the client sends code_verifier to the attacker's token endpoint, and resource indicators do not help when the attacker's authorization server intercepts requests before they reach the honest one. Note the spec's own limit on the fix too: iss validation depends on honest authorization servers actually emitting iss, and offers no protection against an honest server that does not.
Credentials are keyed by issuer. Clients must not reuse credentials across authorization servers and must re-register when the AS changes.
Dynamic Client Registration is deprecated in favor of OAuth Client ID Metadata Documents. DCR is retained only for authorization servers that lack CIMD support.
Worth noting the new SSRF surface that CIMD creates: the authorization server now fetches an attacker-supplied URL. The spec's mitigation list is specific, including blocking RFC 1918 ranges, 127.0.0.0/8, 169.254.0.0/16, ::1, fc00::/7, and fe80::/10, validating redirect targets at each hop, and pinning DNS between check and use to close the TOCTOU window. It also says, correctly, do not hand-roll IP validation, because attackers exploit octal, hex, and IPv4-mapped IPv6 encodings. Use an egress proxy. Stripe's Smokescreen is named as an example.
If that seems like a lot of paranoia for an auth flow, remember that CVE-2025-6514 in mcp-remote scored 9.6 and achieved remote code execution during the initial connection and authorization phase, before any tool was ever called, across 437,000+ downloads. The URL-scheme allowlist and the "must not use shell commands to open URLs" rule, both already present in the 2025-11-25 security guidance and carried forward here, exist because that happened.
Deprecated, with dates
The revision introduced a formal feature lifecycle with Active, Deprecated, and Removed states and a minimum twelve-month deprecation window. Here is the registry that matters:
| Feature | Deprecated in | Earliest removal |
|---|---|---|
| Roots | 2026-07-28 | first revision on or after 2027-07-28 |
| Sampling | 2026-07-28 | first revision on or after 2027-07-28 |
| Logging | 2026-07-28 | first revision on or after 2027-07-28 |
| Dynamic Client Registration | 2026-07-28 | first revision on or after 2027-07-28 |
The suggested migrations are telling. Roots becomes "pass directories and files as tool parameters." Sampling becomes "integrate directly with LLM provider APIs." Logging becomes stderr on stdio, or OpenTelemetry.
My read of those migration notes, and it is a read rather than something the maintainers state, is that these primitives never achieved real client adoption. If a capability's migration path is "just do it yourself with the provider SDK," it was never load-bearing.
The legacy HTTP+SSE transport is a separate case. It was deprecated back in 2025-03-26 and merely reclassified under the new lifecycle policy here, and the sources disagree on its runway. The announcement post is friendlier about the timeline than the normative deprecated-features registry, which puts HTTP+SSE removal at three months after SEP-2596 reaches Final under an expedited-removal exception, not the twelve-month window. Plan against the registry.
Migrating without guessing
The spec gives you a vocabulary. Modern means per-request metadata (2026-07-28 and later). Legacy means the initialize handshake (2025-11-25 and earlier). Dual-era means both.
The compatibility matrix has one genuinely painful row. Modern to Modern works. Dual-era to anything works. Modern client to Legacy server fails. And Legacy client to Modern server fails, because legacy clients have no fall-forward mechanism.
That last row is your entire migration risk. Every client already deployed against your server, that you do not control, breaks the moment you go modern-only. So do not go modern-only.
Ship dual-era. The detection rules are specified:
On stdio, probe with server/discover. A DiscoverResult means modern. A recognized modern error such as UnsupportedProtocolVersionError also means modern, and you retry with an advertised version rather than falling back. Any other error, or a timeout, means legacy. Crucially, the fallback must not be keyed to a single error code, because legacy servers may return -32601, -32602, or nothing at all.
On HTTP, try a modern request and inspect the body on 400 before falling back, because modern servers also return 400 for unsupported version, missing capability, and header validation failures. Branching on the status code alone will make you fall back to legacy against a perfectly good modern server.
Era is a property of the server, not the request. Cache it per server process for stdio, per origin for HTTP.
One more courtesy: if you do run modern-only, name your supported versions in whatever error you return to initialize. That error string may be the only diagnostic a legacy client can put in front of a human.
Tooling, and the state of the ecosystem three days in
The official conformance suite is the most useful and least publicized thing here:
npx @modelcontextprotocol/conformance server --url https://mcp.example.com/mcp \
--spec-version 2026-07-28 --suite all
npx @modelcontextprotocol/conformance listWatch the --suite values, because they differ by subcommand. For server runs it accepts active (the default, which excludes pending and draft scenarios), all, draft, and pending. For client runs it accepts all, core, extensions, backcompat, auth, metadata, draft, and sep-835. The existence of a backcompat suite tells you the maintainers expect dual-era to be the normal state for a while. --expected-failures takes a YAML baseline file, which means you can gate CI on regressions rather than needing a perfect score on day one. That is the right way to adopt it: snapshot your current failures, block anything new.
On SDKs, both major ecosystems shipped a v2 major and a same-day v1 maintenance release. As of this week, npm has @modelcontextprotocol/sdk at 1.30.0 and @modelcontextprotocol/server at 2.0.0; PyPI has mcp at 2.0.0 with 1.29.0 on the v1 line. If you want to sit still for a quarter, pin mcp>=1.25,<2. The TypeScript v2 line splits into scoped packages (/server, /node, /express, /hono, /fastify) and accepts any Standard Schema library rather than Zod specifically.
Hosting vendors moved fast and unevenly. Cloudflare shipped 2026-07-28 support the same day, and describes the result as each request running on a fresh stateless server with no protocol session and no protocol-specific Durable Object; old /sse URLs are now aliases that no longer serve the deprecated transport. AWS shipped AgentCore Gateway support the same day, with dual-version gateways that serve both eras off the per-request version header, and migration explicitly opt-in.
Vercel's MCP documentation, meanwhile, still shows export { handler as GET, handler as POST, handler as DELETE }. GET and DELETE are exactly what 2026-07-28 says a modern-only server should answer with 405. That is not a criticism of Vercel so much as a snapshot of what the next few months look like: the spec moved, and the ecosystem's documentation, tutorials, and model training data have not. Even the official docs site still has pages describing capability declaration during the "initialization handshake" that this revision removed.
Which is the practical warning to end on. For the next several months, the confident answer you get from a search result, a tutorial, or a coding assistant about MCP transports has a high probability of describing a protocol that no longer exists. The schema file is the ground truth. The conformance suite is the arbiter. Everything else, including this post, is a secondary source with an expiration date.
MCP is not a small thing to get wrong at this point. The maintainers report close to half a billion monthly downloads across the Tier 1 SDKs, with the TypeScript and Python SDKs each crossing a billion total. Honeycomb reports that nearly 20% of their monthly interactive queries now come from agents. This is production infrastructure with a three-day-old wire format.
Go read the changelog, sort your tool list, add an idempotency key, and snapshot a conformance baseline before Monday.
Further reading on the surrounding problems: why AI-generated code carries more defects and what catches them, engineering context across long-running agent sessions, and measuring where retrieval actually breaks.
Sources
- MCP specification versioning
- 2026-07-28 changelog
- Streamable HTTP transport
- stdio transport
- Authorization
- Authorization security considerations
- Security best practices
- Server tools
- Deprecated features registry
- MRTR pattern
- Client best practices
- Release announcement
- The future of MCP transports
- Official conformance suite
- Cloudflare changelog
- AWS AgentCore Gateway and the 2026-07-28 spec
- CVE-2025-6514 in mcp-remote