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 @@
```

- **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 @@
`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.

Check warning on line 99 in docs/migration/support-2026-07-28.md

View check run for this annotation

Claude / Claude Code Review

support-2026-07-28.md still claims the stdio probe changes recorded transcripts — falsified by the sibling design; parallel warning in protocol-versions.md was reworded

Two paragraphs in the "Who should not default to 'auto'" section still describe the pre-sibling probe behavior: lines ~114-119 claim "the probe round trip changes recorded transcripts/raw logs" on stdio, but this PR's sibling design makes the session wire byte-identical to a plain `mode: 'legacy'` connect (pinned by this PR's own unit test) — this same diff already reworded the exact parallel `::: warning` in docs/protocol-versions.md to drop the transcript claim and name the real remaining cost
Comment on lines +92 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Two paragraphs in the "Who should not default to 'auto'" section still describe the pre-sibling probe behavior: lines ~114-119 claim "the probe round trip changes recorded transcripts/raw logs" on stdio, but this PR's sibling design makes the session wire byte-identical to a plain mode: 'legacy' connect (pinned by this PR's own unit test) — this same diff already reworded the exact parallel ::: warning in docs/protocol-versions.md to drop the transcript claim and name the real remaining cost (one extra short-lived spawn per connect). Similarly, lines ~121-126 ("the legacy-fallback path also begins with one enveloped probe") are now true only for HTTP and in-place stdio-shaped transports; on the base StdioClientTransport the enveloped probe rides the disposable sibling and never touches the session wire. Reword both to match the protocol-versions.md rewording.

Extended reasoning...

What's stale. This PR moves the SDK stdio probe onto a disposable sibling process, and it carefully reworded the affected prose almost everywhere — including the exact parallel ::: warning block in docs/protocol-versions.md, which the diff changed from "the extra round trip changes recorded transcripts" to "the probe spawns an extra short-lived server process per connect." But the sibling paragraph in docs/migration/support-2026-07-28.md (in the "Who should not default to 'auto'" section, immediately below the Probe policy section this PR rewrites) survived untouched:

…and the probe round trip changes recorded transcripts/raw logs, which matters for tools whose value is byte-stable observation.

Why it's now wrong for exactly its stated audience. The paragraph is explicitly scoped to spawn-per-invocation CLI tools on stdio. Post-PR, on the base StdioClientTransport (the transport such a tool uses), the server/discover probe rides the disposable sibling and the caller's session wire is byte-identical to a plain mode: 'legacy' connect. This is pinned by the PR's own tests: the unit test "the session's traffic is byte-identical to a plain mode:'legacy' connect" (packages/client/test/client/versionNegotiation.test.ts) asserts JSON.stringify(autoSession.sent) === JSON.stringify(plainSession.sent), and the integration test asserts the session pid's wire log contains initialize but never server/discover.

Step-by-step proof. (1) A spawn-per-invocation CLI tool defaults to 'auto' and records its own transport transcript (the population the paragraph warns). (2) Client._connectNegotiated calls readStdioServerParams(transport); the base class's prototype owns _dispose and retains _serverParams, so the sibling path is taken. (3) negotiateStdioViaSibling spawns the sibling, runs the probe there, reaps it. (4) Only then does the session transport start, and _legacyHandshake sends a plain initialize — the recorded transcript is identical to a mode: 'legacy' run. The "changes recorded transcripts" rationale is therefore false for this transport, while the paragraph omits the cost that IS new and real: one extra short-lived server spawn per connect (which is exactly what the reworded protocol-versions.md warning and the PR description's Breaking Changes section both name).

Secondary staleness two paragraphs down (~lines 121-126): "Tooling that classifies traffic must not treat 'saw an envelope' as 'modern era negotiated': the legacy-fallback path also begins with one enveloped probe." On the base stdio transport the enveloped probe went to the sibling; the session wire's legacy fallback begins with a plain un-enveloped initialize. The claim remains true only for HTTP and for in-place stdio-shaped transports/subclasses, so it needs the same scoping the rest of the diff applies (e.g. "on HTTP and in-place stdio probes, the legacy-fallback path also begins with one enveloped probe; on the SDK's base stdio transport the enveloped probe rides the sibling and never touches the session wire").

Why nothing catches it. Docs prose isn't pinned by any test, and the file's other stale sites (lines 59 and 79-81, flagged in an earlier round) were fixed by this PR — these two paragraphs sit just below the rewritten section and were simply missed. This matches the repo's Completeness recurring catch (surviving instances of a replaced phrasing pattern in a file the same diff edits) and the Documentation checklist item ("flag prose that now contradicts the implementation").

How to fix. Mirror the protocol-versions.md rewording: in the "Who should not default to 'auto'" paragraph, replace the transcript sentence with the extra-spawn cost ("and the probe spawns an extra short-lived server process per connect"), and scope the "saw an envelope" caveat to HTTP and in-place stdio probes. Docs-only; nothing breaks at runtime.

```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' } });
Comment thread
claude[bot] marked this conversation as resolved.
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
Comment thread
claude[bot] marked this conversation as resolved.
* 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