fix(client): probe stdio servers on a disposable sibling process (#2514)
This commit is contained in:
parent
f4137630c0
commit
6fe196310d
3 changed files with 1119 additions and 66 deletions
|
|
@ -55,8 +55,9 @@ client.getProtocolEra(); // 'modern' | 'legacy'
|
|||
```
|
||||
|
||||
- **absent / `mode: 'legacy'`** (default) — today's behavior, no probe.
|
||||
- **`mode: 'auto'`** — probe with `server/discover`; fall back to the 2025 handshake on
|
||||
the same connection against a 2025-only server (one extra round trip).
|
||||
- **`mode: 'auto'`** — probe with `server/discover`; fall back to the 2025 handshake
|
||||
against a 2025-only server (one extra round trip; on the SDK's stdio transport the
|
||||
probe rides a disposable sibling process — see below).
|
||||
- **`mode: { pin: '2026-07-28' }`** — modern only; no fallback, `connect()` rejects with
|
||||
`SdkError(EraNegotiationFailed)` against a 2025-only server.
|
||||
|
||||
|
|
@ -77,12 +78,25 @@ falls back to the legacy era — provided the supported-versions list still cont
|
|||
`SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect
|
||||
error. Probe timeouts are **transport-aware**: on **stdio** a server that does not
|
||||
answer within `timeoutMs` is treated as legacy and the client falls back to `initialize`
|
||||
on the same stream (some legacy servers never respond to unknown pre-`initialize`
|
||||
(some legacy servers never respond to unknown pre-`initialize`
|
||||
requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` —
|
||||
a dead HTTP server is never misreported as legacy. One browser-specific exception: an
|
||||
opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because
|
||||
deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers.
|
||||
|
||||
On the SDK's own stdio transport (exactly `StdioClientTransport` — subclasses probe
|
||||
in place, like custom stdio-shaped transports) the probe runs on a short-lived
|
||||
**sibling process** spawned from the same parameters (its stderr is discarded, and it is reaped once the
|
||||
era is known): 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. A child that exits on the probe is simply a legacy server;
|
||||
the caller's transport spawns exactly once, after the era is known, and its wire never
|
||||
carries `server/discover`. 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.
|
||||
|
||||
```typescript
|
||||
versionNegotiation: {
|
||||
mode: 'auto',
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ console.log(client.getProtocolEra());
|
|||
modern
|
||||
```
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
```ts source="../examples/guides/protocolVersions.examples.ts#versionNegotiation_fallback"
|
||||
const fallback = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
|
@ -99,12 +99,14 @@ const cli = new Client(
|
|||
);
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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)`.
|
||||
|
||||
::: warning
|
||||
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.
|
||||
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.
|
||||
:::
|
||||
|
||||
## Serve both eras from one entry point
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
shape: reference
|
||||
---
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
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
|
|||
|
||||
## `SdkError: ERA_NEGOTIATION_FAILED`
|
||||
|
||||
`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.
|
||||
`connect()` found no **protocol era** both sides speak, or the negotiation probe was cut short. Match the message tail:
|
||||
|
||||
- `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'`.
|
||||
- `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'`.
|
||||
- `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.
|
||||
- `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'`.
|
||||
- `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.
|
||||
- `Version negotiation probe failed: ...` — the probe hit a transport failure (network outage, HTTP connection drop): fix connectivity and retry.
|
||||
|
||||
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):
|
||||
|
||||
|
|
@ -87,7 +95,7 @@ The rejection names the pinned revision the server never offered:
|
|||
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)
|
||||
```
|
||||
|
||||
Change the mode to `'auto'`: the probe falls back to the 2025 `initialize` handshake on the same connection.
|
||||
Change the mode to `'auto'`: the probe falls back to the 2025 `initialize` handshake.
|
||||
|
||||
```ts source="../examples/guides/troubleshooting.examples.ts#connect_autoFallback"
|
||||
const negotiated = new Client({ name: 'app', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
|
|
|||
Loading…
Reference in a new issue