Skip to content

Commit 2741549

Browse files
fix(client): handle connection close during the version negotiation probe
A stdio child that exits on the unrecognized server/discover probe is a legacy signal, symmetric with the stdio probe-timeout rule: respawn the child once via the transport's public close()/start() and run the plain initialize handshake there. The respawn is gated on a close-provenance stamp (internal well-known symbol, implemented by StdioClientTransport): a transport the caller closed mid-probe, or one that does not report provenance, rejects with the typed connect error instead, and HTTP-class mid-probe closes keep rejecting typed. StdioClientTransport.start() clears the read buffer, and close() detaches the child's stderr pipe and ends the aggregate stream once teardown completes, so a dead predecessor cannot corrupt the next child's first frame, stderr readers of a closed transport always see end, and a restarted life begins on a fresh stream.
1 parent f60dff0 commit 2741549

11 files changed

Lines changed: 721 additions & 12 deletions

File tree

.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+
Handle a connection that closes during the `server/discover` version-negotiation probe. On stdio, a child that exits on the unrecognized probe is a legacy signal (symmetric with the stdio probe-timeout rule): under `versionNegotiation: { mode: 'auto' }` the client now respawns it once via the transport's own `close()`/`start()` and completes the plain `initialize` handshake there (the exit must close the child's stdio pipes to register as a close; an exit hidden behind a helper process holding them open falls to the probe-timeout path instead) — stdio servers built on SDKs that terminate on any pre-`initialize` request (the official Rust SDK, rmcp) previously hard-failed under `'auto'`. The respawn happens only for transports that positively report close provenance (an internal well-known-symbol stamp implemented by `StdioClientTransport`): a transport the caller closed mid-probe, or a custom transport that does not track provenance, rejects with the typed connect error instead — and on HTTP-class transports a mid-probe close keeps rejecting with the typed connect error. `StdioClientTransport.start()` now also clears the read buffer, and `close()` detaches the child's stderr pipe and ends the aggregate stream once teardown completes, so a dead predecessor cannot corrupt the next child's first frame, `stderr` readers of a closed transport always see `end`, and a restarted life begins on a fresh stream (re-read the `stderr` getter).

docs/migration/support-2026-07-28.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ a dead HTTP server is never misreported as legacy. One browser-specific exceptio
8282
opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because
8383
deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers.
8484

85+
One behavior change to the transport itself: with `stderr: 'pipe'`,
86+
`StdioClientTransport.close()` now ends the aggregate stderr stream when its teardown
87+
completes (previously the stream ended only when the child's own stderr closed — never,
88+
if that pipe outlived `close()`), and a `start()` after `close()` begins a fresh
89+
stream — re-read the `stderr` getter.
90+
8591
```typescript
8692
versionNegotiation: {
8793
mode: 'auto',

docs/protocol-versions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ const cli = new Client(
101101

102102
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.
103103

104+
A connection that closes mid-probe follows the same transport split. On stdio a child that exits on the unrecognized probe is a legacy server — stdio servers built on SDKs that terminate on any pre-`initialize` request (the official Rust SDK, rmcp) behave exactly this way — so under `'auto'` the client respawns the child once and falls back to `initialize` there (the exit must close the child's stdio pipes to register as a close — an exit hidden behind a helper process holding them open falls to the probe-timeout path instead): pre-connect `stderr` subscribers should re-read that getter after `connect()` resolves, and a pre-set `onclose` observer will see the probe child's close. The respawn is limited to transports that positively report close provenance (the SDK's own `StdioClientTransport` does): a transport the caller closed during the probe, or a custom stdio-shaped transport that does not track it, is never respawned — and on HTTP a mid-probe close is ambiguous, never era evidence — `connect()` rejects with the same typed `SdkError(EraNegotiationFailed)` as any probe transport failure.
105+
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

examples/cli-client/host/host.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -513,11 +513,16 @@ export class McpHost {
513513
cwd: entry.cwd,
514514
stderr: 'pipe'
515515
});
516-
transport.stderr?.on('data', (chunk: Buffer) => {
516+
const logStderr = (chunk: Buffer) => {
517517
const line = String(chunk).trim();
518518
if (line) this.ui.serverLog(name, 'stderr', line);
519-
});
519+
};
520+
const preConnectStderr = transport.stderr;
521+
preConnectStderr?.on('data', logStderr);
520522
await client.connect(transport);
523+
// Under 'auto', a legacy fallback may have respawned the child with a
524+
// fresh stderr stream — re-read the getter and subscribe the new life.
525+
if (transport.stderr !== preConnectStderr) transport.stderr?.on('data', logStderr);
521526
}
522527

523528
try {

packages/client/src/client/probeClassifier.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export type ProbeOutcome =
4848
| { kind: 'network-error'; error: unknown }
4949
/** The transport's auth flow challenged during the probe send (`UnauthorizedError`). */
5050
| { kind: 'auth-required'; error: Error }
51+
/** The transport reported close while the probe awaited its reply. */
52+
| { kind: 'closed' }
5153
/** No response arrived within the probe timeout. */
5254
| { kind: 'timeout'; timeoutMs: number };
5355

@@ -141,6 +143,18 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi
141143
// handshake an auth-gated modern server as legacy.
142144
return { kind: 'error', error: outcome.error };
143145
}
146+
case 'closed': {
147+
if (context.transportKind === 'stdio') {
148+
// A stdio child that exits on the unrecognized probe instead of
149+
// answering is a legacy signal — the same backward-compatibility
150+
// rule as the timeout row below (SDKs like the official Rust one
151+
// terminate the server on ANY pre-initialize request).
152+
return { kind: 'legacy' };
153+
}
154+
// On HTTP a mid-probe close is an ambiguous network condition — the
155+
// same typed connect error as any probe transport failure.
156+
return classifyNetworkError(new Error('Connection closed during the version negotiation probe'), context);
157+
}
144158
case 'timeout': {
145159
if (context.transportKind === 'stdio') {
146160
// Per the stdio transport's backward-compatibility rule, a probe
@@ -253,7 +267,8 @@ function isOpaqueFetchTypeError(error: unknown): boolean {
253267
return error instanceof TypeError || (error instanceof Error && error.name === 'TypeError');
254268
}
255269

256-
function describeError(error: unknown): string {
270+
/** Human-readable rendering of an unknown thrown value for probe diagnostics. */
271+
export function describeError(error: unknown): string {
257272
return error instanceof Error ? error.message : String(error);
258273
}
259274

packages/client/src/client/stdio.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core-inter
77
import { ReadBuffer, SdkError, SdkErrorCode, serializeMessage } from '@modelcontextprotocol/core-internal';
88
import spawn from 'cross-spawn';
99

10+
import { CLOSED_BY_CALLER } from './versionNegotiation';
11+
1012
export type StdioServerParameters = {
1113
/**
1214
* The executable to run to start the server.
@@ -126,6 +128,16 @@ export class StdioClientTransport implements Transport {
126128
);
127129
}
128130

131+
// Close-provenance stamp (see CLOSED_BY_CALLER): a fresh life has not been closed locally.
132+
(this as { [CLOSED_BY_CALLER]?: boolean })[CLOSED_BY_CALLER] = false;
133+
// A dead predecessor's late flush must not prepend to the new child's first frame.
134+
this._readBuffer.clear();
135+
if (this._stderrStream?.writableEnded) {
136+
// stderr: 'pipe' — the previous life's stream was ended (by its natural death, or by
137+
// close() completing teardown): this life pipes into a fresh one. Re-read the getter.
138+
this._stderrStream = new PassThrough();
139+
}
140+
129141
return new Promise((resolve, reject) => {
130142
this._process = spawn(this._serverParams.command, this._serverParams.args ?? [], {
131143
// merge default env with server env because mcp server needs some env vars
@@ -183,6 +195,9 @@ export class StdioClientTransport implements Transport {
183195
* If `stderr` piping was requested, a `PassThrough` stream is returned _immediately_, allowing callers to
184196
* attach listeners before the `start` method is invoked. This prevents loss of any early
185197
* error output emitted by the child process.
198+
*
199+
* Under `versionNegotiation: { mode: 'auto' }`, a legacy fallback may respawn the child with a
200+
* fresh stream — re-read this getter after `connect()` resolves.
186201
*/
187202
get stderr(): Stream | null {
188203
if (this._stderrStream) {
@@ -217,6 +232,8 @@ export class StdioClientTransport implements Transport {
217232
}
218233

219234
async close(): Promise<void> {
235+
// Close-provenance stamp (see CLOSED_BY_CALLER): a local close is never respawned.
236+
(this as { [CLOSED_BY_CALLER]?: boolean })[CLOSED_BY_CALLER] = true;
220237
if (this._process) {
221238
const processToClose = this._process;
222239
this._process = undefined;
@@ -252,6 +269,19 @@ export class StdioClientTransport implements Transport {
252269
// ignore
253270
}
254271
}
272+
273+
// Teardown is done: a predecessor whose stderr pipe outlives close()
274+
// (a held-open pipe, a SIGKILL still landing) must not end the stream
275+
// a NEXT life pipes into after start()'s recreation check. Detached
276+
// here — not at the top — so shutdown-time stderr (the child's own
277+
// signal/EOF diagnostics) still reaches subscribers during the waits above.
278+
processToClose.stderr?.unpipe();
279+
// ...and end the aggregate deterministically: after the unpipe the
280+
// predecessor's own end can no longer arrive, so without this a
281+
// stream whose child outlived close() would NEVER end — hanging
282+
// finished()/for-await readers. A stream the graceful path already
283+
// ended is unaffected; start() begins the next life on a fresh one.
284+
this._stderrStream?.end();
255285
}
256286

257287
this._readBuffer.clear();

packages/client/src/client/versionNegotiation.ts

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727

2828
import { UnauthorizedError } from './auth';
2929
import type { ProbeEnvironment, ProbeOutcome, ProbeTransportKind, ProbeVerdict } from './probeClassifier';
30-
import { classifyProbeOutcome } from './probeClassifier';
30+
import { classifyProbeOutcome, describeError } from './probeClassifier';
3131

3232
/**
3333
* Probe policy for `'auto'` and pinned negotiation modes.
@@ -71,7 +71,16 @@ export interface VersionNegotiationProbeOptions {
7171
* outcome is definitive modern evidence. Network outage rejects with a typed
7272
* connect error; a probe timeout falls back to `initialize` on stdio (a silent
7373
* server on a local pipe is a legacy server) and rejects with a typed timeout
74-
* error on HTTP (silence there is an outage).
74+
* error on HTTP (silence there is an outage). A connection closed mid-probe
75+
* follows the same split: on stdio a child that exits on the unrecognized
76+
* probe is a legacy server — the client respawns it once (`close()` +
77+
* `start()`; only for transports that positively report close provenance,
78+
* as the SDK's `StdioClientTransport` does — others reject with the typed
79+
* connect error) and runs `initialize` on the fresh child. The exit counts
80+
* as a close only once it closes the child's stdio pipes; an exit hidden
81+
* behind a helper process holding them open falls to the probe-timeout
82+
* path instead. On HTTP a mid-probe close rejects with the same typed
83+
* connect error as any probe transport failure.
7584
* - `{ pin: '<version>' }` — modern era at exactly the pinned revision: the
7685
* connect-time `server/discover` must offer it. No fallback — anything else
7786
* fails loudly with a typed error.
@@ -158,6 +167,22 @@ export function detectProbeTransportKind(transport: Transport): ProbeTransportKi
158167
return 'stderr' in transport && 'pid' in transport ? 'stdio' : 'http';
159168
}
160169

170+
/**
171+
* Close-provenance contract: a transport opts into the legacy-fallback respawn
172+
* by stamping `this[CLOSED_BY_CALLER] = false` in `start()` and `= true` at the
173+
* top of `close()` — negotiation respawns only on a `false` stamp; a `true`
174+
* stamp (local close) or an absent stamp (no provenance tracked) is never
175+
* respawned. `Symbol.for`: one shared registry key across bundled SDK copies,
176+
* no import needed to interoperate. Internal — not part of the public surface.
177+
*/
178+
export const CLOSED_BY_CALLER: unique symbol = Symbol.for('mcp.closedByCaller');
179+
180+
/** Typed read of the {@linkcode CLOSED_BY_CALLER} stamp — `undefined` when the transport does not implement the contract. */
181+
function closeProvenance(transport: Transport): boolean | undefined {
182+
const value = (transport as { [CLOSED_BY_CALLER]?: unknown })[CLOSED_BY_CALLER];
183+
return typeof value === 'boolean' ? value : undefined;
184+
}
185+
161186
/** Raw reply from one probe exchange, before normalization. */
162187
type RawProbeReply =
163188
| { kind: 'response'; result?: unknown; error?: { code: number; message: string; data?: unknown } }
@@ -264,6 +289,16 @@ class ProbeWindow {
264289
});
265290
}
266291

292+
/**
293+
* The legacy-fallback respawn opened a fresh connection: the forwarded
294+
* mid-window close belonged to the previous life, so the successor's
295+
* eventual close is a NEW event — detach()'s spent-close guard must not
296+
* swallow it for the caller's pre-set observer.
297+
*/
298+
noteRespawn(): void {
299+
this._closeDelivered = false;
300+
}
301+
267302
/** Detach the window's handlers, restoring any the caller pre-set, leaving the transport's own `start` untouched. */
268303
detach(): void {
269304
this._pending = undefined;
@@ -339,7 +374,9 @@ function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome {
339374
return { kind: 'network-error', error };
340375
}
341376
case 'closed': {
342-
return { kind: 'network-error', error: new Error('Connection closed during the version negotiation probe') };
377+
// Not folded into network-error: the classifier's closed row is
378+
// transport-aware (stdio legacy signal vs typed error).
379+
return { kind: 'closed' };
343380
}
344381
case 'timeout': {
345382
return { kind: 'timeout', timeoutMs };
@@ -421,22 +458,72 @@ export async function negotiateEra(
421458
continue;
422459
}
423460
case 'legacy': {
461+
// A closed outcome carries its own cause — the pin and
462+
// modern-only diagnostics must name the close (the caller's
463+
// own close when provenance says so) instead of implying a
464+
// server/discover verdict that never happened.
465+
const closedCause =
466+
outcome.kind === 'closed'
467+
? closeProvenance(deps.transport) === true
468+
? 'the transport was closed during the server/discover probe'
469+
: 'the connection closed during the server/discover probe'
470+
: undefined;
424471
if (negotiation.kind === 'pin') {
425472
throw new SdkError(
426473
SdkErrorCode.EraNegotiationFailed,
427-
`Version negotiation failed: the server did not offer pinned protocol version ${negotiation.version} ` +
428-
`via server/discover (no fallback in pin mode)`
474+
closedCause === undefined
475+
? `Version negotiation failed: the server did not offer pinned protocol version ${negotiation.version} ` +
476+
`via server/discover (no fallback in pin mode)`
477+
: `Version negotiation failed: ${closedCause} before the server offered ` +
478+
`pinned protocol version ${negotiation.version} (no fallback in pin mode)`
429479
);
430480
}
431481
if (!negotiation.fallbackAvailable) {
432482
// Modern-only client: the legacy initialize fallback is
433483
// unavailable and must never carry a 2026-era version string.
434484
throw new SdkError(
435485
SdkErrorCode.EraNegotiationFailed,
436-
'Version negotiation failed: the server gave no modern evidence and this client supports no ' +
437-
'pre-2026-07-28 protocol version to fall back to'
486+
closedCause === undefined
487+
? 'Version negotiation failed: the server gave no modern evidence and this client supports no ' +
488+
'pre-2026-07-28 protocol version to fall back to'
489+
: `Version negotiation failed: ${closedCause} and this client supports no ` +
490+
'pre-2026-07-28 protocol version to fall back to'
438491
);
439492
}
493+
if (outcome.kind === 'closed') {
494+
// The probe consumed the connection (the child exited on
495+
// the unrecognized request): respawn via the public
496+
// lifecycle, then run the fallback handshake there —
497+
// but only on positive provenance that the close was
498+
// NOT local. The read happens BEFORE our own close()
499+
// below (the only close this path issues; start()
500+
// re-stamps false), so `true` always means the caller
501+
// closed mid-probe: never override that kill decision.
502+
// An absent stamp (custom/wrapper transports) fails
503+
// safe: never respawn a transport that cannot
504+
// distinguish its own close() from a child death.
505+
const provenance = closeProvenance(deps.transport);
506+
if (provenance !== false) {
507+
throw new SdkError(
508+
SdkErrorCode.EraNegotiationFailed,
509+
provenance === true
510+
? 'Version negotiation failed: the transport was closed during the server/discover probe'
511+
: 'Version negotiation failed: the connection closed during the server/discover probe and ' +
512+
'this transport does not report close provenance, so the legacy-fallback respawn is unavailable'
513+
);
514+
}
515+
try {
516+
await deps.transport.close();
517+
await deps.transport.start();
518+
} catch (error) {
519+
throw new SdkError(
520+
SdkErrorCode.EraNegotiationFailed,
521+
`Version negotiation failed: the transport could not restart for the legacy fallback: ${describeError(error)}`,
522+
{ cause: error }
523+
);
524+
}
525+
window.noteRespawn();
526+
}
440527
return { era: 'legacy' };
441528
}
442529
case 'error': {

0 commit comments

Comments
 (0)