diff --git a/.changeset/server-era-support.md b/.changeset/server-era-support.md deleted file mode 100644 index c805112f56..0000000000 --- a/.changeset/server-era-support.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@modelcontextprotocol/server': minor ---- - -Add `ServerOptions.eraSupport: 'legacy' | 'dual-era' | 'modern'`, the opt-in for serving the 2026-07-28 draft revision on long-lived connections such as stdio. The default is `'legacy'` and preserves today's behavior exactly: nothing 2026-era is registered or advertised, and 2025 -wire behavior is unchanged by the upgrade. `'dual-era'` serves both protocol eras on the same connection, selecting the era per message (`initialize`-negotiated 2025 traffic as before, per-request `_meta` envelope traffic — including `server/discover` — on the modern era), while -methods that exist in only one era stay invisible to the other. `'modern'` is strict 2026-only: requests without the envelope (including `initialize`) are answered with the unsupported-protocol-version error naming the supported revisions. A 2026-era revision in -`supportedProtocolVersions` now requires declaring `eraSupport` (`'dual-era'` or `'modern'`); on a default `'legacy'` instance it throws a `TypeError` at construction instead of silently installing the `server/discover` handler. On dual-era instances the deprecated -client-identity accessors keep their `initialize`-scoped semantics and are never backfilled from 2026-era requests; handlers read per-request identity from `ctx.mcpReq.envelope`. diff --git a/.changeset/server-serve-stdio.md b/.changeset/server-serve-stdio.md new file mode 100644 index 0000000000..d7331aaaeb --- /dev/null +++ b/.changeset/server-serve-stdio.md @@ -0,0 +1,11 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Add `serveStdio(factory, options?)` (exported from `@modelcontextprotocol/server/stdio`), the connection-pinned stdio entry point for serving the 2026-07-28 draft revision on long-lived connections. The entry owns the transport and the era decision: the client's opening +exchange selects the era (a 2025 `initialize` handshake, 2026-07-28 per-request `_meta` envelope traffic, or a `server/discover` probe followed by either), and ONE instance from the factory is pinned to the connection and serves only that era — mirroring how +`createMcpHandler` classifies each HTTP request before constructing an instance. 2025-era openings are served by default; `legacy: 'reject'` answers them with the unsupported-protocol-version error naming the supported modern revisions instead. A `transport` option +accepts a bring-your-own `StdioServerTransport` (for example over a Unix domain socket); `onerror` reports out-of-band errors; the returned handle's `close()` tears the connection down. + +Removed: `ServerOptions.eraSupport` (introduced in an earlier 2.0 alpha, never in a stable release). A hand-constructed `Server`/`McpServer` serves only the 2025-era protocol it was written for; serving the 2026-07-28 revision always goes through a serving entry. Migrate +`new McpServer(info, { eraSupport: 'dual-era' })` + `connect(new StdioServerTransport())` to `serveStdio(() => new McpServer(info))`, and `eraSupport: 'modern'` to `serveStdio(factory, { legacy: 'reject' })`. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 311ae8d956..f8014412fb 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -552,11 +552,12 @@ These can require code changes: ### Server (stdio / long-lived connections) -- `ServerOptions.eraSupport?: 'legacy' | 'dual-era' | 'modern'` declares which protocol eras a hand-constructed `Server`/`McpServer` serves on its long-lived connection. Default `'legacy'` = today's behavior, byte-identical: do not add the option during a mechanical migration. -- Serving the 2026-07-28 draft revision on stdio is the explicit opt-in `new McpServer(info, { eraSupport: 'dual-era' })` with an unchanged `connect(new StdioServerTransport())`. `'modern'` is strict 2026-only (envelope-less requests, including `initialize`, get the - unsupported-protocol-version error). -- A 2026-era revision in `supportedProtocolVersions` now requires `eraSupport: 'dual-era' | 'modern'`; on a default (`'legacy'`) instance it throws a `TypeError` at construction (previously it silently installed the `server/discover` handler). -- On dual-era instances `getClientCapabilities()` / `getClientVersion()` / `getNegotiatedProtocolVersion()` keep `initialize`-scoped semantics and are never backfilled from 2026-era requests; handlers read per-request identity from `ctx.mcpReq.envelope`. +- A hand-constructed `Server`/`McpServer` connected to a `StdioServerTransport` serves only the 2025-era protocol it was written for: today's behavior, byte-identical — no change required during a mechanical migration. +- Serving the 2026-07-28 draft revision (or both eras) on stdio goes through the connection-pinned entry: `serveStdio(() => new McpServer(info, options))` from `@modelcontextprotocol/server/stdio`. The opening exchange selects the connection's era (2025 `initialize` vs + 2026 per-request envelope, with `server/discover` answered as a probe); one factory instance is pinned per connection. There is no per-instance option that makes a hand-constructed server serve the 2026 revision: move the v1 `server.connect(new StdioServerTransport())` + call into `serveStdio(() => buildServer())`. `serveStdio(factory, { legacy: 'reject' })` refuses 2025-era openings with the unsupported-protocol-version error. +- On 2026-pinned stdio connections `getClientCapabilities()` / `getClientVersion()` return `undefined` (no `initialize` ever runs there) and handlers read per-request identity from `ctx.mcpReq.envelope`; `getNegotiatedProtocolVersion()` reports the pinned revision + (`2026-07-28`), as on instances served through `createMcpHandler`. 2025-pinned connections keep the `initialize`-scoped semantics for all three accessors. - A client whose connection negotiated a modern era drops inbound server→client JSON-RPC requests (the 2026 era has no such channel) instead of answering them; legacy-era connections are unchanged. ## 14. Runtime-Specific JSON Schema Validators (Enhancement) diff --git a/docs/migration.md b/docs/migration.md index 70ef7df0e8..1e9cbfdbde 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1025,9 +1025,9 @@ versionNegotiation: { } ``` -On the server side, a `Server`/`McpServer` serves `server/discover` (advertising only its modern revisions) when it declares modern-era support via the `eraSupport` option (see the stdio section below); servers constructed without it are byte-identical to before (they keep -answering `-32601`, and the `initialize` handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the -next section; serving it on stdio (and other long-lived connections) is the `eraSupport` server option described after that. The client can also issue the request directly via `client.discover()` on a 2026-era connection — a full typed round trip needs each request to carry the per-request `_meta` envelope (the negotiation probe +On the server side, `server/discover` (advertising only the modern revisions) is served by instances hosted through one of the 2026-era serving entries; a hand-constructed `Server`/`McpServer` is byte-identical to before (it keeps answering `-32601`, and the `initialize` +handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and +other long-lived connections) is the `serveStdio` entry point described after that. The client can also issue the request directly via `client.discover()` on a 2026-era connection — a full typed round trip needs each request to carry the per-request `_meta` envelope (the negotiation probe already does; automatic envelope emission for every request is a client-side follow-up) — while on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. ### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler` @@ -1068,38 +1068,41 @@ The entry performs no Origin/Host validation (see the origin-validation middlewa request headers. Power users who want to compose routing themselves can use the exported `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed around (`const { fetch } = handler`). -### Serving the 2026-07-28 draft revision on stdio: `eraSupport` +### Serving the 2026-07-28 draft revision on stdio: `serveStdio` -A hand-constructed `Server`/`McpServer` — the shape every stdio server has — now takes an `eraSupport` option declaring which protocol eras it serves on its long-lived connection. **The default is `'legacy'`: if you do nothing, your server keeps speaking exactly the -2025-era protocol it was written for** — the `initialize` handshake, the same wire bytes, no `server/discover`, nothing new advertised — and upgrading the SDK changes nothing about what it puts on the wire. - -Serving the 2026-07-28 draft revision is one explicit option; the transport stays unchanged: +The server package ships a stdio entry point that mirrors `createMcpHandler` for long-lived connections: the entry owns the transport and the era decision, the client's opening exchange selects the era for the connection, and ONE instance from your factory is pinned to +that connection and serves only that era. ```typescript import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; -const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { eraSupport: 'dual-era' }); -await server.connect(new StdioServerTransport()); +serveStdio(() => { + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + // register tools/resources/prompts once — the same factory serves both eras + return server; +}); ``` -What the values mean: +How the connection's era is decided: -- **`'legacy'` (default)** — today's behavior, unchanged: 2025-era serving negotiated via `initialize`. `server/discover` is not registered or advertised. Declaring a 2026-era revision in `supportedProtocolVersions` without changing `eraSupport` is now a construction-time - `TypeError` (previously it silently installed the discover handler) — serving the new revision is always an explicit declaration, never a side effect of a version list. -- **`'dual-era'`** — both eras on the same connection, selected per message: plain 2025 clients keep using `initialize` and are served exactly as before, while 2026-capable clients negotiate via `server/discover` on the same pipe and every request carrying the per-request - `_meta` envelope is served on the modern era. Methods that exist in only one era stay invisible to the other: a 2025-era client asking for a 2026-only method (such as `server/discover` without an envelope) gets the same plain `-32601` a 2025 server would send, and a - 2026-era request for a removed method (such as `logging/setLevel`) gets `-32601` too. -- **`'modern'`** — strict 2026-only: requests without the per-request envelope (including `initialize`) are answered with the unsupported-protocol-version error naming the supported revisions; legacy-era notifications are dropped. +- A plain 2025 client opens with the `initialize` handshake (or any request without the per-request `_meta` envelope): the connection is pinned to a 2025-era instance and served exactly as a hand-wired stdio server serves it today. Pass `legacy: 'reject'` to refuse + 2025-era openings instead — they are answered with the unsupported-protocol-version error naming the supported modern revisions, and there is no silent 2025 serving. +- A 2026-capable client opens with requests carrying the per-request `_meta` envelope: the connection is pinned to a 2026-era instance. +- A `server/discover` probe is answered (from an instance built with your factory, so the advertisement reflects your real server definition) without pinning the connection: the client either continues with enveloped modern requests — pinning the connection to the 2026 + era — or falls back to `initialize` when it shares no modern revision with the advertisement, in which case the probe instance is discarded and a fresh 2025-era instance serves the handshake. Once the modern era is pinned, a later `initialize` is rejected with the + unsupported-protocol-version error naming the supported revisions. -Declaring `'dual-era'` or `'modern'` automatically adds the SDK's supported modern revisions to `supportedProtocolVersions`, and `'modern'` serves only those: a strict instance's supported list (what `server/discover` advertises and version-mismatch errors name) is modern-only. +Because the entry may construct an instance for a probe that is later discarded (and `createMcpHandler` constructs one per request), factories should be cheap and side-effect-free. Bring your own transport with the `transport` option (for example a +`StdioServerTransport` over a Unix domain socket or TCP stream); by default the entry serves the current process's stdio. The returned handle's `close()` tears down the pinned instance and the transport. -Directionality follows the era of the traffic: the 2026-07-28 revision has no server→client JSON-RPC request channel, so a `'modern'` instance cannot emit `sampling`/`elicitation`/`roots` wire requests (they fail locally with a typed error), while a `'dual-era'` instance -can still send them to the 2025-era clients it serves via `initialize`. On a `'dual-era'` instance the same local typed error applies per request: a handler that is serving a 2026-era request cannot send server→client requests through its request context -(`ctx.mcpReq.send`, `ctx.mcpReq.elicitInput`, `ctx.mcpReq.requestSampling`) — only handlers serving 2025-era requests can. Symmetrically, a client whose connection negotiated a modern era drops inbound JSON-RPC requests instead of answering them. +Directionality follows the connection's era: the 2026-07-28 revision has no server→client JSON-RPC request channel, so handlers serving a 2026-pinned connection cannot emit `sampling`/`elicitation`/`roots` wire requests (they fail locally with a typed error), while a +2025-pinned connection keeps today's behavior. Symmetrically, a client whose connection negotiated a modern era drops inbound JSON-RPC requests instead of answering them. -Declaring `eraSupport: 'dual-era'` is also an assertion that your handlers are ready to serve modern-era requests (for example, that they read per-request client identity from `ctx.mcpReq.envelope` rather than the connection-scoped accessors — see the next section). A -future release may add per-handler era declarations as the basis for a safe automatic default; for now the connection-level `eraSupport` option is the whole opt-in surface. +**The v1 stdio pattern keeps working and stays 2025-only.** A hand-constructed `Server`/`McpServer` connected directly to a `StdioServerTransport` — the way every v1 stdio server is written — still works and serves only the 2025-era protocol it was written for: upgrading +the SDK changes nothing about what it puts on the wire, and no per-instance option turns such a server into a 2026-era server. Serving the 2026-07-28 revision (or both eras) on stdio always goes through `serveStdio`. To migrate an existing v1 stdio server, move its +construction into the factory: replace `await server.connect(new StdioServerTransport())` with `serveStdio(() => buildServer())`, registering tools/resources/prompts inside the factory as before — and pass `{ legacy: 'reject' }` if 2025-era clients should be refused +instead of served. ### Cache fields and cache hints for cacheable 2026-07-28 results @@ -1137,8 +1140,9 @@ capabilities, and `ProtocolError.fromError` recognizes the code/data shape (reco handlers as `ctx.mcpReq.envelope`; instances serving that revision through `createMcpHandler` are backfilled per request, so existing code that calls the accessors keeps working on both eras. On 2025-era connections the accessors keep returning the `initialize`-scoped values, as before. -On a long-lived dual-era instance (`eraSupport: 'dual-era'`, e.g. a stdio server) the accessors are **not** backfilled from modern requests: 2025-era and 2026-era messages interleave on one connection, so instance-level backfill would race. There the accessors keep their -`initialize`-scoped semantics — they reflect what the legacy handshake negotiated (or `undefined` when none ran) — and handlers serving 2026-era requests read the per-request identity from `ctx.mcpReq.envelope`. +On a connection pinned to the 2026-07-28 era by `serveStdio` the identity accessors are **not** backfilled: the modern era carries client identity per request, so connection-scoped identity has nothing stable to report there. +`getClientCapabilities()` and `getClientVersion()` return `undefined` (no `initialize` handshake ever ran on such a connection) and handlers read the per-request identity from `ctx.mcpReq.envelope`. `getNegotiatedProtocolVersion()` reports the pinned revision +(`2026-07-28`) — the entry era-marks the instance when it binds it, so the accessor reports the same value as on instances serving that revision through `createMcpHandler`. On 2025-pinned connections the accessors keep their `initialize`-scoped semantics, as before. ### Origin validation middleware and default arming diff --git a/docs/server.md b/docs/server.md index 1b38ac5c83..53bd3e6051 100644 --- a/docs/server.md +++ b/docs/server.md @@ -64,17 +64,22 @@ await server.connect(transport); #### Serving the 2026-07-28 draft revision on stdio -By default a stdio server speaks the 2025-era protocol it was written for (`eraSupport: 'legacy'`): nothing about its wire behavior changes when you upgrade the SDK. Serving the 2026-07-28 draft revision is one explicit option — the transport stays unchanged: +A hand-constructed stdio server speaks the 2025-era protocol it was written for: nothing about its wire behavior changes when you upgrade the SDK. Serving the 2026-07-28 draft revision goes through the connection-pinned `serveStdio` entry, which mirrors `createMcpHandler` +for long-lived connections — the entry owns the transport and the era decision, and one instance from your factory serves the era the client opened the connection with: ```typescript -const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { eraSupport: 'dual-era' }); -await server.connect(new StdioServerTransport()); +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +serveStdio(() => { + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + // register tools/resources/prompts once — the same factory serves both eras + return server; +}); ``` -With `eraSupport: 'dual-era'` the same long-lived connection serves both eras, selected per message: plain 2025 clients keep using `initialize` and are served exactly as before, while 2026-capable clients negotiate via `server/discover` and send each request with the -per-request `_meta` envelope. Methods that exist in only one era stay invisible to the other (a 2025-era client asking for a 2026-only method gets a plain `-32601`). `eraSupport: 'modern'` is strict 2026-only. On dual-era instances, read per-request client identity from -`ctx.mcpReq.envelope` in your handlers rather than the connection-scoped accessors (see the [migration guide](./migration.md) for details). A runnable example lives at `examples/server/src/dualEraStdio.ts`, with a two-legged client at -`examples/client/src/dualEraStdioClient.ts`. +Plain 2025 clients open with `initialize` and are served exactly as before; 2026-capable clients negotiate via `server/discover` and send each request with the per-request `_meta` envelope, and their connection is pinned to a 2026-era instance. Pass `legacy: 'reject'` to +refuse 2025-era openings with the unsupported-protocol-version error. On 2026-pinned connections, read per-request client identity from `ctx.mcpReq.envelope` in your handlers rather than the connection-scoped accessors (see the [migration guide](./migration.md) for +details). A runnable example lives at `examples/server/src/dualEraStdio.ts`, with a two-legged client at `examples/client/src/dualEraStdioClient.ts`. ## Server instructions diff --git a/examples/client/src/dualEraStdioClient.ts b/examples/client/src/dualEraStdioClient.ts index e58bdcdedd..a8b4a6e317 100644 --- a/examples/client/src/dualEraStdioClient.ts +++ b/examples/client/src/dualEraStdioClient.ts @@ -1,6 +1,7 @@ /** - * Drives the dual-era stdio server example (`examples/server/src/dualEraStdio.ts`) - * with both kinds of client over a real child-process pipe: + * Drives the dual-era stdio server example (`examples/server/src/dualEraStdio.ts`, + * a `serveStdio` server) with both kinds of client, each over its own real + * child-process pipe: * * 1. a plain 2025 client — the `initialize` handshake, served exactly as today; * 2. a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) — the @@ -65,4 +66,4 @@ async function modernLeg(): Promise { await legacyLeg(); await modernLeg(); -console.log('both legs served by the same dual-era stdio server.'); +console.log('both legs served by the same dual-era stdio server factory.'); diff --git a/examples/server/src/dualEraStdio.ts b/examples/server/src/dualEraStdio.ts index 28009e0bc9..4153c38aeb 100644 --- a/examples/server/src/dualEraStdio.ts +++ b/examples/server/src/dualEraStdio.ts @@ -1,30 +1,33 @@ /** - * Dual-era stdio serving with `eraSupport: 'dual-era'`: one server process, - * one long-lived pipe, both protocol eras. + * Dual-era stdio serving with `serveStdio`: one server process, both protocol + * eras, one factory. * - * The same construction backs both legs — nothing about the transport or the - * tool changes per era: + * The entry owns the era decision per connection: the client's opening + * exchange selects the era, one instance from the factory is pinned for the + * connection lifetime, and that instance serves only that era. * * - a plain 2025 client connects with the `initialize` handshake and is served - * exactly as today; - * - a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) negotiates - * the 2026-07-28 revision via `server/discover` on the same pipe and is - * served on the modern era, message by message. + * by a 2025-era instance exactly as today; + * - a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) probes with + * `server/discover`, negotiates the 2026-07-28 revision, and is served by a + * 2026-era instance — every request carrying the per-request `_meta` + * envelope. * - * Opting in is the single `eraSupport` option; the default (`'legacy'`) - * preserves today's behavior exactly. + * The same factory backs both: tools are defined once and served identically + * to either kind of client. * * Run with `tsx examples/server/src/dualEraStdio.ts` (or point any stdio MCP * client at it). `examples/client/src/dualEraStdioClient.ts` drives both legs - * against the built version of this file. + * against this file. */ import type { CallToolResult } from '@modelcontextprotocol/server'; import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; -// One construction for both legs: tools are defined once and served -// identically to 2025-era and 2026-era clients. +// One factory for both eras: tools are defined once and served identically to +// 2025-era and 2026-era clients. The entry constructs one instance per +// connection, for the era that connection's client opened with. const buildServer = () => { const server = new McpServer( { @@ -33,9 +36,7 @@ const buildServer = () => { }, { capabilities: { tools: {} }, - instructions: 'A small dual-era stdio demo server.', - // The one declared act: serve both protocol eras on this long-lived pipe. - eraSupport: 'dual-era' + instructions: 'A small dual-era stdio demo server.' } ); @@ -53,13 +54,13 @@ const buildServer = () => { return server; }; -const server = buildServer(); -// The transport is unchanged: dual-era support is purely a server-options declaration. -await server.connect(new StdioServerTransport()); +// The entry owns the stdio transport and the era decision; 2025-era clients +// are served by default (`legacy: 'serve'`). +const handle = serveStdio(buildServer); console.error('dual-era stdio server ready (serving 2025-era initialize and 2026-07-28 envelope traffic)'); const exit = async () => { - await server.close(); + await handle.close(); // eslint-disable-next-line unicorn/no-process-exit process.exit(0); }; diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 9ae2950719..0831efb13f 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -288,7 +288,7 @@ export class Client extends Protocol { * (surfaced via `onerror`) rather than answered. Connections on a legacy * era — and all responses and notifications — keep today's dispatch path. */ - protected override _classifyInbound(message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { + protected override _shouldDropInbound(message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { if ( this._negotiatedProtocolVersion !== undefined && isModernProtocolVersion(this._negotiatedProtocolVersion) && diff --git a/packages/client/test/client/probeFixtureCorpus.test.ts b/packages/client/test/client/probeFixtureCorpus.test.ts index 2e46b5faea..2d4c8a16f5 100644 --- a/packages/client/test/client/probeFixtureCorpus.test.ts +++ b/packages/client/test/client/probeFixtureCorpus.test.ts @@ -11,8 +11,8 @@ * version" literal and the 400/−32000 session-required body). Recognition * is a typed allowlist — codes and structured data — never message-text * sniffing. - * - the era predicate's per-message form is bound by - * `packages/core/test/shared/classifyInboundMessage.test.ts` (T11). + * - the server-side opening classification (the era a connection's first + * exchange selects) is bound by `packages/server/test/server/serveStdio.test.ts`. * * Probe RUNTIME (timeout/retry policy and the connect loop) is covered by the * negotiation engine suites; this corpus pins classification only, plus the diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 3dd85ad2ad..02e332236f 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -391,8 +391,12 @@ function classificationForClaim(claimedVersion: string | undefined): MessageClas * modern era then answers `initialize` exactly like any other method it does * not define (method-not-found). A malformed claim, or one naming a pre-2026 * revision, keeps the legacy-handshake routing unchanged. + * + * Exported on the core internal barrel for the stdio serving entry, which + * applies the same precedence rule to a connection's opening message; not + * public API. */ -function carriesValidModernEnvelopeClaim(params: unknown): boolean { +export function carriesValidModernEnvelopeClaim(params: unknown): boolean { if (!hasEnvelopeClaim(params)) { return false; } @@ -657,50 +661,6 @@ export function classifyInboundRequest(request: InboundHttpRequest): InboundClas ); } -/* ------------------------------------------------------------------------ * - * Per-message classification (long-lived channels) - * ------------------------------------------------------------------------ */ - -/** - * Classifies one inbound JSON-RPC message for a long-lived dual-era channel - * (stdio and other hand-wired transports with no HTTP edge): the body-primary - * predicate reduced to its per-message form — there is no header layer (the - * stdio transport carries all request metadata inline in the message body) - * and no HTTP method to route. - * - * - `initialize` is the legacy handshake by definition; the version it - * requested is carried as the classification's `revision`. - * - A message whose `params._meta` carries the reserved protocol-version key - * claims the per-request envelope mechanism and classifies into the era of - * the named revision. Envelope validity is enforced at dispatch by the era - * codec — a malformed envelope behind a present claim is a validation - * error, never a silent fall back to legacy handling. - * - A message without that claim — including one carrying only - * `progressToken` or other non-reserved `_meta` keys — is legacy-era - * traffic. - * - * Pure and total over requests and notifications; consumed by the - * protocol-layer classification consult for dual-era server instances. - */ -export function classifyInboundMessage(message: { method: string; params?: unknown }): MessageClassification { - if (message.method === 'initialize') { - const params = message.params; - const requestedVersion = - isPlainObject(params) && typeof params['protocolVersion'] === 'string' ? params['protocolVersion'] : undefined; - // The classification's `revision` names the wire revision the message - // is classified INTO, so it only carries the requested version when - // that version is itself a legacy one — an `initialize` requesting a - // modern revision is still the legacy handshake (it never negotiates - // a modern era) and stays a bare legacy classification. - const legacyRevision = requestedVersion !== undefined && !isModernProtocolVersion(requestedVersion) ? requestedVersion : undefined; - return { era: 'legacy', ...(legacyRevision !== undefined && { revision: legacyRevision }) }; - } - if (hasEnvelopeClaim(message.params)) { - return classificationForClaim(envelopeClaimVersion(message.params)); - } - return { era: 'legacy' }; -} - /* ------------------------------------------------------------------------ * * Modern-only (strict) mapping of legacy routes * ------------------------------------------------------------------------ */ diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index d2335a6065..8feffae174 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -15,7 +15,6 @@ import type { JSONRPCResponse, JSONRPCResultResponse, LoggingLevel, - MessageClassification, MessageExtraInfo, Notification, NotificationMethod, @@ -490,24 +489,23 @@ export abstract class Protocol { protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; /** - * Classification consult for inbound messages whose transport did not - * classify them at the edge — long-lived dual-era channels such as stdio, - * where the protocol era is decided per message rather than per request - * at an HTTP edge. + * Drop consult for inbound messages whose transport did not classify them + * at the edge — long-lived channels such as stdio, where a role class may + * need to decline traffic the negotiated era has no answer for (the + * client-side inbound-request drop on modern-era connections: the + * 2026-07-28 era has no server→client request channel, and on stdio the + * client must never write JSON-RPC responses). * * Consulted ONLY when the transport supplied no - * {@linkcode MessageExtraInfo.classification}: an edge classification - * always wins and the hook is never reached for it. The returned - * classification populates the carrier; on an instance with no negotiated - * protocol version it also selects the wire era for this one message, - * while an instance bound to a negotiated version validates it exactly - * like an edge classification (a mismatch is the typed - * unsupported-protocol-version answer for requests, a drop for - * notifications). Returning `'drop'` discards the message without writing - * any response. The base implementation returns `undefined`: unclassified - * traffic keeps today's dispatch path unchanged. + * {@linkcode MessageExtraInfo.classification}: edge-classified traffic + * never reaches the hook. Returning `'drop'` discards the message without + * writing any response (requests are surfaced via `onerror`). The base + * implementation returns `undefined`: unclassified traffic keeps today's + * dispatch path unchanged. Era selection never happens here — era is + * instance state, owned by the serving entry that constructed and + * connected the instance. */ - protected _classifyInbound(_message: JSONRPCRequest | JSONRPCNotification): MessageClassification | 'drop' | undefined { + protected _shouldDropInbound(_message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { return undefined; } @@ -650,26 +648,15 @@ export abstract class Protocol { // Era is instance state: the negotiated protocol version selects the // codec for everything this connection receives (legacy until - // negotiated). An edge classification is never a per-message era - // switch — it is validated against the instance era below. - let codec = this._negotiatedWireCodec(); - - // Classification consult (only when the transport did not classify; - // an edge classification always wins and never reaches the hook). On - // an unbound instance the hook's classification selects the era for - // this one message (long-lived dual-era channels); a bound instance - // validates it below exactly like an edge classification. - if (extra?.classification === undefined) { - const consulted = this._classifyInbound(rawNotification); - if (consulted === 'drop') { - return; - } - if (consulted !== undefined) { - extra = { ...extra, classification: consulted }; - if (this._negotiatedProtocolVersion === undefined) { - codec = codecForVersion(classifiedWireEra(consulted)); - } - } + // negotiated). Classification is never a per-message era switch — an + // edge classification is validated against the instance era below. + const codec = this._negotiatedWireCodec(); + + // Drop consult (only when the transport did not classify; edge- + // classified traffic never reaches the hook): a role class may decline + // unclassified inbound traffic the negotiated era has no answer for. + if (extra?.classification === undefined && this._shouldDropInbound(rawNotification) === 'drop') { + return; } // Edge→instance handoff check: a classification that disagrees with @@ -720,29 +707,19 @@ export abstract class Protocol { // Era is instance state: the negotiated protocol version selects the // codec for everything this connection receives (legacy until - // negotiated). An edge classification (Q2; produced at the HTTP - // entry) is never a per-message era switch — it is validated against - // the instance era below. Hand-wired legacy transports never - // classify, so their behavior is untouched. - let codec = this._negotiatedWireCodec(); - - // Classification consult (only when the transport did not classify; - // an edge classification always wins and never reaches the hook). On - // an unbound instance the hook's classification selects the era for - // this one message (long-lived dual-era channels); a bound instance - // validates it below exactly like an edge classification. - if (extra?.classification === undefined) { - const consulted = this._classifyInbound(rawRequest); - if (consulted === 'drop') { - this._onerror(new Error(`Dropped inbound request '${rawRequest.method}': not servable on this connection's protocol era`)); - return; - } - if (consulted !== undefined) { - extra = { ...extra, classification: consulted }; - if (this._negotiatedProtocolVersion === undefined) { - codec = codecForVersion(classifiedWireEra(consulted)); - } - } + // negotiated). Classification (Q2; produced at the transport/entry + // edge — this layer only CONSUMES MessageExtraInfo.classification) is + // never a per-message era switch — it is validated against the + // instance era below. Hand-wired legacy transports never classify, so + // their behavior is untouched. + const codec = this._negotiatedWireCodec(); + + // Drop consult (only when the transport did not classify; edge- + // classified traffic never reaches the hook): a role class may decline + // unclassified inbound traffic the negotiated era has no answer for. + if (extra?.classification === undefined && this._shouldDropInbound(rawRequest) === 'drop') { + this._onerror(new Error(`Dropped inbound request '${rawRequest.method}': not servable on this connection's protocol era`)); + return; } // Capture the current transport at request time to ensure responses go to the correct client diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 92acc6a6ad..55b2bf7481 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -631,10 +631,7 @@ export type ListChangedHandlers = { * `Client`/`Server` instance); the protocol layer validates a classified * message against that instance era at dispatch — a mismatch is treated as * an entry/routing error, never a per-message era switch. Unclassified - * traffic is dispatched on the instance era unchanged, except on long-lived - * dual-era channels (e.g. a stdio server that declared dual-era support), - * where the protocol layer's own classification consult classifies each - * message and selects its era per message. + * traffic is dispatched on the instance era unchanged. */ export interface MessageClassification { /** @@ -662,9 +659,7 @@ export interface MessageExtraInfo { * Protocol-era classification of the message, when the transport * classified it at the edge. Validated by the protocol layer against the * instance's negotiated era at dispatch (the edge→instance handoff - * check); an edge classification never selects the era itself. When the - * transport did not classify, the protocol layer's classification consult - * may populate this carrier per message (long-lived dual-era channels). + * check); it does not select the era itself. */ classification?: MessageClassification; diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts index 6ce2402cb8..d98d8e23f1 100644 --- a/packages/core/src/wire/codec.ts +++ b/packages/core/src/wire/codec.ts @@ -173,14 +173,12 @@ export function codecForVersion(version: string | undefined): WireCodec { } /** - * The wire era a classification names (Q2 — produced at the transport/entry - * edge or, for long-lived dual-era channels, by the protocol layer's own - * per-message classification consult). For edge classifications the dispatch - * funnel never resolves a codec FROM the classification: era is instance - * state, and the classified message is VALIDATED against it — a mismatch is - * an entry/routing error. Only an unbound dual-era instance selects the - * message's codec from its classification (per-message era). The exact - * `revision` wins over the coarse era flag when both are present. + * The wire era an edge classification names (Q2 — produced at the + * transport/entry edge; this layer only CONSUMES it). The dispatch funnel no + * longer resolves a codec FROM the classification: era is instance state, and + * a classified inbound message is VALIDATED against the instance era — a + * mismatch is an entry/routing error, never a per-message era switch. The + * exact `revision` wins over the coarse era flag when both are present. */ export function classifiedWireEra(classification: MessageClassification): WireEra { if (classification.revision !== undefined) return codecForVersion(classification.revision).era; diff --git a/packages/core/test/shared/classifyInboundMessage.test.ts b/packages/core/test/shared/classifyInboundMessage.test.ts deleted file mode 100644 index 1b86f690df..0000000000 --- a/packages/core/test/shared/classifyInboundMessage.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Per-message era predicate for long-lived dual-era channels - * (`classifyInboundMessage`) — the body-primary rule (Q2) in its stdio form, - * with the T11 sharpening: classification keys on the SPECIFIC reserved - * envelope key (`io.modelcontextprotocol/protocolVersion`), never on bare - * `_meta` presence. - */ -import { describe, expect, it } from 'vitest'; - -import { classifyInboundMessage } from '../../src/shared/inboundClassification.js'; -import { - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, - LOG_LEVEL_META_KEY, - PROTOCOL_VERSION_META_KEY -} from '../../src/types/index.js'; - -const MODERN = '2026-07-28'; - -const fullEnvelope = (version: string) => ({ - [PROTOCOL_VERSION_META_KEY]: version, - [CLIENT_INFO_META_KEY]: { name: 'fixture-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} -}); - -describe('classifyInboundMessage (per-message body-primary predicate)', () => { - it('classifies `initialize` as legacy and carries the requested version as the revision', () => { - const classification = classifyInboundMessage({ - method: 'initialize', - params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } } - }); - expect(classification).toEqual({ era: 'legacy', revision: '2025-06-18' }); - }); - - it('classifies `initialize` without a parsable protocolVersion as legacy with no revision', () => { - expect(classifyInboundMessage({ method: 'initialize', params: {} })).toEqual({ era: 'legacy' }); - expect(classifyInboundMessage({ method: 'initialize' })).toEqual({ era: 'legacy' }); - }); - - it('classifies `initialize` REQUESTING a modern revision as a bare legacy classification (initialize never negotiates a modern era)', () => { - const classification = classifyInboundMessage({ - method: 'initialize', - params: { protocolVersion: MODERN, capabilities: {}, clientInfo: { name: 'c', version: '1' } } - }); - expect(classification).toEqual({ era: 'legacy' }); - }); - - it('classifies a message carrying the reserved protocol-version envelope key as modern with the claimed revision', () => { - const classification = classifyInboundMessage({ - method: 'tools/list', - params: { _meta: fullEnvelope(MODERN) } - }); - expect(classification).toEqual({ era: 'modern', revision: MODERN }); - }); - - it('classifies an envelope claim naming a 2025-era revision as legacy with that revision', () => { - const classification = classifyInboundMessage({ - method: 'tools/list', - params: { _meta: { [PROTOCOL_VERSION_META_KEY]: '2025-06-18' } } - }); - expect(classification).toEqual({ era: 'legacy', revision: '2025-06-18' }); - }); - - it('classifies a claim with a non-string protocol-version value as a modern claim (validated at dispatch, never silently legacy)', () => { - const classification = classifyInboundMessage({ - method: 'tools/list', - params: { _meta: { [PROTOCOL_VERSION_META_KEY]: 42 } } - }); - expect(classification).toEqual({ era: 'modern' }); - }); - - it('T11: a legacy client carrying only `progressToken` in `_meta` classifies legacy — never bare `_meta` presence', () => { - const classification = classifyInboundMessage({ - method: 'tools/call', - params: { name: 'echo', arguments: {}, _meta: { progressToken: 7 } } - }); - expect(classification).toEqual({ era: 'legacy' }); - }); - - it('T11: other reserved envelope keys without the protocol-version key do NOT constitute a claim', () => { - const classification = classifyInboundMessage({ - method: 'tools/call', - params: { - name: 'echo', - arguments: {}, - _meta: { - [CLIENT_INFO_META_KEY]: { name: 'c', version: '1' }, - [CLIENT_CAPABILITIES_META_KEY]: {}, - [LOG_LEVEL_META_KEY]: 'info' - } - } - }); - expect(classification).toEqual({ era: 'legacy' }); - }); - - it('classifies a claim-less request as legacy', () => { - expect(classifyInboundMessage({ method: 'tools/list', params: {} })).toEqual({ era: 'legacy' }); - expect(classifyInboundMessage({ method: 'ping' })).toEqual({ era: 'legacy' }); - }); - - it('classifies notifications by the same body-primary rule', () => { - expect(classifyInboundMessage({ method: 'notifications/cancelled', params: { requestId: 1 } })).toEqual({ era: 'legacy' }); - expect( - classifyInboundMessage({ method: 'notifications/cancelled', params: { requestId: 1, _meta: fullEnvelope(MODERN) } }) - ).toEqual({ era: 'modern', revision: MODERN }); - }); -}); diff --git a/packages/core/test/shared/protocolClassifyInboundHook.test.ts b/packages/core/test/shared/protocolDropInboundHook.test.ts similarity index 57% rename from packages/core/test/shared/protocolClassifyInboundHook.test.ts rename to packages/core/test/shared/protocolDropInboundHook.test.ts index 23bb7cf6ce..40b99452d8 100644 --- a/packages/core/test/shared/protocolClassifyInboundHook.test.ts +++ b/packages/core/test/shared/protocolDropInboundHook.test.ts @@ -1,67 +1,44 @@ /** - * The protocol-layer classification consult (`Protocol._classifyInbound`): + * The protocol-layer drop consult (`Protocol._shouldDropInbound`): * * - B-2 pin: when the transport supplied an edge classification, the hook is * NEVER consulted — the edge classification always wins. * - The base implementation returns `undefined`, so unclassified traffic on * a default instance keeps today's dispatch path byte-identically. - * - A hook classification populates the `MessageExtraInfo.classification` - * carrier and, on an UNBOUND instance (no negotiated protocol version), - * selects the wire era for that one message (per-message era on long-lived - * dual-era channels). On a BOUND instance it is validated exactly like an - * edge classification (mismatch ⇒ −32004 for requests, drop for - * notifications). - * - Returning `'drop'` discards the message without writing any response. + * - Returning `'drop'` discards the message without writing any response + * (requests are surfaced via `onerror`, notifications are silent). This is + * the seam the client uses to decline inbound requests on connections that + * negotiated a modern era. Era selection never happens here — era is + * instance state owned by the serving entry. */ import { describe, expect, it } from 'vitest'; -import * as z from 'zod/v4'; import type { BaseContext } from '../../src/shared/protocol.js'; -import { Protocol, setNegotiatedProtocolVersion } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; import type { JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, - JSONRPCResultResponse, - MessageClassification, - MessageExtraInfo, - Result -} from '../../src/types/index.js'; -import { - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, - isJSONRPCErrorResponse, - isJSONRPCResultResponse, - PROTOCOL_VERSION_META_KEY + JSONRPCResultResponse } from '../../src/types/index.js'; +import { isJSONRPCResultResponse } from '../../src/types/index.js'; import { InMemoryTransport } from '../../src/util/inMemory.js'; -const MODERN = '2026-07-28'; - -const modernEnvelope = { - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name: 'hook-test-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} -}; - class HookedProtocol extends Protocol { /** Messages the hook was consulted for (in order). */ consulted: Array = []; /** What the hook answers; `undefined` keeps the base behavior. */ - verdict: ((message: JSONRPCRequest | JSONRPCNotification) => MessageClassification | 'drop' | undefined) | undefined; - /** The MessageExtraInfo handed to buildContext for the last dispatched request. */ - lastExtra: MessageExtraInfo | undefined; + verdict: ((message: JSONRPCRequest | JSONRPCNotification) => 'drop' | undefined) | undefined; protected assertCapabilityForMethod(): void {} protected assertNotificationCapability(): void {} protected assertRequestHandlerCapability(): void {} - protected buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): BaseContext { - this.lastExtra = transportInfo; + protected buildContext(ctx: BaseContext): BaseContext { return ctx; } - protected override _classifyInbound(message: JSONRPCRequest | JSONRPCNotification): MessageClassification | 'drop' | undefined { + protected override _shouldDropInbound(message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { this.consulted.push(message); return this.verdict?.(message); } @@ -92,7 +69,7 @@ async function wire>(protocol: T) { describe('B-2: an edge classification always wins', () => { it('never consults the hook for a message that already carries a classification', async () => { const protocol = new HookedProtocol(); - protocol.verdict = () => ({ era: 'modern', revision: MODERN }); + protocol.verdict = () => 'drop'; const { protocolTx, sent } = await wire(protocol); protocolTx.onmessage?.( @@ -122,7 +99,7 @@ describe('B-2: an edge classification always wins', () => { expect(protocol.consulted).toHaveLength(1); expect(protocol.consulted[0]).toMatchObject({ method: 'tools/list' }); - // `undefined` keeps today's path: no handler ⇒ −32601, no classification carrier. + // `undefined` keeps today's path: no handler ⇒ −32601. expect(sent).toHaveLength(1); expect((sent[0] as JSONRPCErrorResponse).error.code).toBe(-32_601); await protocol.close(); @@ -145,78 +122,19 @@ describe("base implementation (no override) keeps today's dispatch", () => { expect(JSON.stringify(response)).not.toContain('resultType'); await protocol.close(); }); -}); -describe('per-message era on an unbound instance (long-lived dual-era channels)', () => { - it('a hook classification of modern serves the message on the 2026 era: envelope honored, result stamped', async () => { + it('an undefined verdict from an overriding hook also keeps the handler path unchanged', async () => { const protocol = new HookedProtocol(); - protocol.verdict = message => (message.method === 'initialize' ? { era: 'legacy' } : { era: 'modern', revision: MODERN }); + protocol.verdict = () => undefined; protocol.setRequestHandler('tools/list', () => ({ tools: [] })); const { peerTx, sent } = await wire(protocol); - await peerTx.send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: modernEnvelope } }); - await flush(); - - expect(sent).toHaveLength(1); - const response = sent[0] as JSONRPCResultResponse; - expect(isJSONRPCResultResponse(response)).toBe(true); - expect((response.result as { resultType?: string }).resultType).toBe('complete'); - // The carrier was populated and reached the handler context. - expect(protocol.lastExtra?.classification).toEqual({ era: 'modern', revision: MODERN }); - await protocol.close(); - }); - - it('a hook classification of legacy answers a 2026-only spec method with a plain −32601 (era gate by registry absence)', async () => { - const protocol = new HookedProtocol(); - protocol.verdict = () => ({ era: 'legacy' }); - // Even an installed handler cannot shadow the era gate. - protocol.setRequestHandler('server/discover', { params: z.looseObject({}) }, () => ({}) as Result); - const { peerTx, sent } = await wire(protocol); - - await peerTx.send({ jsonrpc: '2.0', id: 3, method: 'server/discover', params: {} }); - await flush(); - - expect(sent).toHaveLength(1); - const response = sent[0] as JSONRPCErrorResponse; - expect(isJSONRPCErrorResponse(response)).toBe(true); - expect(response.error).toEqual({ code: -32_601, message: 'Method not found' }); - await protocol.close(); - }); -}); - -describe('hook classification on a BOUND instance is validated like an edge classification', () => { - it('a legacy-classified request on a modern-bound instance answers −32004 with the supported list', async () => { - const protocol = new HookedProtocol(); - protocol.verdict = () => ({ era: 'legacy' }); - const { peerTx, sent } = await wire(protocol); - setNegotiatedProtocolVersion(protocol, MODERN); - - await peerTx.send({ jsonrpc: '2.0', id: 4, method: 'tools/list', params: {} }); + await peerTx.send({ jsonrpc: '2.0', id: 8, method: 'tools/list', params: {} }); await flush(); expect(sent).toHaveLength(1); - const error = (sent[0] as JSONRPCErrorResponse).error as { code: number; data?: { supported?: string[] } }; - expect(error.code).toBe(-32_004); - expect(Array.isArray(error.data?.supported)).toBe(true); - await protocol.close(); - }); - - it('a legacy-classified notification on a modern-bound instance is dropped (no handler invocation, no response)', async () => { - const protocol = new HookedProtocol(); - protocol.verdict = () => ({ era: 'legacy' }); - let invoked = 0; - protocol.fallbackNotificationHandler = async () => { - invoked += 1; - }; - const { peerTx, sent, errors } = await wire(protocol); - setNegotiatedProtocolVersion(protocol, MODERN); - - await peerTx.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); - await flush(); - - expect(invoked).toBe(0); - expect(sent).toHaveLength(0); - expect(errors.length).toBeGreaterThan(0); + expect(isJSONRPCResultResponse(sent[0] as JSONRPCMessage)).toBe(true); + expect((sent[0] as JSONRPCResultResponse).result).toEqual({ tools: [] }); await protocol.close(); }); }); @@ -252,4 +170,19 @@ describe("'drop' verdict", () => { expect(sent).toHaveLength(0); await protocol.close(); }); + + it('responses are never consulted: an inbound response keeps todays correlation path', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => 'drop'; + const { peerTx, sent } = await wire(protocol); + + // An unsolicited response does not reach the hook (it is not a request + // or notification); it surfaces through the response-correlation path. + await peerTx.send({ jsonrpc: '2.0', id: 99, result: {} }); + await flush(); + + expect(protocol.consulted).toHaveLength(0); + expect(sent).toHaveLength(0); + await protocol.close(); + }); }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 76244d12b3..408f4be340 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -43,9 +43,9 @@ export type { PerRequestHTTPServerTransportOptions, PerRequestMessageExtra, PerR export { PerRequestHTTPServerTransport } from './server/perRequestTransport.js'; export type { ServerOptions } from './server/server.js'; export { Server } from './server/server.js'; -// StdioServerTransport is exported from the './stdio' subpath — server stdio has only type-level Node -// imports (erased at compile time), but matching the client's `./stdio` subpath gives consumers a -// consistent shape across packages. +// StdioServerTransport and the serveStdio entry are exported from the './stdio' subpath — server stdio +// has only type-level Node imports (erased at compile time), but matching the client's `./stdio` subpath +// gives consumers a consistent shape across packages. export type { EventId, EventStore, diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 00f35ddeac..3b4f45ab3d 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -59,7 +59,13 @@ import { WebStandardStreamableHTTPServerTransport } from './streamableHttp.js'; * ------------------------------------------------------------------------ */ /** - * Per-request construction context handed to an {@linkcode McpServerFactory}. + * Construction context handed to an {@linkcode McpServerFactory}. + * + * Both serving entries call the factory with this context whenever they need + * a fresh instance: {@linkcode createMcpHandler} once per HTTP request, and + * `serveStdio` (from `@modelcontextprotocol/server/stdio`) once per + * connection — plus once for a `server/discover` probe instance that is + * discarded again if the client falls back to `initialize`. * * Zero-argument factories remain assignable unchanged; the context exists for * factories that vary by principal or era (for example multi-tenant servers @@ -68,22 +74,30 @@ import { WebStandardStreamableHTTPServerTransport } from './streamableHttp.js'; */ export interface McpRequestContext { /** - * The protocol era of the request the constructed instance will serve: - * `modern` for 2026-07-28 (per-request envelope) traffic, `legacy` for - * 2025-era traffic served through the `legacy: 'stateless'` slot. + * The protocol era the constructed instance will serve: `modern` for + * 2026-07-28 (per-request envelope) traffic, `legacy` for 2025-era + * traffic. Under {@linkcode createMcpHandler} a `legacy` instance serves + * one request through the `legacy: 'stateless'` slot; under `serveStdio` + * it serves a connection that opened with the 2025 handshake and stays + * pinned to that era for its lifetime. */ era: 'legacy' | 'modern'; - /** Validated authentication information passed by the caller of the handler face (pass-through). */ + /** + * Validated authentication information passed by the caller of the + * handler face (pass-through; HTTP only — `serveStdio` never sets it). + */ authInfo?: AuthInfo; - /** The original HTTP request being served, when available. */ + /** The original HTTP request being served, when available (HTTP only — `serveStdio` never sets it). */ requestInfo?: Request; } /** * A factory producing a fresh {@linkcode McpServer} (or low-level - * {@linkcode Server}) instance for one request. The same factory backs both - * the modern path and the `legacy: 'stateless'` slot — define your tools, - * resources and prompts once and serve them to both eras. + * {@linkcode Server}) instance for one serving unit: one HTTP request under + * {@linkcode createMcpHandler}, or one connection (or one discarded + * `server/discover` probe) under `serveStdio`. The same factory backs every + * era either entry serves — define your tools, resources and prompts once and + * serve them to both eras. */ export type McpServerFactory = (ctx: McpRequestContext) => McpServer | Server | Promise; diff --git a/packages/server/src/server/serveStdio.ts b/packages/server/src/server/serveStdio.ts new file mode 100644 index 0000000000..e166c4298f --- /dev/null +++ b/packages/server/src/server/serveStdio.ts @@ -0,0 +1,663 @@ +/** + * `serveStdio` — the stdio entry point for serving the 2026-07-28 protocol + * revision on a long-lived connection, with 2025-era serving as the default + * for clients that open with the `initialize` handshake. + * + * The entry owns the stdio transport and the era decision for the connection. + * It classifies the connection's opening exchange exactly once (using the + * same body-primary rules as the HTTP entry), constructs ONE server instance + * from the consumer's factory for the era the client opened with, pins that + * instance for the lifetime of the connection, and passes every later message + * straight through to it. No per-message era classification ever runs after + * the connection is pinned — exactly mirroring how `createMcpHandler` + * classifies an HTTP request before any instance exists. + * + * The opening exchange: + * + * - An `initialize` request (or any claim-less message) opens a 2025-era + * session: the factory builds a legacy instance and the connection is + * pinned to it (`legacy: 'serve'`, the default). With `legacy: 'reject'` + * the opening is answered with the unsupported-protocol-version error + * naming the supported modern revisions instead. + * - A request carrying a valid per-request `_meta` envelope naming a + * supported modern revision pins the connection to a modern instance + * (era-marked and given the modern-only handlers, exactly like the HTTP + * entry's modern path). + * - A `server/discover` probe is answered by an optimistically built modern + * instance but does NOT pin the connection yet: the spec's stdio + * backward-compatibility flow lets a client probe first and then either + * continue with modern requests (which pins the connection modern) or fall + * back to the `initialize` handshake when no mutually supported modern + * revision exists — in which case the probe instance is discarded and a + * fresh legacy instance serves the handshake. + * - Once the modern era is pinned, a later claim-less `initialize` is + * rejected with the unsupported-protocol-version error naming the supported + * revisions (the spec recommends naming them in any error returned to + * `initialize`, and forbids falling back once the modern era is confirmed). + * + * Every instance the factory produces serves exactly one era; the ambiguity + * of the opening exchange lives entirely in this entry. In the probe-fallback + * case the factory is called twice (once for the discarded probe instance, + * once for the legacy instance), so factories should be cheap and + * side-effect-free to construct — the same expectation `createMcpHandler` + * already sets for per-request construction. + * + * Hand-constructed servers connected directly to a `StdioServerTransport` + * are unaffected by this entry: they keep serving the 2025-era protocol they + * were written for. + */ +import type { + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + MessageClassification, + MessageExtraInfo, + RequestId, + Transport, + TransportSendOptions +} from '@modelcontextprotocol/core'; +import { + carriesValidModernEnvelopeClaim, + envelopeClaimVersion, + hasEnvelopeClaim, + isJSONRPCErrorResponse, + isJSONRPCNotification, + isJSONRPCRequest, + isJSONRPCResultResponse, + modernOnlyStrictRejection, + ProtocolErrorCode, + requestMetaOf, + setNegotiatedProtocolVersion, + SUPPORTED_MODERN_PROTOCOL_VERSIONS, + UnsupportedProtocolVersionError, + validateEnvelopeMeta +} from '@modelcontextprotocol/core'; + +import type { McpServerFactory } from './createMcpHandler.js'; +import { McpServer } from './mcp.js'; +import type { Server } from './server.js'; +import { installModernOnlyHandlers } from './server.js'; +import { StdioServerTransport } from './stdio.js'; + +/** Options for {@linkcode serveStdio}. */ +export interface ServeStdioOptions { + /** + * How a 2025-era opening (an `initialize` request, or any claim-less + * message) is handled: + * + * - `'serve'` (default) — the connection is pinned to a 2025-era instance + * from the same factory and served exactly as a hand-wired stdio server + * serves it today. + * - `'reject'` — the opening request is answered with the + * unsupported-protocol-version error naming the supported modern + * revisions (claim-less notifications are dropped); the connection + * stays open for a modern opening. + */ + legacy?: 'serve' | 'reject'; + /** + * Bring your own transport (for example a `StdioServerTransport` + * constructed over a Unix domain socket or TCP stream, per the stdio + * binding's custom-transport guidance). Defaults to a + * {@linkcode StdioServerTransport} over the current process's stdio. The + * entry owns the transport: it starts it, receives every inbound message, + * and closes it when the connection ends. + */ + transport?: Transport; + /** Callback for out-of-band errors (reporting only; it never alters what is written to the wire). */ + onerror?: (error: Error) => void; +} + +/** The handle returned by {@linkcode serveStdio}. */ +export interface StdioServerHandle { + /** Tears the connection down: closes the pinned instance (if any) and the underlying transport. */ + close(): Promise; +} + +/* ------------------------------------------------------------------------ * + * Per-instance channel + * ------------------------------------------------------------------------ */ + +/** + * The transport a pinned instance is connected to: a thin channel that writes + * through to the entry-owned wire transport and receives the messages the + * entry forwards. The wire transport itself is never handed to an instance — + * that is what lets the entry discard an optimistic probe instance (close the + * channel) without tearing down the connection. + */ +class StdioConnectionChannel implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: T, extra?: MessageExtraInfo) => void; + + private _closed = false; + /** Request ids the entry delivered to the instance that the instance has not yet answered. */ + private readonly _pendingRequests = new Set(); + private _drainWaiters: Array<() => void> = []; + + constructor( + private readonly _wire: Transport, + private readonly _onInstanceClose: () => void + ) {} + + async start(): Promise { + // The entry already started the wire transport; connecting an + // instance to its channel must not start anything again. + } + + async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + // The instance answered a delivered request: settle it whether or + // not the wire write below succeeds (write failures surface + // through the wire's own error reporting). + const { id } = message; + if (id !== undefined) { + this._settle(id); + } + } + if (this._closed) { + // A discarded or torn-down instance has nowhere to write; late + // sends are dropped. + return; + } + return this._wire.send(message, options); + } + + setProtocolVersion = (version: string): void => { + this._wire.setProtocolVersion?.(version); + }; + + /** Forwards one inbound message to the connected instance. */ + deliver(message: JSONRPCMessage, extra?: MessageExtraInfo): void { + if (this._closed) { + return; + } + if (isJSONRPCRequest(message)) { + this._pendingRequests.add(message.id); + } + this.onmessage?.(message, extra); + } + + /** + * Resolves once every request delivered to the instance has been answered + * through {@linkcode send} (or the channel has been closed and nothing + * further can be answered). Used by the probe-discard path so a probe + * request the entry accepted is never silently dropped. + */ + async whenRequestsAnswered(): Promise { + if (this._closed || this._pendingRequests.size === 0) { + return; + } + await new Promise(resolve => this._drainWaiters.push(resolve)); + } + + async close(): Promise { + if (this._closed) { + return; + } + this._closed = true; + // Nothing further can be answered through a closed channel; release + // anyone waiting on in-flight answers. + this._pendingRequests.clear(); + this._releaseDrainWaiters(); + try { + this._onInstanceClose(); + } finally { + this.onclose?.(); + } + } + + private _settle(id: RequestId): void { + this._pendingRequests.delete(id); + if (this._pendingRequests.size === 0) { + this._releaseDrainWaiters(); + } + } + + private _releaseDrainWaiters(): void { + const waiters = this._drainWaiters; + this._drainWaiters = []; + for (const waiter of waiters) { + waiter(); + } + } +} + +/* ------------------------------------------------------------------------ * + * Opening-exchange classification + * ------------------------------------------------------------------------ */ + +interface EnvelopeIssue { + key: string; + problem: string; +} + +type OpeningClassification = + /** A 2025-era opening: `initialize`, or any message without an envelope claim. */ + | { kind: 'legacy'; reason: 'initialize' | 'no-claim'; requestedVersion?: string } + /** A valid envelope claim naming a modern revision this entry serves. */ + | { kind: 'modern'; revision: string; classification: MessageClassification } + /** A present envelope claim whose envelope is malformed. */ + | { kind: 'invalid-envelope'; issue: EnvelopeIssue } + /** A valid envelope claim naming a revision this entry does not serve (unknown future or 2025-era). */ + | { kind: 'unsupported-revision'; requested: string }; + +/** + * Classifies one message of the opening exchange with the same body-primary + * rules the HTTP entry applies per request: `initialize` is the legacy + * handshake unless it carries a valid modern envelope claim; a present claim + * is validated (never silently ignored); a claim-less message is 2025-era + * traffic. There is no header layer on stdio, so the body is the only signal. + */ +function classifyOpeningMessage(message: JSONRPCRequest | JSONRPCNotification): OpeningClassification { + const params = message.params; + + if (message.method === 'initialize' && !carriesValidModernEnvelopeClaim(params)) { + const requestedVersion = + params !== null && typeof params === 'object' && typeof (params as { protocolVersion?: unknown }).protocolVersion === 'string' + ? ((params as { protocolVersion: string }).protocolVersion as string) + : undefined; + return { kind: 'legacy', reason: 'initialize', ...(requestedVersion !== undefined && { requestedVersion }) }; + } + + if (!hasEnvelopeClaim(params)) { + return { kind: 'legacy', reason: 'no-claim' }; + } + + // A present claim is validated, never silently ignored — a malformed + // envelope behind the claim is an invalid-params answer, not a fall back + // to legacy serving (mirrors the HTTP entry's envelope rung). + const meta = requestMetaOf(params); + const issues = meta === undefined ? [] : validateEnvelopeMeta(meta); + const firstIssue = issues[0]; + if (firstIssue !== undefined) { + return { kind: 'invalid-envelope', issue: firstIssue }; + } + + const claimedVersion = envelopeClaimVersion(params); + if (claimedVersion === undefined || !SUPPORTED_MODERN_PROTOCOL_VERSIONS.includes(claimedVersion)) { + // The claim names a revision this entry does not serve (an unknown + // future revision, or a 2025-era revision delivered via the envelope + // mechanism) — answered like the HTTP entry's modern path. + return { kind: 'unsupported-revision', requested: claimedVersion ?? 'unknown' }; + } + + return { kind: 'modern', revision: claimedVersion, classification: { era: 'modern', revision: claimedVersion } }; +} + +/* ------------------------------------------------------------------------ * + * The entry + * ------------------------------------------------------------------------ */ + +interface ConnectedInstance { + product: McpServer | Server; + channel: StdioConnectionChannel; +} + +type EntryState = + /** Waiting for the connection's opening message. */ + | { phase: 'opening' } + /** A `server/discover` probe was answered; the era is not pinned yet. */ + | { phase: 'probe'; instance: ConnectedInstance } + /** The connection is pinned to one instance serving one era. */ + | { phase: 'pinned'; era: 'legacy' | 'modern'; instance: ConnectedInstance } + | { phase: 'closed' }; + +/** + * Serves MCP over stdio from a server factory, owning the era decision for + * the connection: the opening exchange selects the era, ONE instance from the + * factory is pinned for the connection lifetime, and everything after passes + * straight through to it. See the module documentation for the opening rules. + * + * ```ts + * import { serveStdio } from '@modelcontextprotocol/server/stdio'; + * + * serveStdio(() => { + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + * // register tools/resources/prompts once — the same factory serves both eras + * return server; + * }); + * ``` + */ +export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions = {}): StdioServerHandle { + const legacyMode = options.legacy ?? 'serve'; + const wire = options.transport ?? new StdioServerTransport(); + + let state: EntryState = { phase: 'opening' }; + /** Channel currently being discarded (its close must not tear the connection down). */ + let discarding: StdioConnectionChannel | undefined; + let closing = false; + + /** + * Whether the connection has been torn down (`handle.close()` or the wire + * closing). The opening arms re-check this after every await: a close can + * race factory construction, and the continuation must neither resurrect + * the connection state nor keep a late-resolved instance around. + */ + const isTornDown = (): boolean => closing || state.phase === 'closed'; + + const reportError = (error: Error) => { + try { + options.onerror?.(error); + } catch { + // Reporting must never affect the wire. + } + }; + + const writeErrorResponse = (id: RequestId, code: number, message: string, data?: unknown): Promise => + wire + .send({ jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined && { data }) } }) + .catch(error => reportError(toError(error))); + + /** Answers a 2025-era request the entry will not serve (the modern-only rejection cells). */ + const answerLegacyRejection = ( + request: JSONRPCRequest, + reason: 'initialize' | 'no-claim', + requestedVersion?: string + ): Promise => { + const rejection = modernOnlyStrictRejection( + { kind: 'legacy', reason, ...(requestedVersion !== undefined && { requestedVersion }) }, + SUPPORTED_MODERN_PROTOCOL_VERSIONS + ); + if (rejection === undefined) { + return Promise.resolve(); + } + reportError(new Error(`Rejected 2025-era request on a modern-only stdio connection (${rejection.cell}): ${rejection.message}`)); + return writeErrorResponse(request.id, rejection.code, rejection.message, rejection.data); + }; + + const onInstanceClosed = (channel: StdioConnectionChannel) => { + if (closing || channel === discarding) { + return; + } + // The pinned (or probe) instance was closed from the instance side: + // the connection is over. + void closeAll(); + }; + + const connectInstance = async (era: 'legacy' | 'modern', revision?: string): Promise => { + const product = await factory({ era }); + const server = product instanceof McpServer ? product.server : product; + if (era === 'modern') { + // Era-write at instance binding, then modern-only handler + // installation — the same helpers the HTTP entry's modern path + // uses, before the instance is connected. + setNegotiatedProtocolVersion(server, revision); + installModernOnlyHandlers(server, SUPPORTED_MODERN_PROTOCOL_VERSIONS); + } + const channel: StdioConnectionChannel = new StdioConnectionChannel(wire, () => onInstanceClosed(channel)); + await product.connect(channel); + return { product, channel }; + }; + + /** Closes an instance whose factory resolved only after the connection was torn down. */ + const disposeLateInstance = (instance: ConnectedInstance): Promise => + instance.product.close().catch(error => reportError(toError(error))); + + const discardProbeInstance = async (instance: ConnectedInstance): Promise => { + // The probe instance served only the discover exchange; closing its + // channel must not tear down the connection the fallback is about to + // continue on. + discarding = instance.channel; + try { + // A probe request the entry accepted must never go silently + // unanswered: a client may pipeline its fallback `initialize` + // straight behind `server/discover` without waiting, and closing + // the instance aborts whatever it still has in flight. Let the + // in-flight DiscoverResult reach the wire before the instance is + // closed; the probe instance only ever receives `server/discover`, + // whose entry-installed handler always answers promptly. + await instance.channel.whenRequestsAnswered(); + await instance.product.close(); + } catch (error) { + reportError(toError(error)); + } finally { + discarding = undefined; + } + }; + + const processMessage = async (message: JSONRPCMessage): Promise => { + if (state.phase === 'closed') { + return; + } + + if (state.phase === 'pinned') { + if ( + state.era === 'modern' && + isJSONRPCRequest(message) && + message.method === 'initialize' && + !carriesValidModernEnvelopeClaim(message.params) + ) { + // The modern era is confirmed for this connection; a late + // legacy handshake is answered with the version error naming + // the supported revisions (the specification recommends + // naming them in any error returned to `initialize`, and + // rules out falling back once the modern era is confirmed). + const requestedVersion = + message.params !== null && + typeof message.params === 'object' && + typeof (message.params as { protocolVersion?: unknown }).protocolVersion === 'string' + ? ((message.params as { protocolVersion: string }).protocolVersion as string) + : undefined; + await answerLegacyRejection(message, 'initialize', requestedVersion); + return; + } + state.instance.channel.deliver(message); + return; + } + + // Negotiation window ('opening' | 'probe'). + if (!isJSONRPCRequest(message) && !isJSONRPCNotification(message)) { + // A JSON-RPC response before any era is pinned: nothing has been + // asked of the client yet, so there is nothing it can answer. + reportError(new Error('Discarded a JSON-RPC response received before the connection negotiated an era')); + return; + } + + const opening = classifyOpeningMessage(message); + switch (opening.kind) { + case 'invalid-envelope': { + const detail = `Invalid _meta envelope for protocol revision 2026-07-28: ${opening.issue.key}: ${opening.issue.problem}`; + if (isJSONRPCRequest(message)) { + await writeErrorResponse(message.id, ProtocolErrorCode.InvalidParams, detail, { envelope: opening.issue }); + } else { + reportError(new Error(`Discarded a notification with a malformed envelope: ${detail}`)); + } + return; + } + case 'unsupported-revision': { + if (isJSONRPCRequest(message)) { + const error = new UnsupportedProtocolVersionError({ + supported: [...SUPPORTED_MODERN_PROTOCOL_VERSIONS], + requested: opening.requested + }); + reportError(error); + await writeErrorResponse(message.id, error.code, error.message, error.data); + } else { + reportError(new Error(`Discarded a notification claiming unsupported protocol revision ${opening.requested}`)); + } + return; + } + case 'modern': { + if (isJSONRPCRequest(message) && message.method === 'server/discover') { + if (state.phase === 'probe') { + // A repeated probe is answered by the same optimistic + // instance and the negotiation window stays open: only + // a non-discover enveloped request commits the + // connection to the modern era, so a later fallback + // `initialize` is still served by a fresh legacy + // instance. + state.instance.channel.deliver(message, { classification: opening.classification }); + return; + } + // Probe: answer from an optimistically built modern + // instance so the advertisement reflects the real server + // definition, but do not pin the connection yet — the + // client may still fall back to `initialize` when it + // shares no modern revision with the advertisement. + const instance = await connectInstance('modern', opening.revision); + if (isTornDown()) { + // The connection was torn down while the factory was + // building the probe instance: dispose of it and stay + // closed instead of resurrecting the negotiation + // window; nothing is delivered or answered. + await disposeLateInstance(instance); + return; + } + state = { phase: 'probe', instance }; + instance.channel.deliver(message, { classification: opening.classification }); + return; + } + if (state.phase === 'probe') { + if (isJSONRPCNotification(message)) { + // An enveloped notification during the negotiation + // window (for example a notifications/cancelled for + // the probe itself) is delivered to the probe instance + // without committing the era: only a non-discover + // enveloped request pins the connection, so a later + // fallback `initialize` is still served by a fresh + // legacy instance. + state.instance.channel.deliver(message, { classification: opening.classification }); + return; + } + // The probe was followed by a modern request: the client + // committed to the modern era — pin the probe instance. + state = { phase: 'pinned', era: 'modern', instance: state.instance }; + } else { + const instance = await connectInstance('modern', opening.revision); + if (isTornDown()) { + // Closed while the factory was building the modern + // instance: dispose of it and stay closed. + await disposeLateInstance(instance); + return; + } + state = { phase: 'pinned', era: 'modern', instance }; + } + state.instance.channel.deliver(message, { classification: opening.classification }); + return; + } + case 'legacy': { + if (legacyMode === 'reject') { + if (isJSONRPCRequest(message)) { + await answerLegacyRejection(message, opening.reason, opening.requestedVersion); + } + // Claim-less notifications are accepted and dropped (the + // stdio analog of the HTTP entry's 202-and-drop); the + // connection stays open for a modern opening. + return; + } + if (state.phase === 'probe') { + // Probe-then-fallback: the client probed, found no + // mutually supported modern revision, and fell back to + // the 2025 handshake on the same connection. The probe + // instance is discarded; a fresh legacy instance serves + // the handshake. + await discardProbeInstance(state.instance); + if (isTornDown()) { + // Closed while the probe was being discarded: stay closed. + return; + } + state = { phase: 'opening' }; + } + const instance = await connectInstance('legacy'); + if (isTornDown()) { + // Closed while the factory was building the legacy + // instance: dispose of it and stay closed. + await disposeLateInstance(instance); + return; + } + state = { phase: 'pinned', era: 'legacy', instance }; + state.instance.channel.deliver(message); + return; + } + } + }; + + // Inbound messages are processed strictly in arrival order: the queue + // absorbs anything that arrives while the opening exchange is still being + // decided (factory construction and instance connection are async). + const queue: JSONRPCMessage[] = []; + let pumping = false; + const pump = async (): Promise => { + if (pumping) { + return; + } + pumping = true; + try { + while (queue.length > 0) { + const message = queue.shift()!; + try { + await processMessage(message); + } catch (error) { + // Every arm of processMessage that answers a request does + // so through writeErrorResponse (which never throws — wire + // failures are routed to onerror) and returns right after, + // so an error escaping to here means the request was never + // answered. Answer it now: a throwing factory or a failed + // connect during the opening exchange must not leave the + // client's request hanging (the stdio analog of the HTTP + // entry's internal-server-error response). Notifications + // carry no id to answer and are only reported. + if (isJSONRPCRequest(message)) { + await writeErrorResponse(message.id, ProtocolErrorCode.InternalError, 'Internal server error'); + } + reportError(toError(error)); + } + } + } finally { + pumping = false; + } + }; + + const closeAll = async (): Promise => { + if (closing || state.phase === 'closed') { + return; + } + closing = true; + const current = state; + state = { phase: 'closed' }; + if (current.phase === 'probe' || current.phase === 'pinned') { + await current.instance.product.close().catch(error => reportError(toError(error))); + } + await wire.close().catch(error => reportError(toError(error))); + }; + + wire.onmessage = (message: JSONRPCMessage) => { + queue.push(message); + void pump(); + }; + wire.onerror = error => { + reportError(error); + if (state.phase === 'probe' || state.phase === 'pinned') { + state.instance.channel.onerror?.(error); + } + }; + wire.onclose = () => { + if (closing || state.phase === 'closed') { + return; + } + closing = true; + const current = state; + state = { phase: 'closed' }; + if (current.phase === 'probe' || current.phase === 'pinned') { + void current.instance.product.close().catch(error => reportError(toError(error))); + } + }; + + const started = wire.start().catch(error => { + reportError(toError(error)); + throw error; + }); + // Surface a failed start through onerror (above); close() still resolves. + started.catch(() => {}); + + return { + close: async () => { + await started.catch(() => {}); + await closeAll(); + } + }; +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 20e2995923..a0cd296ddb 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -16,7 +16,6 @@ import type { Implementation, InitializeRequest, InitializeResult, - JSONRPCNotification, JSONRPCRequest, JsonSchemaType, jsonSchemaValidator, @@ -24,7 +23,6 @@ import type { ListRootsResult, LoggingLevel, LoggingMessageNotification, - MessageClassification, MessageExtraInfo, NotificationMethod, NotificationOptions, @@ -41,14 +39,9 @@ import type { import { assertValidCacheHint, attachCacheHintFallback, - classifyInboundMessage, codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - envelopeClaimVersion, - FIRST_MODERN_PROTOCOL_VERSION, - hasEnvelopeClaim, - isModernProtocolVersion, LATEST_PROTOCOL_VERSION, legacyProtocolVersions, LoggingLevelSchema, @@ -58,14 +51,10 @@ import { Protocol, ProtocolError, ProtocolErrorCode, - requestMetaOf, SdkError, - SdkErrorCode, - SUPPORTED_MODERN_PROTOCOL_VERSIONS, - validateEnvelopeMeta + SdkErrorCode } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; -import * as z from 'zod/v4'; export type ServerOptions = ProtocolOptions & { /** @@ -95,52 +84,6 @@ export type ServerOptions = ProtocolOptions & { */ jsonSchemaValidator?: jsonSchemaValidator; - /** - * Which protocol eras this server serves on its long-lived connection - * (e.g. stdio): the 2025-era `initialize` family, the 2026-07-28 - * per-request-envelope revision, or both. - * - * - `'legacy'` (the default) preserves exactly what existing code was - * written for: the server speaks the 2025-era protocol negotiated via - * `initialize`, never registers or advertises `server/discover`, and - * upgrading the SDK changes nothing about what the instance puts on the - * wire. - * - `'dual-era'` serves BOTH eras on the same connection, selecting the - * era per message: `initialize`-negotiated 2025 traffic is served as - * before, while messages carrying the 2026-07-28 per-request `_meta` - * envelope (including `server/discover`) are served on the modern era. - * Declaring dual-era support is an explicit act — the consumer asserts - * that the server is ready to serve modern-era requests. - * - `'modern'` is strict 2026-07-28-only: requests without the - * per-request envelope (including `initialize`) are answered with the - * unsupported-protocol-version error naming the supported revisions. - * - * Declaring `'dual-era'` or `'modern'` automatically adds the SDK's - * supported modern revisions to - * {@linkcode ProtocolOptions.supportedProtocolVersions}, and `'modern'` - * serves only those: a strict instance's supported-versions list (what - * `server/discover` advertises and version-mismatch errors name) is its - * modern subset. - * - * Opting in is one option away and the transport stays unchanged: - * - * ```ts - * const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { eraSupport: 'dual-era' }); - * await server.connect(new StdioServerTransport()); - * ``` - * - * A 2026-era revision in {@linkcode ProtocolOptions.supportedProtocolVersions} - * requires `'dual-era'` or `'modern'`; passing one on a (default) - * `'legacy'` instance throws a `TypeError` at construction. - * - * Per-request HTTP serving via `createMcpHandler` does not use this - * option: the entry classifies each request and binds the per-request - * instance itself. - * - * @default 'legacy' - */ - eraSupport?: 'legacy' | 'dual-era' | 'modern'; - /** * Cache hints for the cacheable results of the 2026-07-28 protocol * revision (`ttlMs` / `cacheScope`), keyed by operation. The cacheable @@ -162,46 +105,14 @@ export type ServerOptions = ProtocolOptions & { cacheHints?: Partial>; }; -/** - * Permissive params schema for the `server/discover` registration on servers - * that declared modern-era support. The discover request carries only the - * per-request `_meta` envelope, which the protocol layer lifts and validates - * before dispatch — and a long-lived dual-era instance is never bound to a - * single era, so the spec-method registration form (which resolves its - * dispatch schema from the instance era) cannot be used here. - */ -const DISCOVER_PARAMS_SCHEMA = z.looseObject({}); - -/** - * Whether a message's params carry a per-request envelope claim that is both - * well-formed and names a modern protocol revision. - * - * The per-message form of the inbound classifier's `initialize` precedence - * rule: only such a claim overrides the `initialize` ⇒ legacy-handshake - * classification — a message carrying a valid modern envelope is a modern - * request regardless of its method name, and the modern era then answers - * `initialize` exactly like any other method it does not define - * (method-not-found). A malformed claim, or one naming a pre-2026 revision, - * keeps the legacy-handshake routing unchanged. - */ -function carriesValidModernEnvelopeClaim(params: unknown): boolean { - if (!hasEnvelopeClaim(params)) { - return false; - } - const claimedVersion = envelopeClaimVersion(params); - if (claimedVersion === undefined || !isModernProtocolVersion(claimedVersion)) { - return false; - } - const meta = requestMetaOf(params); - return meta !== undefined && validateEnvelopeMeta(meta).length === 0; -} - /* - * Package-internal hooks for the per-request (2026-07-28) HTTP serving entry. + * Package-internal hooks for the 2026-07-28 serving entries (the per-request + * HTTP entry `createMcpHandler` and the connection-pinned stdio entry + * `serveStdio`). * * The connection-scoped client-identity fields and the modern-only handler set are - * private to `Server`; the per-request entry in this package needs to write/install - * them on the fresh instance it gets from a consumer factory. The static initializer + * private to `Server`; the serving entries in this package need to write/install + * them on the fresh instance they get from a consumer factory. The static initializer * below hands these module-scoped closures privileged access; the exported wrappers * are imported by sibling modules in this package only and are deliberately NOT * re-exported from the package index (they are not public API). @@ -274,14 +185,6 @@ export class Server extends Protocol { private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; - private _eraSupport: 'legacy' | 'dual-era' | 'modern'; - /** - * The protocol version a legacy `initialize` handshake negotiated on a - * dual-era instance. A dual-era instance is never bound to a single era - * (the era is selected per message), so the handshake result is recorded - * here only for the initialize-scoped accessor. - */ - private _dualEraInitializeVersion?: string; private _cacheHints?: ServerOptions['cacheHints']; /** @@ -300,7 +203,6 @@ export class Server extends Protocol { this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._instructions = options?.instructions; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); - this._eraSupport = options?.eraSupport ?? 'legacy'; // Configured cache hints fail loudly at construction time (before any // handler registration consults them). @@ -316,45 +218,13 @@ export class Server extends Protocol { this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); - if (this._eraSupport === 'legacy') { - // The default preserves exactly what the code was written for: - // 2025-era serving only, nothing 2026-era registered or - // advertised. Serving a 2026-era revision is a declared act — a - // modern revision in the supported list without that declaration - // is a configuration error, never a silent behavior change. - const modernVersions = modernProtocolVersions(this._supportedProtocolVersions); - if (modernVersions.length > 0) { - throw new TypeError( - `supportedProtocolVersions contains the protocol revision ${modernVersions[0]}, which this server does not serve ` + - `with the default eraSupport of 'legacy'. Declare { eraSupport: 'dual-era' } (serve both eras) or ` + - `{ eraSupport: 'modern' } (2026-era only) to serve it.` - ); - } - } else { - // server/discover is registered (and modern revisions advertised) - // only on servers that declared modern-era support; the served - // modern revisions are added to the supported list so the - // advertisement and version-mismatch errors name them (a new - // array — the shared default constant is never mutated). - const missing = SUPPORTED_MODERN_PROTOCOL_VERSIONS.filter(version => !this._supportedProtocolVersions.includes(version)); - if (missing.length > 0) { - this._supportedProtocolVersions = [...this._supportedProtocolVersions, ...missing]; - } - this.setRequestHandler('server/discover', { params: DISCOVER_PARAMS_SCHEMA }, () => this._ondiscover()); - if (this._eraSupport === 'modern') { - // A strict modern-only server serves only modern revisions, so - // the supported list is reduced to its modern subset — keeping - // the legacy entries would advertise revisions the instance - // never serves in the unsupported-protocol-version error's - // supported list, and `initialize` (the only other consumer of - // the legacy entries) is unreachable on a strict instance. - this._supportedProtocolVersions = modernProtocolVersions(this._supportedProtocolVersions); - // A strict modern-only server is bound to the modern era from - // construction: requests classified into the 2025 era are - // answered with the typed unsupported-protocol-version error - // naming the supported revisions, never served. - this._negotiatedProtocolVersion = this._supportedProtocolVersions[0]; - } + // server/discover is installed only when the supported-versions list + // carries a modern revision: a legacy-only server keeps answering -32601. + // A hand-constructed instance is never era-bound, so the handler stays + // unreachable behind the era gate until a serving entry (createMcpHandler, + // serveStdio) marks the instance as serving the 2026-07-28 era. + if (modernProtocolVersions(this._supportedProtocolVersions).length > 0) { + this.setRequestHandler('server/discover', () => this._ondiscover()); } if (this._capabilities.logging) { @@ -362,35 +232,6 @@ export class Server extends Protocol { } } - /** - * Per-message era classification for long-lived dual-era channels (e.g. a - * stdio server that declared modern-era support). Active only when the - * consumer opted in: default (`'legacy'`) instances return `undefined`, - * which keeps their dispatch byte-identical to today's. Transport-edge - * classification (the per-request HTTP entry) always wins and never - * reaches this hook. - */ - protected override _classifyInbound(message: JSONRPCRequest | JSONRPCNotification): MessageClassification | 'drop' | undefined { - if (this._eraSupport === 'legacy') { - return undefined; - } - // `initialize` is the legacy handshake by definition — unless the - // message carries a valid envelope claim naming a modern revision, in - // which case the claim wins: the message is classified like any other - // enveloped message and served on the modern era, where the era - // registry answers `initialize` with the same plain method-not-found - // it answers every other method that era does not define. A malformed - // or absent claim, or a claim naming a pre-2026 revision, keeps the - // legacy-handshake classification from the per-message predicate. - if (message.method === 'initialize' && carriesValidModernEnvelopeClaim(message.params)) { - const claimedVersion = envelopeClaimVersion(message.params); - if (claimedVersion !== undefined) { - return { era: 'modern', revision: claimedVersion }; - } - } - return classifyInboundMessage(message); - } - /** * Registers the built-in `logging/setLevel` request handler. * @@ -411,76 +252,19 @@ export class Server extends Protocol { }); } - /** - * Era gate for context-related server→client requests, keyed off the era - * of the request currently being served (its classification). - * - * A long-lived dual-era instance is never bound to a single era, so the - * instance-level outbound era gate alone would let a handler that is - * serving a 2026-era request push a server→client wire request - * (sampling, elicitation, roots) onto the connection. The 2026-07-28 - * revision has no server→client JSON-RPC request channel, so the client - * drops the request and the call hangs until timeout. The request - * context therefore applies the same typed local error a strict - * `'modern'` instance raises, per request: spec methods absent from the - * served era's registry fail fast before anything reaches the transport. - * - * Scope: the context request path only (`ctx.mcpReq.send`, - * `ctx.mcpReq.elicitInput`, `ctx.mcpReq.requestSampling`). Related - * notifications, requests served on the legacy era, and instance-level - * senders used outside a request context are unaffected. - */ - private _assertContextRequestInServedEra(classification: MessageClassification | undefined, method: string): void { - if (classification === undefined) { - return; - } - const servedCodec = codecForVersion( - classification.revision ?? (classification.era === 'modern' ? FIRST_MODERN_PROTOCOL_VERSION : undefined) - ); - // Mirrors the outbound era gate: only spec methods missing from the - // served era are gated; methods the served era defines (and - // consumer-owned extension methods) resolve exactly as before. - if (servedCodec.hasRequestMethod(method) || !codecForVersion(undefined).hasRequestMethod(method)) { - return; - } - throw new SdkError( - SdkErrorCode.MethodNotSupportedByProtocolVersion, - `Server-to-client requests are not available on protocol revision ${servedCodec.era}: ` + - `'${method}' cannot be sent while serving a request on that revision. ` + - `Servers obtain client input through request results once multi-round-trip support is available.`, - { method, era: servedCodec.era } - ); - } - protected override buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext { // Only create http when there's actual HTTP transport info or auth info const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream; - const classification = transportInfo?.classification; - // Context-related server→client requests are gated by the era of the - // request being served (see _assertContextRequestInServedEra); - // related notifications (`notify`, `log`) are unaffected. - const baseSend = ctx.mcpReq.send as (request: { method: string }, ...rest: unknown[]) => Promise; - const send = ((request: { method: string }, ...rest: unknown[]) => { - this._assertContextRequestInServedEra(classification, request.method); - return baseSend(request, ...rest); - }) as BaseContext['mcpReq']['send']; return { ...ctx, mcpReq: { ...ctx.mcpReq, - send, // Deprecated as of protocol version 2026-07-28 (SEP-2577): `log` and // `requestSampling` remain functional during the deprecation window // (at least twelve months). See ServerContext for migration guidance. log: (level, data, logger) => this.sendLoggingMessage({ level, data, logger }), - elicitInput: async (params, options) => { - this._assertContextRequestInServedEra(classification, 'elicitation/create'); - return this.elicitInput(params, options); - }, - requestSampling: async (params, options) => { - this._assertContextRequestInServedEra(classification, 'sampling/createMessage'); - return this.createMessage(params, options); - } + elicitInput: (params, options) => this.elicitInput(params, options), + requestSampling: (params, options) => this.createMessage(params, options) }, http: hasHttpInfo ? { @@ -733,15 +517,7 @@ export class Server extends Protocol { // The negotiated version is the instance's connection state — it IS // the wire-era selection for everything this instance sends and // receives from here on (legacy handshake ⇒ a legacy-era version). - // The one exception is a dual-era instance: it serves both eras on - // the same long-lived connection, selecting the era per message, so - // the handshake never binds the instance — the result is recorded - // only for the initialize-scoped accessor. - if (this._eraSupport === 'dual-era') { - this._dualEraInitializeVersion = protocolVersion; - } else { - this._negotiatedProtocolVersion = protocolVersion; - } + this._negotiatedProtocolVersion = protocolVersion; this.transport?.setProtocolVersion?.(protocolVersion); return { @@ -802,13 +578,10 @@ export class Server extends Protocol { * 2026-07-28 (per-request envelope) requests `ctx.mcpReq.envelope` names the revision the * request was sent for, while on 2025-era connections this accessor keeps returning the * `initialize`-negotiated version. The accessor remains functional — instances serving the - * 2026-07-28 era report that revision. On a long-lived dual-era instance (`eraSupport: - * 'dual-era'`), where the era is selected per message, the accessor keeps its - * initialize-scoped semantics and reports what a legacy `initialize` handshake negotiated - * (or `undefined` when none ran). + * 2026-07-28 era report that revision. */ getNegotiatedProtocolVersion(): string | undefined { - return this._negotiatedProtocolVersion ?? this._dualEraInitializeVersion; + return this._negotiatedProtocolVersion; } /** diff --git a/packages/server/src/stdio.ts b/packages/server/src/stdio.ts index 7865c9cedc..deaa8468db 100644 --- a/packages/server/src/stdio.ts +++ b/packages/server/src/stdio.ts @@ -1,8 +1,11 @@ -// Subpath entry for the stdio server transport. +// Subpath entry for stdio serving. // -// Exported separately from the root entry to keep `StdioServerTransport` out of the default bundle +// Exported separately from the root entry to keep the process-stdio surface (`StdioServerTransport` +// and the `serveStdio` entry point, which constructs one by default) out of the default bundle // surface — server stdio has only type-level Node imports, but matching the client's `./stdio` // subpath gives consumers a consistent shape across packages. Import from // `@modelcontextprotocol/server/stdio` only in process-stdio runtimes (Node.js, Bun, Deno). +export type { ServeStdioOptions, StdioServerHandle } from './server/serveStdio.js'; +export { serveStdio } from './server/serveStdio.js'; export { StdioServerTransport } from './server/stdio.js'; diff --git a/packages/server/test/server/discover.test.ts b/packages/server/test/server/discover.test.ts index d9806bd1ef..c2b96da595 100644 --- a/packages/server/test/server/discover.test.ts +++ b/packages/server/test/server/discover.test.ts @@ -1,11 +1,9 @@ /** * `server/discover` machinery + era-aware supported-version list semantics: * - * - the handler is installed ONLY on servers that declare modern-era support - * (`eraSupport: 'dual-era' | 'modern'`); default servers keep answering - * -32601 byte-identically to the deployed fleet, and a modern (2026-07-28+) - * revision in the supported-versions list without that declaration is a - * construction-time TypeError + * - the handler is installed ONLY when the server's supported-versions list + * carries a modern (2026-07-28+) revision; default servers keep answering + * -32601 byte-identically to the deployed fleet * - the advertisement is modern-only (DV-30) and excludes the * listChanged/subscribe-class capabilities (A11 rider — until the * subscriptions/listen milestone lands) @@ -14,10 +12,12 @@ * site, even when the supported list carries one — the guard that must hold * BEFORE any LATEST/SUPPORTED constant bump. * - * The HTTP per-request entry still binds its instances to the modern era - * through the package-internal hook; the `markModern` arm of the harness - * stands in for that path, and the modern-era request shape carries the - * required per-request `_meta` envelope. + * Era is instance state: an inbound `server/discover` is served only by a + * modern-era instance (the method is physically absent from the legacy + * registry). Production marking of modern instances is owned by the + * server-entry milestone; these tests mark instances through the + * package-internal hook the entry will use, and the modern-era request shape + * carries the required per-request `_meta` envelope. */ import type { DiscoverResult, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; import { @@ -92,7 +92,7 @@ describe('server/discover handler gating', () => { it('a server with a modern revision in its supported list serves discover on a modern-era instance', async () => { const server = new Server( { name: 'modern-server', version: '2.0.0' }, - { capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era', instructions: 'hello' } + { capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS, instructions: 'hello' } ); const response = await sendRaw(server, discoverRequest, { markModern: true }); expect(isJSONRPCResultResponse(response)).toBe(true); @@ -118,10 +118,7 @@ describe('server/discover handler gating', () => { describe('discover advertisement constraints', () => { it('advertises modern-only versions (DV-30): no 2025-era string ever appears in supportedVersions', async () => { - const server = new Server( - { name: 'test', version: '1.0.0' }, - { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } - ); + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS }); const response = await sendRaw(server, discoverRequest, { markModern: true }); if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); const result = DiscoverResultSchema.parse(response.result); @@ -143,8 +140,7 @@ describe('discover advertisement constraints', () => { logging: {}, completions: {} }, - supportedProtocolVersions: DUAL_ERA_VERSIONS, - eraSupport: 'dual-era' + supportedProtocolVersions: DUAL_ERA_VERSIONS } ); const response = await sendRaw(server, discoverRequest, { markModern: true }); @@ -170,10 +166,7 @@ describe('discover advertisement constraints', () => { expect(capabilities).toEqual({ tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } }); // The legacy initialize advertisement still carries the full capability set. - const server = new Server( - { name: 'test', version: '1.0.0' }, - { capabilities, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } - ); + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities, supportedProtocolVersions: DUAL_ERA_VERSIONS }); const response = await sendRaw(server, initializeRequest(LATEST_PROTOCOL_VERSION)); if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); const result = InitializeResultSchema.parse(response.result); @@ -185,10 +178,7 @@ describe('discover advertisement constraints', () => { describe('era-aware counter-offer ordering (the guard that precedes any constant bump)', () => { it('an unknown requested version is countered with the latest LEGACY version even when the list carries a modern one', async () => { - const server = new Server( - { name: 'test', version: '1.0.0' }, - { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } - ); + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS }); const response = await sendRaw(server, initializeRequest('1999-01-01')); if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); const result = InitializeResultSchema.parse(response.result); @@ -201,10 +191,7 @@ describe('era-aware counter-offer ordering (the guard that precedes any constant }); it('an initialize REQUESTING the modern revision is also answered with the latest legacy version (initialize never negotiates a modern era)', async () => { - const server = new Server( - { name: 'test', version: '1.0.0' }, - { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } - ); + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS }); const response = await sendRaw(server, initializeRequest(MODERN)); if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); const result = InitializeResultSchema.parse(response.result); diff --git a/packages/server/test/server/dualEraServing.test.ts b/packages/server/test/server/dualEraServing.test.ts deleted file mode 100644 index 32d9b6dd53..0000000000 --- a/packages/server/test/server/dualEraServing.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -/** - * Long-lived dual-era serving (`eraSupport: 'dual-era'`) on one connection: - * - * - the legacy vertical (initialize → tools/list → tools/call) is served - * exactly as a 2025 server serves it (no 2026 wire fields anywhere); - * - the modern vertical (server/discover → tools/list → tools/call, every - * request carrying the per-request `_meta` envelope) is served on the - * 2026 era on the SAME connection; - * - the long-lived era gate: a message classified into the legacy era asking - * for `server/discover`, `subscriptions/listen`, or any 2026-only method is - * answered with a plain −32601 carrying ZERO 2026 vocabulary in message or - * data (the dedicated leak test — the gate is not structural on a long-lived - * instance, which hosts both registries); the modern-direction denial of - * legacy-only methods mirrors it. - * - Q10-L2: a hand-constructed server with the default `eraSupport` serves a - * scripted 2025 session with today's exact result shapes and zero 2026 - * vocabulary on the wire. - */ -import type { JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; -import { - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, - InMemoryTransport, - isJSONRPCErrorResponse, - isJSONRPCResultResponse, - LATEST_PROTOCOL_VERSION, - PROTOCOL_VERSION_META_KEY -} from '@modelcontextprotocol/core'; -import { describe, expect, it } from 'vitest'; -import * as z from 'zod/v4'; - -import { McpServer } from '../../src/server/mcp.js'; - -const MODERN = '2026-07-28'; - -/** - * 2026-era vocabulary that must never leak into a legacy-direction response. - * The gate answers with the same plain `-32601` a 2025 server answers for an - * unknown method — nothing in message or data may reveal that the instance - * also hosts the modern era. - */ -const FORBIDDEN_2026_VOCABULARY = [ - '2026', - 'discover', - 'envelope', - 'modern', - 'dual', - 'era', - '_meta', - 'io.modelcontextprotocol', - 'resultType', - 'protocolVersion', - 'protocol version', - 'subscription' -]; - -/** The 2026-only request methods the era gate must hide from legacy-era traffic. */ -const MODERN_ONLY_METHODS = ['server/discover', 'subscriptions/listen']; - -/** - * Legacy-only methods whose modern-direction denial mirrors the gate. - * (`initialize` is not in this list only because it has its own dedicated - * coverage below: an `initialize` carrying a valid modern envelope claim is - * classified by the claim — the claim wins over the legacy-handshake rule — - * and is denied with the same plain −32601.) - */ -const LEGACY_ONLY_METHODS = ['ping', 'logging/setLevel', 'resources/subscribe']; - -const envelope = (overrides?: Record) => ({ - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name: 'modern-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {}, - ...overrides -}); - -function buildServer(options?: { eraSupport?: 'legacy' | 'dual-era' | 'modern' }) { - const server = new McpServer( - { name: 'dual-era-test-server', version: '1.0.0' }, - { - capabilities: { tools: {} }, - instructions: 'test instructions', - ...(options?.eraSupport ? { eraSupport: options.eraSupport } : {}) - } - ); - server.registerTool('echo', { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ - content: [{ type: 'text', text }] - })); - return server; -} - -async function wire(server: McpServer) { - const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); - const inbound: JSONRPCMessage[] = []; - const waiters = new Map void>(); - peerTx.onmessage = message => { - inbound.push(message); - const id = (message as { id?: string | number }).id; - const waiter = id === undefined ? undefined : waiters.get(id); - if (id !== undefined && waiter) { - waiters.delete(id); - waiter(message); - } - }; - await server.connect(serverTx); - await peerTx.start(); - - const request = (message: JSONRPCRequest): Promise => - new Promise(resolve => { - waiters.set(message.id, resolve); - void peerTx.send(message); - }); - const notify = (message: JSONRPCNotification): Promise => peerTx.send(message); - return { request, notify, inbound, close: () => server.close() }; -} - -const initializeRequest = (id: number): JSONRPCRequest => ({ - jsonrpc: '2.0', - id, - method: 'initialize', - params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'legacy-client', version: '1.0.0' } } -}); - -describe('dual-era serving on one long-lived connection', () => { - it('serves the legacy vertical and the modern vertical on the same connection, each on its own era', async () => { - const server = buildServer({ eraSupport: 'dual-era' }); - const { request, notify, close } = await wire(server); - - // --- Legacy vertical: initialize → initialized → tools/list → tools/call. - const init = await request(initializeRequest(1)); - expect(isJSONRPCResultResponse(init)).toBe(true); - if (isJSONRPCResultResponse(init)) { - expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); - expect(JSON.stringify(init)).not.toContain('resultType'); - expect(JSON.stringify(init)).not.toContain('2026'); - } - await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); - - const legacyList = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); - expect(isJSONRPCResultResponse(legacyList)).toBe(true); - if (isJSONRPCResultResponse(legacyList)) { - expect((legacyList.result as { tools: Array<{ name: string }> }).tools.map(tool => tool.name)).toEqual(['echo']); - expect(JSON.stringify(legacyList)).not.toContain('resultType'); - } - - const legacyCall = await request({ - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { name: 'echo', arguments: { text: 'legacy leg' } } - }); - expect(isJSONRPCResultResponse(legacyCall)).toBe(true); - if (isJSONRPCResultResponse(legacyCall)) { - expect((legacyCall.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'legacy leg' }]); - expect(JSON.stringify(legacyCall)).not.toContain('resultType'); - } - - // --- Modern vertical on the SAME connection: discover → list → call, - // every request carrying the per-request envelope. - const discover = await request({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(discover)).toBe(true); - if (isJSONRPCResultResponse(discover)) { - const result = discover.result as { supportedVersions?: string[]; resultType?: string }; - expect(result.supportedVersions).toEqual([MODERN]); - expect(result.resultType).toBe('complete'); - } - - const modernList = await request({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(modernList)).toBe(true); - if (isJSONRPCResultResponse(modernList)) { - const result = modernList.result as { tools: Array<{ name: string }>; resultType?: string }; - expect(result.tools.map(tool => tool.name)).toEqual(['echo']); - expect(result.resultType).toBe('complete'); - } - - const modernCall = await request({ - jsonrpc: '2.0', - id: 6, - method: 'tools/call', - params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope() } - }); - expect(isJSONRPCResultResponse(modernCall)).toBe(true); - if (isJSONRPCResultResponse(modernCall)) { - const result = modernCall.result as { content: unknown[]; resultType?: string }; - expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); - expect(result.resultType).toBe('complete'); - } - - // The legacy leg is unaffected by the modern exchanges that ran in between. - const legacyAgain = await request({ jsonrpc: '2.0', id: 7, method: 'tools/list', params: {} }); - expect(isJSONRPCResultResponse(legacyAgain)).toBe(true); - expect(JSON.stringify(legacyAgain)).not.toContain('resultType'); - - await close(); - }); - - it('the modern era is reachable without any prior legacy handshake (envelope-first connection)', async () => { - const server = buildServer({ eraSupport: 'dual-era' }); - const { request, close } = await wire(server); - - const discover = await request({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(discover)).toBe(true); - await close(); - }); -}); - -describe('long-lived era gate + zero-2026-vocabulary leak test', () => { - it('a legacy-classified request for any 2026-only method answers a plain −32601 with zero 2026 vocabulary in message or data', async () => { - const server = buildServer({ eraSupport: 'dual-era' }); - const { request, close } = await wire(server); - - // Establish the legacy leg first — the gate must hold on a connection - // that is actively serving 2025 traffic. - const init = await request(initializeRequest(1)); - expect(isJSONRPCResultResponse(init)).toBe(true); - - let id = 10; - for (const method of MODERN_ONLY_METHODS) { - // No envelope claim ⇒ classified legacy ⇒ the modern registry must be invisible. - const response = await request({ jsonrpc: '2.0', id: (id += 1), method, params: {} }); - expect(isJSONRPCErrorResponse(response)).toBe(true); - const error = (response as JSONRPCErrorResponse).error; - expect(error.code).toBe(-32_601); - expect(error.message).toBe('Method not found'); - expect(error.data).toBeUndefined(); - - const serialized = JSON.stringify({ error, id: null }); - for (const term of FORBIDDEN_2026_VOCABULARY) { - expect(serialized.toLowerCase()).not.toContain(term.toLowerCase()); - } - } - await close(); - }); - - it('the modern-direction denial mirrors it: a modern-classified request for a legacy-only method answers −32601', async () => { - const server = buildServer({ eraSupport: 'dual-era' }); - const { request, close } = await wire(server); - - let id = 20; - for (const method of LEGACY_ONLY_METHODS) { - const response = await request({ jsonrpc: '2.0', id: (id += 1), method, params: { _meta: envelope() } }); - expect(isJSONRPCErrorResponse(response)).toBe(true); - const error = (response as JSONRPCErrorResponse).error; - expect(error.code).toBe(-32_601); - expect(error.message).toBe('Method not found'); - } - await close(); - }); -}); - -describe('enveloped initialize on a dual-era instance (a valid modern claim wins over the legacy-handshake rule)', () => { - it('an initialize carrying a valid modern envelope claim answers a plain −32601 and is never served by the legacy handshake', async () => { - const server = buildServer({ eraSupport: 'dual-era' }); - const { request, close } = await wire(server); - - const response = await request({ jsonrpc: '2.0', id: 30, method: 'initialize', params: { _meta: envelope() } }); - expect(isJSONRPCErrorResponse(response)).toBe(true); - const error = (response as JSONRPCErrorResponse).error; - expect(error.code).toBe(-32_601); - expect(error.message).toBe('Method not found'); - expect(error.data).toBeUndefined(); - - // Nothing beyond the normal method-not-found shape leaks 2026 vocabulary. - const serialized = JSON.stringify({ error, id: null }); - for (const term of FORBIDDEN_2026_VOCABULARY) { - expect(serialized.toLowerCase()).not.toContain(term.toLowerCase()); - } - - // The legacy initialize path never ran: the initialize-scoped accessors stay unset. - expect(server.server.getNegotiatedProtocolVersion()).toBeUndefined(); - expect(server.server.getClientVersion()).toBeUndefined(); - - // An envelope-less initialize on the same connection keeps today's behavior: - // the legacy handshake is served exactly as before, with zero 2026 vocabulary. - const init = await request(initializeRequest(31)); - expect(isJSONRPCResultResponse(init)).toBe(true); - if (isJSONRPCResultResponse(init)) { - expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); - expect(JSON.stringify(init)).not.toContain('resultType'); - expect(JSON.stringify(init)).not.toContain('2026'); - } - await close(); - }); - - it('an initialize with a malformed envelope claim keeps the legacy handshake', async () => { - const server = buildServer({ eraSupport: 'dual-era' }); - const { request, close } = await wire(server); - - // The claim key is present but the envelope is incomplete — never a - // silent flip to the modern era; the legacy handshake serves it as before. - const response = await request({ - jsonrpc: '2.0', - id: 40, - method: 'initialize', - params: { - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: 'legacy-client', version: '1.0.0' }, - _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN } - } - }); - expect(isJSONRPCResultResponse(response)).toBe(true); - if (isJSONRPCResultResponse(response)) { - expect((response.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); - } - await close(); - }); - - it('an initialize whose valid envelope claim names a pre-2026 revision keeps the legacy handshake', async () => { - const server = buildServer({ eraSupport: 'dual-era' }); - const { request, close } = await wire(server); - - const response = await request({ - jsonrpc: '2.0', - id: 50, - method: 'initialize', - params: { - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: 'legacy-client', version: '1.0.0' }, - _meta: envelope({ [PROTOCOL_VERSION_META_KEY]: '2025-06-18' }) - } - }); - expect(isJSONRPCResultResponse(response)).toBe(true); - if (isJSONRPCResultResponse(response)) { - expect((response.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); - } - await close(); - }); -}); - -describe('Q10-L2: a hand-constructed server with the default eraSupport on 2025 traffic', () => { - it('serves a scripted 2025 session with the exact 2025 shapes and zero 2026 vocabulary on the wire', async () => { - const server = buildServer(); - const { request, notify, inbound, close } = await wire(server); - - const init = await request(initializeRequest(1)); - expect(isJSONRPCResultResponse(init)).toBe(true); - if (isJSONRPCResultResponse(init)) { - expect(init.result).toEqual({ - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: { tools: { listChanged: true } }, - serverInfo: { name: 'dual-era-test-server', version: '1.0.0' }, - instructions: 'test instructions' - }); - } - await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); - - const list = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); - expect(isJSONRPCResultResponse(list)).toBe(true); - if (isJSONRPCResultResponse(list)) { - const tools = (list.result as { tools: Array> }).tools; - expect(tools).toHaveLength(1); - expect(tools[0]).toMatchObject({ name: 'echo', description: 'Echoes the input text' }); - expect(Object.keys(list.result as Record).sort()).toEqual(['tools']); - } - - const call = await request({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'echo', arguments: { text: 'hi' } } }); - expect(isJSONRPCResultResponse(call)).toBe(true); - if (isJSONRPCResultResponse(call)) { - expect(call.result).toEqual({ content: [{ type: 'text', text: 'hi' }] }); - } - - const ping = await request({ jsonrpc: '2.0', id: 4, method: 'ping' }); - expect(isJSONRPCResultResponse(ping)).toBe(true); - if (isJSONRPCResultResponse(ping)) { - expect(ping.result).toEqual({}); - } - - // A default instance keeps answering server/discover with -32601, byte-identical to the deployed fleet. - const discover = await request({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: {} }); - expect(isJSONRPCErrorResponse(discover)).toBe(true); - if (isJSONRPCErrorResponse(discover)) { - expect(discover.error).toEqual({ code: -32_601, message: 'Method not found' }); - } - - // Nothing the server wrote on this 2025 session carries 2026 wire vocabulary. - const wireBytes = JSON.stringify(inbound); - expect(wireBytes).not.toContain('resultType'); - expect(wireBytes).not.toContain('2026'); - expect(wireBytes).not.toContain('io.modelcontextprotocol/'); - - await close(); - }); -}); diff --git a/packages/server/test/server/eraSupport.test.ts b/packages/server/test/server/eraSupport.test.ts deleted file mode 100644 index 78ff050cb7..0000000000 --- a/packages/server/test/server/eraSupport.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * `ServerOptions.eraSupport` — the stdio/long-lived-connection era opt-in: - * - * - default `'legacy'` for hand-constructed `Server`/`McpServer`: nothing - * 2026-era is registered or advertised, and a modern revision in - * `supportedProtocolVersions` without the declaration is a construction-time - * `TypeError` (never a silent behavior change). - * - `'dual-era'`: `server/discover` registered without any instance binding, - * modern revisions advertised, both eras served per message. - * - `'modern'`: strict 2026-only — envelope-less requests (including - * `initialize`) answer the unsupported-protocol-version error with the - * supported list; legacy-classified notifications are dropped. - * - TS-01 directionality: a modern-bound instance cannot emit server→client - * wire requests (typed local error); a dual-era instance serving the legacy - * leg still can, while a handler serving a 2026-classified request gets the - * same typed error from the ctx-related request path. - */ -import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; -import { - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, - InMemoryTransport, - isJSONRPCErrorResponse, - isJSONRPCResultResponse, - LATEST_PROTOCOL_VERSION, - PROTOCOL_VERSION_META_KEY, - SdkError, - SdkErrorCode, - SUPPORTED_PROTOCOL_VERSIONS -} from '@modelcontextprotocol/core'; -import { describe, expect, it } from 'vitest'; - -import { McpServer } from '../../src/server/mcp.js'; -import { Server } from '../../src/server/server.js'; - -const MODERN = '2026-07-28'; -const DUAL_ERA_VERSIONS = [MODERN, ...SUPPORTED_PROTOCOL_VERSIONS]; - -const envelope = (overrides?: Record) => ({ - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name: 'era-test-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {}, - ...overrides -}); - -const initializeRequest = (id: number, requestedVersion = LATEST_PROTOCOL_VERSION): JSONRPCRequest => ({ - jsonrpc: '2.0', - id, - method: 'initialize', - params: { - protocolVersion: requestedVersion, - capabilities: { sampling: {} }, - clientInfo: { name: 'legacy-client', version: '1.0.0' } - } -}); - -interface Connectable { - connect(transport: InstanceType): Promise; - close(): Promise; -} - -/** Wires a server to one long-lived in-memory connection and returns request/notify drivers. */ -async function wireServer(server: Connectable) { - const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); - const inbound: JSONRPCMessage[] = []; - const waiters = new Map void>(); - peerTx.onmessage = message => { - inbound.push(message); - const id = (message as { id?: string | number }).id; - const waiter = id === undefined ? undefined : waiters.get(id); - if (id !== undefined && waiter) { - waiters.delete(id); - waiter(message); - } - }; - await server.connect(serverTx); - await peerTx.start(); - - const request = (message: JSONRPCRequest): Promise => - new Promise(resolve => { - waiters.set(message.id, resolve); - void peerTx.send(message); - }); - const notify = (message: JSONRPCNotification): Promise => peerTx.send(message); - const flush = () => new Promise(resolve => setTimeout(resolve, 10)); - return { request, notify, flush, inbound, peerTx, close: () => server.close() }; -} - -describe('construction-time guard (default eraSupport is legacy)', () => { - it('throws a TypeError when supportedProtocolVersions carries a modern revision on a default instance', () => { - expect(() => new Server({ name: 't', version: '1' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS })).toThrow( - TypeError - ); - expect(() => new Server({ name: 't', version: '1' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS })).toThrow( - /eraSupport/ - ); - }); - - it('throws for McpServer too (options are forwarded)', () => { - expect(() => new McpServer({ name: 't', version: '1' }, { supportedProtocolVersions: [MODERN] })).toThrow(TypeError); - }); - - it('does not throw when the modern revision is accompanied by a dual-era or modern declaration', () => { - expect( - () => - new Server( - { name: 't', version: '1' }, - { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } - ) - ).not.toThrow(); - expect( - () => new Server({ name: 't', version: '1' }, { capabilities: {}, supportedProtocolVersions: [MODERN], eraSupport: 'modern' }) - ).not.toThrow(); - }); - - it('a default legacy-only construction stays exactly as before (no throw, no discover handler)', async () => { - const server = new Server({ name: 't', version: '1' }, { capabilities: {} }); - const { request, close } = await wireServer(server); - const response = await request({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: { _meta: envelope() } }); - expect(isJSONRPCErrorResponse(response)).toBe(true); - if (isJSONRPCErrorResponse(response)) { - expect(response.error.code).toBe(-32_601); - } - await close(); - }); -}); - -describe("DV-30: server/discover is registered only when eraSupport !== 'legacy'", () => { - it('a dual-era server serves discover with no instance binding and advertises only modern revisions', async () => { - const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); - const { request, close } = await wireServer(server); - - const response = await request({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(response)).toBe(true); - if (isJSONRPCResultResponse(response)) { - const result = response.result as { supportedVersions?: string[]; resultType?: string }; - expect(result.supportedVersions).toEqual([MODERN]); - // Served on the modern era: the wire result carries the 2026 result discriminator. - expect(result.resultType).toBe('complete'); - } - await close(); - }); - - it('the served modern revisions are added to the supported list without mutating the shared default constant', () => { - const before = [...SUPPORTED_PROTOCOL_VERSIONS]; - const server = new Server({ name: 'dual', version: '1' }, { capabilities: {}, eraSupport: 'dual-era' }); - expect(SUPPORTED_PROTOCOL_VERSIONS).toEqual(before); - expect(server).toBeDefined(); - }); -}); - -describe("DV-31: strict 'modern' on a long-lived connection", () => { - async function wireModernServer() { - const server = new Server({ name: 'strict', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'modern' }); - server.setRequestHandler('tools/list', () => ({ tools: [] })); - return { server, ...(await wireServer(server)) }; - } - - it('an envelope-less non-initialize request answers −32004 with the supported list', async () => { - const { request, close } = await wireModernServer(); - const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); - expect(isJSONRPCErrorResponse(response)).toBe(true); - if (isJSONRPCErrorResponse(response)) { - // The envelope-less request on a modern-only instance answers the - // unsupported-protocol-version error with the supported list (the - // HTTP entry's header/body mismatch cells use −32001 instead; there - // is no header layer on a long-lived connection). - expect(response.error.code).toBe(-32_004); - const data = response.error.data as { supported?: string[]; requested?: string }; - // The strict instance serves only modern revisions, so the supported - // list it advertises names only those (never the legacy defaults). - expect(data.supported).toEqual([MODERN]); - expect(typeof data.requested).toBe('string'); - } - await close(); - }); - - it('an envelope-less initialize answers −32004 with the supported list (never a legacy handshake)', async () => { - const { request, close } = await wireModernServer(); - const response = await request(initializeRequest(2)); - expect(isJSONRPCErrorResponse(response)).toBe(true); - if (isJSONRPCErrorResponse(response)) { - expect(response.error.code).toBe(-32_004); - expect((response.error.data as { supported?: string[] }).supported).toEqual([MODERN]); - expect((response.error.data as { requested?: string }).requested).toBe(LATEST_PROTOCOL_VERSION); - } - await close(); - }); - - it('an initialize carrying a valid modern envelope claim answers a plain −32601 (the claim wins over the legacy-handshake rule)', async () => { - const { request, close } = await wireModernServer(); - const response = await request({ jsonrpc: '2.0', id: 5, method: 'initialize', params: { _meta: envelope() } }); - expect(isJSONRPCErrorResponse(response)).toBe(true); - if (isJSONRPCErrorResponse(response)) { - // Classified by its valid modern claim, the request is served on the - // modern era, where `initialize` is answered like every other method - // that era does not define — never with the version error reserved - // for envelope-less requests. - expect(response.error.code).toBe(-32_601); - expect(response.error.message).toBe('Method not found'); - expect(response.error.data).toBeUndefined(); - } - await close(); - }); - - it('a legacy-classified notification is dropped without a response', async () => { - const { notify, flush, inbound, close } = await wireModernServer(); - await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); - await flush(); - expect(inbound).toHaveLength(0); - await close(); - }); - - it('an enveloped modern request is served', async () => { - const { request, close } = await wireModernServer(); - const response = await request({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(response)).toBe(true); - if (isJSONRPCResultResponse(response)) { - expect((response.result as { tools?: unknown[] }).tools).toEqual([]); - expect((response.result as { resultType?: string }).resultType).toBe('complete'); - } - await close(); - }); - - it('server/discover advertises only modern revisions', async () => { - const { request, close } = await wireModernServer(); - const response = await request({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(response)).toBe(true); - if (isJSONRPCResultResponse(response)) { - expect((response.result as { supportedVersions?: string[] }).supportedVersions).toEqual([MODERN]); - } - await close(); - }); - - it('a mixed legacy+modern supported list is reduced to its modern subset at construction', async () => { - const server = new Server( - { name: 'strict', version: '1' }, - { capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'modern' } - ); - const { request, close } = await wireServer(server); - - // The unsupported-protocol-version handoff names only the modern - // revisions: the legacy entries the consumer passed are never served - // by a strict instance, so they are not advertised either. - const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); - expect(isJSONRPCErrorResponse(response)).toBe(true); - if (isJSONRPCErrorResponse(response)) { - expect((response.error.data as { supported?: string[] }).supported).toEqual([MODERN]); - } - await close(); - }); -}); - -describe('TS-01 directionality (era-keyed direction enforcement)', () => { - it('a strict-modern instance cannot emit server→client wire requests: typed local error, nothing reaches the transport', async () => { - const server = new Server({ name: 'strict', version: '1' }, { capabilities: {}, eraSupport: 'modern' }); - const { inbound, flush, close } = await wireServer(server); - - await expect( - server.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 1 }) - ).rejects.toThrow(/not supported by the negotiated protocol version/); - await flush(); - expect(inbound).toHaveLength(0); - await close(); - }); - - it('a dual-era instance serving the legacy leg still emits server→client requests (permitted per the message era)', async () => { - const server = new Server({ name: 'dual', version: '1' }, { capabilities: {}, eraSupport: 'dual-era' }); - const { request, inbound, flush, close } = await wireServer(server); - - // Legacy leg: the 2025 client initializes and declares sampling support. - const init = await request(initializeRequest(1)); - expect(isJSONRPCResultResponse(init)).toBe(true); - - // The server-initiated sampling request is legal on the legacy leg and reaches the wire. - const pending = server.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 1 }); - pending.catch(() => { - // The peer never answers; the request is torn down with the connection below. - }); - await flush(); - expect(inbound.some(message => (message as JSONRPCRequest).method === 'sampling/createMessage')).toBe(true); - await close(); - }); - - it('a handler serving a modern-classified request gets the typed error from the ctx sampling helper; nothing reaches the transport', async () => { - const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); - let captured: unknown; - server.setRequestHandler('tools/list', async (_request, ctx) => { - try { - await ctx.mcpReq.requestSampling({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 1 }); - } catch (error) { - captured = error; - } - return { tools: [] }; - }); - const { request, inbound, flush, close } = await wireServer(server); - - const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(response)).toBe(true); - await flush(); - - expect(captured).toBeInstanceOf(SdkError); - expect((captured as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); - expect((captured as SdkError).message).toMatch(/not available on protocol revision 2026-07-28/); - expect(inbound.some(message => (message as JSONRPCRequest).method === 'sampling/createMessage')).toBe(false); - await close(); - }); - - it('a raw ctx server→client request send while serving a modern-classified request is rejected the same way', async () => { - const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); - let captured: unknown; - server.setRequestHandler('tools/list', async (_request, ctx) => { - try { - await ctx.mcpReq.send({ method: 'roots/list' }); - } catch (error) { - captured = error; - } - return { tools: [] }; - }); - const { request, inbound, flush, close } = await wireServer(server); - - const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(response)).toBe(true); - await flush(); - - expect(captured).toBeInstanceOf(SdkError); - expect((captured as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); - expect(inbound.some(message => (message as JSONRPCRequest).method === 'roots/list')).toBe(false); - await close(); - }); - - it('the same ctx sampling helper on a legacy-classified request still reaches the wire (permitted per the message era)', async () => { - const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); - server.setRequestHandler('tools/list', (_request, ctx) => { - const pending = ctx.mcpReq.requestSampling({ - messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], - maxTokens: 1 - }); - pending.catch(() => { - // The peer never answers; the request is torn down with the connection below. - }); - return { tools: [] }; - }); - const { request, inbound, flush, close } = await wireServer(server); - - // Legacy leg: the 2025 client initializes and declares sampling support. - const init = await request(initializeRequest(1)); - expect(isJSONRPCResultResponse(init)).toBe(true); - - const response = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); - expect(isJSONRPCResultResponse(response)).toBe(true); - await flush(); - - expect(inbound.some(message => (message as JSONRPCRequest).method === 'sampling/createMessage')).toBe(true); - await close(); - }); -}); - -describe('accessor split on long-lived dual-era instances', () => { - it('getClientCapabilities/getClientVersion/getNegotiatedProtocolVersion keep initialize-scoped semantics; modern envelopes never backfill them', async () => { - const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); - server.setRequestHandler('tools/list', () => ({ tools: [] })); - const { request, close } = await wireServer(server); - - // Legacy handshake populates the initialize-scoped accessors. - const init = await request(initializeRequest(1)); - expect(isJSONRPCResultResponse(init)).toBe(true); - expect(server.getClientVersion()).toEqual({ name: 'legacy-client', version: '1.0.0' }); - expect(server.getClientCapabilities()).toEqual({ sampling: {} }); - expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); - - // A modern message carrying a different client identity in its envelope - // is served, but never backfills the instance-level accessors (per-message - // identity is read from the per-request context, not instance state). - const modern = await request({ - jsonrpc: '2.0', - id: 2, - method: 'tools/list', - params: { - _meta: envelope({ [CLIENT_INFO_META_KEY]: { name: 'modern-client', version: '9.9.9' } }) - } - }); - expect(isJSONRPCResultResponse(modern)).toBe(true); - expect(server.getClientVersion()).toEqual({ name: 'legacy-client', version: '1.0.0' }); - expect(server.getClientCapabilities()).toEqual({ sampling: {} }); - expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); - - await close(); - }); -}); diff --git a/packages/server/test/server/legacyDefaultServing.test.ts b/packages/server/test/server/legacyDefaultServing.test.ts new file mode 100644 index 0000000000..877349894c --- /dev/null +++ b/packages/server/test/server/legacyDefaultServing.test.ts @@ -0,0 +1,113 @@ +/** + * Q10-L2 golden pin: a hand-constructed `McpServer` connected to a long-lived + * transport (the shape of every existing stdio server) serves a scripted 2025 + * session with today's exact result shapes and zero 2026 vocabulary on the + * wire — and keeps answering `server/discover` with `-32601`, byte-identical + * to the deployed fleet. Hand-constructed instances serve only the 2025 era; + * serving the 2026-07-28 revision on stdio goes through the `serveStdio` + * entry (covered in `serveStdio.test.ts`). + */ +import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { InMemoryTransport, isJSONRPCErrorResponse, isJSONRPCResultResponse, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { McpServer } from '../../src/server/mcp.js'; + +function buildServer() { + const server = new McpServer( + { name: 'legacy-default-test-server', version: '1.0.0' }, + { capabilities: { tools: {} }, instructions: 'test instructions' } + ); + server.registerTool('echo', { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + return server; +} + +async function wire(server: McpServer) { + const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); + const inbound: JSONRPCMessage[] = []; + const waiters = new Map void>(); + peerTx.onmessage = message => { + inbound.push(message); + const id = (message as { id?: string | number }).id; + const waiter = id === undefined ? undefined : waiters.get(id); + if (id !== undefined && waiter) { + waiters.delete(id); + waiter(message); + } + }; + await server.connect(serverTx); + await peerTx.start(); + + const request = (message: JSONRPCRequest): Promise => + new Promise(resolve => { + waiters.set(message.id, resolve); + void peerTx.send(message); + }); + const notify = (message: JSONRPCNotification): Promise => peerTx.send(message); + return { request, notify, inbound, close: () => server.close() }; +} + +const initializeRequest = (id: number): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'legacy-client', version: '1.0.0' } } +}); + +describe('Q10-L2: a hand-constructed server on 2025 traffic', () => { + it('serves a scripted 2025 session with the exact 2025 shapes and zero 2026 vocabulary on the wire', async () => { + const server = buildServer(); + const { request, notify, inbound, close } = await wire(server); + + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect(init.result).toEqual({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'legacy-default-test-server', version: '1.0.0' }, + instructions: 'test instructions' + }); + } + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const list = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + if (isJSONRPCResultResponse(list)) { + const tools = (list.result as { tools: Array> }).tools; + expect(tools).toHaveLength(1); + expect(tools[0]).toMatchObject({ name: 'echo', description: 'Echoes the input text' }); + expect(Object.keys(list.result as Record).sort()).toEqual(['tools']); + } + + const call = await request({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'echo', arguments: { text: 'hi' } } }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect(call.result).toEqual({ content: [{ type: 'text', text: 'hi' }] }); + } + + const ping = await request({ jsonrpc: '2.0', id: 4, method: 'ping' }); + expect(isJSONRPCResultResponse(ping)).toBe(true); + if (isJSONRPCResultResponse(ping)) { + expect(ping.result).toEqual({}); + } + + // A default instance keeps answering server/discover with -32601, byte-identical to the deployed fleet. + const discover = await request({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: {} }); + expect(isJSONRPCErrorResponse(discover)).toBe(true); + if (isJSONRPCErrorResponse(discover)) { + expect(discover.error).toEqual({ code: -32_601, message: 'Method not found' }); + } + + // Nothing the server wrote on this 2025 session carries 2026 wire vocabulary. + const wireBytes = JSON.stringify(inbound); + expect(wireBytes).not.toContain('resultType'); + expect(wireBytes).not.toContain('2026'); + expect(wireBytes).not.toContain('io.modelcontextprotocol/'); + + await close(); + }); +}); diff --git a/packages/server/test/server/serveStdio.test.ts b/packages/server/test/server/serveStdio.test.ts new file mode 100644 index 0000000000..0ba6c3ecf5 --- /dev/null +++ b/packages/server/test/server/serveStdio.test.ts @@ -0,0 +1,778 @@ +/** + * `serveStdio` — the connection-pinned stdio entry: + * + * - the opening exchange selects the era exactly once; ONE factory instance + * is pinned for the connection lifetime and serves only that era; + * - a legacy opening (`initialize`, or any claim-less message) pins a 2025 + * instance that serves the session exactly as a hand-wired stdio server + * does today (zero 2026 vocabulary on the wire — the per-connection leak + * test); + * - a valid modern envelope opening pins a 2026-07-28 instance (era-written + * by the entry, modern-only handlers installed); + * - a `server/discover` probe is answered without pinning; the next message + * either pins the modern era or falls back to a fresh legacy instance + * (probe instance discarded) when the client returns to `initialize`; + * - once the modern era is pinned, a late claim-less `initialize` is answered + * with the unsupported-protocol-version error naming the supported + * revisions; + * - `legacy: 'reject'` answers legacy openings with the same error and never + * pins a legacy instance; + * - malformed and unsupported envelope claims are answered by the entry, + * consistent with the HTTP entry's treatment, without pinning. + */ +import type { + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + MessageExtraInfo, + Transport +} from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + SdkError, + SdkErrorCode +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import type { McpServerFactory } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; +import type { ServeStdioOptions } from '../../src/server/serveStdio.js'; +import { serveStdio } from '../../src/server/serveStdio.js'; + +const MODERN = '2026-07-28'; + +/** 2026-era vocabulary that must never leak onto a connection pinned to the 2025 era. */ +const FORBIDDEN_2026_VOCABULARY = ['2026', 'discover', 'envelope', 'modern', 'era', 'resultType', 'io.modelcontextprotocol']; + +const envelope = (overrides?: Record) => ({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'serve-stdio-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {}, + ...overrides +}); + +const initializeRequest = (id: number | string, requestedVersion = LATEST_PROTOCOL_VERSION): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { + protocolVersion: requestedVersion, + capabilities: {}, + clientInfo: { name: 'legacy-client', version: '1.0.0' } + } +}); + +/** A factory that records every construction (era + product) and registers one echo tool. */ +function trackingFactory() { + const eras: Array<'legacy' | 'modern'> = []; + const closed: boolean[] = []; + const factory = (ctx: { era: 'legacy' | 'modern' }) => { + const index = eras.length; + eras.push(ctx.era); + closed.push(false); + const server = new McpServer( + { name: 'serve-stdio-test-server', version: '1.0.0' }, + { capabilities: { tools: {} }, instructions: 'serve-stdio test instructions' } + ); + server.registerTool('echo', { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + server.server.onclose = () => { + closed[index] = true; + }; + return server; + }; + return { factory, eras, closed }; +} + +/** Boots the entry on one side of an in-memory pair with the given factory and returns raw drivers for the peer side. */ +async function startEntryWith(factory: McpServerFactory, options?: Omit) { + const [peerTx, wireTx] = InMemoryTransport.createLinkedPair(); + + const inbound: JSONRPCMessage[] = []; + const waiters = new Map void>(); + peerTx.onmessage = message => { + inbound.push(message); + const id = (message as { id?: string | number }).id; + const waiter = id === undefined ? undefined : waiters.get(id); + if (id !== undefined && waiter) { + waiters.delete(id); + waiter(message); + } + }; + await peerTx.start(); + + const errors: Error[] = []; + const handle = serveStdio(factory, { transport: wireTx, onerror: error => void errors.push(error), ...options }); + + const request = (message: JSONRPCRequest): Promise => + new Promise(resolve => { + waiters.set(message.id, resolve); + void peerTx.send(message); + }); + const notify = (message: JSONRPCNotification): Promise => peerTx.send(message); + const flush = () => new Promise(resolve => setTimeout(resolve, 20)); + + return { handle, request, notify, flush, inbound, errors, peerTx }; +} + +/** Boots the entry with a fresh tracking factory (the default harness for most tests). */ +async function startEntry(options?: Omit) { + const { factory, eras, closed } = trackingFactory(); + return { ...(await startEntryWith(factory, options)), eras, closed }; +} + +describe('legacy opening (default legacy: serve)', () => { + it('pins one 2025-era instance for the connection and serves it exactly like a hand-wired stdio server', async () => { + const { handle, request, notify, inbound, eras } = await startEntry(); + + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect(init.result).toEqual({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'serve-stdio-test-server', version: '1.0.0' }, + instructions: 'serve-stdio test instructions' + }); + } + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const list = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + if (isJSONRPCResultResponse(list)) { + expect((list.result as { tools: Array<{ name: string }> }).tools.map(tool => tool.name)).toEqual(['echo']); + expect(Object.keys(list.result as Record).sort()).toEqual(['tools']); + } + + const call = await request({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'echo', arguments: { text: 'hi' } } }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect(call.result).toEqual({ content: [{ type: 'text', text: 'hi' }] }); + } + + // The era decision happened exactly once: one legacy instance, no probe instance. + expect(eras).toEqual(['legacy']); + + // Per-connection leak test: a claim-less server/discover on this + // 2025-pinned connection answers the same plain -32601 a deployed 2025 + // server answers, with zero 2026 vocabulary anywhere in the response. + const gate = await request({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: {} }); + expect(isJSONRPCErrorResponse(gate)).toBe(true); + if (isJSONRPCErrorResponse(gate)) { + expect(gate.error).toEqual({ code: -32_601, message: 'Method not found' }); + } + + // Nothing the entry or the instance wrote on this connection carries 2026 wire vocabulary. + const wireBytes = JSON.stringify(inbound).toLowerCase(); + for (const term of FORBIDDEN_2026_VOCABULARY) { + expect(wireBytes).not.toContain(term.toLowerCase()); + } + + await handle.close(); + }); + + it('a claim-less non-initialize opening also pins the legacy era', async () => { + const { handle, request, eras } = await startEntry(); + + const list = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + expect(eras).toEqual(['legacy']); + + await handle.close(); + }); +}); + +describe('modern opening', () => { + it('a valid enveloped request pins one era-written 2026-07-28 instance', async () => { + const { handle, request, eras } = await startEntry(); + + const list = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(list)).toBe(true); + if (isJSONRPCResultResponse(list)) { + const result = list.result as { tools: Array<{ name: string }>; resultType?: string }; + expect(result.tools.map(tool => tool.name)).toEqual(['echo']); + expect(result.resultType).toBe('complete'); + } + expect(eras).toEqual(['modern']); + + const call = await request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect((call.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'modern leg' }]); + } + + await handle.close(); + }); + + it('an enveloped initialize is classified by its valid modern claim and answered with a plain -32601', async () => { + const { handle, request, eras } = await startEntry(); + + const response = await request({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_601); + expect(response.error.message).toBe('Method not found'); + expect(response.error.data).toBeUndefined(); + } + expect(eras).toEqual(['modern']); + + await handle.close(); + }); + + it('once the modern era is pinned, a late claim-less initialize answers -32004 naming the supported revisions', async () => { + const { handle, request } = await startEntry(); + + const list = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(list)).toBe(true); + + const init = await request(initializeRequest(2)); + expect(isJSONRPCErrorResponse(init)).toBe(true); + if (isJSONRPCErrorResponse(init)) { + expect(init.error.code).toBe(-32_004); + const data = init.error.data as { supported?: string[]; requested?: string }; + expect(data.supported).toContain(MODERN); + expect(data.requested).toBe(LATEST_PROTOCOL_VERSION); + } + + await handle.close(); + }); +}); + +describe('server/discover probe window', () => { + it('answers the probe from an optimistically built modern instance and pins modern when the client continues with the envelope', async () => { + const { handle, request, eras } = await startEntry(); + + const discover = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(discover)).toBe(true); + if (isJSONRPCResultResponse(discover)) { + const result = discover.result as { supportedVersions?: string[]; resultType?: string }; + expect(result.supportedVersions).toEqual([MODERN]); + expect(result.resultType).toBe('complete'); + } + + const call = await request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'after probe' }, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect((call.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'after probe' }]); + } + + // The probe instance IS the pinned instance: the factory ran once. + expect(eras).toEqual(['modern']); + + await handle.close(); + }); + + it('discover followed by initialize falls back to a fresh legacy instance and discards the probe instance', async () => { + const { handle, request, eras, closed } = await startEntry(); + + const discover = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(discover)).toBe(true); + + // The client found no mutually supported modern revision and falls + // back to the 2025 handshake on the same connection. + const init = await request(initializeRequest(2)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + + // The optimistic modern instance was discarded; the legacy session is + // served end to end by the second (legacy) instance. + expect(eras).toEqual(['modern', 'legacy']); + expect(closed[0]).toBe(true); + expect(closed[1]).toBe(false); + + const list = await request({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + if (isJSONRPCResultResponse(list)) { + expect(JSON.stringify(list)).not.toContain('resultType'); + } + + await handle.close(); + }); + + it('answers the probe even when the fallback initialize is pipelined immediately behind it', async () => { + const { handle, request, flush, inbound, errors, eras } = await startEntry(); + + // The client does not wait for the DiscoverResult before falling back: + // both messages are on the wire back to back. The probe must still be + // answered (never silently dropped) and the legacy session served. + const discoverPromise = request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + const initPromise = request(initializeRequest(2)); + + const [discover, init] = await Promise.all([discoverPromise, initPromise]); + expect(isJSONRPCResultResponse(discover)).toBe(true); + if (isJSONRPCResultResponse(discover)) { + expect((discover.result as { supportedVersions?: string[] }).supportedVersions).toEqual([MODERN]); + } + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + // The probe answer reached the wire before the fallback's handshake answer. + expect(inbound.indexOf(discover)).toBeLessThan(inbound.indexOf(init)); + expect(eras).toEqual(['modern', 'legacy']); + + // The legacy session continues normally and nothing was dropped or reported. + const list = await request({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + await flush(); + expect(errors).toEqual([]); + + await handle.close(); + }); + + it('a repeated server/discover probe is answered by the same probe instance and a later initialize still falls back to legacy', async () => { + const { handle, request, eras, closed } = await startEntry(); + + const first = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(first)).toBe(true); + + const second = await request({ jsonrpc: '2.0', id: 'probe-2', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(second)).toBe(true); + if (isJSONRPCResultResponse(second)) { + expect((second.result as { supportedVersions?: string[] }).supportedVersions).toEqual([MODERN]); + } + + // Both probes were answered by the single optimistic instance; the + // connection is still inside the negotiation window. + expect(eras).toEqual(['modern']); + + // The fallback handshake is still served by a fresh legacy instance. + const init = await request(initializeRequest(3)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + expect(eras).toEqual(['modern', 'legacy']); + expect(closed[0]).toBe(true); + expect(closed[1]).toBe(false); + + await handle.close(); + }); + + it('an enveloped notification during the probe window does not pin the era and a later initialize still falls back to legacy', async () => { + const { handle, request, notify, flush, eras, closed, errors } = await startEntry(); + + const discover = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(discover)).toBe(true); + + // The client cancels its probe (for example on a local timeout) with + // an enveloped notification before falling back to the 2025 + // handshake. The notification is delivered to the probe instance but + // does not commit the connection to the modern era. + await notify({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 'probe-1', reason: 'probe timed out', _meta: envelope() } + }); + await flush(); + + const init = await request(initializeRequest(2)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + + // The fallback handshake was served by a fresh legacy instance and + // the probe instance was discarded; nothing was reported as dropped. + expect(eras).toEqual(['modern', 'legacy']); + expect(closed[0]).toBe(true); + expect(closed[1]).toBe(false); + expect(errors).toEqual([]); + + await handle.close(); + }); + + it('an enveloped non-discover request after the probe still pins the modern era', async () => { + const { handle, request, eras } = await startEntry(); + + const discover = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(discover)).toBe(true); + + const call = await request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'commit' }, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(call)).toBe(true); + + // The enveloped request committed the connection: a later claim-less + // initialize is rejected instead of falling back to a legacy instance. + const init = await request(initializeRequest(3)); + expect(isJSONRPCErrorResponse(init)).toBe(true); + if (isJSONRPCErrorResponse(init)) { + expect(init.error.code).toBe(-32_004); + } + // The probe instance is the pinned instance: the factory ran exactly once. + expect(eras).toEqual(['modern']); + + await handle.close(); + }); + + it('a repeated server/discover probe followed by an enveloped request pins the modern era', async () => { + const { handle, request, eras } = await startEntry(); + + const first = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(first)).toBe(true); + + const second = await request({ jsonrpc: '2.0', id: 'probe-2', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(second)).toBe(true); + + const call = await request({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'after repeated probe' }, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect((call.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'after repeated probe' }]); + } + + // The probe instance is the pinned instance: the factory ran exactly once. + expect(eras).toEqual(['modern']); + + await handle.close(); + }); +}); + +describe("legacy: 'reject'", () => { + it('answers a legacy opening with -32004 naming the supported modern revisions and never pins a legacy instance', async () => { + const { handle, request, eras } = await startEntry({ legacy: 'reject' }); + + const init = await request(initializeRequest(1)); + expect(isJSONRPCErrorResponse(init)).toBe(true); + if (isJSONRPCErrorResponse(init)) { + expect(init.error.code).toBe(-32_004); + const data = init.error.data as { supported?: string[]; requested?: string }; + expect(data.supported).toContain(MODERN); + expect(data.requested).toBe(LATEST_PROTOCOL_VERSION); + } + expect(eras).toEqual([]); + + // A modern opening on the same connection is still served afterwards. + const list = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(list)).toBe(true); + expect(eras).toEqual(['modern']); + + await handle.close(); + }); + + it('drops a claim-less notification without a response', async () => { + const { handle, notify, flush, inbound, eras } = await startEntry({ legacy: 'reject' }); + + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + await flush(); + + expect(inbound).toHaveLength(0); + expect(eras).toEqual([]); + + await handle.close(); + }); +}); + +describe('malformed and unsupported envelope claims (entry-answered, never pinned)', () => { + it('a present claim with a malformed envelope answers -32602 naming the envelope problem', async () => { + const { handle, request, eras } = await startEntry(); + + const response = await request({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN } } + }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_602); + expect(response.error.message).toContain('Invalid _meta envelope'); + } + expect(eras).toEqual([]); + + // The connection is not pinned by the rejected opening: a valid + // modern opening afterwards is served normally. + const list = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(list)).toBe(true); + expect(eras).toEqual(['modern']); + + await handle.close(); + }); + + it('a valid claim naming an unsupported revision answers -32004 with the supported list', async () => { + const { handle, request, eras } = await startEntry(); + + const response = await request({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: envelope({ [PROTOCOL_VERSION_META_KEY]: '2099-01-01' }) } + }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_004); + const data = (response as JSONRPCErrorResponse).error.data as { supported?: string[]; requested?: string }; + expect(data.supported).toContain(MODERN); + expect(data.requested).toBe('2099-01-01'); + } + expect(eras).toEqual([]); + + await handle.close(); + }); +}); + +describe('factory or connect failure during the opening exchange (entry-answered, never pinned)', () => { + it('answers a legacy opening with -32603 when the factory throws, reports the error, and leaves the connection unpinned', async () => { + const { factory: workingFactory, eras } = trackingFactory(); + let failures = 1; + const factory: McpServerFactory = ctx => { + if (failures > 0) { + failures -= 1; + throw new Error('factory failed to build an instance'); + } + return workingFactory(ctx); + }; + const { handle, request, flush, errors } = await startEntryWith(factory); + + const init = await request(initializeRequest(1)); + expect(isJSONRPCErrorResponse(init)).toBe(true); + if (isJSONRPCErrorResponse(init)) { + expect(init.error.code).toBe(-32_603); + expect(init.error.message).toBe('Internal server error'); + } + await flush(); + expect(errors.some(error => error.message.includes('factory failed to build an instance'))).toBe(true); + expect(eras).toEqual([]); + + // The failed opening did not pin the connection: a retried handshake + // on the same connection is served by a fresh legacy instance. + const retry = await request(initializeRequest(2)); + expect(isJSONRPCResultResponse(retry)).toBe(true); + expect(eras).toEqual(['legacy']); + + await handle.close(); + }); + + it('answers a modern opening with -32603 when connecting the instance fails and leaves the connection unpinned', async () => { + const { factory: workingFactory, eras } = trackingFactory(); + let failures = 1; + const factory: McpServerFactory = ctx => { + const product = workingFactory(ctx); + if (failures > 0) { + failures -= 1; + product.connect = () => Promise.reject(new Error('instance connect failed')); + } + return product; + }; + const { handle, request, flush, errors } = await startEntryWith(factory); + + const list = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(list)).toBe(true); + if (isJSONRPCErrorResponse(list)) { + expect(list.error.code).toBe(-32_603); + expect(list.error.message).toBe('Internal server error'); + } + await flush(); + expect(errors.some(error => error.message.includes('instance connect failed'))).toBe(true); + // The factory ran but nothing was pinned: the next modern opening is + // served by a freshly connected instance. + expect(eras).toEqual(['modern']); + + const retry = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(retry)).toBe(true); + expect(eras).toEqual(['modern', 'modern']); + + await handle.close(); + }); + + it('answers a server/discover probe with -32603 when the factory rejects and keeps the negotiation window open', async () => { + const { factory: workingFactory, eras } = trackingFactory(); + let failures = 1; + const factory: McpServerFactory = ctx => { + if (failures > 0) { + failures -= 1; + return Promise.reject(new Error('factory failed to build an instance')); + } + return workingFactory(ctx); + }; + const { handle, request, flush, errors } = await startEntryWith(factory); + + const discover = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(discover)).toBe(true); + if (isJSONRPCErrorResponse(discover)) { + expect(discover.error.code).toBe(-32_603); + expect(discover.error.message).toBe('Internal server error'); + } + await flush(); + expect(errors.some(error => error.message.includes('factory failed to build an instance'))).toBe(true); + expect(eras).toEqual([]); + + // The failed probe did not pin anything: the connection is still in + // the negotiation window and a fallback handshake is served normally. + const init = await request(initializeRequest(2)); + expect(isJSONRPCResultResponse(init)).toBe(true); + expect(eras).toEqual(['legacy']); + + await handle.close(); + }); +}); + +describe('a close racing the opening factory', () => { + /** + * A factory that suspends until released and exposes what happens to its + * product afterwards: whether it was closed, and every message that is + * delivered to it after it has been connected. + */ + function gatedObservableFactory() { + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + let entered!: () => void; + const constructionStarted = new Promise(resolve => { + entered = resolve; + }); + const eras: Array<'legacy' | 'modern'> = []; + const productClosed: boolean[] = []; + const delivered: JSONRPCMessage[] = []; + const factory: McpServerFactory = async ctx => { + const index = eras.length; + eras.push(ctx.era); + productClosed.push(false); + entered(); + await gate; + const server = new McpServer({ name: 'serve-stdio-test-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.server.onclose = () => { + productClosed[index] = true; + }; + const realConnect = server.connect.bind(server); + server.connect = async (transport: Transport) => { + await realConnect(transport); + const forward = transport.onmessage; + transport.onmessage = (message: JSONRPCMessage, extra?: MessageExtraInfo) => { + delivered.push(message); + forward?.(message, extra); + }; + }; + return server; + }; + return { factory, constructionStarted, release, eras, productClosed, delivered }; + } + + it('handle.close() during the legacy factory build stays closed: the late instance is closed and never delivered to', async () => { + const { factory, constructionStarted, release, eras, productClosed, delivered } = gatedObservableFactory(); + const { handle, flush, inbound, peerTx } = await startEntryWith(factory); + + // The opening handshake arrives and the entry starts building the + // legacy instance; the connection is closed while the factory is + // still mid-construction. + void peerTx.send(initializeRequest(1)); + await constructionStarted; + await handle.close(); + + // The factory resolves only after the connection is gone. + release(); + await flush(); + + // The connection stays closed: the late-resolved instance is closed, + // the opening message is never delivered to it, nothing further + // reaches the wire, and no other instance is built. + expect(eras).toEqual(['legacy']); + expect(productClosed).toEqual([true]); + expect(delivered).toEqual([]); + expect(inbound).toEqual([]); + }); + + it('handle.close() during the probe-instance build does not resurrect the negotiation window', async () => { + const { factory, constructionStarted, release, eras, productClosed, delivered } = gatedObservableFactory(); + const { handle, flush, inbound, peerTx } = await startEntryWith(factory); + + void peerTx.send({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + await constructionStarted; + await handle.close(); + + release(); + await flush(); + + expect(eras).toEqual(['modern']); + expect(productClosed).toEqual([true]); + expect(delivered).toEqual([]); + expect(inbound).toEqual([]); + }); +}); + +describe('outbound era gate on a modern-pinned connection', () => { + it('a handler calling ctx.mcpReq.requestSampling gets the typed era error locally, with zero sampling wire traffic', async () => { + let observed: unknown; + const factory: McpServerFactory = () => { + const server = new McpServer({ name: 'serve-stdio-test-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('sample', { description: 'Tries to request sampling', inputSchema: z.object({}) }, async (_args, ctx) => { + try { + await ctx.mcpReq.requestSampling({ messages: [], maxTokens: 1 }); + } catch (error) { + observed = error; + } + return { content: [{ type: 'text', text: 'handled locally' }] }; + }); + return server; + }; + const { handle, request, inbound } = await startEntryWith(factory); + + const call = await request({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'sample', arguments: {}, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect((call.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'handled locally' }]); + } + + // The outbound era gate fired locally with the typed error… + expect(observed).toBeInstanceOf(SdkError); + expect((observed as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + // …and nothing beyond the tool-call answer ever reached the wire: no + // sampling/createMessage request was written to the client. + expect(inbound).toEqual([call]); + + await handle.close(); + }); +}); + +describe('teardown', () => { + it('handle.close() closes the pinned instance and the wire transport', async () => { + const { handle, request, closed, peerTx } = await startEntry(); + + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + + let peerClosed = false; + peerTx.onclose = () => { + peerClosed = true; + }; + + await handle.close(); + expect(closed[0]).toBe(true); + expect(peerClosed).toBe(true); + }); +}); diff --git a/test/e2e/fixtures/dual-era-stdio-server.ts b/test/e2e/fixtures/dual-era-stdio-server.ts index b99b9763a9..31cf9b7e22 100644 --- a/test/e2e/fixtures/dual-era-stdio-server.ts +++ b/test/e2e/fixtures/dual-era-stdio-server.ts @@ -1,29 +1,28 @@ /** * Runnable dual-era stdio MCP server fixture for the dual-era stdio e2e cells. * - * `eraSupport: 'dual-era'` is the single declared act on an otherwise ordinary - * hand-constructed McpServer connected to the unchanged StdioServerTransport. + * The connection-pinned `serveStdio` entry over an ordinary `McpServer` + * factory: the client's opening exchange selects the era for the connection + * (a 2025 `initialize` handshake or 2026-07-28 per-request envelope traffic + * negotiated via `server/discover`), and one factory instance serves it. * Spawned as a real child process (via tsx) by * test/e2e/scenarios/stdio-dual-era.test.ts; exits when its stdin reaches EOF. */ import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; import { z } from 'zod/v4'; -const server = new McpServer( - { name: 'dual-era-stdio-e2e-fixture', version: '1.0.0' }, - { capabilities: { tools: {} }, eraSupport: 'dual-era' } -); - -server.registerTool( - 'echo', - { - description: 'Echoes the input text back as a text content block.', - inputSchema: z.object({ text: z.string() }) - }, - ({ text }) => ({ content: [{ type: 'text', text }] }) -); - -await server.connect(new StdioServerTransport()); +serveStdio(() => { + const server = new McpServer({ name: 'dual-era-stdio-e2e-fixture', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool( + 'echo', + { + description: 'Echoes the input text back as a text content block.', + inputSchema: z.object({ text: z.string() }) + }, + ({ text }) => ({ content: [{ type: 'text', text }] }) + ); + return server; +}); process.stderr.write('[dual-era-stdio-server] ready\n'); diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 71b2462633..b526e12749 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2267,7 +2267,7 @@ export const REQUIREMENTS: Record = { note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. The allowed-host control asserts initialize semantics per spec version: a 2026-era request is answered with the latest legacy version, since 2026-era revisions are never negotiated via initialize.' }, - // v2 features: dual-era serving (createMcpHandler entry, eraSupport stdio, result stamping) + // v2 features: dual-era serving (createMcpHandler entry, serveStdio stdio entry, result stamping) 'typescript:hosting:entry:dual-era-one-factory': { source: 'sdk', @@ -2342,9 +2342,9 @@ export const REQUIREMENTS: Record = { 'typescript:transport:stdio:dual-era-serving': { source: 'sdk', behavior: - 'A hand-constructed stdio server declaring eraSupport "dual-era" (transport line unchanged) serves a plain 2025 client via initialize and an auto-negotiating client on 2026-07-28 via server/discover, over a real child-process pipe.', + 'A stdio server hosted by the connection-pinned serveStdio entry serves a plain 2025 client via initialize and an auto-negotiating client on 2026-07-28 via server/discover, each on its own connection against the same factory, over a real child-process pipe.', transports: ['stdio'], - note: 'Dual-era stdio serving is exercised against a real spawned child process (fixtures/dual-era-stdio-server.ts), so the matrix transport arg is ignored and the requirement lists stdio only; the spec-version axis selects which client drives the cell.' + note: 'Dual-era stdio serving is exercised against a real spawned child process (fixtures/dual-era-stdio-server.ts), so the matrix transport arg is ignored and the requirement lists stdio only; the spec-version axis selects which client opens the connection.' }, 'custom-methods:server-handler:roundtrip': { source: 'sdk', diff --git a/test/e2e/scenarios/stdio-dual-era.test.ts b/test/e2e/scenarios/stdio-dual-era.test.ts index 46a2406ea1..319e85cf4c 100644 --- a/test/e2e/scenarios/stdio-dual-era.test.ts +++ b/test/e2e/scenarios/stdio-dual-era.test.ts @@ -3,12 +3,14 @@ * * Like the other transport:stdio scenarios these do not use `wire()`: each * body spawns the dual-era fixture server in - * `fixtures/dual-era-stdio-server.ts` (eraSupport: 'dual-era', unchanged - * StdioServerTransport) as a real child process via {@link StdioClientTransport}. - * The matrix `transport` arg is ignored (the requirement lists - * `transports: ['stdio']`); the spec-version axis selects which client drives - * the cell — a plain 2025 client over `initialize`, or the auto-negotiating - * client reaching 2026-07-28 over `server/discover` on the same kind of pipe. + * `fixtures/dual-era-stdio-server.ts` (the connection-pinned `serveStdio` + * entry over an ordinary McpServer factory) as a real child process via + * {@link StdioClientTransport}. The matrix `transport` arg is ignored (the + * requirement lists `transports: ['stdio']`); the spec-version axis selects + * which client opens the connection — a plain 2025 client over `initialize`, + * or the auto-negotiating client reaching 2026-07-28 over `server/discover` — + * and the entry pins that connection's instance to the era the client opened + * with. */ import { fileURLToPath } from 'node:url'; @@ -38,8 +40,9 @@ verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion }); if (protocolVersion === '2025-11-25') { - // Legacy leg: a plain 2025 client is served via initialize, exactly as - // against an undeclared server. + // Legacy leg: a plain 2025 client opens with initialize and the entry + // pins the connection to a 2025-era instance, served exactly as a + // hand-wired stdio server serves it today. const client = new Client({ name: 'plain-2025-client', version: '0' }); try { await client.connect(transport); @@ -55,8 +58,9 @@ verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion } // Modern leg: the auto-negotiating client reaches 2026-07-28 via - // server/discover on the pipe (no initialize is ever written) and - // tools/call round-trips with the per-request envelope. + // server/discover on the pipe (no initialize is ever written), the entry + // pins the connection to a 2026-era instance, and tools/call round-trips + // with the per-request envelope. const sentMethods: string[] = []; const originalSend = transport.send.bind(transport); transport.send = async message => { diff --git a/test/integration/test/__fixtures__/dualEraStdioServer.ts b/test/integration/test/__fixtures__/dualEraStdioServer.ts index 46499f6156..0624dacc7c 100644 --- a/test/integration/test/__fixtures__/dualEraStdioServer.ts +++ b/test/integration/test/__fixtures__/dualEraStdioServer.ts @@ -1,26 +1,30 @@ /** - * A dual-era stdio server fixture: `eraSupport: 'dual-era'` on an otherwise - * ordinary hand-constructed McpServer connected to the unchanged - * StdioServerTransport. Spawned as a real child process by - * `test/server/dualEraStdio.test.ts`. + * A dual-era stdio server fixture: the connection-pinned `serveStdio` entry + * over an ordinary `McpServer` factory. Spawned as a real child process by + * `test/server/dualEraStdio.test.ts`; each spawned process serves exactly one + * connection, pinned to the era its client opens with. */ import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; -const server = new McpServer( - { name: 'dual-era-stdio-fixture', version: '1.0.0' }, - { capabilities: { tools: {} }, instructions: 'dual-era stdio fixture', eraSupport: 'dual-era' } -); - -server.registerTool('echo', { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ - content: [{ type: 'text', text }] -})); - -await server.connect(new StdioServerTransport()); +const handle = serveStdio(() => { + const server = new McpServer( + { name: 'dual-era-stdio-fixture', version: '1.0.0' }, + { capabilities: { tools: {} }, instructions: 'dual-era stdio fixture' } + ); + server.registerTool( + 'echo', + { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, + async ({ text }) => ({ + content: [{ type: 'text', text }] + }) + ); + return server; +}); const exit = async () => { - await server.close(); + await handle.close(); // eslint-disable-next-line unicorn/no-process-exit process.exit(0); }; diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index 5b833de3eb..8de980f16a 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -6,6 +6,7 @@ import { ProtocolErrorCode, SdkError, SdkErrorCode, + setNegotiatedProtocolVersion, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import { McpServer, Server } from '@modelcontextprotocol/server'; @@ -187,15 +188,12 @@ test('should run a fresh initialize handshake after close() when the previous co const supportedProtocolVersions = [MODERN_REVISION, ...SUPPORTED_PROTOCOL_VERSIONS]; const connectModern = async (client: Client) => { - // Serving a 2026-era revision on a hand-constructed instance is a declared act - // (eraSupport); a dual-era instance answers the client's server/discover probe - // per message with no instance binding. - const server = new Server( - { name: 'modern server', version: '1.0' }, - { capabilities: {}, supportedProtocolVersions, eraSupport: 'dual-era' } - ); + const server = new Server({ name: 'modern server', version: '1.0' }, { capabilities: {}, supportedProtocolVersions }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); + // Stand-in for the modern-era server entry (instance binding): mark the server instance + // as serving the modern era so it can answer the client's server/discover probe. + setNegotiatedProtocolVersion(server, MODERN_REVISION); await client.connect(clientTransport); }; diff --git a/test/integration/test/client/discoverRoundtrip.test.ts b/test/integration/test/client/discoverRoundtrip.test.ts index 14b88a7cf2..cdf551d60e 100644 --- a/test/integration/test/client/discoverRoundtrip.test.ts +++ b/test/integration/test/client/discoverRoundtrip.test.ts @@ -4,17 +4,17 @@ * era-aware counter-offer end to end (a legacy client against a server whose * supported list carries a 2026 revision never sees a 2026 version string). * - * Serving a 2026-era revision on a hand-constructed instance is a declared - * act: the servers under test pass `eraSupport: 'dual-era'` (a modern - * revision in the supported list without that declaration is a - * construction-time TypeError), and a dual-era instance answers the - * `server/discover` probe per message with no instance binding. + * Era is instance state on the server: an inbound `server/discover` is served + * only by a modern-era instance (the method is physically absent from the + * legacy registry). Production binding of modern-era instances belongs to the + * server-side entry that classifies inbound traffic; until it lands these + * tests bind the instance through the package-internal hook it will use. */ import type { Server as HttpServer } from 'node:http'; import { createServer } from 'node:http'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { SdkError, SdkErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; +import { SdkError, SdkErrorCode, setNegotiatedProtocolVersion, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { McpServer } from '@modelcontextprotocol/server'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; @@ -39,14 +39,14 @@ describe('server/discover round-trip against a modern server', () => { while (cleanups.length > 0) await cleanups.pop()!(); }); - async function startServer(options: { kind: 'dual-era' | 'legacy-only' }) { + async function startServer(options: { modernEraInstance: boolean }) { const httpServer: HttpServer = createServer(); const mcpServer = new McpServer( { name: 'dual-era-server', version: '2.0.0' }, { capabilities: { tools: { listChanged: true } }, - instructions: 'dual era', - ...(options.kind === 'dual-era' ? { supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' as const } : {}) + supportedProtocolVersions: DUAL_ERA_VERSIONS, + instructions: 'dual era' } ); mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ @@ -54,6 +54,11 @@ describe('server/discover round-trip against a modern server', () => { })); const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await mcpServer.connect(serverTransport); + if (options.modernEraInstance) { + // Stand-in for the server-side entry (instance binding): mark the + // instance as serving the modern era so it can answer the probe. + setNegotiatedProtocolVersion(mcpServer.server, MODERN); + } httpServer.on('request', (req, res) => void serverTransport.handleRequest(req, res)); const baseUrl = await listenOnRandomPort(httpServer); cleanups.push(async () => { @@ -65,7 +70,7 @@ describe('server/discover round-trip against a modern server', () => { } it('pin-mode 2026 client: server/discover → version selection, no initialize ever sent', async () => { - const baseUrl = await startServer({ kind: 'dual-era' }); + const baseUrl = await startServer({ modernEraInstance: true }); const { bodies, fetchFn } = recordingFetch(); const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); @@ -83,7 +88,7 @@ describe('server/discover round-trip against a modern server', () => { }); it('auto-mode client selects the modern era on the same server', async () => { - const baseUrl = await startServer({ kind: 'dual-era' }); + const baseUrl = await startServer({ modernEraInstance: true }); const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(new StreamableHTTPClientTransport(baseUrl)); cleanups.push(() => client.close()); @@ -91,11 +96,12 @@ describe('server/discover round-trip against a modern server', () => { expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); }); - it('auto-mode against a server that has not opted into modern-era support falls back to the legacy handshake', async () => { - // A hand-constructed server with the default eraSupport never serves - // server/discover: the probe is answered -32601 and the client falls - // back cleanly on the same connection. - const baseUrl = await startServer({ kind: 'legacy-only' }); + it('auto-mode against the same server NOT bound to the modern era falls back to the legacy handshake', async () => { + // A server instance serves the legacy era until it is bound to the + // modern one (binding is owned by the server-side entry); the probe is + // answered -32601 and the client falls back cleanly on the same + // connection. + const baseUrl = await startServer({ modernEraInstance: false }); const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(new StreamableHTTPClientTransport(baseUrl)); cleanups.push(() => client.close()); @@ -105,8 +111,8 @@ describe('server/discover round-trip against a modern server', () => { expect(result.content).toEqual([{ type: 'text', text: 'fallback' }]); }); - it('a plain legacy client against a dual-era server never meets a 2026 version string (counter-offer ordering, e2e)', async () => { - const baseUrl = await startServer({ kind: 'dual-era' }); + it('a plain legacy client against a server with a dual-era list never meets a 2026 version string (counter-offer ordering, e2e)', async () => { + const baseUrl = await startServer({ modernEraInstance: false }); const { fetchFn } = recordingFetch(); const responses: string[] = []; diff --git a/test/integration/test/server/dualEraStdio.test.ts b/test/integration/test/server/dualEraStdio.test.ts index bc0b63088f..10a32d20f2 100644 --- a/test/integration/test/server/dualEraStdio.test.ts +++ b/test/integration/test/server/dualEraStdio.test.ts @@ -1,16 +1,18 @@ /** - * Real-pipe dual-era stdio coverage: the fixture server - * (`__fixtures__/dualEraStdioServer.ts`, `eraSupport: 'dual-era'`, unchanged - * `StdioServerTransport`) is spawned as a real child process and driven over - * its stdio pipe by + * Real-pipe dual-era stdio coverage for the connection-pinned `serveStdio` + * entry: the fixture server (`__fixtures__/dualEraStdioServer.ts`, one + * `McpServer` factory behind `serveStdio`) is spawned as a real child process + * — once per connection — and driven over its stdio pipe by * - * - a plain 2025 client (the `initialize` vertical, served exactly as today), + * - a plain 2025 client (the `initialize` vertical, served exactly as today, + * with the era gate staying vocabulary-clean on that connection), * - the negotiating client in auto mode (the 2026-07-28 vertical: * `server/discover` on the pipe, then list → call with the per-request - * envelope), and - * - the long-lived era-gate negative on one connection: a legacy-classified - * `server/discover` answers a plain −32601 with zero 2026 vocabulary, while - * the same connection keeps serving both eras. + * envelope; a late claim-less `initialize` on the pinned connection answers + * the version error naming the supported revisions), and + * - a raw probe-then-fallback exchange (`server/discover` answered, then the + * client falls back to `initialize` on the same pipe and is served a normal + * 2025 session by a fresh legacy instance). * * Stdio behavior has no conformance harness (upstream conformance issue #258); * this SDK e2e suite is its referee. @@ -33,6 +35,12 @@ const MODERN = '2026-07-28'; const FORBIDDEN_2026_VOCABULARY = ['2026', 'discover', 'envelope', 'modern', 'era', '_meta', 'io.modelcontextprotocol', 'resultType']; +const modernEnvelope = (clientName: string) => ({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: clientName, version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}); + function spawnFixtureTransport(): StdioClientTransport { return new StdioClientTransport({ command: process.execPath, @@ -78,10 +86,10 @@ async function rawRequest(transport: StdioClientTransport, inbound: JSONRPCMessa ); } -describe('dual-era stdio server over a real child-process pipe', () => { +describe('serveStdio over a real child-process pipe (one connection per spawned process)', () => { vi.setConfig({ testTimeout: 30_000 }); - it('legacy vertical: a plain 2025 client is served via initialize, and the era gate stays vocabulary-clean on the same connection', async () => { + it('legacy-opening connection: a plain 2025 client is served via initialize, and the connection stays vocabulary-clean', async () => { const transport = spawnFixtureTransport(); const client = new Client({ name: 'legacy-pipe-client', version: '1.0.0' }); // Raw writes below produce responses the protocol layer does not track. @@ -99,7 +107,7 @@ describe('dual-era stdio server over a real child-process pipe', () => { expect(result.content).toEqual([{ type: 'text', text: 'over the real pipe' }]); expect(JSON.stringify(inbound)).not.toContain('resultType'); - // Era-gate negative on the SAME connection: a legacy-classified + // Era-gate negative on this 2025-pinned connection: a claim-less // server/discover answers a plain −32601 with zero 2026 vocabulary. const gate = await rawRequest(transport, inbound, { jsonrpc: '2.0', @@ -120,7 +128,7 @@ describe('dual-era stdio server over a real child-process pipe', () => { } }); - it('modern vertical: the auto-negotiating client reaches 2026-07-28 via server/discover on the pipe and both eras serve on one connection', async () => { + it('modern-opening connection: the auto-negotiating client reaches 2026-07-28 via server/discover, the connection pins modern, and a late initialize is rejected with the supported list', async () => { const transport = spawnFixtureTransport(); const outbound = recordOutbound(transport); const client = new Client({ name: 'modern-pipe-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); @@ -138,16 +146,7 @@ describe('dual-era stdio server over a real child-process pipe', () => { // Modern vertical: list → call, every request carrying the per-request envelope. // (Attaching it explicitly is the documented stop-gap until automatic // per-request envelope emission lands client-side.) - const envelope = { - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name: 'modern-pipe-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} - }; - // The list leg is asserted at the wire level: the 2026 wire schema - // for cacheable list results requires the ttlMs/cacheScope stamps, - // whose server-side stamping ships with the result-stamping - // milestone — the client-side typed decode of tools/list on the - // modern era completes once that lands. + const envelope = modernEnvelope('modern-pipe-client'); const modernList = await rawRequest(transport, inbound, { jsonrpc: '2.0', id: 'raw-modern-list', @@ -164,31 +163,68 @@ describe('dual-era stdio server over a real child-process pipe', () => { }); expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); - // Both eras concurrently on ONE connection: a raw legacy (envelope-less) - // request on the same pipe is served on the 2025 era… - const legacyList = await rawRequest(transport, inbound, { + // The connection is pinned to the 2026 era: a late claim-less + // initialize is answered with the version error naming the + // supported revisions, never served as a legacy handshake. + const lateInitialize = await rawRequest(transport, inbound, { jsonrpc: '2.0', - id: 'raw-legacy-list', - method: 'tools/list', - params: {} + id: 'raw-late-initialize', + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'late', version: '0' } } }); - const legacyResult = (legacyList as { result?: { tools?: Array<{ name: string }>; resultType?: string } }).result; - expect(legacyResult?.tools?.map(tool => tool.name)).toEqual(['echo']); - expect(legacyResult?.resultType).toBeUndefined(); + const lateError = (lateInitialize as { error: { code: number; data?: { supported?: string[] } } }).error; + expect(lateError.code).toBe(-32_004); + expect(lateError.data?.supported).toContain(MODERN); + } finally { + await client.close(); + } + }); - // …while the era-gate negative holds on the same connection too. - const gate = await rawRequest(transport, inbound, { + it('probe-then-fallback connection: server/discover is answered, then an initialize on the same pipe is served a normal 2025 session', async () => { + const transport = spawnFixtureTransport(); + const inbound: JSONRPCMessage[] = []; + transport.onmessage = message => void inbound.push(message); + transport.onerror = () => {}; + + try { + await transport.start(); + + // The probe is answered by the optimistically built modern instance. + const discover = await rawRequest(transport, inbound, { jsonrpc: '2.0', - id: 'raw-gate-2', - method: 'subscriptions/listen', - params: {} + id: 'probe-1', + method: 'server/discover', + params: { _meta: modernEnvelope('fallback-pipe-client') } }); - const error = (gate as { error: { code: number; message: string; data?: unknown } }).error; - expect(error.code).toBe(-32_601); - expect(error.message).toBe('Method not found'); - expect(error.data).toBeUndefined(); + const discoverResult = (discover as { result?: { supportedVersions?: string[]; resultType?: string } }).result; + expect(discoverResult?.supportedVersions).toEqual([MODERN]); + expect(discoverResult?.resultType).toBe('complete'); + + // The client shares no modern revision and falls back to the 2025 + // handshake on the same connection: a fresh legacy instance serves it. + const init = await rawRequest(transport, inbound, { + jsonrpc: '2.0', + id: 'fallback-init', + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'fallback-pipe-client', version: '1.0.0' } + } + }); + const initResult = (init as { result?: { protocolVersion?: string } }).result; + expect(initResult?.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(JSON.stringify(init)).not.toContain('resultType'); + + await transport.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + // The legacy session works end to end after the fallback. + const list = await rawRequest(transport, inbound, { jsonrpc: '2.0', id: 'fallback-list', method: 'tools/list', params: {} }); + const listResult = (list as { result?: { tools?: Array<{ name: string }>; resultType?: string } }).result; + expect(listResult?.tools?.map(tool => tool.name)).toEqual(['echo']); + expect(listResult?.resultType).toBeUndefined(); } finally { - await client.close(); + await transport.close(); } }); });