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-IDbuffers 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/server2.0.0, 27 July 2026),createMcpHandler(factory)serves 2026-07-28 and, with its defaultlegacy: 'stateless', 2025-era traffic. Withlegacy: 'reject', it refuses the latter. Its migration guide warns that a hand-builtServerorMcpServerstill speaks the 2025 protocol. The Python SDK (mcp2.0.0, 28 July 2026) serves both revisions fromstreamable_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/protocolVersionandio.modelcontextprotocol/clientCapabilitiesfrom each request’s_meta, never from an earlier one. A request missing either gets HTTP 400 and-32602, and an unsupported version gets 400 andUnsupportedProtocolVersionError(-32022) listingdata.supported. Headers that disagree with the body get 400 andHeaderMismatch(-32020). A request needing an undeclared client capability getsMissingRequiredClientCapabilityError(-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. ReturnresultType: "complete",supportedVersions,capabilities, optionalinstructions,io.modelcontextprotocol/serverInfoin_meta,ttlMsandcacheScope. 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-Idand 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/listandprompts/listmay 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/createMessageorroots/list, return anInputRequiredResultwithinputRequests,requestStateor both, and finish on the retry, which carriesinputResponsesand the echoedrequestStateunder a new JSON-RPC ID. Onlytools/call,prompts/getandresources/readmay return one, and only for input types the client declared. ProtectrequestStatewith 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 passRequestStateSecurity(keys=[...]). TypeScript hascreateRequestStateCodec.
Verify: the Inspector’s web client pauses each round at a modal taggedinput_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 oftoolsListChanged,promptsListChanged,resourcesListChangedandresourceSubscriptions. Send only those, tag each withio.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 readsListening, and a change made through one instance reaches a stream held by another. - 9. Make logging per request and remove
ping. Emitnotifications/messageonly for requests whose_metacarriesio.modelcontextprotocol/logLevel, at or above that level, on that request’s own stream (Logging). The TypeScript client doesn’t attachlogLevelby default. Logging is deprecated, so send operational logs to OpenTelemetry, withtraceparentfrom_meta. Treatpingas 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
resultTypeand cache hints. PutresultType: "complete"on every final result, andttlMs(0 or more) andcacheScopeon results ofserver/discover,tools/list,prompts/list,resources/list,resources/templates/listandresources/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, twotools/listcalls return the same order, and a missing URI returns-32602. - 11. Stop resuming streams and make retries safe. Stop replaying events for
Last-Event-IDon 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. Advertiseio.modelcontextprotocol/tasksinserver/discover, return tasks (resultType: "task") only to clients that declared it, and servetasks/get,tasks/updateandtasks/cancel.
Verify: cut the connection during a writetools/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-Methodat all, orMCP-Protocol-Versionon theinitializerequest (2025-11-25 transports), so modern header checks in front of everything reject its requests. The TypeScript SDK’sisLegacyRequest(request)is true for a request with no per-request_meta. Route those to your existing sessionful handler and the rest to alegacy: '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()raisesNoBackChannelErroron 2026-07-28 connections but works for legacy ones, andstateless_http=Trueaffects 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 callsclientInfoandserverInfoself-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
requestStatebefore 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 withx-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/cancelledreferencing 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-tasksrepository. - 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.
- MCP specification: changelog, Versioning and Compatibility, Base protocol, Streamable HTTP, Multi Round-Trip Requests, Subscriptions, Cancellation, Discovery.
- MCP specification: Tools, Resources, Prompts, Caching, Logging, Authorization, deprecation registry.
- MCP specification, revision 2025-11-25: Transports.
- MCP documentation: Versioning, SDKs, Tasks extension overview and SEP-2575: Make MCP Stateless (Final, created 18 June 2025).
- David Soria Parra and Den Delimarsky, The 2026-07-28 Specification, MCP blog, 28 July 2026.
- MCP Inspector: overview, CLI, configuration and flags, authorization, protocol eras and the npm registry record of the latest release (2.11.0, published 14 October 2026).
- TypeScript SDK: migration guide for supporting 2026-07-28 (v2 documentation, undated) and GitHub releases (
@modelcontextprotocol/server2.0.0, 27 July 2026). - Python SDK: What’s new in v2 and migration guide (undated), and the PyPI release history for
mcp(2.0.0 on 28 July 2026, 2.3.0 on 2 October 2026).