From b05977e4edd5b941b497fcc6107367d81824c386 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 20 Jul 2026 17:34:33 +0000 Subject: [PATCH] fix(client): probe stdio servers on a disposable sibling process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some stdio servers exit on any pre-initialize request, so the server/discover version-negotiation probe killed the server and connect() hard-failed under mode 'auto' while mode 'legacy' worked. The probe now runs on a short-lived sibling spawned from the same parameters (stderr discarded, reaped once the era is known — awaiting process exit, never held-open pipes) and the caller's transport starts exactly once, afterwards: a legacy verdict connects with the plain initialize handshake, byte-identical to mode 'legacy'; a modern verdict adopts the sibling's DiscoverResult directly, so the session wire never carries server/discover. Closing the caller's transport during the probe aborts promptly with the typed error and the session child is never spawned. On HTTP, and on custom stdio-shaped transports (which probe in place), a mid-probe close keeps rejecting with the typed connect error, now naming the close in pin/modern-only diagnostics. --- .changeset/probe-close-handling.md | 5 + docs/migration/support-2026-07-28.md | 20 +- docs/protocol-versions.md | 8 +- docs/troubleshooting.md | 12 +- packages/client/src/client/client.ts | 64 ++- packages/client/src/client/probeClassifier.ts | 36 +- packages/client/src/client/stdio.ts | 55 +++ .../client/src/client/versionNegotiation.ts | 237 +++++++++- .../test/client/probeClassifier.test.ts | 20 +- .../test/client/probeFixtureCorpus.test.ts | 2 +- packages/client/test/client/stdio.test.ts | 32 ++ .../test/client/versionNegotiation.test.ts | 444 ++++++++++++++++++ test/e2e/scenarios/stdio-dual-era.test.ts | 9 +- .../test/client/versionNegotiation.test.ts | 234 ++++++++- .../test/server/dualEraStdio.test.ts | 7 +- 15 files changed, 1119 insertions(+), 66 deletions(-) create mode 100644 .changeset/probe-close-handling.md diff --git a/.changeset/probe-close-handling.md b/.changeset/probe-close-handling.md new file mode 100644 index 0000000000..5bf48d3f73 --- /dev/null +++ b/.changeset/probe-close-handling.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Probe stdio servers on a disposable sibling process. Some stdio servers exit on any pre-`initialize` request (servers built on the official Rust SDK, rmcp, behave this way), so under `versionNegotiation: { mode: 'auto' }` the `server/discover` probe previously killed the server and `connect()` hard-failed. The probe now runs on a short-lived sibling spawned from the same parameters — its stderr is discarded and it is reaped once the era is known — and the caller's transport spawns exactly once, afterwards: a legacy verdict connects with the plain `initialize` handshake (byte-identical to `mode: 'legacy'`), a modern verdict is adopted directly, and the session wire never carries `server/discover`. Closing the caller's transport during the probe aborts `connect()` with the typed `SdkError(EraNegotiationFailed)` and the session child is never spawned. On HTTP — and on custom stdio-shaped transports, which probe in place — a mid-probe connection close keeps rejecting with the typed connect error, now naming the close in pin-mode and modern-only diagnostics. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index e098ed40a7..154a11b1d5 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -55,8 +55,9 @@ client.getProtocolEra(); // 'modern' | 'legacy' ``` - **absent / `mode: 'legacy'`** (default) — today's behavior, no probe. -- **`mode: 'auto'`** — probe with `server/discover`; fall back to the 2025 handshake on - the same connection against a 2025-only server (one extra round trip). +- **`mode: 'auto'`** — probe with `server/discover`; fall back to the 2025 handshake + against a 2025-only server (one extra round trip; on the SDK's stdio transport the + probe rides a disposable sibling process — see below). - **`mode: { pin: '2026-07-28' }`** — modern only; no fallback, `connect()` rejects with `SdkError(EraNegotiationFailed)` against a 2025-only server. @@ -77,12 +78,25 @@ falls back to the legacy era — provided the supported-versions list still cont `SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect error. Probe timeouts are **transport-aware**: on **stdio** a server that does not answer within `timeoutMs` is treated as legacy and the client falls back to `initialize` -on the same stream (some legacy servers never respond to unknown pre-`initialize` +(some legacy servers never respond to unknown pre-`initialize` requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` — a dead HTTP server is never misreported as legacy. One browser-specific exception: an opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers. +On the SDK's own stdio transport (exactly `StdioClientTransport` — subclasses probe +in place, like custom stdio-shaped transports) the probe runs on a short-lived +**sibling process** spawned from the same parameters (its stderr is discarded, and it is reaped once the +era is known): some stdio servers exit on any pre-`initialize` request — servers built +on the official Rust SDK, rmcp, behave this way — so the probe must not spend the +caller's one child process. A child that exits on the probe is simply a legacy server; +the caller's transport spawns exactly once, after the era is known, and its wire never +carries `server/discover`. Closing the caller's transport during the probe aborts +`connect()` with a typed `SdkError(EraNegotiationFailed)` and the session child is +never spawned. On HTTP — and on custom stdio-shaped transports, which probe in +place — a mid-probe connection close rejects with the same typed error as any probe +transport failure. + ```typescript versionNegotiation: { mode: 'auto', diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 6b06754ab5..a304f4b604 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -30,7 +30,7 @@ console.log(client.getProtocolEra()); modern ``` -Point the same options at a 2025-only server and `connect()` falls back to the `initialize` handshake on the same connection — one extra round trip, no error. +Point the same options at a 2025-only server and `connect()` falls back to the `initialize` handshake — one extra round trip, no error (on the SDK's stdio transport the probe rides a disposable sibling process; see below). ```ts source="../examples/guides/protocolVersions.examples.ts#versionNegotiation_fallback" const fallback = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); @@ -99,12 +99,14 @@ const cli = new Client( ); ``` -A probe timeout is transport-aware. On stdio a silent server is a legacy server, so `connect()` falls back to `initialize` on the same stream; on HTTP silence is an outage, so `connect()` rejects with `SdkError(RequestTimeout)` instead of misreporting a dead server as legacy. One browser exception: an opaque CORS `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have allow-lists that predate the 2026 headers. +A probe timeout is transport-aware. On stdio a silent server is a legacy server, so `connect()` falls back to `initialize`; on HTTP silence is an outage, so `connect()` rejects with `SdkError(RequestTimeout)` instead of misreporting a dead server as legacy. One browser exception: an opaque CORS `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have allow-lists that predate the 2026 headers. + +On the SDK's own stdio transport (exactly `StdioClientTransport` — subclasses, like custom stdio-shaped transports, probe in place) the probe runs on a short-lived **sibling process** spawned from the same parameters — some stdio servers exit on any pre-`initialize` request (servers built on the official Rust SDK, rmcp, behave this way), so the probe must not spend the caller's one child process. The sibling is invisible infrastructure: its stderr is discarded and it is reaped once the era is known; the caller's transport spawns exactly once, afterwards, and its wire never carries `server/discover`. A child that exits on the probe is simply a legacy server (its exit must close the child's stdio pipes to register — an exit hidden behind a helper process holding them open falls to the probe-timeout path). Closing the caller's transport during the probe aborts `connect()` with a typed `SdkError(EraNegotiationFailed)` and the session child is never spawned. On HTTP — and on custom stdio-shaped transports, which probe in place — a mid-probe connection close rejects with the same typed error as any probe transport failure. The client's `supportedProtocolVersions` option shapes the probe: its 2026+ entries are the versions the probe offers, and the legacy fallback stays available only while the list keeps a pre-2026 entry. A list with no pre-2026 entry removes the fallback — against a 2025-only server, `connect()` rejects with `SdkError(EraNegotiationFailed)`. ::: warning -Do not default a spawn-per-invocation CLI tool to `'auto'`. On stdio, a legacy server that never answers unknown pre-`initialize` requests stalls `connect()` for the full probe timeout before falling back, and the extra round trip changes recorded transcripts. Keep the default and expose `'auto'` (or a pin) as a flag. +Do not default a spawn-per-invocation CLI tool to `'auto'`. On stdio, a legacy server that never answers unknown pre-`initialize` requests stalls `connect()` for the full probe timeout before falling back, and the probe spawns an extra short-lived server process per connect. Keep the default and expose `'auto'` (or a pin) as a flag. ::: ## Serve both eras from one entry point diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7871217abb..1fc325b7e4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,6 +1,7 @@ --- shape: reference --- + # Troubleshooting Each heading on this page is the verbatim error message. Match yours, then apply that entry's fix. @@ -66,7 +67,14 @@ With the global in place the [client OAuth](./clients/oauth.md) flows run unchan ## `SdkError: ERA_NEGOTIATION_FAILED` -`connect()` found no **protocol era** both sides speak. Two shapes produce it: `versionNegotiation: { mode: { pin: ... } }` names a revision the server does not offer over `server/discover`, and pinning never falls back; or `mode: 'auto'` with a `supportedProtocolVersions` list that has no pre-2026 entry, which removes the legacy fallback. +`connect()` found no **protocol era** both sides speak, or the negotiation probe was cut short. Match the message tail: + +- `the server did not offer pinned protocol version ... via server/discover (no fallback in pin mode)` — the pin names a revision the server does not offer, and pinning never falls back: drop the pin or use `'auto'`. +- `the connection closed during the server/discover probe before the server offered pinned protocol version ...` — same pin, but the server exited on the probe (an exit-on-probe legacy server): use `'auto'`. +- `the server gave no modern evidence and this client supports no pre-2026-07-28 protocol version to fall back to` — or its `the connection closed during the server/discover probe and this client supports no ...` variant — `mode: 'auto'` with a modern-only `supportedProtocolVersions` list removes the legacy fallback: restore a pre-2026 entry. +- `the connection closed during the server/discover probe (this transport probed in place — the disposable sibling probe requires the SDK's base StdioClientTransport)` — a subclass of `StdioClientTransport`, or a custom stdio-shaped transport, probed in place and met a server that exits on any pre-`initialize` request: use the base `StdioClientTransport` (which probes on a disposable sibling), or `mode: 'legacy'`. +- `the transport was closed during the server/discover probe` — the caller closed the transport while the probe was in flight; the connect aborted deliberately and the session child was never spawned. +- `Version negotiation probe failed: ...` — the probe hit a transport failure (network outage, HTTP connection drop): fix connectivity and retry. The pinned shape — `transport` here reaches a server still on the 2025 revisions ([Test a server](./testing.md) shows the in-memory wiring these outputs come from): @@ -87,7 +95,7 @@ The rejection names the pinned revision the server never offered: ERA_NEGOTIATION_FAILED: Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via server/discover (no fallback in pin mode) ``` -Change the mode to `'auto'`: the probe falls back to the 2025 `initialize` handshake on the same connection. +Change the mode to `'auto'`: the probe falls back to the 2025 `initialize` handshake. ```ts source="../examples/guides/troubleshooting.examples.ts#connect_autoFallback" const negotiated = new Client({ name: 'app', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index b2dfd99e17..0b386a63e8 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -84,7 +84,15 @@ import type { PriorDiscovery } from './probeClassifier'; import type { CacheMode, CacheScope, ResponseCacheStore } from './responseCache'; import { ClientResponseCache, InMemoryResponseCacheStore, MAX_CACHE_TTL_MS } from './responseCache'; import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation'; -import { detectProbeEnvironment, detectProbeTransportKind, negotiateEra, resolveVersionNegotiation } from './versionNegotiation'; +import { + detectProbeEnvironment, + detectProbeTransportKind, + disarmSpentCloseGuard, + negotiateEra, + negotiateStdioViaSibling, + readStdioServerParams, + resolveVersionNegotiation +} from './versionNegotiation'; /** * The server identity a `DiscoverResult` carries in @@ -199,11 +207,16 @@ export type ClientOptions = ProtocolOptions & { * - `mode: 'auto'` — `connect()` probes the server with `server/discover` first: * definitive modern evidence selects the modern era; definitive legacy signals * (and anything unrecognized) fall back to the plain legacy `initialize` - * handshake on the same connection, byte-equivalent to a 2025 client. A + * handshake, byte-equivalent to a 2025 client. On the SDK's own stdio + * transport (the base `StdioClientTransport` exactly; subclasses probe in + * place) the probe runs on a short-lived sibling process spawned from the + * same parameters (one extra spawn per connect; its stderr is discarded) and + * the caller's transport starts once, after the era is known; HTTP — and + * custom or subclassed stdio-shaped transports — probe on the connection itself. A * network outage rejects with a typed connect error. A probe timeout is * transport-aware: on stdio it indicates a legacy server (some legacy servers * never answer unknown pre-`initialize` requests) and falls back to - * `initialize` on the same stream; on HTTP it rejects with a typed timeout + * `initialize`; on HTTP it rejects with a typed timeout * error (silence on a deployed server is an outage, not a legacy signal). * - `mode: { pin: '2026-07-28' }` — modern era at exactly the pinned revision; * no probe-and-fallback: anything else fails loudly. @@ -1014,8 +1027,9 @@ export class Client extends Protocol { /** * The 2025 `initialize` handshake — the body of the plain legacy connect and - * the `'auto'`-mode fallback path (same connection, same `initialize` body, - * zero 2026 headers). Callers clear the negotiated protocol version before + * the `'auto'`-mode fallback path (same `initialize` body, zero 2026 headers; + * on the stdio sibling path it opens the session child's fresh pipe, in the + * in-place modes it rides the probed connection). Callers clear the negotiated protocol version before * the handshake; its completion sets the negotiated (legacy) version. */ private async _legacyHandshake(transport: Transport, options?: RequestOptions): Promise { @@ -1087,8 +1101,9 @@ export class Client extends Protocol { /** * Negotiated connect (mode `'auto'` or `{ pin }`): probe with `server/discover` - * before the Protocol machinery attaches, then either establish the modern era - * or perform the plain legacy handshake on the same connection. + * before the Protocol machinery attaches — on a disposable sibling process for + * the SDK's stdio transport, in place otherwise — then either establish the + * modern era or perform the plain legacy handshake. */ private async _connectNegotiated( transport: Transport, @@ -1112,25 +1127,48 @@ export class Client extends Protocol { let result: Awaited>; try { - result = await negotiateEra(negotiation, { - transport, + const transportKind = detectProbeTransportKind(transport); + const baseDeps = { clientInfo: this._clientInfo, capabilities: this._capabilities, environment: detectProbeEnvironment(), - transportKind: detectProbeTransportKind(transport), defaultTimeoutMs: options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC - }); + }; + // The SDK's stdio transport probes on a disposable sibling process, + // so the caller's transport spends its one child life on the + // session, never on the probe. Stdio-shaped transports without + // readable spawn parameters (and HTTP) probe in place, as before. + const stdioParams = transportKind === 'stdio' ? readStdioServerParams(transport) : undefined; + result = + stdioParams === undefined + ? await negotiateEra(negotiation, { ...baseDeps, transport, transportKind }) + : await negotiateStdioViaSibling(negotiation, transport, stdioParams, baseDeps); } catch (error) { // Typed connect error — close the channel like a failed initialize does. await transport.close().catch(() => {}); + // The cleanup close has settled: any re-delivery of a spent + // mid-probe close has happened by now, so the spent-close guard + // must not outlive it — on transports whose close() never re-fires + // onclose (stdio), an armed guard would swallow the next GENUINE + // close after a restart. + disarmSpentCloseGuard(transport); throw error; } + // Success path: no cleanup close ever runs here, so no re-delivery of a + // spent mid-window close is coming — disarm any guard NOW, before + // Protocol chains the handler slot, or an in-place negotiation that + // succeeded after a same-tick reply-then-close would carry the armed + // skip into the live session and swallow its first genuine close. + disarmSpentCloseGuard(transport); + await super.connect(transport); if (result.era === 'legacy') { - // Conservative fallback: the plain legacy handshake on the SAME - // connection (the probe never touched the transport version slot). + // Conservative fallback: the in-place probing modes run the plain + // legacy handshake on the probed connection; the sibling path runs + // it as the session child's first exchange (the probe never touched + // the transport version slot either way). await this._legacyHandshake(transport, options); return; } diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index 1b78744964..863770acaf 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -2,7 +2,9 @@ * Probe outcome classifier (pure module): maps the outcome of the connect-time * `server/discover` probe onto one of four verdicts — modern era, the * spec-mandated `-32022` corrective continuation, legacy fallback (the plain - * 2025 `initialize` handshake on the same connection), or a typed connect error. + * 2025 `initialize` handshake — on the probed connection for in-place probes, + * on the session child's fresh pipe when the probe rode a disposable + * sibling), or a typed connect error. * * The classifier is deliberately conservative: anything it does not positively * recognize as modern resolves to the legacy fallback, and a network outage is a @@ -28,10 +30,11 @@ import { export type ProbeEnvironment = 'node' | 'browser'; /** - * The transport class the probe ran on. Only consulted for the timeout row: a - * stdio probe that times out signals a legacy server, while an HTTP timeout - * stays a typed error. Anything that is not the stdio child-process transport - * is treated like HTTP. + * The transport class the probe ran on. Consulted for the timeout and closed + * rows: a stdio probe that times out — or whose child exits, closing the + * connection — signals a legacy server, while an HTTP timeout or close stays + * a typed error. Anything that is not the stdio child-process transport is + * treated like HTTP. */ export type ProbeTransportKind = 'stdio' | 'http'; @@ -48,6 +51,8 @@ export type ProbeOutcome = | { kind: 'network-error'; error: unknown } /** The transport's auth flow challenged during the probe send (`UnauthorizedError`). */ | { kind: 'auth-required'; error: Error } + /** The transport reported close while the probe awaited its reply. */ + | { kind: 'closed' } /** No response arrived within the probe timeout. */ | { kind: 'timeout'; timeoutMs: number }; @@ -82,7 +87,7 @@ export type ProbeVerdict = * arms a loop guard on the second rejection, throwing `error`. */ | { kind: 'corrective'; version: string; error: UnsupportedProtocolVersionError } - /** Definitive legacy signal or unrecognized shape: perform the plain legacy `initialize` handshake on the same connection. */ + /** Definitive legacy signal or unrecognized shape: perform the plain legacy `initialize` handshake (on the probed connection in the in-place modes; on the session child's fresh pipe on the sibling path). */ | { kind: 'legacy' } /** Typed connect error — never converted to an era verdict. */ | { kind: 'error'; error: Error }; @@ -141,11 +146,24 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi // handshake an auth-gated modern server as legacy. return { kind: 'error', error: outcome.error }; } + case 'closed': { + if (context.transportKind === 'stdio') { + // A stdio child that exits on the unrecognized probe instead of + // answering is a legacy signal — the same backward-compatibility + // rule as the timeout row below (SDKs like the official Rust one + // terminate the server on ANY pre-initialize request). + return { kind: 'legacy' }; + } + // On HTTP a mid-probe close is an ambiguous network condition — the + // same typed connect error as any probe transport failure. + return classifyNetworkError(new Error('Connection closed during the version negotiation probe'), context); + } case 'timeout': { if (context.transportKind === 'stdio') { // Per the stdio transport's backward-compatibility rule, a probe // nobody answers within the timeout indicates a legacy server — - // fall back to `initialize` on the same stream. + // fall back to `initialize` (the sibling path runs it on the + // session child's fresh pipe; in-place probes reuse the stream). return { kind: 'legacy' }; } // On HTTP a deployed server answers, so silence is an outage, not a @@ -177,8 +195,8 @@ function classifyResult(result: unknown, context: ProbeClassifierContext): Probe if (overlap !== undefined) { return { kind: 'modern', version: overlap, discover: parsed.value }; } - // A DiscoverResult with no overlap still drives era selection: initialize on - // the same connection when fallback is possible, otherwise a typed error. + // A DiscoverResult with no overlap still drives era selection: the legacy + // `initialize` fallback when available, otherwise a typed error. if (context.fallbackAvailable) { return { kind: 'legacy' }; } diff --git a/packages/client/src/client/stdio.ts b/packages/client/src/client/stdio.ts index 29b0027eae..a4664e1c93 100644 --- a/packages/client/src/client/stdio.ts +++ b/packages/client/src/client/stdio.ts @@ -216,6 +216,61 @@ export class StdioClientTransport implements Transport { } } + /** + * Reap a disposable probe sibling (see the version-negotiation sibling + * flow): signal-first teardown awaiting process `exit` — never the `close` + * event, so a helper process holding the child's stdio pipes can never + * block disposal. Not part of the public transport lifecycle. + * + * @internal + */ + private async _dispose(): Promise { + const proc = this._process; + this._process = undefined; + if (proc && proc.exitCode === null && proc.signalCode === null) { + const exited = new Promise(resolve => proc.once('exit', () => resolve())); + try { + proc.stdin?.end(); + } catch { + // ignore + } + try { + proc.kill('SIGTERM'); + } catch { + // ignore + } + await Promise.race([exited, new Promise(resolve => setTimeout(resolve, 1000).unref())]); + if (proc.exitCode === null && proc.signalCode === null) { + try { + proc.kill('SIGKILL'); + } catch { + // ignore + } + } + await exited; + } + // The child is gone — release the PARENT-side pipe handles too. A helper + // process holding the inherited write ends would otherwise keep them (and + // with them the host's event loop: stdout carries a flowing 'data' + // listener from start()) alive until the helper exits. + try { + proc?.stdout?.destroy(); + } catch { + // ignore + } + try { + proc?.stdin?.destroy(); + } catch { + // ignore + } + try { + proc?.stderr?.destroy(); + } catch { + // ignore + } + this._readBuffer.clear(); + } + async close(): Promise { if (this._process) { const processToClose = this._process; diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index 628d5d7bc1..818ca2bb0d 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -41,9 +41,11 @@ export interface VersionNegotiationProbeOptions { * * The timeout verdict is transport-aware: on stdio, a probe that gets no * response within the timeout indicates a legacy server and falls back to - * the `initialize` handshake on the same stream; on HTTP, where a deployed - * server answers and silence means an outage, `connect()` rejects with the - * standard typed timeout error instead. + * the `initialize` handshake (measured on the disposable sibling for the + * SDK's own stdio transport, with the fallback running on the session + * child's fresh pipe; in place for custom stdio-shaped transports); on + * HTTP, where a deployed server answers and silence means an outage, + * `connect()` rejects with the standard typed timeout error instead. * * @default the standard request timeout (`DEFAULT_REQUEST_TIMEOUT_MSEC`, or the `timeout` passed to `connect()`) */ @@ -67,11 +69,19 @@ export interface VersionNegotiationProbeOptions { * - `'legacy'` — no negotiation: the plain 2025 connect sequence, byte-identical * to a client without this option. * - `'auto'` — probe with `server/discover` at connect; conservative fallback to - * the plain legacy `initialize` handshake on the same connection unless the - * outcome is definitive modern evidence. Network outage rejects with a typed - * connect error; a probe timeout falls back to `initialize` on stdio (a silent - * server on a local pipe is a legacy server) and rejects with a typed timeout - * error on HTTP (silence there is an outage). + * the plain legacy `initialize` handshake unless the outcome is definitive + * modern evidence. Network outage rejects with a typed connect error; a + * probe timeout falls back to `initialize` on stdio (a silent server on a + * local pipe is a legacy server) and rejects with a typed timeout error on + * HTTP (silence there is an outage). On the SDK's stdio transport (the base + * `StdioClientTransport` exactly; subclasses probe in place) the probe + * runs on a short-lived sibling process spawned from the same parameters + * (its stderr is discarded) and the caller's transport starts once, after + * the era is known — so a child that exits on the unrecognized probe, the + * shape of servers built on SDKs that terminate on any pre-`initialize` + * request, is simply a legacy server. A mid-probe connection close on HTTP + * (or on a custom stdio-shaped transport, which probes in place) rejects + * with the typed connect error. * - `{ pin: '' }` — modern era at exactly the pinned revision: the * connect-time `server/discover` must offer it. No fallback — anything else * fails loudly with a typed error. @@ -149,7 +159,7 @@ export function detectProbeEnvironment(): ProbeEnvironment { } /** - * Detect the transport class for the transport-aware timeout verdict (see + * Detect the transport class for the transport-aware timeout and closed verdicts (see * {@linkcode ProbeTransportKind}). The stdio child-process transport is * recognized structurally (`stderr`/`pid` accessors, no `instanceof` — safe * across bundles); everything else is treated like HTTP. @@ -269,26 +279,78 @@ class ProbeWindow { this._pending = undefined; this._transport.onmessage = this._savedOnMessage; this._transport.onerror = this._savedOnError; - this._transport.onclose = this._closeDelivered ? undefined : this._savedOnClose; + if (this._closeDelivered && this._savedOnClose !== undefined) { + // The forwarded mid-window close is spent — the caller's cleanup + // close() must not re-deliver it — but the slot must not be emptied + // either: the caller still owns the transport, and its observer must + // keep seeing later closes. The skip is scoped to the cleanup close + // via disarmSpentCloseGuard(): transports whose close() re-fires + // onclose (HTTP) consume it there; transports that never re-fire + // (stdio: onclose comes only from the child's close event, which + // already happened) would otherwise leave it armed to swallow the + // NEXT genuine close after a restart. + const saved = this._savedOnClose; + const transport = this._transport; + let spent = false; + const wrapper = () => { + if (!spent) { + spent = true; + return; + } + saved(); + }; + transport.onclose = wrapper; + pendingSpentCloseGuards.set(transport, () => { + // Restore by identity — never clobber a handler the caller + // replaced in the meantime. + if (transport.onclose === wrapper) { + transport.onclose = saved; + } + }); + } else { + this._transport.onclose = this._savedOnClose; + } } /** Detach the handlers and arm the one-shot `start()` pass-through for the `Protocol.connect()` handover. */ release(): void { this.detach(); const transport = this._transport; - const originalStart = transport.start.bind(transport); + // Save the raw property value (not a bound copy) so the restore below + // returns `transport.start` to its original identity — no wrapper or + // bind layer accretes across repeated connects on the same transport. + const originalStart = transport.start; let armed = true; - transport.start = async (): Promise => { + transport.start = async function (this: unknown): Promise { if (armed) { armed = false; transport.start = originalStart; return; } - return originalStart(); + return originalStart.call(transport); }; } } +/** + * Spent-close guards from failed negotiations, pending disarm by the cleanup + * site (keyed weakly — an abandoned transport carries its guard to GC). + */ +const pendingSpentCloseGuards = new WeakMap void>(); + +/** + * Disarm a failed negotiation's spent-close guard once the cleanup `close()` + * has settled. Any re-delivery of the spent mid-window close happens DURING + * that close (HTTP transports re-fire `onclose` there; stdio transports never + * do — their close event already fired), so after it every close is genuine + * and must reach the caller's pre-set observer. + */ +export function disarmSpentCloseGuard(transport: Transport): void { + const disarm = pendingSpentCloseGuards.get(transport); + pendingSpentCloseGuards.delete(transport); + disarm?.(); +} + /** Build the probe request: `server/discover` carrying the full per-request `_meta` envelope. */ export function buildProbeRequest( id: string, @@ -339,7 +401,9 @@ function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome { return { kind: 'network-error', error }; } case 'closed': { - return { kind: 'network-error', error: new Error('Connection closed during the version negotiation probe') }; + // Not folded into network-error: the classifier's closed row is + // transport-aware (stdio legacy signal vs typed error). + return { kind: 'closed' }; } case 'timeout': { return { kind: 'timeout', timeoutMs }; @@ -352,10 +416,18 @@ export interface NegotiationDeps { clientInfo: Implementation; capabilities: ClientCapabilities; environment: ProbeEnvironment; - /** The transport class, for the transport-aware timeout verdict (see {@linkcode ProbeTransportKind}). */ + /** The transport class, for the transport-aware timeout and closed verdicts (see {@linkcode ProbeTransportKind}). */ transportKind: ProbeTransportKind; /** The standard request timeout for this connect (probe inherits it unless `probe.timeoutMs` overrides). */ defaultTimeoutMs: number; + /** + * The probe transport is a disposable sibling (see + * {@linkcode negotiateStdioViaSibling}): a mid-probe close is ordinary + * legacy evidence — the probe child spent itself — never a connect-fatal + * condition. Absent for in-place probes, where a closed connection has no + * fallback stream and stays a typed error. + */ + disposableProbe?: boolean; } export type NegotiationResult = { era: 'modern'; version: string; discover: DiscoverResult } | { era: 'legacy' }; @@ -421,11 +493,18 @@ export async function negotiateEra( continue; } case 'legacy': { + // A closed outcome carries its own cause — diagnostics must + // name the close instead of implying a server/discover + // verdict that never happened. + const closedCause = outcome.kind === 'closed' ? 'the connection closed during the server/discover probe' : undefined; if (negotiation.kind === 'pin') { throw new SdkError( SdkErrorCode.EraNegotiationFailed, - `Version negotiation failed: the server did not offer pinned protocol version ${negotiation.version} ` + - `via server/discover (no fallback in pin mode)` + closedCause === undefined + ? `Version negotiation failed: the server did not offer pinned protocol version ${negotiation.version} ` + + `via server/discover (no fallback in pin mode)` + : `Version negotiation failed: ${closedCause} before the server offered ` + + `pinned protocol version ${negotiation.version} (no fallback in pin mode)` ); } if (!negotiation.fallbackAvailable) { @@ -433,8 +512,22 @@ export async function negotiateEra( // unavailable and must never carry a 2026-era version string. throw new SdkError( SdkErrorCode.EraNegotiationFailed, - 'Version negotiation failed: the server gave no modern evidence and this client supports no ' + - 'pre-2026-07-28 protocol version to fall back to' + closedCause === undefined + ? 'Version negotiation failed: the server gave no modern evidence and this client supports no ' + + 'pre-2026-07-28 protocol version to fall back to' + : `Version negotiation failed: ${closedCause} and this client supports no ` + + 'pre-2026-07-28 protocol version to fall back to' + ); + } + if (closedCause !== undefined && deps.disposableProbe !== true) { + // In-place probe: the connection died with the child and + // there is no disposable sibling to spend — the + // same-stream initialize fallback is impossible, so this + // stays a typed connect error. + throw new SdkError( + SdkErrorCode.EraNegotiationFailed, + `Version negotiation failed: ${closedCause} ` + + "(this transport probed in place — the disposable sibling probe requires the SDK's base StdioClientTransport)" ); } return { era: 'legacy' }; @@ -458,3 +551,109 @@ export async function negotiateEra( window.release(); return result; } + +/** + * Structural read of the SDK stdio transport's retained spawn parameters — + * `undefined` for stdio-shaped transports that do not expose them, and for + * SUBCLASSES of the SDK transport (those probe in place, where a mid-probe + * close stays a typed connect error). The sibling path requires the transport + * to be exactly the base class: a subclass cannot be faithfully cloned by + * re-invoking its constructor with the retained params alone (extra ctor + * arguments, transformed state, side effects). Exactness is checked without + * importing the class (the negotiation graph must stay runtime-neutral): only + * the base class's own prototype carries the internal `_dispose` reaper the + * sibling flow depends on — a subclass's prototype does not own it. + */ +export function readStdioServerParams(transport: Transport): Record | undefined { + const proto = Object.getPrototypeOf(transport) as object | null; + if (proto === null || !Object.prototype.hasOwnProperty.call(proto, '_dispose')) { + return undefined; + } + const params = (transport as { _serverParams?: unknown })._serverParams; + return typeof params === 'object' && params !== null && typeof (params as { command?: unknown }).command === 'string' + ? (params as Record) + : undefined; +} + +/** + * stdio era negotiation on a DISPOSABLE SIBLING: stdio servers built on SDKs + * that terminate on any pre-`initialize` request exit when the probe arrives, + * so the probe must not spend the caller's one process life. `server/discover` + * runs on a short-lived sibling spawned from the same parameters; the caller's + * transport starts exactly once, after the era is known. The sibling is + * invisible infrastructure: its stderr is discarded, and it is reaped before + * this resolves (signal escalation awaiting process exit — a helper holding + * its pipes never blocks disposal). A caller `close()` on the session + * transport during the probe aborts the connect with the typed negotiation + * error, and the session transport is never started. + */ +export async function negotiateStdioViaSibling( + negotiation: Extract, + sessionTransport: Transport, + params: Record, + deps: Omit +): Promise { + const SiblingTransport = sessionTransport.constructor as new (params: Record) => Transport; + const sibling = new SiblingTransport({ ...params, stderr: 'ignore' }); + + // The session transport is unstarted while the sibling probes, and closing + // an unstarted transport records nothing — so watch its close() for the + // window's duration (raw property saved; identity restored in the finally). + // The abort races the probe: a caller close rejects promptly instead of + // waiting out the probe timeout. + const originalClose = sessionTransport.close; + let callerClosed = false; + let signalClosed: (() => void) | undefined; + const closedSignal = new Promise((_, reject) => { + signalClosed = () => reject(callerCloseAbortError()); + }); + sessionTransport.close = async function (): Promise { + callerClosed = true; + signalClosed?.(); + return originalClose.call(sessionTransport); + }; + + let result: NegotiationResult; + try { + const negotiated = negotiateEra(negotiation, { + ...deps, + transport: sibling, + transportKind: 'stdio', + disposableProbe: true + }); + // The abort may orphan the probe promise; its late settlement (the + // disposed sibling's close, a timeout) must not surface anywhere. + negotiated.catch(() => {}); + result = await Promise.race([negotiated, closedSignal]); + } finally { + // Dispose FIRST, with the close watch still armed: a caller close() + // landing during the disposal window must still trip the abort below — + // only once the sibling is reaped does the caller get its close back. + await disposeSibling(sibling); + sessionTransport.close = originalClose; + } + if (callerClosed) { + // Checked AFTER the finally so a close during either the probe or the + // disposal window aborts: the session transport is never started. + throw callerCloseAbortError(); + } + return result; +} + +/** The typed abort for a caller `close()` landing while the sibling probes. */ +function callerCloseAbortError(): SdkError { + return new SdkError( + SdkErrorCode.EraNegotiationFailed, + 'Version negotiation failed: the transport was closed during the server/discover probe' + ); +} + +/** Reap a probe sibling — best-effort; a sibling that cannot be reaped must not turn a settled verdict into an error. */ +async function disposeSibling(sibling: Transport): Promise { + try { + const dispose = (sibling as { _dispose?: () => Promise })._dispose; + await (typeof dispose === 'function' ? dispose.call(sibling) : sibling.close()); + } catch { + // ignore + } +} diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts index 566a69baad..3a65f240b7 100644 --- a/packages/client/test/client/probeClassifier.test.ts +++ b/packages/client/test/client/probeClassifier.test.ts @@ -62,7 +62,7 @@ describe('row: DiscoverResult with version overlap → modern, select from suppo }); }); -describe('row: DiscoverResult with NO overlap → initialize on the same connection, else typed error with synthesized data', () => { +describe('row: DiscoverResult with NO overlap → legacy initialize fallback, else typed error with synthesized data', () => { test('fallback possible → legacy (era selection on a dual-era server)', () => { const verdict = classify({ kind: 'result', result: discoverResult(['2027-12-31']) }); expect(verdict).toEqual({ kind: 'legacy' }); @@ -288,11 +288,27 @@ describe('row: timeout — transport-aware verdict', () => { } }); - test('stdio: timeout is a legacy-server signal → fall back to initialize on the same stream', () => { + test('stdio: timeout is a legacy-server signal → legacy initialize fallback', () => { expect(classify({ kind: 'timeout', timeoutMs: 5_000 }, { transportKind: 'stdio' })).toEqual({ kind: 'legacy' }); }); }); +describe('row: closed — transport-aware verdict, symmetric with the timeout row', () => { + test('stdio: a child that exits on the unrecognized probe is a legacy signal → legacy verdict', () => { + expect(classify({ kind: 'closed' }, { transportKind: 'stdio' })).toEqual({ kind: 'legacy' }); + }); + + test('HTTP: a mid-probe close is ambiguous — the same typed connect error as any probe network failure', () => { + const verdict = classify({ kind: 'closed' }, { transportKind: 'http' }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error).toBeInstanceOf(SdkError); + expect((verdict.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect(verdict.error.message).toContain('Connection closed during the version negotiation probe'); + } + }); +}); + describe('row: browser opaque CORS/preflight TypeError, PROBE PHASE ONLY → legacy fallback (F-7)', () => { test('browser environment + bare TypeError → legacy', () => { expect(classify({ kind: 'network-error', error: new TypeError('Failed to fetch') }, { environment: 'browser' })).toEqual({ diff --git a/packages/client/test/client/probeFixtureCorpus.test.ts b/packages/client/test/client/probeFixtureCorpus.test.ts index 687ca3b11f..d74ed0b5ad 100644 --- a/packages/client/test/client/probeFixtureCorpus.test.ts +++ b/packages/client/test/client/probeFixtureCorpus.test.ts @@ -89,7 +89,7 @@ const CORPUS: CorpusRow[] = [ }, // --- T9 edge 3: probe success but no version overlap → initialize on the SAME connection. { - name: 'T9: DiscoverResult with no mutual version + fallback available → legacy (initialize on the same connection)', + name: 'T9: DiscoverResult with no mutual version + fallback available → legacy (initialize fallback)', outcome: { kind: 'result', result: { supportedVersions: ['2027-01-01'], capabilities: {} } diff --git a/packages/client/test/client/stdio.test.ts b/packages/client/test/client/stdio.test.ts index 594ad6dc63..315b8a2595 100644 --- a/packages/client/test/client/stdio.test.ts +++ b/packages/client/test/client/stdio.test.ts @@ -1,3 +1,6 @@ +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; + import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; import type { StdioServerParameters } from '../../src/client/stdio'; @@ -118,3 +121,32 @@ test('should fire onerror and close when ReadBuffer overflows', async () => { expect(error.message).toMatch(/ReadBuffer exceeded maximum size/); await closed; }); + +test('_dispose releases the parent-side pipe handles even when a helper process holds the child stdio', async () => { + // The rmcp-holding anatomy: the child exits, but a helper it spawned with + // stdio: 'inherit' keeps the pipe write ends open. Awaiting 'exit' settles + // disposal promptly — but without destroying the PARENT-side handles, the + // flowing stdout read handle stays ref'd until the helper exits, pinning + // the host's event loop (indefinitely for a daemon helper). + const readyFile = `${tmpdir()}/mcp-dispose-ready-${process.pid}-${Date.now()}`; + const HOLDING_SCRIPT = String.raw` + const { spawn } = require('child_process'); + spawn(process.execPath, ['-e', 'setTimeout(() => {}, 3000)'], { stdio: 'inherit' }); + require('fs').writeFileSync(${JSON.stringify(readyFile)}, 'ready'); + process.stdin.on('end', () => process.exit(0)); + process.stdin.resume(); + `; + const transport = new StdioClientTransport({ command: process.execPath, args: ['-e', HOLDING_SCRIPT] }); + await transport.start(); + // Wait until the grandchild actually exists and holds the inherited pipes + // — disposing earlier would kill the child before its script even runs. + while (!existsSync(readyFile)) await new Promise(resolve => setTimeout(resolve, 25)); + const proc = (transport as unknown as { _process: import('node:child_process').ChildProcess })._process; + + await (transport as unknown as { _dispose: () => Promise })._dispose(); + + // The child is confirmed gone AND the parent-side handles are released — + // destroyed flags are the deterministic proxy for "nothing pins the loop". + expect(proc.stdout?.destroyed).toBe(true); + expect(proc.stdin?.destroyed).toBe(true); +}, 10_000); diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts index f45d028835..e197344b90 100644 --- a/packages/client/test/client/versionNegotiation.test.ts +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -438,6 +438,342 @@ describe('probe timeout policy (transport-aware)', () => { }); }); +/* ------------------------------------------------------------------------- * + * Probe close policy. The SDK's stdio transport probes on a DISPOSABLE + * SIBLING spawned from the same `_serverParams` — servers built on SDKs that + * terminate on any pre-initialize request (the official Rust SDK, rmcp) exit + * when the probe arrives, so the probe must not spend the caller's one child + * life. The session transport starts exactly once, after the era is known. + * Stdio-shaped transports without readable params probe in place, where a + * mid-probe close stays a typed error; HTTP closes stay typed errors. + * ------------------------------------------------------------------------- */ + +describe('stdio sibling probe (disposable probe transport)', () => { + /** + * An SDK-shaped stdio transport: retains `_serverParams` (the sibling + * seam), and scripts its "child" from `params.behavior` — 'rmcp' exits on + * any pre-initialize request, 'modern' answers server/discover, 'silent' + * never replies. Mirrors StdioClientTransport's surface, including the + * internal `_dispose` reaper (recorded, for the leak pins). + */ + class FakeStdioTransport implements Transport { + static instances: FakeStdioTransport[] = []; + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + sessionId?: string; + + readonly _serverParams: Record; + startCalls = 0; + sent: JSONRPCMessage[] = []; + setProtocolVersionCalls: string[] = []; + disposed = false; + private _alive = false; + private _initialized = false; + + constructor(params: Record) { + this._serverParams = params; + FakeStdioTransport.instances.push(this); + } + + get stderr(): null { + return null; + } + get pid(): number | null { + return this._alive ? 100 + FakeStdioTransport.instances.indexOf(this) : null; + } + + async start(): Promise { + if (this._alive) throw new Error('FakeStdioTransport already started!'); + if (this._serverParams.behavior === 'spawnfail') throw new Error('spawn ENOENT'); + this._alive = true; + this._initialized = false; + this.startCalls++; + } + + async send(message: JSONRPCMessage): Promise { + if (!this._alive) throw new Error('Not connected'); + this.sent.push(message); + queueMicrotask(() => { + if (!this._alive || !isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + this._initialized = true; + this.onmessage?.({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: '2025-03-26', + capabilities: {}, + serverInfo: { name: 'fake-stdio-server', version: '1.0.0' } + } + }); + } else if (message.method === 'server/discover' && this._serverParams.behavior === 'modern') { + this.onmessage?.({ jsonrpc: '2.0', id: message.id, result: discoverResult([MODERN]) }); + } else if (!this._initialized && this._serverParams.behavior === 'rmcp') { + // The rmcp shape: any other pre-initialize request kills the + // child — no reply, just the close. + this._alive = false; + this.onclose?.(); + } + // 'silent': no reply at all. + }); + } + + async close(): Promise { + if (this._alive) { + this._alive = false; + this.onclose?.(); + } + } + + /** Test hook: observe the moment disposal begins (reset after use). */ + static onDisposeStart: (() => void) | undefined; + + // Called structurally by the sibling reaper, like the real transport's. + private async _dispose(): Promise { + FakeStdioTransport.onDisposeStart?.(); + this.disposed = true; + this._alive = false; + } + + setProtocolVersion(version: string): void { + this.setProtocolVersionCalls.push(version); + } + } + + const freshInstances = () => { + FakeStdioTransport.instances = []; + }; + + test('rmcp exit-on-probe: the sibling spends itself, the session connects legacy on its first and only spawn', async () => { + freshInstances(); + const session = new FakeStdioTransport({ command: 'srv', behavior: 'rmcp' }); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await client.connect(session); + + expect(FakeStdioTransport.instances).toHaveLength(2); + const sibling = FakeStdioTransport.instances[1]!; + // The sibling carries the same params with stderr discarded, took the + // probe (and only the probe), and was reaped. + expect(sibling._serverParams).toMatchObject({ command: 'srv', stderr: 'ignore' }); + expect(requests(sibling.sent).map(r => r.method)).toEqual(['server/discover']); + expect(sibling.disposed).toBe(true); + // The session transport: one spawn, no probe bytes, plain legacy connect. + expect(session.startCalls).toBe(1); + expect(requests(session.sent).map(r => r.method)).toEqual(['initialize']); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-03-26'); + expect(session.setProtocolVersionCalls).toEqual(['2025-03-26']); + + await client.close(); + }); + + test("the session's traffic is byte-identical to a plain mode:'legacy' connect", async () => { + freshInstances(); + const autoSession = new FakeStdioTransport({ command: 'srv', behavior: 'rmcp' }); + const autoClient = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await autoClient.connect(autoSession); + + const plainSession = new FakeStdioTransport({ command: 'srv', behavior: 'rmcp' }); + const plainClient = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'legacy' } }); + await plainClient.connect(plainSession); + + expect(JSON.stringify(autoSession.sent)).toBe(JSON.stringify(plainSession.sent)); + // Explicit legacy mode never probes and never spawns a sibling. + expect(FakeStdioTransport.instances).toHaveLength(3); + expect(plainSession.startCalls).toBe(1); + + await autoClient.close(); + await plainClient.close(); + }); + + test('modern server: the verdict is adopted verbatim — the session wire carries NO server/discover and no initialize', async () => { + freshInstances(); + const session = new FakeStdioTransport({ command: 'srv', behavior: 'modern' }); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await client.connect(session); + + expect(FakeStdioTransport.instances).toHaveLength(2); + expect(FakeStdioTransport.instances[1]!.disposed).toBe(true); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(client.getServerVersion()?.name).toBe('scripted-modern-server'); + expect(session.startCalls).toBe(1); + // Byte-trace: the sibling probed; the session sent nothing at connect. + expect(requests(session.sent)).toHaveLength(0); + expect(session.setProtocolVersionCalls).toEqual([MODERN]); + + await client.close(); + }); + + test('caller close() mid-probe aborts promptly: typed error, the session transport is never started', async () => { + freshInstances(); + const session = new FakeStdioTransport({ command: 'srv', behavior: 'silent' }); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 30_000 } } }); + + const pending = client.connect(session); + pending.catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 0)); + await session.close(); + + const rejection = await pending.then( + () => undefined, + (error: unknown) => error + ); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((rejection as SdkError).message).toMatch(/transport was closed during the server\/discover probe/); + expect(session.startCalls).toBe(0); + expect(FakeStdioTransport.instances[1]!.disposed).toBe(true); + // The wrapper restored the caller's close by identity. + expect(session.close).toBe(FakeStdioTransport.prototype.close); + }); + + test('pin mode: exit-on-probe rejects naming the close — session never started', async () => { + freshInstances(); + const session = new FakeStdioTransport({ command: 'srv', behavior: 'rmcp' }); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + + const rejection = await client.connect(session).then( + () => undefined, + (error: unknown) => error + ); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((rejection as SdkError).message).toMatch( + /connection closed during the server\/discover probe before the server offered pinned/ + ); + expect(session.startCalls).toBe(0); + expect(FakeStdioTransport.instances[1]!.disposed).toBe(true); + }); + + test('modern-only client: exit-on-probe rejects naming the close — session never started', async () => { + freshInstances(); + const session = new FakeStdioTransport({ command: 'srv', behavior: 'rmcp' }); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN] } + ); + + const rejection = await client.connect(session).then( + () => undefined, + (error: unknown) => error + ); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((rejection as SdkError).message).toMatch(/connection closed during the server\/discover probe and this client supports no/); + expect(session.startCalls).toBe(0); + }); + + test('a sibling that cannot spawn propagates the raw spawn error (the session would fail identically)', async () => { + freshInstances(); + const session = new FakeStdioTransport({ command: 'missing', behavior: 'spawnfail' }); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await expect(client.connect(session)).rejects.toThrow(/spawn ENOENT/); + expect(session.startCalls).toBe(0); + }); + + test('a SUBCLASS of the SDK stdio transport probes in place: typed error naming the base-class requirement, no sibling spawned', async () => { + // A subclass cannot be faithfully cloned by re-invoking its constructor + // with the retained params alone — the gate is exact-class (only the + // base prototype owns the internal reaper), so subclasses keep the + // in-place behavior. + freshInstances(); + class SubclassedStdioTransport extends FakeStdioTransport {} + const transport = new SubclassedStdioTransport({ command: 'srv', behavior: 'rmcp' }); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + const rejection = await client.connect(transport).then( + () => undefined, + (error: unknown) => error + ); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((rejection as SdkError).message).toMatch(/sibling probe requires the SDK's base StdioClientTransport/); + // No sibling was constructed: the only instance is the caller's own, + // and it took the probe itself (in place). + expect(FakeStdioTransport.instances).toHaveLength(1); + expect(requests(transport.sent).map(r => r.method)).toEqual(['server/discover']); + }); + + test('a caller close() landing during sibling DISPOSAL still aborts: the session transport is never started', async () => { + // The close watch stays armed through disposal (the finally disposes + // BEFORE restoring close, and the abort is re-checked after it), so a + // shutdown racing the reap cannot be followed by a session spawn. + freshInstances(); + const session = new FakeStdioTransport({ command: 'srv', behavior: 'modern' }); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + let closedDuringDisposal = false; + FakeStdioTransport.onDisposeStart = () => { + if (!closedDuringDisposal) { + closedDuringDisposal = true; + void session.close(); + } + }; + try { + const rejection = await client.connect(session).then( + () => undefined, + (error: unknown) => error + ); + expect(closedDuringDisposal).toBe(true); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((rejection as SdkError).message).toMatch(/transport was closed during the server\/discover probe/); + expect(session.startCalls).toBe(0); + } finally { + FakeStdioTransport.onDisposeStart = undefined; + } + }); + + test('a stdio-shaped transport WITHOUT readable spawn params probes in place: a mid-probe close stays a typed error', async () => { + // Foreign transports have no sibling seam — exactly main's behavior. + class ForeignStdioTransport extends ScriptedTransport { + get stderr(): null { + return null; + } + get pid(): number { + return 4242; + } + } + const transport = new ForeignStdioTransport((message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.onclose?.(); + } + }); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + const rejection = await client.connect(transport).then( + () => undefined, + (error: unknown) => error + ); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((rejection as SdkError).message).toMatch(/connection closed during the server\/discover probe/); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + test('HTTP-class transport: close mid-probe rejects like any probe transport failure — never a legacy verdict, never a sibling', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.onclose?.(); + return; + } + legacyServerScript(message, t); + }; + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + expect(transport.startCalls).toBe(1); + }); +}); + /* ------------------------------------------------------------------------- * * -32022 corrective continuation — exactly once; loop guard on second * rejection. @@ -869,6 +1205,114 @@ describe('probe window preserves pre-set transport handlers', () => { await client.connect(transport).catch(() => undefined); expect(closes).toBe(1); + + // The spent-close guard was disarmed once the cleanup close settled + // (ScriptedTransport.close() re-fires onclose, like the real HTTP + // transport — the guard consumed exactly that re-delivery). A LATER + // close is genuine and must reach the pre-set observer directly. + transport.onclose?.(); + expect(closes).toBe(2); + }); + + test('a negotiation that SUCCEEDS after a same-tick reply-then-close keeps the observer armed for the live session', async () => { + // The success-path corner: the reply settles the exchange, then the + // close arrives in the same tick — forwarded with nothing pending, so + // negotiation still classifies the reply and succeeds. No cleanup + // close ever runs on success, so no re-delivery is coming: the guard + // must be disarmed before Protocol chains the handler slot, or the + // session's first genuine close would be swallowed. + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + t.onclose?.(); + return; + } + legacyServerScript(message, t); + }; + const transport = new ScriptedTransport(script); + let closes = 0; + transport.onclose = () => { + closes++; + }; + + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + // The mid-window close was forwarded exactly once. + expect(closes).toBe(1); + + // The session's own close is genuine and must reach the observer. + await client.close(); + expect(closes).toBe(2); + }); + + test("stdio-semantics transport (close() never re-fires onclose): a restarted life's genuine close reaches the pre-set observer after a failed negotiation", async () => { + // Real stdio semantics: onclose fires only from the child's own close + // event — the cleanup close() on a dead transport re-delivers NOTHING, + // so the spent-close guard cannot rely on that re-delivery to consume + // its skip. It is disarmed from the cleanup site instead, once the + // cleanup close has settled. Ordering note: a naive self-restoring + // wrapper would NOT fix this — with no re-delivery ever coming, the + // wrapper's FIRST invocation would already be the next genuine close, + // swallowed even as it restores the handler. + class NoRefireStdioShapedTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + sessionId?: string; + startCalls = 0; + sent: JSONRPCMessage[] = []; + private _alive = false; + get stderr(): null { + return null; + } + get pid(): number | null { + return this._alive ? 4242 : null; + } + async start(): Promise { + this._alive = true; + this.startCalls++; + } + async send(message: JSONRPCMessage): Promise { + if (!this._alive) throw new Error('Not connected'); + this.sent.push(message); + queueMicrotask(() => { + if (!this._alive || !isJSONRPCRequest(message)) return; + // Exits on the probe, like an rmcp child: the close event. + this.simulateChildClose(); + }); + } + async close(): Promise { + // stdio semantics: close() tears down but never re-fires + // onclose — the child's close event already delivered it. + this._alive = false; + } + /** The child's own close event — the only onclose source, like the real transport. */ + simulateChildClose(): void { + this._alive = false; + this.onclose?.(); + } + } + + const transport = new NoRefireStdioShapedTransport(); + let closes = 0; + transport.onclose = () => { + closes++; + }; + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + // No _serverParams: probes in place; the mid-probe close is a typed error. + await expect(client.connect(transport)).rejects.toThrow(/connection closed during the server\/discover probe/); + // The child's death mid-probe was forwarded to the observer exactly once + // (the cleanup close, per stdio semantics, re-delivered nothing). + expect(closes).toBe(1); + + // The caller restarts the transport; the new life's death is a GENUINE + // close and must reach the observer — an armed skip would swallow it. + await transport.start(); + transport.simulateChildClose(); + expect(closes).toBe(2); }); test('transport errors DURING the probe window are forwarded to the pre-set handler', async () => { diff --git a/test/e2e/scenarios/stdio-dual-era.test.ts b/test/e2e/scenarios/stdio-dual-era.test.ts index 259cf6ff0b..22e91d010f 100644 --- a/test/e2e/scenarios/stdio-dual-era.test.ts +++ b/test/e2e/scenarios/stdio-dual-era.test.ts @@ -53,9 +53,10 @@ verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion return; } - // Modern leg: the auto-negotiating client reaches 2026-07-28 via - // 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 + // Modern leg: the auto-negotiating client reaches 2026-07-28 via the + // disposable sibling probe (the session pipe carries neither initialize + // nor server/discover), the entry pins the connection to a 2026-era + // instance from the first enveloped request, and tools/call round-trips // with the per-request envelope. const sentMethods: string[] = []; const originalSend = transport.send.bind(transport); @@ -69,7 +70,7 @@ verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion await client.connect(transport); expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion); expect(sentMethods).not.toContain('initialize'); - expect(sentMethods[0]).toBe('server/discover'); + expect(sentMethods).not.toContain('server/discover'); const result = await client.callTool({ name: 'echo', arguments: { text: 'modern leg' } }); expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); diff --git a/test/integration/test/client/versionNegotiation.test.ts b/test/integration/test/client/versionNegotiation.test.ts index 2bd93eba93..809994e3dd 100644 --- a/test/integration/test/client/versionNegotiation.test.ts +++ b/test/integration/test/client/versionNegotiation.test.ts @@ -11,12 +11,16 @@ * Plus: structural fallback hygiene (the auto client's post-probe traffic is * byte-identical to a plain legacy client's, zero 2026 headers), the typed * connect errors for outage and HTTP timeout, and the stdio timeout fallback - * (a silent legacy stdio server is detected by the probe timing out and the - * client falls back to initialize on the same pipe). + * (a silent legacy stdio server is detected by the probe — riding the + * disposable sibling — timing out, and the client connects legacy with + * initialize on the session child's fresh pipe). */ import { randomUUID } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; import type { Server } from 'node:http'; import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; @@ -24,7 +28,7 @@ import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { McpServer } from '@modelcontextprotocol/server'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import * as z from 'zod/v4'; /** A fetch wrapper recording every request our client puts on the wire (URL, headers, body) and the raw response (status, body). */ @@ -229,9 +233,10 @@ describe('typed connect errors (Q12) over real sockets', () => { describe('stdio: silent legacy server (probe timeout fallback)', () => { // The stdio transport's backward-compatibility rule: a probe that gets no // response within a reasonable timeout indicates a legacy server — some - // legacy servers do not respond to unknown pre-initialize requests at all - // — and the client falls back to initialize on the same pipe. (On HTTP, - // by contrast, a timeout stays a typed connect error; see the test above.) + // legacy servers do not respond to unknown pre-initialize requests at all. + // The probe times out on the disposable sibling; the client then connects + // legacy with initialize on the session child's fresh pipe. (On HTTP, by + // contrast, a timeout stays a typed connect error; see the test above.) const SILENT_LEGACY_SERVER_SCRIPT = String.raw` let buffer = ''; process.stdin.on('data', chunk => { @@ -267,7 +272,7 @@ describe('stdio: silent legacy server (probe timeout fallback)', () => { }); `; - it('auto mode: the probe times out, the client falls back to initialize on the same pipe and connects on the legacy era', async () => { + it("auto mode: the probe times out on the sibling and the client connects legacy — initialize on the session child's fresh pipe", async () => { const transport = new StdioClientTransport({ command: process.execPath, args: ['-e', SILENT_LEGACY_SERVER_SCRIPT] @@ -286,3 +291,218 @@ describe('stdio: silent legacy server (probe timeout fallback)', () => { } }, 15_000); }); + +describe('stdio: sibling probe (real children)', () => { + // The probe runs on a DISPOSABLE SIBLING spawned from the same parameters; + // the caller's transport spawns exactly once, after the era is known. Each + // fixture child appends " " lines to a log file, so spawn + // counts and per-child wire traffic are asserted from the file. + const logFile = () => path.join(tmpdir(), `mcp-sibling-probe-${randomUUID()}.log`); + const linesOf = (file: string): string[] => + existsSync(file) + ? readFileSync(file, 'utf8') + .split('\n') + .filter(l => l.trim() !== '') + : []; + const pidsOf = (file: string): number[] => [...new Set(linesOf(file).map(l => Number(l.split(' ')[0])))]; + const methodsOf = (file: string, pid: number): string[] => + linesOf(file) + .filter(l => l.startsWith(`${pid} `)) + .map(l => l.split(' ')[1]!) + .filter(m => m !== 'spawned'); + const isAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + + /** Line-delimited JSON-RPC child: logs every received method; `mode` picks the personality. */ + const fixture = (file: string, mode: 'rmcp' | 'modern' | 'silent' | 'rmcp-holding') => String.raw` + const fs = require('fs'); + const log = entry => fs.appendFileSync(${JSON.stringify(file)}, process.pid + ' ' + entry + '\n'); + log('spawned'); + const MODE = ${JSON.stringify(mode)}; + if (MODE === 'rmcp-holding') { + require('child_process').spawn(process.execPath, ['-e', 'setTimeout(() => {}, 2500)'], { stdio: 'inherit' }); + } + let initialized = false; + let buffer = ''; + const send = obj => process.stdout.write(JSON.stringify(obj) + '\n'); + process.stdin.on('data', chunk => { + buffer += chunk.toString(); + let index; + while ((index = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + if (line.trim() === '') continue; + let message; + try { + message = JSON.parse(line); + } catch { + continue; + } + if (message.method !== undefined) log(message.method); + if (MODE === 'silent') continue; + if (message.method === 'initialize') { + initialized = true; + send({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: message.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'sibling-fixture', version: '1.0.0' } + } + }); + } else if (message.method === 'server/discover' && MODE === 'modern') { + send({ + jsonrpc: '2.0', + id: message.id, + result: { + supportedVersions: ['2026-07-28'], + capabilities: { tools: {} }, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'sibling-fixture', version: '1.0.0' } } + } + }); + } else if (message.method === 'tools/call') { + // The 2026 wire requires resultType on results; the legacy wire has no such field. + const result = { content: [{ type: 'text', text: message.params.arguments.text }] }; + if (MODE === 'modern') result.resultType = 'complete'; + send({ jsonrpc: '2.0', id: message.id, result }); + } else if (!initialized && MODE !== 'modern' && message.id !== undefined) { + // The rmcp shape: exit on any unrecognized pre-initialize request. + process.exit(1); + } + } + }); + process.stdin.resume(); + `; + + const spawnFixture = (file: string, mode: 'rmcp' | 'modern' | 'silent' | 'rmcp-holding') => + new StdioClientTransport({ command: process.execPath, args: ['-e', fixture(file, mode)] }); + + it('rmcp exit-on-probe: sibling spends itself and is reaped; the session connects legacy on its only spawn and serves tools/call', async () => { + const file = logFile(); + const transport = spawnFixture(file, 'rmcp'); + const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + try { + await client.connect(transport); + + const result = await client.callTool({ name: 'echo', arguments: { text: 'sibling' } }); + expect(result.content).toEqual([{ type: 'text', text: 'sibling' }]); + + const pids = pidsOf(file); + expect(pids).toHaveLength(2); + const [siblingPid, sessionPid] = pids as [number, number]; + expect(sessionPid).toBe(transport.pid); + expect(methodsOf(file, siblingPid)).toEqual(['server/discover']); + expect(methodsOf(file, sessionPid)).not.toContain('server/discover'); + expect(methodsOf(file, sessionPid)).toContain('initialize'); + await vi.waitFor(() => expect(isAlive(siblingPid)).toBe(false)); + } finally { + await client.close(); + } + }, 15_000); + + it('modern server: the session adopts the sibling verdict — its wire carries neither server/discover nor initialize', async () => { + const file = logFile(); + const transport = spawnFixture(file, 'modern'); + const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + try { + await client.connect(transport); + expect(client.getNegotiatedProtocolVersion()).toBe('2026-07-28'); + expect(client.getServerVersion()?.name).toBe('sibling-fixture'); + + const result = await client.callTool({ name: 'echo', arguments: { text: 'modern' } }); + expect(result.content).toEqual([{ type: 'text', text: 'modern' }]); + + const pids = pidsOf(file); + expect(pids).toHaveLength(2); + const [siblingPid, sessionPid] = pids as [number, number]; + expect(methodsOf(file, siblingPid)).toEqual(['server/discover']); + // Byte-trace: the verdict was adopted — the session never probed and + // never ran the legacy handshake. + expect(methodsOf(file, sessionPid)).not.toContain('server/discover'); + expect(methodsOf(file, sessionPid)).not.toContain('initialize'); + await vi.waitFor(() => expect(isAlive(siblingPid)).toBe(false)); + } finally { + await client.close(); + } + }, 15_000); + + it('caller close() mid-probe aborts promptly: typed error, the session child is never spawned, the sibling is reaped', async () => { + const file = logFile(); + const transport = spawnFixture(file, 'silent'); + const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + + const pending = client.connect(transport); + pending.catch(() => {}); + await vi.waitFor(() => expect(pidsOf(file)).toHaveLength(1)); + + await transport.close(); + + const rejection = await pending.then( + () => {}, + (error: unknown) => error + ); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((rejection as SdkError).message).toMatch(/transport was closed during the server\/discover probe/); + // The session child was never spawned, and the sibling is reaped. + expect(transport.pid).toBeNull(); + expect(pidsOf(file)).toHaveLength(1); + await vi.waitFor(() => expect(isAlive(pidsOf(file)[0]!)).toBe(false)); + }, 15_000); + + it("a pre-set onclose observer survives a failed pin negotiation: a restarted life's close still reaches it", async () => { + // The sibling design keeps the session transport's handlers untouched + // during the probe (the window opens on the sibling, which has none), + // so a failed negotiation must leave the caller's observer fully + // armed: after the caller restarts the transport, its life's close is + // genuine and must be delivered. (On the real StdioClientTransport, + // close() never re-fires onclose — delivery comes only from the + // child's own close event — so any leftover one-shot suppression + // would swallow exactly this event.) + const file = logFile(); + const transport = spawnFixture(file, 'rmcp'); + let closes = 0; + transport.onclose = () => { + closes++; + }; + const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } }); + + await expect(client.connect(transport)).rejects.toThrow(/no fallback in pin mode/); + // The session child was never spawned, so its observer saw nothing yet. + expect(transport.pid).toBeNull(); + expect(closes).toBe(0); + + // The caller restarts the transport: its first real life. Closing it + // makes the child exit — that close event must reach the observer. + await transport.start(); + expect(transport.pid).not.toBeNull(); + await transport.close(); + await vi.waitFor(() => expect(closes).toBe(1)); + }, 15_000); + + it('a helper holding the probe child’s pipes defers the close past the probe window: the timeout row still lands legacy and the session works', async () => { + const file = logFile(); + const transport = spawnFixture(file, 'rmcp-holding'); + const client = new Client( + { name: 'neg-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 800 } } } + ); + try { + await client.connect(transport); + const result = await client.callTool({ name: 'echo', arguments: { text: 'held' } }); + expect(result.content).toEqual([{ type: 'text', text: 'held' }]); + const pids = pidsOf(file); + expect(pids).toHaveLength(2); + await vi.waitFor(() => expect(isAlive(pids[0]!)).toBe(false)); + } finally { + await client.close(); + } + }, 15_000); +}); diff --git a/test/integration/test/server/dualEraStdio.test.ts b/test/integration/test/server/dualEraStdio.test.ts index 279ff2b2c6..9cef3b8f7b 100644 --- a/test/integration/test/server/dualEraStdio.test.ts +++ b/test/integration/test/server/dualEraStdio.test.ts @@ -128,7 +128,7 @@ describe('serveStdio over a real child-process pipe (one connection per spawned } }); - 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 () => { + it('modern-opening connection: the auto-negotiating client reaches 2026-07-28 via the sibling probe, the session pipe pins modern from its first enveloped request, 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,10 +138,11 @@ describe('serveStdio over a real child-process pipe (one connection per spawned await client.connect(transport); const inbound = recordInbound(transport); - // 2026 negotiated via discover on the pipe — no initialize was ever written. + // 2026 negotiated via the disposable sibling probe — the session + // pipe carries neither initialize nor server/discover. expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); expect(outbound.some(message => (message as { method?: string }).method === 'initialize')).toBe(false); - expect((outbound[0] as { method?: string }).method).toBe('server/discover'); + expect(outbound.some(message => (message as { method?: string }).method === 'server/discover')).toBe(false); // Modern vertical: list → call. The raw list carries a hand-built // envelope so the resultType marker can be read on the wire; the