fix(client): probe stdio servers on a disposable sibling process (#2514)
This commit is contained in:
parent
f4137630c0
commit
6fe196310d
15 changed files with 1119 additions and 66 deletions
5
.changeset/probe-close-handling.md
Normal file
5
.changeset/probe-close-handling.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@modelcontextprotocol/client': patch
|
||||
---
|
||||
|
||||
Probe stdio servers on a disposable sibling process. Some stdio servers exit on any pre-`initialize` request (servers built on the official Rust SDK, rmcp, behave this way), so under `versionNegotiation: { mode: 'auto' }` the `server/discover` probe previously killed the server and `connect()` hard-failed. The probe now runs on a short-lived sibling spawned from the same parameters — its stderr is discarded and it is reaped once the era is known — and the caller's transport spawns exactly once, afterwards: a legacy verdict connects with the plain `initialize` handshake (byte-identical to `mode: 'legacy'`), a modern verdict is adopted directly, and the session wire never carries `server/discover`. Closing the caller's transport during the probe aborts `connect()` with the typed `SdkError(EraNegotiationFailed)` and the session child is never spawned. On HTTP — and on custom stdio-shaped transports, which probe in place — a mid-probe connection close keeps rejecting with the typed connect error, now naming the close in pin-mode and modern-only diagnostics.
|
||||
|
|
@ -55,8 +55,9 @@ client.getProtocolEra(); // 'modern' | 'legacy'
|
|||
```
|
||||
|
||||
- **absent / `mode: 'legacy'`** (default) — today's behavior, no probe.
|
||||
- **`mode: 'auto'`** — probe with `server/discover`; fall back to the 2025 handshake on
|
||||
the same connection against a 2025-only server (one extra round trip).
|
||||
- **`mode: 'auto'`** — probe with `server/discover`; fall back to the 2025 handshake
|
||||
against a 2025-only server (one extra round trip; on the SDK's stdio transport the
|
||||
probe rides a disposable sibling process — see below).
|
||||
- **`mode: { pin: '2026-07-28' }`** — modern only; no fallback, `connect()` rejects with
|
||||
`SdkError(EraNegotiationFailed)` against a 2025-only server.
|
||||
|
||||
|
|
@ -77,12 +78,25 @@ falls back to the legacy era — provided the supported-versions list still cont
|
|||
`SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect
|
||||
error. Probe timeouts are **transport-aware**: on **stdio** a server that does not
|
||||
answer within `timeoutMs` is treated as legacy and the client falls back to `initialize`
|
||||
on the same stream (some legacy servers never respond to unknown pre-`initialize`
|
||||
(some legacy servers never respond to unknown pre-`initialize`
|
||||
requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` —
|
||||
a dead HTTP server is never misreported as legacy. One browser-specific exception: an
|
||||
opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because
|
||||
deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers.
|
||||
|
||||
On the SDK's own stdio transport (exactly `StdioClientTransport` — subclasses probe
|
||||
in place, like custom stdio-shaped transports) the probe runs on a short-lived
|
||||
**sibling process** spawned from the same parameters (its stderr is discarded, and it is reaped once the
|
||||
era is known): some stdio servers exit on any pre-`initialize` request — servers built
|
||||
on the official Rust SDK, rmcp, behave this way — so the probe must not spend the
|
||||
caller's one child process. A child that exits on the probe is simply a legacy server;
|
||||
the caller's transport spawns exactly once, after the era is known, and its wire never
|
||||
carries `server/discover`. Closing the caller's transport during the probe aborts
|
||||
`connect()` with a typed `SdkError(EraNegotiationFailed)` and the session child is
|
||||
never spawned. On HTTP — and on custom stdio-shaped transports, which probe in
|
||||
place — a mid-probe connection close rejects with the same typed error as any probe
|
||||
transport failure.
|
||||
|
||||
```typescript
|
||||
versionNegotiation: {
|
||||
mode: 'auto',
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ console.log(client.getProtocolEra());
|
|||
modern
|
||||
```
|
||||
|
||||
Point the same options at a 2025-only server and `connect()` falls back to the `initialize` handshake on the same connection — one extra round trip, no error.
|
||||
Point the same options at a 2025-only server and `connect()` falls back to the `initialize` handshake — one extra round trip, no error (on the SDK's stdio transport the probe rides a disposable sibling process; see below).
|
||||
|
||||
```ts source="../examples/guides/protocolVersions.examples.ts#versionNegotiation_fallback"
|
||||
const fallback = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
|
@ -99,12 +99,14 @@ const cli = new Client(
|
|||
);
|
||||
```
|
||||
|
||||
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.
|
||||
A probe timeout is transport-aware. On stdio a silent server is a legacy server, so `connect()` falls back to `initialize`; 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.
|
||||
|
||||
On the SDK's own stdio transport (exactly `StdioClientTransport` — subclasses, like custom stdio-shaped transports, probe in place) the probe runs on a short-lived **sibling process** spawned from the same parameters — some stdio servers exit on any pre-`initialize` request (servers built on the official Rust SDK, rmcp, behave this way), so the probe must not spend the caller's one child process. The sibling is invisible infrastructure: its stderr is discarded and it is reaped once the era is known; the caller's transport spawns exactly once, afterwards, and its wire never carries `server/discover`. A child that exits on the probe is simply a legacy server (its exit must close the child's stdio pipes to register — an exit hidden behind a helper process holding them open falls to the probe-timeout path). Closing the caller's transport during the probe aborts `connect()` with a typed `SdkError(EraNegotiationFailed)` and the session child is never spawned. On HTTP — and on custom stdio-shaped transports, which probe in place — a mid-probe connection close rejects with the same typed error as any probe transport failure.
|
||||
|
||||
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)`.
|
||||
|
||||
::: warning
|
||||
Do not default a spawn-per-invocation CLI tool to `'auto'`. On stdio, a legacy server that never answers unknown pre-`initialize` requests stalls `connect()` for the full probe timeout before falling back, and the extra round trip changes recorded transcripts. Keep the default and expose `'auto'` (or a pin) as a flag.
|
||||
Do not default a spawn-per-invocation CLI tool to `'auto'`. On stdio, a legacy server that never answers unknown pre-`initialize` requests stalls `connect()` for the full probe timeout before falling back, and the probe spawns an extra short-lived server process per connect. Keep the default and expose `'auto'` (or a pin) as a flag.
|
||||
:::
|
||||
|
||||
## Serve both eras from one entry point
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
shape: reference
|
||||
---
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
Each heading on this page is the verbatim error message. Match yours, then apply that entry's fix.
|
||||
|
|
@ -66,7 +67,14 @@ With the global in place the [client OAuth](./clients/oauth.md) flows run unchan
|
|||
|
||||
## `SdkError: ERA_NEGOTIATION_FAILED`
|
||||
|
||||
`connect()` found no **protocol era** both sides speak. Two shapes produce it: `versionNegotiation: { mode: { pin: ... } }` names a revision the server does not offer over `server/discover`, and pinning never falls back; or `mode: 'auto'` with a `supportedProtocolVersions` list that has no pre-2026 entry, which removes the legacy fallback.
|
||||
`connect()` found no **protocol era** both sides speak, or the negotiation probe was cut short. Match the message tail:
|
||||
|
||||
- `the server did not offer pinned protocol version ... via server/discover (no fallback in pin mode)` — the pin names a revision the server does not offer, and pinning never falls back: drop the pin or use `'auto'`.
|
||||
- `the connection closed during the server/discover probe before the server offered pinned protocol version ...` — same pin, but the server exited on the probe (an exit-on-probe legacy server): use `'auto'`.
|
||||
- `the server gave no modern evidence and this client supports no pre-2026-07-28 protocol version to fall back to` — or its `the connection closed during the server/discover probe and this client supports no ...` variant — `mode: 'auto'` with a modern-only `supportedProtocolVersions` list removes the legacy fallback: restore a pre-2026 entry.
|
||||
- `the connection closed during the server/discover probe (this transport probed in place — the disposable sibling probe requires the SDK's base StdioClientTransport)` — a subclass of `StdioClientTransport`, or a custom stdio-shaped transport, probed in place and met a server that exits on any pre-`initialize` request: use the base `StdioClientTransport` (which probes on a disposable sibling), or `mode: 'legacy'`.
|
||||
- `the transport was closed during the server/discover probe` — the caller closed the transport while the probe was in flight; the connect aborted deliberately and the session child was never spawned.
|
||||
- `Version negotiation probe failed: ...` — the probe hit a transport failure (network outage, HTTP connection drop): fix connectivity and retry.
|
||||
|
||||
The pinned shape — `transport` here reaches a server still on the 2025 revisions ([Test a server](./testing.md) shows the in-memory wiring these outputs come from):
|
||||
|
||||
|
|
@ -87,7 +95,7 @@ The rejection names the pinned revision the server never offered:
|
|||
ERA_NEGOTIATION_FAILED: Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via server/discover (no fallback in pin mode)
|
||||
```
|
||||
|
||||
Change the mode to `'auto'`: the probe falls back to the 2025 `initialize` handshake on the same connection.
|
||||
Change the mode to `'auto'`: the probe falls back to the 2025 `initialize` handshake.
|
||||
|
||||
```ts source="../examples/guides/troubleshooting.examples.ts#connect_autoFallback"
|
||||
const negotiated = new Client({ name: 'app', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
|
|
|||
|
|
@ -84,7 +84,15 @@ import type { PriorDiscovery } from './probeClassifier';
|
|||
import type { CacheMode, CacheScope, ResponseCacheStore } from './responseCache';
|
||||
import { ClientResponseCache, InMemoryResponseCacheStore, MAX_CACHE_TTL_MS } from './responseCache';
|
||||
import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation';
|
||||
import { detectProbeEnvironment, detectProbeTransportKind, negotiateEra, resolveVersionNegotiation } from './versionNegotiation';
|
||||
import {
|
||||
detectProbeEnvironment,
|
||||
detectProbeTransportKind,
|
||||
disarmSpentCloseGuard,
|
||||
negotiateEra,
|
||||
negotiateStdioViaSibling,
|
||||
readStdioServerParams,
|
||||
resolveVersionNegotiation
|
||||
} from './versionNegotiation';
|
||||
|
||||
/**
|
||||
* The server identity a `DiscoverResult` carries in
|
||||
|
|
@ -199,11 +207,16 @@ export type ClientOptions = ProtocolOptions & {
|
|||
* - `mode: 'auto'` — `connect()` probes the server with `server/discover` first:
|
||||
* definitive modern evidence selects the modern era; definitive legacy signals
|
||||
* (and anything unrecognized) fall back to the plain legacy `initialize`
|
||||
* handshake on the same connection, byte-equivalent to a 2025 client. A
|
||||
* handshake, byte-equivalent to a 2025 client. On the SDK's own stdio
|
||||
* transport (the base `StdioClientTransport` exactly; subclasses probe in
|
||||
* place) the probe runs on a short-lived sibling process spawned from the
|
||||
* same parameters (one extra spawn per connect; its stderr is discarded) and
|
||||
* the caller's transport starts once, after the era is known; HTTP — and
|
||||
* custom or subclassed stdio-shaped transports — probe on the connection itself. A
|
||||
* network outage rejects with a typed connect error. A probe timeout is
|
||||
* transport-aware: on stdio it indicates a legacy server (some legacy servers
|
||||
* never answer unknown pre-`initialize` requests) and falls back to
|
||||
* `initialize` on the same stream; on HTTP it rejects with a typed timeout
|
||||
* `initialize`; on HTTP it rejects with a typed timeout
|
||||
* error (silence on a deployed server is an outage, not a legacy signal).
|
||||
* - `mode: { pin: '2026-07-28' }` — modern era at exactly the pinned revision;
|
||||
* no probe-and-fallback: anything else fails loudly.
|
||||
|
|
@ -1014,8 +1027,9 @@ export class Client extends Protocol<ClientContext> {
|
|||
|
||||
/**
|
||||
* The 2025 `initialize` handshake — the body of the plain legacy connect and
|
||||
* the `'auto'`-mode fallback path (same connection, same `initialize` body,
|
||||
* zero 2026 headers). Callers clear the negotiated protocol version before
|
||||
* the `'auto'`-mode fallback path (same `initialize` body, zero 2026 headers;
|
||||
* on the stdio sibling path it opens the session child's fresh pipe, in the
|
||||
* in-place modes it rides the probed connection). Callers clear the negotiated protocol version before
|
||||
* the handshake; its completion sets the negotiated (legacy) version.
|
||||
*/
|
||||
private async _legacyHandshake(transport: Transport, options?: RequestOptions): Promise<void> {
|
||||
|
|
@ -1087,8 +1101,9 @@ export class Client extends Protocol<ClientContext> {
|
|||
|
||||
/**
|
||||
* Negotiated connect (mode `'auto'` or `{ pin }`): probe with `server/discover`
|
||||
* before the Protocol machinery attaches, then either establish the modern era
|
||||
* or perform the plain legacy handshake on the same connection.
|
||||
* before the Protocol machinery attaches — on a disposable sibling process for
|
||||
* the SDK's stdio transport, in place otherwise — then either establish the
|
||||
* modern era or perform the plain legacy handshake.
|
||||
*/
|
||||
private async _connectNegotiated(
|
||||
transport: Transport,
|
||||
|
|
@ -1112,25 +1127,48 @@ export class Client extends Protocol<ClientContext> {
|
|||
|
||||
let result: Awaited<ReturnType<typeof negotiateEra>>;
|
||||
try {
|
||||
result = await negotiateEra(negotiation, {
|
||||
transport,
|
||||
const transportKind = detectProbeTransportKind(transport);
|
||||
const baseDeps = {
|
||||
clientInfo: this._clientInfo,
|
||||
capabilities: this._capabilities,
|
||||
environment: detectProbeEnvironment(),
|
||||
transportKind: detectProbeTransportKind(transport),
|
||||
defaultTimeoutMs: options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC
|
||||
});
|
||||
};
|
||||
// The SDK's stdio transport probes on a disposable sibling process,
|
||||
// so the caller's transport spends its one child life on the
|
||||
// session, never on the probe. Stdio-shaped transports without
|
||||
// readable spawn parameters (and HTTP) probe in place, as before.
|
||||
const stdioParams = transportKind === 'stdio' ? readStdioServerParams(transport) : undefined;
|
||||
result =
|
||||
stdioParams === undefined
|
||||
? await negotiateEra(negotiation, { ...baseDeps, transport, transportKind })
|
||||
: await negotiateStdioViaSibling(negotiation, transport, stdioParams, baseDeps);
|
||||
} catch (error) {
|
||||
// Typed connect error — close the channel like a failed initialize does.
|
||||
await transport.close().catch(() => {});
|
||||
// The cleanup close has settled: any re-delivery of a spent
|
||||
// mid-probe close has happened by now, so the spent-close guard
|
||||
// must not outlive it — on transports whose close() never re-fires
|
||||
// onclose (stdio), an armed guard would swallow the next GENUINE
|
||||
// close after a restart.
|
||||
disarmSpentCloseGuard(transport);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Success path: no cleanup close ever runs here, so no re-delivery of a
|
||||
// spent mid-window close is coming — disarm any guard NOW, before
|
||||
// Protocol chains the handler slot, or an in-place negotiation that
|
||||
// succeeded after a same-tick reply-then-close would carry the armed
|
||||
// skip into the live session and swallow its first genuine close.
|
||||
disarmSpentCloseGuard(transport);
|
||||
|
||||
await super.connect(transport);
|
||||
|
||||
if (result.era === 'legacy') {
|
||||
// Conservative fallback: the plain legacy handshake on the SAME
|
||||
// connection (the probe never touched the transport version slot).
|
||||
// Conservative fallback: the in-place probing modes run the plain
|
||||
// legacy handshake on the probed connection; the sibling path runs
|
||||
// it as the session child's first exchange (the probe never touched
|
||||
// the transport version slot either way).
|
||||
await this._legacyHandshake(transport, options);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
* Probe outcome classifier (pure module): maps the outcome of the connect-time
|
||||
* `server/discover` probe onto one of four verdicts — modern era, the
|
||||
* spec-mandated `-32022` corrective continuation, legacy fallback (the plain
|
||||
* 2025 `initialize` handshake on the same connection), or a typed connect error.
|
||||
* 2025 `initialize` handshake — on the probed connection for in-place probes,
|
||||
* on the session child's fresh pipe when the probe rode a disposable
|
||||
* sibling), or a typed connect error.
|
||||
*
|
||||
* The classifier is deliberately conservative: anything it does not positively
|
||||
* recognize as modern resolves to the legacy fallback, and a network outage is a
|
||||
|
|
@ -28,10 +30,11 @@ import {
|
|||
export type ProbeEnvironment = 'node' | 'browser';
|
||||
|
||||
/**
|
||||
* The transport class the probe ran on. Only consulted for the timeout row: a
|
||||
* stdio probe that times out signals a legacy server, while an HTTP timeout
|
||||
* stays a typed error. Anything that is not the stdio child-process transport
|
||||
* is treated like HTTP.
|
||||
* The transport class the probe ran on. Consulted for the timeout and closed
|
||||
* rows: a stdio probe that times out — or whose child exits, closing the
|
||||
* connection — signals a legacy server, while an HTTP timeout or close stays
|
||||
* a typed error. Anything that is not the stdio child-process transport is
|
||||
* treated like HTTP.
|
||||
*/
|
||||
export type ProbeTransportKind = 'stdio' | 'http';
|
||||
|
||||
|
|
@ -48,6 +51,8 @@ export type ProbeOutcome =
|
|||
| { kind: 'network-error'; error: unknown }
|
||||
/** The transport's auth flow challenged during the probe send (`UnauthorizedError`). */
|
||||
| { kind: 'auth-required'; error: Error }
|
||||
/** The transport reported close while the probe awaited its reply. */
|
||||
| { kind: 'closed' }
|
||||
/** No response arrived within the probe timeout. */
|
||||
| { kind: 'timeout'; timeoutMs: number };
|
||||
|
||||
|
|
@ -82,7 +87,7 @@ export type ProbeVerdict =
|
|||
* arms a loop guard on the second rejection, throwing `error`.
|
||||
*/
|
||||
| { kind: 'corrective'; version: string; error: UnsupportedProtocolVersionError }
|
||||
/** Definitive legacy signal or unrecognized shape: perform the plain legacy `initialize` handshake on the same connection. */
|
||||
/** Definitive legacy signal or unrecognized shape: perform the plain legacy `initialize` handshake (on the probed connection in the in-place modes; on the session child's fresh pipe on the sibling path). */
|
||||
| { kind: 'legacy' }
|
||||
/** Typed connect error — never converted to an era verdict. */
|
||||
| { kind: 'error'; error: Error };
|
||||
|
|
@ -141,11 +146,24 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi
|
|||
// handshake an auth-gated modern server as legacy.
|
||||
return { kind: 'error', error: outcome.error };
|
||||
}
|
||||
case 'closed': {
|
||||
if (context.transportKind === 'stdio') {
|
||||
// A stdio child that exits on the unrecognized probe instead of
|
||||
// answering is a legacy signal — the same backward-compatibility
|
||||
// rule as the timeout row below (SDKs like the official Rust one
|
||||
// terminate the server on ANY pre-initialize request).
|
||||
return { kind: 'legacy' };
|
||||
}
|
||||
// On HTTP a mid-probe close is an ambiguous network condition — the
|
||||
// same typed connect error as any probe transport failure.
|
||||
return classifyNetworkError(new Error('Connection closed during the version negotiation probe'), context);
|
||||
}
|
||||
case 'timeout': {
|
||||
if (context.transportKind === 'stdio') {
|
||||
// Per the stdio transport's backward-compatibility rule, a probe
|
||||
// nobody answers within the timeout indicates a legacy server —
|
||||
// fall back to `initialize` on the same stream.
|
||||
// fall back to `initialize` (the sibling path runs it on the
|
||||
// session child's fresh pipe; in-place probes reuse the stream).
|
||||
return { kind: 'legacy' };
|
||||
}
|
||||
// On HTTP a deployed server answers, so silence is an outage, not a
|
||||
|
|
@ -177,8 +195,8 @@ function classifyResult(result: unknown, context: ProbeClassifierContext): Probe
|
|||
if (overlap !== undefined) {
|
||||
return { kind: 'modern', version: overlap, discover: parsed.value };
|
||||
}
|
||||
// A DiscoverResult with no overlap still drives era selection: initialize on
|
||||
// the same connection when fallback is possible, otherwise a typed error.
|
||||
// A DiscoverResult with no overlap still drives era selection: the legacy
|
||||
// `initialize` fallback when available, otherwise a typed error.
|
||||
if (context.fallbackAvailable) {
|
||||
return { kind: 'legacy' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,6 +216,61 @@ export class StdioClientTransport implements Transport {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reap a disposable probe sibling (see the version-negotiation sibling
|
||||
* flow): signal-first teardown awaiting process `exit` — never the `close`
|
||||
* event, so a helper process holding the child's stdio pipes can never
|
||||
* block disposal. Not part of the public transport lifecycle.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
private async _dispose(): Promise<void> {
|
||||
const proc = this._process;
|
||||
this._process = undefined;
|
||||
if (proc && proc.exitCode === null && proc.signalCode === null) {
|
||||
const exited = new Promise<void>(resolve => proc.once('exit', () => resolve()));
|
||||
try {
|
||||
proc.stdin?.end();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
proc.kill('SIGTERM');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await Promise.race([exited, new Promise(resolve => setTimeout(resolve, 1000).unref())]);
|
||||
if (proc.exitCode === null && proc.signalCode === null) {
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
await exited;
|
||||
}
|
||||
// The child is gone — release the PARENT-side pipe handles too. A helper
|
||||
// process holding the inherited write ends would otherwise keep them (and
|
||||
// with them the host's event loop: stdout carries a flowing 'data'
|
||||
// listener from start()) alive until the helper exits.
|
||||
try {
|
||||
proc?.stdout?.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
proc?.stdin?.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
proc?.stderr?.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this._readBuffer.clear();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this._process) {
|
||||
const processToClose = this._process;
|
||||
|
|
|
|||
|
|
@ -41,9 +41,11 @@ export interface VersionNegotiationProbeOptions {
|
|||
*
|
||||
* The timeout verdict is transport-aware: on stdio, a probe that gets no
|
||||
* response within the timeout indicates a legacy server and falls back to
|
||||
* the `initialize` handshake on the same stream; on HTTP, where a deployed
|
||||
* server answers and silence means an outage, `connect()` rejects with the
|
||||
* standard typed timeout error instead.
|
||||
* the `initialize` handshake (measured on the disposable sibling for the
|
||||
* SDK's own stdio transport, with the fallback running on the session
|
||||
* child's fresh pipe; in place for custom stdio-shaped transports); on
|
||||
* HTTP, where a deployed server answers and silence means an outage,
|
||||
* `connect()` rejects with the standard typed timeout error instead.
|
||||
*
|
||||
* @default the standard request timeout (`DEFAULT_REQUEST_TIMEOUT_MSEC`, or the `timeout` passed to `connect()`)
|
||||
*/
|
||||
|
|
@ -67,11 +69,19 @@ export interface VersionNegotiationProbeOptions {
|
|||
* - `'legacy'` — no negotiation: the plain 2025 connect sequence, byte-identical
|
||||
* to a client without this option.
|
||||
* - `'auto'` — probe with `server/discover` at connect; conservative fallback to
|
||||
* the plain legacy `initialize` handshake on the same connection unless the
|
||||
* outcome is definitive modern evidence. Network outage rejects with a typed
|
||||
* connect error; a probe timeout falls back to `initialize` on stdio (a silent
|
||||
* server on a local pipe is a legacy server) and rejects with a typed timeout
|
||||
* error on HTTP (silence there is an outage).
|
||||
* the plain legacy `initialize` handshake unless the outcome is definitive
|
||||
* modern evidence. Network outage rejects with a typed connect error; a
|
||||
* probe timeout falls back to `initialize` on stdio (a silent server on a
|
||||
* local pipe is a legacy server) and rejects with a typed timeout error on
|
||||
* HTTP (silence there is an outage). On the SDK's stdio transport (the base
|
||||
* `StdioClientTransport` exactly; subclasses probe in place) the probe
|
||||
* runs on a short-lived sibling process spawned from the same parameters
|
||||
* (its stderr is discarded) and the caller's transport starts once, after
|
||||
* the era is known — so a child that exits on the unrecognized probe, the
|
||||
* shape of servers built on SDKs that terminate on any pre-`initialize`
|
||||
* request, is simply a legacy server. A mid-probe connection close on HTTP
|
||||
* (or on a custom stdio-shaped transport, which probes in place) rejects
|
||||
* with the typed connect error.
|
||||
* - `{ pin: '<version>' }` — modern era at exactly the pinned revision: the
|
||||
* connect-time `server/discover` must offer it. No fallback — anything else
|
||||
* fails loudly with a typed error.
|
||||
|
|
@ -149,7 +159,7 @@ export function detectProbeEnvironment(): ProbeEnvironment {
|
|||
}
|
||||
|
||||
/**
|
||||
* Detect the transport class for the transport-aware timeout verdict (see
|
||||
* Detect the transport class for the transport-aware timeout and closed verdicts (see
|
||||
* {@linkcode ProbeTransportKind}). The stdio child-process transport is
|
||||
* recognized structurally (`stderr`/`pid` accessors, no `instanceof` — safe
|
||||
* across bundles); everything else is treated like HTTP.
|
||||
|
|
@ -269,26 +279,78 @@ class ProbeWindow {
|
|||
this._pending = undefined;
|
||||
this._transport.onmessage = this._savedOnMessage;
|
||||
this._transport.onerror = this._savedOnError;
|
||||
this._transport.onclose = this._closeDelivered ? undefined : this._savedOnClose;
|
||||
if (this._closeDelivered && this._savedOnClose !== undefined) {
|
||||
// The forwarded mid-window close is spent — the caller's cleanup
|
||||
// close() must not re-deliver it — but the slot must not be emptied
|
||||
// either: the caller still owns the transport, and its observer must
|
||||
// keep seeing later closes. The skip is scoped to the cleanup close
|
||||
// via disarmSpentCloseGuard(): transports whose close() re-fires
|
||||
// onclose (HTTP) consume it there; transports that never re-fire
|
||||
// (stdio: onclose comes only from the child's close event, which
|
||||
// already happened) would otherwise leave it armed to swallow the
|
||||
// NEXT genuine close after a restart.
|
||||
const saved = this._savedOnClose;
|
||||
const transport = this._transport;
|
||||
let spent = false;
|
||||
const wrapper = () => {
|
||||
if (!spent) {
|
||||
spent = true;
|
||||
return;
|
||||
}
|
||||
saved();
|
||||
};
|
||||
transport.onclose = wrapper;
|
||||
pendingSpentCloseGuards.set(transport, () => {
|
||||
// Restore by identity — never clobber a handler the caller
|
||||
// replaced in the meantime.
|
||||
if (transport.onclose === wrapper) {
|
||||
transport.onclose = saved;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this._transport.onclose = this._savedOnClose;
|
||||
}
|
||||
}
|
||||
|
||||
/** Detach the handlers and arm the one-shot `start()` pass-through for the `Protocol.connect()` handover. */
|
||||
release(): void {
|
||||
this.detach();
|
||||
const transport = this._transport;
|
||||
const originalStart = transport.start.bind(transport);
|
||||
// Save the raw property value (not a bound copy) so the restore below
|
||||
// returns `transport.start` to its original identity — no wrapper or
|
||||
// bind layer accretes across repeated connects on the same transport.
|
||||
const originalStart = transport.start;
|
||||
let armed = true;
|
||||
transport.start = async (): Promise<void> => {
|
||||
transport.start = async function (this: unknown): Promise<void> {
|
||||
if (armed) {
|
||||
armed = false;
|
||||
transport.start = originalStart;
|
||||
return;
|
||||
}
|
||||
return originalStart();
|
||||
return originalStart.call(transport);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spent-close guards from failed negotiations, pending disarm by the cleanup
|
||||
* site (keyed weakly — an abandoned transport carries its guard to GC).
|
||||
*/
|
||||
const pendingSpentCloseGuards = new WeakMap<Transport, () => void>();
|
||||
|
||||
/**
|
||||
* Disarm a failed negotiation's spent-close guard once the cleanup `close()`
|
||||
* has settled. Any re-delivery of the spent mid-window close happens DURING
|
||||
* that close (HTTP transports re-fire `onclose` there; stdio transports never
|
||||
* do — their close event already fired), so after it every close is genuine
|
||||
* and must reach the caller's pre-set observer.
|
||||
*/
|
||||
export function disarmSpentCloseGuard(transport: Transport): void {
|
||||
const disarm = pendingSpentCloseGuards.get(transport);
|
||||
pendingSpentCloseGuards.delete(transport);
|
||||
disarm?.();
|
||||
}
|
||||
|
||||
/** Build the probe request: `server/discover` carrying the full per-request `_meta` envelope. */
|
||||
export function buildProbeRequest(
|
||||
id: string,
|
||||
|
|
@ -339,7 +401,9 @@ function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome {
|
|||
return { kind: 'network-error', error };
|
||||
}
|
||||
case 'closed': {
|
||||
return { kind: 'network-error', error: new Error('Connection closed during the version negotiation probe') };
|
||||
// Not folded into network-error: the classifier's closed row is
|
||||
// transport-aware (stdio legacy signal vs typed error).
|
||||
return { kind: 'closed' };
|
||||
}
|
||||
case 'timeout': {
|
||||
return { kind: 'timeout', timeoutMs };
|
||||
|
|
@ -352,10 +416,18 @@ export interface NegotiationDeps {
|
|||
clientInfo: Implementation;
|
||||
capabilities: ClientCapabilities;
|
||||
environment: ProbeEnvironment;
|
||||
/** The transport class, for the transport-aware timeout verdict (see {@linkcode ProbeTransportKind}). */
|
||||
/** The transport class, for the transport-aware timeout and closed verdicts (see {@linkcode ProbeTransportKind}). */
|
||||
transportKind: ProbeTransportKind;
|
||||
/** The standard request timeout for this connect (probe inherits it unless `probe.timeoutMs` overrides). */
|
||||
defaultTimeoutMs: number;
|
||||
/**
|
||||
* The probe transport is a disposable sibling (see
|
||||
* {@linkcode negotiateStdioViaSibling}): a mid-probe close is ordinary
|
||||
* legacy evidence — the probe child spent itself — never a connect-fatal
|
||||
* condition. Absent for in-place probes, where a closed connection has no
|
||||
* fallback stream and stays a typed error.
|
||||
*/
|
||||
disposableProbe?: boolean;
|
||||
}
|
||||
|
||||
export type NegotiationResult = { era: 'modern'; version: string; discover: DiscoverResult } | { era: 'legacy' };
|
||||
|
|
@ -421,11 +493,18 @@ export async function negotiateEra(
|
|||
continue;
|
||||
}
|
||||
case 'legacy': {
|
||||
// A closed outcome carries its own cause — diagnostics must
|
||||
// name the close instead of implying a server/discover
|
||||
// verdict that never happened.
|
||||
const closedCause = outcome.kind === 'closed' ? 'the connection closed during the server/discover probe' : undefined;
|
||||
if (negotiation.kind === 'pin') {
|
||||
throw new SdkError(
|
||||
SdkErrorCode.EraNegotiationFailed,
|
||||
`Version negotiation failed: the server did not offer pinned protocol version ${negotiation.version} ` +
|
||||
`via server/discover (no fallback in pin mode)`
|
||||
closedCause === undefined
|
||||
? `Version negotiation failed: the server did not offer pinned protocol version ${negotiation.version} ` +
|
||||
`via server/discover (no fallback in pin mode)`
|
||||
: `Version negotiation failed: ${closedCause} before the server offered ` +
|
||||
`pinned protocol version ${negotiation.version} (no fallback in pin mode)`
|
||||
);
|
||||
}
|
||||
if (!negotiation.fallbackAvailable) {
|
||||
|
|
@ -433,8 +512,22 @@ export async function negotiateEra(
|
|||
// unavailable and must never carry a 2026-era version string.
|
||||
throw new SdkError(
|
||||
SdkErrorCode.EraNegotiationFailed,
|
||||
'Version negotiation failed: the server gave no modern evidence and this client supports no ' +
|
||||
'pre-2026-07-28 protocol version to fall back to'
|
||||
closedCause === undefined
|
||||
? 'Version negotiation failed: the server gave no modern evidence and this client supports no ' +
|
||||
'pre-2026-07-28 protocol version to fall back to'
|
||||
: `Version negotiation failed: ${closedCause} and this client supports no ` +
|
||||
'pre-2026-07-28 protocol version to fall back to'
|
||||
);
|
||||
}
|
||||
if (closedCause !== undefined && deps.disposableProbe !== true) {
|
||||
// In-place probe: the connection died with the child and
|
||||
// there is no disposable sibling to spend — the
|
||||
// same-stream initialize fallback is impossible, so this
|
||||
// stays a typed connect error.
|
||||
throw new SdkError(
|
||||
SdkErrorCode.EraNegotiationFailed,
|
||||
`Version negotiation failed: ${closedCause} ` +
|
||||
"(this transport probed in place — the disposable sibling probe requires the SDK's base StdioClientTransport)"
|
||||
);
|
||||
}
|
||||
return { era: 'legacy' };
|
||||
|
|
@ -458,3 +551,109 @@ export async function negotiateEra(
|
|||
window.release();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural read of the SDK stdio transport's retained spawn parameters —
|
||||
* `undefined` for stdio-shaped transports that do not expose them, and for
|
||||
* SUBCLASSES of the SDK transport (those probe in place, where a mid-probe
|
||||
* close stays a typed connect error). The sibling path requires the transport
|
||||
* to be exactly the base class: a subclass cannot be faithfully cloned by
|
||||
* re-invoking its constructor with the retained params alone (extra ctor
|
||||
* arguments, transformed state, side effects). Exactness is checked without
|
||||
* importing the class (the negotiation graph must stay runtime-neutral): only
|
||||
* the base class's own prototype carries the internal `_dispose` reaper the
|
||||
* sibling flow depends on — a subclass's prototype does not own it.
|
||||
*/
|
||||
export function readStdioServerParams(transport: Transport): Record<string, unknown> | undefined {
|
||||
const proto = Object.getPrototypeOf(transport) as object | null;
|
||||
if (proto === null || !Object.prototype.hasOwnProperty.call(proto, '_dispose')) {
|
||||
return undefined;
|
||||
}
|
||||
const params = (transport as { _serverParams?: unknown })._serverParams;
|
||||
return typeof params === 'object' && params !== null && typeof (params as { command?: unknown }).command === 'string'
|
||||
? (params as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* stdio era negotiation on a DISPOSABLE SIBLING: stdio servers built on SDKs
|
||||
* that terminate on any pre-`initialize` request exit when the probe arrives,
|
||||
* so the probe must not spend the caller's one process life. `server/discover`
|
||||
* runs on a short-lived sibling spawned from the same parameters; the caller's
|
||||
* transport starts exactly once, after the era is known. The sibling is
|
||||
* invisible infrastructure: its stderr is discarded, and it is reaped before
|
||||
* this resolves (signal escalation awaiting process exit — a helper holding
|
||||
* its pipes never blocks disposal). A caller `close()` on the session
|
||||
* transport during the probe aborts the connect with the typed negotiation
|
||||
* error, and the session transport is never started.
|
||||
*/
|
||||
export async function negotiateStdioViaSibling(
|
||||
negotiation: Extract<ResolvedVersionNegotiation, { kind: 'auto' | 'pin' }>,
|
||||
sessionTransport: Transport,
|
||||
params: Record<string, unknown>,
|
||||
deps: Omit<NegotiationDeps, 'transport' | 'transportKind' | 'disposableProbe'>
|
||||
): Promise<NegotiationResult> {
|
||||
const SiblingTransport = sessionTransport.constructor as new (params: Record<string, unknown>) => Transport;
|
||||
const sibling = new SiblingTransport({ ...params, stderr: 'ignore' });
|
||||
|
||||
// The session transport is unstarted while the sibling probes, and closing
|
||||
// an unstarted transport records nothing — so watch its close() for the
|
||||
// window's duration (raw property saved; identity restored in the finally).
|
||||
// The abort races the probe: a caller close rejects promptly instead of
|
||||
// waiting out the probe timeout.
|
||||
const originalClose = sessionTransport.close;
|
||||
let callerClosed = false;
|
||||
let signalClosed: (() => void) | undefined;
|
||||
const closedSignal = new Promise<never>((_, reject) => {
|
||||
signalClosed = () => reject(callerCloseAbortError());
|
||||
});
|
||||
sessionTransport.close = async function (): Promise<void> {
|
||||
callerClosed = true;
|
||||
signalClosed?.();
|
||||
return originalClose.call(sessionTransport);
|
||||
};
|
||||
|
||||
let result: NegotiationResult;
|
||||
try {
|
||||
const negotiated = negotiateEra(negotiation, {
|
||||
...deps,
|
||||
transport: sibling,
|
||||
transportKind: 'stdio',
|
||||
disposableProbe: true
|
||||
});
|
||||
// The abort may orphan the probe promise; its late settlement (the
|
||||
// disposed sibling's close, a timeout) must not surface anywhere.
|
||||
negotiated.catch(() => {});
|
||||
result = await Promise.race([negotiated, closedSignal]);
|
||||
} finally {
|
||||
// Dispose FIRST, with the close watch still armed: a caller close()
|
||||
// landing during the disposal window must still trip the abort below —
|
||||
// only once the sibling is reaped does the caller get its close back.
|
||||
await disposeSibling(sibling);
|
||||
sessionTransport.close = originalClose;
|
||||
}
|
||||
if (callerClosed) {
|
||||
// Checked AFTER the finally so a close during either the probe or the
|
||||
// disposal window aborts: the session transport is never started.
|
||||
throw callerCloseAbortError();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The typed abort for a caller `close()` landing while the sibling probes. */
|
||||
function callerCloseAbortError(): SdkError {
|
||||
return new SdkError(
|
||||
SdkErrorCode.EraNegotiationFailed,
|
||||
'Version negotiation failed: the transport was closed during the server/discover probe'
|
||||
);
|
||||
}
|
||||
|
||||
/** Reap a probe sibling — best-effort; a sibling that cannot be reaped must not turn a settled verdict into an error. */
|
||||
async function disposeSibling(sibling: Transport): Promise<void> {
|
||||
try {
|
||||
const dispose = (sibling as { _dispose?: () => Promise<void> })._dispose;
|
||||
await (typeof dispose === 'function' ? dispose.call(sibling) : sibling.close());
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ describe('row: DiscoverResult with version overlap → modern, select from suppo
|
|||
});
|
||||
});
|
||||
|
||||
describe('row: DiscoverResult with NO overlap → initialize on the same connection, else typed error with synthesized data', () => {
|
||||
describe('row: DiscoverResult with NO overlap → legacy initialize fallback, else typed error with synthesized data', () => {
|
||||
test('fallback possible → legacy (era selection on a dual-era server)', () => {
|
||||
const verdict = classify({ kind: 'result', result: discoverResult(['2027-12-31']) });
|
||||
expect(verdict).toEqual({ kind: 'legacy' });
|
||||
|
|
@ -288,11 +288,27 @@ describe('row: timeout — transport-aware verdict', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('stdio: timeout is a legacy-server signal → fall back to initialize on the same stream', () => {
|
||||
test('stdio: timeout is a legacy-server signal → legacy initialize fallback', () => {
|
||||
expect(classify({ kind: 'timeout', timeoutMs: 5_000 }, { transportKind: 'stdio' })).toEqual({ kind: 'legacy' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('row: closed — transport-aware verdict, symmetric with the timeout row', () => {
|
||||
test('stdio: a child that exits on the unrecognized probe is a legacy signal → legacy verdict', () => {
|
||||
expect(classify({ kind: 'closed' }, { transportKind: 'stdio' })).toEqual({ kind: 'legacy' });
|
||||
});
|
||||
|
||||
test('HTTP: a mid-probe close is ambiguous — the same typed connect error as any probe network failure', () => {
|
||||
const verdict = classify({ kind: 'closed' }, { transportKind: 'http' });
|
||||
expect(verdict.kind).toBe('error');
|
||||
if (verdict.kind === 'error') {
|
||||
expect(verdict.error).toBeInstanceOf(SdkError);
|
||||
expect((verdict.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed);
|
||||
expect(verdict.error.message).toContain('Connection closed during the version negotiation probe');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('row: browser opaque CORS/preflight TypeError, PROBE PHASE ONLY → legacy fallback (F-7)', () => {
|
||||
test('browser environment + bare TypeError → legacy', () => {
|
||||
expect(classify({ kind: 'network-error', error: new TypeError('Failed to fetch') }, { environment: 'browser' })).toEqual({
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ const CORPUS: CorpusRow[] = [
|
|||
},
|
||||
// --- T9 edge 3: probe success but no version overlap → initialize on the SAME connection.
|
||||
{
|
||||
name: 'T9: DiscoverResult with no mutual version + fallback available → legacy (initialize on the same connection)',
|
||||
name: 'T9: DiscoverResult with no mutual version + fallback available → legacy (initialize fallback)',
|
||||
outcome: {
|
||||
kind: 'result',
|
||||
result: { supportedVersions: ['2027-01-01'], capabilities: {} }
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal';
|
||||
|
||||
import type { StdioServerParameters } from '../../src/client/stdio';
|
||||
|
|
@ -118,3 +121,32 @@ test('should fire onerror and close when ReadBuffer overflows', async () => {
|
|||
expect(error.message).toMatch(/ReadBuffer exceeded maximum size/);
|
||||
await closed;
|
||||
});
|
||||
|
||||
test('_dispose releases the parent-side pipe handles even when a helper process holds the child stdio', async () => {
|
||||
// The rmcp-holding anatomy: the child exits, but a helper it spawned with
|
||||
// stdio: 'inherit' keeps the pipe write ends open. Awaiting 'exit' settles
|
||||
// disposal promptly — but without destroying the PARENT-side handles, the
|
||||
// flowing stdout read handle stays ref'd until the helper exits, pinning
|
||||
// the host's event loop (indefinitely for a daemon helper).
|
||||
const readyFile = `${tmpdir()}/mcp-dispose-ready-${process.pid}-${Date.now()}`;
|
||||
const HOLDING_SCRIPT = String.raw`
|
||||
const { spawn } = require('child_process');
|
||||
spawn(process.execPath, ['-e', 'setTimeout(() => {}, 3000)'], { stdio: 'inherit' });
|
||||
require('fs').writeFileSync(${JSON.stringify(readyFile)}, 'ready');
|
||||
process.stdin.on('end', () => process.exit(0));
|
||||
process.stdin.resume();
|
||||
`;
|
||||
const transport = new StdioClientTransport({ command: process.execPath, args: ['-e', HOLDING_SCRIPT] });
|
||||
await transport.start();
|
||||
// Wait until the grandchild actually exists and holds the inherited pipes
|
||||
// — disposing earlier would kill the child before its script even runs.
|
||||
while (!existsSync(readyFile)) await new Promise(resolve => setTimeout(resolve, 25));
|
||||
const proc = (transport as unknown as { _process: import('node:child_process').ChildProcess })._process;
|
||||
|
||||
await (transport as unknown as { _dispose: () => Promise<void> })._dispose();
|
||||
|
||||
// The child is confirmed gone AND the parent-side handles are released —
|
||||
// destroyed flags are the deterministic proxy for "nothing pins the loop".
|
||||
expect(proc.stdout?.destroyed).toBe(true);
|
||||
expect(proc.stdin?.destroyed).toBe(true);
|
||||
}, 10_000);
|
||||
|
|
|
|||
|
|
@ -438,6 +438,342 @@ describe('probe timeout policy (transport-aware)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Probe close policy. The SDK's stdio transport probes on a DISPOSABLE
|
||||
* SIBLING spawned from the same `_serverParams` — servers built on SDKs that
|
||||
* terminate on any pre-initialize request (the official Rust SDK, rmcp) exit
|
||||
* when the probe arrives, so the probe must not spend the caller's one child
|
||||
* life. The session transport starts exactly once, after the era is known.
|
||||
* Stdio-shaped transports without readable params probe in place, where a
|
||||
* mid-probe close stays a typed error; HTTP closes stay typed errors.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
describe('stdio sibling probe (disposable probe transport)', () => {
|
||||
/**
|
||||
* An SDK-shaped stdio transport: retains `_serverParams` (the sibling
|
||||
* seam), and scripts its "child" from `params.behavior` — 'rmcp' exits on
|
||||
* any pre-initialize request, 'modern' answers server/discover, 'silent'
|
||||
* never replies. Mirrors StdioClientTransport's surface, including the
|
||||
* internal `_dispose` reaper (recorded, for the leak pins).
|
||||
*/
|
||||
class FakeStdioTransport implements Transport {
|
||||
static instances: FakeStdioTransport[] = [];
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
sessionId?: string;
|
||||
|
||||
readonly _serverParams: Record<string, unknown>;
|
||||
startCalls = 0;
|
||||
sent: JSONRPCMessage[] = [];
|
||||
setProtocolVersionCalls: string[] = [];
|
||||
disposed = false;
|
||||
private _alive = false;
|
||||
private _initialized = false;
|
||||
|
||||
constructor(params: Record<string, unknown>) {
|
||||
this._serverParams = params;
|
||||
FakeStdioTransport.instances.push(this);
|
||||
}
|
||||
|
||||
get stderr(): null {
|
||||
return null;
|
||||
}
|
||||
get pid(): number | null {
|
||||
return this._alive ? 100 + FakeStdioTransport.instances.indexOf(this) : null;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this._alive) throw new Error('FakeStdioTransport already started!');
|
||||
if (this._serverParams.behavior === 'spawnfail') throw new Error('spawn ENOENT');
|
||||
this._alive = true;
|
||||
this._initialized = false;
|
||||
this.startCalls++;
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
if (!this._alive) throw new Error('Not connected');
|
||||
this.sent.push(message);
|
||||
queueMicrotask(() => {
|
||||
if (!this._alive || !isJSONRPCRequest(message)) return;
|
||||
if (message.method === 'initialize') {
|
||||
this._initialized = true;
|
||||
this.onmessage?.({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result: {
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: {},
|
||||
serverInfo: { name: 'fake-stdio-server', version: '1.0.0' }
|
||||
}
|
||||
});
|
||||
} else if (message.method === 'server/discover' && this._serverParams.behavior === 'modern') {
|
||||
this.onmessage?.({ jsonrpc: '2.0', id: message.id, result: discoverResult([MODERN]) });
|
||||
} else if (!this._initialized && this._serverParams.behavior === 'rmcp') {
|
||||
// The rmcp shape: any other pre-initialize request kills the
|
||||
// child — no reply, just the close.
|
||||
this._alive = false;
|
||||
this.onclose?.();
|
||||
}
|
||||
// 'silent': no reply at all.
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this._alive) {
|
||||
this._alive = false;
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Test hook: observe the moment disposal begins (reset after use). */
|
||||
static onDisposeStart: (() => void) | undefined;
|
||||
|
||||
// Called structurally by the sibling reaper, like the real transport's.
|
||||
private async _dispose(): Promise<void> {
|
||||
FakeStdioTransport.onDisposeStart?.();
|
||||
this.disposed = true;
|
||||
this._alive = false;
|
||||
}
|
||||
|
||||
setProtocolVersion(version: string): void {
|
||||
this.setProtocolVersionCalls.push(version);
|
||||
}
|
||||
}
|
||||
|
||||
const freshInstances = () => {
|
||||
FakeStdioTransport.instances = [];
|
||||
};
|
||||
|
||||
test('rmcp exit-on-probe: the sibling spends itself, the session connects legacy on its first and only spawn', async () => {
|
||||
freshInstances();
|
||||
const session = new FakeStdioTransport({ command: 'srv', behavior: 'rmcp' });
|
||||
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
||||
await client.connect(session);
|
||||
|
||||
expect(FakeStdioTransport.instances).toHaveLength(2);
|
||||
const sibling = FakeStdioTransport.instances[1]!;
|
||||
// The sibling carries the same params with stderr discarded, took the
|
||||
// probe (and only the probe), and was reaped.
|
||||
expect(sibling._serverParams).toMatchObject({ command: 'srv', stderr: 'ignore' });
|
||||
expect(requests(sibling.sent).map(r => r.method)).toEqual(['server/discover']);
|
||||
expect(sibling.disposed).toBe(true);
|
||||
// The session transport: one spawn, no probe bytes, plain legacy connect.
|
||||
expect(session.startCalls).toBe(1);
|
||||
expect(requests(session.sent).map(r => r.method)).toEqual(['initialize']);
|
||||
expect(client.getNegotiatedProtocolVersion()).toBe('2025-03-26');
|
||||
expect(session.setProtocolVersionCalls).toEqual(['2025-03-26']);
|
||||
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test("the session's traffic is byte-identical to a plain mode:'legacy' connect", async () => {
|
||||
freshInstances();
|
||||
const autoSession = new FakeStdioTransport({ command: 'srv', behavior: 'rmcp' });
|
||||
const autoClient = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await autoClient.connect(autoSession);
|
||||
|
||||
const plainSession = new FakeStdioTransport({ command: 'srv', behavior: 'rmcp' });
|
||||
const plainClient = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'legacy' } });
|
||||
await plainClient.connect(plainSession);
|
||||
|
||||
expect(JSON.stringify(autoSession.sent)).toBe(JSON.stringify(plainSession.sent));
|
||||
// Explicit legacy mode never probes and never spawns a sibling.
|
||||
expect(FakeStdioTransport.instances).toHaveLength(3);
|
||||
expect(plainSession.startCalls).toBe(1);
|
||||
|
||||
await autoClient.close();
|
||||
await plainClient.close();
|
||||
});
|
||||
|
||||
test('modern server: the verdict is adopted verbatim — the session wire carries NO server/discover and no initialize', async () => {
|
||||
freshInstances();
|
||||
const session = new FakeStdioTransport({ command: 'srv', behavior: 'modern' });
|
||||
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
||||
await client.connect(session);
|
||||
|
||||
expect(FakeStdioTransport.instances).toHaveLength(2);
|
||||
expect(FakeStdioTransport.instances[1]!.disposed).toBe(true);
|
||||
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
|
||||
expect(client.getServerVersion()?.name).toBe('scripted-modern-server');
|
||||
expect(session.startCalls).toBe(1);
|
||||
// Byte-trace: the sibling probed; the session sent nothing at connect.
|
||||
expect(requests(session.sent)).toHaveLength(0);
|
||||
expect(session.setProtocolVersionCalls).toEqual([MODERN]);
|
||||
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test('caller close() mid-probe aborts promptly: typed error, the session transport is never started', async () => {
|
||||
freshInstances();
|
||||
const session = new FakeStdioTransport({ command: 'srv', behavior: 'silent' });
|
||||
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 30_000 } } });
|
||||
|
||||
const pending = client.connect(session);
|
||||
pending.catch(() => {});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await session.close();
|
||||
|
||||
const rejection = await pending.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error
|
||||
);
|
||||
expect(rejection).toBeInstanceOf(SdkError);
|
||||
expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed);
|
||||
expect((rejection as SdkError).message).toMatch(/transport was closed during the server\/discover probe/);
|
||||
expect(session.startCalls).toBe(0);
|
||||
expect(FakeStdioTransport.instances[1]!.disposed).toBe(true);
|
||||
// The wrapper restored the caller's close by identity.
|
||||
expect(session.close).toBe(FakeStdioTransport.prototype.close);
|
||||
});
|
||||
|
||||
test('pin mode: exit-on-probe rejects naming the close — session never started', async () => {
|
||||
freshInstances();
|
||||
const session = new FakeStdioTransport({ command: 'srv', behavior: 'rmcp' });
|
||||
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } });
|
||||
|
||||
const rejection = await client.connect(session).then(
|
||||
() => undefined,
|
||||
(error: unknown) => error
|
||||
);
|
||||
expect(rejection).toBeInstanceOf(SdkError);
|
||||
expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed);
|
||||
expect((rejection as SdkError).message).toMatch(
|
||||
/connection closed during the server\/discover probe before the server offered pinned/
|
||||
);
|
||||
expect(session.startCalls).toBe(0);
|
||||
expect(FakeStdioTransport.instances[1]!.disposed).toBe(true);
|
||||
});
|
||||
|
||||
test('modern-only client: exit-on-probe rejects naming the close — session never started', async () => {
|
||||
freshInstances();
|
||||
const session = new FakeStdioTransport({ command: 'srv', behavior: 'rmcp' });
|
||||
const client = new Client(
|
||||
{ name: 'c', version: '0' },
|
||||
{ versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN] }
|
||||
);
|
||||
|
||||
const rejection = await client.connect(session).then(
|
||||
() => undefined,
|
||||
(error: unknown) => error
|
||||
);
|
||||
expect(rejection).toBeInstanceOf(SdkError);
|
||||
expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed);
|
||||
expect((rejection as SdkError).message).toMatch(/connection closed during the server\/discover probe and this client supports no/);
|
||||
expect(session.startCalls).toBe(0);
|
||||
});
|
||||
|
||||
test('a sibling that cannot spawn propagates the raw spawn error (the session would fail identically)', async () => {
|
||||
freshInstances();
|
||||
const session = new FakeStdioTransport({ command: 'missing', behavior: 'spawnfail' });
|
||||
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
||||
await expect(client.connect(session)).rejects.toThrow(/spawn ENOENT/);
|
||||
expect(session.startCalls).toBe(0);
|
||||
});
|
||||
|
||||
test('a SUBCLASS of the SDK stdio transport probes in place: typed error naming the base-class requirement, no sibling spawned', async () => {
|
||||
// A subclass cannot be faithfully cloned by re-invoking its constructor
|
||||
// with the retained params alone — the gate is exact-class (only the
|
||||
// base prototype owns the internal reaper), so subclasses keep the
|
||||
// in-place behavior.
|
||||
freshInstances();
|
||||
class SubclassedStdioTransport extends FakeStdioTransport {}
|
||||
const transport = new SubclassedStdioTransport({ command: 'srv', behavior: 'rmcp' });
|
||||
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
||||
const rejection = await client.connect(transport).then(
|
||||
() => undefined,
|
||||
(error: unknown) => error
|
||||
);
|
||||
expect(rejection).toBeInstanceOf(SdkError);
|
||||
expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed);
|
||||
expect((rejection as SdkError).message).toMatch(/sibling probe requires the SDK's base StdioClientTransport/);
|
||||
// No sibling was constructed: the only instance is the caller's own,
|
||||
// and it took the probe itself (in place).
|
||||
expect(FakeStdioTransport.instances).toHaveLength(1);
|
||||
expect(requests(transport.sent).map(r => r.method)).toEqual(['server/discover']);
|
||||
});
|
||||
|
||||
test('a caller close() landing during sibling DISPOSAL still aborts: the session transport is never started', async () => {
|
||||
// The close watch stays armed through disposal (the finally disposes
|
||||
// BEFORE restoring close, and the abort is re-checked after it), so a
|
||||
// shutdown racing the reap cannot be followed by a session spawn.
|
||||
freshInstances();
|
||||
const session = new FakeStdioTransport({ command: 'srv', behavior: 'modern' });
|
||||
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
let closedDuringDisposal = false;
|
||||
FakeStdioTransport.onDisposeStart = () => {
|
||||
if (!closedDuringDisposal) {
|
||||
closedDuringDisposal = true;
|
||||
void session.close();
|
||||
}
|
||||
};
|
||||
try {
|
||||
const rejection = await client.connect(session).then(
|
||||
() => undefined,
|
||||
(error: unknown) => error
|
||||
);
|
||||
expect(closedDuringDisposal).toBe(true);
|
||||
expect(rejection).toBeInstanceOf(SdkError);
|
||||
expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed);
|
||||
expect((rejection as SdkError).message).toMatch(/transport was closed during the server\/discover probe/);
|
||||
expect(session.startCalls).toBe(0);
|
||||
} finally {
|
||||
FakeStdioTransport.onDisposeStart = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
test('a stdio-shaped transport WITHOUT readable spawn params probes in place: a mid-probe close stays a typed error', async () => {
|
||||
// Foreign transports have no sibling seam — exactly main's behavior.
|
||||
class ForeignStdioTransport extends ScriptedTransport {
|
||||
get stderr(): null {
|
||||
return null;
|
||||
}
|
||||
get pid(): number {
|
||||
return 4242;
|
||||
}
|
||||
}
|
||||
const transport = new ForeignStdioTransport((message, t) => {
|
||||
if (!isJSONRPCRequest(message)) return;
|
||||
if (message.method === 'server/discover') {
|
||||
t.onclose?.();
|
||||
}
|
||||
});
|
||||
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
||||
const rejection = await client.connect(transport).then(
|
||||
() => undefined,
|
||||
(error: unknown) => error
|
||||
);
|
||||
expect(rejection).toBeInstanceOf(SdkError);
|
||||
expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed);
|
||||
expect((rejection as SdkError).message).toMatch(/connection closed during the server\/discover probe/);
|
||||
expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false);
|
||||
});
|
||||
|
||||
test('HTTP-class transport: close mid-probe rejects like any probe transport failure — never a legacy verdict, never a sibling', async () => {
|
||||
const script: Script = (message, t) => {
|
||||
if (!isJSONRPCRequest(message)) return;
|
||||
if (message.method === 'server/discover') {
|
||||
t.onclose?.();
|
||||
return;
|
||||
}
|
||||
legacyServerScript(message, t);
|
||||
};
|
||||
const transport = new ScriptedTransport(script);
|
||||
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
||||
await expect(client.connect(transport)).rejects.toSatisfy(
|
||||
error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed
|
||||
);
|
||||
expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false);
|
||||
expect(transport.startCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* -32022 corrective continuation — exactly once; loop guard on second
|
||||
* rejection.
|
||||
|
|
@ -869,6 +1205,114 @@ describe('probe window preserves pre-set transport handlers', () => {
|
|||
await client.connect(transport).catch(() => undefined);
|
||||
|
||||
expect(closes).toBe(1);
|
||||
|
||||
// The spent-close guard was disarmed once the cleanup close settled
|
||||
// (ScriptedTransport.close() re-fires onclose, like the real HTTP
|
||||
// transport — the guard consumed exactly that re-delivery). A LATER
|
||||
// close is genuine and must reach the pre-set observer directly.
|
||||
transport.onclose?.();
|
||||
expect(closes).toBe(2);
|
||||
});
|
||||
|
||||
test('a negotiation that SUCCEEDS after a same-tick reply-then-close keeps the observer armed for the live session', async () => {
|
||||
// The success-path corner: the reply settles the exchange, then the
|
||||
// close arrives in the same tick — forwarded with nothing pending, so
|
||||
// negotiation still classifies the reply and succeeds. No cleanup
|
||||
// close ever runs on success, so no re-delivery is coming: the guard
|
||||
// must be disarmed before Protocol chains the handler slot, or the
|
||||
// session's first genuine close would be swallowed.
|
||||
const script: Script = (message, t) => {
|
||||
if (!isJSONRPCRequest(message)) return;
|
||||
if (message.method === 'server/discover') {
|
||||
t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } });
|
||||
t.onclose?.();
|
||||
return;
|
||||
}
|
||||
legacyServerScript(message, t);
|
||||
};
|
||||
const transport = new ScriptedTransport(script);
|
||||
let closes = 0;
|
||||
transport.onclose = () => {
|
||||
closes++;
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await client.connect(transport);
|
||||
expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25');
|
||||
// The mid-window close was forwarded exactly once.
|
||||
expect(closes).toBe(1);
|
||||
|
||||
// The session's own close is genuine and must reach the observer.
|
||||
await client.close();
|
||||
expect(closes).toBe(2);
|
||||
});
|
||||
|
||||
test("stdio-semantics transport (close() never re-fires onclose): a restarted life's genuine close reaches the pre-set observer after a failed negotiation", async () => {
|
||||
// Real stdio semantics: onclose fires only from the child's own close
|
||||
// event — the cleanup close() on a dead transport re-delivers NOTHING,
|
||||
// so the spent-close guard cannot rely on that re-delivery to consume
|
||||
// its skip. It is disarmed from the cleanup site instead, once the
|
||||
// cleanup close has settled. Ordering note: a naive self-restoring
|
||||
// wrapper would NOT fix this — with no re-delivery ever coming, the
|
||||
// wrapper's FIRST invocation would already be the next genuine close,
|
||||
// swallowed even as it restores the handler.
|
||||
class NoRefireStdioShapedTransport implements Transport {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
sessionId?: string;
|
||||
startCalls = 0;
|
||||
sent: JSONRPCMessage[] = [];
|
||||
private _alive = false;
|
||||
get stderr(): null {
|
||||
return null;
|
||||
}
|
||||
get pid(): number | null {
|
||||
return this._alive ? 4242 : null;
|
||||
}
|
||||
async start(): Promise<void> {
|
||||
this._alive = true;
|
||||
this.startCalls++;
|
||||
}
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
if (!this._alive) throw new Error('Not connected');
|
||||
this.sent.push(message);
|
||||
queueMicrotask(() => {
|
||||
if (!this._alive || !isJSONRPCRequest(message)) return;
|
||||
// Exits on the probe, like an rmcp child: the close event.
|
||||
this.simulateChildClose();
|
||||
});
|
||||
}
|
||||
async close(): Promise<void> {
|
||||
// stdio semantics: close() tears down but never re-fires
|
||||
// onclose — the child's close event already delivered it.
|
||||
this._alive = false;
|
||||
}
|
||||
/** The child's own close event — the only onclose source, like the real transport. */
|
||||
simulateChildClose(): void {
|
||||
this._alive = false;
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
const transport = new NoRefireStdioShapedTransport();
|
||||
let closes = 0;
|
||||
transport.onclose = () => {
|
||||
closes++;
|
||||
};
|
||||
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
||||
// No _serverParams: probes in place; the mid-probe close is a typed error.
|
||||
await expect(client.connect(transport)).rejects.toThrow(/connection closed during the server\/discover probe/);
|
||||
// The child's death mid-probe was forwarded to the observer exactly once
|
||||
// (the cleanup close, per stdio semantics, re-delivered nothing).
|
||||
expect(closes).toBe(1);
|
||||
|
||||
// The caller restarts the transport; the new life's death is a GENUINE
|
||||
// close and must reach the observer — an armed skip would swallow it.
|
||||
await transport.start();
|
||||
transport.simulateChildClose();
|
||||
expect(closes).toBe(2);
|
||||
});
|
||||
|
||||
test('transport errors DURING the probe window are forwarded to the pre-set handler', async () => {
|
||||
|
|
|
|||
|
|
@ -53,9 +53,10 @@ verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion
|
|||
return;
|
||||
}
|
||||
|
||||
// Modern leg: the auto-negotiating client reaches 2026-07-28 via
|
||||
// server/discover on the pipe (no initialize is ever written), the entry
|
||||
// pins the connection to a 2026-era instance, and tools/call round-trips
|
||||
// Modern leg: the auto-negotiating client reaches 2026-07-28 via the
|
||||
// disposable sibling probe (the session pipe carries neither initialize
|
||||
// nor server/discover), the entry pins the connection to a 2026-era
|
||||
// instance from the first enveloped request, and tools/call round-trips
|
||||
// with the per-request envelope.
|
||||
const sentMethods: string[] = [];
|
||||
const originalSend = transport.send.bind(transport);
|
||||
|
|
@ -69,7 +70,7 @@ verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion
|
|||
await client.connect(transport);
|
||||
expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion);
|
||||
expect(sentMethods).not.toContain('initialize');
|
||||
expect(sentMethods[0]).toBe('server/discover');
|
||||
expect(sentMethods).not.toContain('server/discover');
|
||||
|
||||
const result = await client.callTool({ name: 'echo', arguments: { text: 'modern leg' } });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]);
|
||||
|
|
|
|||
|
|
@ -11,12 +11,16 @@
|
|||
* Plus: structural fallback hygiene (the auto client's post-probe traffic is
|
||||
* byte-identical to a plain legacy client's, zero 2026 headers), the typed
|
||||
* connect errors for outage and HTTP timeout, and the stdio timeout fallback
|
||||
* (a silent legacy stdio server is detected by the probe timing out and the
|
||||
* client falls back to initialize on the same pipe).
|
||||
* (a silent legacy stdio server is detected by the probe — riding the
|
||||
* disposable sibling — timing out, and the client connects legacy with
|
||||
* initialize on the session child's fresh pipe).
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import type { Server } from 'node:http';
|
||||
import { createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
||||
|
|
@ -24,7 +28,7 @@ import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal';
|
|||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
/** A fetch wrapper recording every request our client puts on the wire (URL, headers, body) and the raw response (status, body). */
|
||||
|
|
@ -229,9 +233,10 @@ describe('typed connect errors (Q12) over real sockets', () => {
|
|||
describe('stdio: silent legacy server (probe timeout fallback)', () => {
|
||||
// The stdio transport's backward-compatibility rule: a probe that gets no
|
||||
// response within a reasonable timeout indicates a legacy server — some
|
||||
// legacy servers do not respond to unknown pre-initialize requests at all
|
||||
// — and the client falls back to initialize on the same pipe. (On HTTP,
|
||||
// by contrast, a timeout stays a typed connect error; see the test above.)
|
||||
// legacy servers do not respond to unknown pre-initialize requests at all.
|
||||
// The probe times out on the disposable sibling; the client then connects
|
||||
// legacy with initialize on the session child's fresh pipe. (On HTTP, by
|
||||
// contrast, a timeout stays a typed connect error; see the test above.)
|
||||
const SILENT_LEGACY_SERVER_SCRIPT = String.raw`
|
||||
let buffer = '';
|
||||
process.stdin.on('data', chunk => {
|
||||
|
|
@ -267,7 +272,7 @@ describe('stdio: silent legacy server (probe timeout fallback)', () => {
|
|||
});
|
||||
`;
|
||||
|
||||
it('auto mode: the probe times out, the client falls back to initialize on the same pipe and connects on the legacy era', async () => {
|
||||
it("auto mode: the probe times out on the sibling and the client connects legacy — initialize on the session child's fresh pipe", async () => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: ['-e', SILENT_LEGACY_SERVER_SCRIPT]
|
||||
|
|
@ -286,3 +291,218 @@ describe('stdio: silent legacy server (probe timeout fallback)', () => {
|
|||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
describe('stdio: sibling probe (real children)', () => {
|
||||
// The probe runs on a DISPOSABLE SIBLING spawned from the same parameters;
|
||||
// the caller's transport spawns exactly once, after the era is known. Each
|
||||
// fixture child appends "<pid> <method>" lines to a log file, so spawn
|
||||
// counts and per-child wire traffic are asserted from the file.
|
||||
const logFile = () => path.join(tmpdir(), `mcp-sibling-probe-${randomUUID()}.log`);
|
||||
const linesOf = (file: string): string[] =>
|
||||
existsSync(file)
|
||||
? readFileSync(file, 'utf8')
|
||||
.split('\n')
|
||||
.filter(l => l.trim() !== '')
|
||||
: [];
|
||||
const pidsOf = (file: string): number[] => [...new Set(linesOf(file).map(l => Number(l.split(' ')[0])))];
|
||||
const methodsOf = (file: string, pid: number): string[] =>
|
||||
linesOf(file)
|
||||
.filter(l => l.startsWith(`${pid} `))
|
||||
.map(l => l.split(' ')[1]!)
|
||||
.filter(m => m !== 'spawned');
|
||||
const isAlive = (pid: number): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Line-delimited JSON-RPC child: logs every received method; `mode` picks the personality. */
|
||||
const fixture = (file: string, mode: 'rmcp' | 'modern' | 'silent' | 'rmcp-holding') => String.raw`
|
||||
const fs = require('fs');
|
||||
const log = entry => fs.appendFileSync(${JSON.stringify(file)}, process.pid + ' ' + entry + '\n');
|
||||
log('spawned');
|
||||
const MODE = ${JSON.stringify(mode)};
|
||||
if (MODE === 'rmcp-holding') {
|
||||
require('child_process').spawn(process.execPath, ['-e', 'setTimeout(() => {}, 2500)'], { stdio: 'inherit' });
|
||||
}
|
||||
let initialized = false;
|
||||
let buffer = '';
|
||||
const send = obj => process.stdout.write(JSON.stringify(obj) + '\n');
|
||||
process.stdin.on('data', chunk => {
|
||||
buffer += chunk.toString();
|
||||
let index;
|
||||
while ((index = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, index);
|
||||
buffer = buffer.slice(index + 1);
|
||||
if (line.trim() === '') continue;
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (message.method !== undefined) log(message.method);
|
||||
if (MODE === 'silent') continue;
|
||||
if (message.method === 'initialize') {
|
||||
initialized = true;
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result: {
|
||||
protocolVersion: message.params.protocolVersion,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: 'sibling-fixture', version: '1.0.0' }
|
||||
}
|
||||
});
|
||||
} else if (message.method === 'server/discover' && MODE === 'modern') {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result: {
|
||||
supportedVersions: ['2026-07-28'],
|
||||
capabilities: { tools: {} },
|
||||
_meta: { 'io.modelcontextprotocol/serverInfo': { name: 'sibling-fixture', version: '1.0.0' } }
|
||||
}
|
||||
});
|
||||
} else if (message.method === 'tools/call') {
|
||||
// The 2026 wire requires resultType on results; the legacy wire has no such field.
|
||||
const result = { content: [{ type: 'text', text: message.params.arguments.text }] };
|
||||
if (MODE === 'modern') result.resultType = 'complete';
|
||||
send({ jsonrpc: '2.0', id: message.id, result });
|
||||
} else if (!initialized && MODE !== 'modern' && message.id !== undefined) {
|
||||
// The rmcp shape: exit on any unrecognized pre-initialize request.
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
process.stdin.resume();
|
||||
`;
|
||||
|
||||
const spawnFixture = (file: string, mode: 'rmcp' | 'modern' | 'silent' | 'rmcp-holding') =>
|
||||
new StdioClientTransport({ command: process.execPath, args: ['-e', fixture(file, mode)] });
|
||||
|
||||
it('rmcp exit-on-probe: sibling spends itself and is reaped; the session connects legacy on its only spawn and serves tools/call', async () => {
|
||||
const file = logFile();
|
||||
const transport = spawnFixture(file, 'rmcp');
|
||||
const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
|
||||
const result = await client.callTool({ name: 'echo', arguments: { text: 'sibling' } });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'sibling' }]);
|
||||
|
||||
const pids = pidsOf(file);
|
||||
expect(pids).toHaveLength(2);
|
||||
const [siblingPid, sessionPid] = pids as [number, number];
|
||||
expect(sessionPid).toBe(transport.pid);
|
||||
expect(methodsOf(file, siblingPid)).toEqual(['server/discover']);
|
||||
expect(methodsOf(file, sessionPid)).not.toContain('server/discover');
|
||||
expect(methodsOf(file, sessionPid)).toContain('initialize');
|
||||
await vi.waitFor(() => expect(isAlive(siblingPid)).toBe(false));
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it('modern server: the session adopts the sibling verdict — its wire carries neither server/discover nor initialize', async () => {
|
||||
const file = logFile();
|
||||
const transport = spawnFixture(file, 'modern');
|
||||
const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
expect(client.getNegotiatedProtocolVersion()).toBe('2026-07-28');
|
||||
expect(client.getServerVersion()?.name).toBe('sibling-fixture');
|
||||
|
||||
const result = await client.callTool({ name: 'echo', arguments: { text: 'modern' } });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'modern' }]);
|
||||
|
||||
const pids = pidsOf(file);
|
||||
expect(pids).toHaveLength(2);
|
||||
const [siblingPid, sessionPid] = pids as [number, number];
|
||||
expect(methodsOf(file, siblingPid)).toEqual(['server/discover']);
|
||||
// Byte-trace: the verdict was adopted — the session never probed and
|
||||
// never ran the legacy handshake.
|
||||
expect(methodsOf(file, sessionPid)).not.toContain('server/discover');
|
||||
expect(methodsOf(file, sessionPid)).not.toContain('initialize');
|
||||
await vi.waitFor(() => expect(isAlive(siblingPid)).toBe(false));
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it('caller close() mid-probe aborts promptly: typed error, the session child is never spawned, the sibling is reaped', async () => {
|
||||
const file = logFile();
|
||||
const transport = spawnFixture(file, 'silent');
|
||||
const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
||||
const pending = client.connect(transport);
|
||||
pending.catch(() => {});
|
||||
await vi.waitFor(() => expect(pidsOf(file)).toHaveLength(1));
|
||||
|
||||
await transport.close();
|
||||
|
||||
const rejection = await pending.then(
|
||||
() => {},
|
||||
(error: unknown) => error
|
||||
);
|
||||
expect(rejection).toBeInstanceOf(SdkError);
|
||||
expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed);
|
||||
expect((rejection as SdkError).message).toMatch(/transport was closed during the server\/discover probe/);
|
||||
// The session child was never spawned, and the sibling is reaped.
|
||||
expect(transport.pid).toBeNull();
|
||||
expect(pidsOf(file)).toHaveLength(1);
|
||||
await vi.waitFor(() => expect(isAlive(pidsOf(file)[0]!)).toBe(false));
|
||||
}, 15_000);
|
||||
|
||||
it("a pre-set onclose observer survives a failed pin negotiation: a restarted life's close still reaches it", async () => {
|
||||
// The sibling design keeps the session transport's handlers untouched
|
||||
// during the probe (the window opens on the sibling, which has none),
|
||||
// so a failed negotiation must leave the caller's observer fully
|
||||
// armed: after the caller restarts the transport, its life's close is
|
||||
// genuine and must be delivered. (On the real StdioClientTransport,
|
||||
// close() never re-fires onclose — delivery comes only from the
|
||||
// child's own close event — so any leftover one-shot suppression
|
||||
// would swallow exactly this event.)
|
||||
const file = logFile();
|
||||
const transport = spawnFixture(file, 'rmcp');
|
||||
let closes = 0;
|
||||
transport.onclose = () => {
|
||||
closes++;
|
||||
};
|
||||
const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } });
|
||||
|
||||
await expect(client.connect(transport)).rejects.toThrow(/no fallback in pin mode/);
|
||||
// The session child was never spawned, so its observer saw nothing yet.
|
||||
expect(transport.pid).toBeNull();
|
||||
expect(closes).toBe(0);
|
||||
|
||||
// The caller restarts the transport: its first real life. Closing it
|
||||
// makes the child exit — that close event must reach the observer.
|
||||
await transport.start();
|
||||
expect(transport.pid).not.toBeNull();
|
||||
await transport.close();
|
||||
await vi.waitFor(() => expect(closes).toBe(1));
|
||||
}, 15_000);
|
||||
|
||||
it('a helper holding the probe child’s pipes defers the close past the probe window: the timeout row still lands legacy and the session works', async () => {
|
||||
const file = logFile();
|
||||
const transport = spawnFixture(file, 'rmcp-holding');
|
||||
const client = new Client(
|
||||
{ name: 'neg-client', version: '1.0.0' },
|
||||
{ versionNegotiation: { mode: 'auto', probe: { timeoutMs: 800 } } }
|
||||
);
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const result = await client.callTool({ name: 'echo', arguments: { text: 'held' } });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'held' }]);
|
||||
const pids = pidsOf(file);
|
||||
expect(pids).toHaveLength(2);
|
||||
await vi.waitFor(() => expect(isAlive(pids[0]!)).toBe(false));
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ describe('serveStdio over a real child-process pipe (one connection per spawned
|
|||
}
|
||||
});
|
||||
|
||||
it('modern-opening connection: the auto-negotiating client reaches 2026-07-28 via server/discover, the connection pins modern, and a late initialize is rejected with the supported list', async () => {
|
||||
it('modern-opening connection: the auto-negotiating client reaches 2026-07-28 via the sibling probe, the session pipe pins modern from its first enveloped request, and a late initialize is rejected with the supported list', async () => {
|
||||
const transport = spawnFixtureTransport();
|
||||
const outbound = recordOutbound(transport);
|
||||
const client = new Client({ name: 'modern-pipe-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
|
@ -138,10 +138,11 @@ describe('serveStdio over a real child-process pipe (one connection per spawned
|
|||
await client.connect(transport);
|
||||
const inbound = recordInbound(transport);
|
||||
|
||||
// 2026 negotiated via discover on the pipe — no initialize was ever written.
|
||||
// 2026 negotiated via the disposable sibling probe — the session
|
||||
// pipe carries neither initialize nor server/discover.
|
||||
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
|
||||
expect(outbound.some(message => (message as { method?: string }).method === 'initialize')).toBe(false);
|
||||
expect((outbound[0] as { method?: string }).method).toBe('server/discover');
|
||||
expect(outbound.some(message => (message as { method?: string }).method === 'server/discover')).toBe(false);
|
||||
|
||||
// Modern vertical: list → call. The raw list carries a hand-built
|
||||
// envelope so the resultType marker can be read on the wire; the
|
||||
|
|
|
|||
Loading…
Reference in a new issue