The MCP Stateless Revolution: A Deep Dive into the 2026-07-28 Spec and Hands-On Migration

MCP 2026-07-28 drops sessions and handshakes for a per-request self-describing stateless architecture.
The 2026-07-28 MCP spec removes the initialize handshake and Mcp-Session-Id header, putting protocol version, client capabilities, and identity into each request's _meta envelope so any instance can serve any request behind a plain round-robin load balancer. App state moves to a database, addressed by model-visible handles that must be ownership-checked. Server-initiated elicit and sampling become Multi Round-Trip Requests returning input_required, with the client retrying using inputResponses and an HMAC-SHA256-signed requestState. Includes a TypeScript SDK v2 migration path, two-instance verification, error codes, and a twelve-step checklist.
The MCP Stateless Revolution: A Deep Dive into the 2026-07-28 Spec and Hands-On Migration

The Model Context Protocol (MCP) has undergone a major architectural shift. The 2026-07-28 version of the spec officially removes the initialize handshake and the Mcp-Session-Id session header, transforming the entire protocol from a stateful session model into a stateless, per-request self-describing model. The official release blog frames this as "one of the most requested features by developers," and states outright that "MCP is moving from a bidirectional stateful protocol to a request/response stateless protocol."
This means any service instance can handle any request. An ordinary round-robin load balancer is enough to scale horizontally, with no sticky sessions and no shared session storage required. For teams building Agent toolchains, this is both a significant deployment simplification and a migration effort that must be taken seriously.
What Exactly Changed in This Spec
The changes center on two main threads: "de-sessionization" and "request self-description." In the old version, the client first sent initialize to complete the handshake, then maintained context via Mcp-Session-Id. The new spec removes the handshake, the HTTP GET notification stream, SSE resumability, and server-initiated requests, and packs the protocol version, client capabilities, and client info into the _meta envelope of every request.
| Change | Old (2025-11-25) | New (2026-07-28) |
|---|---|---|
| Session | Mcp-Session-Id header + DELETE to end | Removed; explicit handles instead |
| Handshake | initialize + notifications/initialized | Removed; version and capabilities travel with requests |
| Server-initiated requests | Push elicitation/create, sampling/createMessage | Return resultType: "input_required", client retries |
| Notification stream | HTTP GET stream + resources/subscribe | subscriptions/listen long-lived POST |
| Resumability | Last-Event-ID replay | Removed; reconnect means resending the request |
| Result type | Untyped field | Mandatory resultType: complete or input_required |
| Caching | None | List and read results must carry ttlMs and cacheScope |
In addition, server/discover becomes a new RPC that every server must implement, used to declare supported versions, capabilities, and identity. ping and logging/setLevel are removed, and log levels are now passed via the request _meta. The error code for a missing resource changes from -32002 to -32602, and three new error codes -32020 through -32022 are added.
The spec also marks Roots, Sampling, Logging, the old HTTP+SSE transport, and dynamic client registration as deprecated. Under the official feature lifecycle policy, deprecated features have a removal window of at least twelve months, giving existing systems some breathing room.
The _meta envelope is a reserved field in MCP across versions, used to carry ancillary metadata. The spec allows a _meta object in the params or result of requests/responses, and tools and clients can read and write it freely. In versions before 2026-07-28, it primarily carried auxiliary information such as progress tokens and trace IDs; the new spec elevates it to the primary vehicle for protocol negotiation: protocol version, client capability declarations, client name and version, log level, and other information previously exchanged once during the initialize handshake are now written into _meta on every request. The cost of this design is a slight increase in per-request size and the need to keep headers and body consistent; the benefit is that the server can fully understand request semantics without relying on any prior state, enabling stateless deployment. When implementing, note that _meta key names should follow the spec's reserved prefixes to avoid colliding with tool-defined metadata.
Why Stateless Is the Better Choice
Beyond the deployment benefits, the official rationale includes an often-overlooked dimension: model visibility. The release blog notes that if a server needs to maintain state across calls, "mint an explicit handle from one tool and have the model pass it back as an argument. We found this works better than session state hidden in the transport layer—the model can see the handle and pass it between tools."
This point deserves elaboration. Session state is an opaque black box to the model: it cannot reason about it, debug it, or explicitly orchestrate it across tools. Once handles like cart_id or job_id are exposed as tool return values, the model gains first-class access to state and can make more sensible decisions.
The AWS architecture team reached the same conclusion in its platform practice: the sticky sessions and shared session storage required by session-based servers "are no longer necessary." They validated the practical value of this spec change from an infrastructure perspective, cross-corroborating the MCP official blog's claims.
| Dimension | Stateful MCP (session era) | Stateless MCP (2026-07-28) |
|---|---|---|
| First request | initialize then notifications/initialized | A real call, tools/call, directly |
| Load balancing | Sticky sessions or shared session storage | Ordinary round-robin |
| Serverless fit | Sessions must survive cold starts—awkward | Natural fit |
| Where state lives | Hidden in transport-layer sessions | Your own database, addressed by model-visible handles |
| Asking the user | Server pushes elicitation/create mid-call | Return input_required, client retries |
Multi Round-Trip Requests (MRTR) is the core mechanism this spec uses to replace server-initiated pushes. In the old model, the server pushed elicitation/create or sampling/createMessage to the client over an SSE connection mid-execution, and the server continued after the client responded; this required the connection to stay alive throughout the interaction and the server to locate that specific session. MRTR splits one interaction into multiple independent HTTP requests: when the server finds required input missing, it returns resultType: "input_required", an inputRequests list, and an optional requestState, then ends the request; the client collects the user's answers and re-initiates the same call with inputResponses and the requestState passed back verbatim. The server verifies the origin and integrity of requestState via signature, restores context, and continues. Each request is thus self-contained and any instance can pick it up—at the cost of requiring the client to implement a retry loop, which SDK clients already handle.
Building a Stateless MCP Server in TypeScript
The original author's hands-on work is based on the official TypeScript SDK v2 (@modelcontextprotocol/server 2.3.1), verified on Node.js 24.22.0. The core idea: construct a fresh McpServer instance for each request and hand it to createMcpHandler; on Node, wrap it with toNodeHandler from @modelcontextprotocol/node, and on Web runtimes call handler.fetch(request) directly.
Put State in Storage, Not in Sessions
The example is a minimal shopping cart service. Cart data lives in shared storage, keyed by a server-minted handle. The original uses a JSON file to simulate this so two processes can share it; in production, replace it with Postgres or Redis.
There is a key security detail here: handles flow through the model, so they must be treated as identifiers, not access credentials. The original's carts.get() explicitly checks cart.owner === owner, with a comment that says it plainly—"a handle is not a credential." Every tool call must compare the handle against the authenticated user.
Using requestState for Interaction Intermediate State
Data needed only across the rounds of a single interaction goes into requestState, signed with HMAC-SHA256 via createRequestStateCodec. This codec shares a key across instances, so state minted by instance A can be verified by instance B.
The SDK migration docs state explicitly that requestState "round-trips through the client and is therefore untrusted input" and must be integrity-protected; and that it is "signed, not encrypted," so secrets must never go in it. These two statements define the complete boundary for using requestState.
Two-Instance Verification: State Really Isn't in the Connection
The original starts two instances (ports 3101 and 3102), creates a cart on A, then adds items via B and A in turn:
create_cart on A → Created cart_d892bc05-… on A
add_item on B → 1 line(s) in cart_d892bc05-…, served by B
add_item on A → 2 line(s) in cart_d892bc05-…, served by A
Each call lands on a different process with no interruption, because state lives in storage, not in the connection. This is the most intuitive validation of the stateless design.
What a Stateless Request Looks Like
A stateless MCP request is a single HTTP POST carrying three headers: MCP-Protocol-Version, Mcp-Method, and for tool calls, Mcp-Name. The request body's params._meta repeats the protocol version and declares client capabilities. Because there is no prior handshake, the client's very first request can be the tool call itself.
server/discover is implemented automatically by the SDK, returning the supported version list, capabilities, and serverInfo. List-type results have cacheHints set, telling clients they can cache for 60 seconds. Without setting them, the SDK adopts the most conservative strategy: ttlMs: 0 with cacheScope: "private". The spec emphasizes that "public" permits shared intermediate caches, so when tool lists vary by user, they must stay private.
ttlMs and cacheScope in cacheHints are the caching semantics newly added by this spec. ttlMs indicates how many milliseconds the result may be cached; a value of 0 means non-cacheable. When cacheScope is "public", shared intermediate caches (such as CDNs or reverse proxies) may store and reuse the response; when "private", only private caches for a single user or client are allowed. For tools/list, resources/list, and resource read results, the spec mandates both fields, and the SDK defaults to the most conservative ttlMs: 0 plus private when they are not explicitly set. The rule is simple: use public only if content is identical for all users; once lists or resources vary by authenticated principal, keep it private, or a shared cache could return one user's data to another.
Confirmations Now Handled by Multi Round-Trip Requests
Without sessions, the server cannot push elicitation/create mid-call. Instead, Multi Round-Trip Requests (MRTR) take over: the server returns resultType: "input_required" with a question list inputRequests and optional requestState; after asking the user, the client retries the same call with inputResponses and the requestState passed back verbatim.
The original demonstrates this flow with clear_cart: the first round goes to instance B and returns input_required; the second round goes to instance A, and because both instances share MCP_STATE_KEY, A successfully verifies the state minted by B and returns complete. After changing one character in requestState, the server returns -32602 with "Invalid or expired requestState".
With the SDK client, you don't need to write retry logic by hand—it automatically responds and retries via the elicitation/create handler you register. However, the original flags an easily overlooked point: the v2 Client still defaults to the 2025 handshake, so you must explicitly enable the new negotiation via versionNegotiation: { mode: 'auto' } or { pin: '2026-07-28' }.
Error Responses and Compatibility Boundaries
The error semantics of a stateless server are quite clear: header/body mismatch or missing headers return HTTP 400 with -32020; unsupported protocol versions return -32022 with data.supported; a tool requesting an undeclared client capability returns -32021; removed methods like ping return 404 with -32601; GET requests return 405.
Header validation exists for security. The Streamable HTTP spec requires servers to reject inconsistencies, otherwise the discrepancy between "load balancers routing by header values while MCP servers execute by body values" could be exploited.
On compatibility, createMcpHandler defaults to legacy: 'stateless', answering the 2025-era initialize and subsequent requests per request without issuing a session ID. Ordinary tools work, but tools that need user input will fail for old clients, because the per-request legacy connection cannot receive server-to-client requests. In the original's tests, clear_cart returned a clear error under the old client, noting that the connection cannot declare the required capability.
If old clients truly need confirmation interactions, a viable approach is to keep the existing stateful handler and use isLegacyRequest(request) as a pre-check to route old traffic to the old handler. The spec's versioning page also makes clear that old clients connecting to a new-only server will fail, because "legacy clients have no forward-fallback mechanism."
isLegacyRequest(request) is a request-discrimination helper provided by the SDK, used at a single handler entry point to distinguish 2025-era traffic from 2026-07-28-era traffic. Typical usage is to call it first in the routing layer: requests returning true go to the retained stateful handler, and those returning false go to the per-request stateless handler. This way old clients can still complete the handshake, maintain sessions, and use ordinary tools, while new clients enjoy stateless deployment. Note that this dual-track path is not fully equivalent: connections going through the old handler answer per request and cannot receive server-to-client requests, so tools relying on elicitation or sampling will fail for old clients with a clear error. The spec's versioning page also notes that old clients connecting to a new-only server fail outright, because legacy clients have no forward-fallback mechanism—so dual-track support is a transitional measure during migration, not a long-term solution.
Migration Checklist
The core migration actions can be summarized as: upgrade the SDK, switch to per-request handlers, split session data into handles / requestState / auth info, rewrite push-style elicit as input_required, and finally remove session infrastructure and set cache hints.
- Upgrade the SDK: TypeScript from
@modelcontextprotocol/sdk1.x to@modelcontextprotocol/server2.x; the Tier 1 SDKs for Python, Go, and C# have also been updated. - Audit all session ID usage points (
extra.sessionId,ctx.sessionId, maps keyed byMcp-Session-Id) and migrate data to handles,requestState, or the authenticated principal. - Return handles from tools (
cart_id,job_id,doc_id), and name them in tool descriptions so the model passes them back. - Validate user ownership on every handle.
- Rewrite
elicitInput()andrequestSampling()asreturn inputRequired({...}), and readctx.mcpReq.inputResponseson re-entry. - Wrap
requestStatewithcreateRequestStateCodecand share the key across instances. - Make tools safely retryable—streams are no longer resumable, and a dropped connection means the client re-initiates the request.
- Set
cacheHintsfortools/listand resources, usingprivatewhen results vary by user. - Remove
pinghandlers and session-level log-level code, switching to stderr or OpenTelemetry. - Change resource-not-found errors from
-32002to-32602. - Test both eras: modern clients with
mode: 'auto'and legacy clients, including tools that need user input. - Only after the above steps, remove sticky sessions, session storage, and GET routes.
Common Pitfalls
Most of the high-frequency errors the original lists surface as visible 400s in testing, but two of them silently leak data and deserve special emphasis: treating cart_id as an access credential, and marking user-specific lists as cacheScope: 'public'. The former causes unauthorized access; the latter can cause a shared cache to return one user's list to another.
Other points to watch include: writing secrets into requestState (clients can base64-decode it), using different MCP_STATE_KEY values across instances so cross-instance retries fail, custom clients omitting the Mcp-Method / Mcp-Name headers, v2 clients stuck on default handshake settings, and putting tools that need elicit under the legacy: 'stateless' path.
Key Takeaways
- The 2026-07-28 spec makes the MCP protocol layer fully stateless: no
initialize, noMcp-Session-Id, and every request is self-describing. - Servers must implement
server/discoverand return-32020for header/body mismatches. - Application state goes into a database, addressed by handles returned from tools, and every handle requires ownership authorization.
- Confirmation interactions become multi-round requests: return
input_required, and the client retries withinputResponsesandrequestState. - Sign
requestStatewithcreateRequestStateCodecand share the key; it is signed, not encrypted, so no secrets. createMcpHandlercan serve legacy clients per request, but that path cannot handle elicit interactions.- The v2 TypeScript client still defaults to the 2025 handshake; actively enable the new version with
versionNegotiation: { mode: 'auto' }.
One-line Summary
The MCP 2026-07-28 spec removes sessions and handshakes, moving to a per-request self-describing stateless architecture.
Paragraph Summary
The 2026-07-28 MCP spec removes the initialize handshake and the Mcp-Session-Id session header, packing protocol version, client capabilities, and identity into the _meta envelope of every request so any service instance can handle any request, with ordinary round-robin load balancing for horizontal scaling. Application state moves into a database, addressed by explicit handles returned from tools (such as cart_id), and every handle must be validated for user ownership. Server-initiated elicit and sampling become Multi Round-Trip Requests: return input_required, and the client retries with inputResponses and an HMAC-SHA256-signed requestState. The article also provides a complete TypeScript SDK v2 migration path, two-instance verification, error code semantics, compatibility boundaries, and a twelve-step migration checklist.
Related articles

Cursor AI Programming in Practice: Build a Student Management System from Scratch in 30 Minutes
A Bilibili creator demonstrates Cursor AI programming: generating a student management system in 30 minutes with natural language. Details on Agent mode, Claude selection, prompt techniques, and thresholds.

Top 20 AI Coding Tools Ranking: From Rock-Solid to Lackluster, a Scored Matrix
Bilibili creator releases top 20 AI coding tools ranking, tiered T0–T4 from rock-solid to lackluster, with Claude Code and Codex tied at T0. This article details the scoring dimensions (capability, reliability, robustness, durability, cost, domestic availability) and tool performances to help developers choose the right AI coding assistant.

Vibe Coding Systematic Tutorial: Master AI-Assisted Programming from Zero
A three-month Vibe Coding tutorial from a Bilibili creator covering Codex, ClaudeCode, and Cursor, from core logic to commercial project practice for beginners.