Skip to content

Commit 03aa2c0

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 03aa2c0

14 files changed

Lines changed: 1107 additions & 62 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

docs/troubleshooting.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
shape: reference
33
---
4+
45
# Troubleshooting
56

67
Each heading on this page is the verbatim error message. Match yours, then apply that entry's fix.
@@ -87,7 +88,7 @@ The rejection names the pinned revision the server never offered:
8788
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)
8889
```
8990

90-
Change the mode to `'auto'`: the probe falls back to the 2025 `initialize` handshake on the same connection.
91+
Change the mode to `'auto'`: the probe falls back to the 2025 `initialize` handshake.
9192

9293
```ts source="../examples/guides/troubleshooting.examples.ts#connect_autoFallback"
9394
const negotiated = new Client({ name: 'app', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });

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: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
* Probe outcome classifier (pure module): maps the outcome of the connect-time
33
* `server/discover` probe onto one of four verdicts — modern era, the
44
* spec-mandated `-32022` corrective continuation, legacy fallback (the plain
5-
* 2025 `initialize` handshake on the same connection), or a typed connect error.
5+
* 2025 `initialize` handshake — on the probed connection for in-place probes,
6+
* on the session child's fresh pipe when the probe rode a disposable
7+
* sibling), or a typed connect error.
68
*
79
* The classifier is deliberately conservative: anything it does not positively
810
* recognize as modern resolves to the legacy fallback, and a network outage is a
@@ -28,10 +30,11 @@ import {
2830
export type ProbeEnvironment = 'node' | 'browser';
2931

3032
/**
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.
33+
* The transport class the probe ran on. Consulted for the timeout and closed
34+
* rows: a stdio probe that times out — or whose child exits, closing the
35+
* connection — signals a legacy server, while an HTTP timeout or close stays
36+
* a typed error. Anything that is not the stdio child-process transport is
37+
* treated like HTTP.
3538
*/
3639
export type ProbeTransportKind = 'stdio' | 'http';
3740

@@ -48,6 +51,8 @@ export type ProbeOutcome =
4851
| { kind: 'network-error'; error: unknown }
4952
/** The transport's auth flow challenged during the probe send (`UnauthorizedError`). */
5053
| { kind: 'auth-required'; error: Error }
54+
/** The transport reported close while the probe awaited its reply. */
55+
| { kind: 'closed' }
5156
/** No response arrived within the probe timeout. */
5257
| { kind: 'timeout'; timeoutMs: number };
5358

@@ -82,7 +87,7 @@ export type ProbeVerdict =
8287
* arms a loop guard on the second rejection, throwing `error`.
8388
*/
8489
| { kind: 'corrective'; version: string; error: UnsupportedProtocolVersionError }
85-
/** Definitive legacy signal or unrecognized shape: perform the plain legacy `initialize` handshake on the same connection. */
90+
/** 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). */
8691
| { kind: 'legacy' }
8792
/** Typed connect error — never converted to an era verdict. */
8893
| { kind: 'error'; error: Error };
@@ -141,11 +146,24 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi
141146
// handshake an auth-gated modern server as legacy.
142147
return { kind: 'error', error: outcome.error };
143148
}
149+
case 'closed': {
150+
if (context.transportKind === 'stdio') {
151+
// A stdio child that exits on the unrecognized probe instead of
152+
// answering is a legacy signal — the same backward-compatibility
153+
// rule as the timeout row below (SDKs like the official Rust one
154+
// terminate the server on ANY pre-initialize request).
155+
return { kind: 'legacy' };
156+
}
157+
// On HTTP a mid-probe close is an ambiguous network condition — the
158+
// same typed connect error as any probe transport failure.
159+
return classifyNetworkError(new Error('Connection closed during the version negotiation probe'), context);
160+
}
144161
case 'timeout': {
145162
if (context.transportKind === 'stdio') {
146163
// Per the stdio transport's backward-compatibility rule, a probe
147164
// nobody answers within the timeout indicates a legacy server —
148-
// fall back to `initialize` on the same stream.
165+
// fall back to `initialize` (the sibling path runs it on the
166+
// session child's fresh pipe; in-place probes reuse the stream).
149167
return { kind: 'legacy' };
150168
}
151169
// On HTTP a deployed server answers, so silence is an outage, not a
@@ -177,8 +195,8 @@ function classifyResult(result: unknown, context: ProbeClassifierContext): Probe
177195
if (overlap !== undefined) {
178196
return { kind: 'modern', version: overlap, discover: parsed.value };
179197
}
180-
// A DiscoverResult with no overlap still drives era selection: initialize on
181-
// the same connection when fallback is possible, otherwise a typed error.
198+
// A DiscoverResult with no overlap still drives era selection: the legacy
199+
// `initialize` fallback when available, otherwise a typed error.
182200
if (context.fallbackAvailable) {
183201
return { kind: 'legacy' };
184202
}

0 commit comments

Comments
 (0)