You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
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).
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.
103
105
104
106
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)`.
105
107
106
108
::: 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.
Copy file name to clipboardExpand all lines: docs/troubleshooting.md
+2-1Lines changed: 2 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,6 +1,7 @@
1
1
---
2
2
shape: reference
3
3
---
4
+
4
5
# Troubleshooting
5
6
6
7
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:
87
88
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)
88
89
```
89
90
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.
/** 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). */
86
91
|{kind: 'legacy'}
87
92
/** Typed connect error — never converted to an era verdict. */
88
93
|{kind: 'error';error: Error};
@@ -141,11 +146,24 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi
141
146
// handshake an auth-gated modern server as legacy.
142
147
return{kind: 'error',error: outcome.error};
143
148
}
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
+
returnclassifyNetworkError(newError('Connection closed during the version negotiation probe'),context);
160
+
}
144
161
case'timeout': {
145
162
if(context.transportKind==='stdio'){
146
163
// Per the stdio transport's backward-compatibility rule, a probe
147
164
// 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).
149
167
return{kind: 'legacy'};
150
168
}
151
169
// On HTTP a deployed server answers, so silence is an outage, not a
@@ -177,8 +195,8 @@ function classifyResult(result: unknown, context: ProbeClassifierContext): Probe
0 commit comments