Serve MCP protocol revision 2026-07-28 on SDK v2 (#324)
* Serve MCP protocol revision 2026-07-28 on the v2 SDK
The 2026-07-28 revision removes protocol-level sessions: there is no
`initialize` handshake, no `Mcp-Session-Id`, and no GET stream. Every request
carries its own protocol version and client identity in `params._meta`, repeats
them in the `MCP-Protocol-Version`/`Mcp-Method`/`Mcp-Name` headers, and is
answered on its own. Serving it means moving off the monolithic
`@modelcontextprotocol/sdk` (v1) onto the v2 package split — `createMcpHandler`
from `@modelcontextprotocol/server` plus the `toNodeHandler`/`toWebRequest`
adapters from `@modelcontextprotocol/node`.
McpHandler now owns two clearly separated legs behind one endpoint, and routes
between them with the SDK's own `isLegacyRequest` predicate so the classification
can never drift from what the entry itself would do:
- the modern leg is `createMcpHandler(factory, { legacy: "reject" })`, which
serves `server/discover` and every other 2026-07-28 method statelessly, stamps
`resultType` and the server identity `_meta` on results, validates the standard
headers against the body (`-32020`), rejects unsupported versions (`-32022`)
and malformed envelopes (`-32602`), and advertises `ttlMs`/`cacheScope` cache
hints on the cacheable operations;
- the legacy leg is `legacyStatelessFallback`, which keeps answering the
`initialize` handshake for 2024-10-07 … 2025-11-25 clients. It is stateless
too, so the session map and `Mcp-Session-Id` routing are gone entirely and the
removed GET/DELETE session operations answer 405 — which SDK clients already
treat as "no server-initiated stream".
With sessions gone, the handler's tool and resource registries become the only
application state: they live on McpHandler, and `buildServer()` replays them onto
a fresh server per request, so a tool an extension registers is served on the
very next request without patching live sessions. `close()` (called from
`onunload`) tears the modern leg down.
The v2 SDK registers tools with a Standard Schema that can describe itself as
JSON Schema, which zod only grew in v4 — and it rejects a bare zod 3 schema
outright. Moving this plugin to zod 4 would break `addMcpTool`, the extension
API third-party plugins compile against, so src/mcpSchema.ts bridges instead:
validation stays on zod 3's own Standard Schema implementation, and the JSON
Schema is produced by zod-to-json-schema with the same options the v1 SDK used,
so the shapes advertised in `tools/list` are unchanged.
Known limitations: the modern leg advertises only 2026-07-28 in
`server/discover` (legacy clients never call it), and `subscriptions/listen`
notifications are published through the handler's in-process bus, which is all a
single-process plugin needs.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Keep the legacy MCP leg sessionful so listChanged stays true
Review follow-up. Serving the 2025-era revisions through the SDK's stateless
legacy fallback quietly broke a promise the handshake makes: `initialize` still
answered with `tools.listChanged: true` and `resources.listChanged: true`, but a
stateless leg has no connection to notify on, so `notifications/tools/list_changed`
went nowhere. A client that trusts the capability — which is exactly what it is
for — waits for a notification instead of re-polling, so an MCP tool registered
by another plugin through `addMcpTool` stayed invisible to every legacy client
for as long as it kept its connection. Before the 2026-07-28 migration that
worked, because tools were registered on the live per-session server.
The legacy leg is therefore sessionful again, and only the legacy leg: it builds
one server per `initialize`, hands back an `Mcp-Session-Id`, keeps the session in
a map owned by McpHandler, and answers 404 for a session that has ended, exactly
as before. `GET` (the session's notification stream) and `DELETE` (session
termination) work again for those revisions. The modern leg is untouched — it
stays per-request and neither issues nor reads the session header — so the two
legs remain cleanly separated, with the SDK's own `isLegacyRequest` deciding
which one owns a request.
The wiring is v2 throughout: `NodeStreamableHTTPServerTransport` from
`@modelcontextprotocol/node` is the v2 package split's Node Streamable HTTP
transport, so no v1 SDK code returns. `buildServer()` now hands back the
`RegisteredTool` handles alongside the server; the modern leg drops them (its
servers do not outlive a request) and the legacy leg keeps them so a tool
registered or removed after the handshake can be applied to every live session —
which is what makes the SDK emit the list-changed notification. `close()` now
tears down open sessions along with the modern leg.
Tested end to end against a real pre-2026 client (the v1 SDK client, the same one
the integration suite drives) in src/mcpLegacyClient.test.ts: handshake, session
id, tool calls, and the list-changed notification for both registration and
removal. The docs that described the legacy path as stateless — README, the
OpenAPI description, and the endpoint's GET operation — are corrected.
Known limitation: a legacy client that disconnects without sending `DELETE`
leaves its session in the map until the plugin unloads. That is the behavior this
endpoint has always had; the modern path, which has no sessions at all, is the
migration away from it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Let the MCP handler answer unsupported modern protocol versions
Review follow-up. The `/mcp/` router rejected any `MCP-Protocol-Version` value it
did not recognise with a bare `{"error": "Unsupported MCP-Protocol-Version: ..."}`
body, before the request ever reached the MCP handler. On the 2026-07-28 revision
that is the wrong answer and the wrong shape: the specification's reply to an
unserved revision is JSON-RPC `-32022`, whose `data.supported` names the revisions
the endpoint does serve, and that field is precisely how a modern client learns
what to fall back to. A client asking for a revision one day newer than ours got
an opaque string instead, with nothing to negotiate from — and the integration
test asserting `-32022` could never have passed against the mounted endpoint.
The filter now consults the handler's classification before rejecting: an
unrecognised version on a modern-shaped request is passed through so the SDK's
validation ladder can answer it, while legacy-shaped requests keep the plain
rejection they have always had. Because that classification is a property of the
body, `express.json()` moves ahead of the filter; authentication still runs
first, so an unauthenticated caller is answered 401 and never reaches either.
Classification is the SDK's own `isLegacyRequest`, exposed as
`McpHandler.isModernRequest` and shared with `handleRequest`, so the filter and
the leg that ends up serving the request can never disagree. Worth recording,
because it is not obvious: the SDK treats *any* POST whose version header it does
not recognise as modern-shaped, so such a request is now answered with a JSON-RPC
error — `-32022` when the `_meta` envelope claims the unknown revision, `-32602`
when there is no envelope — rather than the bare body. The status stays 400
either way. The plain rejection therefore survives for the body-less legacy
session operations (`GET`, `DELETE`), which carry nothing to classify.
requestHandler.test.ts mocks McpHandler, so it can assert the filter's two
branches but not the order that produces them. src/mcpEndpoint.test.ts is new and
drives the mounted endpoint with the real RequestHandler and the real McpHandler:
authentication before version filtering, both filter branches with their real
bodies, and a request from each leg reaching the right one.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Name the MCP legs by mechanism instead of by era
The two request paths on `/mcp/` were named "modern" and "legacy", following the
SDK's own vocabulary. Those words describe a moment in time: within a couple of
years the 2026-07-28 revision will itself be old, and a reader will have to work
out which era "modern" meant when the comment was written.
The durable difference between the two paths is what the code branches on: one
leg keeps a session (`initialize`, `Mcp-Session-Id`, a notification stream), the
other answers every request on its own. Identifiers now say that — `sessionless`
and `sessionful` — and prose names revisions by date wherever a specific one
matters. `mcpModern.test.ts` becomes `mcpSessionless.test.ts` and
`mcpLegacyClient.test.ts` becomes `mcpSessionfulClient.test.ts`.
Comments and docs anchored to the transition ("still works", "exactly as it was
before the migration", "currently-shipping", "keep the rejection they have always
had") are rewritten as present-tense invariants, since they only made sense to a
reader who remembered the world before this change. `mcpSchema.ts` also gains its
exit condition: delete it once the project's schemas are zod 4.
No behavior changes; `docs/openapi.yaml` and the README table of contents are
regenerated from the edited sources.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Adam Coddington <me@adamcoddington.net>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> A
Anas Khan committed
05abe0d3560180cc8f5d4c968631029fa2f7c41e
Parent: 03d7198
Committed by GitHub <noreply@github.com>
on 8/24/2026, 11:55:46 PM