feat(server): serveStdio — connection-pinned era serving for stdio; remove ServerOptions.eraSupport#2315
Conversation
…nly 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.
…erOptions.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.
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.
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 detectedLatest commit: e70d007 The changes in this PR will be included in the next version bump. This PR includes changesets to release 5 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
…d 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.
…ted 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.
…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.
…ound 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.
…ed 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.
…o 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.
…he 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.
…io 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).
…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.
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.
| cacheHints?: Partial<Record<CacheableResultMethod, CacheHint>>; | ||
| }; | ||
|
|
||
| /** | ||
| * 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). |
There was a problem hiding this comment.
🟡 The deprecated getClientCapabilities() / getClientVersion() JSDoc says instances serving the 2026-07-28 era "are backfilled per request from the validated envelope", but only createMcpHandler performs that backfill (seedClientIdentityFromEnvelope) — serveStdio's connectInstance() never does, so on 2026-pinned stdio connections (introduced by this PR) the accessors return undefined, exactly as the migration guide added in this PR documents. Qualify the two JSDoc blocks: backfilled per request under createMcpHandler; undefined on serveStdio 2026-pinned connections (handlers read ctx.mcpReq.envelope).
Extended reasoning...
What the bug is. The deprecated accessors Server.getClientCapabilities() and Server.getClientVersion() (packages/server/src/server/server.ts, JSDoc unchanged by this diff) both state: "The accessor remains functional — instances serving the 2026-07-28 era are backfilled per request from the validated envelope." That sentence was accurate while the only modern-serving instances were the per-request ones built by createMcpHandler, which calls seedClientIdentityFromEnvelope for every request (createMcpHandler.ts:481). This PR introduces a second class of modern-serving instances — connection-pinned instances built by serveStdio — for which the claim is false.
The code path. serveStdio's connectInstance() (packages/server/src/server/serveStdio.ts) only calls setNegotiatedProtocolVersion(server, revision) and installModernOnlyHandlers(...) for a modern-pinned instance; it never imports or calls seedClientIdentityFromEnvelope (a grep confirms the only caller is createMcpHandler.ts). So _clientCapabilities / _clientVersion on a 2026-pinned stdio instance stay at their initial undefined, and the accessors return undefined for the entire connection.
Step-by-step proof.
- A 2026-capable client opens a
serveStdioconnection with an enveloped request claiming2026-07-28; the entry pins a modern instance viaconnectInstance('modern', '2026-07-28')— no identity seeding happens. - A handler on this connection calls
server.getClientCapabilities()orserver.getClientVersion(). _oninitializenever ran (the modern era has noinitialize) and no per-request backfill ever runs on this instance, so both accessors returnundefined.- The hover JSDoc, however, told the consumer the accessor "remains functional" on 2026-era instances because it is "backfilled per request from the validated envelope" — code written against that promise (e.g. logging or capability checks based on the accessor) silently sees
undefinedon every serveStdio modern connection.
Why nothing prevents it / why it is PR-attributable. Nothing cross-checks JSDoc prose against the entries that bind instances. The JSDoc was correct before this PR; the PR adds the new instance class and its own migration.md / migration-SKILL.md prose explicitly says these two accessors return undefined on 2026-pinned stdio connections — so the accessor JSDoc now contradicts both the implementation and the migration guide added in this same PR.
Impact. Docs-only: behavior is unaffected, and the migration guide (and docs/server.md) already point handlers at ctx.mcpReq.envelope. The only harm is a misleading hover doc on the deprecated accessors for consumers adopting the new stdio entry.
How to fix. One-sentence qualification on each of the two JSDoc blocks, e.g.: "instances serving the 2026-07-28 era through createMcpHandler are backfilled per request from the validated envelope; on connections pinned to that era by serveStdio the accessor returns undefined — read ctx.mcpReq.envelope instead." (Note this is distinct from the existing review comment about getNegotiatedProtocolVersion() in migration.md — that one is the converse direction, prose claiming undefined where the code returns the pinned revision; this one is the accessor JSDoc claiming a backfill that the new entry never performs.)
…emove ServerOptions.eraSupport (#2315)
Adds
serveStdio, a stdio entry point that mirrorscreateMcpHandlerfor long-lived connections: the entry owns the transport and the protocol-version era decision, the client's opening exchange selects the era for the connection, and one server instance built from a consumer factory is pinned to that connection and serves only that era. The per-message dual-era option an earlier 2.0 alpha added toServerOptions(eraSupport) is removed along with the per-message era machinery it required.Motivation and Context
The 2026-07-28 draft revision is served over HTTP by
createMcpHandler, which classifies each request before constructing a per-request server instance — so every instance speaks exactly one protocol era, selected by the entry. stdio previously took a different approach: a single hand-constructed instance witheraSupport: 'dual-era'classified every message and switched eras per message, which pushed era-selection branches into the protocol dispatch layer and theServerclass. This PR gives stdio the same shape as HTTP: an entry owns the era decision, instances stay single-era, and the per-message machinery is deleted.The specification supports the connection-pinned model: era determination is a property of the server (clients cache it for the stdio process lifetime), a dual-era server selects its behavior from how the client opens (an
initializeopening selects legacy semantics scoped to the stdio process), and serving both eras concurrently on one process is only a MAY.What changed:
serveStdio(factory, options?)(exported from@modelcontextprotocol/server/stdio): owns the stdio transport (or a bring-your-own transport, e.g. over a Unix domain socket), classifies the connection's opening exchange with the same body-primary rules as the HTTP entry, builds ONE instance from the factory for that era, pins it for the connection lifetime, and passes everything else straight through. Aserver/discoverprobe is answered from an optimistically built modern instance without pinning; the client either continues with enveloped modern requests (pinning the modern era) or falls back toinitialize(the probe instance is discarded and a fresh 2025-era instance serves the handshake). Once the modern era is pinned, a laterinitializeis rejected with the unsupported-protocol-version error naming the supported revisions, as the spec recommends.legacy: 'reject'refuses 2025-era openings the same way; the default serves them.ServerOptions.eraSupportremoved (it only ever existed in unreleased 2.0 alphas). A hand-constructedServer/McpServerserves the 2025-era protocol it was written for — upgrading the SDK changes nothing about its wire behavior — and serving the 2026-07-28 revision always goes through a serving entry. With the option gone, the per-message machinery it required is deleted: the per-message era selection in the protocol dispatch layer, the server-side per-message classification override, the dual-erainitializebookkeeping, the per-request era wrapping of context senders (the instance-level outbound era gate covers pinned instances), and the special-casedserver/discoverregistration for unbound instances. The protocol-layer hook that remains can only drop unclassified inbound messages — it is what lets a client on a modern-era connection discard inbound requests instead of answering them — and can no longer influence era selection.serveStdio(one factory, both eras, each client on its own connection); the real-pipe integration suite covers a legacy-opening connection, a modern-opening connection (including the late-initializerejection), and the probe-then-fallback flow against a real child process; the package-level suite covers the full opening state machine over an in-memory transport.eraSupport; a changeset records the new entry and the removal.How Has This Been Tested?
initialize, probe→modern, probe→initializefallback with the probe instance discarded,initializeafter the modern era is pinned,legacy: 'reject', malformed and unsupported envelope claims, teardown), plus a golden pin asserting a hand-constructed server still serves a scripted 2025 session byte-shape-identically and keeps answeringserver/discoverwith-32601.Breaking Changes
ServerOptions.eraSupportis removed (it never shipped in a stable release). Migratenew McpServer(info, { eraSupport: 'dual-era' })+connect(new StdioServerTransport())toserveStdio(() => new McpServer(info)), anderaSupport: 'modern'toserveStdio(factory, { legacy: 'reject' }). Hand-constructed servers are otherwise byte-identical to before, and a 2026-era revision insupportedProtocolVersionsno longer throws at construction.Types of changes
Checklist
Additional context
close()semantics predictable.undefined(client identity is per-request there); handlers readctx.mcpReq.envelope. Documented in the migration guide.