MCP stateless migration is the work of moving a Model Context Protocol (MCP) server from the session-based 2025-06-18 or 2025-11-25 revisions to revision 2026-07-28, in which each request carries its own protocol version and client capabilities and no protocol session links one request to the next. For a remote server on Streamable HTTP it comes down to six changes: read _meta on every request instead of initialize, stop issuing Mcp-Session-Id, implement server/discover, return Multi Round-Trip Requests instead of calling the client, move change notifications to subscriptions/listen, and add resultType, ttlMs and cacheScope to results. One endpoint can serve 2025-era and 2026-07-28 clients at once, so you can migrate an MCP server to stateless operation without cutting off older clients. The 12 steps below say what to change and how to verify it. The threat model is in our MCP server security guide.

What changed in 2026-07-28, with Mcp-Session-Id removed and server/discover added

Most of the major changes in the changelog replace an older mechanism:

Removed or replaced What takes its place SEP
Protocol sessions and Mcp-Session-Id Handles your server mints, passed back as tool arguments SEP-2567
initialize and notifications/initialized io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities in each request’s _meta (required), plus io.modelcontextprotocol/clientInfo (SHOULD) SEP-2575
Capabilities and identity from initialize server/discover, which servers MUST implement, plus io.modelcontextprotocol/serverInfo in each result’s _meta SEP-2575
The GET stream, resources/subscribe and resources/unsubscribe subscriptions/listen, a POST whose SSE response carries opted-in notifications SEP-2575
logging/setLevel io.modelcontextprotocol/logLevel in each request’s _meta SEP-2575
ping and notifications/roots/list_changed Nothing SEP-2575
Server-sent elicitation/create, sampling/createMessage and roots/list Multi Round-Trip Requests (MRTR), in which an InputRequiredResult is answered by retrying the original request SEP-2322
SSE event IDs and Last-Event-ID The client re-issues a lost request under a new ID SEP-2575
Experimental core tasks, with tasks/result and tasks/list The io.modelcontextprotocol/tasks extension, with tasks/get and tasks/update SEP-2663

Another makes resultType a required field on every result. Minor changes add ttlMs and cacheScope to list and read results (SEP-2549), require Mcp-Method on each POST, plus Mcp-Name for tools/call, resources/read and prompts/get (SEP-2243), and move resource-not-found from -32002 to -32602. Roots, Sampling, Logging (SEP-2577), Dynamic Client Registration (PR #2858) and HTTP+SSE (SEP-2596) remain in the spec but are deprecated. The deprecation registry puts their earliest removal at the first revision released on or after 28 July 2027, or for HTTP+SSE three months after SEP-2596 reaches Final.

Where state lives in stateless vs stateful MCP

Under the 2025 revisions the protocol itself was stateful. The client sent initialize once, the server answered with an Mcp-Session-Id, and every later request on that session inherited the version and capabilities agreed at the start. A server running on several instances therefore needed sticky routing or a shared session store, or a request could land on an instance that had never seen the session.

Under 2026-07-28 the protocol is stateless. Each request carries its own version and capabilities in _meta, so any instance can answer it. Your application can still keep state, such as a basket, a draft or a long-running job. It lives in your own store under a handle your server mints and the client passes back as an ordinary tool argument. The difference is ownership. The protocol no longer protects that state for you, and the security best practices say servers “MUST NOT treat possession of a state handle as authentication”. Step 6 below covers binding each handle to the verified user.

The MCP 2026-07-28 migration checklist

Work through the steps on a staging copy. “Modern” means 2026-07-28 and later, “legacy” 2025-11-25 and earlier, and “dual-era” both, as on the spec’s Versioning and Compatibility page. The numbered curl checks are in the testing section.

  • 1. Find everything that depends on the session. Search code and gateway configuration for Mcp-Session-Id, data stored under a session ID (user, tenant, scopes, filtered tool lists, log levels, subscriptions), calls to the client (elicitation, sampling, roots, ping), the GET stream, Last-Event-ID buffers and sticky-session rules.
    Verify: every hit maps to a step below.
  • 2. Choose dual-era or modern-only, and the SDK entry point. In the TypeScript SDK v2 (@modelcontextprotocol/server 2.0.0, 27 July 2026), createMcpHandler(factory) serves 2026-07-28 and, with its default legacy: 'stateless', 2025-era traffic. With legacy: 'reject', it refuses the latter. Its migration guide warns that a hand-built Server or McpServer still speaks the 2025 protocol. The Python SDK (mcp 2.0.0, 28 July 2026) serves both revisions from streamable_http_app() with nothing to configure. The release post lists Go and C# support on release day too, with Rust in beta.
    Verify: the Inspector passes in the modern era, and in the legacy era only if you chose dual-era.
  • 3. Read version and capabilities from every request. Take io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities from each request’s _meta, never from an earlier one. A request missing either gets HTTP 400 and -32602, and an unsupported version gets 400 and UnsupportedProtocolVersionError (-32022) listing data.supported. Headers that disagree with the body get 400 and HeaderMismatch (-32020). A request needing an undeclared client capability gets MissingRequiredClientCapabilityError (-32021).
    Verify: curl checks 2 and 3.
  • 4. Authenticate every request. Validate the bearer token on every POST and derive the user, tenant and scopes from it each time. Delete caches keyed by session ID. The Authorization page requires authorisation on every HTTP request, and SEP-2575 adds that “every request must be independently authenticated and authorized”.
    Verify: repeat a successful call with an expired token and expect 401.
  • 5. Implement server/discover. Return resultType: "complete", supportedVersions, capabilities, optional instructions, io.modelcontextprotocol/serverInfo in _meta, ttlMs and cacheScope. Clients may skip it, so keep every check in the methods themselves.
    Verify: curl check 1.
  • 6. Replace session state with handles. Mint no session IDs on the modern path. A modern-only server SHOULD ignore Mcp-Session-Id and answer GET and DELETE with 405 (Streamable HTTP). Keep cross-call state, such as a basket, under a handle your server mints and checks against the caller on every call (Stateful Tools). tools/list, resources/list and prompts/list may vary by the request’s authorisation, never by connection.
    Verify: with two instances and no affinity, a create-then-use sequence works wherever each call lands, another user’s token can’t use the handle, and on a modern-only server a GET returns 405.
  • 7. Return Multi Round-Trip Requests instead of calling the client. Where a tool sent elicitation/create, sampling/createMessage or roots/list, return an InputRequiredResult with inputRequests, requestState or both, and finish on the retry, which carries inputResponses and the echoed requestState under a new JSON-RPC ID. Only tools/call, prompts/get and resources/read may return one, and only for input types the client declared. Protect requestState with an HMAC or AEAD bound to the user, a short expiry and the originating request, with one key for all instances. The Python SDK’s default key is per process, so pass RequestStateSecurity(keys=[...]). TypeScript has createRequestStateCodec.
    Verify: the Inspector’s web client pauses each round at a modal tagged input_required, a retry with one changed byte or another user’s token fails, and a retry served by another instance succeeds.
  • 8. Move change notifications to subscriptions/listen. Answer with an SSE stream whose first message, notifications/subscriptions/acknowledged, lists the subset you’ll honour of toolsListChanged, promptsListChanged, resourcesListChanged and resourceSubscriptions. Send only those, tag each with io.modelcontextprotocol/subscriptionId, send SSE comments as keep-alives, and end a stream you close with a final result (Subscriptions). Publish changes to every instance that may hold a stream.
    Verify: the Inspector’s subscription badge reads Listening, and a change made through one instance reaches a stream held by another.
  • 9. Make logging per request and remove ping. Emit notifications/message only for requests whose _meta carries io.modelcontextprotocol/logLevel, at or above that level, on that request’s own stream (Logging). The TypeScript client doesn’t attach logLevel by default. Logging is deprecated, so send operational logs to OpenTelemetry, with traceparent from _meta. Treat ping as unknown on the modern path.
    Verify: with the Inspector’s Log Level per Request set to Off, a tool call logs nothing, and curl check 4 returns 404 and -32601.
  • 10. Add resultType and cache hints. Put resultType: "complete" on every final result, and ttlMs (0 or more) and cacheScope on results of server/discover, tools/list, prompts/list, resources/list, resources/templates/list and resources/read. Use "private" for anything that depends on the caller, and the same scope on every page (Caching). Return tools in a deterministic order.
    Verify: snapshots of each list show both fields, two tools/list calls return the same order, and a missing URI returns -32602.
  • 11. Stop resuming streams and make retries safe. Stop replaying events for Last-Event-ID on the modern path. A broken stream now loses the in-flight request and the client re-issues it under a new ID, and closing a stream cancels its request, so stop that work. Give every write tool an idempotency key. Move work that must outlive a connection to the tasks extension. Advertise io.modelcontextprotocol/tasks in server/discover, return tasks (resultType: "task") only to clients that declared it, and serve tasks/get, tasks/update and tasks/cancel.
    Verify: cut the connection during a write tools/call, retry it, and check the effect happened once.
  • 12. Date the deprecated features. Add no new use of Roots, Sampling, Logging, HTTP+SSE or Dynamic Client Registration. Instead, pass directories as tool parameters or configuration, call your model provider’s API directly, and register clients with Client ID Metadata Documents, as in our guide to MCP OAuth with CIMD.
    Verify: each remaining use has an owner and a removal date before July 2027.

Serving 2025-11-25 clients during the transition

A dual-era server MAY answer both eras on one endpoint, picking per opening. A request with modern _meta is served statelessly, and initialize starts a legacy session. From the spec’s compatibility matrix and those rules:

Your server 2025-11-25 client 2026-07-28-only client Dual-era client
Legacy only Works Fails Works, after falling back to initialize
Dual-era Works Works Works on 2026-07-28
Modern only Fails with HTTP 400 Works Works

Five rules apply:

  • Route on the body. A 2025-11-25 client isn’t required to send Mcp-Method at all, or MCP-Protocol-Version on the initialize request (2025-11-25 transports), so modern header checks in front of everything reject its requests. The TypeScript SDK’s isLegacyRequest(request) is true for a request with no per-request _meta. Route those to your existing sessionful handler and the rest to a legacy: 'reject' handler.
  • Return the error body. A dual-era client tries a modern request first. A recognised modern JSON-RPC error in a 400 keeps it on 2026-07-28, but an empty or unrecognised body sends it back to initialize.
  • Name your versions to legacy clients. A modern-only server SHOULD list them in any error it returns to initialize, because “legacy clients have no fall-forward mechanism”.
  • Expect cached eras. Clients SHOULD cache the era per origin and re-probe if that assumption fails, which on a dual-era server it never does.
  • Know your SDK’s legacy path. The TypeScript guide says its stateless legacy HTTP has no return path for server-to-client requests, so a tool that needs input from a 2025-era client falls back to a capability refusal. In Python, ctx.elicit() raises NoBackChannelError on 2026-07-28 connections but works for legacy ones, and stateless_http=True affects only 2025-era clients (What’s new in v2).

Security consequences of each change

Our guide covers request state, retries and caches in its statelessness section. For the migration, the changes have these consequences:

  • Per-request _meta. Capabilities hold for one request. The spec calls clientInfo and serverInfo self-reported and says not to rely on them for security decisions.
  • No sessions. Authenticate every request (step 4), and authorise each use of a handle. Holding one proves nothing about the caller.
  • MRTR. Verify requestState before use. The TypeScript codec is “signed, not encrypted”, so keep secrets out of it.
  • No resumption. Retries can repeat writes (step 11).
  • Cache scope. A "public" result may be shared across access tokens, so permission-filtered lists must be "private", with permissions still checked per call.
  • Mirrored headers. An intermediary that routes or rate-limits on Mcp-* headers SHOULD reject versions that don’t require header-body validation, so base gateway policy for legacy traffic on the token. Don’t mark secrets or PII with x-mcp-header.
  • Subscriptions. The Resources page asks for permission checks before operations, and an acknowledgement may list a subset, so we’d drop URIs the caller can’t read.

Testing the migration with MCP Inspector and curl

The MCP Inspector treats protocol era as a per-server setting, one of legacy (the default, a plain initialize), auto (probe server/discover, then fall back) or modern (pin 2026-07-28, no fallback). The CLI takes --protocol-era. Version 2.11.0, the latest on npm on 19 October 2026, needs Node 22.19.0 or newer. Sign in once first. The Inspector’s clients share stored tokens, and --stored-auth-only uses them and fails rather than opening a browser.

MCP_URL=https://mcp.example.com/mcp
INSPECTOR=@modelcontextprotocol/inspector@2.11.0

# Dual-era servers pass both runs; modern-only servers fail the legacy one
for era in modern legacy; do
  npx -y "$INSPECTOR" --cli "$MCP_URL" --transport http --protocol-era "$era" \
    --stored-auth-only --method tools/list --format json > "tools-$era.json"
  echo "$era: exit $?"
done
diff <(jq -S '.result.tools | sort_by(.name)' tools-modern.json) \
     <(jq -S '.result.tools | sort_by(.name)' tools-legacy.json)

post() {  # post <MCP-Protocol-Version> <Mcp-Method> <body>; TEST_TOKEN is a staging token
  curl -s -w '\nHTTP %{http_code}\n' -X POST "$MCP_URL" \
    -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
    -H "MCP-Protocol-Version: $1" -H "Mcp-Method: $2" \
    -H "Authorization: Bearer $TEST_TOKEN" -d "$3"
}
body() {  # body <id> <method> <protocolVersion>
  printf '{"jsonrpc":"2.0","id":%s,"method":"%s","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"%s","io.modelcontextprotocol/clientCapabilities":{}}}}' "$1" "$2" "$3"
}
post 2026-07-28 server/discover "$(body 1 server/discover 2026-07-28)"  # 1. 200, supportedVersions
post 1900-01-01 tools/list "$(body 2 tools/list 1900-01-01)"           # 2. 400, -32022
post 2026-07-28 tools/list '{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'  # 3. 400, -32602
post 2026-07-28 ping "$(body 4 ping 2026-07-28)"                       # 4. 404, -32601

Responses may be JSON or an SSE stream. In the web client, Protocol Era auto shows the negotiated era as a badge on the Protocol tab, which also shows -32020, -32021 and -32022 as typed errors. For a full test plan, see our guide to testing an MCP server.

Our view on serving both eras and securing state first

Serve both eras from one handler factory and keep the legacy path until your logs show legacy traffic has stopped, which may take a while, as clients cache eras. Count requests per era and per clientInfo name, a use the spec allows. The Python SDK’s notes describe 2025-era clients as, “today, that is most clients”.

Do steps 4 and 7 first. Per-request authentication and a shared, integrity-checked requestState key are where a migration can widen access with no visible error, while mistakes in steps 3, 5 and 10 show up as protocol errors in the first test run. If your tools ask users to confirm actions and much of your traffic is legacy, keep a sessionful legacy handler behind isLegacyRequest, because the TypeScript SDK’s stateless legacy mode refuses those requests. Remove sticky sessions last.

Where this checklist stops

  • It rests on the spec and the TypeScript and Python SDK documentation as of 19 October 2026, not on SDK source code or other SDKs.
  • It doesn’t say which hosts speak 2026-07-28, so check yours before turning off the legacy path.
  • On closing subscriptions, the Subscriptions page says a server SHOULD send a final result, while the Cancellation page says it MUST send notifications/cancelled referencing the listen request. Check what your SDK does.
  • Step 11 uses only the tasks extension’s overview. The full specification is in the modelcontextprotocol/ext-tasks repository.
  • Passing checks show the shapes are right for the requests you sent. Load, and instances you didn’t test, need their own runs.

If you’d rather we did this

Our engineers can make these changes to your server and test both eras before you switch. See MCP server development.

Sources

All pages opened on 19 October 2026. MCP specification pages belong to revision 2026-07-28 (published 28 July 2026) unless stated.