Skip to content

Commit 6fe1963

Browse files
fix(client): probe stdio servers on a disposable sibling process (#2514)
1 parent f413763 commit 6fe1963

15 files changed

Lines changed: 1119 additions & 66 deletions

.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: 10 additions & 2 deletions
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.
@@ -66,7 +67,14 @@ With the global in place the [client OAuth](./clients/oauth.md) flows run unchan
6667

6768
## `SdkError: ERA_NEGOTIATION_FAILED`
6869

69-
`connect()` found no **protocol era** both sides speak. Two shapes produce it: `versionNegotiation: { mode: { pin: ... } }` names a revision the server does not offer over `server/discover`, and pinning never falls back; or `mode: 'auto'` with a `supportedProtocolVersions` list that has no pre-2026 entry, which removes the legacy fallback.
70+
`connect()` found no **protocol era** both sides speak, or the negotiation probe was cut short. Match the message tail:
71+
72+
- `the server did not offer pinned protocol version ... via server/discover (no fallback in pin mode)` — the pin names a revision the server does not offer, and pinning never falls back: drop the pin or use `'auto'`.
73+
- `the connection closed during the server/discover probe before the server offered pinned protocol version ...` — same pin, but the server exited on the probe (an exit-on-probe legacy server): use `'auto'`.
74+
- `the server gave no modern evidence and this client supports no pre-2026-07-28 protocol version to fall back to` — or its `the connection closed during the server/discover probe and this client supports no ...` variant — `mode: 'auto'` with a modern-only `supportedProtocolVersions` list removes the legacy fallback: restore a pre-2026 entry.
75+
- `the connection closed during the server/discover probe (this transport probed in place — the disposable sibling probe requires the SDK's base StdioClientTransport)` — a subclass of `StdioClientTransport`, or a custom stdio-shaped transport, probed in place and met a server that exits on any pre-`initialize` request: use the base `StdioClientTransport` (which probes on a disposable sibling), or `mode: 'legacy'`.
76+
- `the transport was closed during the server/discover probe` — the caller closed the transport while the probe was in flight; the connect aborted deliberately and the session child was never spawned.
77+
- `Version negotiation probe failed: ...` — the probe hit a transport failure (network outage, HTTP connection drop): fix connectivity and retry.
7078

7179
The pinned shape — `transport` here reaches a server still on the 2025 revisions ([Test a server](./testing.md) shows the in-memory wiring these outputs come from):
7280

@@ -87,7 +95,7 @@ The rejection names the pinned revision the server never offered:
8795
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)
8896
```
8997

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

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

packages/client/src/client/client.ts

Lines changed: 51 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,16 @@ 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 base `StdioClientTransport` exactly; subclasses probe in
212+
* place) the probe runs on a short-lived sibling process spawned from the
213+
* same parameters (one extra spawn per connect; its stderr is discarded) and
214+
* the caller's transport starts once, after the era is known; HTTP — and
215+
* custom or subclassed stdio-shaped transports — probe on the connection itself. A
203216
* network outage rejects with a typed connect error. A probe timeout is
204217
* transport-aware: on stdio it indicates a legacy server (some legacy servers
205218
* never answer unknown pre-`initialize` requests) and falls back to
206-
* `initialize` on the same stream; on HTTP it rejects with a typed timeout
219+
* `initialize`; on HTTP it rejects with a typed timeout
207220
* error (silence on a deployed server is an outage, not a legacy signal).
208221
* - `mode: { pin: '2026-07-28' }` — modern era at exactly the pinned revision;
209222
* no probe-and-fallback: anything else fails loudly.
@@ -1014,8 +1027,9 @@ export class Client extends Protocol<ClientContext> {
10141027

10151028
/**
10161029
* 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
1030+
* the `'auto'`-mode fallback path (same `initialize` body, zero 2026 headers;
1031+
* on the stdio sibling path it opens the session child's fresh pipe, in the
1032+
* in-place modes it rides the probed connection). Callers clear the negotiated protocol version before
10191033
* the handshake; its completion sets the negotiated (legacy) version.
10201034
*/
10211035
private async _legacyHandshake(transport: Transport, options?: RequestOptions): Promise<void> {
@@ -1087,8 +1101,9 @@ export class Client extends Protocol<ClientContext> {
10871101

10881102
/**
10891103
* 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.
1104+
* before the Protocol machinery attaches — on a disposable sibling process for
1105+
* the SDK's stdio transport, in place otherwise — then either establish the
1106+
* modern era or perform the plain legacy handshake.
10921107
*/
10931108
private async _connectNegotiated(
10941109
transport: Transport,
@@ -1112,25 +1127,48 @@ export class Client extends Protocol<ClientContext> {
11121127

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

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

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

0 commit comments

Comments
 (0)