The MCP 2026-07-28 Spec: Sessions Are Gone — A Migration Guide to Stateless

On July 28, 2026, a new revision of the Model Context Protocol specification shipped and became
the Current protocol version. It’s the first one since 2025-11-25, eight months earlier.
In the official announcement, MCP co-creator David Soria Parra called this release “the most
important thing to happen to MCP since remote MCP launched over a year ago.”
Here’s the one-line summary. MCP is stateless now. The initialize handshake is gone, and
the Mcp-Session-Id header is gone. If you’re running an MCP server or client today, this isn’t
a release note you can get around to eventually — it’s a breaking change that requires code edits.
The ripple effects are already showing. On August 6, Cloudflare marked McpAgent — the thing you
used to build MCP servers on Workers — as deprecated and feature-frozen, and pointed people at
a stateless handler instead. Once sessions disappear, you no longer need a Durable Object to hold
one open. Section 12 covers that story on its own.
Rather than restating the changelog, this article focuses on which code you actually have to change. Each item carries its SEP number, so you can open the relevant PR directly if you need the source.
1. Sessions are gone (SEP-2567)
This is the big one. Protocol-level sessions and the Mcp-Session-Id header have been removed
from the Streamable HTTP transport.
There’s a consequence that follows from it. tools/list, resources/list, and prompts/list
can no longer return different values per connection. Implementations like “this session is an
authenticated user, so show the admin tools too” used to be possible; list responses are no longer
tied to a connection.
So what does a server do when it needs to carry state across calls? The spec’s answer is explicit — pass around a server-issued handle as an ordinary tool argument.
// Before — a structure that leaned on the session header
app.post('/mcp', async (c) => {
const sessionId = c.req.header('Mcp-Session-Id');
const state = await sessions.get(sessionId); // this header no longer arrives
// ...
});
// After — state lives in a server-issued handle, passed explicitly as a tool argument
server.tool('open_workspace', {}, async () => ({
resultType: 'complete',
content: [{ type: 'text', text: 'Workspace opened' }],
structuredContent: { workspaceHandle: 'ws_01J8ZQ...' }, // issued by the server
}));
server.tool('write_file', {
workspaceHandle: z.string(), // the client passes it straight back on the next call
path: z.string(),
body: z.string(),
}, async ({ workspaceHandle, path, body }) => { /* ... */ });
Implicit connection state has become explicit data. That’s far easier to handle when you run multiple instances behind a load balancer or go serverless, because it no longer matters which instance receives the request.
2. The initialize handshake is gone (SEP-2575)
initialize and notifications/initialized were removed wholesale. Instead, every request
carries the information it needs in its own _meta.
_meta key |
Direction | Required | Contents |
|---|---|---|---|
io.modelcontextprotocol/protocolVersion |
Client → Server | Yes | Protocol version ("2026-07-28") |
io.modelcontextprotocol/clientCapabilities |
Client → Server | Yes | Client capabilities relevant to this request |
io.modelcontextprotocol/clientInfo |
Client → Server | No (SHOULD) | Client name and version |
io.modelcontextprotocol/logLevel |
Client → Server | No | Minimum log level for this request |
io.modelcontextprotocol/serverInfo |
Server → Client (in the result’s _meta) |
No (SHOULD) | Server name and version |
// Every request now looks like this
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "write_file",
"arguments": { "path": "a.txt", "body": "hi" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": { },
"io.modelcontextprotocol/clientInfo": { "name": "my-client", "version": "1.0.0" }
}
}
}
A request missing a required field is malformed. The server MUST reject it with JSON-RPC -32602
(Invalid params), and over HTTP the response status must be 400 Bad Request. If the version
doesn’t match, return UnsupportedProtocolVersionError (-32022).
Capability declaration picked up a rule too. A server MUST NOT rely on capabilities the client
has not declared. If handling the request requires an undeclared capability, it must return
MissingRequiredClientCapabilityError (-32021) and list the missing capabilities in
data.requiredCapabilities.
The spec nails down what stateless means. A server MUST NOT build context by relying on earlier requests on the same connection. It even spells out that a stdio process is not a conversation or a session — a client may interleave unrelated requests on the same transport, and a server must not treat connection or process identity as a stand-in for session continuity.
An entire handshake round trip is gone, so environments with frequent cold starts — edge runtimes or serverless — see a real benefit. Conversely, any code that depended on information “fetched once at initialization and reused forever” has to be reworked.
3. server/discover: now a required implementation (SEP-2575)
This is the RPC that fills the gap the handshake left behind. Servers MUST implement
server/discover. Its job is to advertise supported protocol versions, capabilities, and identity.
Clients can use it in two ways.
- Call it ahead of other requests to pick a version (MAY)
- On STDIO, use it as a backward-compatibility probe — a response means modern, no response means legacy
Here’s the shape of DiscoverResult. Watch the field names — the list of supported versions is
supportedVersions, and server identity goes inside _meta, not at the top level of the result.
{
"resultType": "complete",
"supportedVersions": ["2026-07-28", "2025-11-25"],
"capabilities": { "tools": {}, "resources": {} },
"_meta": {
"io.modelcontextprotocol/serverInfo": { "name": "my-mcp-server", "version": "2.0.0" }
}
}
The error a server returns on a version mismatch is specified too. Include the supported list so the client can pick again.
{
"jsonrpc": "2.0", "id": 1,
"error": {
"code": -32022,
"message": "Unsupported protocol version",
"data": { "supported": ["2026-07-28", "2025-11-25"], "requested": "1900-01-01" }
}
}
If you’re building a server, this is the first code to add in this migration. Without it, you’re out of spec.
Serving old and new clients together
The spec sorts implementations into three groups.
| Term | Meaning |
|---|---|
| Modern | Sends version, identity, and capabilities in _meta on every request (2026-07-28 and later) |
| Legacy | Establishes a session via the initialize handshake (2025-11-25 and earlier) |
| Dual-era | An implementation that supports both |
A dual-era server decides based on how the client speaks to it. If a request arrives with modern
_meta, handle it statelessly; if initialize arrives, handle it the legacy way. Serving both from
a single endpoint is allowed (MAY).
Client-side detection differs by transport.
- stdio — probe with
server/discover; if you don’t get a recognizable modern error, treat it as legacy - Streamable HTTP — send a modern request and fall back only after inspecting the body of a
400 Bad Request
The verdict is a property of the server, not of an individual request. Cache it for the lifetime of the process on stdio, and per origin on HTTP (SHOULD).
One combination deserves attention. Legacy client → modern server fails. A legacy client has no
mechanism to negotiate up to a higher version. So if you run a modern-only server, you SHOULD list
your supported versions in the error message you return for initialize requests — that message may
be the only clue a legacy-client user ever sees.
4. Subscriptions changed (SEP-2575)
The HTTP GET endpoint plus resources/subscribe / resources/unsubscribe are gone, consolidated
into a single subscriptions/listen. It’s one long-lived POST response stream.
The client explicitly opts in to the kinds of notifications it wants.
toolsListChangedpromptsListChangedresourcesListChangedresourceSubscriptions
The server confirms them and tags the notifications it sends with
io.modelcontextprotocol/subscriptionId.
There’s one easy thing to get wrong here. Request-scoped notifications like
notifications/progress and notifications/message do not travel over the subscriptions/listen
stream. They continue to flow on the response stream of the original request. If progress
indicators suddenly stop appearing, look here first.
5. MRTR — how servers ask the client back (SEP-2322)
There used to be a separate direction where servers issued requests to clients: roots/list,
sampling/createMessage, elicitation/create, and so on. To quote the spec directly: servers MUST
now send such requests using the MRTR pattern, and the previous server-initiated request pattern
is no longer supported. This is a breaking change.
The new flow goes like this.
- The client sends a request (
id: 1) - If the server needs more information, it returns an
InputRequiredResultwithresultType: "input_required" - The client asks the user, then resends the original request under a new
id, carrying the answer
Here’s where people trip most often. inputRequests and inputResponses are maps, not arrays.
The key is an identifier the server assigned, and the value is a complete request object
(method + params) — one of ElicitRequest, CreateMessageRequest, or ListRootsRequest.
// Step 2 — server response
{
"jsonrpc": "2.0", "id": 1,
"result": {
"resultType": "input_required",
"inputRequests": {
"github_login": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Please enter your GitHub username",
"requestedSchema": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
}
}
}
},
"requestState": "AEAD-protected blob"
}
}
The response is a map under the same keys, and its values are the result objects for each request.
// Step 3 — retry under a new id, carrying the answer and the requestState verbatim
{
"jsonrpc": "2.0", "id": 2,
"method": "tools/call",
"params": {
"name": "delete_file",
"arguments": { "path": "a.txt" },
"inputResponses": {
"github_login": { "action": "accept", "content": { "name": "octocat" } }
},
"requestState": "AEAD-protected blob"
}
}
requestState — the mechanism that makes MRTR stateless
requestState is an opaque string meaningful only to the server. The server encodes whatever
context it needs into it and sends it down; the client hands it back untouched. That’s exactly why
the server doesn’t need a state store.
The rules are fairly strict.
- A server MUST include at least one of
inputRequestsandrequestStatein everyInputRequiredResult - A client MUST echo the value back exactly as received, and MUST NOT inspect, parse, or modify its contents. If it didn’t receive one, it must not include one in the retry
- The retry’s JSON-RPC
idMUST differ from the original request’s. It is an independent request
The security requirement matters most. This value arrives by way of the client, so it is attacker-controllable input (the spec says MUST treat it that way). If it affects authorization, resource access, or business logic, the server MUST protect its integrity (HMAC, AEAD, or similar) and MUST reject state that fails validation. You may skip this only when tampering can do nothing beyond making the request fail.
To limit replay, it’s RECOMMENDED (SHOULD) to embed the following inside the integrity-protected payload and validate them on every receipt.
- The authenticated subject — reject if a different subject presents it
- A short expiry (TTL) — reject if presented after it passes
- An identifier of the original request (method name plus a digest of key parameters) — reject if presented with a request that doesn’t match
That said, these measures only narrow the replay window; they do not guarantee single use. If one-time use is genuinely required, the server MUST enforce it separately.
Only three requests can use MRTR
| Client request | InputRequiredResult allowed |
|---|---|
prompts/get |
Allowed |
resources/read |
Allowed |
tools/call |
Allowed |
It MUST NOT be sent for any other request. Nor may a server put a kind of request in
inputRequests that the client hasn’t declared as a capability — if the client didn’t declare
elicitation, you can’t include elicitation/create.
Bidirectional requests became one-directional retries. That’s much simpler at the transport level, but the client has to implement the retry loop itself. Every callback that used to handle server-initiated requests must move into this structure.
6. resultType is now required on every result (SEP-2322)
Every result carries a required resultType field.
"complete"— an ordinary result"input_required"— an intermediate MRTR result
There’s an explicit backward-compatibility rule. When a server on an earlier protocol omits this
field, the client MUST treat it as "complete". If you’re building a client, make sure that
branch is in there.
7. What was removed
ping
logging/setLevel
notifications/roots/list_changed
tasks/result (→ replaced by tasks/get polling)
tasks/list
notifications/elicitation/complete
Log level is now specified per request via io.modelcontextprotocol/logLevel in _meta. And
servers MUST NOT send notifications/message for requests that lack this field. If your server
streamed logs unconditionally, you need to add that condition.
SSE stream resumption is gone too. With the Last-Event-ID header and SSE event IDs removed from
Streamable HTTP, an interrupted response stream means the in-flight request is simply lost. The
client MUST resend under a new request ID. If you wrote code expecting reconnection to pick up where
it left off, this is where it quietly breaks.
8. Tasks moved from core to an extension (SEP-2663)
Tasks, previously an experimental feature, moved out of the core protocol into the official
extension io.modelcontextprotocol/tasks. It wasn’t a simple relocation — the design was reworked.
| Change | Details |
|---|---|
tasks/result removed |
It was blocking. Replaced by polling with tasks/get |
tasks/update added |
For passing client → server input |
tasks/list removed |
— |
| Handle return | The server may return a task handle on its own initiative, without per-request opt-in |
Extensions are advertised through the extensions map in capabilities. Identifiers follow the
_meta key naming convention, and the prefix is mandatory.
{
"capabilities": {
"tools": {},
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
There’s a rule for when only one side supports the extension. The supporting side MUST fall back to core behavior or reject with an appropriate error.
9. Deprecated — Roots, Sampling, Logging (SEP-2577)
Three features are marked for deprecation. During the deprecation period they remain fully functional, but new implementations shouldn’t use them. Here’s what the spec recommends instead.
| Deprecated | Replacement |
|---|---|
| Roots | Pass directories and files as tool parameters, resource URIs, or server configuration |
| Sampling | Integrate directly with an LLM provider’s API |
| Logging | stderr for stdio; OpenTelemetry otherwise |
Some things were reclassified as deprecated alongside them.
- HTTP+SSE transport — already deprecated as of
2025-03-26, now formally in the Deprecated state. Move to Streamable HTTP (SEP-2596) "thisServer"and"allServers"forincludeContext— omit the field or use"none"- OAuth 2.0 Dynamic Client Registration (RFC 7591) — replaced by Client ID Metadata Documents. It remains for backward compatibility with authorization servers that don’t support those
The important part here is the feature lifecycle policy introduced in the same revision. It defines three states — Active / Deprecated / Removed — and a deprecated feature stays in the spec for at least 12 months before it becomes eligible for removal. That’s not unconditional, though — if the policy’s expedited-removal exception applies, the window shrinks to at least 90 days. An active security risk is one such case. So these features won’t vanish in the very next revision, but don’t read that as “a year of guaranteed safety.”
10. Caching and routing — quiet changes that hit real deployments hard
These aren’t on the headline list of major changes, but they affect real implementations immediately.
List results are now cacheable (SEP-2549). Results from tools/list, prompts/list,
resources/list, resources/read, and resources/templates/list carry required ttlMs and
cacheScope fields. ttlMs is a freshness hint in milliseconds, and cacheScope is "public" or
"private", determining whether an intermediary cache may share it. The point is to cut down on
polling.
Request headers are now required (SEP-2243). A Streamable HTTP POST must carry Mcp-Method and
Mcp-Name. The goal is to make routing possible without parsing the body. If you’ve had to
distinguish MCP traffic at a reverse proxy or WAF, the headers are all you need now.
tools/list SHOULD return a deterministic order. This is about client caching and LLM prompt
cache hit rates. If you were pulling the tool list out of a map and emitting it in a different order
each time, add a sort. Your prompt cache has been getting invalidated on every call.
OpenTelemetry trace context is now standardized (SEP-414). Propagate traceparent, tracestate,
and baggage in _meta.
11. Error codes were renumbered
There’s now an error code allocation policy. The JSON-RPC server error range is divided like this.
-32000to-32019— implementation-defined space (existing SDK usage stays valid)-32020to-32099— reserved for the MCP spec
A few numbers changed to match.
| Error | Before | After |
|---|---|---|
HeaderMismatch |
-32001 |
-32020 |
MissingRequiredClientCapability |
-32003 |
-32021 |
UnsupportedProtocolVersion |
-32004 |
-32022 |
| Resource not found | -32002 |
-32602 (Invalid Params) |
There’s an easy misreading here. Those first three codes were introduced during this revision’s draft stage, so they never existed in code implementing a previously released version. The only row that might actually be baked into your existing code is the last one.
Moving resource-not-found from -32002 to -32602 (Invalid Params) was a change to align with the
JSON-RPC spec. Implementations of this version MUST NOT emit -32002, but clients SHOULD still
accept -32002 from servers on older versions.
12. If you’re on Cloudflare Workers — McpAgent is deprecated
The clearest illustration of how this spec change ripples into real implementations is Cloudflare.
On August 6, 2026, Cloudflare announced “The next generation of MCP” and retired McpAgent.
If you read sections 1 and 2, you can probably see why this was the natural conclusion. Until now,
running an MCP server on Workers effectively required Durable Objects. You had to maintain a session
identified by Mcp-Session-Id and the SSE stream bound to it, which meant you needed something to
pin requests to the same instance. With sessions gone, that premise disappeared.
Cloudflare put it this way.
This release of the MCP 2026-07-28 specification … removes the need for
McpAgent. While Durable Objects remain the right primitive when an application itself needs state, MCP itself no longer requires a Durable Object to speak the protocol.
The documented status of McpAgent today is “deprecated and feature-frozen.” It doesn’t stop
working right this minute, but no new features will be added. The replacement is createMcpHandler,
officially described as creating a stateless MCP request handler that is callable from an MCP
SDK v2 server factory.
export default {
fetch(request, env, ctx) {
return createMcpHandler(createServer)(request, env, ctx);
},
} satisfies ExportedHandler;
No Durable Object class, no session routing. Each request builds a fresh server, handles it, and is done.
There’s one easy misreading here. It’s not that Durable Objects are no longer needed. What’s gone is the DO that existed “to speak the MCP protocol.” If your app itself needs state, a DO is still the right tool. The official docs draw exactly that distinction.
Store cross-request data behind an authenticated handle in a Durable Object, D1, KV, or R2 rather than an MCP session ID.
That’s the “server-issued explicit handle” from section 1. The state still lives in DO, D1, KV, or R2; what changes is that the key pointing at that state becomes an authenticated handle instead of an MCP session ID.
The migration burden doesn’t all land at once, either. Cloudflare stated that the /mcp endpoint
“accepts both the new protocol and stateless requests from 2025 Streamable HTTP clients.” Even if
you still have users on older clients, you can move the server first.
Be careful about cost claims. Cloudflare only used the qualitative framing of savings “from having fewer moving parts” and gave no concrete savings figures. Dropping the DO instances clearly pushes the bill downward, but this isn’t something to approach expecting a percentage.
13. Migration checklist
If you operate a server, work through these in order.
- Implement
server/discover— required; without it you’re out of spec - Remove code that depends on
Mcp-Session-Id→ use a server-issued handle as a tool argument - Remove
initialize/notifications/initializedhandlers - Read the protocol version and client capabilities from
_metaon every request - Add
resultType: "complete"to every result -
resources/subscribe/unsubscribe→subscriptions/listen - Server-initiated requests (
roots/list,sampling/createMessage,elicitation/create) → MRTR - Remove
pingandlogging/setLevelhandlers - Add a guard so
notifications/messageisn’t sent for requests withoutlogLevel - Add
ttlMsandcacheScopeto list results - Sort
tools/list(prompt cache hit rate) - Re-check your error code constants
- If you’re on the HTTP+SSE transport, move to Streamable HTTP
- If you use tasks, migrate to the official extension —
tasks/result→tasks/getpolling,tasks/updateadded,tasks/listremoved - Authorization: validate
issin authorization responses (MUST); key credentials by issuer and MUST NOT reuse them with a different authorization server - If you use DCR, specify
application_type, and consider moving to Client ID Metadata Documents - On Cloudflare Workers,
McpAgent→createMcpHandler, with state behind an authenticated handle
For clients, add these.
- Treat responses without
resultTypeas"complete"(older-server compatibility, MUST) - Implement the MRTR retry loop — echo
requestStateback verbatim, and use a retryiddifferent from the original request’s - Drop any expectation of SSE resumption — when the stream breaks, reissue under a new request ID
Wrapping up
All four Tier 1 SDKs — TypeScript, Python, Go, and C# — already speak 2026-07-28 as of the release.
The Rust SDK supports it in beta. The official announcement acknowledges that “there is some
migration cost for developers who relied on session identifiers.”
The direction itself is clear, though: strip out implicit connection state and put everything explicitly inside the request. If you run MCP servers where instances come and go — edge, serverless — this change is a win, not a loss. There’s no longer any reason to hold onto a session store.
The deprecation window is at least 12 months, so you don’t have to tear everything up today. But
server/discover is the exception. That one isn’t a deprecation, it’s a new mandatory
requirement — if you want to claim support for the new spec, it has to go in now.