Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/probe-close-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

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.
20 changes: 17 additions & 3 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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',
Expand Down
8 changes: 5 additions & 3 deletions docs/protocol-versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' } });
Expand Down Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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):

Expand All @@ -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' } });
Expand Down
64 changes: 51 additions & 13 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,15 @@ import type { PriorDiscovery } from './probeClassifier';
import type { CacheMode, CacheScope, ResponseCacheStore } from './responseCache';
import { ClientResponseCache, InMemoryResponseCacheStore, MAX_CACHE_TTL_MS } from './responseCache';
import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation';
import { detectProbeEnvironment, detectProbeTransportKind, negotiateEra, resolveVersionNegotiation } from './versionNegotiation';
import {
detectProbeEnvironment,
detectProbeTransportKind,
disarmSpentCloseGuard,
negotiateEra,
negotiateStdioViaSibling,
readStdioServerParams,
resolveVersionNegotiation
} from './versionNegotiation';

/**
* The server identity a `DiscoverResult` carries in
Expand Down Expand Up @@ -199,11 +207,16 @@ export type ClientOptions = ProtocolOptions & {
* - `mode: 'auto'` — `connect()` probes the server with `server/discover` first:
* definitive modern evidence selects the modern era; definitive legacy signals
* (and anything unrecognized) fall back to the plain legacy `initialize`
* handshake on the same connection, byte-equivalent to a 2025 client. A
* handshake, byte-equivalent to a 2025 client. On the SDK's own stdio
* transport (the base `StdioClientTransport` exactly; subclasses probe in
* place) the probe runs on a short-lived sibling process spawned from the
* same parameters (one extra spawn per connect; its stderr is discarded) and
* the caller's transport starts once, after the era is known; HTTP — and
* custom or subclassed stdio-shaped transports — probe on the connection itself. A
* network outage rejects with a typed connect error. A probe timeout is
* transport-aware: on stdio it indicates a legacy server (some legacy servers
* never answer unknown pre-`initialize` requests) and falls back to
* `initialize` on the same stream; on HTTP it rejects with a typed timeout
* `initialize`; on HTTP it rejects with a typed timeout
* error (silence on a deployed server is an outage, not a legacy signal).
* - `mode: { pin: '2026-07-28' }` — modern era at exactly the pinned revision;
* no probe-and-fallback: anything else fails loudly.
Expand Down Expand Up @@ -1014,8 +1027,9 @@ export class Client extends Protocol<ClientContext> {

/**
* The 2025 `initialize` handshake — the body of the plain legacy connect and
* the `'auto'`-mode fallback path (same connection, same `initialize` body,
* zero 2026 headers). Callers clear the negotiated protocol version before
* the `'auto'`-mode fallback path (same `initialize` body, zero 2026 headers;
* on the stdio sibling path it opens the session child's fresh pipe, in the
* in-place modes it rides the probed connection). Callers clear the negotiated protocol version before
* the handshake; its completion sets the negotiated (legacy) version.
*/
private async _legacyHandshake(transport: Transport, options?: RequestOptions): Promise<void> {
Expand Down Expand Up @@ -1087,8 +1101,9 @@ export class Client extends Protocol<ClientContext> {

/**
* Negotiated connect (mode `'auto'` or `{ pin }`): probe with `server/discover`
* before the Protocol machinery attaches, then either establish the modern era
* or perform the plain legacy handshake on the same connection.
* before the Protocol machinery attaches — on a disposable sibling process for
* the SDK's stdio transport, in place otherwise — then either establish the
* modern era or perform the plain legacy handshake.
*/
private async _connectNegotiated(
transport: Transport,
Expand All @@ -1112,25 +1127,48 @@ export class Client extends Protocol<ClientContext> {

let result: Awaited<ReturnType<typeof negotiateEra>>;
try {
result = await negotiateEra(negotiation, {
transport,
const transportKind = detectProbeTransportKind(transport);
const baseDeps = {
clientInfo: this._clientInfo,
capabilities: this._capabilities,
environment: detectProbeEnvironment(),
transportKind: detectProbeTransportKind(transport),
defaultTimeoutMs: options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC
});
};
// The SDK's stdio transport probes on a disposable sibling process,
// so the caller's transport spends its one child life on the
// session, never on the probe. Stdio-shaped transports without
// readable spawn parameters (and HTTP) probe in place, as before.
const stdioParams = transportKind === 'stdio' ? readStdioServerParams(transport) : undefined;
result =
stdioParams === undefined
? await negotiateEra(negotiation, { ...baseDeps, transport, transportKind })
: await negotiateStdioViaSibling(negotiation, transport, stdioParams, baseDeps);
} catch (error) {
// Typed connect error — close the channel like a failed initialize does.
await transport.close().catch(() => {});
// The cleanup close has settled: any re-delivery of a spent
// mid-probe close has happened by now, so the spent-close guard
// must not outlive it — on transports whose close() never re-fires
// onclose (stdio), an armed guard would swallow the next GENUINE
// close after a restart.
disarmSpentCloseGuard(transport);
throw error;
}

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

await super.connect(transport);

if (result.era === 'legacy') {
// Conservative fallback: the plain legacy handshake on the SAME
// connection (the probe never touched the transport version slot).
// Conservative fallback: the in-place probing modes run the plain
// legacy handshake on the probed connection; the sibling path runs
// it as the session child's first exchange (the probe never touched
// the transport version slot either way).
await this._legacyHandshake(transport, options);
return;
}
Expand Down
Loading
Loading