From 73686a2d66928ead0107249f7dda02df2e563ce3 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 | 12 + docs/protocol-versions.md | 6 +- packages/client/src/client/client.ts | 32 +- packages/client/src/client/probeClassifier.ts | 14 + packages/client/src/client/stdio.ts | 38 ++ .../client/src/client/versionNegotiation.ts | 203 +++++++++- .../test/client/probeClassifier.test.ts | 16 + .../test/client/versionNegotiation.test.ts | 355 ++++++++++++++++++ test/e2e/scenarios/stdio-dual-era.test.ts | 9 +- .../test/client/versionNegotiation.test.ts | 220 ++++++++++- .../test/server/dualEraStdio.test.ts | 7 +- 12 files changed, 888 insertions(+), 29 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..24cc78b6f8 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -83,6 +83,18 @@ a dead HTTP server is never misreported as legacy. One browser-specific exceptio 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 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..35e0d996e7 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -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 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/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index b2dfd99e17..eb8bc43929 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 @@ -1112,17 +1120,31 @@ 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; } diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index 1b78744964..abe87f8806 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -48,6 +48,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 }; @@ -141,6 +143,18 @@ 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 diff --git a/packages/client/src/client/stdio.ts b/packages/client/src/client/stdio.ts index 29b0027eae..5ccab2f124 100644 --- a/packages/client/src/client/stdio.ts +++ b/packages/client/src/client/stdio.ts @@ -216,6 +216,44 @@ 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) { + this._readBuffer.clear(); + return; + } + 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; + 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..5920e6c198 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -67,11 +67,18 @@ 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 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. @@ -269,26 +276,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 +398,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 }; @@ -356,6 +417,14 @@ export interface NegotiationDeps { 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 +490,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,10 +509,20 @@ 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}`); + } return { era: 'legacy' }; } case 'error': { @@ -458,3 +544,92 @@ 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 (those probe + * in place, where a mid-probe close stays a typed connect error). + */ +export function readStdioServerParams(transport: Transport): Record | 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); + }; + + 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(() => {}); + const result = await Promise.race([negotiated, closedSignal]); + if (callerClosed) { + throw callerCloseAbortError(); + } + return result; + } finally { + sessionTransport.close = originalClose; + await disposeSibling(sibling); + } +} + +/** 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..0ca674287a 100644 --- a/packages/client/test/client/probeClassifier.test.ts +++ b/packages/client/test/client/probeClassifier.test.ts @@ -293,6 +293,22 @@ describe('row: timeout — transport-aware verdict', () => { }); }); +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/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts index f45d028835..d1e76bdfda 100644 --- a/packages/client/test/client/versionNegotiation.test.ts +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -438,6 +438,286 @@ 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?.(); + } + } + + // Called structurally by the sibling reaper, like the real transport's. + private async _dispose(): Promise { + 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 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 +1149,81 @@ 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("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..7d11ecc34c 100644 --- a/test/integration/test/client/versionNegotiation.test.ts +++ b/test/integration/test/client/versionNegotiation.test.ts @@ -15,8 +15,11 @@ * client falls back to initialize on the same 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 +27,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). */ @@ -286,3 +289,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