Skip to content

Commit dd2fcc7

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() unpipes the child's stderr, so a dead predecessor can neither corrupt the next child's first frame nor end the next life's stderr stream.
1 parent f60dff0 commit dd2fcc7

10 files changed

Lines changed: 638 additions & 8 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 — 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 once teardown completes, so a dead predecessor can neither corrupt the next child's first frame nor end the next life's `stderr` stream.

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: 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: 25 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,17 @@ 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 natural death ended the stream; this life pipes
137+
// into a fresh one. (A locally closed predecessor was unpiped in close() and cannot end
138+
// the stream, so an un-ended stream is safe to keep.)
139+
this._stderrStream = new PassThrough();
140+
}
141+
129142
return new Promise((resolve, reject) => {
130143
this._process = spawn(this._serverParams.command, this._serverParams.args ?? [], {
131144
// merge default env with server env because mcp server needs some env vars
@@ -183,6 +196,9 @@ export class StdioClientTransport implements Transport {
183196
* If `stderr` piping was requested, a `PassThrough` stream is returned _immediately_, allowing callers to
184197
* attach listeners before the `start` method is invoked. This prevents loss of any early
185198
* error output emitted by the child process.
199+
*
200+
* Under `versionNegotiation: { mode: 'auto' }`, a legacy fallback may respawn the child with a
201+
* fresh stream — re-read this getter after `connect()` resolves.
186202
*/
187203
get stderr(): Stream | null {
188204
if (this._stderrStream) {
@@ -217,6 +233,8 @@ export class StdioClientTransport implements Transport {
217233
}
218234

219235
async close(): Promise<void> {
236+
// Close-provenance stamp (see CLOSED_BY_CALLER): a local close is never respawned.
237+
(this as { [CLOSED_BY_CALLER]?: boolean })[CLOSED_BY_CALLER] = true;
220238
if (this._process) {
221239
const processToClose = this._process;
222240
this._process = undefined;
@@ -252,6 +270,13 @@ export class StdioClientTransport implements Transport {
252270
// ignore
253271
}
254272
}
273+
274+
// Teardown is done: a predecessor whose stderr pipe outlives close()
275+
// (a held-open pipe, a SIGKILL still landing) must not end the stream
276+
// a NEXT life pipes into after start()'s recreation check. Detached
277+
// here — not at the top — so shutdown-time stderr (the child's own
278+
// signal/EOF diagnostics) still reaches subscribers during the waits above.
279+
processToClose.stderr?.unpipe();
255280
}
256281

257282
this._readBuffer.clear();

packages/client/src/client/versionNegotiation.ts

Lines changed: 69 additions & 3 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,11 @@ 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()`) and runs `initialize` on the fresh child; on HTTP it rejects
78+
* with the same typed connect error as any probe transport failure.
7579
* - `{ pin: '<version>' }` — modern era at exactly the pinned revision: the
7680
* connect-time `server/discover` must offer it. No fallback — anything else
7781
* fails loudly with a typed error.
@@ -158,6 +162,22 @@ export function detectProbeTransportKind(transport: Transport): ProbeTransportKi
158162
return 'stderr' in transport && 'pid' in transport ? 'stdio' : 'http';
159163
}
160164

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

287+
/**
288+
* The legacy-fallback respawn opened a fresh connection: the forwarded
289+
* mid-window close belonged to the previous life, so the successor's
290+
* eventual close is a NEW event — detach()'s spent-close guard must not
291+
* swallow it for the caller's pre-set observer.
292+
*/
293+
noteRespawn(): void {
294+
this._closeDelivered = false;
295+
}
296+
267297
/** Detach the window's handlers, restoring any the caller pre-set, leaving the transport's own `start` untouched. */
268298
detach(): void {
269299
this._pending = undefined;
@@ -339,7 +369,9 @@ function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome {
339369
return { kind: 'network-error', error };
340370
}
341371
case 'closed': {
342-
return { kind: 'network-error', error: new Error('Connection closed during the version negotiation probe') };
372+
// Not folded into network-error: the classifier's closed row is
373+
// transport-aware (stdio legacy signal vs typed error).
374+
return { kind: 'closed' };
343375
}
344376
case 'timeout': {
345377
return { kind: 'timeout', timeoutMs };
@@ -437,6 +469,40 @@ export async function negotiateEra(
437469
'pre-2026-07-28 protocol version to fall back to'
438470
);
439471
}
472+
if (outcome.kind === 'closed') {
473+
// The probe consumed the connection (the child exited on
474+
// the unrecognized request): respawn via the public
475+
// lifecycle, then run the fallback handshake there —
476+
// but only on positive provenance that the close was
477+
// NOT local. The read happens BEFORE our own close()
478+
// below (the only close this path issues; start()
479+
// re-stamps false), so `true` always means the caller
480+
// closed mid-probe: never override that kill decision.
481+
// An absent stamp (custom/wrapper transports) fails
482+
// safe: never respawn a transport that cannot
483+
// distinguish its own close() from a child death.
484+
const provenance = closeProvenance(deps.transport);
485+
if (provenance !== false) {
486+
throw new SdkError(
487+
SdkErrorCode.EraNegotiationFailed,
488+
provenance === true
489+
? 'Version negotiation failed: the transport was closed during the server/discover probe'
490+
: 'Version negotiation failed: the connection closed during the server/discover probe and ' +
491+
'this transport does not report close provenance, so the legacy-fallback respawn is unavailable'
492+
);
493+
}
494+
try {
495+
await deps.transport.close();
496+
await deps.transport.start();
497+
} catch (error) {
498+
throw new SdkError(
499+
SdkErrorCode.EraNegotiationFailed,
500+
`Version negotiation failed: the transport could not restart for the legacy fallback: ${describeError(error)}`,
501+
{ cause: error }
502+
);
503+
}
504+
window.noteRespawn();
505+
}
440506
return { era: 'legacy' };
441507
}
442508
case 'error': {

packages/client/test/client/probeClassifier.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,22 @@ describe('row: timeout — transport-aware verdict', () => {
293293
});
294294
});
295295

296+
describe('row: closed — transport-aware verdict, symmetric with the timeout row', () => {
297+
test('stdio: a child that exits on the unrecognized probe is a legacy signal → legacy fallback', () => {
298+
expect(classify({ kind: 'closed' }, { transportKind: 'stdio' })).toEqual({ kind: 'legacy' });
299+
});
300+
301+
test('HTTP: a mid-probe close is ambiguous — the same typed connect error as any probe network failure', () => {
302+
const verdict = classify({ kind: 'closed' }, { transportKind: 'http' });
303+
expect(verdict.kind).toBe('error');
304+
if (verdict.kind === 'error') {
305+
expect(verdict.error).toBeInstanceOf(SdkError);
306+
expect((verdict.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed);
307+
expect(verdict.error.message).toContain('Connection closed during the version negotiation probe');
308+
}
309+
});
310+
});
311+
296312
describe('row: browser opaque CORS/preflight TypeError, PROBE PHASE ONLY → legacy fallback (F-7)', () => {
297313
test('browser environment + bare TypeError → legacy', () => {
298314
expect(classify({ kind: 'network-error', error: new TypeError('Failed to fetch') }, { environment: 'browser' })).toEqual({

0 commit comments

Comments
 (0)