MCP Went Stateless: Migrating to the 2026-07-28 Spec (and Proving It Works) A developer migrated a platform to the 2026-07-28 MCP spec, which removes sessions and the initialize handshake in favor of self-contained requests with _meta metadata. The migration revealed that the new spec is a second wire protocol, not a revision, and that v2 SDK packages ship alongside the legacy v1 line. Two breaking issues not documented in the changelog were encountered: the unsigned blob security bug and the fully qualified _meta keys that silently fail if wrong. πŸ“– TL;DR - The 2026-07-28spec shippedfinal on July 28, 2026 RC locked May 21 . It is the biggest revision since MCP launched.Sessions are gone.No initialize , no Mcp-Session-Id . Every request is self-contained.- TypeScript ships as two new packagesβ€” @modelcontextprotocol/client@2 and @modelcontextprotocol/server@2 . The old @modelcontextprotocol/sdk line lives on at 1.x for 2025-era servers. server/discover is aMUST. Mcp-Method / Mcp-Name headers are required on Streamable HTTP POSTs.- Roots, Sampling, Logging, and HTTP+SSE are deprecatedwith a 12-month clock.- The new 1 security bug: an unsignedblob. It round-trips through the client, which makes it attacker-controlled input. requestState I spent last week migrating a platform that talks to other people's MCP servers in both revisions. This is the writeup I wanted before I started. Not a spec summary β€” a diff, a build order, and the two things that broke in ways the changelog does not warn you about. This is the mental model shift that makes everything else click. Older MCP negotiated a version string during a handshake. The 2026 SDK does not model revisions as a flat list anymore. It models two eras : | Era | Revisions | How the version travels | |---|---|---| legacy | 2024-10-07 … 2025-11-25 | Negotiated via initialize , carries Mcp-Session-Id | modern | 2026-07-28 and later | Rides in meta on every request; server/discover advertises capabilities up front | These are not points on one line. They are two different wire protocols that happen to share JSON-RPC. Once you internalize that, the migration stops looking like "add a feature" and starts looking like "support a second protocol." Forget SDK APIs for a second. Here is the part that matters, because it is what your gateway, your load balancer, and your security scanner all see. Two round trips minimum, and a session header you have to carry forever: POST /mcp HTTP/1.1 Content-Type: application/json {"jsonrpc":"2.0","id":1,"method":"initialize","params":{ "protocolVersion":"2025-11-25", "clientInfo":{"name":"my-client","version":"1.0.0"}, "capabilities":{} }} β†’ 200 OK Mcp-Session-Id: 9f2c1e7a-... ← now sticky-route every later request here POST /mcp HTTP/1.1 Mcp-Session-Id: 9f2c1e7a-... Content-Type: application/json {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{ "name":"get user","arguments":{"id":"123"} }} One round trip. No handshake. Everything the server needs is in the envelope: POST /mcp HTTP/1.1 Content-Type: application/json Mcp-Method: tools/call Mcp-Name: get user {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{ "name":"get user", "arguments":{"id":"123"}, " meta":{ "io.modelcontextprotocol/protocolVersion":"2026-07-28", "io.modelcontextprotocol/clientInfo":{"name":"my-client","version":"1.0.0"}, "io.modelcontextprotocol/clientCapabilities":{"elicitation":{"form":{}}} } }} Three things to notice. Mcp-Method and Mcp-Name are headers now SEP-2243 . A gateway can route on them without parsing the body β€” that is the whole point. The meta keys are fully qualified : io.modelcontextprotocol/protocolVersion , not protocolVersion . Getting this wrong is a silent no-op, not an error. And the session ID is simply absent. Any instance in your cluster can serve this request. The payoff: a remote server that needed sticky sessions and a shared session store can now sit behind a plain round-robin load balancer. That is the real reason this change happened. This surprised me and it is good news. v2 did not replace the SDK β€” it shipped alongside it. { "@modelcontextprotocol/sdk": "^1.30.0", // legacy line, still maintained "@modelcontextprotocol/client": "^2.0.0", // new: stateless + auto-fallback "@modelcontextprotocol/server": "^2.0.0" // new: for building 2026-spec servers } All three coexist in one package.json . No fork, no vendoring, no version pinning games. There is a codemod for the mechanical parts: npx @modelcontextprotocol/codemod@beta v1-to-v2 . It handles the renames β€” .tool becomes registerTool , imports move to the new packages. What it cannot do is decide your statefulness strategy , which is the actual work. Tool schemas in v2 use Standard Schema https://standardschema.dev/ , so Zod v4, Valibot, and ArkType all work instead of Zod-only. This is the list I actually worked through. Ordered by how much it breaks if you skip it. 1. Implement server/discover. It is a MUST in the new spec. It replaces the handshake as the way a client learns your capabilities and supported versions. No server/discover , no modern-era client. 2. Stamp resultType: "complete" on every result. Cheap, mandatory, easy to forget. 3. Add ttlMs and cacheScope to tools/list , prompts/list , resources/list , resources/read , and resources/templates/list . Clients now cache tool lists like HTTP responses. 4. Order tools/list deterministically. A SHOULD, not a MUST β€” but unstable ordering defeats client-side caching, so it is effectively required. 5. Swap the HTTP GET stream and resources/subscribe for subscriptions/listen. 6. Drop ping, logging/setLevel, and notifications/roots/list changed from your modern-era handler. 7. Fix your error codes. More on that next, because it is the sneakiest one. One trap worth calling out: if your v1 server sets sessionIdGenerator: undefined and you have a comment nearby that says "stateless mode," that is not this . That is v1's stateless- session mode. It still performs the initialize handshake. It is not 2026-compliant. I had exactly this comment in my own codebase. The spec partitioned the JSON-RPC server-error range. -32000 … -32019 stays implementation-defined; -32020 … -32099 is now reserved for the spec. | Code | Meaning | Note | |---|---|---| -32020 | HeaderMismatch | Mcp-Method disagrees with the body | -32021 | MissingRequiredClientCapability | You asked for something the client never advertised | -32022 | UnsupportedProtocolVersion | Version in meta is not supported | -32602 | Resource not found | Changed β€” was -32002 | That last row is the one that will bite you quietly: run this in your client codebase right now grep -rn "32002" src/ A hardcoded -32002 check does not throw when the code changes. It just stops matching , and your "resource not found" branch silently becomes your generic-error branch. Practical advice: accept both codes when validating, emit the era-appropriate one. The v2 SDK still ships -32002 in ProtocolErrorCode for legacy compatibility, so both are live in the wild. The spec added a formal lifecycle β€” Active β†’ Deprecated β†’ Removed β€” with a minimum 12-month window. Nothing here disappears before July 2027 . includeContext: "thisServer" | "allServers" Do not add new usage of any of these. And audit your docs and UI β€” mine were still advertising two of them. Here is the part you will not find in the changelog. LATEST PROTOCOL VERSION is 2025-11-25 Yes, in @modelcontextprotocol/client@2 . I assumed it was a packaging mistake. It is not. That constant means "the newest legacy-era version, used for the fallback handshake." It is not the newest revision overall. If you write protocolVersion: LATEST PROTOCOL VERSION expecting 2026-07-28 , you have quietly pinned yourself to the old era. The era model explains it. Once you stop reading revisions as one ordered list, the naming makes sense. The v2 client advertises automatic fallback: point it at a 2025-era server and it detects, falls back to initialize , and connects. Mostly true, and it deletes a lot of detection code you would otherwise write. Then I shipped it as the single client for everything and broke auth-gated servers. The failure: v2's auto mode treats a failed server/discover probe as fatal . A 2025-era gateway that answers unknown methods with 401 becomes unreachable β€” even though the initialize that would have followed uses the same credentials and succeeds fine.Second, related discovery: v2's legacy mode is not bug-for-bug identical to the v1 SDK against non-conforming servers. So "just pin to 2025-11-25 on the new client" is also not the old flow. What actually works is keeping the generations apart behind one facade: // dispatcher β€” one facade, two clients underneath switch preference { case 'legacy': return connectV1 url ; // exact pre-2026 flow, untouched case 'modern': return connectV2 url, '2026-07-28' ; // fail loudly, no fallback case 'auto': // default default: try { return await connectV2 url ; } // try stateless first catch { return await connectV1 url ; } // on ANY failure, fall back to v1 } The rule I would tattoo on this migration: never route your existing working path through the new client. Add the new generation beside the old one, dispatch between them, and let auto fall back to the flow you already trust. One more sharp edge inside that facade: callTool 's second argument is a result schema in v1 and request options in v2. Same method name, different meaning. Forward options only on the modern path. Migration is not only removal. Three additions are worth adopting deliberately. MRTR Multi Round-Trip Requests . A server can return an InputRequiredResult mid-tool-call. The client gathers answers, echoes back the requestState , and re-issues the original request. This is how a tool asks a clarifying question without failing. If your agent loop does not implement it, a fully compliant server just looks broken to you. The design question MRTR forces: who answers the question? An auto-approve policy that fills in defaults defeats the point β€” the server asked because it needed a decision. In Agent Studio https://mcpplaygroundonline.com/mcp-agent-studio I wired the answers to come from the model itself: the tool schema carries mcpInputResponses and mcpRequestState , an input required result goes back to the model as retry instructions, and the whole exchange stays visible in the transcript. If you are building an agent loop against a 2026 server, that is the shape I would copy. Tasks. Now an extension io.modelcontextprotocol/tasks , not core. Poll tasks/get , tasks/update , tasks/cancel . Note that tasks/list was removed and task creation is server-directed. Code written against the experimental core Tasks API from 2025-11-25 needs updating. MCP Apps. Servers return HTML rendered in a sandboxed iframe; UI actions route back through JSON-RPC. The demo value here is high. Also free with the upgrade: full JSON Schema 2020-12 in tool inputs oneOf , anyOf , allOf , $ref and standardized W3C Trace Context propagation. If you were flattening complex inputs to work around old schema limits, you can stop. Statelessness moved state onto the wire, and wire state is attacker-controlled. Under MRTR, your server mints a requestState blob, hands it to the client, and receives it back on the retry. The client can modify it in between. The spec makes integrity protection a MUST, and the SDK explicitly does not do it for you. If an unprotected blob influences authorization or business logic, that is direct exploitation β€” tamper with the blob, resume someone else's operation. Sign it. Use the SDK's createRequestStateCodec rather than rolling your own MAC. The other new-in-2026 checks worth running against yourself: cacheScope: "public" on tenant-scoped data iss validation application type You can get the first 20% with curl. Fire a tools/call with no handshake and see whether it works: curl -sS https://your-server.example.com/mcp \ -H 'Content-Type: application/json' \ -H 'Mcp-Method: tools/list' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{ " meta":{ "io.modelcontextprotocol/protocolVersion":"2026-07-28", "io.modelcontextprotocol/clientInfo":{"name":"curl","version":"1.0"} }}}' | jq If that returns tools with ttlMs and cacheScope , you are on the new era. If it returns -32022 , you are not. Ironically, statelessness made this commodity check easier β€” curl and Postman can now do the raw protocol ping they never could before. Good. That was never the interesting part. The interesting part is everything curl cannot answer: oneOf ? InputRequiredResult , -32022 , and a requestState requestState actually signed, or did I just assume the SDK did it?That is the gap I built MCP Playground https://mcpplaygroundonline.com/ around, and the 2026 spec is fully wired into it. Paste a URL and there is a protocol switch on every test surface: Auto detect and show the negotiated revision , Force 2025-11-25 , or Force 2026-07-28 β€” so you can prove your server answers both eras instead of hoping. If you are building a client , the test client https://mcpplaygroundonline.com/mcp-test-client points at mock servers in either revision, including ones that return InputRequiredResult , -32022 UnsupportedProtocolVersion , and a deliberately tampered requestState , so you can exercise your retry loop against hostile-but-legal responses. No install, runs in the browser. If I did it again, in this order: protocolVersion hardcoded in four files, pinned to a revision legacy / modern / auto so nothing existing changes behavior. server/discover , resultType , ttlMs / cacheScope to the server. Mcp-Method / Mcp-Name handling. requestState before you ship MRTR β€” not after.Step 8 is the one people skip. Regressions in an MCP migration are rarely loud β€” a tool quietly stops resolving, an auth flow fails silently, and you find out from a user. The 2026-07-28 spec deletes the handshake, makes server/discover mandatory, moves protocol metadata into meta , changes error codes, and starts a 12-month clock on Roots, Sampling, and Logging. The migration is a checklist, not a rewrite. Keep your old path intact, add the new one beside it, sign your requestState , and then prove both eras work before your users find the gap. Further reading Migrating something now? Tell me what broke β€” I am collecting the failure modes that are not in the changelog.