📖 TL;DR

  • The 2026-07-28 spec shipped final 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 a MUST. Mcp-Method / Mcp-Name headers are required on Streamable HTTP POSTs.
  • Roots, Sampling, Logging, and HTTP+SSE are deprecated with a 12-month clock.
  • The new #1 security bug: an unsigned requestState blob. It round-trips through the client, which makes it attacker-controlled input.

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.

Stop thinking "version bump." Start thinking "era."

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."

The wire diff: what a request actually looks like now

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.

Before (2025-11-25, stateful)

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

Enter fullscreen mode Exit fullscreen mode

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"}
}}

Enter fullscreen mode Exit fullscreen mode

After (2026-07-28, stateless)

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":{}}}
  }
}}

Enter fullscreen mode Exit fullscreen mode

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.

Install: two packages, not one upgrade

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
}

Enter fullscreen mode Exit fullscreen mode

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 .

Enter fullscreen mode Exit fullscreen mode

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, so Zod v4, Valibot, and ArkType all work instead of Zod-only.

The server checklist: seven concrete changes

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.

Error codes: grep for these today

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/

Enter fullscreen mode Exit fullscreen mode

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.

Deprecations: the 12-month clock started

The spec added a formal lifecycle — Active → Deprecated → Removed — with a minimum 12-month window. Nothing here disappears before July 2027.

  • Roots → pass scope explicitly via tool parameters, resource URIs, or config.
  • Sampling → call the LLM API directly from your server. Fewer round trips anyway.
  • Logging → stderr or OpenTelemetry.
  • HTTP+SSE transport → Streamable HTTP.
  • includeContext: "thisServer" | "allServers"
  • OAuth Dynamic Client Registration → Client ID Metadata Documents.

Do not add new usage of any of these. And audit your docs and UI — mine were still advertising two of them.

Two gotchas that cost me a day

Here is the part you will not find in the changelog.

Gotcha 1: 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.

Gotcha 2: auto-fallback is not a free lunch

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
}

Enter fullscreen mode Exit fullscreen mode

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.

The new surface you actually want

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 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.

The security bug this revision created

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 — leaks across users via any shared intermediary.
  • Missing iss validation in auth responses — RFC 9207 mix-up mitigation, now mandatory.
  • Missing application_type during client registration — OIDC redirect URI conflicts.
  • Credentials reused across issuers — must be keyed by issuer identity.

Scan your MCP server →

How to actually test the migrated server

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

Enter fullscreen mode Exit fullscreen mode

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:

  • Does my server behave the same on both revisions, for the clients that have not migrated?
  • Does a real model still pick the right tool now that my schemas use oneOf?
  • Does my client handle InputRequiredResult, -32022, and a tampered requestState correctly?
  • Is my requestState actually signed, or did I just assume the SDK did it?

How MCP Playground helps

That is the gap I built MCP Playground 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 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.

Test any MCP server free →

My migration order

If I did it again, in this order:

  1. Centralize your protocol constants first. I found protocolVersion hardcoded in four files, pinned to a revision 20 months out of date. Fix that before anything else or you will chase the same strings again next revision.
  2. Add v2 packages alongside v1. Do not remove anything yet.
  3. Build the dispatcher (legacy / modern / auto) so nothing existing changes behavior.
  4. Add server/discover, resultType, ttlMs / cacheScope to the server.
  5. Fix error codes, add Mcp-Method / Mcp-Name handling.
  6. Sign requestState before you ship MRTR — not after.
  7. Swap deprecated features on the 12-month clock, separately.
  8. Run both revisions against a real model and confirm every tool still resolves.

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.

Wrapping up

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.

Test any MCP server free →


Further reading

Migrating something now? Tell me what broke — I am collecting the failure modes that are not in the changelog.