Skip to content

Commit ec76435

Browse files
fix(client): probe stdio servers on a disposable sibling process
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.
1 parent f413763 commit ec76435

13 files changed

Lines changed: 1089 additions & 50 deletions

File tree

.changeset/probe-close-handling.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
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.

docs/migration/support-2026-07-28.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,9 @@ client.getProtocolEra(); // 'modern' | 'legacy'
5555
```
5656

5757
- **absent / `mode: 'legacy'`** (default) — today's behavior, no probe.
58-
- **`mode: 'auto'`** — probe with `server/discover`; fall back to the 2025 handshake on
59-
the same connection against a 2025-only server (one extra round trip).
58+
- **`mode: 'auto'`** — probe with `server/discover`; fall back to the 2025 handshake
59+
against a 2025-only server (one extra round trip; on the SDK's stdio transport the
60+
probe rides a disposable sibling process — see below).
6061
- **`mode: { pin: '2026-07-28' }`** — modern only; no fallback, `connect()` rejects with
6162
`SdkError(EraNegotiationFailed)` against a 2025-only server.
6263

@@ -77,12 +78,25 @@ falls back to the legacy era — provided the supported-versions list still cont
7778
`SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect
7879
error. Probe timeouts are **transport-aware**: on **stdio** a server that does not
7980
answer within `timeoutMs` is treated as legacy and the client falls back to `initialize`
80-
on the same stream (some legacy servers never respond to unknown pre-`initialize`
81+
(some legacy servers never respond to unknown pre-`initialize`
8182
requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)`
8283
a dead HTTP server is never misreported as legacy. One browser-specific exception: an
8384
opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because
8485
deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers.
8586

87+
On the SDK's own stdio transport (exactly `StdioClientTransport` — subclasses probe
88+
in place, like custom stdio-shaped transports) the probe runs on a short-lived
89+
**sibling process** spawned from the same parameters (its stderr is discarded, and it is reaped once the
90+
era is known): some stdio servers exit on any pre-`initialize` request — servers built
91+
on the official Rust SDK, rmcp, behave this way — so the probe must not spend the
92+
caller's one child process. A child that exits on the probe is simply a legacy server;
93+
the caller's transport spawns exactly once, after the era is known, and its wire never
94+
carries `server/discover`. Closing the caller's transport during the probe aborts
95+
`connect()` with a typed `SdkError(EraNegotiationFailed)` and the session child is
96+
never spawned. On HTTP — and on custom stdio-shaped transports, which probe in
97+
place — a mid-probe connection close rejects with the same typed error as any probe
98+
transport failure.
99+
86100
```typescript
87101
versionNegotiation: {
88102
mode: 'auto',

docs/protocol-versions.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ console.log(client.getProtocolEra());
3030
modern
3131
```
3232

33-
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.
33+
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).
3434

3535
```ts source="../examples/guides/protocolVersions.examples.ts#versionNegotiation_fallback"
3636
const fallback = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
@@ -99,12 +99,14 @@ const cli = new Client(
9999
);
100100
```
101101

102-
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.
102+
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.
103+
104+
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.
103105

104106
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)`.
105107

106108
::: warning
107-
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.
109+
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.
108110
:::
109111

110112
## Serve both eras from one entry point

packages/client/src/client/client.ts

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,15 @@ import type { PriorDiscovery } from './probeClassifier';
8484
import type { CacheMode, CacheScope, ResponseCacheStore } from './responseCache';
8585
import { ClientResponseCache, InMemoryResponseCacheStore, MAX_CACHE_TTL_MS } from './responseCache';
8686
import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation';
87-
import { detectProbeEnvironment, detectProbeTransportKind, negotiateEra, resolveVersionNegotiation } from './versionNegotiation';
87+
import {
88+
detectProbeEnvironment,
89+
detectProbeTransportKind,
90+
disarmSpentCloseGuard,
91+
negotiateEra,
92+
negotiateStdioViaSibling,
93+
readStdioServerParams,
94+
resolveVersionNegotiation
95+
} from './versionNegotiation';
8896

8997
/**
9098
* The server identity a `DiscoverResult` carries in
@@ -199,11 +207,15 @@ export type ClientOptions = ProtocolOptions & {
199207
* - `mode: 'auto'` — `connect()` probes the server with `server/discover` first:
200208
* definitive modern evidence selects the modern era; definitive legacy signals
201209
* (and anything unrecognized) fall back to the plain legacy `initialize`
202-
* handshake on the same connection, byte-equivalent to a 2025 client. A
210+
* handshake, byte-equivalent to a 2025 client. On the SDK's own stdio
211+
* transport the probe runs on a short-lived sibling process spawned from the
212+
* same parameters (one extra spawn per connect; its stderr is discarded) and
213+
* the caller's transport starts once, after the era is known; HTTP — and
214+
* custom stdio-shaped transports — probe on the connection itself. A
203215
* network outage rejects with a typed connect error. A probe timeout is
204216
* transport-aware: on stdio it indicates a legacy server (some legacy servers
205217
* never answer unknown pre-`initialize` requests) and falls back to
206-
* `initialize` on the same stream; on HTTP it rejects with a typed timeout
218+
* `initialize`; on HTTP it rejects with a typed timeout
207219
* error (silence on a deployed server is an outage, not a legacy signal).
208220
* - `mode: { pin: '2026-07-28' }` — modern era at exactly the pinned revision;
209221
* no probe-and-fallback: anything else fails loudly.
@@ -1014,8 +1026,9 @@ export class Client extends Protocol<ClientContext> {
10141026

10151027
/**
10161028
* The 2025 `initialize` handshake — the body of the plain legacy connect and
1017-
* the `'auto'`-mode fallback path (same connection, same `initialize` body,
1018-
* zero 2026 headers). Callers clear the negotiated protocol version before
1029+
* the `'auto'`-mode fallback path (same `initialize` body, zero 2026 headers;
1030+
* on the stdio sibling path it opens the session child's fresh pipe, in the
1031+
* in-place modes it rides the probed connection). Callers clear the negotiated protocol version before
10191032
* the handshake; its completion sets the negotiated (legacy) version.
10201033
*/
10211034
private async _legacyHandshake(transport: Transport, options?: RequestOptions): Promise<void> {
@@ -1087,8 +1100,9 @@ export class Client extends Protocol<ClientContext> {
10871100

10881101
/**
10891102
* Negotiated connect (mode `'auto'` or `{ pin }`): probe with `server/discover`
1090-
* before the Protocol machinery attaches, then either establish the modern era
1091-
* or perform the plain legacy handshake on the same connection.
1103+
* before the Protocol machinery attaches — on a disposable sibling process for
1104+
* the SDK's stdio transport, in place otherwise — then either establish the
1105+
* modern era or perform the plain legacy handshake.
10921106
*/
10931107
private async _connectNegotiated(
10941108
transport: Transport,
@@ -1112,25 +1126,48 @@ export class Client extends Protocol<ClientContext> {
11121126

11131127
let result: Awaited<ReturnType<typeof negotiateEra>>;
11141128
try {
1115-
result = await negotiateEra(negotiation, {
1116-
transport,
1129+
const transportKind = detectProbeTransportKind(transport);
1130+
const baseDeps = {
11171131
clientInfo: this._clientInfo,
11181132
capabilities: this._capabilities,
11191133
environment: detectProbeEnvironment(),
1120-
transportKind: detectProbeTransportKind(transport),
11211134
defaultTimeoutMs: options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC
1122-
});
1135+
};
1136+
// The SDK's stdio transport probes on a disposable sibling process,
1137+
// so the caller's transport spends its one child life on the
1138+
// session, never on the probe. Stdio-shaped transports without
1139+
// readable spawn parameters (and HTTP) probe in place, as before.
1140+
const stdioParams = transportKind === 'stdio' ? readStdioServerParams(transport) : undefined;
1141+
result =
1142+
stdioParams === undefined
1143+
? await negotiateEra(negotiation, { ...baseDeps, transport, transportKind })
1144+
: await negotiateStdioViaSibling(negotiation, transport, stdioParams, baseDeps);
11231145
} catch (error) {
11241146
// Typed connect error — close the channel like a failed initialize does.
11251147
await transport.close().catch(() => {});
1148+
// The cleanup close has settled: any re-delivery of a spent
1149+
// mid-probe close has happened by now, so the spent-close guard
1150+
// must not outlive it — on transports whose close() never re-fires
1151+
// onclose (stdio), an armed guard would swallow the next GENUINE
1152+
// close after a restart.
1153+
disarmSpentCloseGuard(transport);
11261154
throw error;
11271155
}
11281156

1157+
// Success path: no cleanup close ever runs here, so no re-delivery of a
1158+
// spent mid-window close is coming — disarm any guard NOW, before
1159+
// Protocol chains the handler slot, or an in-place negotiation that
1160+
// succeeded after a same-tick reply-then-close would carry the armed
1161+
// skip into the live session and swallow its first genuine close.
1162+
disarmSpentCloseGuard(transport);
1163+
11291164
await super.connect(transport);
11301165

11311166
if (result.era === 'legacy') {
1132-
// Conservative fallback: the plain legacy handshake on the SAME
1133-
// connection (the probe never touched the transport version slot).
1167+
// Conservative fallback: the in-place probing modes run the plain
1168+
// legacy handshake on the probed connection; the sibling path runs
1169+
// it as the session child's first exchange (the probe never touched
1170+
// the transport version slot either way).
11341171
await this._legacyHandshake(transport, options);
11351172
return;
11361173
}

packages/client/src/client/probeClassifier.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@ import {
2828
export type ProbeEnvironment = 'node' | 'browser';
2929

3030
/**
31-
* The transport class the probe ran on. Only consulted for the timeout row: a
32-
* stdio probe that times out signals a legacy server, while an HTTP timeout
33-
* stays a typed error. Anything that is not the stdio child-process transport
34-
* is treated like HTTP.
31+
* The transport class the probe ran on. Consulted for the timeout and closed
32+
* rows: a stdio probe that times out — or whose child exits, closing the
33+
* connection — signals a legacy server, while an HTTP timeout or close stays
34+
* a typed error. Anything that is not the stdio child-process transport is
35+
* treated like HTTP.
3536
*/
3637
export type ProbeTransportKind = 'stdio' | 'http';
3738

@@ -48,6 +49,8 @@ export type ProbeOutcome =
4849
| { kind: 'network-error'; error: unknown }
4950
/** The transport's auth flow challenged during the probe send (`UnauthorizedError`). */
5051
| { kind: 'auth-required'; error: Error }
52+
/** The transport reported close while the probe awaited its reply. */
53+
| { kind: 'closed' }
5154
/** No response arrived within the probe timeout. */
5255
| { kind: 'timeout'; timeoutMs: number };
5356

@@ -141,6 +144,18 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi
141144
// handshake an auth-gated modern server as legacy.
142145
return { kind: 'error', error: outcome.error };
143146
}
147+
case 'closed': {
148+
if (context.transportKind === 'stdio') {
149+
// A stdio child that exits on the unrecognized probe instead of
150+
// answering is a legacy signal — the same backward-compatibility
151+
// rule as the timeout row below (SDKs like the official Rust one
152+
// terminate the server on ANY pre-initialize request).
153+
return { kind: 'legacy' };
154+
}
155+
// On HTTP a mid-probe close is an ambiguous network condition — the
156+
// same typed connect error as any probe transport failure.
157+
return classifyNetworkError(new Error('Connection closed during the version negotiation probe'), context);
158+
}
144159
case 'timeout': {
145160
if (context.transportKind === 'stdio') {
146161
// Per the stdio transport's backward-compatibility rule, a probe

packages/client/src/client/stdio.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,61 @@ export class StdioClientTransport implements Transport {
216216
}
217217
}
218218

219+
/**
220+
* Reap a disposable probe sibling (see the version-negotiation sibling
221+
* flow): signal-first teardown awaiting process `exit` — never the `close`
222+
* event, so a helper process holding the child's stdio pipes can never
223+
* block disposal. Not part of the public transport lifecycle.
224+
*
225+
* @internal
226+
*/
227+
private async _dispose(): Promise<void> {
228+
const proc = this._process;
229+
this._process = undefined;
230+
if (proc && proc.exitCode === null && proc.signalCode === null) {
231+
const exited = new Promise<void>(resolve => proc.once('exit', () => resolve()));
232+
try {
233+
proc.stdin?.end();
234+
} catch {
235+
// ignore
236+
}
237+
try {
238+
proc.kill('SIGTERM');
239+
} catch {
240+
// ignore
241+
}
242+
await Promise.race([exited, new Promise(resolve => setTimeout(resolve, 1000).unref())]);
243+
if (proc.exitCode === null && proc.signalCode === null) {
244+
try {
245+
proc.kill('SIGKILL');
246+
} catch {
247+
// ignore
248+
}
249+
}
250+
await exited;
251+
}
252+
// The child is gone — release the PARENT-side pipe handles too. A helper
253+
// process holding the inherited write ends would otherwise keep them (and
254+
// with them the host's event loop: stdout carries a flowing 'data'
255+
// listener from start()) alive until the helper exits.
256+
try {
257+
proc?.stdout?.destroy();
258+
} catch {
259+
// ignore
260+
}
261+
try {
262+
proc?.stdin?.destroy();
263+
} catch {
264+
// ignore
265+
}
266+
try {
267+
proc?.stderr?.destroy();
268+
} catch {
269+
// ignore
270+
}
271+
this._readBuffer.clear();
272+
}
273+
219274
async close(): Promise<void> {
220275
if (this._process) {
221276
const processToClose = this._process;

0 commit comments

Comments
 (0)