fix(client): probe stdio servers on a disposable sibling process (#2514)

This commit is contained in:
Felix Weinberger 2026-07-21 13:07:12 +01:00 • committed by GitHub
commit 6fe196310d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1119 additions and 66 deletions

View file

@ -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.

View file

@ -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',

View file

@ -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

View file

@ -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' } });

View file

@ -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<ClientContext> {
/**
* 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<void> {
@ -1087,8 +1101,9 @@ export class Client extends Protocol<ClientContext> {
/**
* 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<ClientContext> {
let result: Awaited<ReturnType<typeof negotiateEra>>;
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;
}

View file

@ -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' };
}

View file

@ -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<void> {
const proc = this._process;
this._process = undefined;
if (proc && proc.exitCode === null && proc.signalCode === null) {
const exited = new Promise<void>(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<void> {
if (this._process) {
const processToClose = this._process;

View file

@ -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: '<version>' }` — 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<void> => {
transport.start = async function (this: unknown): Promise<void> {
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<Transport, () => 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<string, unknown> | 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<string, unknown>)
: 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<ResolvedVersionNegotiation, { kind: 'auto' | 'pin' }>,
sessionTransport: Transport,
params: Record<string, unknown>,
deps: Omit<NegotiationDeps, 'transport' | 'transportKind' | 'disposableProbe'>
): Promise<NegotiationResult> {
const SiblingTransport = sessionTransport.constructor as new (params: Record<string, unknown>) => 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<never>((_, reject) => {
signalClosed = () => reject(callerCloseAbortError());
});
sessionTransport.close = async function (): Promise<void> {
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<void> {
try {
const dispose = (sibling as { _dispose?: () => Promise<void> })._dispose;
await (typeof dispose === 'function' ? dispose.call(sibling) : sibling.close());
} catch {
// ignore
}
}

View file

@ -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({

View file

@ -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: {} }

View file

@ -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<void> })._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);

View file

@ -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<string, unknown>;
startCalls = 0;
sent: JSONRPCMessage[] = [];
setProtocolVersionCalls: string[] = [];
disposed = false;
private _alive = false;
private _initialized = false;
constructor(params: Record<string, unknown>) {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
this._alive = true;
this.startCalls++;
}
async send(message: JSONRPCMessage): Promise<void> {
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<void> {
// 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 () => {

View file

@ -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' }]);

View file

@ -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 "<pid> <method>" 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);
});

View file

@ -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