In the not quite two years since it launched, the Model Context Protocol has become the common way agents reach external tools and data. Claude, ChatGPT, VS Code, and Cursor all speak it, the Tier 1 SDKs are pulling close to half a billion downloads a month, and building an integration once now means it works nearly everywhere.
The recurring criticism was that MCP required a stateful connection between client and server. That requirement traces back to the protocol’s origins in stdio, a transport built for local use, where the client launches the server as a subprocess and the two talk over pipes for as long as that process lives. A session costs nothing there, because there is one client and one server and the connection is a sensible place to keep whatever the two sides agreed on. When MCP went remote, that model came along unchanged. Serving it well meant routing requests to sticky sessions, holding streams open, replaying messages after a drop, and generally carrying more operational weight than an ordinary web service does.
The 2026-07-28 specification, which plenty of people have already started calling MCP 2.0, removes the requirement. Released on July 28 alongside updated TypeScript, Python, Go, and C# SDKs, it makes MCP fully stateless: every request declares its own protocol version, identifies its client, and states its capabilities, so any instance behind an ordinary round-robin load balancer can answer any request. Simon Willison, who built three projects against the new spec in the days after it landed, called it “the most significant change to the MCP spec since it first launched.”
Reaching that point cost a lot of surface area. The specification withdrew the server’s ability to initiate a JSON-RPC request, which changes how sampling, elicitation, and roots work. The standalone SSE stream, Mcp-Session-Id, ping, logging/setLevel, and SSE resumability were all retired alongside it. In exchange, MCP gained several things it did not have before: HTTP headers that let a gateway route and meter traffic without parsing JSON, cache hints on list endpoints, a formal extensions mechanism, and a written deprecation policy with a twelve-month floor.
The rest of this post covers those changes in the order you are likely to hit them, with a migration checklist near the end for anyone maintaining a server or a client today.
Every request now describes itself
Under 2025-11-25 and earlier, a client opened with initialize, received the server’s capabilities and a negotiated protocol version, sent notifications/initialized, and only then started doing work. Everything after that point relied on the connection remembering what had been agreed.
The latest specification optimizes that opening exchange away by removing initialize and notifications/initialized, both of which existed to establish session state, along with the negotiation round trip that came with them. Each request now simply carries whatever it needs in params._meta:
{
"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": {}
}
}
}
Three reserved _meta keys do the work the handshake used to do. Two are required on every request: io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities. The third, io.modelcontextprotocol/clientInfo, is a should, and servers should return io.modelcontextprotocol/serverInfo in each result’s _meta, so both sides still get the identity information the handshake used to exchange. Log level moved here too, as io.modelcontextprotocol/logLevel, and servers must not emit notifications/message for a request that did not ask for logging.
Leave out a required field and the request is malformed: the server must reject it with -32602 (Invalid params), which on HTTP is a 400. There is a second failure worth designing for. If a request needs a capability the client never declared, the server must return MissingRequiredClientCapabilityError (-32021), whose data.requiredCapabilities names exactly what was missing.
Results carry the mirror image. The server identifies itself the same way, in the result’s _meta, and every result declares its type:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "62F and clear" }],
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "ExampleServer",
"version": "1.0.0"
}
}
}
}
Worth noting before you build anything on those two fields: clientInfo and serverInfo are self-reported and the protocol does not verify either one. The spec is explicit that they exist for display, logging, and debugging, and that implementations should not make security decisions on them.
Version mismatches are now handled as an error rather than as a negotiation. If the server does not speak the version you asked for, it tells you what it does speak:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32022,
"message": "Unsupported protocol version",
"data": {
"supported": ["2026-07-28", "2025-11-25"],
"requested": "1900-01-01"
}
}
}
The client picks a mutually supported version out of supported and retries. In the common case where the client guessed right, this costs nothing, which is the point of moving negotiation off the happy path.
You can still ask up front if you want to. Servers must implement server/discover, which returns supported versions, capabilities, identity, and an optional instructions string of natural-language guidance for the model, and clients may call it before anything else. On stdio it also serves as the backward-compatibility probe. On HTTP most clients will skip it and simply make the call they wanted to make.
Sessions retired, and state became an explicit choice
Streamable HTTP dropped Mcp-Session-Id along with the HTTP DELETE that used to terminate a session. A server that only speaks this revision should ignore an incoming Mcp-Session-Id header, never mint or echo one, and answer GET or DELETE on the MCP endpoint with 405 Method Not Allowed.
One consequence is easy to miss on a first read: list endpoints no longer vary per connection. tools/list, resources/list, and prompts/list return the same thing to every client at the same protocol version and auth context. If your server was quietly showing a different tool catalog per session, that behavior no longer has a home in the protocol.
Servers that genuinely need state across calls are expected to mint it themselves and pass it as ordinary tool arguments. A create_query tool returns a queryId, and a fetch_page tool takes that ID as a parameter. The handle lives in the JSON body where your logging, your gateway, and your policy engine can all see it, rather than in a transport header that only the connection understood.
SSE resumability went the same way. With Last-Event-ID and SSE event IDs both retired, a broken response stream loses the in-flight request, and the client must re-issue it as a new request with a new JSON-RPC id. For long tool calls over unreliable networks that is a real regression, and the spec’s answer is to move that work to the Tasks extension instead of replaying it at the transport layer.
The wire is now readable by infrastructure
The same revision that took state out of the transport put more information into it. Streamable HTTP now mirrors selected body fields into HTTP headers, and those headers are required for compliance:
| Header | Source field | Required for |
|---|---|---|
MCP-Protocol-Version | protocolVersion in _meta | Every POST |
Mcp-Method | method | Every request |
Mcp-Name | params.name or params.uri | tools/call, resources/read, prompts/get |
A tool call now looks like this on the wire:
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":{...}}
That opens up a category of work that previously required deserializing every request body. An nginx rule, a WAF, a rate limiter, or an API gateway can route on Mcp-Method, meter per tool using Mcp-Name, and reject an unrecognized protocol version, all without touching the JSON.
Servers can extend this to their own parameters by annotating them with x-mcp-header in the tool’s inputSchema:
{
"name": "execute_sql",
"inputSchema": {
"type": "object",
"properties": {
"region": {
"type": "string",
"x-mcp-header": "Region"
},
"query": { "type": "string" }
},
"required": ["region", "query"]
}
}
The client then emits Mcp-Param-Region: us-west1 alongside the body, so a gateway can route a Spanner query to the right region without reading the arguments. Using x-mcp-header is optional for servers, but clients must support it, and a client on Streamable HTTP must exclude from tools/list any tool whose annotation breaks the rules.
Those rules are worth reading before you annotate anything. Only primitive types qualify (integer, string, boolean, and explicitly not number), and the annotated property must be statically reachable from the schema root through a chain of properties keys alone. The chain cannot pass through items or any other array keyword, through oneOf, anyOf, allOf, or not, through if/then/else, or through $ref. Nested objects are fine as long as every step is a properties key.
The header is not the source of truth
The body stays authoritative, and the spec is careful about this. Any server that parses the body must validate that header values match the corresponding body values, decoding the Base64 sentinel form first where one was used. A mismatch is a 400 Bad Request with JSON-RPC error -32020:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32020,
"message": "Header mismatch: Mcp-Name header value 'foo' does not match body value 'bar'"
}
}
The problem this closes is a split brain between two components that read different sources of truth: a load balancer routing on Mcp-Name: read_file while the server executes params.name = "delete_file".
There is a second guardrail aimed at intermediaries, and it is easy to skip. An intermediary that enforces policy on mirrored headers should check that MCP-Protocol-Version names a revision that requires header/body validation, and reject the request if the version is older or the header is absent. Without that check, an attacker declares 2025-11-25, where nothing validates headers against the body, and your gateway enforces policy on values the server will never verify.
Values that cannot ride in an ASCII header use a sentinel encoding, as in Mcp-Param-Text: =?base64?IHBhZGRlZCA=?=. The markers are lowercase and case-sensitive, and a plain-ASCII value that happens to look like the sentinel must itself be encoded so it cannot be mistaken for one.
Multi Round-Trip Requests replace everything the server used to initiate
Losing the ability to initiate a request is the most invasive change for anyone whose server calls back into the client. roots/list, sampling/createMessage, and elicitation/create survive as request shapes, but a server can only ask for them now by returning a result indicating that it needs input, then waiting for the client to come back with it.
That signal rides on resultType, the field every result carries. "complete" marks an ordinary result, as in the example above, and "input_required" marks a request for input. The set is not closed: extensions define their own values, and the Tasks extension returns "task". A client must reject any value it does not recognize, and must treat a result from an older server that omits the field entirely as "complete".
An InputRequiredResult carries an inputRequests map keyed by server-assigned identifiers, plus an opaque requestState blob:
{
"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 client gathers the input, then retries the original request with a matching inputResponses map and the exact requestState value echoed back. The retry uses a new JSON-RPC id, since it is a genuinely independent request rather than a continuation of the first one. InputRequiredResult is only legal on tools/call, resources/read, and prompts/get, and servers must not return it anywhere else.
Two requirements tend to surprise implementers. The server must include at least one of inputRequests or requestState, and must not ask for a capability the client never declared, so no elicitation capability in _meta.io.modelcontextprotocol/clientCapabilities means no elicitation/create in your inputRequests. Servers also must not assume the client will ever come back. MRTR gives you no delivery guarantee and no way to hold a transaction open across the gap. That is also why notifications/elicitation/complete and the elicitationId field from 2025-11-25 were both removed: with no session to correlate against, a server-initiated completion signal has nowhere to go.
requestState is attacker-controlled input
requestState is minted by the server, handed to the client, and handed back later. It makes a round trip through a party the server does not control, and the spec treats it accordingly.
If requestState influences authorization, resource access, or business logic, servers must integrity-protect it with HMAC or AEAD and must reject state that fails verification. To bound replay, servers should bind three things into the protected payload and check each on receipt: the authenticated principal, so state presented by a different principal is rejected; a short TTL; and an identifier for the originating request, such as the method name plus a digest of its salient parameters.
Even with all three in place, you have bounded the replay window and prevented cross-user reuse without making the state single-use. If a given requestState must be redeemed at most once, the spec says you enforce that server-side, with storage, which is the same state the stateless design was meant to let you avoid. The revision does not pretend otherwise, and it limits the requirement to the cases that actually need it. A reasonable mental model is that requestState behaves like a signed cookie rather than a session pointer, and the controls that apply to one apply to the other.
subscriptions/listen replaces the GET stream
The specification consolidated three mechanisms into one. The standalone GET /mcp SSE stream, resources/subscribe, and resources/unsubscribe were all replaced by subscriptions/listen, a POST whose response is a long-lived SSE stream. The client passes a notifications filter naming what it wants: toolsListChanged, promptsListChanged, and resourcesListChanged are booleans, while resourceSubscriptions takes an array of resource URIs. A server must not send a type the client did not ask for.
Two details separate a working client from one that quietly misses events. The server replies with notifications/subscriptions/acknowledged, and its notifications field reflects only the subset it agreed to honor, with unsupported types omitted, so a client should compare the acknowledgment against what it asked for. Every message on the stream then carries io.modelcontextprotocol/subscriptionId in _meta, and the value is the JSON-RPC id of the subscriptions/listen request itself. That is what lets one client hold several subscriptions at once and tell them apart, which matters most on stdio where they all share a single channel.
A clean shutdown is also distinguishable from a dropped one. When the server ends a subscription itself, it should first answer the original subscriptions/listen request with an empty result; a stream that closes without one was an unexpected disconnect, and the client may treat that as a cue to reconnect. On stdio it must then re-send subscriptions/listen, because the server keeps no subscription state across connections.
Request-scoped notifications do not travel on that stream. notifications/progress and notifications/message flow only on the SSE response stream of the request they belong to, so a client can have two kinds of stream open at once carrying entirely separate traffic.
Two operational details are called out in the spec and are easy to get wrong. Servers should send X-Accel-Buffering: no so that nginx and similar proxies stop buffering SSE events and holding them until a chunk fills. And on the long-lived listen stream, servers are encouraged to emit an SSE comment line (:\r\n) periodically as a keep-alive, so a quiet connection is not closed by an intermediary or an idle timeout.
Cancellation got simpler on HTTP. Closing the SSE response stream is itself the cancellation signal, and the server must stop sending messages for that request. Because each request has its own stream, the disconnect is unambiguous. notifications/cancelled now applies only to stdio.
Lists are cacheable, and tool order matters
Because list endpoints no longer vary per connection, their results became cacheable in a way they were not before. Servers must now include two caching hints on any "complete" result from server/discover, tools/list, prompts/list, resources/list, resources/templates/list, and resources/read. ttlMs is a freshness hint in milliseconds, with semantics borrowed from Cache-Control: max-age, and the spec is careful to call it a freshness check rather than a polling timer. cacheScope is "public" or "private": public means the response holds nothing user-specific and any shared cache may serve it to anyone, while private means it may be reused within one authorization context and never across two.
Two things are excluded. Interim "input_required" results carry no hints at all, and any result produced by an MRTR retry, meaning any request carrying inputResponses or requestState, must not be cached, because those inputs are not part of the cache key.
The hints complement listChanged notifications rather than replacing them, and a notification invalidates a still-fresh cached response immediately. Since list endpoints no longer vary per connection, a tool catalog marked cacheScope: "public" can sit in a CDN or gateway cache and serve every client at that protocol version.
That last part deserves care on an authenticated server. A "public" response may be shared between callers even though it came back from an authenticated endpoint, so marking a per-tenant tools/list public hands one tenant’s catalog to the next. The spec says plainly that cacheScope is not an access control, and that servers must enforce per-primitive authorization themselves.
Alongside this is a quieter recommendation: servers should return tools from tools/list in a deterministic order. The reason is prompt caching rather than tidiness. Tool definitions get serialized into the model’s prompt, so a stable order keeps the prefix byte-identical between calls and the provider’s prompt cache keeps hitting. A server that shuffles its tool list invalidates that prefix on every call.
Authorization hardening
Authorization picked up four changes, none of which depend on the stateless rework. They are worth adopting even if you defer everything else in this post.
Issuer validation. Following RFC 9207, authorization servers should include the iss parameter in authorization responses, and MCP clients must validate a present iss against the recorded issuer before redeeming the authorization code. This closes the authorization server mix-up attack, where a malicious AS tricks a client into redeeming a code at the wrong endpoint. Servers advertise support through authorization_response_iss_parameter_supported: true.
Client ID Metadata Documents. Dynamic Client Registration is deprecated, and CIMD replaces it. The client_id becomes an HTTPS URL pointing at a JSON document the authorization server fetches on demand:
{
"client_id": "https://app.example.com/oauth/client-metadata.json",
"client_name": "Example MCP Client",
"client_uri": "https://app.example.com",
"redirect_uris": [
"http://127.0.0.1:3000/callback",
"http://localhost:3000/callback"
],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
The client_id value inside the document must match the URL exactly, the URL must use https and contain a path component, and the authorization server must validate both the match and the requested redirect URI before showing a consent screen. Support is advertised as client_id_metadata_document_supported: true.
The practical advantage over DCR is portability. A CIMD client ID is a URL the authorization server resolves whenever it needs to, so nothing has to be re-registered when the authorization server changes. The client preference order is now pre-registration first, then CIMD, then DCR as a fallback, and prompting the user only when nothing else is available.
Credential binding. Clients that persist client credentials must key them by the authorization server’s issuer identifier, must not reuse them with a different authorization server, and must re-register when it changes. If protected resource metadata starts pointing at a different issuer than the one your credentials came from, the client should surface an error instead of silently trying them.
application_type. Clients using DCR must now specify an appropriate application_type. Omitting it defaults to "web" under OpenID Connect, which is the reason so many CLI and desktop clients have been hitting rejected localhost redirect URIs. Native apps, desktop apps, CLI tools, and locally-hosted web apps should send "native". Non-OIDC servers ignore the parameter safely.
What is deprecated, and what is simply gone
Removed outright in this revision:
initializeandnotifications/initializedMcp-Session-Id, and the DELETE that terminated a session- The standalone GET SSE endpoint
resources/subscribeandresources/unsubscribepinglogging/setLevelnotifications/roots/list_changednotifications/elicitation/completeand theelicitationIdfield- SSE resumability:
Last-Event-IDand event IDs - Server-initiated JSON-RPC requests
tasks/listand the blockingtasks/result, as Tasks moved to an extension
Deprecated, with a minimum twelve-month window before removal under the new feature lifecycle policy:
| Feature | Suggested migration |
|---|---|
| Roots | Pass paths as tool parameters or resource URIs |
| Sampling | Call an LLM provider API directly |
| Logging | Write to stderr, or use OpenTelemetry |
| HTTP+SSE transport | Streamable HTTP |
| Dynamic Client Registration | Client ID Metadata Documents |
includeContext: "thisServer", "allServers" | Omit it, or use "none" |
Deprecating Roots, Sampling, and Logging is the most opinionated call in the release. All three were client-side capabilities the server reached back into, and all three are awkward to express once there is no session to reach back through. The lifecycle policy itself may end up mattering more than any individual deprecation: MCP now has written states (Active, Deprecated, Removed), a twelve-month floor, and a registry of deprecated features, so removals stop being a surprise.
Error codes were tidied up at the same time. The JSON-RPC server-error range is now partitioned, with -32000 to -32019 left implementation-defined and existing SDK usage grandfathered, and -32020 to -32099 reserved for the specification. Codes introduced during the draft cycle were renumbered, so anyone who tracked the release candidates needs to update them:
| Error | Draft code | Final code |
|---|---|---|
HeaderMismatch | -32001 | -32020 |
MissingRequiredClientCapability | -32003 | -32021 |
UnsupportedProtocolVersion | -32004 | -32022 |
Separately, resource-not-found moved from -32002 to -32602 (Invalid Params) to line up with JSON-RPC.
Extensions absorb what left the core
Several features left the core protocol in this revision, and the extensions mechanism is where they went. ClientCapabilities and ServerCapabilities gained an extensions field, a map of prefixed identifiers to per-extension settings objects:
{
"capabilities": {
"tools": {},
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
}
}
Tasks moved out of the experimental core into io.modelcontextprotocol/tasks and was redesigned on the way. Polling through tasks/get replaces the blocking tasks/result, a new tasks/update carries client-to-server input mid-task, tasks/list is gone, and tasks/cancel asks for a stop the server acknowledges but is not obliged to honor. A server can now decide on its own that a call will take a while and answer with a CreateTaskResult instead of holding a stream open. It still needs permission to do so: the client declares the tasks extension in its capabilities once, and a server must never hand a task to a client that did not.
The handle is durable, and that is what replaces the resumability taken out of the transport. A client that crashes or reconnects resumes polling the same taskId. Tasks report a status of working, input_required, completed, failed, or cancelled, and the last three are terminal.
MCP Apps and Enterprise-Managed Authorization are extensions as well. We wrote about what EMA does and where it stops when it shipped. The structural point is that the core protocol now has a way to shrink, and a feature can mature on its own schedule instead of being frozen into a revision before it is ready.
Two smaller additions round out the release. OpenTelemetry trace context propagation is now a documented convention in _meta, using traceparent, tracestate, and baggage, so tracing a call across an agent, a gateway, and a server no longer requires everyone’s private hack. And inputSchema and outputSchema were loosened to allow any JSON Schema 2020-12 keywords, with structuredContent allowing any JSON value, accompanied by $ref resolution requirements and resource bounds on composition keywords so that the extra freedom does not become a denial-of-service surface.
Migrating
The two eras have names now. Modern means per-request metadata, 2026-07-28 and later. Legacy means an initialize handshake, 2025-11-25 and earlier. Dual-era means supporting both.
A dual-era client on HTTP sends a modern request first and, on 400 Bad Request, inspects the body before deciding anything. Modern servers also return 400 for UnsupportedProtocolVersionError, MissingRequiredClientCapabilityError, and header validation failures, so a recognized modern JSON-RPC error means retry with a supported version rather than fall back. An empty or unrecognized body means the server is legacy, and the client falls back to initialize. On stdio, the probe is server/discover instead.
Cache that determination. Era is a property of the server process on stdio or the origin on HTTP, not of an individual request, so clients should hold the result for that lifetime and may persist it across restarts, re-probing if the assumption later fails.
A dual-era server picks its behavior from how the client opens: modern _meta gets stateless handling, an initialize request gets legacy session semantics, and both can run on the same endpoint. If you support only modern versions, name your supported protocol versions in whatever error you return to initialize. Legacy clients have no fall-forward mechanism, and that error message is often the only diagnostic a user will see.
Two combinations have no shim at all. A modern client cannot talk to a legacy server, and a legacy client cannot talk to a modern server. Both fail, and the spec’s compatibility matrix says so plainly.
For an existing server, a workable order of operations:
- Stop reading
Mcp-Session-Id. Return405on GET and DELETE. - Read version, client info, and capabilities from
params._metaon every request. - Implement
server/discover. - Add
resultType: "complete"to every result. - Validate
MCP-Protocol-Version,Mcp-Method, andMcp-Nameagainst the body, returning-32020on mismatch. - Add
ttlMsandcacheScopeto list results, and make your tool ordering deterministic. - Rewrite any sampling, elicitation, or roots callback as MRTR, and sign your
requestState. - Replace
resources/subscribewithsubscriptions/listen. - Move long-running work to the Tasks extension, since SSE resumability is gone.
All four Tier 1 SDKs (TypeScript, Python, Go, and C#) ship 2026-07-28 support, with Rust in beta. On the infrastructure side, Cloudflare reports that a stateless server no longer needs McpAgent or Durable Objects and can deploy to a plain Worker, with Durable Objects reserved for the cases that genuinely coordinate state. Simon Willison, who built three projects against the new spec including a CLI explorer and a Datasette plugin, summarized the developer experience as “so much cleaner from both a client- and server-side implementation perspective.”
What this means for anyone securing MCP traffic
The header changes help security tooling for the same reason they help load balancers. Policy that used to require a JSON parser in the hot path now runs off Mcp-Method and Mcp-Name directly, which covers per-tool rate limits, per-tool authorization, and per-tool audit. Deterministic tool ordering and cacheScope: "public" make a shared catalog cache workable too, giving you one place to inspect what a server is actually offering.
Two things move in the other direction. requestState puts server-authored state in the client’s hands on every multi-turn tool call. The spec mandates integrity protection and recommends principal binding, a TTL, and a request digest, which are the right controls and also the kind that get postponed. If you review MCP server implementations, that is a good first place to look.
Header and body validation also only exists from 2026-07-28 forward. A request that arrives claiming 2025-11-25 carries no such guarantee, which is why the spec tells intermediaries to check the version before trusting a mirrored header. Any gateway that enforces on headers without pinning the version is open to a downgrade followed by a header/body desync. Neither of these is a flaw in the revision, and the spec names both directly. They are simply where the attack surface moved.
What has not changed is the harder problem underneath. A valid token and a well-formed tools/call still say nothing about whether the call should run. Mcp-Name: execute_sql is a much better signal than an opaque POST body, and it is still not an answer to whether this agent, in this context, should be running that query right now. We have written about that gap before. 2026-07-28 does not close it, but by making the traffic legible to the infrastructure sitting in front of the server, it gives whatever does close it a lot more to work with.


