{"slug": "mcp-went-stateless-migrating-to-the-2026-07-28-spec-and-proving-it-works", "title": "MCP Went Stateless: Migrating to the 2026-07-28 Spec (and Proving It Works)", "summary": "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.", "body_md": "📖 TL;DR\n\n- The\n2026-07-28spec shippedfinal on July 28, 2026(RC locked May 21). It is the biggest revision since MCP launched.Sessions are gone.No`initialize`\n\n, no`Mcp-Session-Id`\n\n. Every request is self-contained.- TypeScript ships as\ntwo new packages—`@modelcontextprotocol/client@2`\n\nand`@modelcontextprotocol/server@2`\n\n. The old`@modelcontextprotocol/sdk`\n\nline lives on at`1.x`\n\nfor 2025-era servers.`server/discover`\n\nis aMUST.`Mcp-Method`\n\n/`Mcp-Name`\n\nheaders are required on Streamable HTTP POSTs.- Roots, Sampling, Logging, and HTTP+SSE are\ndeprecatedwith a 12-month clock.- The new #1 security bug:\nan unsignedblob. It round-trips through the client, which makes it attacker-controlled input.`requestState`\n\nI 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.\n\nNot a spec summary — a diff, a build order, and the two things that broke in ways the changelog does not warn you about.\n\nThis is the mental model shift that makes everything else click.\n\nOlder MCP negotiated a version string during a handshake. The 2026 SDK does not model revisions as a flat list anymore. It models **two eras**:\n\n| Era | Revisions | How the version travels |\n|---|---|---|\n`legacy` |\n2024-10-07 … 2025-11-25 | Negotiated via `initialize` , carries `Mcp-Session-Id`\n|\n`modern` |\n2026-07-28 and later | Rides in `_meta` on every request; `server/discover` advertises capabilities up front |\n\nThese are not points on one line. They are **two different wire protocols** that happen to share JSON-RPC.\n\nOnce you internalize that, the migration stops looking like \"add a feature\" and starts looking like \"support a second protocol.\"\n\nForget 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.\n\nTwo round trips minimum, and a session header you have to carry forever:\n\n```\nPOST /mcp HTTP/1.1\nContent-Type: application/json\n\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\n  \"protocolVersion\":\"2025-11-25\",\n  \"clientInfo\":{\"name\":\"my-client\",\"version\":\"1.0.0\"},\n  \"capabilities\":{}\n}}\n\n→ 200 OK\n   Mcp-Session-Id: 9f2c1e7a-...      ← now sticky-route every later request here\nPOST /mcp HTTP/1.1\nMcp-Session-Id: 9f2c1e7a-...\nContent-Type: application/json\n\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\n  \"name\":\"get_user\",\"arguments\":{\"id\":\"123\"}\n}}\n```\n\nOne round trip. No handshake. Everything the server needs is in the envelope:\n\n```\nPOST /mcp HTTP/1.1\nContent-Type: application/json\nMcp-Method: tools/call\nMcp-Name: get_user\n\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\n  \"name\":\"get_user\",\n  \"arguments\":{\"id\":\"123\"},\n  \"_meta\":{\n    \"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\n    \"io.modelcontextprotocol/clientInfo\":{\"name\":\"my-client\",\"version\":\"1.0.0\"},\n    \"io.modelcontextprotocol/clientCapabilities\":{\"elicitation\":{\"form\":{}}}\n  }\n}}\n```\n\n**Three things to notice.**\n\n`Mcp-Method`\n\nand `Mcp-Name`\n\nare headers now (SEP-2243). A gateway can route on them without parsing the body — that is the whole point.\n\nThe `_meta`\n\nkeys are **fully qualified**: `io.modelcontextprotocol/protocolVersion`\n\n, not `protocolVersion`\n\n. Getting this wrong is a silent no-op, not an error.\n\nAnd the session ID is simply absent. Any instance in your cluster can serve this request.\n\n**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.\n\nThis surprised me and it is good news. v2 did not replace the SDK — it shipped **alongside** it.\n\n```\n{\n  \"@modelcontextprotocol/sdk\":    \"^1.30.0\",  // legacy line, still maintained\n  \"@modelcontextprotocol/client\": \"^2.0.0\",   // new: stateless + auto-fallback\n  \"@modelcontextprotocol/server\": \"^2.0.0\"    // new: for building 2026-spec servers\n}\n```\n\nAll three coexist in one `package.json`\n\n. No fork, no vendoring, no version pinning games.\n\nThere is a codemod for the mechanical parts:\n\n```\nnpx @modelcontextprotocol/codemod@beta v1-to-v2 .\n```\n\nIt handles the renames — `.tool()`\n\nbecomes `registerTool`\n\n, imports move to the new packages. What it cannot do is decide your **statefulness strategy**, which is the actual work.\n\nTool schemas in v2 use [Standard Schema](https://standardschema.dev/), so Zod v4, Valibot, and ArkType all work instead of Zod-only.\n\nThis is the list I actually worked through. Ordered by how much it breaks if you skip it.\n\n**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\n\n`server/discover`\n\n, no modern-era client.**2. Stamp resultType: \"complete\" on every result.** Cheap, mandatory, easy to forget.\n\n**3. Add ttlMs and cacheScope** to\n\n`tools/list`\n\n, `prompts/list`\n\n, `resources/list`\n\n, `resources/read`\n\n, and `resources/templates/list`\n\n. 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.\n\n**5. Swap the HTTP GET stream and resources/subscribe for subscriptions/listen.**\n\n**6. Drop ping, logging/setLevel, and notifications/roots/list_changed** from your modern-era handler.\n\n**7. Fix your error codes.** More on that next, because it is the sneakiest one.\n\nOne trap worth calling out: if your v1 server sets `sessionIdGenerator: undefined`\n\nand 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`\n\nhandshake. It is not 2026-compliant. I had exactly this comment in my own codebase.\n\nThe spec partitioned the JSON-RPC server-error range. `-32000`\n\n…`-32019`\n\nstays implementation-defined; `-32020`\n\n…`-32099`\n\nis now reserved for the spec.\n\n| Code | Meaning | Note |\n|---|---|---|\n`-32020` |\n`HeaderMismatch` |\n`Mcp-Method` disagrees with the body |\n`-32021` |\n`MissingRequiredClientCapability` |\nYou asked for something the client never advertised |\n`-32022` |\n`UnsupportedProtocolVersion` |\nVersion in `_meta` is not supported |\n`-32602` |\nResource not found |\nChanged — was `-32002`\n|\n\nThat last row is the one that will bite you quietly:\n\n```\n# run this in your client codebase right now\ngrep -rn \"32002\" src/\n```\n\nA hardcoded `-32002`\n\ncheck does not throw when the code changes. It just **stops matching**, and your \"resource not found\" branch silently becomes your generic-error branch.\n\nPractical advice: accept both codes when validating, emit the era-appropriate one. The v2 SDK still ships `-32002`\n\nin `ProtocolErrorCode`\n\nfor legacy compatibility, so both are live in the wild.\n\nThe spec added a formal lifecycle — Active → Deprecated → Removed — with a minimum 12-month window. Nothing here disappears before **July 2027**.\n\n`includeContext: \"thisServer\" | \"allServers\"`\n\nDo not add *new* usage of any of these. And audit your docs and UI — mine were still advertising two of them.\n\nHere is the part you will not find in the changelog.\n\n`LATEST_PROTOCOL_VERSION`\n\nis `2025-11-25`\n\nYes, in `@modelcontextprotocol/client@2`\n\n. I assumed it was a packaging mistake. It is not.\n\nThat 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`\n\nexpecting `2026-07-28`\n\n, you have quietly pinned yourself to the old era.\n\nThe era model explains it. Once you stop reading revisions as one ordered list, the naming makes sense.\n\nThe v2 client advertises automatic fallback: point it at a 2025-era server and it detects, falls back to `initialize`\n\n, and connects. Mostly true, and it deletes a lot of detection code you would otherwise write.\n\nThen I shipped it as the single client for everything and broke auth-gated servers.\n\nThe failure: v2's `auto`\n\nmode treats a **failed server/discover probe as fatal**. A 2025-era gateway that answers unknown methods with\n\n`401`\n\nbecomes unreachable — even though the `initialize`\n\nthat would have followed uses the same credentials and succeeds fine.Second, related discovery: v2's `legacy`\n\nmode 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.\n\nWhat actually works is keeping the generations apart behind one facade:\n\n```\n// dispatcher — one facade, two clients underneath\nswitch (preference) {\n  case 'legacy':  return connectV1(url);            // exact pre-2026 flow, untouched\n  case 'modern':  return connectV2(url, '2026-07-28'); // fail loudly, no fallback\n  case 'auto':                                       // default\n  default:\n    try   { return await connectV2(url);  }          // try stateless first\n    catch { return await connectV1(url);  }          // on ANY failure, fall back to v1\n}\n```\n\n**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`\n\nfall back to the flow you already trust.\n\nOne more sharp edge inside that facade: `callTool`\n\n'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.\n\nMigration is not only removal. Three additions are worth adopting deliberately.\n\n**MRTR (Multi Round-Trip Requests).** A server can return an `InputRequiredResult`\n\nmid-tool-call. The client gathers answers, echoes back the `requestState`\n\n, and re-issues the original request. This is how a tool asks a clarifying question without failing.\n\nIf your agent loop does not implement it, a fully compliant server just looks broken to you.\n\nThe 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`\n\nand `_mcpRequestState`\n\n, an `input_required`\n\nresult 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.\n\n**Tasks.** Now an extension (`io.modelcontextprotocol/tasks`\n\n), not core. Poll `tasks/get`\n\n, `tasks/update`\n\n, `tasks/cancel`\n\n. 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.\n\n**MCP Apps.** Servers return HTML rendered in a sandboxed iframe; UI actions route back through JSON-RPC. The demo value here is high.\n\nAlso free with the upgrade: full **JSON Schema 2020-12** in tool inputs (`oneOf`\n\n, `anyOf`\n\n, `allOf`\n\n, `$ref`\n\n) and standardized **W3C Trace Context** propagation. If you were flattening complex inputs to work around old schema limits, you can stop.\n\nStatelessness moved state onto the wire, and wire state is attacker-controlled.\n\nUnder MRTR, your server mints a `requestState`\n\nblob, 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.\n\nIf an unprotected blob influences authorization or business logic, that is direct exploitation — tamper with the blob, resume someone else's operation.\n\nSign it. Use the SDK's `createRequestStateCodec`\n\nrather than rolling your own MAC.\n\nThe other new-in-2026 checks worth running against yourself:\n\n`cacheScope: \"public\"`\n\non tenant-scoped data`iss`\n\nvalidation`application_type`\n\nYou can get the first 20% with curl. Fire a `tools/call`\n\nwith no handshake and see whether it works:\n\n```\ncurl -sS https://your-server.example.com/mcp \\\n  -H 'Content-Type: application/json' \\\n  -H 'Mcp-Method: tools/list' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{\n        \"_meta\":{\n          \"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\n          \"io.modelcontextprotocol/clientInfo\":{\"name\":\"curl\",\"version\":\"1.0\"}\n        }}}' | jq\n```\n\nIf that returns tools with `ttlMs`\n\nand `cacheScope`\n\n, you are on the new era. If it returns `-32022`\n\n, you are not.\n\nIronically, 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.\n\nThe interesting part is everything curl cannot answer:\n\n`oneOf`\n\n?`InputRequiredResult`\n\n, `-32022`\n\n, and a `requestState`\n\n`requestState`\n\nactually 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`\n\n, `-32022 UnsupportedProtocolVersion`\n\n, and a deliberately tampered `requestState`\n\n, so you can exercise your retry loop against hostile-but-legal responses. No install, runs in the browser.\n\nIf I did it again, in this order:\n\n`protocolVersion`\n\nhardcoded in four files, pinned to a revision `legacy`\n\n/ `modern`\n\n/ `auto`\n\n) so nothing existing changes behavior.`server/discover`\n\n, `resultType`\n\n, `ttlMs`\n\n/ `cacheScope`\n\nto the server.`Mcp-Method`\n\n/ `Mcp-Name`\n\nhandling.`requestState`\n\nbefore 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.\n\nThe 2026-07-28 spec deletes the handshake, makes `server/discover`\n\nmandatory, moves protocol metadata into `_meta`\n\n, changes error codes, and starts a 12-month clock on Roots, Sampling, and Logging. The migration is a checklist, not a rewrite.\n\nKeep your old path intact, add the new one beside it, sign your `requestState`\n\n, and then prove both eras work before your users find the gap.\n\n**Further reading**\n\n*Migrating something now? Tell me what broke — I am collecting the failure modes that are not in the changelog.*", "url": "https://wpnews.pro/news/mcp-went-stateless-migrating-to-the-2026-07-28-spec-and-proving-it-works", "canonical_source": "https://dev.to/rupa_tiwari_dd308948d710f/mcp-went-stateless-migrating-to-the-2026-07-28-spec-and-proving-it-works-174", "published_at": "2026-07-30 18:06:09+00:00", "updated_at": "2026-07-30 18:32:56.680225+00:00", "lang": "en", "topics": ["developer-tools", "ai-infrastructure"], "entities": ["Model Context Protocol", "MCP", "@modelcontextprotocol/client", "@modelcontextprotocol/server", "@modelcontextprotocol/sdk"], "alternates": {"html": "https://wpnews.pro/news/mcp-went-stateless-migrating-to-the-2026-07-28-spec-and-proving-it-works", "markdown": "https://wpnews.pro/news/mcp-went-stateless-migrating-to-the-2026-07-28-spec-and-proving-it-works.md", "text": "https://wpnews.pro/news/mcp-went-stateless-migrating-to-the-2026-07-28-spec-and-proving-it-works.txt", "jsonld": "https://wpnews.pro/news/mcp-went-stateless-migrating-to-the-2026-07-28-spec-and-proving-it-works.jsonld"}}