MCP v2 in practice: what the 2026-07-28 spec changes, and what it does not
The 2026-07-28 revision drops MCP sessions and the initialize handshake, replaces server-initiated requests with retries, and starts a twelve-month clock on Roots, Sampling and Logging. What that means for a server you have already deployed.
Daniel Steman/
First, the name
The Model Context Protocol published a new specification revision on 28 July 2026, and by its own maintainers' description it is the largest revision the protocol has had since launch. A lot of the writing about it, Cloudflare's included, calls it MCP v2. The specification does not use that name anywhere I could find. It calls the revision 2026-07-28, the same way every revision before it was named after its date.
The shorthand stuck for an understandable reason, though. The official Python and C# SDKs both released a 2.0 in the same week, and the TypeScript SDK went to v2 as well, so there genuinely is a v2 of something. It is just the SDKs, not the protocol. The difference matters when you go searching, because the two terms turn up different pages: MCP v2 mostly finds commentary, and 2026-07-28 finds the specification, its changelog, and the SEP behind each individual change. The second set is where the answers are.
The revision was locked as a release candidate on 21 May 2026 and published as final on 28 July. The ten weeks in between were a validation window for SDK maintainers and client implementers, which is why all four Tier 1 SDKs, TypeScript, Python, Go and C#, supported the revision on the day it landed. The Rust SDK shipped beta support. Normally it goes the other way around: a spec is published, and the libraries most people actually build on catch up over the following months, so nobody can act on the new version until their language of choice gets there. This time the libraries were ready first, which is what makes upgrading a decision rather than a wait.
What follows is what actually changed, what it changes for a server that is already deployed and taking traffic, and which parts you can reasonably not think about yet. Every claim is linked at the bottom.
The protocol stopped keeping sessions
Almost everything else in this revision follows from one decision: MCP no longer has a session.
In every revision up to and including 2025-11-25, a remote MCP connection opened with an initialize request, a matching notifications/initialized, and, on the Streamable HTTP transport, an Mcp-Session-Id header that the server minted and the client echoed on everything afterwards. Capabilities were negotiated once at the start and then assumed for the life of the connection. That is a completely ordinary design, and it is the design most stateful protocols use.
The trouble was where it put the cost. A server built that way cannot be scaled by putting a second copy of it behind a load balancer, because the second copy has never seen the handshake. You either pin each client to one instance with sticky sessions, or you push the session state into something shared like Redis, and you have to do that before you can serve your second concurrent user, not later when the traffic justifies it. Plenty of teams built exactly that. GitHub's own MCP server ran on Redis sessions, and part of what it did to support this revision was delete them.
Under 2026-07-28, the initialize handshake is gone (SEP-2575), and so are protocol-level sessions and the Mcp-Session-Id header (SEP-2567). Every request now carries what the handshake used to establish, in a _meta object on its params:
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": {}
}
}
}The practical consequence is that tools/list no longer varies per connection, because there is no connection for it to vary by. Any instance can answer any request without having seen anything that came before it. You can put a server behind an ordinary round-robin load balancer, or on a serverless platform that will not hold a connection open for you, without building session plumbing first.
Your application can still be stateful. The protocol simply stopped carrying that state on your behalf. The pattern the specification points at is an explicit, server-minted handle passed back as an ordinary tool argument, which is the same thing a REST API does with a resource id, and considerably easier to reason about than an invisible session that some layer of your stack is holding for you.
There is also a new server/discover method, which every server must implement. A client may call it before anything else to select a protocol version up front, or use it as a backwards-compatibility probe on stdio. It is the useful half of the handshake, made optional and turned into a normal request.
When the server needs something back, it now asks by returning
The old shape had a second consequence that is easy to overlook. Sampling, elicitation and roots/list all worked by the server sending its own JSON-RPC request down an open stream to the client. That only works while a stream is open, which a stateless protocol does not guarantee you.
The replacement is called Multi Round-Trip Requests (SEP-2322), and it is simpler than the name suggests. Rather than pushing a question at the client, the server answers the original call with an interim result: resultType is "input_required", and an inputRequests field carries what it needs. The client goes and gets that, by asking the user or calling its model, and then retries the original request with inputResponses attached. It is the same conversation, expressed as two complete request and response rounds instead of one round with an interruption in the middle.
To make that work, every result now carries a required resultType field. Ordinary results are "complete". Clients are required to treat a missing resultType from an older server as "complete", which is what lets an up-to-date client keep talking to a server that has not upgraded yet.
If your server needs to correlate the two rounds, that is now explicitly your job: you encode whatever identifier you need into requestState and the client echoes it back to you. The notifications/elicitation/complete notification and the elicitationId field, both of which had only arrived in 2025-11-25, are gone, because a client that learns the outcome by retrying does not need to be told separately that something finished.
Streams got narrower, and lost their memory
The standalone HTTP GET stream is gone, and so are resources/subscribe and resources/unsubscribe. Long-lived server-to-client change notifications now come from a single subscriptions/listen request whose response stream stays open. The client opts in by notification type, tools list changed, prompts list changed, resources list changed, resource subscriptions, and the server acknowledges and tags what it sends with a subscription id.
Request-scoped notifications did not move. notifications/progress and notifications/message still arrive on the response stream of the request they belong to, which is where you would look for them anyway.
The change most likely to catch someone out is that streams are no longer resumable. SSE event ids and the Last-Event-ID header are both gone from the transport. If a response stream breaks, the in-flight request is lost, and the client has to re-issue it as a brand new request with a new request id. For a tool that returns in under a second, this is nothing. For a tool that runs for two minutes over a flaky connection it is a real regression, and it is a good part of the reason long-running work was moved into the Tasks extension instead of staying in core.
A few things were removed outright rather than deprecated: ping, logging/setLevel, and notifications/roots/list_changed. Log level is now set per request through io.modelcontextprotocol/logLevel in _meta, and a server must not emit notifications/message for a request that did not ask for logging in the first place.
The HTTP surface got opinionated, on purpose
The rest of the transport work goes into making MCP traffic legible to ordinary HTTP infrastructure. Two headers are now required for compliance: Mcp-Method on every request, carrying the JSON-RPC method, and Mcp-Name on tools/call, resources/read and prompts/get, carrying the tool name or resource URI. A load balancer, a WAF or a rate limiter can now route, throttle and meter by tool name without parsing a single JSON body. GitHub described making precisely that change: reading values from headers instead of doing deep packet inspection.
MCP-Protocol-Version is still a header and still required, and it now must match the io.modelcontextprotocol/protocolVersion value in the body. A mismatch is a 400 with a HeaderMismatch error, code -32020.
That last rule is about security, not tidiness, and the specification says so plainly: if a load balancer routes on the header while the server executes the body, then a request that disagrees with itself is an exploit waiting to be found. Any server that reads the body has to check the two agree. Servers can also mirror chosen tool parameters into headers with an x-mcp-header annotation, which arrives as Mcp-Param-{Name}. The specification's own example is a region parameter on an execute_sql tool, which is the shape you want when a gateway has to route by tenant or region without opening the request.
Two more additions are aimed at infrastructure, not at tool authors. Results from tools/list, prompts/list, resources/list, resources/read and resources/templates/list now carry ttlMs and cacheScope (SEP-2549): a freshness hint in milliseconds, and a public or private flag saying whether shared intermediaries may cache the response. Both are modelled on HTTP Cache-Control, and both only became possible once list results stopped varying per connection. Separately, servers should now return tools from tools/list in a deterministic order, which matters because a reshuffled tool list invalidates an LLM prompt cache that would otherwise have hit.
Tracing got a convention too. traceparent, tracestate and baggage are now documented _meta keys (SEP-414), so W3C trace context propagates through an MCP call chain the way it propagates through everything else you already run.
A few smaller corrections matter if you have written a client. Resource not found moved from -32002 to the JSON-RPC standard -32602. The 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. And tool inputSchema and outputSchema accept any JSON Schema 2020-12 keywords now, so oneOf, anyOf, allOf and conditionals are all available where they were not before (SEP-2106).
Three features went on a twelve-month clock
The governance change may end up outlasting the technical ones. MCP now has a written feature lifecycle policy (SEP-2596). A feature is Active, Deprecated or Removed; anything deprecated has to stay available for at least twelve months before it can be removed; and there is a registry listing everything currently sitting in the Deprecated state.
The practical effect is that "when does my code break" now has an answer you can look up. Nothing deprecated on 28 July 2026 can be removed by a revision published before 28 July 2027.
| Deprecated | Suggested migration | Where |
|---|---|---|
| Roots | Pass the directories or files as tool parameters, as resource URIs, or in server configuration | SEP-2577 |
| Sampling | Call an LLM provider API directly from the server | SEP-2577 |
| Logging | Write to stderr on stdio, or emit OpenTelemetry | SEP-2577 |
| HTTP+SSE transport (2024-11-05) | Streamable HTTP, which has been the replacement since 2025-03-26 | SEP-2596 |
| Dynamic Client Registration | Client ID Metadata Documents, with DCR still available for authorization servers that do not support them | PR #2858 |
None of these stopped working. All of them still work today, and they keep working through this revision and for at least a year after it. The one genuinely worth planning around is Sampling, because its suggested migration is not a mechanical rewrite. Instead of asking the client's model for a completion, you call an LLM provider yourself, which means your server now needs an API key, a budget, and an opinion about which model it wants. That is a design decision, not a find and replace, and eleven months of notice makes it a far smaller problem than three weeks would have.
Everything else moves to extensions
Extensions became a first-class part of the protocol in this revision, with a new extensions field on both client and server capabilities, reverse-DNS identifiers, independent versioning, and their own release cadence separate from the core specification. Three official ones shipped alongside it.
Tasks (io.modelcontextprotocol/tasks, SEP-2663) was an experimental core feature and is now an extension, reshaped for a protocol with no sessions. The blocking tasks/result method is replaced by polling with tasks/get, a new tasks/update carries client-to-server input mid-flight, tasks/list is gone, and a server may hand back a task handle without the client having opted in on that particular request.
MCP Apps lets a server define an HTML interface that the client renders in a sandboxed iframe, communicating over JSON-RPC, so a tool can return a form or a small dashboard instead of a wall of JSON for a model to narrate. Enterprise-Managed Authorization lets IT administrators provision server access centrally, so each user does not have to authorize individually.
The framework matters more than any of the three extensions do. It keeps the core protocol from accumulating every feature anyone ever asked for, and it lets a capability iterate on its own schedule without waiting for the next dated revision of the spec.
Authorization moved closer to boring OAuth
The authorization work is the least surprising part of the revision, which is a good sign, because surprising authorization is how incidents start. Authorization servers should now include the iss parameter in authorization responses per RFC 9207, and clients must validate a present iss against the issuer they recorded before redeeming the authorization code (SEP-2468). That closes a response-confusion gap between multiple authorities.
Dynamic Client Registration is deprecated in favour of Client ID Metadata Documents, and remains available for authorization servers that do not support the newer mechanism. Clients that do still use DCR must declare an appropriate application_type, which avoids a class of OpenID Connect redirect URI conflicts (SEP-837). And client credentials are now explicitly bound to the authorization server that issued them: key them by issuer, never reuse them against a different authorization server, and re-register when the authorization server changes (SEP-2352).
None of this is new thinking. It is the set of things production OAuth 2.0 and OpenID Connect deployments already do, written into the specification so that MCP clients stop each reinventing them slightly differently.
What to actually do, if you write Python MCP servers
Here it gets briefly confusing, because two different Python packages are commonly called FastMCP and they are on different clocks right now.
mcp, the official Python SDK, published 2.0.0 on 28 July 2026, the same day as the specification. It also published 1.29.0 that day, so the 1.x line is still being maintained and nothing is forcing you off it. Version 2 is a real rewrite of the protocol layer, not just a version bump: the SDK's own FastMCP class is now called MCPServer and everything under mcp.server.fastmcp.* moved to mcp.server.mcpserver.*, wire types moved into a separate mcp_types distribution that is still importable as mcp.types, Python attributes are snake_case while the JSON on the wire stays camelCase, and transport options moved off the constructor onto run().
fastmcp, the standalone framework, is the one most people mean when they say FastMCP, and it is the one foro.sh deploys. Its 4.0 line arrived as 4.0.0b1 on 28 July 2026, the same day again, but it is still in beta. As of today, 11 August 2026, pip install fastmcp resolves to 3.4.7, released on 10 August, and the newest 4.x build is 4.0.0b2 from 7 August.
FastMCP 4's release notes are worth reading in full if you maintain a server, because they are unusually direct about the trade-offs. One deployment serves both protocol eras at once, negotiated per connection, so a stateless client and a handshake-era client can both talk to it. Most FastMCP 3 servers are expected to upgrade untouched. It hands back per-user state on a protocol that deliberately has none, through UserSession and SessionId keyed to the authenticated user and stored server-side. Background tasks live in a separate fastmcp-tasks package, with @mcp.tool(task=True) still the entire authoring surface. And it removes server-initiated sampling and roots from the server API outright, on the stated reasoning that a method which only works against old clients is a trap.
If you have a FastMCP 3 server running, there is nothing you have to do this month, and your server keeps serving the clients it serves. If you want stateless serving, background tasks or the enterprise auth pieces, 4.0 is there to try, with the ordinary caveat that a beta is a beta and you should pin an exact version while you evaluate it.
What changes on foro.sh
For a server already deployed here, honestly: nothing. foro.sh runs one container per project behind Traefik, and the protocol revision your server speaks is whichever revision your FastMCP or SDK version speaks. Nothing in the platform pins you to a spec revision, so 28 July came and went without a deployed server noticing.
It is worth being straight about why that is. The problem this revision set out to solve, a session that has to be pinned or shared across replicas, is a problem a single-container deployment never had in the first place. We got the benefit of the fix without ever having felt the problem. That is luck, not foresight.
Where it does reach us is attribution. The per-tool metrics we report identify the calling client from clientInfo, which under the old protocol was established once during initialize and is now carried in _meta on every request. Same information, different place to read it from. The session id we currently hang on a call for grouping simply does not exist for a client speaking 2026-07-28, since the specification tells servers to ignore Mcp-Session-Id and never mint or echo one. That one is on us to fix.
And if you do want to try FastMCP 4 on a server you have deployed, the upgrade is the ordinary one: bump the dependency in your repo and push. The next deploy builds it, health-checks it, and keeps the previous version serving until the new one passes.
If you only do one thing
Check whether your server uses Sampling or Roots. Those are the two deprecations with a real design cost attached, and you have until at least 28 July 2027 to deal with them, which is long enough to do it properly and short enough to be worth writing down somewhere now. Nearly everything else in this revision is something your SDK will absorb for you on the next upgrade, which is the whole reason the ten-week validation window existed.
Sources
- Model Context Protocol, Key Changes in the 2026-07-28 specification (the changelog every fact above is checked against)
- Model Context Protocol, Streamable HTTP transport, 2026-07-28
- MCP blog, The 2026-07-28 Specification
- MCP blog, The 2026-07-28 MCP Specification Release Candidate
- MCP blog, Beta SDKs for the 2026-07-28 Spec Release Candidate Are Here
- Cloudflare, MCP v2
- GitHub Changelog, GitHub MCP Server supports the next MCP specification
- AWS, How AgentCore Gateway supports the MCP 2026-07-28 spec
- Pydantic, MCP Python SDK v2 beta: what is new and how to try it
- FastMCP 4.0.0b1 release notes
- aaif.io, MCP 2026-07-28: what is changing and how to migrate