📖 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
, noMcp-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 at1.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, 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:
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 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 dataiss
validationapplication_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 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.
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.