You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 it
once with a fresh transport start() (the probe's close event already
tore the connection down) and run the plain initialize handshake there.
The respawn is gated on a close-provenance stamp (well-known symbol in a
leaf module, implemented by StdioClientTransport): a transport the
caller closed mid-probe, or one that does not report provenance, rejects
with the typed connect error naming the close, and HTTP-class mid-probe
closes keep rejecting typed. Transport hygiene: start() clears the read
buffer and scopes child event handlers per life so a dead predecessor's
deferred events cannot touch a successor; close() unpipes the child's
stderr after teardown and always ends the aggregate stream (also for a
never-started transport); a respawned life whose predecessor's stream
was never read drops stderr instead of buffering toward backpressure.
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 a fresh transport `start()` — the probe's close event already tore the connection down — 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` hygiene: `start()` clears the read buffer and scopes child event handlers per life, so a dead predecessor's deferred `close`/stdout events can no longer touch a successor after a `close()` → `start()` restart; `close()` detaches the child's stderr pipe and ends the aggregate stream once teardown completes — also for a never-started transport — 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; a fresh stream nobody ever read drops output instead of buffering toward backpressure).
Copy file name to clipboardExpand all lines: docs/protocol-versions.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -101,6 +101,8 @@ const cli = new Client(
101
101
102
102
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.
103
103
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 (closed via `transport.close()` — killing the child process directly is outside the transport's API and indistinguishable from a server-side exit), 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
+
104
106
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)`.
0 commit comments