From a941791423535c214af5b745f6e0de30f3e8d02e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 17 Jun 2026 10:53:54 +0000 Subject: [PATCH 01/14] refactor(core): reduce the protocol-layer inbound consult to a drop-only hook The classification consult added for per-message dual-era serving selected a wire codec per message on unbound instances. Era is connection state owned by the serving entries, so the hook no longer returns classifications: it can only decline a message ('drop'), which is what the client uses to discard inbound requests on modern-era connections. The per-message predicate classifyInboundMessage is removed with its only consumer; the carriesValidModernEnvelopeClaim helper is exported on the internal barrel for the stdio serving entry. --- .changeset/server-era-support.md | 9 -- .../test/client/probeFixtureCorpus.test.ts | 4 +- .../core/src/shared/inboundClassification.ts | 50 +------ packages/core/src/shared/protocol.ts | 95 +++++------- packages/core/src/types/types.ts | 9 +- packages/core/src/wire/codec.ts | 14 +- .../shared/classifyInboundMessage.test.ts | 107 -------------- .../protocolClassifyInboundHook.test.ts | 135 +++++------------- packages/server/test/server/discover.test.ts | 43 ++---- test/integration/test/client/client.test.ts | 12 +- .../test/client/discoverRoundtrip.test.ts | 42 +++--- 11 files changed, 129 insertions(+), 391 deletions(-) delete mode 100644 .changeset/server-era-support.md delete mode 100644 packages/core/test/shared/classifyInboundMessage.test.ts 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/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..13fbe11ce8 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 _classifyInbound(_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._classifyInbound(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._classifyInbound(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/protocolClassifyInboundHook.test.ts index 23bb7cf6ce..07ff62c437 100644 --- a/packages/core/test/shared/protocolClassifyInboundHook.test.ts +++ b/packages/core/test/shared/protocolClassifyInboundHook.test.ts @@ -1,67 +1,44 @@ /** - * The protocol-layer classification consult (`Protocol._classifyInbound`): + * The protocol-layer drop consult (`Protocol._classifyInbound`): * * - 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 _classifyInbound(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/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/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[] = []; From 104927e5548a564d3066e8f1448965fd202fc4c2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 17 Jun 2026 10:54:18 +0000 Subject: [PATCH 02/14] feat(server): add the connection-pinned serveStdio entry; remove ServerOptions.eraSupport serveStdio(factory, options?) (exported from the ./stdio subpath) owns the stdio transport and the era decision for a connection: the opening exchange selects the era (initialize/claim-less => 2025, valid modern envelope => 2026, server/discover answered as a probe with an initialize fallback window), one factory instance is pinned for the connection lifetime, and later messages pass straight through. legacy: 'reject' answers 2025-era openings with the unsupported-protocol-version error naming the supported revisions. ServerOptions.eraSupport (an earlier alpha's per-message dual-era option) is removed along with the per-message machinery it required: the Server classification override, the dual-era initialize bookkeeping, and the per-request context era wrapping (the instance-level outbound era gate covers pinned instances). Hand-constructed servers keep their pre-existing 2025-only behavior, including discover registration keyed on the supported-versions list. --- packages/server/src/index.ts | 6 +- packages/server/src/server/serveStdio.ts | 537 ++++++++++++++++++ packages/server/src/server/server.ts | 263 +-------- packages/server/src/stdio.ts | 7 +- .../server/test/server/dualEraServing.test.ts | 384 ------------- .../server/test/server/eraSupport.test.ts | 390 ------------- .../test/server/legacyDefaultServing.test.ts | 113 ++++ .../server/test/server/serveStdio.test.ts | 398 +++++++++++++ 8 files changed, 1074 insertions(+), 1024 deletions(-) create mode 100644 packages/server/src/server/serveStdio.ts delete mode 100644 packages/server/test/server/dualEraServing.test.ts delete mode 100644 packages/server/test/server/eraSupport.test.ts create mode 100644 packages/server/test/server/legacyDefaultServing.test.ts create mode 100644 packages/server/test/server/serveStdio.test.ts 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/serveStdio.ts b/packages/server/src/server/serveStdio.ts new file mode 100644 index 0000000000..ac1380522a --- /dev/null +++ b/packages/server/src/server/serveStdio.ts @@ -0,0 +1,537 @@ +/** + * `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, + isJSONRPCNotification, + isJSONRPCRequest, + 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; + + 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 (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; + } + this.onmessage?.(message, extra); + } + + async close(): Promise { + if (this._closed) { + return; + } + this._closed = true; + try { + this._onInstanceClose(); + } finally { + this.onclose?.(); + } + } +} + +/* ------------------------------------------------------------------------ * + * 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; + + 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 }; + }; + + 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 { + 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' && state.phase === 'opening') { + // 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); + state = { phase: 'probe', instance }; + instance.channel.deliver(message, { classification: opening.classification }); + return; + } + if (state.phase === 'probe') { + // The probe was followed by a modern message: 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); + 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); + state = { phase: 'opening' }; + } + const instance = await connectInstance('legacy'); + 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) { + 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/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..d2ebbfad54 --- /dev/null +++ b/packages/server/test/server/serveStdio.test.ts @@ -0,0 +1,398 @@ +/** + * `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 } 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'; +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 and returns raw drivers for the peer side. */ +async function startEntry(options?: Omit) { + const { factory, eras, closed } = trackingFactory(); + 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, eras, closed, peerTx }; +} + +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(); + }); +}); + +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('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); + }); +}); From e04bdb2a0555eecb30ebdb05e78d5f0d02977d16 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 17 Jun 2026 10:54:38 +0000 Subject: [PATCH 03/14] test: migrate dual-era stdio examples and coverage to serveStdio The stdio example, the e2e dual-era stdio fixture/scenario, and the real-pipe integration suite now host the server through serveStdio. The integration suite covers one legacy-opening connection and one modern-opening connection against the same factory (replacing the interleaved-eras-on-one-connection assertions), plus the probe-then-initialize fallback and the initialize-after-modern-pinned rejection over a real child-process pipe. The tests that had only added the removed eraSupport option to satisfy its construction-time guard are restored to their previous form. --- examples/client/src/dualEraStdioClient.ts | 7 +- examples/server/src/dualEraStdio.ts | 43 ++++--- test/e2e/fixtures/dual-era-stdio-server.ts | 35 +++-- test/e2e/requirements.ts | 6 +- test/e2e/scenarios/stdio-dual-era.test.ts | 24 ++-- .../test/__fixtures__/dualEraStdioServer.ts | 36 +++--- .../test/server/dualEraStdio.test.ts | 120 ++++++++++++------ 7 files changed, 158 insertions(+), 113 deletions(-) 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/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/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(); } }); }); From 15fa66dd6f3ea909185540654f7e349ee7073c39 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 17 Jun 2026 10:55:10 +0000 Subject: [PATCH 04/14] docs: document serveStdio and the eraSupport removal Migration guide and server guide now describe the connection-pinned stdio entry (factory, opening-exchange rules, legacy: 'reject', BYO transport) and the one-line migration from the removed ServerOptions.eraSupport; the changeset for the unreleased option is replaced accordingly. --- .changeset/server-serve-stdio.md | 11 +++++++ docs/migration-SKILL.md | 12 ++++--- docs/migration.md | 54 +++++++++++++++++--------------- docs/server.md | 19 ++++++----- 4 files changed, 58 insertions(+), 38 deletions(-) create mode 100644 .changeset/server-serve-stdio.md 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..f7860c3f82 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -552,11 +552,13 @@ 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 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. `serveStdio(factory, { legacy: 'reject' })` refuses 2025-era openings with the unsupported-protocol-version error. +- REMOVED (was only in earlier 2.0 alphas): `ServerOptions.eraSupport`. Replace `new McpServer(info, { eraSupport: 'dual-era' })` + `connect(new StdioServerTransport())` with `serveStdio(() => new McpServer(info))`; replace `eraSupport: 'modern'` with + `serveStdio(factory, { legacy: 'reject' })`. +- On 2026-pinned stdio connections `getClientCapabilities()` / `getClientVersion()` / `getNegotiatedProtocolVersion()` return `undefined` (no `initialize` ever runs there); handlers read per-request identity from `ctx.mcpReq.envelope`. 2025-pinned connections keep the + `initialize`-scoped semantics. - 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..bba07c7ca8 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,40 @@ 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. +**Removed: `ServerOptions.eraSupport`.** The earlier 2.0 alpha briefly accepted `eraSupport: 'dual-era' | 'modern'` on `Server`/`McpServer`, selecting the era per message on one instance. That option is gone: replace `new McpServer(info, { eraSupport: 'dual-era' })` + +`connect(new StdioServerTransport())` with `serveStdio(() => new McpServer(info))` (and `eraSupport: 'modern'` with `serveStdio(factory, { legacy: 'reject' })`). A hand-constructed `Server`/`McpServer` connected directly to a transport serves only the 2025-era protocol it +was written for — upgrading the SDK changes nothing about what it puts on the wire, and serving the 2026-07-28 revision always goes through one of the serving entries (`createMcpHandler` for HTTP, `serveStdio` for stdio). ### Cache fields and cache hints for cacheable 2026-07-28 results @@ -1137,8 +1139,8 @@ 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 accessors are **not** backfilled: the modern era carries client identity per request, so connection-scoped accessors have nothing stable to report there. They return `undefined` (no `initialize` handshake +ever ran on such a connection), and handlers read the per-request identity from `ctx.mcpReq.envelope`. 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 From dcf1ac0fd41230db7fac16989845f887dc5db120 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 17 Jun 2026 12:44:48 +0000 Subject: [PATCH 05/14] fix(server): answer the opening request when serveStdio fails to build or connect an instance A factory that throws or rejects, or an instance whose connect fails, while serveStdio is processing an inbound request previously left that request unanswered: the pump's catch only reported the error, so the client hung on its opening exchange. The catch now answers the request with an internal error (-32603) echoing its id before reporting, mirroring how the HTTP entry answers a throwing factory with its internal-server-error response. Notifications are still only reported. Every classification arm that writes an error response does so via writeErrorResponse (which never throws) and returns immediately, so the catch can only fire for requests that were never answered - no double response is possible. --- packages/server/src/server/serveStdio.ts | 12 ++ .../server/test/server/serveStdio.test.ts | 110 +++++++++++++++++- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/serveStdio.ts b/packages/server/src/server/serveStdio.ts index ac1380522a..c75ea0f4e0 100644 --- a/packages/server/src/server/serveStdio.ts +++ b/packages/server/src/server/serveStdio.ts @@ -474,6 +474,18 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions 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)); } } diff --git a/packages/server/test/server/serveStdio.test.ts b/packages/server/test/server/serveStdio.test.ts index d2ebbfad54..2fc4cecbc0 100644 --- a/packages/server/test/server/serveStdio.test.ts +++ b/packages/server/test/server/serveStdio.test.ts @@ -33,6 +33,7 @@ import { 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'; @@ -83,9 +84,8 @@ function trackingFactory() { return { factory, eras, closed }; } -/** Boots the entry on one side of an in-memory pair and returns raw drivers for the peer side. */ -async function startEntry(options?: Omit) { - const { factory, eras, closed } = trackingFactory(); +/** 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[] = []; @@ -112,7 +112,13 @@ async function startEntry(options?: Omit) { const notify = (message: JSONRPCNotification): Promise => peerTx.send(message); const flush = () => new Promise(resolve => setTimeout(resolve, 20)); - return { handle, request, notify, flush, inbound, errors, eras, closed, peerTx }; + 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)', () => { @@ -379,6 +385,102 @@ describe('malformed and unsupported envelope claims (entry-answered, never pinne }); }); +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('teardown', () => { it('handle.close() closes the pinned instance and the wire transport', async () => { const { handle, request, closed, peerTx } = await startEntry(); From 9ef7fe532157da429d4393804e2c70e8881cab8f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 17 Jun 2026 12:45:42 +0000 Subject: [PATCH 06/14] fix(server): keep the serveStdio negotiation window open across repeated server/discover probes The probe special-case only matched while the connection was still in the opening phase, so a second server/discover received during the probe phase fell into the modern-commitment branch and pinned the connection. After that a legitimate fallback initialize was rejected with -32004 instead of being served by a fresh legacy instance. A server/discover received in the probe phase is now answered by the existing probe instance without changing phase; only a non-discover enveloped request commits the connection to the modern era. --- packages/server/src/server/serveStdio.ts | 12 +++- .../server/test/server/serveStdio.test.ts | 55 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/serveStdio.ts b/packages/server/src/server/serveStdio.ts index c75ea0f4e0..4847044afc 100644 --- a/packages/server/src/server/serveStdio.ts +++ b/packages/server/src/server/serveStdio.ts @@ -409,7 +409,17 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions return; } case 'modern': { - if (isJSONRPCRequest(message) && message.method === 'server/discover' && state.phase === 'opening') { + 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 diff --git a/packages/server/test/server/serveStdio.test.ts b/packages/server/test/server/serveStdio.test.ts index 2fc4cecbc0..d15324a89b 100644 --- a/packages/server/test/server/serveStdio.test.ts +++ b/packages/server/test/server/serveStdio.test.ts @@ -300,6 +300,61 @@ describe('server/discover probe window', () => { 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('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'", () => { From 064e88fe6b3d2fe5b96f8d489bad6fece73795fc Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 17 Jun 2026 12:48:17 +0000 Subject: [PATCH 07/14] fix(server): deliver the probe answer before serveStdio discards the probe instance When a client pipelines its fallback initialize directly behind a server/discover probe without waiting for the answer, the probe-instance discard closed the channel while the DiscoverResult write was still in flight: closing aborts the in-flight handler, the late send was dropped silently, and the probe request was never answered. The connection channel now tracks delivered-but-unanswered request ids, and the discard path waits for those answers to reach the wire before closing the probe instance. The probe instance only ever receives server/discover, whose entry-installed handler always answers, so the wait is bounded; a channel close releases any remaining waiters. --- packages/server/src/server/serveStdio.ts | 57 +++++++++++++++++++ .../server/test/server/serveStdio.test.ts | 31 ++++++++++ 2 files changed, 88 insertions(+) diff --git a/packages/server/src/server/serveStdio.ts b/packages/server/src/server/serveStdio.ts index 4847044afc..55868e4207 100644 --- a/packages/server/src/server/serveStdio.ts +++ b/packages/server/src/server/serveStdio.ts @@ -60,8 +60,10 @@ import { carriesValidModernEnvelopeClaim, envelopeClaimVersion, hasEnvelopeClaim, + isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, + isJSONRPCResultResponse, modernOnlyStrictRejection, ProtocolErrorCode, requestMetaOf, @@ -128,6 +130,9 @@ class StdioConnectionChannel implements Transport { 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, @@ -140,6 +145,15 @@ class StdioConnectionChannel implements Transport { } 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. @@ -157,20 +171,55 @@ class StdioConnectionChannel implements Transport { 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(); + } + } } /* ------------------------------------------------------------------------ * @@ -338,6 +387,14 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions // 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)); diff --git a/packages/server/test/server/serveStdio.test.ts b/packages/server/test/server/serveStdio.test.ts index d15324a89b..2386e493de 100644 --- a/packages/server/test/server/serveStdio.test.ts +++ b/packages/server/test/server/serveStdio.test.ts @@ -301,6 +301,37 @@ describe('server/discover probe window', () => { 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(); From b450d4558747296b1dfc93e14aba03631b34b50a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 17 Jun 2026 12:50:54 +0000 Subject: [PATCH 08/14] refactor(core): rename the protocol inbound consult to _shouldDropInbound The hook can only return 'drop' or undefined since the per-message era classification was removed, so the old name no longer described what it does. Pure rename of the protected member, its Client override, and the covering suite (file renamed to match); no behavior change. --- packages/client/src/client/client.ts | 2 +- packages/core/src/shared/protocol.ts | 6 +++--- ...yInboundHook.test.ts => protocolDropInboundHook.test.ts} | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) rename packages/core/test/shared/{protocolClassifyInboundHook.test.ts => protocolDropInboundHook.test.ts} (97%) 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/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 13fbe11ce8..8feffae174 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -505,7 +505,7 @@ export abstract class Protocol { * instance state, owned by the serving entry that constructed and * connected the instance. */ - protected _classifyInbound(_message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { + protected _shouldDropInbound(_message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { return undefined; } @@ -655,7 +655,7 @@ export abstract class Protocol { // 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._classifyInbound(rawNotification) === 'drop') { + if (extra?.classification === undefined && this._shouldDropInbound(rawNotification) === 'drop') { return; } @@ -717,7 +717,7 @@ export abstract class Protocol { // 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._classifyInbound(rawRequest) === 'drop') { + 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; } diff --git a/packages/core/test/shared/protocolClassifyInboundHook.test.ts b/packages/core/test/shared/protocolDropInboundHook.test.ts similarity index 97% rename from packages/core/test/shared/protocolClassifyInboundHook.test.ts rename to packages/core/test/shared/protocolDropInboundHook.test.ts index 07ff62c437..40b99452d8 100644 --- a/packages/core/test/shared/protocolClassifyInboundHook.test.ts +++ b/packages/core/test/shared/protocolDropInboundHook.test.ts @@ -1,5 +1,5 @@ /** - * The protocol-layer drop 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. @@ -38,7 +38,7 @@ class HookedProtocol extends Protocol { return ctx; } - protected override _classifyInbound(message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { + protected override _shouldDropInbound(message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { this.consulted.push(message); return this.verdict?.(message); } From 85273fff6f313896783f66d3f8b557ed8362ee08 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 17 Jun 2026 12:52:01 +0000 Subject: [PATCH 09/14] test(server): pin the outbound era gate for handlers on a modern-pinned serveStdio connection A handler calling ctx.mcpReq.requestSampling on a connection serveStdio pinned to the 2026-07-28 era gets the typed method-not-supported error locally, and no sampling request reaches the wire. --- .../server/test/server/serveStdio.test.ts | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/server/test/server/serveStdio.test.ts b/packages/server/test/server/serveStdio.test.ts index 2386e493de..698aa45881 100644 --- a/packages/server/test/server/serveStdio.test.ts +++ b/packages/server/test/server/serveStdio.test.ts @@ -28,7 +28,9 @@ import { isJSONRPCErrorResponse, isJSONRPCResultResponse, LATEST_PROTOCOL_VERSION, - PROTOCOL_VERSION_META_KEY + PROTOCOL_VERSION_META_KEY, + SdkError, + SdkErrorCode } from '@modelcontextprotocol/core'; import { describe, expect, it } from 'vitest'; import * as z from 'zod/v4'; @@ -567,6 +569,45 @@ describe('factory or connect failure during the opening exchange (entry-answered }); }); +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(); From e70d007cc1162817344a64b89eb06c6195407c87 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 17 Jun 2026 12:54:56 +0000 Subject: [PATCH 10/14] docs: split the deprecated-accessor behavior on 2026-pinned serveStdio connections by accessor The migration guide and its skill variant claimed all three deprecated accessors return undefined on a serveStdio connection pinned to 2026-07-28. Only the identity accessors (getClientCapabilities, getClientVersion) do; getNegotiatedProtocolVersion reports the pinned revision because the entry era-marks the instance when binding it, matching its JSDoc and createMcpHandler-served instances. Docs only, no code change. --- docs/migration-SKILL.md | 4 ++-- docs/migration.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index f7860c3f82..8cfc59d571 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -557,8 +557,8 @@ These can require code changes: envelope, with `server/discover` answered as a probe); one factory instance is pinned per connection. `serveStdio(factory, { legacy: 'reject' })` refuses 2025-era openings with the unsupported-protocol-version error. - REMOVED (was only in earlier 2.0 alphas): `ServerOptions.eraSupport`. Replace `new McpServer(info, { eraSupport: 'dual-era' })` + `connect(new StdioServerTransport())` with `serveStdio(() => new McpServer(info))`; replace `eraSupport: 'modern'` with `serveStdio(factory, { legacy: 'reject' })`. -- On 2026-pinned stdio connections `getClientCapabilities()` / `getClientVersion()` / `getNegotiatedProtocolVersion()` return `undefined` (no `initialize` ever runs there); handlers read per-request identity from `ctx.mcpReq.envelope`. 2025-pinned connections keep the - `initialize`-scoped semantics. +- 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 bba07c7ca8..103cec1b6b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1139,8 +1139,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 connection pinned to the 2026-07-28 era by `serveStdio` the accessors are **not** backfilled: the modern era carries client identity per request, so connection-scoped accessors have nothing stable to report there. They return `undefined` (no `initialize` handshake -ever ran on such a connection), and handlers read the per-request identity from `ctx.mcpReq.envelope`. On 2025-pinned connections the accessors keep their `initialize`-scoped semantics, as before. +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 From d4be53a24c1d60262bbdeb9305eda07a0d8c39b8 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 09:45:39 +0000 Subject: [PATCH 11/14] fix(server): keep a serveStdio connection closed when a close races the opening factory A handle.close() or wire close that landed while an opening arm was awaiting the consumer factory (or the probe discard) was overwritten by the continuation: the arm reassigned the connection state back to probe/pinned/opening and connected a freshly built instance that nothing would ever close, since the close paths had already run and are guarded by the closing flag. The opening arms now re-check the torn-down condition after every await, close a late-resolved instance instead of adopting it, and never deliver the message that triggered the build. Covered by two new tests that close the handle while a gated factory is mid-construction (legacy opening and server/discover probe) and assert the instance is closed, nothing is delivered to it, and the connection state is not resurrected. --- packages/server/src/server/serveStdio.ts | 36 +++++++ .../server/test/server/serveStdio.test.ts | 93 ++++++++++++++++++- 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/serveStdio.ts b/packages/server/src/server/serveStdio.ts index 55868e4207..2aecf25ae4 100644 --- a/packages/server/src/server/serveStdio.ts +++ b/packages/server/src/server/serveStdio.ts @@ -327,6 +327,14 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions 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); @@ -381,6 +389,10 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions 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 @@ -483,6 +495,14 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions // 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; @@ -493,6 +513,12 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions 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 }); @@ -515,9 +541,19 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions // 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; diff --git a/packages/server/test/server/serveStdio.test.ts b/packages/server/test/server/serveStdio.test.ts index 698aa45881..c30f773bef 100644 --- a/packages/server/test/server/serveStdio.test.ts +++ b/packages/server/test/server/serveStdio.test.ts @@ -20,7 +20,14 @@ * - 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 } from '@modelcontextprotocol/core'; +import type { + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + MessageExtraInfo, + Transport +} from '@modelcontextprotocol/core'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, @@ -569,6 +576,90 @@ describe('factory or connect failure during the opening exchange (entry-answered }); }); +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; From 0a09fd13da653b01d151520add0229137715b4e8 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 09:46:50 +0000 Subject: [PATCH 12/14] fix(server): do not let an enveloped notification commit the serveStdio probe window to the modern era Inside the probe window only a server/discover request had special handling, so any other modern-classified message - including a notification carrying a valid envelope, such as a notifications/cancelled sent for a timed-out probe - fell into the pinning branch and committed the connection. A legitimate fallback initialize after that was rejected with -32004 instead of being served, contradicting the documented contract that only a non-discover enveloped request commits the era. Enveloped notifications received during the probe window are now delivered to the probe instance without changing phase; non-discover enveloped requests still pin the modern era. New tests cover probe -> enveloped notifications/cancelled -> initialize falling back to a fresh legacy instance, and probe -> enveloped request still committing (a later initialize is rejected). --- packages/server/src/server/serveStdio.ts | 13 +++- .../server/test/server/serveStdio.test.ts | 60 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/serveStdio.ts b/packages/server/src/server/serveStdio.ts index 2aecf25ae4..e166c4298f 100644 --- a/packages/server/src/server/serveStdio.ts +++ b/packages/server/src/server/serveStdio.ts @@ -508,7 +508,18 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions return; } if (state.phase === 'probe') { - // The probe was followed by a modern message: the client + 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 { diff --git a/packages/server/test/server/serveStdio.test.ts b/packages/server/test/server/serveStdio.test.ts index c30f773bef..0ba6c3ecf5 100644 --- a/packages/server/test/server/serveStdio.test.ts +++ b/packages/server/test/server/serveStdio.test.ts @@ -370,6 +370,66 @@ describe('server/discover probe window', () => { 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(); From 23866bfa575e9639dfc43fb41992f1e6a908a843 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 09:48:02 +0000 Subject: [PATCH 13/14] docs: cover serveStdio in the McpServerFactory and McpRequestContext JSDoc The factory contract is shared by both serving entries, but its JSDoc only described the HTTP per-request semantics. The McpRequestContext and McpServerFactory blocks now state when each entry calls the factory (per HTTP request vs per stdio connection, plus the discarded server/discover probe instance), what era 'legacy' means under each entry, and that authInfo and requestInfo are HTTP-only fields. --- .../server/src/server/createMcpHandler.ts | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) 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; From caf8c9d9dd59c05dd0ac86dcd9edc7e0f33da979 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 09:50:31 +0000 Subject: [PATCH 14/14] docs: frame the stdio migration entries for readers coming from v1 The migration guide entries for stdio serving now describe the change purely from a v1 reader's perspective: the hand-constructed Server/McpServer + StdioServerTransport pattern still works and serves only the 2025-era protocol; serving the 2026-07-28 revision (or both eras) on stdio goes through serveStdio, by moving the server construction into the factory. The narration of an interim option that existed only in earlier 2.0 alphas is removed from both guides; that history stays in the changeset. --- docs/migration-SKILL.md | 7 +++---- docs/migration.md | 7 ++++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 8cfc59d571..f8014412fb 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -553,10 +553,9 @@ These can require code changes: ### Server (stdio / long-lived connections) - 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 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. `serveStdio(factory, { legacy: 'reject' })` refuses 2025-era openings with the unsupported-protocol-version error. -- REMOVED (was only in earlier 2.0 alphas): `ServerOptions.eraSupport`. Replace `new McpServer(info, { eraSupport: 'dual-era' })` + `connect(new StdioServerTransport())` with `serveStdio(() => new McpServer(info))`; replace `eraSupport: 'modern'` with - `serveStdio(factory, { legacy: 'reject' })`. +- 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. diff --git a/docs/migration.md b/docs/migration.md index 103cec1b6b..1e9cbfdbde 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1099,9 +1099,10 @@ Because the entry may construct an instance for a probe that is later discarded 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. -**Removed: `ServerOptions.eraSupport`.** The earlier 2.0 alpha briefly accepted `eraSupport: 'dual-era' | 'modern'` on `Server`/`McpServer`, selecting the era per message on one instance. That option is gone: replace `new McpServer(info, { eraSupport: 'dual-era' })` + -`connect(new StdioServerTransport())` with `serveStdio(() => new McpServer(info))` (and `eraSupport: 'modern'` with `serveStdio(factory, { legacy: 'reject' })`). A hand-constructed `Server`/`McpServer` connected directly to a transport serves only the 2025-era protocol it -was written for — upgrading the SDK changes nothing about what it puts on the wire, and serving the 2026-07-28 revision always goes through one of the serving entries (`createMcpHandler` for HTTP, `serveStdio` for stdio). +**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