Skip to content

Commit 73686a2

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 73686a2

12 files changed

Lines changed: 888 additions & 29 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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,18 @@ a dead HTTP server is never misreported as legacy. One browser-specific exceptio
8383
opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because
8484
deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers.
8585

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

docs/protocol-versions.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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 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: 27 additions & 5 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
@@ -1112,17 +1120,31 @@ export class Client extends Protocol<ClientContext> {
11121120

11131121
let result: Awaited<ReturnType<typeof negotiateEra>>;
11141122
try {
1115-
result = await negotiateEra(negotiation, {
1116-
transport,
1123+
const transportKind = detectProbeTransportKind(transport);
1124+
const baseDeps = {
11171125
clientInfo: this._clientInfo,
11181126
capabilities: this._capabilities,
11191127
environment: detectProbeEnvironment(),
1120-
transportKind: detectProbeTransportKind(transport),
11211128
defaultTimeoutMs: options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC
1122-
});
1129+
};
1130+
// The SDK's stdio transport probes on a disposable sibling process,
1131+
// so the caller's transport spends its one child life on the
1132+
// session, never on the probe. Stdio-shaped transports without
1133+
// readable spawn parameters (and HTTP) probe in place, as before.
1134+
const stdioParams = transportKind === 'stdio' ? readStdioServerParams(transport) : undefined;
1135+
result =
1136+
stdioParams === undefined
1137+
? await negotiateEra(negotiation, { ...baseDeps, transport, transportKind })
1138+
: await negotiateStdioViaSibling(negotiation, transport, stdioParams, baseDeps);
11231139
} catch (error) {
11241140
// Typed connect error — close the channel like a failed initialize does.
11251141
await transport.close().catch(() => {});
1142+
// The cleanup close has settled: any re-delivery of a spent
1143+
// mid-probe close has happened by now, so the spent-close guard
1144+
// must not outlive it — on transports whose close() never re-fires
1145+
// onclose (stdio), an armed guard would swallow the next GENUINE
1146+
// close after a restart.
1147+
disarmSpentCloseGuard(transport);
11261148
throw error;
11271149
}
11281150

packages/client/src/client/probeClassifier.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export type ProbeOutcome =
4848
| { kind: 'network-error'; error: unknown }
4949
/** The transport's auth flow challenged during the probe send (`UnauthorizedError`). */
5050
| { kind: 'auth-required'; error: Error }
51+
/** The transport reported close while the probe awaited its reply. */
52+
| { kind: 'closed' }
5153
/** No response arrived within the probe timeout. */
5254
| { kind: 'timeout'; timeoutMs: number };
5355

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

packages/client/src/client/stdio.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,44 @@ 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+
this._readBuffer.clear();
232+
return;
233+
}
234+
const exited = new Promise<void>(resolve => proc.once('exit', () => resolve()));
235+
try {
236+
proc.stdin?.end();
237+
} catch {
238+
// ignore
239+
}
240+
try {
241+
proc.kill('SIGTERM');
242+
} catch {
243+
// ignore
244+
}
245+
await Promise.race([exited, new Promise(resolve => setTimeout(resolve, 1000).unref())]);
246+
if (proc.exitCode === null && proc.signalCode === null) {
247+
try {
248+
proc.kill('SIGKILL');
249+
} catch {
250+
// ignore
251+
}
252+
}
253+
await exited;
254+
this._readBuffer.clear();
255+
}
256+
219257
async close(): Promise<void> {
220258
if (this._process) {
221259
const processToClose = this._process;

0 commit comments

Comments
 (0)