Published July 28, 2026, the MCP 2026-07-28 specification changed the protocol's core operating model: every request now stands on its own, so server-side session memory is no longer needed. If you run an MCP server, this stateless migration guide walks through exactly what's gone, what's new, and how to migrate without breaking it — based on the specification's official blog announcement and the /specification/2026-07-28 pages.
💡 Pro Tip: Implement the server/discover RPC before you start migrating — calling it is optional for the client, but the spec requires the server to have it in place, or era-detecting clients will assume you're "legacy" and fall back.Table of Contents
- Why the move to stateless (horizontal scaling, LB)
- The difference between the legacy and modern models
- Retired: Mcp-Session-Id and initialize/initialized
- Per-request capability negotiation with _meta
- server/discover: mandatory to implement, optional to call
- Step-by-step migration: stdio transport
- Step-by-step migration: Streamable HTTP transport
- Exiting the old SSE transport
- Backward compatibility and running dual-mode
- Setting up the dispatcher to run dual-mode
- Post-migration verification checklist
- FAQ
- What exactly changed in the MCP 2026-07-28 specification?
- Why was the Mcp-Session-Id header removed, and what replaced it?
- How do I migrate my existing MCP server to the stateless spec?
- Is server/discover mandatory? Should I delete the initialize handshake?
- When should I move from DCR to CIMD?
- How long can I keep running my legacy HTTP+SSE server?
- Conclusion
- Sources
Why the move to stateless (horizontal scaling, LB)
Legacy MCP required a connection-based initialize/initialized handshake: the client connected once, and the server held session state (capability list, protocol version, identity) for that connection. This model forced every request from the same client to land on the same process — meaning you needed sticky sessions or a shared session store (Redis, in-server memory sync).
MCP's official blog announcement describes this shift plainly: "MCP is transforming from a bidirectional stateful protocol into a request/response stateless protocol." Every request now carries its own protocol version and client capabilities in the _meta field, so "Any request can now land on any server instance behind a plain round-robin load balancer without needing shared storage." In practice, this means you no longer need to write sticky-session ingress rules for an MCP server that scales horizontally on Kubernetes.
If you genuinely need persistent state on the server side (say, a shopping cart or a long-running job ID), the specification suggests a clear solution: hand back a tool-generated "handle" value as a model argument, and the model sends it as a parameter on subsequent calls. State is now carried explicitly in the data layer, not the transport.
The difference between the legacy and modern models
Dimension | Legacy (2025-11-25 and earlier) | Modern (2026-07-28+) |
|---|---|---|
Connection setup | initialize/initialized handshake | None — every request is independent |
Identity/version transport | Connection-scoped session memory | Per-request, in _meta |
Session header | Mcp-Session-Id | Removed |
Scaling | Requires sticky routing / shared store | Plain round-robin LB is enough |
Server-state model | Implicit (transport-level) | Explicit handle (tool parameter) |
Retired: Mcp-Session-Id and initialize/initialized
These two have stood in front of every MCP connection for a long time: the initialize handshake since the 2024-11-05 revision, and the Mcp-Session-Id header since the 2025-03-26 revision that introduced Streamable HTTP. The official announcement states both were retired together: "we've officially retired the initialize/initialized exchange along with the Mcp-Session-Id header." What's removed:
- The
Mcp-Session-Idheader: no longer sent on the Streamable HTTP transport, and the server doesn't need to expect it either. - The
initialize/initializedhandshake: there's no longer a connection-setup exchange; the server learns the protocol version from_metathe moment it receives the first request.
If you're writing a new server, don't implement either of these at all. If you have an existing server, follow the migration steps in the next section, but be aware of this: these two weren't deprecated, they were flat-out removed. The changelog's first two "Major changes" entries say "Remove protocol-level sessions and the Mcp-Session-Id header" (SEP-2567) and "remove the initialize/notifications/initialized handshake" (SEP-2575); neither appears in the deprecated features registry either. So there's no transition window the specification recognizes here — how long you keep the legacy flow alive depends entirely on your own client fleet's migration timeline.
Per-request capability negotiation with _meta
Every JSON-RPC request now carries its own identity. The official announcement shows the header layer of an example request like this:
1POST /mcp HTTP/1.12MCP-Protocol-Version: 2026-07-283Mcp-Method: tools/call4Mcp-Name: searchThe block in the announcement also includes a JSON body; only the header layer is shown here. While the header level carries the protocol version and method/name info, per the specification the _meta field on the body side also carries the protocol version, client identity, and client capabilities together on every request — in the official announcement's words, "Each request now travels on its own, carrying its protocol version, client identity, and client capabilities in _meta." So the server now has to read from every request's body the information it used to learn once, at connection open.
The specification defines these fields normatively under the label "Per-request protocol fields"; you need to know the exact key names and requirement status:
_meta key | Type | Required |
|---|---|---|
io.modelcontextprotocol/protocolVersion | string | Yes |
io.modelcontextprotocol/clientInfo | Implementation | No |
io.modelcontextprotocol/clientCapabilities | ClientCapabilities | Yes |
io.modelcontextprotocol/logLevel | LoggingLevel | No |
If a required field is missing, the request is malformed: the server must reject it with -32602 (Invalid params), and over HTTP the status must be 400 Bad Request. Keep this in mind — it directly affects era-detection in the HTTP section below.
Extension negotiation follows the same logic: capabilities appear as a map under capabilities.extensions, each extension ID carrying its own settings object, with IDs following reverse-domain naming (e.g. io.modelcontextprotocol/tasks, io.modelcontextprotocol/ui). On the server side, rewrite your handler to read these fields on every request, not just at connection open.
server/discover: mandatory to implement, optional to call
It's worth being precise here because it's easily misread: implementing the `server/discover` RPC on the server side is mandatory, the only thing optional is whether the client calls this RPC before every request. The specification's changelog says it plainly: servers MUST implement this RPC ("servers MUST implement this RPC"), while clients MAY call it before any other request ("Clients MAY call it before any other request").
Behavior is also clear when a client requests an unsupported protocol version: the server must return UnsupportedProtocolVersionError and list the versions it does support, giving clients both era-detection and a graceful degradation path for future transitions.
You can also use server/discover as a migration probe: if the client calls it and gets a response, the server is modern (2026-07-28+); an unrecognized error means legacy. The next section lays out this probe logic separately for stdio and Streamable HTTP.
Step-by-step migration: stdio transport
On the stdio transport, MCP generally talks to a single client for the lifetime of the process, so migration is relatively simple. But the spec warns explicitly: don't count the stdio process as a session — unrelated requests can interleave on the same transport, and process identity doesn't substitute for conversation continuity. The probe logic the specification recommends is: "probe with server/discover and fall back on any error that is not a recognized modern error." That is, the client side calls server/discover first; if it gets back an unrecognized/unexpected error, it assumes the server is legacy and falls back to the old initialize flow.
1# Era detection over stdio (conceptual flow)2# server/discover takes no body parameters, but it is not exempt from the required _meta fields.3echo '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' | ./mcp-server4# Modern server: returns a valid discover response5# Legacy server: returns an unrecognized-method error -> client falls back to initializeThree things to do on the server side: (1) add a server/discover handler, (2) update your dispatcher to read _meta on every JSON-RPC request, (3) don't delete the initialize handler right away — since the spec removed this flow outright it gives no defined transition window, so base how long you run both in parallel on your own client fleet's timeline.
The client cache rule also comes into play here: clients should cache the result of era detection for the lifetime of the server process on stdio, not re-probe on every request.
Step-by-step migration: Streamable HTTP transport
On the HTTP side, since multiple clients can connect to the same server from different origins, the probe logic is a bit different: "attempt a modern request and inspect the body of a 400 Bad Request before falling back." The client tries a modern request directly; if the server doesn't recognize it and returns a 400 Bad Request, the client inspects the body content to decide the server is legacy. The reason you have to look at the body is the rule from the previous section: since a modern server must also return 400 for a request missing a required _meta field, the status code alone doesn't tell you the era.
1POST /mcp HTTP/1.12MCP-Protocol-Version: 2026-07-283Mcp-Method: tools/call4Mcp-Name: search5Content-Type: application/json6 7{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"search","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}A mandatory change to watch for: Streamable HTTP requests must now carry the Mcp-Method and Mcp-Name headers (SEP-2243). This lets a gateway or rate-limiter make routing/limiting decisions without parsing the JSON body — behind nginx or a similar reverse proxy, include these two headers in your logging and rate-limit rules.
Era detection here should be cached per origin (the HTTP counterpart of the stdio process-lifetime rule), and can be kept persistent across restarts if needed.
Exiting the old SSE transport
The legacy HTTP+SSE transport was also officially declared deprecated in this release — "The legacy HTTP+SSE transport is also considered to be officially deprecated, with a year-long offramp." When setting your timeline, know that this sentence alone isn't enough: the normative record, the deprecated features registry, gives the earliest removal date for this same transport as "Three months after SEP-2596 reaches Final," and describes itself as a derived view kept consistent with "per-feature deprecation notices and changelog entries, which are the normative records." SEP-2596 itself was merged with the `final` label on May 18, 2026; the three-month window based on that closes in mid-August 2026. So if you still have an SSE-based MCP server implementation running, base your plan on the registry line, not the "I have a year" assumption, and don't build new development on top of it.
There's a second, related break: the server-initiated request pattern is gone entirely — from Streamable HTTP too, not just legacy SSE. Server-initiated elicitation/create, sampling/createMessage, and roots/list calls are now replaced, on every transport, by Multi Round-Trip Requests (MRTR, SEP-2322). In the MRTR flow, when the server needs additional input, it returns an InputRequiredResult carrying resultType: "input_required". This result has two fields whose shape is easy to misremember: inputRequests isn't an array, it's a map whose keys are server-assigned string IDs; its values must be ElicitRequest, CreateMessageRequest, or ListRootsRequest — that is, full request objects shaped like {method, params}. The second field, requestState, is an opaque string meaningful only to the server; the client is forbidden from inspecting, parsing, or modifying its content. The server must send at least one of these two in every InputRequiredResult response — and it's requestState that makes the flow genuinely stateless, because the server encodes the context it needs into this field and hands it off to the client, so context isn't lost even if a retry lands on a different instance:
1{2 "jsonrpc": "2.0",3 "id": 1,4 "result": {5 "resultType": "input_required",6 "inputRequests": {7 "github_login": {8 "method": "elicitation/create",9 "params": {10 "mode": "form",11 "message": "Please provide your GitHub username",12 "requestedSchema": {13 "type": "object",14 "properties": { "name": { "type": "string" } },15 "required": ["name"]16 }17 }18 }19 },20 "requestState": "AEAD-protected blob"21 }22}The client retries the original request with a new JSON-RPC `id`, sends requestState back verbatim, and delivers the answers it collected in an inputResponses map, keyed the same way as inputRequests:
1{2 "github_login": {3 "action": "accept",4 "content": { "name": "octocat" }5 }6}Treat requestState as attacker-controlled input on the server side: if it affects authorization, resource access, or business logic, protect its integrity (HMAC or AEAD) and reject any state that fails validation. Backward-compat note: for legacy responses lacking a resultType field, the client should treat it as "complete" automatically.
Backward compatibility and running dual-mode
You don't have to migrate overnight. The spec defines a formal deprecation policy: "A formal deprecation policy with a twelve-month minimum window so you can plan upgrades instead of reacting to them." Within this window, three deprecated features — Roots, Sampling, Logging (SEP-2577) — keep working, so you can migrate gradually.
Auth has a similarly soft transition: Dynamic Client Registration (DCR) is officially deprecated in favor of Client ID Metadata Documents (CIMD), but "DCR continues to work for backward compatibility, but will be removed in a future version of the MCP spec." Your existing DCR integration won't break immediately, but it isn't permanent — plan the move to CIMD on your own timeline, just don't leave it undated.
For a dual-era server, the dispatcher first checks whether _meta is present — if so, route to the modern flow; if not (and an initialize request arrives), route to legacy. SDK support helps too: "All four Tier 1 SDKs speak 2026-07-28 as of today" — TypeScript, Python, Go, and C#; for Rust, "the Rust SDK supports the new spec in beta." Whatever language you write in, updating to the official SDK does most of the migration for you.
Setting up the dispatcher to run dual-mode
The example below is a conceptual skeleton showing how the server-side request dispatcher can distinguish a modern request from a legacy one — not the SDK's own internal implementation, but the decision logic you'd add to your own dispatcher layer:
1type RequestMeta = Record<string, unknown>;2 3type JsonRpcRequest = {4 jsonrpc: "2.0";5 id: string | number;6 method: string;7 params?: { _meta?: RequestMeta; [key: string]: unknown };8};9 10type JsonRpcResponse = { jsonrpc: "2.0"; id: string | number; [key: string]: unknown };11 12declare function respondWithDiscoverInfo(request: JsonRpcRequest): JsonRpcResponse;13declare function dispatchModern(request: JsonRpcRequest, meta: RequestMeta): JsonRpcResponse;14declare function dispatchLegacy(request: JsonRpcRequest): JsonRpcResponse;15declare function invalidParamsError(request: JsonRpcRequest): JsonRpcResponse;16 17function handleIncoming(request: JsonRpcRequest): JsonRpcResponse {18 const meta = request.params?._meta;19 if (meta) {20 // Modern flow: version + capabilities arrive on every request21 if (request.method === "server/discover") {22 return respondWithDiscoverInfo(request); // MUST implement23 }24 return dispatchModern(request, meta);25 }26 27 if (request.method === "initialize") {28 // Legacy flow: run in parallel until your client fleet has migrated29 return dispatchLegacy(request);30 }31 32 // No required _meta and not legacy: malformed request -> -32602 Invalid params33 return invalidParamsError(request);34}The critical detail here is ordering: _meta presence is checked first, since a request lacking required per-request fields — server/discover included — is malformed and must get -32602. If _meta is present, the request enters the modern flow, where the mandatory server/discover is also answered; if absent, only initialize falls into legacy, and everything else gets -32602.
This pattern lets one server binary talk to both pre- and post-2026-07-28 clients — once your whole fleet sends per-request _meta, you can safely drop dispatchLegacy and the initialize handler.
Post-migration verification checklist
After completing the migration, verify each of the following items one by one — each corresponds to a concrete breaking point in the specification:
Check | What it verifies | Where to look |
|---|---|---|
Does server/discover respond | Mandatory RPC implementation | Request/response in server logs |
Correct error on unsupported version request | UnsupportedProtocolVersionError + version list | Test with a manual old-version request |
Mcp-Method/Mcp-Name headers | Gateway route/rate-limit compliance | Reverse proxy access log |
MRTR flow | input_required → inputResponses round trip | Tool call requiring confirmation or a missing parameter |
ttlMs/cacheScope on list responses | Cacheable list results | tools/list, resources/templates/list response |
SDK version | 2026-07-28 support | package.json / go.mod / SDK changelog |
New error codes | Are -32020, -32021, -32022 recognized on the client | Client error-handling tests |
Don't forget that tools/list, prompts/list, resources/list, resources/read, and resources/templates/list responses now carry ttlMs and cacheScope fields (SEP-2549) — if you don't read these fields and cache on the client side, you'll miss the performance gain the spec introduces. Also make sure you handle the new reserved error codes (-32020 HeaderMismatch, -32021 MissingRequiredClientCapability, -32022 UnsupportedProtocolVersion) distinguishably on the client side.
GOLDEN TIP
The most valuable insight in this article
This tip holds the article's most important takeaway.
Easter Egg
You found a hidden gem!
There's a hidden detail in this section. Want to uncover it?
Reader Reward
A step skipped in this migration tends to surface under production load, so check the list below once before migrating and once after. It gathers every required and optional item from this guide into a single sequence.
FAQ
What exactly changed in the MCP 2026-07-28 specification?
Published on July 28, 2026, the specification turned MCP from a bidirectional stateful protocol into a request/response stateless one: the Mcp-Session-Id header and the initialize/initialized handshake were removed, and every request now carries its own protocol version and capabilities in the _meta field. Server-initiated elicitation/sampling/roots calls were also replaced by Multi Round-Trip Requests (MRTR), and a formal Extensions framework was added.
Why was the Mcp-Session-Id header removed, and what replaced it?
The goal was to let any MCP request land on any server instance, eliminating the need for sticky routing or a shared session store — so servers can scale behind a plain round-robin load balancer. When cross-call state is needed, servers now generate an explicit handle and send it back to the model as a parameter.
How do I migrate my existing MCP server to the stateless spec?
Three steps: implement server/discover as mandatory; turn any session state into a handle and make it a tool parameter; keep the initialize flow running alongside older clients for as long as your fleet's timeline needs (the spec gives no separate window here). Make the transition gradual with era detection via a server/discover probe on stdio, and a modern request plus 400-body inspection on HTTP.
Is server/discover mandatory? Should I delete the initialize handshake?
Implementing server/discover is mandatory for servers; the client calling it before every request is optional, and it doubles as a backward-compat probe on stdio. The initialize handshake was removed entirely from the spec, so don't use it in new implementations — but since deprecated features like Roots/Sampling/Logging keep working for at least 12 months, you don't have to break your existing server abruptly.
When should I move from DCR to CIMD?
Not immediately — DCR keeps working for backward compatibility, the spec just promotes CIMD as the new standard. Build new integrations on CIMD and wind down existing DCR flows on your own timeline.
How long can I keep running my legacy HTTP+SSE server?
The blog announcement mentions a year-long offramp for this transport, but the normative deprecated features registry gives the earliest removal date as three months after SEP-2596 reaches Final. Plan around the registry line, and move new feature development to Streamable HTTP and the stateless model within that window.
Conclusion
The stateless model in MCP 2026-07-28 frees the protocol from sticky-session dependency and makes it genuinely horizontally scalable — at the cost of retiring the initialize flow, Mcp-Session-Id, and open-stream server-initiated calls. You don't have to migrate overnight: add server/discover as a mandatory implementation and use a dual-mode dispatcher to carry old and new clients together for a while.
New to MCP? Start with what MCP is and how to integrate it, or, for Claude Code specifically, MCP integration in Claude Code. Sharing a server across multiple agents? Claude Code multi-agent teams and multi-agent coordination with CrewAI are good companions. To design your agent's tool-use loop around the stateless model, the agentic AI tool-use and planner-loop production guide is a good next step; orchestrating MCP servers on a LangGraph agent, see production agent architecture with LangGraph too.
Sources
- MCP 2026-07-28 official announcement — the primary source for the stateless transition, the removal of
Mcp-Session-Idandinitialize/initialized, andserver/discoverand MRTR. - MCP 2026-07-28 Base Protocol overview — the
resultType, per-request_metafield table, and new error codes. - MCP 2026-07-28 MRTR page — the
inputRequests/inputResponsesmap shape,requestStaterules, and security requirements. - MCP deprecated features registry — earliest removal dates for Roots/Sampling/Logging, DCR, and HTTP+SSE.
- MCP 2026-07-28 versioning page — the
server/discoverrequirement,UnsupportedProtocolVersionError, era-detection and cache rules, extension negotiation. - MCP 2026-07-28 transports page — stdio and Streamable HTTP transport rules, custom transport requirements.
- MCP 2026-07-28 changelog — a line-by-line change list with SEP numbers; the exact wording of the
server/discoverrequirement and the session/handshake removal is here. - Claude blog: bringing MCP 2026-07-28 to Claude — a summary of the stateless transition in the context of the Anthropic ecosystem.
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

