Skip to content

Commit f011bd6

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. The probe window restores transport.close by identity, so repeated connects never stack wrapper layers. - 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; with stderr 'pipe' the shared stderr stream is piped with end: false so it spans restarts (a respawned child's write to the ended stream raised an unhandled stream error) and carries every child's output; a stale child close event (close() can resolve before it fires) no longer clears a restarted transport's successor state. - Pin-mode and modern-only failures name the mid-probe close instead of implying the server answered the probe. - Migration docs: SdkErrorCode table gains the new code; the 2026 probe-policy section documents the transport-aware close split.
1 parent 1480241 commit f011bd6

13 files changed

Lines changed: 665 additions & 38 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. With `stderr: 'pipe'`, the `stderr` stream now spans restarts: it is no longer ended by a child's exit, so it carries every child's output — key teardown on `onclose`, not on the stderr stream ending.

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,17 @@ error. Probe timeouts are **transport-aware**: on **stdio** a server that does n
7878
answer within `timeoutMs` is treated as legacy and the client falls back to `initialize`
7979
on the same stream (some legacy servers never respond to unknown pre-`initialize`
8080
requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)`
81-
a dead HTTP server is never misreported as legacy. One browser-specific exception: an
82-
opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because
83-
deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers.
81+
a dead HTTP server is never misreported as legacy. A mid-probe **connection close** is
82+
transport-aware the same way: on **stdio** a server process that exits on the
83+
unrecognized probe is treated as legacy — the client restarts the transport (respawning
84+
the server) and falls back to `initialize` there (stdio servers built on SDKs that
85+
terminate on any pre-`initialize` request behave this way); on **HTTP** a mid-probe
86+
close rejects with `SdkError(EraProbeConnectionClosed)` — an ambiguous drop is never
87+
misreported as legacy. A close initiated locally via `transport.close()` during the
88+
probe rejects with the same typed error on every transport. One browser-specific
89+
exception: an opaque CORS/preflight `TypeError` during the probe falls back to the
90+
legacy era, because deployed 2025 servers commonly have CORS allow-lists that predate
91+
the 2026 headers.
8492

8593
```typescript
8694
versionNegotiation: {

docs/migration/upgrade-to-v2.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -996,6 +996,7 @@ the third argument — `new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented,
996996
| `ListPaginationExceeded` | No-arg `list*()` aggregate walk hit `listMaxPages` |
997997
| `MethodNotSupportedByProtocolVersion` | Outbound spec method does not exist on the negotiated protocol version |
998998
| `EraNegotiationFailed` | `connect()` could not negotiate a protocol era (probe failed / no overlap) |
999+
| `EraProbeConnectionClosed` | Connection closed during the `server/discover` probe (HTTP-class transport, or a locally initiated close on any transport) |
9991000
| `ClientHttpNotImplemented` | HTTP POST request failed |
10001001
| `ClientHttpAuthentication` | Server returned 401 after re-authentication |
10011002
| `ClientHttpForbidden` | Server returned 403 `insufficient_scope` after step-up retry cap |

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: 31 additions & 10 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. With `stderr: 'pipe'`, the
127+
* {@linkcode stderr} stream spans restarts — it is not ended by a
128+
* child's exit and carries every child's output.
121129
*/
122130
async start(): Promise<void> {
123131
if (this._process) {
@@ -127,7 +135,7 @@ export class StdioClientTransport implements Transport {
127135
}
128136

129137
return new Promise((resolve, reject) => {
130-
this._process = spawn(this._serverParams.command, this._serverParams.args ?? [], {
138+
const proc = spawn(this._serverParams.command, this._serverParams.args ?? [], {
131139
// merge default env with server env because mcp server needs some env vars
132140
env: {
133141
...getDefaultEnvironment(),
@@ -138,26 +146,35 @@ export class StdioClientTransport implements Transport {
138146
windowsHide: process.platform === 'win32',
139147
cwd: this._serverParams.cwd
140148
});
149+
this._process = proc;
141150

142-
this._process.on('error', error => {
151+
proc.on('error', error => {
143152
reject(error);
144153
this.onerror?.(error);
145154
});
146155

147-
this._process.on('spawn', () => {
156+
proc.on('spawn', () => {
148157
resolve();
149158
});
150159

151-
this._process.on('close', _code => {
160+
proc.on('close', _code => {
161+
// Stale-event guard: close() can resolve before this event
162+
// fires, so a restarted transport may already own a successor
163+
// child — its state must not be touched by the old child's exit.
164+
if (this._process !== proc && this._process !== undefined) {
165+
return;
166+
}
152167
this._process = undefined;
168+
// Drop any partial trailing line so a restarted transport starts with a clean stream.
169+
this._readBuffer.clear();
153170
this.onclose?.();
154171
});
155172

156-
this._process.stdin?.on('error', error => {
173+
proc.stdin?.on('error', error => {
157174
this.onerror?.(error);
158175
});
159176

160-
this._process.stdout?.on('data', chunk => {
177+
proc.stdout?.on('data', chunk => {
161178
try {
162179
this._readBuffer.append(chunk);
163180
this.processReadBuffer();
@@ -167,12 +184,15 @@ export class StdioClientTransport implements Transport {
167184
}
168185
});
169186

170-
this._process.stdout?.on('error', error => {
187+
proc.stdout?.on('error', error => {
171188
this.onerror?.(error);
172189
});
173190

174-
if (this._stderrStream && this._process.stderr) {
175-
this._process.stderr.pipe(this._stderrStream);
191+
if (this._stderrStream && proc.stderr) {
192+
// end: false — the shared PassThrough must survive a child exit
193+
// so a restarted transport can pipe the next child's stderr into
194+
// it (writing to an ended stream raises ERR_STREAM_WRITE_AFTER_END).
195+
proc.stderr.pipe(this._stderrStream, { end: false });
176196
}
177197
});
178198
}
@@ -182,7 +202,8 @@ export class StdioClientTransport implements Transport {
182202
*
183203
* If `stderr` piping was requested, a `PassThrough` stream is returned _immediately_, allowing callers to
184204
* attach listeners before the `start` method is invoked. This prevents loss of any early
185-
* error output emitted by the child process.
205+
* error output emitted by the child process. The stream is not ended by a child's exit —
206+
* it spans transport restarts; key teardown on `onclose` instead.
186207
*/
187208
get stderr(): Stream | null {
188209
if (this._stderrStream) {

0 commit comments

Comments
 (0)