📖 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, noMcp-Session-Id. Every request is self-contained.- TypeScript ships as two new packages —
@modelcontextprotocol/client@2and@modelcontextprotocol/server@2. The old@modelcontextprotocol/sdkline lives on at1.xfor 2025-era servers.server/discoveris a MUST.Mcp-Method/Mcp-Nameheaders 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
requestStateblob. 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
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"}
}}
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":{}}}
}
}}
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
}
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, 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/
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
}
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
issvalidation in auth responses — RFC 9207 mix-up mitigation, now mandatory. -
Missing
application_typeduring client registration — OIDC redirect URI conflicts. - Credentials reused across issuers — must be keyed by issuer identity.
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
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 tamperedrequestStatecorrectly? - Is my
requestStateactually 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.
My migration order
If I did it again, in this order:
-
Centralize your protocol constants first. I found
protocolVersionhardcoded 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. - Add v2 packages alongside v1. Do not remove anything yet.
- Build the dispatcher (
legacy/modern/auto) so nothing existing changes behavior. - Add
server/discover,resultType,ttlMs/cacheScopeto the server. - Fix error codes, add
Mcp-Method/Mcp-Namehandling. - Sign
requestStatebefore you ship MRTR — not after. - Swap deprecated features on the 12-month clock, separately.
- 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.
Further reading
- MCP 2026-07-28 changelog — official
- Migrate Your MCP Server to the 2026-07-28 Spec — canonical version of this post
- Build an MCP Server on the 2026-07-28 Spec — starting fresh instead of migrating
- MCP Goes Stateless — why the session model was removed
- What Is the Model Context Protocol? — background
Migrating something now? Tell me what broke — I am collecting the failure modes that are not in the changelog.
Top comments (0)