Skip to content

Commit c8698d1

Browse files
fix(client): handle connection close during the version negotiation probe
A connection that closed while the server/discover probe awaited its reply was a hard, untyped negotiation failure with no fallback. Stdio servers built on SDKs that terminate on any pre-initialize request (the official Rust SDK, rmcp, is the prominent example) connect fine under mode 'legacy' but hard-failed under 'auto'. - stdio: a server-side close during the probe is now a legacy signal, symmetric with the stdio probe-timeout rule. The stream died with the process, so the probe window leaves the transport's start() live and the Protocol.connect() handover restarts it (StdioClientTransport respawns from its retained parameters); the plain initialize fallback runs on the respawned process, byte-identical to a legacy connect. - HTTP-class: a mid-probe close stays an error (never era evidence) but is now typed: SdkErrorCode.EraProbeConnectionClosed, a stable caller predicate instead of message-sniffing. - A close initiated on the local side (transport.close() during the probe, caller shutdown or transport error recovery) gets the typed error on every transport: a local close never restarts the server. - A restart that itself fails (respawn error, or a stdio-shaped custom transport whose start() cannot run again) rejects with a typed EraNegotiationFailed carrying the underlying error as data.cause. - StdioClientTransport clears its read buffer on child exit so a restarted transport starts with a clean stream. - Pin-mode and modern-only failures name the mid-probe close instead of implying the server answered the probe.
1 parent 1480241 commit c8698d1

11 files changed

Lines changed: 593 additions & 22 deletions

File tree

.changeset/probe-close-handling.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@modelcontextprotocol/core-internal': patch
3+
'@modelcontextprotocol/client': patch
4+
---
5+
6+
Handle a connection that closes during the `server/discover` version-negotiation probe instead of dead-ending in an untyped failure.
7+
8+
On stdio, a server process that exits on the unrecognized probe is now classified as a legacy signal — symmetric with the stdio probe-timeout rule. Stdio servers built on SDKs that terminate on any pre-`initialize` request (the official Rust SDK, rmcp, is the prominent example) previously connected fine under `mode: 'legacy'` but hard-failed under `mode: 'auto'`; the client now restarts the transport (`StdioClientTransport.start()` respawns the server from its retained parameters, exactly what a `mode: 'legacy'` connect would have done) and completes the plain `initialize` handshake there. The respawned process sees bytes identical to a plain legacy connect.
9+
10+
On HTTP-class transports a mid-probe close stays an error — an ambiguous network condition is never era evidence — but is now typed: `SdkError` with the new `SdkErrorCode.EraProbeConnectionClosed`, giving callers a stable predicate instead of message-sniffing the previous generic `EraNegotiationFailed` wrapper. A close initiated on the local side (`transport.close()` during the probe — a caller shutdown, or the transport's own error recovery) gets the same typed error on every transport: a local close never restarts the server. A restart that itself fails (respawn error, or a stdio-shaped custom transport whose `start()` cannot run again) rejects with a typed `EraNegotiationFailed` carrying the underlying error as `data.cause`.
11+
12+
`StdioClientTransport` also clears its read buffer when the child process exits, so a restarted transport never sees a trailing partial line from the dead process. Known limitation: with `stderr: 'pipe'`, the `stderr` stream ends when the first process exits and does not carry a restarted process's output.

docs/protocol-versions.md

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

9696
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.
9797

98+
A connection that closes mid-probe follows the same transport split. On stdio a server process that exits on the unrecognized probe is a legacy server — stdio servers built on SDKs that terminate on any pre-`initialize` request behave exactly this way (the official Rust SDK, rmcp, is the prominent example) — so `connect()` restarts the transport, respawning the server, and falls back to `initialize` there; the respawned process sees the same bytes a `mode: 'legacy'` connect would have sent. On HTTP a mid-probe close is ambiguous — a proxy drop or a crash, never era evidence — so `connect()` rejects with the typed `SdkError(EraProbeConnectionClosed)`.
99+
98100
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)`.
99101

100102
::: warning

packages/client/src/client/client.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,11 +1077,35 @@ export class Client extends Protocol<ClientContext> {
10771077
throw error;
10781078
}
10791079

1080-
await super.connect(transport);
1080+
const restart = result.era === 'legacy' && result.restart === true;
1081+
try {
1082+
// On the stdio close-fallback (`restart`), negotiateEra left the
1083+
// transport's start() live, so this connect's unconditional start()
1084+
// genuinely restarts it — respawning the server process, exactly
1085+
// what a plain legacy connect does. Everywhere else start() is the
1086+
// armed one-shot pass-through.
1087+
await super.connect(transport);
1088+
} catch (error) {
1089+
if (!restart) throw error;
1090+
// A failed restart is a negotiation-phase failure: surface it with
1091+
// the typed contract every other negotiation failure has (the
1092+
// pre-restart behavior for a mid-probe close), not a raw transport
1093+
// error — e.g. a respawn spawn failure, or a stdio-shaped custom
1094+
// transport whose start() cannot run again after close.
1095+
await transport.close().catch(() => {});
1096+
throw new SdkError(
1097+
SdkErrorCode.EraNegotiationFailed,
1098+
'Version negotiation failed: the transport could not be restarted for the legacy fallback after the ' +
1099+
`connection closed during the probe: ${error instanceof Error ? error.message : String(error)}`,
1100+
{ cause: error }
1101+
);
1102+
}
10811103

10821104
if (result.era === 'legacy') {
10831105
// Conservative fallback: the plain legacy handshake on the SAME
1084-
// connection (the probe never touched the transport version slot).
1106+
// connection (the probe never touched the transport version slot) —
1107+
// or, on the stdio close-fallback, on the transport just restarted
1108+
// above (see the probe classifier's closed row for the rationale).
10851109
await this._legacyHandshake(transport, options);
10861110
return;
10871111
}

packages/client/src/client/probeClassifier.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
* Probe outcome classifier (pure module): maps the outcome of the connect-time
33
* `server/discover` probe onto one of four verdicts — modern era, the
44
* spec-mandated `-32022` corrective continuation, legacy fallback (the plain
5-
* 2025 `initialize` handshake on the same connection), or a typed connect error.
5+
* 2025 `initialize` handshake — on the same connection, or on a restarted
6+
* transport when a stdio close consumed it), or a typed connect error.
67
*
78
* The classifier is deliberately conservative: anything it does not positively
89
* recognize as modern resolves to the legacy fallback, and a network outage is a
@@ -48,6 +49,14 @@ export type ProbeOutcome =
4849
| { kind: 'network-error'; error: unknown }
4950
/** The transport's auth flow challenged during the probe send (`UnauthorizedError`). */
5051
| { kind: 'auth-required'; error: Error }
52+
/**
53+
* The transport reported close while the probe awaited its reply.
54+
* `localClose` is true when the close was initiated on the local side —
55+
* `transport.close()` ran during the probe window, whether from a caller
56+
* shutdown/abort or the transport's own error recovery — and is therefore
57+
* never a server-side signal.
58+
*/
59+
| { kind: 'closed'; localClose?: boolean }
5160
/** No response arrived within the probe timeout. */
5261
| { kind: 'timeout'; timeoutMs: number };
5362

@@ -82,8 +91,15 @@ export type ProbeVerdict =
8291
* arms a loop guard on the second rejection, throwing `error`.
8392
*/
8493
| { kind: 'corrective'; version: string; error: UnsupportedProtocolVersionError }
85-
/** Definitive legacy signal or unrecognized shape: perform the plain legacy `initialize` handshake on the same connection. */
86-
| { kind: 'legacy' }
94+
/**
95+
* Definitive legacy signal or unrecognized shape: perform the plain legacy
96+
* `initialize` handshake on the same connection. `restart` marks the one
97+
* row where the same connection no longer exists (stdio close): the
98+
* handshake runs on a restarted transport instead — for the stdio
99+
* transport, `start()` respawns the server process, which is exactly what
100+
* a `mode: 'legacy'` connect would have done.
101+
*/
102+
| { kind: 'legacy'; restart?: true }
87103
/** Typed connect error — never converted to an era verdict. */
88104
| { kind: 'error'; error: Error };
89105

@@ -125,6 +141,39 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi
125141
// handshake an auth-gated modern server as legacy.
126142
return { kind: 'error', error: outcome.error };
127143
}
144+
case 'closed': {
145+
if (outcome.localClose) {
146+
// The local side closed the transport mid-probe — a caller
147+
// shutdown/abort, or the transport's own error recovery (e.g.
148+
// read-buffer overflow). Never era evidence on any transport,
149+
// and on stdio it must not respawn a process the local side
150+
// chose to terminate.
151+
return {
152+
kind: 'error',
153+
error: new SdkError(
154+
SdkErrorCode.EraProbeConnectionClosed,
155+
'Connection closed locally during the version negotiation probe'
156+
)
157+
};
158+
}
159+
if (context.transportKind === 'stdio') {
160+
// A stdio child that exits on the unrecognized probe instead of
161+
// answering is a legacy signal — the same backward-compatibility
162+
// rule as the timeout row below (SDKs like the official Rust one
163+
// terminate the server on ANY pre-initialize request). The
164+
// stream died with the process, so the fallback cannot run on
165+
// the same connection: `restart` has the caller run `initialize`
166+
// on a restarted transport.
167+
return { kind: 'legacy', restart: true };
168+
}
169+
// On HTTP a mid-probe close is an ambiguous network condition
170+
// (proxy drop, crash, redeploy) — a typed connect error, never an
171+
// era verdict.
172+
return {
173+
kind: 'error',
174+
error: new SdkError(SdkErrorCode.EraProbeConnectionClosed, 'Connection closed during the version negotiation probe')
175+
};
176+
}
128177
case 'timeout': {
129178
if (context.transportKind === 'stdio') {
130179
// Per the stdio transport's backward-compatibility rule, a probe

packages/client/src/client/stdio.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,14 @@ export class StdioClientTransport implements Transport {
118118

119119
/**
120120
* Starts the server process and prepares to communicate with it.
121+
*
122+
* After the child process exits (`onclose`), `start()` may be called
123+
* again: a fresh process is spawned from the same
124+
* {@linkcode StdioServerParameters}. The client's version-negotiation
125+
* close-fallback relies on this to run the legacy handshake after a
126+
* server exits on the connect-time probe. Known limitation: with
127+
* `stderr: 'pipe'`, the {@linkcode stderr} stream ends when the first
128+
* process exits and does not carry a restarted process's output.
121129
*/
122130
async start(): Promise<void> {
123131
if (this._process) {
@@ -150,6 +158,8 @@ export class StdioClientTransport implements Transport {
150158

151159
this._process.on('close', _code => {
152160
this._process = undefined;
161+
// Drop any partial trailing line so a restarted transport starts with a clean stream.
162+
this._readBuffer.clear();
153163
this.onclose?.();
154164
});
155165

0 commit comments

Comments
 (0)