Skip to content
effidevFlutter · Cloudflare edge · Cloud cost optimization
English

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

Migration guide to the stateless MCP 2026-07-28 specification

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.

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.

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.

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.

  1. The client sends a request (id: 1)
  2. If the server needs more information, it returns an InputRequiredResult with resultType: "input_required"
  3. 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.

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.

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.

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.

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.

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.

For clients, add these.

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.