-
Notifications
You must be signed in to change notification settings - Fork 2k
fix(client): recover from connection close during the version-negotiation probe #2514
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| --- | ||
| '@modelcontextprotocol/core-internal': patch | ||
| '@modelcontextprotocol/client': patch | ||
| --- | ||
|
|
||
| Handle a connection that closes during the `server/discover` version-negotiation probe. | ||
|
|
||
| On stdio, a server process that exits on the unrecognized probe is a legacy signal — symmetric with the stdio probe-timeout rule. 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'`; `StdioClientTransport` now restarts itself (respawning the server from its retained parameters) and the client completes the plain `initialize` handshake there — the respawned process sees bytes identical to a plain legacy connect. A transport that was closed locally, or that cannot restart, rejects with a typed negotiation error instead: a deliberately terminated server is never resurrected. On HTTP-class transports a mid-probe close keeps surfacing as the same typed connect error as any other probe transport failure — an ambiguous drop is never era evidence. | ||
|
|
||
| `StdioClientTransport` restart mechanics: child events are generation-scoped (a slow-dying child's late events cannot touch its successor), the read buffer is cleared on child exit, and with `stderr: 'pipe'` the stream spans restarts — it ends exactly once, on `close()`, not on a child's exit. | ||
|
Check warning on line 10 in .changeset/probe-close-handling.md
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,8 +3,8 @@ | |
| import type { Stream } from 'node:stream'; | ||
| import { PassThrough } from 'node:stream'; | ||
|
|
||
| import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal'; | ||
| import { ReadBuffer, SdkError, SdkErrorCode, serializeMessage } from '@modelcontextprotocol/core-internal'; | ||
| import type { JSONRPCMessage, RestartableTransport } from '@modelcontextprotocol/core-internal'; | ||
| import { ReadBuffer, SdkError, SdkErrorCode, serializeMessage, TRANSPORT_RESTART } from '@modelcontextprotocol/core-internal'; | ||
| import spawn from 'cross-spawn'; | ||
|
|
||
| export type StdioServerParameters = { | ||
|
|
@@ -98,11 +98,15 @@ | |
| * | ||
| * This transport is only available in Node.js environments. | ||
| */ | ||
| export class StdioClientTransport implements Transport { | ||
| export class StdioClientTransport implements RestartableTransport { | ||
| private _process?: ChildProcess; | ||
| private _readBuffer: ReadBuffer; | ||
| private _serverParams: StdioServerParameters; | ||
| private _stderrStream: PassThrough | null = null; | ||
| /** Monotonic child generation; every child's event handlers check it so a dead child's late events cannot touch a successor. */ | ||
| private _generation = 0; | ||
| /** `true` after a local {@linkcode close} (caller shutdown or the transport's own error recovery); cleared by {@linkcode start}. */ | ||
| private _closedLocally = false; | ||
|
|
||
| onclose?: () => void; | ||
| onerror?: (error: Error) => void; | ||
|
|
@@ -118,16 +122,26 @@ | |
|
|
||
| /** | ||
| * Starts the server process and prepares to communicate with it. | ||
| * | ||
| * After the child process exits, `start()` may be called again: a fresh | ||
| * process is spawned from the same {@linkcode StdioServerParameters}. | ||
| */ | ||
| async start(): Promise<void> { | ||
| if (this._process) { | ||
|
claude[bot] marked this conversation as resolved.
|
||
| throw new Error( | ||
| 'StdioClientTransport already started! If using Client class, note that connect() calls start() automatically.' | ||
| ); | ||
| } | ||
| this._closedLocally = false; | ||
| if (this._stderrStream?.writableEnded) { | ||
| // close() ended the previous life's aggregate stream; a caller | ||
| // start() after close() begins a new life with a fresh stream. | ||
| this._stderrStream = new PassThrough(); | ||
| } | ||
|
Check warning on line 140 in packages/client/src/client/stdio.ts
|
||
|
Comment on lines
+136
to
+140
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The Extended reasoning...What the bug is. |
||
| const generation = ++this._generation; | ||
|
|
||
| return new Promise((resolve, reject) => { | ||
| this._process = spawn(this._serverParams.command, this._serverParams.args ?? [], { | ||
| const proc = spawn(this._serverParams.command, this._serverParams.args ?? [], { | ||
| // merge default env with server env because mcp server needs some env vars | ||
| env: { | ||
| ...getDefaultEnvironment(), | ||
|
|
@@ -138,26 +152,39 @@ | |
| windowsHide: process.platform === 'win32', | ||
| cwd: this._serverParams.cwd | ||
| }); | ||
| this._process = proc; | ||
|
|
||
| this._process.on('error', error => { | ||
| proc.on('error', error => { | ||
| reject(error); | ||
| this.onerror?.(error); | ||
| if (this._generation === generation) { | ||
| this.onerror?.(error); | ||
| } | ||
| }); | ||
|
|
||
| this._process.on('spawn', () => { | ||
| proc.on('spawn', () => { | ||
| resolve(); | ||
| }); | ||
|
|
||
| this._process.on('close', _code => { | ||
| proc.on('close', _code => { | ||
| if (this._generation !== generation) { | ||
| return; | ||
| } | ||
| this._process = undefined; | ||
| // Drop any partial trailing line so a restarted transport starts with a clean stream. | ||
| this._readBuffer.clear(); | ||
| this.onclose?.(); | ||
| }); | ||
|
Check warning on line 176 in packages/client/src/client/stdio.ts
|
||
|
Comment on lines
165
to
176
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 In the window between close() resolving and the next start(), a SIGKILLed child's late stdout flush still passes the generation guard (close() never bumps Extended reasoning...What the bug is. The generation-scoping this PR adds covers events landing after the next Why the new guard makes the residue sticky. Impact. The restarted child's first stdout chunk is appended after the stale partial line, so its first JSON-RPC message parses as Step-by-step proof.
The ordering in steps 3–5 (data flush before Why the SDK's own flows are unaffected — and why this is a nit. The version-negotiation Fix (one line). Clear Relation to the earlier review comments. The prior comments at this file targeted the then-unguarded data/error/close handlers injecting after |
||
|
|
||
| this._process.stdin?.on('error', error => { | ||
| this.onerror?.(error); | ||
| proc.stdin?.on('error', error => { | ||
| if (this._generation === generation) { | ||
| this.onerror?.(error); | ||
| } | ||
| }); | ||
|
|
||
| this._process.stdout?.on('data', chunk => { | ||
| proc.stdout?.on('data', chunk => { | ||
| if (this._generation !== generation) { | ||
| return; | ||
| } | ||
| try { | ||
| this._readBuffer.append(chunk); | ||
| this.processReadBuffer(); | ||
|
claude[bot] marked this conversation as resolved.
|
||
|
|
@@ -167,22 +194,39 @@ | |
| } | ||
| }); | ||
|
|
||
| this._process.stdout?.on('error', error => { | ||
| this.onerror?.(error); | ||
| proc.stdout?.on('error', error => { | ||
| if (this._generation === generation) { | ||
| this.onerror?.(error); | ||
| } | ||
| }); | ||
|
|
||
| if (this._stderrStream && this._process.stderr) { | ||
| this._process.stderr.pipe(this._stderrStream); | ||
| if (this._stderrStream && proc.stderr) { | ||
| // end: false — the aggregate stream spans children; it ends in close(), never on a child's exit. | ||
| proc.stderr.pipe(this._stderrStream, { end: false }); | ||
|
Comment on lines
201
to
+205
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 The { end: false } pipe change means the stderr PassThrough is now never ended on any code path — not on child exit and not even on final transport close(), which never touches _stderrStream — so every stderr: 'pipe' consumer (including plain mode: 'legacy' clients that never opt into version negotiation) loses the end-of-stream signal: code awaiting 'end'/stream.finished(), for-await-of loops, and .pipe(dest) log destinations now hang or never close where pre-PR they terminated on child exit. At minimum close() (deliberate final teardown — the SDK's own restart flow never goes through it) should end/destroy _stderrStream, and this consumer-facing behavior change on the default path needs a docs/migration entry (it is currently documented only in the changeset and JSDoc). Extended reasoning...What changed. Pre-PR, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The code half of this is now addressed well: One item from the original comment is still open: the docs/migration entry. The end-of-stream semantics on the default |
||
| } | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Version-negotiation close-fallback (internal, keyed by | ||
| * `TRANSPORT_RESTART`): respawn the server after the child died out from | ||
| * under the connection. Refuses after a local {@linkcode close} — a | ||
| * deliberately terminated server is never resurrected. | ||
| */ | ||
| async [TRANSPORT_RESTART](): Promise<void> { | ||
| if (this._closedLocally) { | ||
| throw new Error('StdioClientTransport was closed locally; refusing to respawn the server process'); | ||
| } | ||
| await this.start(); | ||
| } | ||
|
|
||
| /** | ||
| * The `stderr` stream of the child process, if {@linkcode StdioServerParameters.stderr} was set to `"pipe"` or `"overlapped"`. | ||
| * | ||
| * If `stderr` piping was requested, a `PassThrough` stream is returned _immediately_, allowing callers to | ||
| * attach listeners before the `start` method is invoked. This prevents loss of any early | ||
| * error output emitted by the child process. | ||
| * error output emitted by the child process. The stream spans restarts — a child's exit | ||
| * never ends it; it ends exactly once, when {@linkcode close} is called. | ||
| */ | ||
| get stderr(): Stream | null { | ||
| if (this._stderrStream) { | ||
|
|
@@ -217,8 +261,9 @@ | |
| } | ||
|
|
||
| async close(): Promise<void> { | ||
| if (this._process) { | ||
| const processToClose = this._process; | ||
| this._closedLocally = true; | ||
| const processToClose = this._process; | ||
| if (processToClose) { | ||
| this._process = undefined; | ||
|
|
||
| const closePromise = new Promise<void>(resolve => { | ||
|
|
@@ -255,6 +300,14 @@ | |
| } | ||
|
|
||
| this._readBuffer.clear(); | ||
|
|
||
| if (this._stderrStream !== null && !this._stderrStream.writableEnded) { | ||
| // The aggregate stderr stream ends exactly once, here. Unpipe first: | ||
| // a SIGKILLed child may still flush after we gave up waiting, and a | ||
| // write to an ended stream would error. | ||
| processToClose?.stderr?.unpipe(this._stderrStream); | ||
| this._stderrStream.end(); | ||
| } | ||
| } | ||
|
|
||
| send(message: JSONRPCMessage): Promise<void> { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 The PR description matches an earlier iteration of this change, not the shipped code, in four consumer-visible ways — most importantly, the Breaking Changes section tells callers to match
error.code === SdkErrorCode.EraProbeConnectionClosed, an enum member that exists nowhere in the codebase (the shipped HTTP mid-probe close rejects with the genericSdkError(EraNegotiationFailed)). The changeset and docs are correct; please refresh the PR description before merge since it will likely become the merge-commit body and reviewers/consumers will follow its (currently unusable) migration advice.Extended reasoning...
What is stale. The PR description describes a superseded revision of this change and now contradicts the shipped implementation in four ways:
Nonexistent error code in the migration advice. The Breaking Changes section says callers "should switch to
error.code === SdkErrorCode.EraProbeConnectionClosed(HTTP)". A grep over the entire checkout finds zero hits forEraProbeConnectionClosed— theSdkErrorCodeenum's negotiation code isEraNegotiationFailed, and the shipped HTTP mid-probe close routes throughclassifyProbeOutcome'sclosedrow →classifyNetworkError→SdkError(EraNegotiationFailed)(packages/client/src/client/probeClassifier.ts, pinned by the newprobeClassifier.test.tsrows and documented in bothdocs/protocol-versions.mdanddocs/migration/support-2026-07-28.md). A caller following the description's advice writes a predicate that cannot even compile. Earlier bot review comments in the timeline confirm a prior iteration did add that enum member — it was dropped in the rework without the description being updated.Wrong restart mechanism. The description says
negotiateEra"skips arming the one-shotstart()pass-through so theProtocol.connect()handover's unconditionalstart()respawns the server". The shipped code does the opposite: on theverdict.restartbranch,negotiateEraawaitstransport[TRANSPORT_RESTART]()itself (packages/client/src/client/versionNegotiation.ts), andwindow.release()— which does arm the one-shot pass-through that absorbsProtocol.connect()'sstart()— runs on every success path. Relatedly, the "Additional context" claim that "no new option or transport method is needed" contradicts the diff, which adds theTRANSPORT_RESTARTsymbol andRestartableTransportinterface topackages/core-internal/src/shared/transport.ts.Inverted stderr limitation. The description's "Known limitation: with
stderr: 'pipe', thestderrstream ends at the first child's exit and does not carry a restarted process's output" is the exact opposite of the shipped behavior:start()pipes with{ end: false }(packages/client/src/client/stdio.ts), the getter JSDoc says the stream spans restarts and ends exactly once inclose(), and bothstdioRestart.test.tsand the integration test pin that both children's banners reach the shared stream.Wrong custom-transport contract. The description says "custom stdio-shaped transports get the same behavior if their
start()supports being called again after close()". The shipped design instead gates restart on theTRANSPORT_RESTARTcapability symbol — a stdio-shaped transport with a perfectly re-callablestart()but no capability gets the typed 'cannot restart' error, pinned by the "stdio-shaped transport WITHOUT the restart capability: typed error" test inversionNegotiation.test.ts.Step-by-step proof for the most harmful claim (#1). (a) A caller on
mode: 'auto'over HTTP wants to handle a mid-probe drop; per the description they writeif (error instanceof SdkError && error.code === SdkErrorCode.EraProbeConnectionClosed) retry(). (b) TypeScript rejects the code outright (EraProbeConnectionClosedis not a member ofSdkErrorCode); if they hard-code a string instead, the predicate never matches. (c) The actual mid-probe HTTP close rejects withSdkError(EraNegotiationFailed)and the message "Version negotiation probe failed: Connection closed during the version negotiation probe" — exactly whatprobeClassifier.test.ts's HTTP-close rows pin. The migration advice is unusable as written.Why this is a nit, not blocking. Everything that actually ships is internally consistent: the changeset (
.changeset/probe-close-handling.md), both docs pages, the JSDoc, and the tests all match the code. The only stale artifact is the PR description itself — likely a leftover merge-commit body from the earlier iteration. Per this repo's own Documentation & Changesets convention, in-diff prose is what must match the implementation, and it does. No runtime behavior is wrong and no published artifact misleads consumers; the harm is confined to PR metadata (which will likely become the merge-commit body and the reference reviewers/consumers read).How to fix. Rewrite the PR description to match the shipped design before merge: drop the
EraProbeConnectionClosedmigration advice (HTTP closes stayEraNegotiationFailed), describe theTRANSPORT_RESTARTcapability + in-negotiateErarestart mechanism, replace the stderr "known limitation" with the actual spans-restarts{ end: false }behavior, and state that custom transports opt in via the capability symbol rather than a re-callablestart().