From 10380ddfae09151b4469b4697ae97fa6f5b79d98 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 05:45:51 -0400 Subject: [PATCH 01/94] feat(mcpl): type MCPL request failures by what reached the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed request to an MCPL server used to reject with a plain Error, so the only way to tell "never written" from "written and unanswered" from "answered with an error" was to match message text. Callers that act in the world need that difference as a fact: a publish or a reaction that was never sent left the world as it was, while one that was handed to the transport and never answered may have landed. (A rollback reaction drain met exactly this: its pending reactions failed at shutdown, and only their message text said whether each had been written.) McplRequestError extends Error with `outcome`: - `not-sent`: sendRequest refused before writing (connection already closed); - `error-response`: a JSON-RPC error response; `code` and `data` kept verbatim (a connector can describe a partial multi-part post there); - `no-response`: the request was handed to the transport and no answer came (per-request timeout, a deliberate close while awaiting, or an unexpected disconnect). It does not prove the request reached the server — the transport can decline or drop a write — only that the host can no longer say it didn't. Messages are byte-identical to before, and every rejection is still an Error. Exported from the mcpl index and the package root. Tests (test/mcpl-request-outcome.test.ts, a real stdio server): an error response keeps code and data; an unanswered request and a process exit while awaiting are no-response; a request on a closed connection is not-sent; the class keeps Error identity. 12/12 with the existing request-timeout tests. Reviewed by Tessa-974 (room-203 #43780), whose wording correction for no-response this carries. Shared with room-225's lane 1 (Ada-1017). Co-Authored-By: Claude Opus 5.5 --- changelog.d/mcpl-request-outcome.added.md | 10 +++ src/index.ts | 2 +- src/mcpl/index.ts | 2 +- src/mcpl/server-connection.ts | 45 +++++++++-- test/mcpl-request-outcome.test.ts | 92 +++++++++++++++++++++++ 5 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 changelog.d/mcpl-request-outcome.added.md create mode 100644 test/mcpl-request-outcome.test.ts diff --git a/changelog.d/mcpl-request-outcome.added.md b/changelog.d/mcpl-request-outcome.added.md new file mode 100644 index 000000000..241ec226e --- /dev/null +++ b/changelog.d/mcpl-request-outcome.added.md @@ -0,0 +1,10 @@ +- **`McplRequestError`: why an MCPL request produced no result.** A request + to an MCPL server that fails now rejects with `McplRequestError` (exported), + whose `outcome` says which of three things happened: `not-sent` (refused + before anything was written: the connection was already closed), + `error-response` (the server answered with a JSON-RPC error; `code` and the + error's `data` are kept), or `no-response` (the request was handed to the + transport and no answer came back: a timeout, or the connection closed + while awaiting; it may or may not have reached the server). Only + `not-sent` proves the server never saw the request. Messages are unchanged, + and it is still an `Error`, so existing handling keeps working. diff --git a/src/index.ts b/src/index.ts index adfab80b2..6baa23fab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,7 +90,7 @@ export type { McplServerConfig } from './mcpl/index.js'; // MCPL client connection (exposed so external tooling — server playtests, // health probes, protocol harnesses — can dial an MCPL server exactly the // way the framework host does, over stdio or WebSocket) -export { McplServerConnection } from './mcpl/index.js'; +export { McplServerConnection, McplRequestError } from './mcpl/index.js'; export type { McplHostCapabilities } from './mcpl/index.js'; // MCPL tool lifecycle (RFC-007) and tool classes (RFC-008): the types a host diff --git a/src/mcpl/index.ts b/src/mcpl/index.ts index d82790c67..9033077ec 100644 --- a/src/mcpl/index.ts +++ b/src/mcpl/index.ts @@ -106,7 +106,7 @@ export { } from './errors.js'; // Server connection and registry -export { McplServerConnection } from './server-connection.js'; +export { McplServerConnection, McplRequestError } from './server-connection.js'; export { McplServerRegistry, type McplCapabilityQuery } from './server-registry.js'; // Feature set management (permission layer) diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index f29fea22d..18f82d096 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -62,6 +62,34 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; /** MCP protocol version used in the initialize handshake. */ const MCP_PROTOCOL_VERSION = '2024-11-05'; +/** + * Why a request to an MCPL server produced no result, as far as the host can + * know it. Messages are unchanged from plain errors; the `outcome` makes the + * difference between "never left the host" and "left, and no answer came + * back" a fact a caller can act on instead of a string to match: + * - `not-sent`: the request was refused before anything was written (the + * connection was already closed); + * - `error-response`: the server answered with a JSON-RPC error (it may have + * acted before failing; the error alone does not say); + * - `no-response`: the request was handed to the transport and no answer + * came back (timeout, or the connection closed while awaiting). It does + * not prove the request reached the server — the transport can decline or + * drop a write — only that the host can no longer say it didn't. + */ +export class McplRequestError extends Error { + constructor( + message: string, + readonly outcome: 'not-sent' | 'error-response' | 'no-response', + readonly code?: number, + /** The JSON-RPC error's `data`, verbatim, when the server supplied one + * (e.g. which parts of a multi-part send were posted). */ + readonly data?: unknown, + ) { + super(message); + this.name = 'McplRequestError'; + } +} + /** * Represents a pending JSON-RPC request awaiting a response. */ @@ -846,7 +874,7 @@ export class McplServerConnection extends EventEmitter { // Reject all pending requests for (const [id, pending] of this.pendingRequests) { if (pending.timer) clearTimeout(pending.timer); - pending.reject(new Error(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`)); + pending.reject(new McplRequestError(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`, 'no-response')); } this.pendingRequests.clear(); this.orphanedRequests.clear(); @@ -973,7 +1001,7 @@ export class McplServerConnection extends EventEmitter { options: RequestOptions = {}, ): Promise { if (this.closed) { - return Promise.reject(new Error(`Cannot send request: connection to "${this.id}" is closed`)); + return Promise.reject(new McplRequestError(`Cannot send request: connection to "${this.id}" is closed`, 'not-sent')); } const id = this.nextRequestId++; @@ -994,13 +1022,14 @@ export class McplServerConnection extends EventEmitter { this.orphanedRequests.set(id, { method, ...(method === 'push/render' ? { renderParams: { featureSet: params.featureSet, key: params.key, eventId: params.eventId } } : {}) }); if (this.orphanedRequests.size > 4096) this.orphanedRequests.delete(this.orphanedRequests.keys().next().value!); } - reject(new Error( + reject(new McplRequestError( `MCPL server "${this.id}" did not respond to ${method} (id=${id}) ` + `within ${timeoutMs}ms — the server may be hung. The response ` + `outcome is unknown: the request was abandoned locally but was not ` + `cancelled, so the tool may still have completed server-side; verify ` + `state before retrying; a blind retry of a stateful/side-effecting ` + `tool may duplicate it.`, + 'no-response', )); }, timeoutMs); // Don't hold the event loop open for the watchdog alone. @@ -1215,7 +1244,12 @@ export class McplServerConnection extends EventEmitter { if (response.error) { pending.reject( - new Error(`MCPL server "${this.id}" returned error for ${pending.method}: [${response.error.code}] ${response.error.message}`), + new McplRequestError( + `MCPL server "${this.id}" returned error for ${pending.method}: [${response.error.code}] ${response.error.message}`, + 'error-response', + typeof response.error.code === 'number' ? response.error.code : undefined, + (response.error as { data?: unknown }).data, + ), ); } else { pending.resolve(response.result); @@ -1267,8 +1301,9 @@ export class McplServerConnection extends EventEmitter { for (const [id, pending] of this.pendingRequests) { if (pending.timer) clearTimeout(pending.timer); pending.reject( - new Error( + new McplRequestError( `MCPL server "${this.id}" disconnected unexpectedly (code=${info.code ?? 'n/a'}, signal=${info.signal ?? 'n/a'}, reason=${info.reason ?? 'unknown'}) while awaiting ${pending.method} (id=${id})`, + 'no-response', ), ); } diff --git a/test/mcpl-request-outcome.test.ts b/test/mcpl-request-outcome.test.ts new file mode 100644 index 000000000..1b0f45647 --- /dev/null +++ b/test/mcpl-request-outcome.test.ts @@ -0,0 +1,92 @@ +/** + * McplRequestError: a request that produced no result says which of three + * things happened, as a fact rather than a message to parse. + * not-sent refused before anything was written (connection closed) + * error-response the server answered with a JSON-RPC error (code, data kept) + * no-response the request was handed to the transport and no answer + * came back (timeout, or the connection closed while + * awaiting): it may or may not have reached the server + * Callers that act in the world (publishing speech, placing reactions) need + * the difference: only `not-sent` proves the server never saw the request. + */ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +import { McplServerConnection, McplRequestError } from '../src/mcpl/server-connection.js'; +import type { McplHostCapabilities, McplServerConfig } from '../src/mcpl/types.js'; + +// Stdio server: answers initialize; tools/call 'fail' gets a JSON-RPC error +// with data, 'stuck' gets nothing, 'exit' kills the process mid-request. +const SERVER = ` +let buf = ''; +process.stdin.on('data', (c) => { + buf += c.toString('utf8'); + let i; + while ((i = buf.indexOf('\\n')) >= 0) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + let m; try { m = JSON.parse(line); } catch { continue; } + const out = (o) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: m.id, ...o }) + '\\n'); + if (m.method === 'initialize') out({ result: { capabilities: {} } }); + else if (m.method === 'tools/call' && m.params.name === 'fail') { + out({ error: { code: -32001, message: 'POSTED: part 1 of 2', data: { posted: ['111'] } } }); + } else if (m.method === 'tools/call' && m.params.name === 'exit') { + process.exit(3); + } + // 'stuck': deliberately no response. + } +}); +setInterval(() => {}, 1 << 30); +`; + +const HOST_CAPS: McplHostCapabilities = { version: '0.4', pushEvents: true, contextHooks: { beforeInference: true }, featureSets: true }; +const config = (overrides?: Partial): McplServerConfig => + ({ id: 'outcome', command: process.execPath, args: ['-e', SERVER], ...overrides }); + +let connection: McplServerConnection | null = null; +afterEach(async () => { + await connection?.close(); + connection = null; +}); + +const rejectsWith = async (promise: Promise, outcome: McplRequestError['outcome']): Promise => { + let caught: unknown; + await assert.rejects(promise, (err: unknown) => { caught = err; return true; }); + assert.ok(caught instanceof McplRequestError, `expected McplRequestError, got ${String(caught)}`); + assert.equal((caught as McplRequestError).outcome, outcome); + return caught as McplRequestError; +}; + +test('an error response is error-response, with its code and data kept', async () => { + connection = await McplServerConnection.connect(config(), HOST_CAPS); + const err = await rejectsWith(connection.sendToolsCall('fail', {}), 'error-response'); + assert.equal(err.code, -32001); + assert.deepEqual(err.data, { posted: ['111'] }); + assert.match(err.message, /returned error for tools\/call: \[-32001\] POSTED: part 1 of 2/); +}); + +test('a request nobody answers is no-response, and the message is unchanged', async () => { + connection = await McplServerConnection.connect(config({ requestTimeoutMs: 200 }), HOST_CAPS); + const err = await rejectsWith(connection.sendToolsCall('stuck', {}), 'no-response'); + assert.match(err.message, /did not respond to tools\/call/); +}); + +test('a connection lost while awaiting is no-response', async () => { + connection = await McplServerConnection.connect(config({ requestTimeoutMs: 10_000 }), HOST_CAPS); + await rejectsWith(connection.sendToolsCall('exit', {}), 'no-response'); +}); + +test('a request on a closed connection is not-sent', async () => { + connection = await McplServerConnection.connect(config(), HOST_CAPS); + await connection.close(); + const err = await rejectsWith(connection.sendToolsCall('fail', {}), 'not-sent'); + assert.match(err.message, /Cannot send request: connection to "outcome" is closed/); + connection = null; +}); + +test('McplRequestError is an Error that keeps its message and name', () => { + const err = new McplRequestError('boom', 'not-sent'); + assert.ok(err instanceof Error); + assert.equal(err.name, 'McplRequestError'); + assert.equal(err.message, 'boom'); +}); From 2c9aff13402ea872572a49236906a576e7c77445 Mon Sep 17 00:00:00 2001 From: Olive-1804 Date: Thu, 8 Oct 2026 01:11:39 -0400 Subject: [PATCH 02/94] feat(mcp): legacy engine's protocol boundary, family selection, paginated tools/list First batch of room-284's modern-MCP client work (shelf-487; design in personal:olive-1804 #52935, revised #53069, implementation authorized #53087). Protocol family and transport are decided from configuration alone (src/mcpl/protocol-family.ts): a URL's scheme decides a network server (ws/wss = legacy MCPL, http/https = modern MCP 2026-07-28); only a stdio server has a choice, `protocol: 'legacy' | 'modern'`, defaulting to legacy so every existing stdio config opens exactly as before. No cross-family probing: the spec's discover-first probe makes real MCPL servers exit (heartbeat-mcpl), and the SDK's stdio probe launches every server twice. serverConfigProblems() adds checks only for what the modern family brought: protocol on a URL, a modern requestTimeoutMs outside 1..2^31-1, and effective MCPL-only policy on a modern server. Every legacy shape still passes. Legacy engine (McplServerConnection): - Offers and now requires MCP 2024-11-05. Another returned revision, or none, is McplProtocolVersionError kind 'mismatch'. An initialize answered -32022 is kind 'rejected', keeping the server's supported list, code and data, with a protocol: 'modern' hint when the list names modern MCP. The established revision is on connection.protocolVersion. - A version verdict is permanent: connectWithReconnect makes no retry stub, and attemptReconnect stops its loop and emits reconnect-failed with permanent: true. The framework raises the ops alert at once for it, since no fifth attempt will come, and its connect-failed trace says willRetry: false. - tools/list follows nextCursor to the complete inventory. A repeated cursor, or more than 100 pages, is an error. A single-page answer is returned exactly as before, other fields included (mcpl-capability-scoping relies on that). - A modern config is refused before anything is dialed. Carries Petra-1012's McplRequestError commit (7144970, cherry-picked as 10380dd) as the shared request-outcome contract, per Tessa (room-203 #53124). Test stubs that answered initialize without protocolVersion now send 2024-11-05: six existing files, plus mcpl-request-outcome.test.ts from the carried commit. Every probed real server family (TS SDK 1.x, Python FastMCP 1.x/2.x, SDK 2 dual-era, discord/heartbeat/slack/spend MCPL servers) already sends it. The changelog fragment names the hand-written-server consequence. Tests: mcp-protocol-family (6), mcpl-legacy-version (10). Full suite 1397/1397 (baseline 1377 + 4 carried + 16 new). --- .../legacy-mcp-protocol-boundary.breaking.md | 17 ++ src/framework.ts | 15 +- src/index.ts | 10 +- src/mcpl/index.ts | 12 +- src/mcpl/protocol-family.ts | 160 +++++++++++++++++ src/mcpl/server-connection.ts | 163 ++++++++++++++++-- src/mcpl/types.ts | 29 +++- .../awareness-barrier-mcpl-server.mjs | 1 + test/fixtures/legacy-version-server.mjs | 65 +++++++ test/host-quiesce.test.ts | 2 +- test/mcp-protocol-family.test.ts | 103 +++++++++++ test/mcpl-capability-scoping.test.ts | 2 +- test/mcpl-legacy-version.test.ts | 155 +++++++++++++++++ test/mcpl-request-outcome.test.ts | 2 +- test/mcpl-request-timeout.test.ts | 2 +- test/mcpl-runtime-lifecycle.test.ts | 4 +- test/mcpl-ws-transport.test.ts | 4 +- 17 files changed, 710 insertions(+), 36 deletions(-) create mode 100644 changelog.d/legacy-mcp-protocol-boundary.breaking.md create mode 100644 src/mcpl/protocol-family.ts create mode 100644 test/fixtures/legacy-version-server.mjs create mode 100644 test/mcp-protocol-family.test.ts create mode 100644 test/mcpl-legacy-version.test.ts diff --git a/changelog.d/legacy-mcp-protocol-boundary.breaking.md b/changelog.d/legacy-mcp-protocol-boundary.breaking.md new file mode 100644 index 000000000..92d319362 --- /dev/null +++ b/changelog.d/legacy-mcp-protocol-boundary.breaking.md @@ -0,0 +1,17 @@ +- **MCPL/MCP server authors:** the legacy engine now enforces the revision it + speaks. `McplServerConnection` offers MCP `2024-11-05` and now also requires + it back: an `initialize` result with any other `protocolVersion`, or with + none, fails as `McplProtocolVersionError` (`kind: 'mismatch'`), stating what + each side said. Every server family probed answers `2024-11-05`, but a + hand-written server that omits the field must add it. An `initialize` + answered with JSON-RPC `-32022` fails as `McplProtocolVersionError` + (`kind: 'rejected'`), keeping the server's `supported` list, code and data. + When that list names modern MCP, the message says to configure the server + with `protocol: 'modern'`. A protocol-version verdict is never retried: no + reconnect stub, and a reconnecting connection stops its backoff loop + (`reconnect-failed` carries `permanent: true` and raises the ops alert at + once). The established revision is on `connection.protocolVersion`. +- `tools/list` now follows `nextCursor` across pages, so a paginating server's + whole inventory reaches the agent; before, every page after the first was + silently dropped. A repeated cursor, or more than 100 pages, is an error + rather than a partial inventory. diff --git a/src/framework.ts b/src/framework.ts index 349bdd693..3e5188f72 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -391,6 +391,7 @@ import { EventGate, formatShadowWarning } from './gate/event-gate.js'; import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js'; import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js'; import type { McplServerConnection } from './mcpl/server-connection.js'; +import { McplProtocolVersionError } from './mcpl/server-connection.js'; import type { McplServerConfig, McplHostCapabilities, @@ -13311,7 +13312,9 @@ export class AgentFramework { serverId: config.id, error: err.message, attempt: 0, - willRetry: config.reconnect === true, + // A protocol-version verdict is never retried (no common revision; + // only configuration can fix it). + willRetry: config.reconnect === true && !(error instanceof McplProtocolVersionError), }); } } @@ -14485,7 +14488,7 @@ export class AgentFramework { willRetry: connection.willReconnect, }); }); - connection.on('reconnect-failed', (params: { error: string; attempt: number }) => { + connection.on('reconnect-failed', (params: { error: string; attempt: number; permanent?: boolean }) => { this.emitTrace({ type: 'mcpl:server-connect-failed', serverId: connection.id, @@ -14493,11 +14496,13 @@ export class AgentFramework { attempt: params.attempt, willRetry: connection.willReconnect, }); - // The reconnect loop never gives up (backoff caps at ~300s), so - // "the server is effectively down" is an attempt-count judgment: + // The reconnect loop gives up only on a protocol-version verdict + // (`permanent`), which no later attempt can change — alert at once, + // since no fifth attempt will come. Otherwise backoff caps at ~300s, + // so "the server is effectively down" is an attempt-count judgment: // 5 failed attempts ≈ a few minutes of outage. Throttled per // (serverId, kind), so a long outage re-posts every ~15 min. - if (params.attempt >= 5) { + if (params.permanent || params.attempt >= 5) { this.opsAlert( 'mcpl-down', connection.id, diff --git a/src/index.ts b/src/index.ts index 6baa23fab..9e6b0073f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,7 +90,15 @@ export type { McplServerConfig } from './mcpl/index.js'; // MCPL client connection (exposed so external tooling — server playtests, // health probes, protocol harnesses — can dial an MCPL server exactly the // way the framework host does, over stdio or WebSocket) -export { McplServerConnection, McplRequestError } from './mcpl/index.js'; +export { McplServerConnection, McplRequestError, McplProtocolVersionError } from './mcpl/index.js'; +export { + LEGACY_MCP_PROTOCOL_VERSION, + MODERN_MCP_PROTOCOL_VERSION, + resolveServerBinding, + serverConfigProblems, + checkServerConfig, +} from './mcpl/index.js'; +export type { McpProtocolFamily, McpTransportKind, ServerBinding } from './mcpl/index.js'; export type { McplHostCapabilities } from './mcpl/index.js'; // MCPL tool lifecycle (RFC-007) and tool classes (RFC-008): the types a host diff --git a/src/mcpl/index.ts b/src/mcpl/index.ts index 9033077ec..c3bc7ef31 100644 --- a/src/mcpl/index.ts +++ b/src/mcpl/index.ts @@ -106,7 +106,17 @@ export { } from './errors.js'; // Server connection and registry -export { McplServerConnection, McplRequestError } from './server-connection.js'; +export { McplServerConnection, McplRequestError, McplProtocolVersionError } from './server-connection.js'; +export { + LEGACY_MCP_PROTOCOL_VERSION, + MODERN_MCP_PROTOCOL_VERSION, + MAX_TIMER_MS, + MCPL_ONLY_POLICY_FIELDS, + resolveServerBinding, + serverConfigProblems, + checkServerConfig, +} from './protocol-family.js'; +export type { McpProtocolFamily, McpTransportKind, ServerBinding } from './protocol-family.js'; export { McplServerRegistry, type McplCapabilityQuery } from './server-registry.js'; // Feature set management (permission layer) diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts new file mode 100644 index 000000000..5037d1205 --- /dev/null +++ b/src/mcpl/protocol-family.ts @@ -0,0 +1,160 @@ +/** + * Which MCP protocol family a configured server speaks, and over which + * transport. Both are decided from the configuration alone, before anything + * is spawned or dialed: there is no cross-family probing and no fallback. + * + * - **Legacy**: MCP 2024-11-05, with MCPL riding on it as an experimental + * capability. `McplServerConnection` speaks it, over stdio or MCPL's + * WebSocket binding. + * - **Modern**: MCP 2026-07-28, which is stateless. It has no `initialize`; + * every request carries its version and client capabilities in `_meta`, and + * server-to-client requests are replaced by in-band continuation. + * `ModernMcpConnection` speaks it, over stdio or Streamable HTTP. + * + * A URL's scheme decides a network server's family: `ws://`/`wss://` is + * legacy, `http://`/`https://` is modern, because neither family has the + * other's binding. A stdio (`command`) server can be either. It is legacy + * unless its config says `protocol: 'modern'`, so every existing stdio + * configuration opens exactly as before. + * + * Why no auto-detection: the modern spec's discover-first probe makes some + * real MCPL servers exit (heartbeat-mcpl closes on any first message that + * isn't `initialize`), the SDK's stdio probe launches the server twice, and a + * server's era is a stable fact its configuration can state. + */ +import type { McplServerConfig } from './types.js'; + +/** The one revision the legacy engine offers and accepts. */ +export const LEGACY_MCP_PROTOCOL_VERSION = '2024-11-05'; +/** The revision the modern engine pins. */ +export const MODERN_MCP_PROTOCOL_VERSION = '2026-07-28'; + +export type McpProtocolFamily = 'legacy' | 'modern'; +export type McpTransportKind = 'stdio' | 'websocket' | 'http'; + +export interface ServerBinding { + family: McpProtocolFamily; + transport: McpTransportKind; +} + +/** The largest delay a Node/Bun timer honors (2³¹−1 ms, about 24.8 days). */ +export const MAX_TIMER_MS = 2_147_483_647; + +/** + * Configuration fields that are MCPL policy: they have no meaning for a modern + * server, which cannot negotiate MCPL. Setting one there is rejected rather + * than ignored, so an operator who chose a policy learns that it cannot apply. + */ +export const MCPL_ONLY_POLICY_FIELDS = [ + 'enabledFeatureSets', + 'disabledFeatureSets', + 'enabledCapabilities', + 'disabledCapabilities', + 'scopes', + 'channelSubscription', + 'allowHostCommands', + 'toolLifecycle', + 'autofetch', + 'shouldTriggerInference', +] as const satisfies readonly (keyof McplServerConfig)[]; + +type BindingFields = Pick; + +function urlScheme(config: BindingFields): string { + let parsed: URL; + try { + parsed = new URL(config.url!); + } catch { + throw new Error(`MCP server "${config.id}": invalid url "${config.url}"`); + } + return parsed.protocol; +} + +/** + * Resolve a server's family and transport. Throws on a configuration that + * names no usable transport (for example an `ftp://` URL, or + * `transport: 'http'` with a WebSocket URL). + */ +export function resolveServerBinding(config: BindingFields): ServerBinding { + const networkKind = (): McpTransportKind => { + const scheme = urlScheme(config); + if (scheme === 'ws:' || scheme === 'wss:') return 'websocket'; + if (scheme === 'http:' || scheme === 'https:') return 'http'; + throw new Error( + `MCP server "${config.id}": url must be ws:// or wss:// (legacy MCPL) or http:// or https:// (modern MCP), got "${scheme}"`, + ); + }; + + let transport: McpTransportKind; + if (config.transport === 'stdio' || (config.transport === undefined && config.command)) { + if (!config.command) { + throw new Error(`MCP server "${config.id}": the stdio transport requires "command"`); + } + transport = 'stdio'; + } else if (config.url) { + transport = networkKind(); + if (config.transport !== undefined && config.transport !== transport) { + throw new Error( + `MCP server "${config.id}": transport "${config.transport}" does not match url "${config.url}"`, + ); + } + } else { + throw new Error(`MCP server "${config.id}": needs "command" (stdio) or "url" (WebSocket or HTTP)`); + } + + if (transport === 'stdio') return { family: config.protocol ?? 'legacy', transport }; + return { family: transport === 'http' ? 'modern' : 'legacy', transport }; +} + +/** A configured value that would take effect, as opposed to an empty default. */ +function isEffective(value: unknown): boolean { + if (value === undefined || value === null || value === false) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === 'object') return Object.keys(value as object).length > 0; + return true; +} + +/** + * Problems with a server configuration's protocol settings, as messages; empty + * when it is usable. Legacy configurations are checked only for what this + * design added, so every configuration that worked before still passes. + */ +export function serverConfigProblems(config: McplServerConfig): string[] { + let binding: ServerBinding; + try { + binding = resolveServerBinding(config); + } catch (error) { + return [(error as Error).message]; + } + const problems: string[] = []; + if (config.protocol !== undefined) { + if (config.protocol !== 'legacy' && config.protocol !== 'modern') { + problems.push(`MCP server "${config.id}": protocol must be 'legacy' or 'modern', got ${JSON.stringify(config.protocol)}`); + } else if (binding.transport !== 'stdio') { + problems.push( + `MCP server "${config.id}": "protocol" applies only to stdio (command) servers; the url's scheme decides a network server's family`, + ); + } + } + if (binding.family === 'modern') { + const timeout = config.requestTimeoutMs; + if (timeout !== undefined && !(Number.isInteger(timeout) && timeout >= 1 && timeout <= MAX_TIMER_MS)) { + problems.push( + `MCP server "${config.id}": requestTimeoutMs must be an integer from 1 to ${MAX_TIMER_MS} for a modern server (got ${String(timeout)}); every modern call has a deadline`, + ); + } + for (const field of MCPL_ONLY_POLICY_FIELDS) { + if (isEffective(config[field])) { + problems.push(`MCP server "${config.id}": "${field}" is MCPL policy, which a modern MCP server cannot negotiate`); + } + } + } + return problems; +} + +/** Throw one error listing every problem, or return the resolved binding. */ +export function checkServerConfig(config: McplServerConfig): ServerBinding { + const problems = serverConfigProblems(config); + if (problems.length > 0) throw new Error(problems.join('; ')); + return resolveServerBinding(config); +} diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 18f82d096..0a13dcf0a 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -15,6 +15,11 @@ import { openTransport, type McplTransport, type TransportCloseInfo } from './tr import { maskNegotiatedCapabilities } from './capability-mask.js'; import { CapabilityGrant, expandAdvertisementShorthand } from './capability-grant.js'; import { CAPABILITY_DISABLED } from './errors.js'; +import { + LEGACY_MCP_PROTOCOL_VERSION, + MODERN_MCP_PROTOCOL_VERSION, + resolveServerBinding, +} from './protocol-family.js'; import type { McplServerConfig, @@ -59,8 +64,67 @@ const INITIALIZE_TIMEOUT_MS = 30_000; * tool work while still bounding the hang. */ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; -/** MCP protocol version used in the initialize handshake. */ -const MCP_PROTOCOL_VERSION = '2024-11-05'; +/** MCP protocol version used in the initialize handshake: the only revision + * this legacy engine offers or accepts. */ +const MCP_PROTOCOL_VERSION = LEGACY_MCP_PROTOCOL_VERSION; + +/** JSON-RPC code for an unsupported protocol version (MCP 2026-07-28 §basic, + * the code a modern-only server answers our `initialize` with). */ +const UNSUPPORTED_PROTOCOL_VERSION_CODE = -32022; + +/** Upper bound on `tools/list` pages, against a server whose cursors never + * end. Generous: real inventories fit in a handful of pages. */ +const MAX_TOOL_LIST_PAGES = 100; + +/** + * The server and this legacy engine have no protocol revision in common, so + * the connection was never established. This is a protocol-negotiation fact, + * not a request outcome. Retrying cannot change it: the engine offers one + * revision, and only configuration can fix the mismatch. A connection that + * gets this error does not schedule reconnects. + * + * - `rejected`: the server answered `initialize` with error -32022. + * `supported` is the server's own list, when it gave one. + * - `mismatch`: the server answered `initialize` with a revision other than + * 2024-11-05, or with none. + */ +export class McplProtocolVersionError extends Error { + readonly offered = MCP_PROTOCOL_VERSION; + constructor( + message: string, + readonly kind: 'rejected' | 'mismatch', + /** Revision the server answered with (`mismatch`; null when it gave none). */ + readonly returned: string | null, + /** Revisions the server says it supports (`rejected`, when stated). */ + readonly supported: readonly string[] | null, + /** The JSON-RPC error's code and data, verbatim (`rejected`). */ + readonly code?: number, + readonly data?: unknown, + ) { + super(message); + this.name = 'McplProtocolVersionError'; + } +} + +function protocolVersionRejection(id: string, error: { code: number; message: string; data?: unknown }): McplProtocolVersionError { + const data = error.data as { supported?: unknown } | undefined; + const supported = Array.isArray(data?.supported) && data.supported.every((v) => typeof v === 'string') + ? (data.supported as string[]) + : null; + const modernHint = supported?.includes(MODERN_MCP_PROTOCOL_VERSION) + ? ` It speaks modern MCP: configure it with protocol: 'modern' (stdio) or reach it over http(s).` + : ''; + return new McplProtocolVersionError( + `MCPL server "${id}" does not support MCP ${MCP_PROTOCOL_VERSION}` + + (supported ? ` (it supports: ${supported.join(', ') || 'none listed'}).` : ` (${error.message}).`) + + modernHint, + 'rejected', + null, + supported, + error.code, + error.data, + ); +} /** * Why a request to an MCPL server produced no result, as far as the host can @@ -144,6 +208,10 @@ export class McplServerConnection extends EventEmitter { * the sole source of the `tools` capability path). */ mcpToolsAdvertised = false; + /** The MCP revision established by the last successful handshake (always + * 2024-11-05 for this engine), or null before the first one. */ + protocolVersion: string | null = null; + /** Host-owned per-server authority for host/command (config * allowHostCommands, default false). No capability path exists and the * grant cannot confer it — server params never decide admin authority. */ @@ -499,11 +567,12 @@ export class McplServerConnection extends EventEmitter { config: McplServerConfig, hostCapabilities: McplHostCapabilities, ): Promise { - const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised } = await McplServerConnection.handshake(config, hostCapabilities); + const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised, protocolVersion } = await McplServerConnection.handshake(config, hostCapabilities); const connection = new McplServerConnection(config.id, capabilities, transport); connection.droppedCapabilities = droppedCapabilities; connection.mcpToolsAdvertised = mcpToolsAdvertised; + connection.protocolVersion = protocolVersion; connection.allowHostCommands = config.allowHostCommands === true; connection.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; @@ -529,7 +598,19 @@ export class McplServerConnection extends EventEmitter { private static async handshake( config: McplServerConfig, hostCapabilities: McplHostCapabilities, - ): Promise<{ transport: McplTransport; capabilities: McplCapabilities | null; droppedCapabilities: ReadonlySet; mcpToolsAdvertised: boolean }> { + ): Promise<{ transport: McplTransport; capabilities: McplCapabilities | null; droppedCapabilities: ReadonlySet; mcpToolsAdvertised: boolean; protocolVersion: string }> { + // This engine speaks the legacy family only. A configuration that + // resolves to the modern family belongs to ModernMcpConnection; refuse it + // here rather than dialing an http(s) url as a WebSocket. A configuration + // that doesn't resolve at all keeps openTransport's existing errors. + let family: string | null = null; + try { family = resolveServerBinding(config).family; } catch { /* openTransport reports it */ } + if (family === 'modern') { + throw new Error( + `MCP server "${config.id}" is configured for modern MCP (${MODERN_MCP_PROTOCOL_VERSION}); ` + + `McplServerConnection speaks the legacy family (${MCP_PROTOCOL_VERSION} + MCPL) only`, + ); + } const transport = await openTransport(config); try { @@ -547,7 +628,7 @@ export class McplServerConnection extends EventEmitter { // Await the initialize response, racing an early transport close/error and // a timeout. All three paths clean up their listeners. - const { mcpl: rawCapabilities, mcpToolsAdvertised } = await new Promise<{ mcpl: McplCapabilities | null; mcpToolsAdvertised: boolean }>((resolve, reject) => { + const { mcpl: rawCapabilities, mcpToolsAdvertised, protocolVersion } = await new Promise<{ mcpl: McplCapabilities | null; mcpToolsAdvertised: boolean; protocolVersion: string }>((resolve, reject) => { const cleanup = () => { transport.off('line', onLine); transport.off('close', onClose); @@ -564,10 +645,26 @@ export class McplServerConnection extends EventEmitter { if (msg.id !== initId) return; cleanup(); if (msg.error) { - reject(new Error(`MCPL server "${config.id}" initialize error: ${msg.error.message}`)); + reject(msg.error.code === UNSUPPORTED_PROTOCOL_VERSION_CODE + ? protocolVersionRejection(config.id, msg.error) + : new Error(`MCPL server "${config.id}" initialize error: ${msg.error.message}`)); return; } const result = msg.result as Record | undefined; + // The server's answer is the established revision. This engine + // implements exactly one, so any other answer (or none) means the + // two sides never agreed on what they're speaking. + const returned = typeof result?.protocolVersion === 'string' ? result.protocolVersion : null; + if (returned !== MCP_PROTOCOL_VERSION) { + reject(new McplProtocolVersionError( + `MCPL server "${config.id}" answered initialize with protocolVersion ` + + `${returned === null ? '(none)' : JSON.stringify(returned)}; this client's legacy engine speaks only ${MCP_PROTOCOL_VERSION}`, + 'mismatch', + returned, + null, + )); + return; + } const caps = result?.capabilities as Record | undefined; const experimental = caps?.experimental as Record | undefined; // §5.1: `tools` capability is the OUTER standard MCP member — the @@ -576,6 +673,7 @@ export class McplServerConnection extends EventEmitter { resolve({ mcpl: (experimental?.mcpl as McplCapabilities) ?? null, mcpToolsAdvertised: caps?.tools !== undefined, + protocolVersion: returned, }); }; const onClose = (info: TransportCloseInfo) => { @@ -613,7 +711,7 @@ export class McplServerConnection extends EventEmitter { console.error(`MCPL server "${config.id}" capabilities masked by host config: ${dropped.join(', ')}`); } - return { transport, capabilities: scoped, droppedCapabilities: new Set(dropped), mcpToolsAdvertised }; + return { transport, capabilities: scoped, droppedCapabilities: new Set(dropped), mcpToolsAdvertised, protocolVersion }; } catch (err) { // Never leak the child / socket if the handshake fails. await transport.close().catch(() => { /* best effort */ }); @@ -634,7 +732,9 @@ export class McplServerConnection extends EventEmitter { try { return await McplServerConnection.connect(config, hostCapabilities); } catch (error) { - if (!config.reconnect) { + // No common protocol revision is a configuration fact, not an outage: + // a retry stub would restart the server forever for nothing. + if (!config.reconnect || error instanceof McplProtocolVersionError) { throw error; } @@ -815,9 +915,38 @@ export class McplServerConnection extends EventEmitter { // Standard MCP methods // ========================================================================== - /** Send `tools/list` and return the server's tool definitions. */ - sendToolsList(): Promise<{ tools: McpToolDefinition[] }> { - return this.sendRequest('tools/list', {}) as Promise<{ tools: McpToolDefinition[] }>; + /** + * Send `tools/list` and return the server's complete tool inventory, + * following `nextCursor` across pages (2024-11-05 pagination). A server + * whose cursors don't terminate — a repeated cursor, or more than + * {@link MAX_TOOL_LIST_PAGES} pages — gets an error rather than a silently + * partial inventory. + */ + async sendToolsList(): Promise<{ tools: McpToolDefinition[] }> { + const tools: McpToolDefinition[] = []; + const seen = new Set(); + let cursor: string | undefined; + let first: Record | undefined; + for (let page = 0; page < MAX_TOOL_LIST_PAGES; page++) { + const result = await this.sendRequest('tools/list', cursor === undefined ? {} : { cursor }) as { + tools?: McpToolDefinition[]; + nextCursor?: unknown; + }; + first ??= result; + tools.push(...(result.tools ?? [])); + if (typeof result.nextCursor !== 'string' || result.nextCursor === '') { + // The first page's other fields are kept as they came, so a + // single-page answer is returned exactly as before. + const { nextCursor: _done, ...rest } = first as { nextCursor?: unknown }; + return { ...rest, tools }; + } + if (seen.has(result.nextCursor)) { + throw new Error(`MCPL server "${this.id}" repeated tools/list cursor ${JSON.stringify(result.nextCursor)}`); + } + seen.add(result.nextCursor); + cursor = result.nextCursor; + } + throw new Error(`MCPL server "${this.id}" tools/list did not finish within ${MAX_TOOL_LIST_PAGES} pages`); } /** Send `tools/call` and return the result. Optionally includes state/checkpoint for stateful tools. */ @@ -948,7 +1077,7 @@ export class McplServerConnection extends EventEmitter { const attempt = Math.max(1, this.reconnectAttempts); try { - const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised } = await McplServerConnection.handshake( + const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised, protocolVersion } = await McplServerConnection.handshake( this.config, this.hostCapabilities, ); @@ -963,6 +1092,7 @@ export class McplServerConnection extends EventEmitter { this.capabilities = capabilities; this.droppedCapabilities = droppedCapabilities; this.mcpToolsAdvertised = mcpToolsAdvertised; + this.protocolVersion = protocolVersion; this.allowHostCommands = this.config?.allowHostCommands === true; this.closed = false; this.nextRequestId = 1; @@ -975,8 +1105,13 @@ export class McplServerConnection extends EventEmitter { } catch (error) { console.error(`MCPL server "${this.id}" reconnect failed:`, (error as Error).message); this.reconnectAttempts = attempt + 1; - this.emit('reconnect-failed', { error: (error as Error).message, attempt }); - this.scheduleReconnect(); + // No common protocol revision: every further attempt would restart the + // server to hear the same answer. Stop retrying; the configuration is + // what has to change. Every other failure keeps the backoff loop. + const permanent = error instanceof McplProtocolVersionError; + if (permanent) this.reconnectEnabled = false; + this.emit('reconnect-failed', { error: (error as Error).message, attempt, ...(permanent ? { permanent: true } : {}) }); + if (!permanent) this.scheduleReconnect(); } } diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 0f913ea5a..209229b01 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -243,18 +243,31 @@ export interface McplServerConfig { inheritEnv?: boolean; /** - * WebSocket URL for the network transport (`ws://` or `wss://`). Mutually - * exclusive with `command`. When set (or `transport: 'websocket'`), the host - * dials this endpoint instead of spawning a process — newline-delimited - * JSON-RPC MCPL runs over the socket identically to stdio. + * URL for a network server, used instead of `command`. Its scheme decides + * the protocol family: + * - `ws://` or `wss://` is a legacy MCPL server over MCPL's WebSocket + * binding. The host dials it and runs the same newline-delimited JSON-RPC + * as over stdio. + * - `http://` or `https://` is a modern MCP (2026-07-28) server over + * Streamable HTTP. */ url?: string; /** - * Transport selector. Defaults to `'websocket'` when a bare `url` is given - * (no `command`), `'stdio'` otherwise. Set explicitly to disambiguate. + * Transport selector. When it is absent, a `command` means stdio and a bare + * `url` means the transport its scheme names. Set it explicitly to + * disambiguate; it must agree with the url's scheme. */ - transport?: 'stdio' | 'websocket'; + transport?: 'stdio' | 'websocket' | 'http'; + + /** + * Protocol family for a stdio (`command`) server. Defaults to `'legacy'`, + * which is MCP 2024-11-05 + MCPL and today's behavior. `'modern'` speaks + * MCP 2026-07-28 through the SDK-backed modern engine. A network server's + * family comes from its url's scheme, so setting this on one is a + * configuration error. There is no probing between families. + */ + protocol?: 'legacy' | 'modern'; /** * Bearer token for WebSocket auth. Appended to `url` as a `token` query @@ -1315,6 +1328,8 @@ export interface McpToolDefinition { name: string; description?: string; inputSchema: Record; + /** Server metadata for the tool (e.g. RFC-008 `mcpl/class` hints). */ + _meta?: Record; } /** diff --git a/test/fixtures/awareness-barrier-mcpl-server.mjs b/test/fixtures/awareness-barrier-mcpl-server.mjs index a16b342b3..24156d023 100644 --- a/test/fixtures/awareness-barrier-mcpl-server.mjs +++ b/test/fixtures/awareness-barrier-mcpl-server.mjs @@ -166,6 +166,7 @@ function beginServerTraffic() { function handle(message) { if (message.method === 'initialize') { reply(message.id, { + protocolVersion: '2024-11-05', capabilities: { experimental: { mcpl: { diff --git a/test/fixtures/legacy-version-server.mjs b/test/fixtures/legacy-version-server.mjs new file mode 100644 index 000000000..33979b28e --- /dev/null +++ b/test/fixtures/legacy-version-server.mjs @@ -0,0 +1,65 @@ +// Stdio MCP server for the legacy engine's protocol-version and pagination +// tests. Usage: node legacy-version-server.mjs [startLog] +// +// Modes (how `initialize` is answered): +// ok protocolVersion 2024-11-05 +// reject error -32022 with data { supported: ['2026-07-28'], requested } +// reject-nodata error -32022 without data +// mismatch protocolVersion 2025-06-18 +// missing no protocolVersion at all +// ok-then-reject `ok` on the first start recorded in startLog, `reject` after +// +// tools/list pages: `ok` serves three pages (cursors p2, p3). Tool `cursor-loop` +// mode is selected with MODE_LIST=loop (always nextCursor "same") or +// MODE_LIST=empty (nextCursor ""). Tool `die` exits the process. +import { appendFileSync, existsSync, readFileSync } from 'node:fs'; + +let mode = process.argv[2] ?? 'ok'; +const startLog = process.argv[3]; +if (startLog) { + const priorStarts = existsSync(startLog) ? readFileSync(startLog, 'utf8').split('\n').filter(Boolean).length : 0; + appendFileSync(startLog, `start ${process.pid}\n`); + if (mode === 'ok-then-reject') mode = priorStarts === 0 ? 'ok' : 'reject'; +} +const listMode = process.env.MODE_LIST ?? 'pages'; + +const send = (m) => process.stdout.write(JSON.stringify(m) + '\n'); +const reply = (id, result) => send({ jsonrpc: '2.0', id, result }); +const error = (id, code, message, data) => send({ jsonrpc: '2.0', id, error: { code, message, ...(data === undefined ? {} : { data }) } }); + +const tool = (name) => ({ name, description: name, inputSchema: { type: 'object' } }); + +let buf = ''; +process.stdin.on('data', (chunk) => { + buf += chunk.toString('utf8'); + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i); + buf = buf.slice(i + 1); + if (!line.trim()) continue; + let m; + try { m = JSON.parse(line); } catch { continue; } + if (m.method === 'initialize') { + const requested = m.params?.protocolVersion; + if (mode === 'reject') error(m.id, -32022, `Unsupported protocol version: ${requested}`, { supported: ['2026-07-28'], requested }); + else if (mode === 'reject-nodata') error(m.id, -32022, `Unsupported protocol version: ${requested}`); + else if (mode === 'mismatch') reply(m.id, { protocolVersion: '2025-06-18', capabilities: { tools: {} } }); + else if (mode === 'missing') reply(m.id, { capabilities: { tools: {} } }); + else reply(m.id, { protocolVersion: '2024-11-05', capabilities: { tools: {} } }); + } else if (m.method === 'tools/list') { + const cursor = m.params?.cursor; + if (listMode === 'loop') reply(m.id, { tools: [tool(`loop-${cursor ?? 'first'}`)], nextCursor: 'same' }); + else if (listMode === 'empty') reply(m.id, { tools: [tool('only')], nextCursor: '' }); + else if (cursor === undefined) reply(m.id, { tools: [tool('a1'), tool('a2')], nextCursor: 'p2' }); + else if (cursor === 'p2') reply(m.id, { tools: [tool('b1')], nextCursor: 'p3' }); + else if (cursor === 'p3') reply(m.id, { tools: [tool('c1'), tool('die')] }); + else error(m.id, -32602, `unknown cursor ${cursor}`); + } else if (m.method === 'tools/call') { + if (m.params?.name === 'die') process.exit(3); + reply(m.id, { content: [{ type: 'text', text: 'ok' }] }); + } else if (m.id !== undefined) { + error(m.id, -32601, `Method not found: ${m.method}`); + } + } +}); +process.stdin.on('end', () => process.exit(0)); diff --git a/test/host-quiesce.test.ts b/test/host-quiesce.test.ts index cae60190e..14be7b64d 100644 --- a/test/host-quiesce.test.ts +++ b/test/host-quiesce.test.ts @@ -364,7 +364,7 @@ process.stdin.on('data', (c) => { if (!line.trim()) continue; let m; try { m = JSON.parse(line); } catch { continue; } const reply = (result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: m.id, result }) + '\\n'); - if (m.method === 'initialize') reply({ capabilities: { experimental: { mcpl: { version: '0.4', pushEvents: true } } } }); + if (m.method === 'initialize') reply({ protocolVersion: '2024-11-05', capabilities: { experimental: { mcpl: { version: '0.4', pushEvents: true } } } }); else if (m.method === 'tools/list') reply({ tools: [] }); else if (m.id === 900) log('push-response', { error: m.error ?? null }); else if (m.id === 901) log('resume-response', { result: m.result ?? null, error: m.error ?? null }); diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts new file mode 100644 index 000000000..cd53aba66 --- /dev/null +++ b/test/mcp-protocol-family.test.ts @@ -0,0 +1,103 @@ +/** + * Protocol family and transport selection (room-284 design, shelf-487). + * A URL's scheme decides a network server's family; only a stdio server has + * a choice, and it defaults to legacy. Validation adds checks only for what + * the modern family brought, so every legacy configuration still passes. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + resolveServerBinding, + serverConfigProblems, + MCPL_ONLY_POLICY_FIELDS, + MAX_TIMER_MS, +} from '../src/mcpl/protocol-family.js'; +import type { McplServerConfig } from '../src/mcpl/types.js'; + +const cfg = (c: Partial): McplServerConfig => ({ id: 's', ...c }); + +test('family and transport follow the configuration', () => { + assert.deepEqual(resolveServerBinding(cfg({ command: 'srv' })), { family: 'legacy', transport: 'stdio' }); + assert.deepEqual(resolveServerBinding(cfg({ command: 'srv', protocol: 'legacy' })), { family: 'legacy', transport: 'stdio' }); + assert.deepEqual(resolveServerBinding(cfg({ command: 'srv', protocol: 'modern' })), { family: 'modern', transport: 'stdio' }); + assert.deepEqual(resolveServerBinding(cfg({ url: 'ws://localhost:3000/mcpl' })), { family: 'legacy', transport: 'websocket' }); + assert.deepEqual(resolveServerBinding(cfg({ url: 'wss://example.com/mcpl' })), { family: 'legacy', transport: 'websocket' }); + assert.deepEqual(resolveServerBinding(cfg({ url: 'http://localhost:8080/mcp' })), { family: 'modern', transport: 'http' }); + assert.deepEqual(resolveServerBinding(cfg({ url: 'https://example.com/mcp' })), { family: 'modern', transport: 'http' }); + // Existing semantics: a command wins over a url unless transport says otherwise. + assert.deepEqual(resolveServerBinding(cfg({ command: 'srv', url: 'ws://x/mcpl' })), { family: 'legacy', transport: 'stdio' }); + assert.deepEqual( + resolveServerBinding(cfg({ command: 'srv', url: 'ws://x/mcpl', transport: 'websocket' })), + { family: 'legacy', transport: 'websocket' }, + ); + assert.deepEqual( + resolveServerBinding(cfg({ url: 'https://x/mcp', transport: 'http' })), + { family: 'modern', transport: 'http' }, + ); +}); + +test('a configuration that names no usable transport throws', () => { + assert.throws(() => resolveServerBinding(cfg({})), /needs "command"/); + assert.throws(() => resolveServerBinding(cfg({ url: 'ftp://x' })), /ws:\/\/ or wss:\/\/.*http:\/\/ or https:\/\//); + assert.throws(() => resolveServerBinding(cfg({ url: 'not a url' })), /invalid url/); + assert.throws(() => resolveServerBinding(cfg({ url: 'ws://x', transport: 'http' })), /does not match url/); + assert.throws(() => resolveServerBinding(cfg({ url: 'https://x', transport: 'websocket' })), /does not match url/); + assert.throws(() => resolveServerBinding(cfg({ url: 'ws://x', transport: 'stdio' })), /requires "command"/); +}); + +test('every legacy configuration shape passes validation unchanged', () => { + const legacy: McplServerConfig[] = [ + cfg({ command: 'srv', requestTimeoutMs: 0 }), // zero still disables the legacy watchdog + cfg({ command: 'srv', enabledFeatureSets: ['a.*'], allowHostCommands: true, channelSubscription: 'auto' }), + cfg({ url: 'wss://x/mcpl', token: 't', toolLifecycle: { observe: { tools: ['*'] } } as never, autofetch: { maxBytes: 1 } }), + cfg({ command: 'srv', protocol: 'legacy', disabledCapabilities: ['contextHooks.*'] }), + ]; + for (const c of legacy) assert.deepEqual(serverConfigProblems(c), [], JSON.stringify(c)); +}); + +test('protocol is a stdio-only choice', () => { + assert.match(serverConfigProblems(cfg({ url: 'https://x/mcp', protocol: 'modern' })).join(), /applies only to stdio/); + assert.match(serverConfigProblems(cfg({ url: 'ws://x/mcpl', protocol: 'legacy' })).join(), /applies only to stdio/); + assert.match(serverConfigProblems(cfg({ command: 'srv', protocol: 'new' as never })).join(), /must be 'legacy' or 'modern'/); +}); + +test('a modern server needs a real deadline', () => { + for (const bad of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, MAX_TIMER_MS + 1]) { + const problems = serverConfigProblems(cfg({ command: 'srv', protocol: 'modern', requestTimeoutMs: bad })); + assert.match(problems.join(), /requestTimeoutMs must be an integer from 1/, String(bad)); + } + for (const good of [undefined, 1, 60_000, MAX_TIMER_MS]) { + assert.deepEqual(serverConfigProblems(cfg({ url: 'https://x/mcp', requestTimeoutMs: good })), [], String(good)); + } +}); + +test('MCPL policy on a modern server is rejected, empty values are not', () => { + const effective: Partial = { + enabledFeatureSets: ['a'], + disabledFeatureSets: ['b'], + enabledCapabilities: ['pushEvents'], + disabledCapabilities: ['channels'], + scopes: { a: {} as never }, + channelSubscription: 'manual', + allowHostCommands: true, + toolLifecycle: { observe: {} } as never, + autofetch: { maxBytes: 10 }, + shouldTriggerInference: () => true, + }; + for (const field of MCPL_ONLY_POLICY_FIELDS) { + const problems = serverConfigProblems(cfg({ url: 'https://x/mcp', [field]: effective[field] })); + assert.equal(problems.length, 1, field); + assert.match(problems[0]!, new RegExp(`"${field}" is MCPL policy`)); + } + const empty = cfg({ + command: 'srv', + protocol: 'modern', + enabledFeatureSets: [], + disabledCapabilities: [], + scopes: {}, + allowHostCommands: false, + toolLifecycle: {} as never, + }); + assert.deepEqual(serverConfigProblems(empty), []); +}); diff --git a/test/mcpl-capability-scoping.test.ts b/test/mcpl-capability-scoping.test.ts index e0c7777ef..29f223e16 100644 --- a/test/mcpl-capability-scoping.test.ts +++ b/test/mcpl-capability-scoping.test.ts @@ -179,7 +179,7 @@ process.stdin.on('data', (c) => { if (!line.trim()) continue; let m; try { m = JSON.parse(line); } catch { continue; } if (m.method === 'initialize') { - send({ jsonrpc: '2.0', id: m.id, result: { capabilities: { experimental: { mcpl: { + send({ jsonrpc: '2.0', id: m.id, result: { protocolVersion: '2024-11-05', capabilities: { experimental: { mcpl: { version: '0.4', pushEvents: true, contextHooks: { beforeInference: true, afterInference: { blocking: true } }, diff --git a/test/mcpl-legacy-version.test.ts b/test/mcpl-legacy-version.test.ts new file mode 100644 index 000000000..1d6a171e0 --- /dev/null +++ b/test/mcpl-legacy-version.test.ts @@ -0,0 +1,155 @@ +/** + * The legacy engine's protocol boundary and inventory (room-284, shelf-487): + * - it offers and accepts exactly MCP 2024-11-05; anything else is a typed + * McplProtocolVersionError carrying what each side said; + * - such a verdict is never retried (no reconnect stub, no backoff loop); + * - tools/list follows nextCursor to the complete inventory, and refuses a + * server whose cursors don't terminate. + */ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { McplServerConnection, McplProtocolVersionError, McplRequestError } from '../src/mcpl/server-connection.js'; +import type { McplHostCapabilities, McplServerConfig } from '../src/mcpl/types.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const SERVER = join(here, 'fixtures', 'legacy-version-server.mjs'); +const HOST_CAPS: McplHostCapabilities = { version: '0.5', pushEvents: true }; + +function config(mode: string, extra: Partial = {}, startLog?: string): McplServerConfig { + return { + id: 'legacy', + command: process.execPath, + args: [SERVER, mode, ...(startLog ? [startLog] : [])], + ...extra, + }; +} + +const cleanups: Array<() => Promise | void> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +async function connect(c: McplServerConfig): Promise { + const connection = await McplServerConnection.connect(c, HOST_CAPS); + cleanups.push(() => connection.close()); + connection.ready(); + return connection; +} + +test('a 2024-11-05 answer establishes the connection and records its revision', async () => { + const connection = await connect(config('ok')); + assert.equal(connection.protocolVersion, '2024-11-05'); +}); + +test('-32022 with a supported list is a typed rejection naming the modern remedy', async () => { + await assert.rejects(McplServerConnection.connect(config('reject'), HOST_CAPS), (err: unknown) => { + assert.ok(err instanceof McplProtocolVersionError); + assert.equal(err.kind, 'rejected'); + assert.equal(err.offered, '2024-11-05'); + assert.deepEqual(err.supported, ['2026-07-28']); + assert.equal(err.code, -32022); + assert.deepEqual(err.data, { supported: ['2026-07-28'], requested: '2024-11-05' }); + assert.match(err.message, /does not support MCP 2024-11-05 \(it supports: 2026-07-28\)/); + assert.match(err.message, /protocol: 'modern'/); + // A negotiation verdict, not a request outcome. + assert.ok(!(err instanceof McplRequestError)); + return true; + }); +}); + +test('-32022 without data still rejects, with the server\'s own message', async () => { + await assert.rejects(McplServerConnection.connect(config('reject-nodata'), HOST_CAPS), (err: unknown) => { + assert.ok(err instanceof McplProtocolVersionError); + assert.equal(err.kind, 'rejected'); + assert.equal(err.supported, null); + assert.match(err.message, /Unsupported protocol version: 2024-11-05/); + assert.doesNotMatch(err.message, /protocol: 'modern'/); + return true; + }); +}); + +test('another legacy revision, or none, is a mismatch rather than a silent connection', async () => { + await assert.rejects(McplServerConnection.connect(config('mismatch'), HOST_CAPS), (err: unknown) => { + assert.ok(err instanceof McplProtocolVersionError); + assert.equal(err.kind, 'mismatch'); + assert.equal(err.returned, '2025-06-18'); + assert.match(err.message, /protocolVersion "2025-06-18"; this client's legacy engine speaks only 2024-11-05/); + return true; + }); + await assert.rejects(McplServerConnection.connect(config('missing'), HOST_CAPS), (err: unknown) => { + assert.ok(err instanceof McplProtocolVersionError); + assert.equal(err.kind, 'mismatch'); + assert.equal(err.returned, null); + assert.match(err.message, /protocolVersion \(none\)/); + return true; + }); +}); + +test('a version verdict gets no reconnect stub even with reconnect: true', async () => { + await assert.rejects( + McplServerConnection.connectWithReconnect(config('reject', { reconnect: true, reconnectIntervalMs: 20 }), HOST_CAPS), + (err: unknown) => err instanceof McplProtocolVersionError, + ); +}); + +test('a version verdict on reconnect stops the backoff loop', async () => { + const dir = mkdtempSync(join(tmpdir(), 'legacy-version-')); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + const startLog = join(dir, 'starts.log'); + const connection = await McplServerConnection.connectWithReconnect( + config('ok-then-reject', { reconnect: true, reconnectIntervalMs: 20, reconnectMaxIntervalMs: 40 }, startLog), + HOST_CAPS, + ); + cleanups.push(() => connection.close()); + connection.ready(); + const failures: Array<{ error: string; attempt: number; permanent?: boolean }> = []; + connection.on('reconnect-failed', (info) => failures.push(info)); + + // The first process answers 2024-11-05; make it exit so the loop runs. + await assert.rejects(connection.sendToolsCall('die', {})); + const deadline = Date.now() + 5_000; + while (failures.length === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 20)); + assert.equal(failures.length, 1, 'one failed reconnect'); + assert.equal(failures[0]!.permanent, true); + assert.match(failures[0]!.error, /does not support MCP 2024-11-05/); + assert.equal(connection.willReconnect, false); + + // No further attempts: the start count stays at 2 (original + one retry). + await new Promise((r) => setTimeout(r, 300)); + const starts = existsSync(startLog) ? readFileSync(startLog, 'utf8').split('\n').filter(Boolean).length : 0; + assert.equal(starts, 2); + assert.equal(failures.length, 1); +}); + +test('tools/list follows nextCursor to the complete inventory', async () => { + const connection = await connect(config('ok')); + const { tools } = await connection.sendToolsList(); + assert.deepEqual(tools.map((t) => t.name), ['a1', 'a2', 'b1', 'c1', 'die']); +}); + +test('an empty nextCursor ends the inventory', async () => { + const connection = await connect(config('ok', { env: { MODE_LIST: 'empty' } })); + const { tools } = await connection.sendToolsList(); + assert.deepEqual(tools.map((t) => t.name), ['only']); +}); + +test('a repeating cursor is an error, not a silently partial inventory', async () => { + const connection = await connect(config('ok', { env: { MODE_LIST: 'loop' } })); + await assert.rejects(connection.sendToolsList(), /repeated tools\/list cursor "same"/); +}); + +test('a modern configuration is refused by the legacy engine before anything is dialed', async () => { + await assert.rejects( + McplServerConnection.connect({ id: 'modern', url: 'https://example.invalid/mcp' }, HOST_CAPS), + /configured for modern MCP \(2026-07-28\); McplServerConnection speaks the legacy family/, + ); + await assert.rejects( + McplServerConnection.connect({ id: 'modern', command: process.execPath, protocol: 'modern' }, HOST_CAPS), + /configured for modern MCP/, + ); +}); diff --git a/test/mcpl-request-outcome.test.ts b/test/mcpl-request-outcome.test.ts index 1b0f45647..43821d1d1 100644 --- a/test/mcpl-request-outcome.test.ts +++ b/test/mcpl-request-outcome.test.ts @@ -27,7 +27,7 @@ process.stdin.on('data', (c) => { if (!line.trim()) continue; let m; try { m = JSON.parse(line); } catch { continue; } const out = (o) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: m.id, ...o }) + '\\n'); - if (m.method === 'initialize') out({ result: { capabilities: {} } }); + if (m.method === 'initialize') out({ result: { protocolVersion: '2024-11-05', capabilities: {} } }); else if (m.method === 'tools/call' && m.params.name === 'fail') { out({ error: { code: -32001, message: 'POSTED: part 1 of 2', data: { posted: ['111'] } } }); } else if (m.method === 'tools/call' && m.params.name === 'exit') { diff --git a/test/mcpl-request-timeout.test.ts b/test/mcpl-request-timeout.test.ts index 2778469e3..038274f4f 100644 --- a/test/mcpl-request-timeout.test.ts +++ b/test/mcpl-request-timeout.test.ts @@ -23,7 +23,7 @@ process.stdin.on('data', (c) => { if (!line.trim()) continue; let m; try { m = JSON.parse(line); } catch { continue; } const reply = (result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: m.id, result }) + '\\n'); - if (m.method === 'initialize') reply({ capabilities: {} }); + if (m.method === 'initialize') reply({ protocolVersion: '2024-11-05', capabilities: {} }); else if (m.method === 'tools/list') reply({ tools: [{ name: 'stuck', description: 's', inputSchema: { type: 'object' } }] }); // tools/call: deliberately NO response, connection stays open. } diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index 82c48137a..56c10c52b 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -28,7 +28,7 @@ process.stdin.on('data', (c) => { if (!line.trim()) continue; let m; try { m = JSON.parse(line); } catch { continue; } const reply = (result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: m.id, result }) + '\\n'); - if (m.method === 'initialize') reply({ capabilities: { experimental: { mcpl: { version: '0.4', pushEvents: true } } } }); + if (m.method === 'initialize') reply({ protocolVersion: '2024-11-05', capabilities: { experimental: { mcpl: { version: '0.4', pushEvents: true } } } }); else if (m.method === 'tools/list') reply({ tools: [{ name: 'echo', description: 'e', inputSchema: { type: 'object' } }] }); else if (m.method === 'tools/call') reply({ content: [{ type: 'text', text: String((m.params && m.params.arguments && m.params.arguments.text) || '') }] }); } @@ -139,7 +139,7 @@ test('connectMcplServer hot-connects a network-only (WebSocket) MCPL server', as let msg: { id?: unknown; method?: string; params?: { arguments?: Record } }; try { msg = JSON.parse(String(data)); } catch { return; } const reply = (result: unknown) => ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result })); - if (msg.method === 'initialize') reply({ capabilities: { experimental: { mcpl: { version: '0.4', pushEvents: true } } } }); + if (msg.method === 'initialize') reply({ protocolVersion: '2024-11-05', capabilities: { experimental: { mcpl: { version: '0.4', pushEvents: true } } } }); else if (msg.method === 'tools/list') reply({ tools: [{ name: 'echo', description: 'e', inputSchema: { type: 'object' } }] }); }); }); diff --git a/test/mcpl-ws-transport.test.ts b/test/mcpl-ws-transport.test.ts index 0599910c3..7626eaf76 100644 --- a/test/mcpl-ws-transport.test.ts +++ b/test/mcpl-ws-transport.test.ts @@ -28,7 +28,7 @@ function startMockMcplWsServer(onSocket?: (ws: WebSocket) => void) { const reply = (result: unknown) => ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result })); switch (msg.method) { case 'initialize': - reply({ capabilities: { experimental: { mcpl: { version: '0.4', pushEvents: true } } } }); + reply({ protocolVersion: '2024-11-05', capabilities: { experimental: { mcpl: { version: '0.4', pushEvents: true } } } }); break; case 'notifications/initialized': break; // notification, no reply @@ -133,7 +133,7 @@ process.stdin.on('data', (c) => { if (!line.trim()) continue; let m; try { m = JSON.parse(line); } catch { continue; } const reply = (result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: m.id, result }) + '\\n'); - if (m.method === 'initialize') reply({ capabilities: { experimental: { mcpl: { version: '0.4', pushEvents: true } } } }); + if (m.method === 'initialize') reply({ protocolVersion: '2024-11-05', capabilities: { experimental: { mcpl: { version: '0.4', pushEvents: true } } } }); else if (m.method === 'tools/list') reply({ tools: [{ name: 'echo', description: 'e', inputSchema: { type: 'object' } }] }); else if (m.method === 'tools/call') reply({ content: [{ type: 'text', text: String((m.params && m.params.arguments && m.params.arguments.text) || '') }] }); } From 3a3104ccd7e7fd12e7185a14231b57aabbffad98 Mon Sep 17 00:00:00 2001 From: Olive-1804 Date: Thu, 8 Oct 2026 01:12:48 -0400 Subject: [PATCH 03/94] =?UTF-8?q?feat(mcp):=20ModernMcpConnection=20?= =?UTF-8?q?=E2=80=94=20MCP=202026-07-28=20over=20stdio=20and=20Streamable?= =?UTF-8?q?=20HTTP=20via=20SDK=202.x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second batch of room-284's modern-MCP client work (shelf-487). Adds the modern engine as its own connection class. Framework integration is the next batch; nothing in the framework constructs it yet. The SDK (@modelcontextprotocol/client ^2.3.1, pinned to 2026-07-28) owns the modern wire: server/discover, the per-request _meta envelope, resultType, input_required continuation, cancellation, subscriptions/listen, and the HTTP binding's headers, 401 retry and header-mismatch relist. Connectome owns: - stdio through its own spawner (SpawnerStdioTransport over StdioTransport). The child launches once per connect: a custom transport probes in place, where the SDK's own stdio transport starts a throwaway sibling. It gets the same env allowlist, inheritEnv and stderr lines as a legacy child, and cancellation by notifications/cancelled. - HTTP through the SDK's StreamableHTTPClientTransport, with token and accessProvider behind the SDK's AuthProvider seam: a cached bearer, re-resolved once on 401. - One deadline per call. requestTimeoutMs is always positive for modern (0 is rejected by config validation, since the SDK reads 0 as "time out now"), and is passed as timeout, maxTotalTimeout and an outer AbortSignal, so continuation rounds and pre-dispatch retries can't renew the wait. At the deadline the SDK requests cancellation and the error says the outcome is unknown. Nothing is ever replayed. - Failures use the McplRequestError contract, claiming only what's established. A JSON-RPC error, or an HTTP 401/403 carrying the status, is error-response. Everything after hand-off, the deadline included, is no-response. Connectome's own pre-send refusal is not-sent. A result the server returned that can't be used (failed validation, input this client can't supply, a rounds cap) is a plain Error, not an outcome. - Results kept by presence: structuredContent false/0/null are values. - Lifetime: reconnect with the legacy backoff (reconnect / intervals). A lost list-change subscription is reopened while the connection lives, followed by one inventory refresh. Each connect is a generation, so a superseded client never touches a newer one. Client capabilities are {}: no sampling, elicitation or roots. Tests (13, at our seams): stdio launch count, env allowlist, stderr, results by presence, the deadline cancelled server-side (warm-up first, since SDK 2.0.0's server ignored cancellation of request id 0 in probes), list change, crash and reconnect with relaunch count 2, no-reconnect refusal as not-sent, retrying first connect stopped by close, config errors; plus one HTTP smoke covering accessProvider refresh, required headers, deadline abort seen server-side, 401 connect failure, and auth wall as error-response 401. Stable across 3 reruns. --- package.json | 2 + src/index.ts | 2 + src/mcpl/index.ts | 2 + src/mcpl/modern-connection.ts | 455 ++++++++++++++++++++++++++++ test/fixtures/modern-mcp-server.mjs | 13 + test/fixtures/modern-tools.mjs | 73 +++++ test/mcp-modern-connection.test.ts | 186 ++++++++++++ test/mcp-modern-http.test.ts | 138 +++++++++ 8 files changed, 871 insertions(+) create mode 100644 src/mcpl/modern-connection.ts create mode 100644 test/fixtures/modern-mcp-server.mjs create mode 100644 test/fixtures/modern-tools.mjs create mode 100644 test/mcp-modern-connection.test.ts create mode 100644 test/mcp-modern-http.test.ts diff --git a/package.json b/package.json index 4bf3dbcaa..138d9d6db 100644 --- a/package.json +++ b/package.json @@ -49,11 +49,13 @@ "@animalabs/chronicle": "^0.4.0", "@animalabs/context-manager": "^0.13.0", "@animalabs/membrane": "^0.5.78", + "@modelcontextprotocol/client": "^2.3.1", "chokidar": "^4.0.3", "discord.js": "^14.25.1", "ws": "^8.18.0" }, "devDependencies": { + "@modelcontextprotocol/server": "^2.3.1", "@types/node": "^22.0.0", "@types/ws": "^8.5.13", "discord.js": "^14.25.1", diff --git a/src/index.ts b/src/index.ts index 9e6b0073f..34b61109a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,6 +91,8 @@ export type { McplServerConfig } from './mcpl/index.js'; // health probes, protocol harnesses — can dial an MCPL server exactly the // way the framework host does, over stdio or WebSocket) export { McplServerConnection, McplRequestError, McplProtocolVersionError } from './mcpl/index.js'; +export { ModernMcpConnection } from './mcpl/index.js'; +export type { ModernToolCallResult } from './mcpl/index.js'; export { LEGACY_MCP_PROTOCOL_VERSION, MODERN_MCP_PROTOCOL_VERSION, diff --git a/src/mcpl/index.ts b/src/mcpl/index.ts index c3bc7ef31..8e09c869e 100644 --- a/src/mcpl/index.ts +++ b/src/mcpl/index.ts @@ -107,6 +107,8 @@ export { // Server connection and registry export { McplServerConnection, McplRequestError, McplProtocolVersionError } from './server-connection.js'; +export { ModernMcpConnection } from './modern-connection.js'; +export type { ModernToolCallResult } from './modern-connection.js'; export { LEGACY_MCP_PROTOCOL_VERSION, MODERN_MCP_PROTOCOL_VERSION, diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts new file mode 100644 index 000000000..cce545d09 --- /dev/null +++ b/src/mcpl/modern-connection.ts @@ -0,0 +1,455 @@ +/** + * ModernMcpConnection: a server that speaks modern MCP (2026-07-28), reached + * over stdio or Streamable HTTP through the official SDK client. + * + * Responsibilities divide like this. The SDK owns the modern wire: + * `server/discover`, the per-request `_meta` envelope, `resultType`, + * continuation of `input_required` results, cancellation, list-change + * subscriptions, and the HTTP binding's headers, auth retry and + * header-mismatch handling. This class owns what Connectome decides: + * - **stdio** runs through Connectome's own spawner, so the child gets the + * same env allowlist, `inheritEnv` and stderr surfacing as a legacy server, + * and it starts once per connect. The SDK's own stdio transport starts a + * throwaway second copy for discovery. + * - **HTTP credentials** come from `token`/`accessProvider`: a cached bearer + * token, re-resolved once when the server answers 401. + * - **One deadline per tool call** (`requestTimeoutMs`, always positive) + * bounds every leg of the call. At the deadline the SDK requests + * cancellation and the outcome is unknown. Calls are never replayed. + * - **Lifetime.** On a lost transport the connection restarts with the same + * backoff settings as legacy, when `reconnect` is set. A lost list-change + * stream is reopened while the connection lives. Each connect is a + * generation, so a superseded client can never touch a newer one. + * + * Nothing here is MCPL: a modern server has no grant, no planes and no + * server→host requests. The framework uses this connection only through its + * tool paths, and the MCPL machinery never sees it. + */ +import { EventEmitter } from 'node:events'; + +import { + Client, + ProtocolError, + SdkError, + SdkErrorCode, + SdkHttpError, + StreamableHTTPClientTransport, + type AuthProvider, + type CallToolResult, + type JSONRPCMessage, + type McpSubscription, + type Transport, +} from '@modelcontextprotocol/client'; + +import { StdioTransport, type McplTransport } from './transport.js'; +import { McplRequestError } from './server-connection.js'; +import { MODERN_MCP_PROTOCOL_VERSION, checkServerConfig } from './protocol-family.js'; +import type { McplServerConfig, McpToolDefinition } from './types.js'; + +const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; +/** Bound on establishing the connection (`server/discover`), like the legacy + * engine's initialize timeout. */ +const CONNECT_TIMEOUT_MS = 30_000; +/** Inventory listing has its own bound: it is host work, not a resident's call. */ +const LIST_TIMEOUT_MS = 30_000; + +/** A modern tool result as the framework consumes it. `structuredContent` is + * kept by presence: `false`, `0` and `null` are values, not absence. */ +export interface ModernToolCallResult { + content: CallToolResult['content']; + isError?: boolean; + structuredContent?: unknown; + _meta?: Record; +} + +/** + * SDK `Transport` over Connectome's stdio spawner. Framing is the same + * newline-delimited JSON-RPC as legacy. Lines that are not JSON are ignored, + * as the legacy engine ignores them. Because this is a custom transport, the + * SDK runs `server/discover` on this very process instead of starting a + * throwaway copy, and cancels with `notifications/cancelled`, which is the + * stdio binding's mechanism. + */ +class SpawnerStdioTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + private line: McplTransport | null = null; + + constructor( + private readonly config: McplServerConfig, + private readonly onStderr: (line: string) => void, + ) {} + + async start(): Promise { + const line = StdioTransport.spawn(this.config); + this.line = line; + line.on('line', (text: string) => { + let message: JSONRPCMessage; + try { + message = JSON.parse(text) as JSONRPCMessage; + } catch { + return; + } + this.onmessage?.(message); + }); + line.on('stderr', (text: string) => this.onStderr(text)); + line.on('error', (error: Error) => this.onerror?.(error)); + line.on('close', () => this.onclose?.()); + } + + async send(message: JSONRPCMessage): Promise { + if (!this.line || this.line.closed) throw new Error(`MCP server "${this.config.id}" stdio transport is closed`); + this.line.writeLine(JSON.stringify(message)); + } + + async close(): Promise { + await this.line?.close(); + } +} + +/** Connectome's credential plumbing as the SDK's minimal bearer seam: a + * cached token, fetched on first use and once again after a 401. */ +function bearerAuth(config: McplServerConfig): AuthProvider | undefined { + const provider = config.accessProvider; + if (!provider && !config.token) return undefined; + let cached: string | undefined; + const resolve = async (): Promise => { + if (provider) { + try { + const fresh = await provider(); + if (fresh) return fresh; + } catch (error) { + // Same fallback as the WebSocket dial: use the static token, and let + // the server refuse a stale credential loudly. + console.error( + `[mcp] server "${config.id}": access provider failed (${error instanceof Error ? error.message : error}) — using configured fallback`, + ); + } + } + return config.token; + }; + return { + token: async () => (cached ??= await resolve()), + onUnauthorized: async () => { + cached = await resolve(); + }, + }; +} + +export class ModernMcpConnection extends EventEmitter { + readonly id: string; + readonly family = 'modern' as const; + readonly transportKind: 'stdio' | 'http'; + /** The pinned revision, once a connect has established it; null before. */ + protocolVersion: string | null = null; + + private client: Client | null = null; + private connected = false; + private generation = 0; + private closedByHost = false; + private readonly reconnectEnabled: boolean; + private readonly reconnectIntervalMs: number; + private readonly reconnectMaxIntervalMs: number; + private reconnectTimer: ReturnType | null = null; + private reconnectAttempts = 0; + private relistenTimer: ReturnType | null = null; + private relistenAttempts = 0; + + private constructor(private readonly config: McplServerConfig) { + super(); + this.id = config.id; + const binding = checkServerConfig(config); + if (binding.family !== 'modern') { + throw new Error(`MCP server "${config.id}" is not configured for modern MCP; it belongs to McplServerConnection`); + } + this.transportKind = binding.transport === 'http' ? 'http' : 'stdio'; + this.reconnectEnabled = config.reconnect === true; + this.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000; + this.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000; + // An 'error' with no listener would throw out of an SDK callback and take + // the host down; out-of-band errors here are diagnostics, never fatal. + this.on('error', (error: Error) => { + if (this.listenerCount('error') === 1) console.error(`[mcp] ${this.id}: ${error.message}`); + }); + } + + /** The per-call deadline: `requestTimeoutMs`, validated positive. */ + get requestTimeoutMs(): number { + return this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + } + + get isConnected(): boolean { + return this.connected; + } + + /** Whether a lost connection will be re-established in the background. */ + get willReconnect(): boolean { + return this.reconnectEnabled && !this.closedByHost; + } + + /** + * Connect, or with `reconnect` set, return a connection that keeps + * retrying in the background when the first attempt fails (the legacy + * engine's contract). Configuration errors always throw. + */ + static async connect(config: McplServerConfig): Promise { + const connection = new ModernMcpConnection(config); + try { + await connection.open(); + } catch (error) { + if (!connection.reconnectEnabled) throw error; + console.error(`MCP server "${config.id}" initial connect failed, will retry:`, (error as Error).message); + connection.reconnectAttempts = 1; + // A macrotask, so the caller has wired its listeners (synchronously, + // right after this resolves) before the event fires. + setImmediate(() => connection.emit('connect-failed', { error: (error as Error).message, attempt: 0 })); + connection.scheduleReconnect(); + } + return connection; + } + + /** One connect generation: a fresh client and transport. */ + private async open(): Promise { + const generation = ++this.generation; + const live = () => generation === this.generation && !this.closedByHost; + + const transport: Transport = this.transportKind === 'stdio' + ? new SpawnerStdioTransport(this.config, (line) => { if (live()) this.emit('stderr', { line }); }) + : new StreamableHTTPClientTransport(new URL(this.config.url!), { authProvider: bearerAuth(this.config) }); + + const client = new Client( + { name: 'agent-framework', version: '1.0.0' }, + { + versionNegotiation: { mode: { pin: MODERN_MCP_PROTOCOL_VERSION } }, + // Nothing advertised: this client supplies no sampling, elicitation + // or roots, so a conforming server cannot require them. + capabilities: {}, + listChanged: { + tools: { + autoRefresh: false, + debounceMs: 0, + onChanged: () => { if (live()) this.emit('tools-list-changed'); }, + }, + }, + }, + ); + client.onerror = (error: Error) => { + // The SDK reports late responses to deadline-abandoned calls here + // ("unknown message ID"), among other out-of-band errors. They are + // logged, never delivered: the call's outcome was already reported. + if (live()) this.emit('error', error); + }; + client.onclose = () => { + if (live()) this.handleLost('transport closed'); + }; + + try { + await client.connect(transport, { timeout: CONNECT_TIMEOUT_MS }); + } catch (error) { + await client.close().catch(() => {}); + throw error; + } + if (!live()) { + await client.close().catch(() => {}); + return; + } + this.client = client; + this.connected = true; + this.protocolVersion = MODERN_MCP_PROTOCOL_VERSION; + this.reconnectAttempts = 0; + this.watchSubscription(client.autoOpenedSubscription, generation); + } + + /** + * Keep list-change awareness while the connection lives. The SDK opens the + * subscription at connect when the server advertises tool list changes. + * When the subscription ends without our asking, it is reopened with + * backoff, and the inventory is refreshed once it's back, since changes + * may have been missed in between. A transport loss is the reconnect + * path's job, not this one's. + */ + private watchSubscription(subscription: McpSubscription | undefined, generation: number): void { + if (!subscription) return; + void subscription.closed.then((reason) => { + if (reason === 'local' || generation !== this.generation || this.closedByHost || !this.connected) return; + this.scheduleRelisten(generation); + }); + } + + private scheduleRelisten(generation: number): void { + if (this.relistenTimer) return; + const delay = this.backoffDelay(this.relistenAttempts); + this.relistenTimer = setTimeout(() => { + this.relistenTimer = null; + void this.relisten(generation); + }, delay); + this.relistenTimer.unref?.(); + } + + private async relisten(generation: number): Promise { + const client = this.client; + if (!client || generation !== this.generation || this.closedByHost || !this.connected) return; + try { + const subscription = await client.listen({ toolsListChanged: true }, { timeout: CONNECT_TIMEOUT_MS }); + if (generation !== this.generation || this.closedByHost) { + await subscription.close().catch(() => {}); + return; + } + this.relistenAttempts = 0; + this.watchSubscription(subscription, generation); + this.emit('tools-list-changed'); + } catch (error) { + this.relistenAttempts++; + this.emit('error', new Error(`MCP server "${this.id}" could not reopen its list-change subscription: ${(error as Error).message}`)); + this.scheduleRelisten(generation); + } + } + + /** The transport went away (child exit, or a closed HTTP client). */ + private handleLost(reason: string): void { + if (!this.connected) return; + this.connected = false; + this.client = null; + this.emit('close', { reason }); + if (this.willReconnect) this.scheduleReconnect(); + } + + /** The legacy engine's backoff: doubling from the base interval, capped, + * with ±25% jitter. */ + private backoffDelay(failures: number): number { + const exponent = Math.max(0, Math.min(failures - 1, 30)); + const capped = Math.min(this.reconnectIntervalMs * 2 ** exponent, this.reconnectMaxIntervalMs); + return capped * (0.75 + Math.random() * 0.5); + } + + private scheduleReconnect(): void { + if (!this.willReconnect || this.reconnectTimer) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + void this.attemptReconnect(); + }, this.backoffDelay(this.reconnectAttempts)); + } + + private async attemptReconnect(): Promise { + if (!this.willReconnect || this.connected) return; + const attempt = Math.max(1, this.reconnectAttempts); + try { + await this.open(); + if (!this.connected) return; + console.error(`MCP server "${this.id}" reconnected`); + this.emit('reconnect', { attempts: attempt }); + } catch (error) { + this.reconnectAttempts = attempt + 1; + this.emit('reconnect-failed', { error: (error as Error).message, attempt }); + this.scheduleReconnect(); + } + } + + /** The server's complete tool inventory (the SDK follows every page). */ + async listTools(): Promise { + const client = this.requireClient('tools/list'); + try { + const { tools } = await client.listTools(undefined, { cacheMode: 'refresh', timeout: LIST_TIMEOUT_MS }); + return tools.map((tool) => ({ + name: tool.name, + ...(tool.description !== undefined ? { description: tool.description } : {}), + inputSchema: tool.inputSchema as Record, + ...(tool._meta !== undefined ? { _meta: tool._meta as Record } : {}), + })); + } catch (error) { + throw this.requestFailure('tools/list', error); + } + } + + /** + * Call a tool within the configured deadline. One budget covers the whole + * logical call: the SDK's per-leg timer and its `maxTotalTimeout` across + * continuation rounds, and an abort signal around any retry the SDK makes + * before dispatch (an auth refresh, or a header-mismatch relist). + */ + async callTool(name: string, args: Record): Promise { + const client = this.requireClient('tools/call'); + const timeoutMs = this.requestTimeoutMs; + try { + const result = await client.callTool( + { name, arguments: args }, + { timeout: timeoutMs, maxTotalTimeout: timeoutMs, signal: AbortSignal.timeout(timeoutMs) }, + ); + return { + content: result.content ?? [], + ...(result.isError !== undefined ? { isError: result.isError } : {}), + ...('structuredContent' in result ? { structuredContent: result.structuredContent } : {}), + ...(result._meta !== undefined ? { _meta: result._meta as Record } : {}), + }; + } catch (error) { + throw this.requestFailure(`tools/call "${name}"`, error, timeoutMs); + } + } + + /** Close for good: no reconnect, no reopened subscription. */ + async close(): Promise { + this.closedByHost = true; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + if (this.relistenTimer) clearTimeout(this.relistenTimer); + this.reconnectTimer = null; + this.relistenTimer = null; + const client = this.client; + const wasConnected = this.connected; + this.client = null; + this.connected = false; + if (client) await client.close().catch(() => {}); + if (wasConnected) this.emit('close', { reason: 'closed by host' }); + } + + /** Connectome refuses before anything reaches the SDK: provably not sent. */ + private requireClient(method: string): Client { + if (!this.client || !this.connected) { + throw new McplRequestError(`Cannot send ${method}: connection to "${this.id}" is not established`, 'not-sent'); + } + return this.client; + } + + /** + * Map an SDK failure onto the request-outcome contract, claiming no more + * than the failure establishes: + * - `error-response`: the server answered with an error. That is a + * JSON-RPC error, or an HTTP auth refusal, which carries the status as + * its code. + * - `no-response`: everything after hand-off that proves nothing about + * delivery, including the deadline, where cancellation was requested and + * the outcome is unknown. + * A call the server answered with a result Connectome can't use (it fails + * validation, or needs input this client can't supply) is a plain error: + * the server did act, so no request outcome applies. + */ + private requestFailure(what: string, error: unknown, timeoutMs?: number): Error { + const prefix = `MCP server "${this.id}"`; + if (error instanceof ProtocolError) { + return new McplRequestError(`${prefix} returned error for ${what}: [${error.code}] ${error.message}`, 'error-response', error.code, error.data); + } + if (error instanceof SdkHttpError && (error.code === SdkErrorCode.ClientHttpAuthentication || error.code === SdkErrorCode.ClientHttpForbidden)) { + return new McplRequestError(`${prefix} refused ${what}: HTTP ${error.status} (${error.message})`, 'error-response', error.status, error.data); + } + if (error instanceof SdkError) { + switch (error.code) { + case SdkErrorCode.RequestTimeout: + return new McplRequestError( + `${prefix} did not answer ${what}${timeoutMs ? ` within ${timeoutMs}ms` : ''}. Cancellation was requested; ` + + `the outcome is unknown: the tool may still complete server-side. Verify state before retrying; ` + + `a blind retry of a side-effecting tool may duplicate it.`, + 'no-response', + ); + case SdkErrorCode.InvalidResult: + case SdkErrorCode.UnsupportedResultType: + case SdkErrorCode.InputRequiredRoundsExceeded: + case SdkErrorCode.CapabilityNotSupported: + return new Error(`${prefix} answered ${what}, but the result can't be used: ${error.message}`); + default: + return new McplRequestError(`${prefix} ${what} failed: ${error.message}`, 'no-response'); + } + } + const message = error instanceof Error ? error.message : String(error); + return new McplRequestError(`${prefix} ${what} failed: ${message}`, 'no-response'); + } +} diff --git a/test/fixtures/modern-mcp-server.mjs b/test/fixtures/modern-mcp-server.mjs new file mode 100644 index 000000000..21c324d38 --- /dev/null +++ b/test/fixtures/modern-mcp-server.mjs @@ -0,0 +1,13 @@ +// Stdio modern-MCP fixture: SDK 2.x serveStdio pinned to 2026-07-28 with +// legacy openings refused (the house services' posture). +// Usage: node modern-mcp-server.mjs [startLog] [eventsFile] +// startLog receives one line per process start, to count launches. +import { appendFileSync } from 'node:fs'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import { createServer } from './modern-tools.mjs'; + +const [startLog, eventsFile] = process.argv.slice(2); +if (startLog) appendFileSync(startLog, `start ${process.pid}\n`); +process.stderr.write(`modern fixture ${process.pid} up\n`); + +serveStdio(() => createServer({ eventsFile }), { legacy: 'reject' }); diff --git a/test/fixtures/modern-tools.mjs b/test/fixtures/modern-tools.mjs new file mode 100644 index 000000000..d4e28a724 --- /dev/null +++ b/test/fixtures/modern-tools.mjs @@ -0,0 +1,73 @@ +// Tools for the modern-MCP fixture servers (SDK 2.x McpServer), shared by the +// stdio fixture (modern-mcp-server.mjs) and the in-process HTTP test server. +import { appendFileSync } from 'node:fs'; +import { fromJsonSchema, McpServer } from '@modelcontextprotocol/server'; + +const obj = (properties = {}) => fromJsonSchema({ type: 'object', properties }); + +/** + * @param {{ log?: (line: string) => void, eventsFile?: string }} [opts] + * `eventsFile` (optional) receives one line per notable server-side event + * (`slow-start`, `slow-aborted`, `slow-finished`) so a test can observe what + * the server saw, independently of what the client reports. + */ +export function createServer(opts = {}) { + const note = (line) => { + opts.log?.(line); + if (opts.eventsFile) appendFileSync(opts.eventsFile, `${line}\n`); + }; + const server = new McpServer( + { name: 'modern-fixture', version: '1.0.0' }, + { capabilities: { tools: { listChanged: true } } }, + ); + server.registerTool('echo', { description: 'Echo text back', inputSchema: obj({ text: { type: 'string' } }) }, + async (args) => ({ content: [{ type: 'text', text: `echo:${args.text}` }] })); + server.registerTool('structured_only', { description: 'Only structured content', inputSchema: obj() }, + async () => ({ content: [], structuredContent: { answer: 42, ok: false, nothing: null } })); + server.registerTool('structured_and_text', { description: 'Summary text plus structured data', inputSchema: obj() }, + async () => ({ content: [{ type: 'text', text: 'Found 2 rows.' }], structuredContent: { rows: [{ id: 1 }, { id: 2 }] } })); + server.registerTool('structured_zero', { description: 'Falsy structured value', inputSchema: obj() }, + async () => ({ content: [], structuredContent: 0 })); + server.registerTool('media', { description: 'Mixed standard content', inputSchema: obj() }, + async () => ({ + content: [ + { type: 'text', text: 'before' }, + { type: 'resource_link', uri: 'file:///srv/report.pdf', name: 'report.pdf', mimeType: 'application/pdf' }, + { type: 'resource', resource: { uri: 'memo://note', mimeType: 'text/plain', text: 'embedded note text' } }, + { type: 'resource', resource: { uri: 'memo://bin', mimeType: 'application/octet-stream', blob: Buffer.from('binary!').toString('base64') } }, + { type: 'audio', data: Buffer.from('RIFF0000WAVEfmt ').toString('base64'), mimeType: 'audio/wav' }, + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + ], + })); + server.registerTool('fail', { description: 'Reports a tool error', inputSchema: obj() }, + async () => ({ content: [{ type: 'text', text: 'the tool failed on purpose' }], isError: true })); + server.registerTool('slow', { description: 'Sleeps, then answers', inputSchema: obj({ ms: { type: 'number' } }) }, + async (args, ctx) => { + const ms = Number(args.ms ?? 1000); + const signal = ctx?.mcpReq?.signal; + note('slow-start'); + signal?.addEventListener('abort', () => note('slow-aborted')); + await new Promise((resolve) => setTimeout(resolve, ms)); + note('slow-finished'); + return { content: [{ type: 'text', text: `slept ${ms}` }] }; + }); + server.registerTool('env', { description: 'Reports selected environment variables', inputSchema: obj() }, + async () => ({ content: [{ type: 'text', text: JSON.stringify({ + declared: process.env.FIXTURE_DECLARED ?? null, + hostOnly: process.env.FIXTURE_HOST_ONLY ?? null, + path: Boolean(process.env.PATH), + }) }] })); + let added = 0; + server.registerTool('add_tool', { description: 'Registers another tool (list change)', inputSchema: obj() }, + async () => { + const name = `added_${++added}`; + server.registerTool(name, { description: 'added at runtime', inputSchema: obj() }, + async () => ({ content: [{ type: 'text', text: name }] })); + return { content: [{ type: 'text', text: `registered ${name}` }] }; + }); + server.registerTool('shout', { description: 'Writes a line to stderr', inputSchema: obj({ text: { type: 'string' } }) }, + async (args) => { process.stderr.write(`shout: ${args.text}\n`); return { content: [{ type: 'text', text: 'shouted' }] }; }); + server.registerTool('die', { description: 'Exits the server process', inputSchema: obj() }, + async () => { setTimeout(() => process.exit(7), 10); return { content: [{ type: 'text', text: 'bye' }] }; }); + return server; +} diff --git a/test/mcp-modern-connection.test.ts b/test/mcp-modern-connection.test.ts new file mode 100644 index 000000000..f496ec677 --- /dev/null +++ b/test/mcp-modern-connection.test.ts @@ -0,0 +1,186 @@ +/** + * The modern engine (room-284, shelf-487) over stdio, against an SDK 2.x + * fixture that refuses legacy openings, as the house services do. What + * Connectome owns here, rather than the SDK: + * - one launch per connect, through Connectome's own spawner (env allowlist, + * stderr), instead of the SDK's sibling-probe launch; + * - one deadline per call, which ends as `no-response` with cancellation + * requested, and is never replayed; + * - results kept by presence (structured `0` is a value); + * - reconnect with the legacy backoff settings, and list-change awareness; + * - the request-outcome contract on failures. + */ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { ModernMcpConnection } from '../src/mcpl/modern-connection.js'; +import { McplRequestError } from '../src/mcpl/server-connection.js'; +import type { McplServerConfig } from '../src/mcpl/types.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const SERVER = join(here, 'fixtures', 'modern-mcp-server.mjs'); + +const cleanups: Array<() => Promise | void> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'modern-mcp-')); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +const lines = (file: string) => (existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean) : []); +const until = async (cond: () => boolean, ms = 5000) => { + const deadline = Date.now() + ms; + while (!cond() && Date.now() < deadline) await new Promise((r) => setTimeout(r, 20)); + assert.ok(cond(), 'condition reached in time'); +}; + +function config(dir: string, extra: Partial = {}): McplServerConfig { + return { + id: 'modern', + command: process.execPath, + args: [SERVER, join(dir, 'starts.log'), join(dir, 'events.log')], + protocol: 'modern', + ...extra, + }; +} + +async function connect(c: McplServerConfig): Promise { + const connection = await ModernMcpConnection.connect(c); + cleanups.push(() => connection.close()); + return connection; +} + +test('connects once over Connectome\'s spawner and speaks 2026-07-28', async () => { + const dir = scratch(); + const stderr: string[] = []; + const connection = await connect(config(dir)); + connection.on('stderr', ({ line }: { line: string }) => stderr.push(line)); + assert.equal(connection.isConnected, true); + assert.equal(connection.protocolVersion, '2026-07-28'); + assert.equal(connection.transportKind, 'stdio'); + const names = (await connection.listTools()).map((t) => t.name); + assert.ok(names.includes('echo') && names.includes('structured_only'), names.join()); + const echo = await connection.callTool('echo', { text: 'hi' }); + assert.deepEqual(echo.content, [{ type: 'text', text: 'echo:hi' }]); + // In-place discovery: the SDK's sibling probe would have made this 2. + assert.equal(lines(join(dir, 'starts.log')).length, 1); + // The child's stderr surfaces as lines, as a legacy child's does. + await connection.callTool('shout', { text: 'hello' }); + await until(() => stderr.includes('shout: hello')); +}); + +test('the child gets the env allowlist plus declared env, as legacy children do', async () => { + const dir = scratch(); + process.env.FIXTURE_HOST_ONLY = 'leaked'; + cleanups.push(() => { delete process.env.FIXTURE_HOST_ONLY; }); + const connection = await connect(config(dir, { env: { FIXTURE_DECLARED: 'yes' } })); + const result = await connection.callTool('env', {}); + const seen = JSON.parse((result.content[0] as { text: string }).text); + assert.deepEqual(seen, { declared: 'yes', hostOnly: null, path: true }); +}); + +test('results are kept by presence: structured-only, falsy structured, tool errors', async () => { + const connection = await connect(config(scratch())); + const only = await connection.callTool('structured_only', {}); + assert.deepEqual(only.content, []); + assert.deepEqual(only.structuredContent, { answer: 42, ok: false, nothing: null }); + const zero = await connection.callTool('structured_zero', {}); + assert.ok('structuredContent' in zero); + assert.equal(zero.structuredContent, 0); + const plain = await connection.callTool('echo', { text: 'x' }); + assert.ok(!('structuredContent' in plain)); + const fail = await connection.callTool('fail', {}); + assert.equal(fail.isError, true); +}); + +test('a deadline requests cancellation and reports no-response, outcome unknown', async () => { + const dir = scratch(); + const connection = await connect(config(dir, { requestTimeoutMs: 300 })); + await connection.callTool('echo', { text: 'warm' }); // the slow call is not the connection's first request + const started = Date.now(); + await assert.rejects(connection.callTool('slow', { ms: 2000 }), (err: unknown) => { + assert.ok(err instanceof McplRequestError); + assert.equal(err.outcome, 'no-response'); + assert.match(err.message, /did not answer tools\/call "slow" within 300ms/); + assert.match(err.message, /Cancellation was requested; the outcome is unknown/); + return true; + }); + assert.ok(Date.now() - started < 1500, 'rejected at the deadline, not at the tool\'s end'); + // The server saw the cancellation: the request was cancelled, not merely abandoned. + await until(() => lines(join(dir, 'events.log')).includes('slow-aborted')); +}); + +test('a list change is announced, and the next listing has the new tool', async () => { + const connection = await connect(config(scratch())); + let changes = 0; + connection.on('tools-list-changed', () => changes++); + await connection.callTool('add_tool', {}); + await until(() => changes > 0); + assert.ok((await connection.listTools()).some((t) => t.name === 'added_1')); +}); + +test('a lost child reconnects with backoff, relaunching once, and lists again', async () => { + const dir = scratch(); + const connection = await connect(config(dir, { reconnect: true, reconnectIntervalMs: 50, reconnectMaxIntervalMs: 100 })); + const events: string[] = []; + connection.on('close', () => events.push('close')); + connection.on('reconnect', () => events.push('reconnect')); + await connection.callTool('die', {}); + await until(() => events.includes('reconnect')); + assert.deepEqual(events, ['close', 'reconnect']); + assert.equal(connection.isConnected, true); + assert.ok((await connection.listTools()).some((t) => t.name === 'echo')); + assert.equal(lines(join(dir, 'starts.log')).length, 2); +}); + +test('without reconnect a lost child stays down, and calls are refused before sending', async () => { + const connection = await connect(config(scratch())); + let closed = false; + connection.on('close', () => { closed = true; }); + await connection.callTool('die', {}); + await until(() => closed); + assert.equal(connection.isConnected, false); + assert.equal(connection.willReconnect, false); + await assert.rejects(connection.callTool('echo', { text: 'x' }), (err: unknown) => { + assert.ok(err instanceof McplRequestError); + assert.equal(err.outcome, 'not-sent'); + return true; + }); +}); + +test('a first connect that fails keeps retrying with reconnect, and close stops it', async () => { + const dir = scratch(); + const failures: unknown[] = []; + const connection = await connect({ + id: 'nothing', + command: process.execPath, + args: ['-e', 'process.exit(1)'], + protocol: 'modern', + reconnect: true, + reconnectIntervalMs: 30, + reconnectMaxIntervalMs: 60, + }); + connection.on('connect-failed', (e) => failures.push(e)); + connection.on('reconnect-failed', (e) => failures.push(e)); + assert.equal(connection.isConnected, false); + await until(() => failures.length >= 2); + await connection.close(); + const count = failures.length; + await new Promise((r) => setTimeout(r, 200)); + assert.equal(failures.length, count, 'no attempts after close'); + void dir; +}); + +test('configuration mistakes throw before anything is launched', async () => { + await assert.rejects(ModernMcpConnection.connect({ id: 'x', url: 'https://x.invalid/mcp', protocol: 'modern' }), /applies only to stdio/); + await assert.rejects(ModernMcpConnection.connect({ id: 'x', command: 'srv', protocol: 'modern', requestTimeoutMs: 0 }), /requestTimeoutMs must be an integer/); + await assert.rejects(ModernMcpConnection.connect({ id: 'x', command: 'srv' }), /not configured for modern MCP/); +}); diff --git a/test/mcp-modern-http.test.ts b/test/mcp-modern-http.test.ts new file mode 100644 index 000000000..106bf9b50 --- /dev/null +++ b/test/mcp-modern-http.test.ts @@ -0,0 +1,138 @@ +/** + * The modern engine over Streamable HTTP (room-284, shelf-487), against an + * in-process SDK 2.x `createMcpHandler` that refuses legacy openings. This is + * the one HTTP smoke path. It covers Connectome's own seams, and leaves the + * SDK's conformance to the SDK: + * - credentials: a cached bearer from `accessProvider`, refreshed once on 401; + * - the required headers reach the server; + * - a deadline aborts the request stream, which the server sees as cancellation; + * - an auth wall is an `error-response` carrying the HTTP status. + */ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer as createHttpServer, type Server } from 'node:http'; +import { Readable } from 'node:stream'; +import { join, dirname } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { AddressInfo } from 'node:net'; + +import { createMcpHandler } from '@modelcontextprotocol/server'; +import { ModernMcpConnection } from '../src/mcpl/modern-connection.js'; +import { McplRequestError } from '../src/mcpl/server-connection.js'; + +const here = dirname(fileURLToPath(import.meta.url)); + +interface Seen { method: string; auth: string | null; version: string | null; mcpMethod: string | null; mcpName: string | null } + +async function startServer(accepted: (token: string | null) => boolean): Promise<{ url: string; seen: Seen[]; events: string[]; server: Server }> { + const { createServer } = await import(pathToFileURL(join(here, 'fixtures', 'modern-tools.mjs')).href) as { + createServer: (opts: { log: (line: string) => void }) => unknown; + }; + const seen: Seen[] = []; + const events: string[] = []; + const handler = createMcpHandler(() => createServer({ log: (line) => events.push(line) }) as never, { legacy: 'reject' }); + const server = createHttpServer(async (req, res) => { + const auth = req.headers.authorization ?? null; + seen.push({ + method: req.method ?? '', + auth, + version: (req.headers['mcp-protocol-version'] as string | undefined) ?? null, + mcpMethod: (req.headers['mcp-method'] as string | undefined) ?? null, + mcpName: (req.headers['mcp-name'] as string | undefined) ?? null, + }); + if (!accepted(auth?.replace(/^Bearer /, '') ?? null)) { + res.writeHead(401, { 'content-type': 'text/plain' }).end('unauthorized'); + return; + } + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === 'string') headers.set(key, value); + else if (Array.isArray(value)) headers.set(key, value.join(', ')); + } + // The client closing its request stream is the HTTP binding's + // cancellation: carry it into the handler as an abort. + const abort = new AbortController(); + res.on('close', () => { if (!res.writableEnded) abort.abort(); }); + const response = await handler.fetch(new Request(`http://127.0.0.1${req.url}`, { + method: req.method, + headers, + body: req.method === 'GET' || req.method === 'HEAD' ? undefined : Buffer.concat(chunks), + signal: abort.signal, + })); + res.writeHead(response.status, Object.fromEntries(response.headers)); + if (response.body) Readable.fromWeb(response.body as never).pipe(res); + else res.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + return { url: `http://127.0.0.1:${port}/mcp`, seen, events, server }; +} + +const cleanups: Array<() => Promise | void> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +test('HTTP: bearer from accessProvider, one refresh on 401, required headers, calls', async () => { + const { url, seen, server } = await startServer((token) => token === 'token-2'); + cleanups.push(() => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); })); + let issued = 0; + const connection = await ModernMcpConnection.connect({ + id: 'remote', + url, + accessProvider: async () => `token-${++issued}`, + }); + cleanups.push(() => connection.close()); + + assert.equal(connection.transportKind, 'http'); + assert.equal(connection.protocolVersion, '2026-07-28'); + assert.deepEqual((await connection.callTool('echo', { text: 'over http' })).content, [{ type: 'text', text: 'echo:over http' }]); + const only = await connection.callTool('structured_only', {}); + assert.deepEqual(only.structuredContent, { answer: 42, ok: false, nothing: null }); + + assert.equal(issued, 2, 'token-1 was refused once, token-2 cached thereafter'); + const posts = seen.filter((s) => s.method === 'POST'); + assert.ok(posts.every((s) => s.version === '2026-07-28'), 'every POST names the revision'); + assert.ok(posts.every((s) => s.mcpMethod !== null), 'every POST names its method'); + assert.ok(posts.some((s) => s.mcpMethod === 'tools/call' && s.mcpName === 'echo'), 'tools/call names its tool'); + assert.ok(posts.slice(1).every((s) => s.auth === 'Bearer token-2')); +}); + +test('HTTP: a deadline aborts the request stream, and the server sees the cancellation', async () => { + const { url, events, server } = await startServer(() => true); + cleanups.push(() => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); })); + const connection = await ModernMcpConnection.connect({ id: 'remote', url, token: 't', requestTimeoutMs: 300 }); + cleanups.push(() => connection.close()); + await assert.rejects(connection.callTool('slow', { ms: 1500 }), (err: unknown) => { + assert.ok(err instanceof McplRequestError); + assert.equal(err.outcome, 'no-response'); + assert.match(err.message, /Cancellation was requested/); + return true; + }); + const deadline = Date.now() + 3000; + while (!events.includes('slow-aborted') && Date.now() < deadline) await new Promise((r) => setTimeout(r, 20)); + assert.ok(events.includes('slow-aborted'), `server events: ${events.join(', ')}`); +}); + +test('HTTP: a credential the server keeps refusing fails the connect, naming 401', async () => { + const { url, server } = await startServer(() => false); + cleanups.push(() => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); })); + await assert.rejects(ModernMcpConnection.connect({ id: 'remote', url, token: 'stale' }), /401/); +}); + +test('HTTP: an auth wall on a call is an error-response carrying the status', async () => { + let allow = true; + const { url, server } = await startServer(() => allow); + cleanups.push(() => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); })); + const connection = await ModernMcpConnection.connect({ id: 'remote', url, token: 't' }); + cleanups.push(() => connection.close()); + allow = false; + await assert.rejects(connection.callTool('echo', { text: 'x' }), (err: unknown) => { + assert.ok(err instanceof McplRequestError); + assert.equal(err.outcome, 'error-response'); + assert.equal(err.code, 401); + return true; + }); +}); From 7f7e91b600e2e3b2563b8e3b99b4c7f088588246 Mon Sep 17 00:00:00 2001 From: Olive-1804 Date: Thu, 8 Oct 2026 01:38:00 -0400 Subject: [PATCH 04/94] =?UTF-8?q?fix(mcp):=20review=20corrections=20to=20b?= =?UTF-8?q?atches=201=E2=80=932,=20outcomes=20from=20wire=20evidence=20and?= =?UTF-8?q?=20owned=20lifetimes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Theo-1805's review of 2c9aff1/3a3104c (room-284 #53300, #53316, #53318, #53323) and Petra-1012's read of the outcome mapping (room-203 #53269), reproduced against SDK 2.3.1. Request outcomes from the wire (was: inferred from error classes). ProtocolError doesn't prove a remote error: SDK 2.3.1 raises it locally for an outputSchema that won't compile (before any tools/call) and for output validation failures (after a successful result), and both were reported as error-response. Now an ObservedTransport sits between the SDK and the real transport, and a per-request AsyncLocalStorage scope records two things: whether a request of the observed method was handed off, and how the server answered the most recent one. - Never handed off → not-sent. This covers SDK pre-dispatch rejects, a pre-aborted signal, and no transport. - A JSON-RPC error on the wire → error-response with that error's code and data. - A result → a plain Error, "answered, but the result can't be used". - Handed off and unanswered → no-response. At the deadline, cancellation was requested. - HTTP 401/403 → error-response with code undefined and data.httpStatus, so JSON-RPC and HTTP codes never share `code` (Petra). Auxiliary requests inside a call, such as the SDK's header-mismatch relist, are another method, so they never overwrite the call's evidence. listTools() uses the same contract with tools/list as its method, and one LIST_TIMEOUT_MS budget now bounds the whole paginated walk (an outer signal), not each page. Lifetime: - close() owns a connect in flight. During server/discover the SDK hasn't attached its Protocol yet, so client.close() alone leaves the probe's child alive. close() now closes the opening transport, then awaits the connect's end. - List-change awareness covers a subscription the server advertises but that never opened. The SDK swallows a refused initial listen into onerror and still connects, so keepListening() schedules a reopen when capabilities.tools.listChanged is set and no subscription exists. - Reopen timers are keyed by generation. A stale failure can't take the single slot or schedule across generations; handleLost and each new generation cancel it. Legacy pagination (2c9aff1): cursors are opaque, so '' continues and only an absent nextCursor ends the inventory. A page without a tools array, or with a non-string nextCursor (null included), is an error rather than a partial or empty inventory published as complete. Tests: mcp-modern-outcomes (6; a hand-written server logs every request, and each outcome is checked against that log: 0 calls; 1 call answered; a wire error; listen refused then reopened, also across a reconnect; close() during a hanging reconnect handshake kills that launch, and no launch follows). Legacy: '' cursor continues; malformed pages rejected. HTTP auth-wall test updated for code/httpStatus. Full suite 1413/1413; the touched files repeated 3x stable. --- src/mcpl/modern-connection.ts | 420 ++++++++++++++++-------- src/mcpl/server-connection.ts | 27 +- test/fixtures/legacy-version-server.mjs | 14 +- test/fixtures/modern-raw-server.mjs | 92 ++++++ test/mcp-modern-http.test.ts | 6 +- test/mcp-modern-outcomes.test.ts | 146 ++++++++ test/mcpl-legacy-version.test.ts | 15 +- 7 files changed, 573 insertions(+), 147 deletions(-) create mode 100644 test/fixtures/modern-raw-server.mjs create mode 100644 test/mcp-modern-outcomes.test.ts diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index cce545d09..9aaeae664 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -16,20 +16,27 @@ * - **One deadline per tool call** (`requestTimeoutMs`, always positive) * bounds every leg of the call. At the deadline the SDK requests * cancellation and the outcome is unknown. Calls are never replayed. + * - **Request outcomes from the wire.** A failed call is classified by what + * crossed Connectome's transport boundary, never by the error's class. The + * SDK raises the same error classes locally (an invalid outputSchema before + * dispatch, output validation after a successful result) as it does for + * server answers. * - **Lifetime.** On a lost transport the connection restarts with the same - * backoff settings as legacy, when `reconnect` is set. A lost list-change - * stream is reopened while the connection lives. Each connect is a - * generation, so a superseded client can never touch a newer one. + * backoff settings as legacy, when `reconnect` is set. List-change + * awareness is kept while the connection lives: a subscription the server + * advertises but that didn't open, or that ended, is reopened. Each connect + * is a generation, so a superseded client never touches a newer one. + * `close()` owns a connect in flight too. * * Nothing here is MCPL: a modern server has no grant, no planes and no * server→host requests. The framework uses this connection only through its * tool paths, and the MCPL machinery never sees it. */ +import { AsyncLocalStorage } from 'node:async_hooks'; import { EventEmitter } from 'node:events'; import { Client, - ProtocolError, SdkError, SdkErrorCode, SdkHttpError, @@ -38,7 +45,9 @@ import { type CallToolResult, type JSONRPCMessage, type McpSubscription, + type MessageExtraInfo, type Transport, + type TransportSendOptions, } from '@modelcontextprotocol/client'; import { StdioTransport, type McplTransport } from './transport.js'; @@ -62,6 +71,102 @@ export interface ModernToolCallResult { _meta?: Record; } +/** + * What crossed Connectome's transport boundary for one logical request: were + * any of its requests handed to the transport, and how did the server answer + * the most recent one? A logical call can take several legs: a + * header-mismatch retry, or the rounds of an `input_required` continuation. + */ +interface WireRecord { + readonly method: string; + handedOff: boolean; + currentId?: string | number; + answer: 'none' | 'result' | 'error'; + error?: { code: number; message: string; data?: unknown }; +} + +/** The logical request a send belongs to. The SDK hands each request to the + * transport inside the caller's async chain, so the scope reaches `send`. */ +const wireScope = new AsyncLocalStorage(); + +/** + * The boundary between the SDK and the actual transport. It records, for the + * logical request in scope, each request of the observed method that it + * hands on, and the answer that comes back for it. Everything else passes + * through untouched: the SDK sees the inner transport's own members. + */ +class ObservedTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: T, extra?: MessageExtraInfo) => void; + private readonly awaiting = new Map(); + + constructor(private readonly inner: Transport) {} + + get sessionId(): string | undefined { + return this.inner.sessionId; + } + + get hasPerRequestStream(): boolean | undefined { + return this.inner.hasPerRequestStream; + } + + setProtocolVersion(version: string): void { + this.inner.setProtocolVersion?.(version); + } + + setSupportedProtocolVersions(versions: string[]): void { + this.inner.setSupportedProtocolVersions?.(versions); + } + + async start(): Promise { + this.inner.onmessage = (message, extra) => { + this.observe(message); + this.onmessage?.(message, extra); + }; + this.inner.onerror = (error) => this.onerror?.(error); + this.inner.onclose = () => this.onclose?.(); + await this.inner.start(); + } + + async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + const record = wireScope.getStore(); + if (record && 'method' in message && 'id' in message && message.method === record.method) { + // Handed off from here on: past this point nothing proves it unsent. + record.handedOff = true; + record.currentId = message.id; + record.answer = 'none'; + record.error = undefined; + this.awaiting.set(message.id, record); + } + await this.inner.send(message, options); + } + + async close(): Promise { + await this.inner.close(); + } + + /** Drop a finished request's entries (unanswered legs included). */ + forget(record: WireRecord): void { + for (const [id, entry] of this.awaiting) if (entry === record) this.awaiting.delete(id); + } + + private observe(message: JSONRPCMessage): void { + if (!('id' in message) || (!('result' in message) && !('error' in message))) return; + const record = this.awaiting.get(message.id as string | number); + if (!record) return; + this.awaiting.delete(message.id as string | number); + // A superseded leg's late answer says nothing about the current one. + if (record.currentId !== message.id) return; + if ('error' in message) { + record.answer = 'error'; + record.error = message.error as WireRecord['error']; + } else { + record.answer = 'result'; + } + } +} + /** * SDK `Transport` over Connectome's stdio spawner. Framing is the same * newline-delimited JSON-RPC as legacy. Lines that are not JSON are ignored, @@ -73,7 +178,7 @@ export interface ModernToolCallResult { class SpawnerStdioTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage) => void; + onmessage?: (message: T, extra?: MessageExtraInfo) => void; private line: McplTransport | null = null; constructor( @@ -137,6 +242,13 @@ function bearerAuth(config: McplServerConfig): AuthProvider | undefined { }; } +/** One connect generation's client and the boundary it talks through. */ +interface Session { + readonly generation: number; + readonly client: Client; + readonly wire: ObservedTransport; +} + export class ModernMcpConnection extends EventEmitter { readonly id: string; readonly family = 'modern' as const; @@ -144,8 +256,9 @@ export class ModernMcpConnection extends EventEmitter { /** The pinned revision, once a connect has established it; null before. */ protocolVersion: string | null = null; - private client: Client | null = null; - private connected = false; + private session: Session | null = null; + /** A connect in flight, owned so `close()` can end it. */ + private opening: { session: Session; done: Promise } | null = null; private generation = 0; private closedByHost = false; private readonly reconnectEnabled: boolean; @@ -153,7 +266,8 @@ export class ModernMcpConnection extends EventEmitter { private readonly reconnectMaxIntervalMs: number; private reconnectTimer: ReturnType | null = null; private reconnectAttempts = 0; - private relistenTimer: ReturnType | null = null; + /** At most one pending reopen, and only ever for the current generation. */ + private relisten: { generation: number; timer: ReturnType } | null = null; private relistenAttempts = 0; private constructor(private readonly config: McplServerConfig) { @@ -180,7 +294,7 @@ export class ModernMcpConnection extends EventEmitter { } get isConnected(): boolean { - return this.connected; + return this.session !== null; } /** Whether a lost connection will be re-established in the background. */ @@ -209,14 +323,18 @@ export class ModernMcpConnection extends EventEmitter { return connection; } - /** One connect generation: a fresh client and transport. */ + private isCurrent(generation: number): boolean { + return generation === this.generation && !this.closedByHost; + } + + /** One connect generation: a fresh client over a fresh transport. */ private async open(): Promise { const generation = ++this.generation; - const live = () => generation === this.generation && !this.closedByHost; - - const transport: Transport = this.transportKind === 'stdio' - ? new SpawnerStdioTransport(this.config, (line) => { if (live()) this.emit('stderr', { line }); }) + this.cancelRelisten(); + const inner: Transport = this.transportKind === 'stdio' + ? new SpawnerStdioTransport(this.config, (line) => { if (this.isCurrent(generation)) this.emit('stderr', { line }); }) : new StreamableHTTPClientTransport(new URL(this.config.url!), { authProvider: bearerAuth(this.config) }); + const wire = new ObservedTransport(inner); const client = new Client( { name: 'agent-framework', version: '1.0.0' }, @@ -229,77 +347,98 @@ export class ModernMcpConnection extends EventEmitter { tools: { autoRefresh: false, debounceMs: 0, - onChanged: () => { if (live()) this.emit('tools-list-changed'); }, + onChanged: () => { if (this.isCurrent(generation)) this.emit('tools-list-changed'); }, }, }, }, ); client.onerror = (error: Error) => { // The SDK reports late responses to deadline-abandoned calls here - // ("unknown message ID"), among other out-of-band errors. They are - // logged, never delivered: the call's outcome was already reported. - if (live()) this.emit('error', error); + // ("unknown message ID"), and a list-change subscription it could not + // open at connect, among other out-of-band errors. They are logged, + // never delivered: the call's outcome was already reported. + if (this.isCurrent(generation)) this.emit('error', error); }; client.onclose = () => { - if (live()) this.handleLost('transport closed'); + if (this.isCurrent(generation)) this.handleLost(generation, 'transport closed'); }; + const session: Session = { generation, client, wire }; + const done = client.connect(wire, { timeout: CONNECT_TIMEOUT_MS }); + this.opening = { session, done }; try { - await client.connect(transport, { timeout: CONNECT_TIMEOUT_MS }); + await done; } catch (error) { await client.close().catch(() => {}); + await wire.close().catch(() => {}); throw error; + } finally { + if (this.opening?.session === session) this.opening = null; } - if (!live()) { + if (!this.isCurrent(generation)) { + // Superseded or closed while connecting: this generation never serves. await client.close().catch(() => {}); + await wire.close().catch(() => {}); return; } - this.client = client; - this.connected = true; + this.session = session; this.protocolVersion = MODERN_MCP_PROTOCOL_VERSION; this.reconnectAttempts = 0; - this.watchSubscription(client.autoOpenedSubscription, generation); + this.keepListening(session, client.autoOpenedSubscription); } /** - * Keep list-change awareness while the connection lives. The SDK opens the - * subscription at connect when the server advertises tool list changes. - * When the subscription ends without our asking, it is reopened with - * backoff, and the inventory is refreshed once it's back, since changes - * may have been missed in between. A transport loss is the reconnect - * path's job, not this one's. + * Keep list-change awareness while this generation lives. The SDK opens the + * subscription at connect when the server advertises tool list changes. If + * that open failed (the SDK reports it through `onerror` and connects + * anyway), or the subscription later ends without our asking, it is + * reopened with backoff. Once it's back the inventory is refreshed, since + * changes may have been missed. A lost transport is the reconnect path's + * job, not this one's. */ - private watchSubscription(subscription: McpSubscription | undefined, generation: number): void { - if (!subscription) return; + private keepListening(session: Session, subscription: McpSubscription | undefined): void { + if (!session.client.getServerCapabilities()?.tools?.listChanged) return; + if (!subscription) { + this.scheduleRelisten(session.generation); + return; + } void subscription.closed.then((reason) => { - if (reason === 'local' || generation !== this.generation || this.closedByHost || !this.connected) return; - this.scheduleRelisten(generation); + if (reason === 'local' || this.session !== session || !this.isCurrent(session.generation)) return; + this.scheduleRelisten(session.generation); }); } + private cancelRelisten(): void { + if (this.relisten) clearTimeout(this.relisten.timer); + this.relisten = null; + } + private scheduleRelisten(generation: number): void { - if (this.relistenTimer) return; - const delay = this.backoffDelay(this.relistenAttempts); - this.relistenTimer = setTimeout(() => { - this.relistenTimer = null; - void this.relisten(generation); - }, delay); - this.relistenTimer.unref?.(); - } - - private async relisten(generation: number): Promise { - const client = this.client; - if (!client || generation !== this.generation || this.closedByHost || !this.connected) return; + if (!this.isCurrent(generation)) return; + if (this.relisten?.generation === generation) return; + this.cancelRelisten(); + const timer = setTimeout(() => { + if (this.relisten?.timer === timer) this.relisten = null; + void this.reopenSubscription(generation); + }, this.backoffDelay(Math.max(1, this.relistenAttempts))); + timer.unref?.(); + this.relisten = { generation, timer }; + } + + private async reopenSubscription(generation: number): Promise { + const session = this.session; + if (!session || session.generation !== generation || !this.isCurrent(generation)) return; try { - const subscription = await client.listen({ toolsListChanged: true }, { timeout: CONNECT_TIMEOUT_MS }); - if (generation !== this.generation || this.closedByHost) { + const subscription = await session.client.listen({ toolsListChanged: true }, { timeout: CONNECT_TIMEOUT_MS }); + if (this.session !== session || !this.isCurrent(generation)) { await subscription.close().catch(() => {}); return; } this.relistenAttempts = 0; - this.watchSubscription(subscription, generation); + this.keepListening(session, subscription); this.emit('tools-list-changed'); } catch (error) { + if (this.session !== session || !this.isCurrent(generation)) return; this.relistenAttempts++; this.emit('error', new Error(`MCP server "${this.id}" could not reopen its list-change subscription: ${(error as Error).message}`)); this.scheduleRelisten(generation); @@ -307,10 +446,10 @@ export class ModernMcpConnection extends EventEmitter { } /** The transport went away (child exit, or a closed HTTP client). */ - private handleLost(reason: string): void { - if (!this.connected) return; - this.connected = false; - this.client = null; + private handleLost(generation: number, reason: string): void { + if (this.session?.generation !== generation) return; + this.session = null; + this.cancelRelisten(); this.emit('close', { reason }); if (this.willReconnect) this.scheduleReconnect(); } @@ -332,34 +471,41 @@ export class ModernMcpConnection extends EventEmitter { } private async attemptReconnect(): Promise { - if (!this.willReconnect || this.connected) return; + if (!this.willReconnect || this.session) return; const attempt = Math.max(1, this.reconnectAttempts); try { await this.open(); - if (!this.connected) return; + if (!this.session) return; console.error(`MCP server "${this.id}" reconnected`); this.emit('reconnect', { attempts: attempt }); } catch (error) { + if (this.closedByHost) return; this.reconnectAttempts = attempt + 1; this.emit('reconnect-failed', { error: (error as Error).message, attempt }); this.scheduleReconnect(); } } - /** The server's complete tool inventory (the SDK follows every page). */ + /** + * The server's complete tool inventory. The SDK follows every page; one + * budget, {@link LIST_TIMEOUT_MS}, bounds the whole walk, not each page. + * Its failures use the request-outcome contract with `tools/list` as the + * observed method. A relist the SDK makes inside a tool call is auxiliary + * to that call, and leaves the call's evidence alone. + */ async listTools(): Promise { - const client = this.requireClient('tools/list'); - try { - const { tools } = await client.listTools(undefined, { cacheMode: 'refresh', timeout: LIST_TIMEOUT_MS }); - return tools.map((tool) => ({ - name: tool.name, - ...(tool.description !== undefined ? { description: tool.description } : {}), - inputSchema: tool.inputSchema as Record, - ...(tool._meta !== undefined ? { _meta: tool._meta as Record } : {}), - })); - } catch (error) { - throw this.requestFailure('tools/list', error); - } + const { tools } = await this.request('tools/list', 'tools/list', (client) => + client.listTools(undefined, { + cacheMode: 'refresh', + timeout: LIST_TIMEOUT_MS, + signal: AbortSignal.timeout(LIST_TIMEOUT_MS), + }), LIST_TIMEOUT_MS); + return tools.map((tool) => ({ + name: tool.name, + ...(tool.description !== undefined ? { description: tool.description } : {}), + inputSchema: tool.inputSchema as Record, + ...(tool._meta !== undefined ? { _meta: tool._meta as Record } : {}), + })); } /** @@ -369,87 +515,103 @@ export class ModernMcpConnection extends EventEmitter { * before dispatch (an auth refresh, or a header-mismatch relist). */ async callTool(name: string, args: Record): Promise { - const client = this.requireClient('tools/call'); const timeoutMs = this.requestTimeoutMs; - try { - const result = await client.callTool( + const result = await this.request('tools/call', `tools/call "${name}"`, (client) => + client.callTool( { name, arguments: args }, { timeout: timeoutMs, maxTotalTimeout: timeoutMs, signal: AbortSignal.timeout(timeoutMs) }, - ); - return { - content: result.content ?? [], - ...(result.isError !== undefined ? { isError: result.isError } : {}), - ...('structuredContent' in result ? { structuredContent: result.structuredContent } : {}), - ...(result._meta !== undefined ? { _meta: result._meta as Record } : {}), - }; + ), timeoutMs); + return { + content: result.content ?? [], + ...(result.isError !== undefined ? { isError: result.isError } : {}), + ...('structuredContent' in result ? { structuredContent: result.structuredContent } : {}), + ...(result._meta !== undefined ? { _meta: result._meta as Record } : {}), + }; + } + + /** Run one logical request in a wire scope, and classify its failure by + * what that scope saw. */ + private async request(method: string, what: string, run: (client: Client) => Promise, timeoutMs?: number): Promise { + const session = this.session; + if (!session) { + // Connectome refuses before anything reaches the SDK: provably not sent. + throw new McplRequestError(`Cannot send ${what}: connection to "${this.id}" is not established`, 'not-sent'); + } + const record: WireRecord = { method, handedOff: false, answer: 'none' }; + try { + return await wireScope.run(record, () => run(session.client)); } catch (error) { - throw this.requestFailure(`tools/call "${name}"`, error, timeoutMs); + throw this.requestFailure(what, record, error, timeoutMs); + } finally { + session.wire.forget(record); } } - /** Close for good: no reconnect, no reopened subscription. */ + /** Close for good: no reconnect, no reopened subscription, no connect left + * in flight. Awaits the in-flight connect's end. */ async close(): Promise { this.closedByHost = true; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); - if (this.relistenTimer) clearTimeout(this.relistenTimer); this.reconnectTimer = null; - this.relistenTimer = null; - const client = this.client; - const wasConnected = this.connected; - this.client = null; - this.connected = false; - if (client) await client.close().catch(() => {}); - if (wasConnected) this.emit('close', { reason: 'closed by host' }); - } - - /** Connectome refuses before anything reaches the SDK: provably not sent. */ - private requireClient(method: string): Client { - if (!this.client || !this.connected) { - throw new McplRequestError(`Cannot send ${method}: connection to "${this.id}" is not established`, 'not-sent'); + this.cancelRelisten(); + const opening = this.opening; + if (opening) { + // Closing the transport ends the probe; the connect then rejects and + // its own cleanup runs. Wait for that, so nothing outlives close(). + await opening.session.wire.close().catch(() => {}); + await opening.session.client.close().catch(() => {}); + await opening.done.catch(() => {}); + } + const session = this.session; + this.session = null; + if (session) { + await session.client.close().catch(() => {}); + await session.wire.close().catch(() => {}); + this.emit('close', { reason: 'closed by host' }); } - return this.client; } /** - * Map an SDK failure onto the request-outcome contract, claiming no more - * than the failure establishes: - * - `error-response`: the server answered with an error. That is a - * JSON-RPC error, or an HTTP auth refusal, which carries the status as - * its code. - * - `no-response`: everything after hand-off that proves nothing about - * delivery, including the deadline, where cancellation was requested and - * the outcome is unknown. - * A call the server answered with a result Connectome can't use (it fails - * validation, or needs input this client can't supply) is a plain error: - * the server did act, so no request outcome applies. + * Map a failure onto the request-outcome contract by what crossed the + * transport boundary for this request, claiming no more than that shows: + * - nothing handed to the transport: `not-sent`, whatever the SDK raised + * (a pre-dispatch validation, a signal already aborted, no transport); + * - an HTTP 401/403 refusal: `error-response`. The server answered, at the + * HTTP layer, so the status is in `data.httpStatus` and `code` stays the + * JSON-RPC code space's (absent); + * - the most recent leg answered with a JSON-RPC error: `error-response` + * with that error's code and data; + * - the most recent leg answered with a result: the server acted, but what + * it returned can't be used (output validation, input this client can't + * supply, a rounds cap). That's a plain error, not a request outcome; + * - handed off and unanswered: `no-response`. At the deadline, cancellation + * was requested and the outcome is unknown. */ - private requestFailure(what: string, error: unknown, timeoutMs?: number): Error { + private requestFailure(what: string, record: WireRecord, error: unknown, timeoutMs?: number): Error { const prefix = `MCP server "${this.id}"`; - if (error instanceof ProtocolError) { - return new McplRequestError(`${prefix} returned error for ${what}: [${error.code}] ${error.message}`, 'error-response', error.code, error.data); - } + const message = error instanceof Error ? error.message : String(error); if (error instanceof SdkHttpError && (error.code === SdkErrorCode.ClientHttpAuthentication || error.code === SdkErrorCode.ClientHttpForbidden)) { - return new McplRequestError(`${prefix} refused ${what}: HTTP ${error.status} (${error.message})`, 'error-response', error.status, error.data); + const data = error.data && typeof error.data === 'object' ? error.data : {}; + return new McplRequestError(`${prefix} refused ${what}: HTTP ${error.status} (${message})`, 'error-response', undefined, { ...data, httpStatus: error.status }); } - if (error instanceof SdkError) { - switch (error.code) { - case SdkErrorCode.RequestTimeout: - return new McplRequestError( - `${prefix} did not answer ${what}${timeoutMs ? ` within ${timeoutMs}ms` : ''}. Cancellation was requested; ` + - `the outcome is unknown: the tool may still complete server-side. Verify state before retrying; ` + - `a blind retry of a side-effecting tool may duplicate it.`, - 'no-response', - ); - case SdkErrorCode.InvalidResult: - case SdkErrorCode.UnsupportedResultType: - case SdkErrorCode.InputRequiredRoundsExceeded: - case SdkErrorCode.CapabilityNotSupported: - return new Error(`${prefix} answered ${what}, but the result can't be used: ${error.message}`); - default: - return new McplRequestError(`${prefix} ${what} failed: ${error.message}`, 'no-response'); - } + if (!record.handedOff) { + return new McplRequestError(`${prefix} ${what} was not sent: ${message}`, 'not-sent'); + } + if (record.answer === 'error' && record.error) { + const { code, message: wireMessage, data } = record.error; + return new McplRequestError(`${prefix} returned error for ${what}: [${code}] ${wireMessage}`, 'error-response', code, data); + } + if (record.answer === 'result') { + return new Error(`${prefix} answered ${what}, but the result can't be used: ${message}`); + } + if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { + return new McplRequestError( + `${prefix} did not answer ${what}${timeoutMs ? ` within ${timeoutMs}ms` : ''}. Cancellation was requested; ` + + `the outcome is unknown: the tool may still complete server-side. Verify state before retrying; ` + + `a blind retry of a side-effecting tool may duplicate it.`, + 'no-response', + ); } - const message = error instanceof Error ? error.message : String(error); return new McplRequestError(`${prefix} ${what} failed: ${message}`, 'no-response'); } } diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 0a13dcf0a..66a2949c0 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -917,10 +917,12 @@ export class McplServerConnection extends EventEmitter { /** * Send `tools/list` and return the server's complete tool inventory, - * following `nextCursor` across pages (2024-11-05 pagination). A server - * whose cursors don't terminate — a repeated cursor, or more than - * {@link MAX_TOOL_LIST_PAGES} pages — gets an error rather than a silently - * partial inventory. + * following `nextCursor` across pages (2024-11-05 pagination). Cursors are + * opaque strings, `''` included: only an absent `nextCursor` ends the + * inventory. A page that isn't well formed (no `tools` array, a cursor + * that isn't a string), or cursors that don't terminate (a repeat, or more + * than {@link MAX_TOOL_LIST_PAGES} pages), are errors, never a partial or + * empty inventory published as complete. */ async sendToolsList(): Promise<{ tools: McpToolDefinition[] }> { const tools: McpToolDefinition[] = []; @@ -928,18 +930,23 @@ export class McplServerConnection extends EventEmitter { let cursor: string | undefined; let first: Record | undefined; for (let page = 0; page < MAX_TOOL_LIST_PAGES; page++) { - const result = await this.sendRequest('tools/list', cursor === undefined ? {} : { cursor }) as { - tools?: McpToolDefinition[]; - nextCursor?: unknown; - }; + const result = await this.sendRequest('tools/list', cursor === undefined ? {} : { cursor }) as Record | null; + const malformed = (why: string) => + new Error(`MCPL server "${this.id}" sent a malformed tools/list page ${page + 1}: ${why}`); + if (!result || typeof result !== 'object' || !Array.isArray(result.tools)) { + throw malformed('no tools array'); + } first ??= result; - tools.push(...(result.tools ?? [])); - if (typeof result.nextCursor !== 'string' || result.nextCursor === '') { + tools.push(...(result.tools as McpToolDefinition[])); + if (result.nextCursor === undefined) { // The first page's other fields are kept as they came, so a // single-page answer is returned exactly as before. const { nextCursor: _done, ...rest } = first as { nextCursor?: unknown }; return { ...rest, tools }; } + if (typeof result.nextCursor !== 'string') { + throw malformed(`nextCursor must be a string, got ${JSON.stringify(result.nextCursor)}`); + } if (seen.has(result.nextCursor)) { throw new Error(`MCPL server "${this.id}" repeated tools/list cursor ${JSON.stringify(result.nextCursor)}`); } diff --git a/test/fixtures/legacy-version-server.mjs b/test/fixtures/legacy-version-server.mjs index 33979b28e..9a84796f1 100644 --- a/test/fixtures/legacy-version-server.mjs +++ b/test/fixtures/legacy-version-server.mjs @@ -9,9 +9,10 @@ // missing no protocolVersion at all // ok-then-reject `ok` on the first start recorded in startLog, `reject` after // -// tools/list pages: `ok` serves three pages (cursors p2, p3). Tool `cursor-loop` -// mode is selected with MODE_LIST=loop (always nextCursor "same") or -// MODE_LIST=empty (nextCursor ""). Tool `die` exits the process. +// tools/list pages: by default three pages (cursors p2, p3). MODE_LIST picks +// an edge case: loop (always nextCursor "same"), empty (nextCursor "" then a +// second page), notools (no tools array), badcursor (nextCursor 5), +// nullcursor (nextCursor null). Tool `die` exits the process. import { appendFileSync, existsSync, readFileSync } from 'node:fs'; let mode = process.argv[2] ?? 'ok'; @@ -49,7 +50,12 @@ process.stdin.on('data', (chunk) => { } else if (m.method === 'tools/list') { const cursor = m.params?.cursor; if (listMode === 'loop') reply(m.id, { tools: [tool(`loop-${cursor ?? 'first'}`)], nextCursor: 'same' }); - else if (listMode === 'empty') reply(m.id, { tools: [tool('only')], nextCursor: '' }); + // '' is an opaque cursor like any other: it continues to a second page. + else if (listMode === 'empty' && cursor === undefined) reply(m.id, { tools: [tool('first')], nextCursor: '' }); + else if (listMode === 'empty' && cursor === '') reply(m.id, { tools: [tool('second')] }); + else if (listMode === 'notools') reply(m.id, {}); + else if (listMode === 'badcursor') reply(m.id, { tools: [tool('x')], nextCursor: 5 }); + else if (listMode === 'nullcursor') reply(m.id, { tools: [tool('x')], nextCursor: null }); else if (cursor === undefined) reply(m.id, { tools: [tool('a1'), tool('a2')], nextCursor: 'p2' }); else if (cursor === 'p2') reply(m.id, { tools: [tool('b1')], nextCursor: 'p3' }); else if (cursor === 'p3') reply(m.id, { tools: [tool('c1'), tool('die')] }); diff --git a/test/fixtures/modern-raw-server.mjs b/test/fixtures/modern-raw-server.mjs new file mode 100644 index 000000000..84a0e054a --- /dev/null +++ b/test/fixtures/modern-raw-server.mjs @@ -0,0 +1,92 @@ +// A hand-written modern-MCP (2026-07-28) stdio server, for the edge cases an +// SDK server won't produce on request. It logs every launch and request to +// a JSONL file, so tests can check what reached the wire against what the +// client reports. +// Usage: node modern-raw-server.mjs +// Flags, comma-separated: +// invalid-schema tools/list advertises an outputSchema that doesn't compile +// listen-fail-first the first subscriptions/listen of each launch is refused +// hang-discover-later launches after the first never answer server/discover +// Tools: op (outputSchema {value: integer}, answers with a string: invalid +// structured content), plain (text), err (a JSON-RPC error), touch (announces +// a tool list change on the open subscription), die (exits). +import { createInterface } from 'node:readline'; +import { appendFileSync, existsSync, readFileSync } from 'node:fs'; + +const [flagList = '', log] = process.argv.slice(2); +const flags = new Set(flagList.split(',').filter(Boolean)); +const prior = existsSync(log) ? readFileSync(log, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []; +const launch = prior.filter((x) => x.event === 'start').length + 1; +const note = (x) => appendFileSync(log, `${JSON.stringify({ ...x, launch })}\n`); +note({ event: 'start', pid: process.pid }); + +const send = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`); +const result = (id, body) => send({ jsonrpc: '2.0', id, result: { resultType: 'complete', ...body } }); +const error = (id, code, message, data) => send({ jsonrpc: '2.0', id, error: { code, message, ...(data === undefined ? {} : { data }) } }); +const SUB = 'io.modelcontextprotocol/subscriptionId'; + +let listens = 0; +let subscription = null; + +createInterface({ input: process.stdin }).on('line', (line) => { + let m; + try { m = JSON.parse(line); } catch { return; } + note({ event: m.id === undefined ? 'notification' : 'request', method: m.method, id: m.id ?? null, ...(m.method === 'tools/call' ? { tool: m.params?.name } : {}) }); + switch (m.method) { + case 'server/discover': + if (flags.has('hang-discover-later') && launch > 1) return; + return result(m.id, { + supportedVersions: ['2026-07-28'], + capabilities: { tools: { listChanged: true } }, + ttlMs: 60000, + cacheScope: 'private', + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'raw-fixture', version: '1' } }, + }); + case 'subscriptions/listen': + listens++; + if (flags.has('listen-fail-first') && listens === 1) return error(m.id, -32603, 'first listen unavailable'); + subscription = m.id; + return send({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { notifications: { toolsListChanged: true }, _meta: { [SUB]: m.id } }, + }); + case 'tools/list': + // 2026-07-28 list results carry cache hints. + return result(m.id, { + ttlMs: 60000, + cacheScope: 'private', + tools: [ + { + name: 'op', + inputSchema: { type: 'object' }, + outputSchema: { + type: 'object', + properties: { value: { type: flags.has('invalid-schema') ? 'not-a-json-schema-type' : 'integer' } }, + required: ['value'], + }, + }, + { name: 'plain', inputSchema: { type: 'object' } }, + { name: 'err', inputSchema: { type: 'object' } }, + { name: 'touch', inputSchema: { type: 'object' } }, + { name: 'die', inputSchema: { type: 'object' } }, + ], + }); + case 'tools/call': { + const name = m.params?.name; + if (name === 'op') return result(m.id, { content: [], structuredContent: { value: 'not an integer' } }); + if (name === 'err') return error(m.id, -32603, 'boom', { why: 'on purpose' }); + if (name === 'touch') { + if (subscription !== null) { + send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed', params: { _meta: { [SUB]: subscription } } }); + } + return result(m.id, { content: [{ type: 'text', text: subscription === null ? 'no subscription' : 'touched' }] }); + } + if (name === 'die') { setTimeout(() => process.exit(5), 10); return result(m.id, { content: [{ type: 'text', text: 'bye' }] }); } + return result(m.id, { content: [{ type: 'text', text: 'plain' }] }); + } + default: + if (m.id !== undefined) error(m.id, -32601, `Method not found: ${m.method}`); + } +}); +process.stdin.on('end', () => process.exit(0)); diff --git a/test/mcp-modern-http.test.ts b/test/mcp-modern-http.test.ts index 106bf9b50..235bccb72 100644 --- a/test/mcp-modern-http.test.ts +++ b/test/mcp-modern-http.test.ts @@ -122,7 +122,7 @@ test('HTTP: a credential the server keeps refusing fails the connect, naming 401 await assert.rejects(ModernMcpConnection.connect({ id: 'remote', url, token: 'stale' }), /401/); }); -test('HTTP: an auth wall on a call is an error-response carrying the status', async () => { +test('HTTP: an auth wall on a call is an error-response with the status in data', async () => { let allow = true; const { url, server } = await startServer(() => allow); cleanups.push(() => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); })); @@ -132,7 +132,9 @@ test('HTTP: an auth wall on a call is an error-response carrying the status', as await assert.rejects(connection.callTool('echo', { text: 'x' }), (err: unknown) => { assert.ok(err instanceof McplRequestError); assert.equal(err.outcome, 'error-response'); - assert.equal(err.code, 401); + // The status is HTTP's, not a JSON-RPC code: it rides in data. + assert.equal(err.code, undefined); + assert.equal((err.data as { httpStatus?: number }).httpStatus, 401); return true; }); }); diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts new file mode 100644 index 000000000..5c6b2dca7 --- /dev/null +++ b/test/mcp-modern-outcomes.test.ts @@ -0,0 +1,146 @@ +/** + * The modern engine's request outcomes and lifetime against what actually + * crossed the wire (room-284 review, Theo #53300 / Petra #53269). A + * hand-written server logs every request, and each case checks the reported + * outcome against that log: + * - an SDK-local rejection before dispatch is `not-sent` (0 calls on the wire); + * - a result that fails validation is an answer that can't be used (1 call, + * answered), never a claimed server error; + * - a JSON-RPC error on the wire is `error-response` with its code and data; + * - a list-change subscription refused at connect is reopened; + * - `close()` during a reconnect handshake ends the launch it was waiting on. + */ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { ModernMcpConnection } from '../src/mcpl/modern-connection.js'; +import { McplRequestError } from '../src/mcpl/server-connection.js'; +import type { McplServerConfig } from '../src/mcpl/types.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const SERVER = join(here, 'fixtures', 'modern-raw-server.mjs'); + +interface LogEntry { event: string; method?: string; id?: unknown; tool?: string; pid?: number; launch: number } + +const cleanups: Array<() => Promise | void> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +function scratchLog(): string { + const dir = mkdtempSync(join(tmpdir(), 'modern-raw-')); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + return join(dir, 'wire.jsonl'); +} + +const wire = (log: string): LogEntry[] => + existsSync(log) ? readFileSync(log, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l) as LogEntry) : []; +const calls = (log: string, tool: string) => wire(log).filter((e) => e.method === 'tools/call' && e.tool === tool).length; + +async function until(cond: () => boolean, what: string, ms = 5000): Promise { + const deadline = Date.now() + ms; + while (!cond() && Date.now() < deadline) await new Promise((r) => setTimeout(r, 20)); + assert.ok(cond(), `in time: ${what}`); +} + +async function connect(flags: string, log: string, extra: Partial = {}): Promise { + const connection = await ModernMcpConnection.connect({ + id: 'raw', + command: process.execPath, + args: [SERVER, flags, log], + protocol: 'modern', + ...extra, + }); + cleanups.push(() => connection.close()); + return connection; +} + +test('an outputSchema the SDK cannot compile rejects before dispatch: not-sent, nothing on the wire', async () => { + const log = scratchLog(); + const connection = await connect('invalid-schema', log); + await connection.listTools(); + await assert.rejects(connection.callTool('op', {}), (err: unknown) => { + assert.ok(err instanceof McplRequestError, String(err)); + assert.equal(err.outcome, 'not-sent'); + assert.match(err.message, /tools\/call "op" was not sent: .*invalid outputSchema/); + return true; + }); + assert.equal(calls(log, 'op'), 0); +}); + +test('a result that fails its outputSchema was answered: a plain error, not a server error', async () => { + const log = scratchLog(); + const connection = await connect('', log); + await connection.listTools(); + await assert.rejects(connection.callTool('op', {}), (err: unknown) => { + assert.ok(!(err instanceof McplRequestError), 'no request outcome is claimed'); + assert.match((err as Error).message, /answered tools\/call "op", but the result can't be used: .*output schema/); + return true; + }); + assert.equal(calls(log, 'op'), 1); +}); + +test('a JSON-RPC error on the wire is error-response with its code and data', async () => { + const log = scratchLog(); + const connection = await connect('', log); + await connection.listTools(); + await assert.rejects(connection.callTool('err', {}), (err: unknown) => { + assert.ok(err instanceof McplRequestError); + assert.equal(err.outcome, 'error-response'); + assert.equal(err.code, -32603); + assert.deepEqual(err.data, { why: 'on purpose' }); + assert.match(err.message, /returned error for tools\/call "err": \[-32603\] boom/); + return true; + }); + // The plain call on the same connection is unaffected. + assert.deepEqual((await connection.callTool('plain', {})).content, [{ type: 'text', text: 'plain' }]); +}); + +test('a subscription refused at connect is reopened, and change awareness returns', async () => { + const log = scratchLog(); + const connection = await connect('listen-fail-first', log, { reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }); + let changes = 0; + connection.on('tools-list-changed', () => changes++); + await until(() => wire(log).filter((e) => e.method === 'subscriptions/listen').length >= 2, 'a second listen'); + // Reopening refreshes once, since changes may have been missed meanwhile. + await until(() => changes >= 1, 'the refresh after reopening'); + const before = changes; + const touched = await connection.callTool('touch', {}); + assert.deepEqual(touched.content, [{ type: 'text', text: 'touched' }]); + await until(() => changes > before, 'the announced change'); +}); + +test('awareness survives a reconnect whose first listen is refused again', async () => { + const log = scratchLog(); + const connection = await connect('listen-fail-first', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }); + let reconnected = false; + connection.on('reconnect', () => { reconnected = true; }); + await connection.callTool('die', {}); + await until(() => reconnected, 'reconnect'); + // Launch 2 refuses its first listen too; the newer generation reopens it. + await until(() => wire(log).filter((e) => e.method === 'subscriptions/listen' && e.launch === 2).length >= 2, 'a second listen on launch 2'); + let changes = 0; + connection.on('tools-list-changed', () => changes++); + await new Promise((r) => setTimeout(r, 50)); + const before = changes; + await connection.callTool('touch', {}); + await until(() => changes > before, 'the announced change on launch 2'); +}); + +test('close() during a reconnect handshake ends the launch it was waiting on, and stops', async () => { + const log = scratchLog(); + const connection = await connect('hang-discover-later', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }); + await connection.callTool('die', {}); + await until(() => wire(log).some((e) => e.launch === 2 && e.method === 'server/discover'), 'launch 2 waiting on discover'); + const second = wire(log).find((e) => e.launch === 2 && e.event === 'start')!; + await connection.close(); + const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } }; + await until(() => !alive(second.pid!), 'the hanging launch has exited', 3000); + await new Promise((r) => setTimeout(r, 200)); + assert.equal(wire(log).filter((e) => e.event === 'start').length, 2, 'no launch after close'); + assert.equal(connection.isConnected, false); +}); diff --git a/test/mcpl-legacy-version.test.ts b/test/mcpl-legacy-version.test.ts index 1d6a171e0..d672fea31 100644 --- a/test/mcpl-legacy-version.test.ts +++ b/test/mcpl-legacy-version.test.ts @@ -132,10 +132,21 @@ test('tools/list follows nextCursor to the complete inventory', async () => { assert.deepEqual(tools.map((t) => t.name), ['a1', 'a2', 'b1', 'c1', 'die']); }); -test('an empty nextCursor ends the inventory', async () => { +test('an empty-string cursor is a cursor: only an absent nextCursor ends the inventory', async () => { const connection = await connect(config('ok', { env: { MODE_LIST: 'empty' } })); const { tools } = await connection.sendToolsList(); - assert.deepEqual(tools.map((t) => t.name), ['only']); + assert.deepEqual(tools.map((t) => t.name), ['first', 'second']); +}); + +test('a malformed page is an error, never a partial or empty inventory', async () => { + for (const [mode, pattern] of [ + ['notools', /malformed tools\/list page 1: no tools array/], + ['badcursor', /malformed tools\/list page 1: nextCursor must be a string, got 5/], + ['nullcursor', /malformed tools\/list page 1: nextCursor must be a string, got null/], + ] as const) { + const connection = await connect(config('ok', { env: { MODE_LIST: mode } })); + await assert.rejects(connection.sendToolsList(), pattern, mode); + } }); test('a repeating cursor is an error, not a silently partial inventory', async () => { From 9a2710808954930c0252fbca9bc1a7c9b8a93d7c Mon Sep 17 00:00:00 2001 From: Olive-1804 Date: Thu, 8 Oct 2026 02:21:14 -0400 Subject: [PATCH 05/94] fix(mcp): second review round, continuation scope, reaping, malformed frames, startup stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Theo-1805's recheck of 7f7e91b (room-284 #53325, #53542, #53568, #53570). - input_required continuations keep their evidence. The SDK drives the next leg from the transport's inbound callback, outside the caller's async chain, so leg 2's send had no wire scope, and an error or a silence on leg 2 was reported as leg 1's "answered, but can't be used". Now the observer hands each matched answer to the SDK inside its record's scope (wireScope.run), so every leg's send is recorded against its own call. A superseded leg's late answer re-enters nothing. - stdio close reaps. StdioTransport.close() resolved as soon as kill() set child.killed, before the child exited, so a child handling SIGTERM outlived close(). Now it waits for the real exit. After 2 s it sends SIGKILL and waits up to 2 s more. A child that never spawned returns at once. This is a shared primitive, so the legacy engine reaps too. - Writes that race a child's exit no longer crash the host. An EPIPE on child.stdin had no listener: it is now reported as a transport 'error' when anyone listens, and otherwise ignored, since 'close' already reports the exit. A full-suite run caught this as an uncaught EPIPE in a reconnect test. - A JSON line that isn't a JSON-RPC message is a diagnostic, never a crash. `null`, an array or a bare value went to observe() as a message and threw from the stream callback. Lines are now validated with the SDK's own parseJSONRPCMessage. A malformed one goes to onerror (bounded excerpt) and is dropped, and observe() guards non-objects as well. - Startup stderr is kept. Lines emitted while connect() was still pending had no listener and were dropped. They are now held, the newest 200, and delivered when the first 'stderr' listener attaches. Tests, raw fixture flags/tools added (ignore-sigterm, nullframe, cont): - an error or a hang on the second leg is error-response with leg 2's code/data, or no-response; - three concurrent continuations (two error, one ok) never cross evidence; - four non-JSON-RPC lines → four diagnostics, and the connection stays usable; - close() returns only after a SIGTERM-ignoring child is dead; - the startup stderr banner reaches a listener attached after connect(). Outcome and connection files: 19/19. --- src/mcpl/modern-connection.ts | 64 +++++++++++++++++++++++++---- src/mcpl/transport.ts | 37 +++++++++++++---- test/fixtures/modern-raw-server.mjs | 22 +++++++++- test/mcp-modern-connection.test.ts | 4 +- test/mcp-modern-outcomes.test.ts | 63 ++++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 20 deletions(-) diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 9aaeae664..4df032dc0 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -41,6 +41,7 @@ import { SdkErrorCode, SdkHttpError, StreamableHTTPClientTransport, + parseJSONRPCMessage, type AuthProvider, type CallToolResult, type JSONRPCMessage, @@ -61,6 +62,8 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; const CONNECT_TIMEOUT_MS = 30_000; /** Inventory listing has its own bound: it is host work, not a resident's call. */ const LIST_TIMEOUT_MS = 30_000; +/** Startup stderr kept for the first listener. */ +const STDERR_BACKLOG_LINES = 200; /** A modern tool result as the framework consumes it. `structuredContent` is * kept by presence: `false`, `0` and `null` are values, not absence. */ @@ -121,8 +124,13 @@ class ObservedTransport implements Transport { async start(): Promise { this.inner.onmessage = (message, extra) => { - this.observe(message); - this.onmessage?.(message, extra); + // An answer to a request in scope is handed to the SDK inside that + // request's scope. The SDK drives an input_required continuation from + // this very callback, outside the caller's async chain, and the next + // leg's send must still be recorded against the right call. + const record = this.observe(message); + if (record) wireScope.run(record, () => this.onmessage?.(message, extra)); + else this.onmessage?.(message, extra); }; this.inner.onerror = (error) => this.onerror?.(error); this.inner.onclose = () => this.onclose?.(); @@ -151,19 +159,23 @@ class ObservedTransport implements Transport { for (const [id, entry] of this.awaiting) if (entry === record) this.awaiting.delete(id); } - private observe(message: JSONRPCMessage): void { - if (!('id' in message) || (!('result' in message) && !('error' in message))) return; + /** Record an answer to a request in scope; returns its record when it + * answers that record's current leg. */ + private observe(message: JSONRPCMessage): WireRecord | undefined { + if (typeof message !== 'object' || message === null) return undefined; + if (!('id' in message) || (!('result' in message) && !('error' in message))) return undefined; const record = this.awaiting.get(message.id as string | number); - if (!record) return; + if (!record) return undefined; this.awaiting.delete(message.id as string | number); // A superseded leg's late answer says nothing about the current one. - if (record.currentId !== message.id) return; + if (record.currentId !== message.id) return undefined; if ('error' in message) { record.answer = 'error'; record.error = message.error as WireRecord['error']; } else { record.answer = 'result'; } + return record; } } @@ -190,12 +202,26 @@ class SpawnerStdioTransport implements Transport { const line = StdioTransport.spawn(this.config); this.line = line; line.on('line', (text: string) => { - let message: JSONRPCMessage; + // A line that isn't JSON is ignored, as the legacy engine ignores it. + // A line that is JSON but not a JSON-RPC message (`null`, an array, a + // bare value) is a diagnostic. It must never reach a handler that + // assumes the shape: thrown from this callback, it would end the host. + let parsed: unknown; try { - message = JSON.parse(text) as JSONRPCMessage; + parsed = JSON.parse(text); } catch { return; } + let message: JSONRPCMessage; + try { + message = parseJSONRPCMessage(parsed); + } catch (error) { + this.onerror?.(new Error( + `MCP server "${this.config.id}" sent a malformed JSON-RPC message (${text.length > 200 ? `${text.slice(0, 200)}…` : text}): ` + + (error instanceof Error ? error.message.split('\n')[0] : String(error)), + )); + return; + } this.onmessage?.(message); }); line.on('stderr', (text: string) => this.onStderr(text)); @@ -269,6 +295,10 @@ export class ModernMcpConnection extends EventEmitter { /** At most one pending reopen, and only ever for the current generation. */ private relisten: { generation: number; timer: ReturnType } | null = null; private relistenAttempts = 0; + /** Child stderr from before anyone listens (the caller attaches only once + * connect() returns), delivered to the first listener. Bounded: the + * newest lines are kept. */ + private stderrBacklog: string[] = []; private constructor(private readonly config: McplServerConfig) { super(); @@ -286,6 +316,22 @@ export class ModernMcpConnection extends EventEmitter { this.on('error', (error: Error) => { if (this.listenerCount('error') === 1) console.error(`[mcp] ${this.id}: ${error.message}`); }); + this.on('newListener', (event: string | symbol) => { + if (event !== 'stderr' || this.stderrBacklog.length === 0) return; + const pending = this.stderrBacklog; + this.stderrBacklog = []; + // After the listener is added (newListener fires just before). + queueMicrotask(() => { for (const line of pending) this.emit('stderr', { line }); }); + }); + } + + private deliverStderr(line: string): void { + if (this.listenerCount('stderr') > 0) { + this.emit('stderr', { line }); + return; + } + this.stderrBacklog.push(line); + if (this.stderrBacklog.length > STDERR_BACKLOG_LINES) this.stderrBacklog.shift(); } /** The per-call deadline: `requestTimeoutMs`, validated positive. */ @@ -332,7 +378,7 @@ export class ModernMcpConnection extends EventEmitter { const generation = ++this.generation; this.cancelRelisten(); const inner: Transport = this.transportKind === 'stdio' - ? new SpawnerStdioTransport(this.config, (line) => { if (this.isCurrent(generation)) this.emit('stderr', { line }); }) + ? new SpawnerStdioTransport(this.config, (line) => { if (this.isCurrent(generation)) this.deliverStderr(line); }) : new StreamableHTTPClientTransport(new URL(this.config.url!), { authProvider: bearerAuth(this.config) }); const wire = new ObservedTransport(inner); diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index 044249cc4..d344e8949 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -188,6 +188,12 @@ export class StdioTransport extends McplTransport { }); this.child.on('error', (err) => this.emit('error', err)); + // A write racing the child's exit fails with EPIPE on stdin. That is the + // exit's echo, which 'close' already reports, never a reason to crash: an + // unhandled stream 'error' would end the host. + this.child.stdin?.on('error', (err) => { + if (this.listenerCount('error') > 0) this.emit('error', err); + }); this.child.on('exit', (code, signal) => this.markClosed({ code, signal, reason: 'child process exited' }), ); @@ -216,22 +222,35 @@ export class StdioTransport extends McplTransport { this.child.stdin?.write(json + '\n'); } + /** + * Close and reap: resolves once the child has actually exited, not merely + * been signalled (`child.killed` is true as soon as a signal is sent). A + * child that outlives SIGTERM by {@link STDIO_EXIT_GRACE_MS} gets SIGKILL, + * and close waits that long again, so shutdown stays bounded. + */ async close(): Promise { this.markClosed({ reason: 'closed by host' }); this.rl.close(); - if (this.child && !this.child.killed) { - this.child.kill(); - } - await new Promise((resolve) => { - if (!this.child || this.child.exitCode !== null || this.child.killed) { - resolve(); - } else { - this.child.once('exit', () => resolve()); - } + const child = this.child; + // Never started (a spawn error): there is no process to reap. + if (child.pid === undefined) return; + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = new Promise((resolve) => child.once('exit', () => resolve())); + const exitWithin = (ms: number) => new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), ms); + void exited.then(() => { clearTimeout(timer); resolve(true); }); }); + child.kill('SIGTERM'); + if (await exitWithin(STDIO_EXIT_GRACE_MS)) return; + console.error(`[mcpl] stdio child ${child.pid} ignored SIGTERM for ${STDIO_EXIT_GRACE_MS}ms — sending SIGKILL`); + child.kill('SIGKILL'); + await exitWithin(STDIO_EXIT_GRACE_MS); } } +/** How long a stdio child gets to exit after SIGTERM, and again after SIGKILL. */ +const STDIO_EXIT_GRACE_MS = 2_000; + // --------------------------------------------------------------------------- // WebSocket // --------------------------------------------------------------------------- diff --git a/test/fixtures/modern-raw-server.mjs b/test/fixtures/modern-raw-server.mjs index 84a0e054a..f63c1a82c 100644 --- a/test/fixtures/modern-raw-server.mjs +++ b/test/fixtures/modern-raw-server.mjs @@ -7,9 +7,13 @@ // invalid-schema tools/list advertises an outputSchema that doesn't compile // listen-fail-first the first subscriptions/listen of each launch is refused // hang-discover-later launches after the first never answer server/discover +// ignore-sigterm the process survives SIGTERM (only SIGKILL ends it) // Tools: op (outputSchema {value: integer}, answers with a string: invalid // structured content), plain (text), err (a JSON-RPC error), touch (announces -// a tool list change on the open subscription), die (exits). +// a tool list change on the open subscription), nullframe (writes JSON lines +// that aren't JSON-RPC messages, then answers), cont (a state-only +// input_required first leg; the second leg does what arguments.leg2 says: +// 'error', 'hang' or 'ok'), die (exits). import { createInterface } from 'node:readline'; import { appendFileSync, existsSync, readFileSync } from 'node:fs'; @@ -19,6 +23,7 @@ const prior = existsSync(log) ? readFileSync(log, 'utf8').split('\n').filter(Boo const launch = prior.filter((x) => x.event === 'start').length + 1; const note = (x) => appendFileSync(log, `${JSON.stringify({ ...x, launch })}\n`); note({ event: 'start', pid: process.pid }); +if (flags.has('ignore-sigterm')) process.on('SIGTERM', () => note({ event: 'sigterm-ignored' })); const send = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`); const result = (id, body) => send({ jsonrpc: '2.0', id, result: { resultType: 'complete', ...body } }); @@ -69,6 +74,8 @@ createInterface({ input: process.stdin }).on('line', (line) => { { name: 'plain', inputSchema: { type: 'object' } }, { name: 'err', inputSchema: { type: 'object' } }, { name: 'touch', inputSchema: { type: 'object' } }, + { name: 'nullframe', inputSchema: { type: 'object' } }, + { name: 'cont', inputSchema: { type: 'object' } }, { name: 'die', inputSchema: { type: 'object' } }, ], }); @@ -83,6 +90,19 @@ createInterface({ input: process.stdin }).on('line', (line) => { return result(m.id, { content: [{ type: 'text', text: subscription === null ? 'no subscription' : 'touched' }] }); } if (name === 'die') { setTimeout(() => process.exit(5), 10); return result(m.id, { content: [{ type: 'text', text: 'bye' }] }); } + if (name === 'nullframe') { + process.stdout.write('null\n[1,2]\n"just a string"\n{"no":"jsonrpc"}\n'); + return result(m.id, { content: [{ type: 'text', text: 'after the noise' }] }); + } + if (name === 'cont') { + const leg2 = m.params?.arguments?.leg2; + if (m.params?.requestState === undefined) { + return send({ jsonrpc: '2.0', id: m.id, result: { resultType: 'input_required', requestState: `state-for-${leg2}` } }); + } + if (leg2 === 'error') return error(m.id, -32603, 'second leg failed', { leg: 2, state: m.params.requestState }); + if (leg2 === 'hang') return; + return result(m.id, { content: [{ type: 'text', text: `leg 2 done (${m.params.requestState})` }] }); + } return result(m.id, { content: [{ type: 'text', text: 'plain' }] }); } default: diff --git a/test/mcp-modern-connection.test.ts b/test/mcp-modern-connection.test.ts index f496ec677..d71e2b6f5 100644 --- a/test/mcp-modern-connection.test.ts +++ b/test/mcp-modern-connection.test.ts @@ -72,7 +72,9 @@ test('connects once over Connectome\'s spawner and speaks 2026-07-28', async () assert.deepEqual(echo.content, [{ type: 'text', text: 'echo:hi' }]); // In-place discovery: the SDK's sibling probe would have made this 2. assert.equal(lines(join(dir, 'starts.log')).length, 1); - // The child's stderr surfaces as lines, as a legacy child's does. + // The child's stderr surfaces as lines, as a legacy child's does, + // startup lines included: they came before anyone could listen. + await until(() => stderr.some((l) => /^modern fixture \d+ up$/.test(l))); await connection.callTool('shout', { text: 'hello' }); await until(() => stderr.includes('shout: hello')); }); diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts index 5c6b2dca7..0f5f45ab0 100644 --- a/test/mcp-modern-outcomes.test.ts +++ b/test/mcp-modern-outcomes.test.ts @@ -144,3 +144,66 @@ test('close() during a reconnect handshake ends the launch it was waiting on, an assert.equal(wire(log).filter((e) => e.event === 'start').length, 2, 'no launch after close'); assert.equal(connection.isConnected, false); }); + +test('an input_required continuation keeps its evidence: the second leg decides the outcome', async () => { + const log = scratchLog(); + const connection = await connect('', log, { requestTimeoutMs: 600 }); + await connection.listTools(); + await assert.rejects(connection.callTool('cont', { leg2: 'error' }), (err: unknown) => { + assert.ok(err instanceof McplRequestError, String(err)); + assert.equal(err.outcome, 'error-response'); + assert.equal(err.code, -32603); + assert.deepEqual(err.data, { leg: 2, state: 'state-for-error' }); + return true; + }); + await assert.rejects(connection.callTool('cont', { leg2: 'hang' }), (err: unknown) => { + assert.ok(err instanceof McplRequestError, String(err)); + assert.equal(err.outcome, 'no-response'); + assert.match(err.message, /Cancellation was requested/); + return true; + }); + assert.equal(calls(log, 'cont'), 4, 'two legs each'); +}); + +test('concurrent continuations never cross their evidence', async () => { + const log = scratchLog(); + const connection = await connect('', log, { requestTimeoutMs: 2000 }); + await connection.listTools(); + const [failing, passing, failingToo] = await Promise.allSettled([ + connection.callTool('cont', { leg2: 'error' }), + connection.callTool('cont', { leg2: 'ok' }), + connection.callTool('cont', { leg2: 'error' }), + ]); + for (const settled of [failing, failingToo]) { + assert.equal(settled.status, 'rejected'); + const err = (settled as PromiseRejectedResult).reason; + assert.ok(err instanceof McplRequestError); + assert.equal(err.outcome, 'error-response'); + assert.deepEqual(err.data, { leg: 2, state: 'state-for-error' }); + } + assert.equal(passing.status, 'fulfilled'); + assert.deepEqual((passing as PromiseFulfilledResult<{ content: unknown }>).value.content, [{ type: 'text', text: 'leg 2 done (state-for-ok)' }]); +}); + +test('a JSON line that is not a JSON-RPC message is a diagnostic, never a crash', async () => { + const log = scratchLog(); + const connection = await connect('', log); + const diagnostics: string[] = []; + connection.on('error', (e: Error) => diagnostics.push(e.message)); + await connection.listTools(); + assert.deepEqual((await connection.callTool('nullframe', {})).content, [{ type: 'text', text: 'after the noise' }]); + await until(() => diagnostics.length >= 4, 'four diagnostics'); + assert.ok(diagnostics.every((d) => /sent a malformed JSON-RPC message/.test(d)), diagnostics.join(' | ')); + assert.equal(connection.isConnected, true); + assert.deepEqual((await connection.callTool('plain', {})).content, [{ type: 'text', text: 'plain' }]); +}); + +test('close() reaps: a child that ignores SIGTERM is killed before close resolves', async () => { + const log = scratchLog(); + const connection = await connect('ignore-sigterm', log); + const pid = wire(log).find((e) => e.event === 'start')!.pid!; + await connection.close(); + const alive = (p: number) => { try { process.kill(p, 0); return true; } catch { return false; } }; + assert.equal(alive(pid), false, 'dead when close() resolved'); + assert.ok(wire(log).some((e) => e.event === 'sigterm-ignored'), 'it really did ignore SIGTERM'); +}); From f9954a5cc628728a2653761b9776a1bd2e48ca25 Mon Sep 17 00:00:00 2001 From: Olive-1804 Date: Thu, 8 Oct 2026 02:21:45 -0400 Subject: [PATCH 06/94] feat(mcp): modern servers in the framework, standard result reading, structured results Third batch of room-284's modern-MCP client work (shelf-487): the framework integration. Connecting. connectMcplServerInternal validates first: serverConfigProblems for any config whose binding resolves, so a modern config with MCPL policy, a deadline of 0, or protocol on a URL is an error before any routing is registered. A config naming no usable transport keeps the transport's own error. Duplicate ids are refused across both engines. A modern binding goes to ModernMcpConnection and is kept in its own map, apart from the MCPL registry, so quiesce's plane pauses, awareness gates and policy never see it. Fatal startup paths and stop() close modern connections too, including any connect still in flight. Events (wireModernEvents). stderr, errors, connect and reconnect failures (with the ops alert at attempt 5) and close get the MCPL traces. A list change, and the relist after a reconnect, take handleToolsListChanged, the legacy refresh path, so any wake goes through ordinary admission. No awareness gate: it orders MCPL data-plane traffic, which this family doesn't have. A closed connection without reconnect is dropped, as the MCPL registry drops one. Tools. refreshMcplTools lists modern servers under the same prefix, tool policy and RFC-008 class hints. A failed listing leaves the server unlisted (unknown) and traces it. Dispatch. Both the model path and the direct path (executeToolCall, ModuleContext.callTool) route modern calls, sharing the not-found and policy refusals with MCPL. Both read results through mcpToolResult: - A standard peer (an MCP-only legacy server, or a modern server) gets normalizeStandardToolResult (src/mcpl/tool-result-normalize.ts): - text stays text, and inline images stay native; - audio and blobs are saved to the workspace `tool-results/`, with a bounded stub saying where; - a resource_link is shown and never fetched; embedded text is shown; - structuredContent is kept by presence on the new ToolResult.structured, and shown to the model as JSON only when the content has no text. - MCPL peers keep the RFC-005 reading (autofetch only for them now), gaining `structured` and the JSON view for empty content. - The direct path now fails on isError. It used to report every answer as success, so tts-relay's edit check read a failed edit as success. Scripts. A result with structured content reaches the script as one JSON object, { content, structuredContent, isError }, with text content as the text itself, under the same 5 MB cap. Other results keep the history string contract. Status. listMcplServers entries gain family, protocolVersion and transport. Host lifetime evidence (Tessa #53152, Agnes/Kit #53184, Theo #53178), in test/mcp-modern-framework.test.ts against a real framework: - a modern call in flight when quiesce begins completes, and the drain succeeds: the server finished, nothing was aborted, and the result reached the turn; - a list change while quiesced refreshes the inventory, and its wake parks (gatedRequests) with no inference until resume, then runs; - a modern child lost while quiesced is relaunched and relisted with no turn and no new wake. Also covered: listing and status, the direct path (structured by presence including 0, tool error as a failure, media stubs), the model path, the script object, a config error at connect, and the standard reading for an MCP-only legacy peer. Plus 10 unit tests for the normalizer. Docs: README "Modern MCP servers" (family selection table, what differs), requestTimeoutMs doc for modern, and changelog fragments (modern-mcp-client.added, mcp-standard-tool-results.changed). Receipts (Lethe, Node 24.19.0): every test file run standalone gives 1441/1441 (133 files 1375 plus mcpl-coalescing 66). The full-suite runner twice reported fewer tests than exist (1420, 1425) with 0 failures, because one file's later subtests went missing from the report; per-file runs are complete. mcpl-coalescing hung once at R16 under 6-way parallel load and once right after that run was killed. It then passed twice standalone (66/66) and R16 alone 3x; baseline 02adf27 passed R16 once. Not attributed either way. --- README.md | 36 ++ .../mcp-standard-tool-results.changed.md | 24 ++ changelog.d/modern-mcp-client.added.md | 28 ++ src/framework.ts | 345 +++++++++++++++--- src/mcpl/tool-result-normalize.ts | 153 ++++++++ src/mcpl/types.ts | 5 + src/types/events.ts | 8 + test/mcp-modern-framework.test.ts | 258 +++++++++++++ test/mcp-tool-result-normalize.test.ts | 111 ++++++ test/mcpl-list-status.test.ts | 4 + 10 files changed, 916 insertions(+), 56 deletions(-) create mode 100644 changelog.d/mcp-standard-tool-results.changed.md create mode 100644 changelog.d/modern-mcp-client.added.md create mode 100644 src/mcpl/tool-result-normalize.ts create mode 100644 test/mcp-modern-framework.test.ts create mode 100644 test/mcp-tool-result-normalize.test.ts diff --git a/README.md b/README.md index 7e4075c09..545279646 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,42 @@ A pattern that matches no tool is reported once, as a console line and an A pattern that could name a server's tools is not judged until that server has listed them, so late connects and reconnects don't cause early reports. +#### Modern MCP servers (2026-07-28) + +The same `mcplServers` list also takes servers that speak modern MCP +(revision `2026-07-28`), reached through the official SDK client. The +configuration chooses the family, and nothing is probed: + +| Configuration | Family | Transport | +|---|---|---| +| `url: 'ws://…'` / `'wss://…'` | MCPL (MCP `2024-11-05`) | WebSocket | +| `url: 'http://…'` / `'https://…'` | modern MCP | Streamable HTTP (`token` / `accessProvider` as bearer) | +| `command` | MCPL | stdio | +| `command` + `protocol: 'modern'` | modern MCP | stdio | + +```typescript +{ + mcplServers: [ + { id: 'docs', url: 'https://mcp.example.com/mcp', accessProvider: getToken }, + { id: 'search', command: 'search-mcp', protocol: 'modern', reconnect: true }, + ], +} +``` + +A modern server's tools get the same prefix, `enabledTools`/`disabledTools`, +class overrides and dispatch as an MCPL server's, and `listMcplServers()` +shows its `family`, `protocolVersion` and `transport`. Its config differs in +two ways: +- It has no MCPL surface (grant, push, channels, inference requests, feature + sets), so setting MCPL-only fields on one is a configuration error. +- `requestTimeoutMs` is one deadline per tool call and must be 1 to 2^31−1; + 0 is refused, where MCPL reads it as no watchdog. + +At the deadline cancellation is requested, the outcome is reported as +unknown, and the call is never retried. An MCPL server that refuses +`2024-11-05` with `-32022` fails with `McplProtocolVersionError`, which names +the fix, and is not retried. + #### Feature sets `enabledFeatureSets` and `disabledFeatureSets` select which of a server's diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md new file mode 100644 index 000000000..ae9cad54a --- /dev/null +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -0,0 +1,24 @@ +- MCP tool results from servers that aren't MCPL peers (MCP-only legacy + servers and modern servers) are now read by their content types: + - text stays text, and inline images stay native; + - audio and binary resources are saved to the workspace (`tool-results/`), + with a bounded stub saying where; + - a `resource_link` is shown as a reference and never fetched; + - an embedded text resource shows its text. + + Before, any non-text block turned the whole result into raw JSON. + `structuredContent` is now kept by presence on the new + `ToolResult.structured`, so `false`, `0` and `null` are values. The model + sees it as JSON only when the content carries no text; a structured-only + result used to reach the model as an empty string. Scripts receive + `{ content, structuredContent, isError }` as one JSON object whenever a + result has structured content. MCPL peers keep their RFC-005 reading and + gain `structured`. +- The direct tool path (`executeToolCall`, `ModuleContext.callTool`) now + treats an MCP tool error (`isError: true`) as a failure (`success: false` + with the error text), as the model path does. Before, every answer counted + as success. Its `data` now has the model path's shape too: text joined into + a string, or an array when an image is present. +- Closing a stdio MCP/MCPL server now waits for the child to exit. A child + still alive 2 s after SIGTERM gets SIGKILL. Before, close returned as soon + as the signal was sent. diff --git a/changelog.d/modern-mcp-client.added.md b/changelog.d/modern-mcp-client.added.md new file mode 100644 index 000000000..285d8bc13 --- /dev/null +++ b/changelog.d/modern-mcp-client.added.md @@ -0,0 +1,28 @@ +- Modern MCP servers (revision `2026-07-28`) can now be configured beside + MCPL ones, through the official SDK 2.x client. A server's URL decides its + family: `ws://`/`wss://` stays MCPL (MCP `2024-11-05`), and + `http://`/`https://` is modern MCP over Streamable HTTP. A stdio server + stays legacy unless its config sets `protocol: 'modern'`. A modern server's + tools use the same prefix, tool policy, RFC-008 class hints and dispatch + paths as any MCPL server's. It has no MCPL surface: no grant, push, + channels, inference requests, quiesce plane or awareness gate. MCPL-only + policy on one is a configuration error, and so is a modern + `requestTimeoutMs` outside 1..2^31−1, since 0 means "time out now" to the + SDK. Details: + - **stdio:** runs through the framework's own spawner (env allowlist, + `inheritEnv`, stderr lines including startup), launched once per connect. + - **HTTP credentials:** come from `token`/`accessProvider`, as a cached + bearer refreshed once on 401. + - **Deadline:** each call gets one `requestTimeoutMs` deadline across every + leg. At the deadline cancellation is requested, the outcome is reported + as unknown, and nothing is replayed. + - **Failures:** reported as `McplRequestError`, with the outcome taken from + what actually crossed the transport: `not-sent`, `error-response` (HTTP + 401/403 with `data.httpStatus`) or `no-response`. A server answer that + can't be used is a plain error. + - **Lifetime:** reconnects use the legacy backoff settings. A tool-list + change subscription is kept open, reopened if refused or lost, and its + changes go through ordinary admission, so they park under quiesce like + any wake. `close()` and `stop()` end any connect in flight. + - **Status:** `listMcplServers()` entries gain `family`, `protocolVersion` + and `transport`. diff --git a/src/framework.ts b/src/framework.ts index 3e5188f72..14f5cbe23 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -392,6 +392,9 @@ import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js'; import type { McplServerConnection } from './mcpl/server-connection.js'; import { McplProtocolVersionError } from './mcpl/server-connection.js'; +import { ModernMcpConnection } from './mcpl/modern-connection.js'; +import { resolveServerBinding, serverConfigProblems } from './mcpl/protocol-family.js'; +import { normalizeStandardToolResult } from './mcpl/tool-result-normalize.js'; import type { McplServerConfig, McplHostCapabilities, @@ -1372,6 +1375,13 @@ export class AgentFramework { private toolPatternKnownPrefixes: Map = new Map(); /** Maps serverId → McplServerConfig for prefix lookup. */ private mcplServerConfigs: Map = new Map(); + /** Modern-MCP (2026-07-28) servers, by id; read through modernServers(). + * They are kept apart from the MCPL registry on purpose: they have no + * grant, planes or server→host traffic, so the MCPL machinery (quiesce's + * plane pauses, awareness gates, policy) never sees them. They take part + * only through the tool paths, and their list changes reach agents + * through ordinary admission. */ + private modernMcpConnections: Map | undefined; /** Host capabilities advertised during the MCP handshake — stored so servers * can be connected at runtime (connectMcplServer) after initialization. */ private mcplHostCapabilities: McplHostCapabilities | null = null; @@ -2024,6 +2034,7 @@ export class AgentFramework { if (this.mcplServerRegistry) { shutdownPromises.push(this.mcplServerRegistry.closeAll()); } + shutdownPromises.push(this.closeModernMcpConnections()); await Promise.all(shutdownPromises); // Streams may queue storage repairs while being cancelled above. Retry @@ -11188,8 +11199,9 @@ export class AgentFramework { if (!this.mcplServerRegistry) { return { success: false, error: 'MCPL not initialized', isError: true }; } - const server = this.mcplServerRegistry.getServer(config.id); - if (!server) { + const modern = this.modernServers().get(config.id) ?? null; + const server = modern ? null : this.mcplServerRegistry.getServer(config.id); + if (!modern && !server) { return { success: false, error: `MCPL server ${config.id} not found`, isError: true }; } const prefix = config.toolPrefix ?? `mcpl--${config.id}`; @@ -11201,12 +11213,13 @@ export class AgentFramework { isError: true, }; } + const args = (call.input && typeof call.input === 'object') ? call.input as Record : {}; try { - const result = await server.sendToolsCall(toolName, call.input as Record); - return { - success: true, - data: result.content, - }; + // The same reading as the model path, so a tool error is a failure + // here too (this path used to report every answer as success). + if (modern) return await this.mcpToolResult(await modern.callTool(toolName, args), call.id, false); + const result = await server!.sendToolsCall(toolName, args); + return await this.mcpToolResult(result, call.id, server!.capabilities !== null); } catch (err) { return { success: false, @@ -11827,10 +11840,14 @@ export class AgentFramework { // stream while the script still runs and wedge the tool round. script.endTurn = true; } - if (result.isError) { + // An MCP structured result reaches the script whole, as one JSON object, + // so code can use structuredContent directly and doesn't have to parse + // it back out of text. + const structured = Object.prototype.hasOwnProperty.call(result, 'structured'); + if (result.isError && !structured) { return `Error: ${result.error ?? 'tool call failed'}`; } - if (result.data === undefined) return ''; + if (result.data === undefined && !structured) return ''; // RFC-005 "eagerly lazy": a script receiving a result is the strongest // available signal that the bytes are wanted (scripts process, they do // not browse) — materialize every referenced payload now, so the stub @@ -11849,6 +11866,21 @@ export class AgentFramework { // Scripts get (nearly) full data — the script environment IS the spill: // filtering big results in code is the whole point. 5MB protocol safety // cap only; no context-cap truncation, no file spill. + if (structured) { + // Inside the object, text content is the text itself; arrays get the + // history rendering (images as placeholders). The same 5MB protocol + // safety cap applies, over the whole object. + const content = result.isError + ? (result.error ?? '') + : typeof result.data === 'string' + ? result.data + : result.data === undefined ? '' : toolResultDataToHistoryString(result.data, 5_000_000); + return truncateForHistory(JSON.stringify({ + content, + structuredContent: result.structured, + isError: result.isError === true, + }), 5_000_000); + } return toolResultDataToHistoryString(result.data, 5_000_000); } @@ -13298,6 +13330,7 @@ export class AgentFramework { } catch (error) { if (error instanceof DiscordAwarenessAccountingError) { await this.mcplServerRegistry.closeAll(); + await this.closeModernMcpConnections(); throw error; } // Fail-open: log and continue with remaining servers @@ -13329,6 +13362,7 @@ export class AgentFramework { this.completeMcplDataPlaneGate(startupBarrier); } catch (error) { await this.mcplServerRegistry.closeAll(); + await this.closeModernMcpConnections(); throw error; } @@ -13336,6 +13370,20 @@ export class AgentFramework { await this.refreshMcplTools(); } + /** The modern connections, created on first use, so a framework assembled + * field by field (as unit tests do) needs no setup for them. */ + private modernServers(): Map { + return (this.modernMcpConnections ??= new Map()); + } + + /** Close every modern-MCP connection, including any connect still in + * flight, so none outlives a failed startup or stop(). */ + private async closeModernMcpConnections(): Promise { + const modern = [...this.modernServers().values()]; + this.modernServers().clear(); + await Promise.all(modern.map((connection) => connection.close())); + } + /** Reconcile the durable ledger with Chronicle, then deliver every server's work. */ async syncDiscordAwarenessMarkers(_onlyServerId?: string): Promise { if (!this.discordAwarenessOutbox) return; @@ -13693,12 +13741,36 @@ export class AgentFramework { throw new Error('MCPL subsystem is not initialized'); } + // Configuration mistakes the family brought (protocol on a URL, a modern + // deadline of 0, MCPL policy on a modern server) fail here, before any + // routing is registered. A configuration that names no usable transport + // keeps the transport's own error, as before. + let family: 'legacy' | 'modern' | null = null; + try { family = resolveServerBinding(config).family; } catch { /* the transport reports it */ } + if (family !== null) { + const problems = serverConfigProblems(config); + if (problems.length > 0) { + throw new Error(`MCP server "${config.id}" configuration: ${problems.join('; ')}`); + } + } + if (this.modernServers().has(config.id) || this.mcplServerRegistry.getServer(config.id)) { + throw new Error(`MCPL server "${config.id}" is already registered`); + } + // Register prefix + config for tool dispatch routing (idempotent with // the pre-registration pass in initializeMcpl). const prefix = config.toolPrefix ?? `mcpl--${config.id}`; this.mcplPrefixMap.set(prefix, config.id); this.mcplServerConfigs.set(config.id, config); + if (family === 'modern') { + const modern = await ModernMcpConnection.connect(config); + this.modernServers().set(config.id, modern); + this.wireModernEvents(modern); + this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` }); + return; + } + // Record per-server channel subscription policy before the server // registers channels — handleRegister fires during the handshake. if (this.channelRegistry) { @@ -14124,6 +14196,11 @@ export class AgentFramework { const config = this.mcplServerConfigs.get(id); const oldToolNames = new Set(this.mcplTools.map(t => t.name)); + const modern = this.modernServers().get(id); + if (modern) { + this.modernServers().delete(id); + await modern.close(); + } await this.mcplServerRegistry.removeServer(id); this.channelRegistry?.removeServer(id); @@ -14168,6 +14245,13 @@ export class AgentFramework { id: string; connected: boolean; retrying: boolean; + /** Which engine serves it: `legacy` (MCP 2024-11-05 + MCPL) or `modern` + * (MCP 2026-07-28), as its configuration selects. */ + family: 'legacy' | 'modern'; + /** The MCP revision its last handshake established; null until one has. */ + protocolVersion: string | null; + /** How it is reached. */ + transport: 'stdio' | 'websocket' | 'http'; toolPrefix: string; toolCount: number; /** True only after the §5.3 policy receipt activated the grant. */ @@ -14209,6 +14293,8 @@ export class AgentFramework { }> { const result: Array<{ id: string; connected: boolean; retrying: boolean; + family: 'legacy' | 'modern'; protocolVersion: string | null; + transport: 'stdio' | 'websocket' | 'http'; toolPrefix: string; toolCount: number; policyEstablished: boolean; effectiveGrant: string[]; maskedCapabilities: string[]; deniedCapabilities: string[]; allowHostCommands: boolean; @@ -14223,8 +14309,15 @@ export class AgentFramework { }> = []; for (const [id, config] of this.mcplServerConfigs) { const prefix = config.toolPrefix ?? `mcpl--${id}`; - const connection = this.mcplServerRegistry?.getServer(id) ?? null; - const connected = connection?.isConnected ?? false; + const modern = this.modernServers().get(id) ?? null; + const connection = modern ? null : this.mcplServerRegistry?.getServer(id) ?? null; + const connected = modern?.isConnected ?? connection?.isConnected ?? false; + let binding: { family: 'legacy' | 'modern'; transport: 'stdio' | 'websocket' | 'http' }; + try { + binding = resolveServerBinding(config); + } catch { + binding = { family: 'legacy', transport: config.url ? 'websocket' : 'stdio' }; + } // Attribute through the dispatch resolver: a bare prefix match would // also count a nested prefix's tools (`foo` vs `foo--bar`) as ours. const ownTools = (this.mcplTools ?? []).flatMap((t) => { @@ -14234,7 +14327,10 @@ export class AgentFramework { result.push({ id, connected, - retrying: !connected && (connection?.willReconnect ?? false), + retrying: !connected && (modern?.willReconnect ?? connection?.willReconnect ?? false), + family: binding.family, + protocolVersion: modern?.protocolVersion ?? connection?.protocolVersion ?? null, + transport: binding.transport, toolPrefix: prefix, toolCount: ownTools.length, policyEstablished: connection?.policyEstablished ?? false, @@ -14568,6 +14664,63 @@ export class AgentFramework { }); } + /** + * Wire a modern-MCP connection's events. There's less here than for MCPL + * on purpose: no push, inference, channels or policy exist on this family. + * A list change, including the relist after a reconnect, takes the legacy + * engine's refresh path (`handleToolsListChanged`), so any wake it causes + * goes through ordinary admission: it parks under quiesce or a hold like + * every other wake. It installs no awareness gate, since that gate orders + * MCPL data-plane traffic, which this family doesn't carry. + */ + private wireModernEvents(connection: ModernMcpConnection): void { + connection.on('stderr', (params: { line: string }) => { + this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: params.line }); + }); + connection.on('tools-list-changed', () => { + this.handleToolsListChanged(connection.id); + }); + connection.on('reconnect', (info?: { attempts?: number }) => { + this.emitTrace({ type: 'mcpl:server-reconnected', serverId: connection.id, attempts: info?.attempts ?? 0 }); + this.emitTrace({ type: 'module:added', moduleName: `mcpl:${connection.id}` }); + this.handleToolsListChanged(connection.id); + }); + const failed = (params: { error: string; attempt: number }) => { + this.emitTrace({ + type: 'mcpl:server-connect-failed', + serverId: connection.id, + error: params.error, + attempt: params.attempt, + willRetry: connection.willReconnect, + }); + if (params.attempt >= 5) { + this.opsAlert('mcpl-down', connection.id, `MCP server unreachable (attempt ${params.attempt}): ${params.error}`); + } + }; + connection.on('connect-failed', failed); + connection.on('reconnect-failed', failed); + connection.on('error', (err: Error) => { + this.emitTrace({ type: 'mcpl:server-error', serverId: connection.id, error: err.message }); + }); + connection.on('close', () => { + // A closed provider's class hints are stale (RFC-008), as for MCPL. + this.dropMcplToolClasses(connection.id); + this.emitTrace({ + type: 'mcpl:server-closed', + serverId: connection.id, + code: null, + signal: null, + willReconnect: connection.willReconnect, + }); + this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` }); + // Without reconnect a closed server is gone, as the MCPL registry + // drops one; its configuration stays listed as disconnected. + if (!connection.willReconnect && this.modernServers().get(connection.id) === connection) { + this.modernServers().delete(connection.id); + } + }); + } + /** * Discover tools from all connected MCPL servers and cache them. * Tools are namespaced as `{toolPrefix}--{toolName}` per server config. @@ -14625,6 +14778,37 @@ export class AgentFramework { } } + // Modern servers: the same namespacing, tool policy and RFC-008 class + // hints. Feature sets are MCPL and don't apply. A server whose listing + // fails stays unlisted (unknown), never "listed nothing". + for (const server of this.modernServers().values()) { + if (!server.isConnected) continue; + const config = this.mcplServerConfigs.get(server.id); + const prefix = config?.toolPrefix ?? `mcpl--${server.id}`; + try { + const listed = await server.listTools(); + listedServers.add(server.id); + for (const tool of listed) { + if (!isToolAllowed(tool.name, config)) continue; + const namespacedName = `${prefix}--${tool.name}`; + toolServers.set(namespacedName, server.id); + const declaredClasses = parseDeclaredClasses(tool._meta, namespacedName); + if (declaredClasses) toolClasses.set(namespacedName, declaredClasses); + tools.push({ + name: namespacedName, + description: tool.description ?? '', + inputSchema: tool.inputSchema as import('./types/index.js').ToolDefinition['inputSchema'], + }); + } + } catch (error) { + this.emitTrace({ + type: 'mcpl:server-error', + serverId: server.id, + error: `tools/list failed: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + this.mcplTools = tools; this.mcplToolFeatureSets = toolFeatureSets; this.mcplToolClasses = toolClasses; @@ -14776,9 +14960,10 @@ export class AgentFramework { */ private dispatchMcplToolCall(agentName: string, call: ToolCall, serverId: string, prefix: string): void { const toolName = call.name.slice(prefix.length + 2); // strip "{prefix}--" - const server = this.mcplServerRegistry!.getServer(serverId); + const modern = this.modernServers().get(serverId) ?? null; + const server = modern ? null : this.mcplServerRegistry!.getServer(serverId); - if (!server) { + if (!modern && !server) { // RFC-007: refused before executing — no lifecycle events. this.toolLifecycleEmitter?.refuse(agentName, call.id); this.pushEvent({ @@ -14813,6 +14998,36 @@ export class AgentFramework { const startTime = Date.now(); const args = (call.input && typeof call.input === 'object') ? call.input as Record : {}; + const failDispatch = (error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)); + this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: err.message, stack: err.stack }); + // RFC-007: transport error, closed connection or timeout — no result. + this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id); + this.pushEvent({ + type: 'tool-result', + callId: call.id, + agentName, + moduleName: `mcpl:${serverId}`, + result: { success: false, error: err.message, isError: true }, + }); + }; + + if (modern) { + // Modern MCP: no state exchange, no channels, no RFC-005 references. + modern.callTool(toolName, args) + .then((result) => this.mcpToolResult(result, call.id, false)) + .then((result) => { + this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs: Date.now() - startTime }); + this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: `mcpl:${serverId}`, result }); + }) + .catch(failDispatch); + return; + } + const legacy = server!; + // An MCPL peer's results get the RFC-005 reading; an MCP-only peer's + // the standard one, the same as a modern server's. + const mcplPeer = legacy.capabilities !== null; + // Build state params for stateful tools (Step 8). // A tools/list attribution selects that exact stateful set. Untagged tools // retain the host-managed or unique server-managed fallback, but never guess @@ -14860,15 +15075,18 @@ export class AgentFramework { } } - server.sendToolsCall(toolName, args, stateParams) + legacy.sendToolsCall(toolName, args, stateParams) .then(async (result) => { // RFC-005: register references and eagerly fetch the small ones - // before serialization, so their stubs carry a saved path. - await this.autoFetchReferences(result.content, serverId, true) - .catch((e) => console.error('[rfc005] autofetch error:', (e as Error).message)); - return result; + // before serialization, so their stubs carry a saved path. Only an + // MCPL peer's blocks are references; an MCP-only peer's are standard. + if (mcplPeer) { + await this.autoFetchReferences(result.content, serverId, true) + .catch((e) => console.error('[rfc005] autofetch error:', (e as Error).message)); + } + return { result, toolResult: await this.mcpToolResult(result, call.id, mcplPeer) }; }) - .then((result) => { + .then(({ result, toolResult }) => { const durationMs = Date.now() - startTime; this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs }); @@ -14937,50 +15155,65 @@ export class AgentFramework { } } - // Convert MCP tool result to framework ToolResult. - // When the result contains non-text blocks (e.g. images from an MCP - // tool like zulip-mcp's fetch_attachment), pass the full content array - // through so toMembraneToolResult can preserve image blocks natively. - // Text-only results still collapse to a joined string for backward - // compatibility with callers that expect data to be string-ish. - const textContent = result.content - ?.filter((c) => c.type === 'text' && c.text) - .map((c) => c.text!) - .join('\n'); - const hasNonText = result.content?.some((c) => c.type !== 'text'); - const data = result.isError - ? undefined - : hasNonText - ? result.content - : (textContent || undefined); - this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: `mcpl:${serverId}`, - result: { - success: !result.isError, - data, - error: result.isError ? (textContent || 'Tool call failed') : undefined, - isError: result.isError ?? false, - }, + result: toolResult, }); }) - .catch((error) => { - const err = error instanceof Error ? error : new Error(String(error)); - this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: err.message, stack: err.stack }); - // RFC-007: transport error, closed connection or timeout — no result. - this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id); + .catch(failDispatch); + } - this.pushEvent({ - type: 'tool-result', - callId: call.id, - agentName, - moduleName: `mcpl:${serverId}`, - result: { success: false, error: err.message, isError: true }, - }); - }); + /** + * The framework's ToolResult for an MCP tool result, on both dispatch + * paths (model and direct). A standard peer, meaning an MCP-only legacy + * server or any modern server, gets the standard reading + * (tool-result-normalize.ts): typed content blocks, workspace-saved + * payloads, and `structured` kept by presence. An MCPL peer keeps the + * RFC-005 reading. Its content array passes through when it holds + * non-text blocks, so references and images render natively. It gets + * `structured` too, with the JSON view when its content is empty. + */ + private async mcpToolResult( + result: { content?: unknown; isError?: boolean; structuredContent?: unknown }, + callId: string, + mcplPeer: boolean, + ): Promise { + const hasStructured = Object.prototype.hasOwnProperty.call(result, 'structuredContent'); + if (!mcplPeer) { + const label = `${new Date().toISOString().slice(0, 10)}-${callId}`.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80); + const workspace = this.getWorkspaceModule(); + const mount = workspace ? this.firstWritableMountName(workspace) : null; + const save = workspace && mount + ? async (fileName: string, bytes: Buffer, mimeType: string) => { + const path = `${mount}/tool-results/${fileName}`; + const written = await workspace.writeBinary(path, bytes, mimeType); + return written.success ? path : null; + } + : null; + return normalizeStandardToolResult(result, label, save); + } + const blocks = (Array.isArray(result.content) ? result.content : []) as Array<{ type?: string; text?: string }>; + // When the result contains non-text blocks (RFC-005 references, images), + // pass the full content array through so toMembraneToolResult can + // preserve them natively. Text-only results collapse to a joined string + // for callers that expect data to be string-ish. + let textContent = blocks + .filter((c) => c.type === 'text' && c.text) + .map((c) => c.text!) + .join('\n'); + if (blocks.length === 0 && hasStructured) textContent = JSON.stringify(result.structuredContent) ?? 'null'; + const hasNonText = blocks.some((c) => c.type !== 'text'); + const structured = hasStructured ? { structured: result.structuredContent } : {}; + return { + success: !result.isError, + data: result.isError ? undefined : hasNonText ? blocks : (textContent || undefined), + error: result.isError ? (textContent || 'Tool call failed') : undefined, + isError: result.isError ?? false, + ...structured, + }; } /** diff --git a/src/mcpl/tool-result-normalize.ts b/src/mcpl/tool-result-normalize.ts new file mode 100644 index 000000000..4b82ed85a --- /dev/null +++ b/src/mcpl/tool-result-normalize.ts @@ -0,0 +1,153 @@ +/** + * What a standard MCP tool result becomes for the framework. Standard means + * the server did not negotiate MCPL: a legacy MCP-only peer or a modern + * server. MCPL peers keep their RFC-005 reading (flat `resource` and + * uri-form media as references), which is not this module's job. + * + * An MCP result has two views, and each consumer gets its own: + * - **The content array is the server's model-facing view.** Text stays text + * and inline images stay native. Anything the provider format can't carry + * (audio, binary blobs) is saved to the workspace, and a bounded stub says + * where it went: a stub tells the reader where the payload is; it never + * replaces it silently. A `resource_link` is shown as a reference and never + * fetched. An embedded resource shows its text, or is saved like a blob. + * - **`structuredContent` is the machine-readable view.** It is kept by + * presence on `ToolResult.structured`, for scripts and modules. The model + * sees it rendered as JSON only when the content carries no text, because + * otherwise the server already chose what the model reads. + * + * Before this, a structured-only result reached the model as an empty string, + * and a mixed result became one JSON string with its image as base64 text + * (room-284 probe P1c). + */ +import type { ToolResult } from '../types/index.js'; + +/** Saves a payload under a host-chosen name; resolves to its workspace path, + * or null when there is nowhere writable to put it. */ +export type SaveToolPayload = (fileName: string, bytes: Buffer, mimeType: string) => Promise; + +/** Stub fields are bounded independently of anything the server sends. */ +const STUB_FIELD_CHARS = 200; + +type Block = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }; + +function bounded(value: unknown): string { + const text = typeof value === 'string' ? value : JSON.stringify(value) ?? ''; + return text.length > STUB_FIELD_CHARS ? `${text.slice(0, STUB_FIELD_CHARS)}…` : text; +} + +function decodedSize(base64: string): number { + const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0; + return Math.max(0, Math.floor((base64.length * 3) / 4) - padding); +} + +function humanSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function extensionFor(mimeType: string): string { + const subtype = mimeType.split('/')[1]?.split(';')[0]?.trim().toLowerCase() ?? ''; + const known: Record = { 'octet-stream': 'bin', 'mpeg': 'mp3', 'plain': 'txt', 'x-wav': 'wav' }; + const ext = known[subtype] ?? subtype.replace(/[^a-z0-9]/g, ''); + return ext.length > 0 && ext.length <= 10 ? ext : 'bin'; +} + +export interface StandardToolResult { + content?: unknown; + isError?: boolean; + structuredContent?: unknown; +} + +/** + * Normalize a standard MCP tool result into the framework's `ToolResult`. + * `label` names saved payloads (one per call; payloads get an index suffix). + * Without `save`, payloads are described but not saved, and the stub says so. + */ +export async function normalizeStandardToolResult( + result: StandardToolResult, + label: string, + save: SaveToolPayload | null, +): Promise { + const raw = Array.isArray(result.content) ? result.content : []; + const blocks: Block[] = []; + let saved = 0; + + const payloadStub = async (kind: string, base64: string, mimeType: string, origin?: string): Promise => { + const size = humanSize(decodedSize(base64)); + const what = `[${kind}${origin ? ` ${bounded(origin)}` : ''}: ${bounded(mimeType)}, ${size}`; + if (!save) return `${what}. Not shown: the model can't take this type, and no workspace is mounted to save it]`; + const path = await save(`${label}-${++saved}.${extensionFor(mimeType)}`, Buffer.from(base64, 'base64'), mimeType) + .catch(() => null); + return path + ? `${what}, saved to workspace file ${path}]` + : `${what}. Not shown: the model can't take this type, and saving it to the workspace failed]`; + }; + + for (const entry of raw) { + const block = (entry && typeof entry === 'object' ? entry : {}) as Record; + switch (block.type) { + case 'text': + if (typeof block.text === 'string') blocks.push({ type: 'text', text: block.text }); + break; + case 'image': + if (typeof block.data === 'string' && typeof block.mimeType === 'string') { + blocks.push({ type: 'image', data: block.data, mimeType: block.mimeType }); + } else { + blocks.push({ type: 'text', text: `[image without inline data, not fetched${typeof block.uri === 'string' ? `: ${bounded(block.uri)}` : ''}]` }); + } + break; + case 'audio': + if (typeof block.data === 'string' && typeof block.mimeType === 'string') { + blocks.push({ type: 'text', text: await payloadStub('audio', block.data, block.mimeType) }); + } else { + blocks.push({ type: 'text', text: `[audio without inline data, not fetched${typeof block.uri === 'string' ? `: ${bounded(block.uri)}` : ''}]` }); + } + break; + case 'resource_link': { + const name = typeof block.name === 'string' ? block.name : undefined; + const mime = typeof block.mimeType === 'string' ? `, ${bounded(block.mimeType)}` : ''; + blocks.push({ type: 'text', text: `[resource link${name ? ` "${bounded(name)}"` : ''}: ${bounded(block.uri)}${mime}; not fetched]` }); + break; + } + case 'resource': { + const resource = (block.resource && typeof block.resource === 'object' ? block.resource : {}) as Record; + const uri = typeof resource.uri === 'string' ? resource.uri : undefined; + const mime = typeof resource.mimeType === 'string' ? resource.mimeType : undefined; + if (typeof resource.text === 'string') { + blocks.push({ type: 'text', text: `[resource ${bounded(uri ?? '(no uri)')}${mime ? `, ${bounded(mime)}` : ''}]\n${resource.text}` }); + } else if (typeof resource.blob === 'string') { + blocks.push({ type: 'text', text: await payloadStub('resource', resource.blob, mime ?? 'application/octet-stream', uri) }); + } else { + blocks.push({ type: 'text', text: `[resource ${bounded(uri ?? '(no uri)')} with no contents]` }); + } + break; + } + default: + // Unknown to this client: shown as data rather than dropped. + blocks.push({ type: 'text', text: JSON.stringify(entry) ?? '' }); + } + } + + const hasStructured = Object.prototype.hasOwnProperty.call(result, 'structuredContent'); + const hasText = blocks.some((b) => b.type === 'text' && b.text.trim().length > 0); + if (hasStructured && !hasText) { + blocks.push({ type: 'text', text: JSON.stringify(result.structuredContent) ?? 'null' }); + } + + const text = blocks.filter((b): b is Extract => b.type === 'text').map((b) => b.text).join('\n'); + const structured = hasStructured ? { structured: result.structuredContent } : {}; + if (result.isError) { + return { success: false, error: text || 'Tool call failed', isError: true, ...structured }; + } + const hasImage = blocks.some((b) => b.type === 'image'); + return { + success: true, + // Text-only results keep the joined-string convention callers expect. + // An image keeps the array, so the model sees it natively. + data: hasImage ? blocks : (text || undefined), + isError: false, + ...structured, + }; +} diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 209229b01..3daf4e49e 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -388,6 +388,11 @@ export interface McplServerConfig { * agent turn forever; with the timeout the pending request rejects with a * descriptive error, which the framework surfaces as a normal isError * tool_result. Set 0 to disable. Default: 60000 (60 seconds). + * + * For a modern server it is one deadline per tool call, across every leg + * of the call, and must be an integer from 1 to 2^31−1. It can't be + * disabled: the SDK reads 0 as "time out now". At the deadline + * cancellation is requested and the outcome is unknown. */ requestTimeoutMs?: number; diff --git a/src/types/events.ts b/src/types/events.ts index b5e8afb8e..36037c65c 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -188,6 +188,14 @@ export interface ToolResult { error?: string; /** Whether this was an error (for LLM) */ isError?: boolean; + /** + * An MCP tool's `structuredContent`: its machine-readable result, beside + * `data`, which is the content view. Present exactly when the server sent + * one, so `false`, `0` and `null` are values here, not absence. Programs + * read it (scripts get it in their result object); the model sees content, + * and sees this rendered as JSON only when the content carries no text. + */ + structured?: unknown; /** * When true, the framework saves tool_use + tool_result messages to context, * cancels the active stream, and resets the agent to idle. diff --git a/test/mcp-modern-framework.test.ts b/test/mcp-modern-framework.test.ts new file mode 100644 index 000000000..c6b25e5dc --- /dev/null +++ b/test/mcp-modern-framework.test.ts @@ -0,0 +1,258 @@ +/** + * A modern-MCP server inside a real framework (room-284, shelf-487): tool + * listing and status, both dispatch paths, the script view of structured + * results, an MCP-only legacy peer read the same standard way, and the host + * lifetime contract (Tessa #53152, Agnes/Kit #53184, Theo #53178): + * - a modern call already in flight completes while the host quiesces; + * - a list change while quiesced refreshes the inventory, and its wake parks + * until resume; + * - a modern child lost while quiesced reconnects and relists without + * starting a turn. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { AgentFramework } from '../src/index.js'; +import type { EventResponse, Module, ModuleContext, ProcessEvent, ProcessState, ToolDefinition, ToolResult } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const MODERN = join(here, 'fixtures', 'modern-mcp-server.mjs'); + +/** Turns every external message into a wake, so tool-list diffs wake agents. */ +class Waker implements Module { + readonly name = 'waker'; + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + getTools(): ToolDefinition[] { return []; } + async handleToolCall(): Promise { return { success: false, error: 'no tools', isError: true }; } + async onProcess(event: ProcessEvent, _state: ProcessState): Promise { + if (event.type === 'external-message') { + return { addMessages: [{ participant: 'User', content: [{ type: 'text', text: 'go' }] }], requestInference: true }; + } + return {}; + } +} + +async function waitFor(what: string, cond: () => boolean, timeoutMs = 8000): Promise { + const deadline = Date.now() + timeoutMs; + while (!cond()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise((r) => setTimeout(r, 25)); + } +} + +const lines = (file: string) => (existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean) : []); + +interface Harness { framework: AgentFramework; membrane: MockMembrane; dir: string } + +async function withModern(fn: (h: Harness) => Promise, extraServers: Record[] = []): Promise { + const dir = mkdtempSync(join(tmpdir(), 'modern-framework-')); + const membrane = new MockMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], + modules: [new Waker()], + mcplServers: [ + { + id: 'modern', + command: process.execPath, + args: [MODERN, join(dir, 'starts.log'), join(dir, 'events.log')], + protocol: 'modern', + reconnect: true, + reconnectIntervalMs: 50, + reconnectMaxIntervalMs: 100, + }, + ...(extraServers as never[]), + ], + }); + try { + await fn({ framework, membrane, dir }); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } +} + +const call = (name: string, input: Record = {}) => ({ id: `t-${name}-${Math.random()}`, name, input }); + +test('modern tools are listed under the prefix, and status names family, revision and transport', async () => { + await withModern(async ({ framework }) => { + const names = framework.getAllTools().map((t) => t.name); + assert.ok(names.includes('mcpl--modern--echo'), names.join()); + const [status] = framework.listMcplServers(); + assert.equal(status!.family, 'modern'); + assert.equal(status!.protocolVersion, '2026-07-28'); + assert.equal(status!.transport, 'stdio'); + assert.equal(status!.connected, true); + assert.ok(status!.toolCount >= 5); + assert.deepEqual(status!.effectiveGrant, []); + }); +}); + +test('direct path: structured kept by presence, tool errors fail, media is described', async () => { + await withModern(async ({ framework }) => { + const only = await framework.executeToolCall(call('mcpl--modern--structured_only')); + assert.equal(only.success, true); + assert.deepEqual(only.structured, { answer: 42, ok: false, nothing: null }); + assert.equal(only.data, '{"answer":42,"ok":false,"nothing":null}'); + + const zero = await framework.executeToolCall(call('mcpl--modern--structured_zero')); + assert.ok('structured' in zero); + assert.equal(zero.structured, 0); + + const fail = await framework.executeToolCall(call('mcpl--modern--fail')); + assert.equal(fail.success, false); + assert.equal(fail.isError, true); + assert.equal(fail.error, 'the tool failed on purpose'); + + const media = await framework.executeToolCall(call('mcpl--modern--media')); + assert.ok(Array.isArray(media.data), 'an image keeps the array'); + const text = (media.data as Array<{ type: string; text?: string }>).filter((b) => b.type === 'text').map((b) => b.text).join('\n'); + assert.match(text, /\[resource link "report\.pdf": file:\/\/\/srv\/report\.pdf, application\/pdf; not fetched\]/); + assert.match(text, /embedded note text/); + assert.match(text, /\[audio: audio\/wav, \d+ B\. Not shown/); + assert.ok((media.data as Array<{ type: string }>).some((b) => b.type === 'image')); + }); +}); + +test('model path: the result reaches the model, structured-only as JSON', async () => { + await withModern(async ({ framework, membrane }) => { + framework.start(); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'toolu_01MODERNSTRUCTURED000000', name: 'mcpl--modern--structured_only', input: {} }, + ] as never, 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }])); + framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as never); + await waitFor('the tool round', () => (membrane.lastStream?.receivedToolResults.length ?? 0) >= 1); + // The framework renders string data JSON-encoded, as it does for every + // string tool result; decoded, it is the structured value's JSON. + const [delivered] = membrane.lastStream!.receivedToolResults[0] as Array<{ content: string }>; + assert.deepEqual(JSON.parse(JSON.parse(delivered!.content)), { answer: 42, ok: false, nothing: null }); + }); +}); + +test('scripts get structured results whole, as one JSON object', async () => { + await withModern(async ({ framework }) => { + framework.start(); + const handle = (framework as unknown as { + handleScriptToolCall: (agent: string, tool: string, args: Record) => Promise; + }).handleScriptToolCall.bind(framework); + const out = JSON.parse(await handle('agent', 'mcpl--modern--structured_and_text', {})); + assert.deepEqual(out, { content: 'Found 2 rows.', structuredContent: { rows: [{ id: 1 }, { id: 2 }] }, isError: false }); + // Without structured content the script contract is unchanged: the + // history rendering, which JSON-encodes a text result. + assert.equal(await handle('agent', 'mcpl--modern--echo', { text: 'x' }), JSON.stringify('echo:x')); + }); +}); + +test('MCPL policy on a modern server is a configuration error at connect', async () => { + await withModern(async ({ framework }) => { + await assert.rejects( + framework.connectMcplServer({ id: 'bad', url: 'https://example.invalid/mcp', enabledFeatureSets: ['x'] }), + /"enabledFeatureSets" is MCPL policy/, + ); + await assert.rejects( + framework.connectMcplServer({ id: 'bad2', url: 'https://example.invalid/mcp', protocol: 'modern' }), + /applies only to stdio/, + ); + assert.ok(!framework.listMcplServers().some((s) => s.id === 'bad' || s.id === 'bad2'), 'nothing registered'); + }); +}); + +test('an MCP-only legacy peer gets the same standard reading', async () => { + const LEGACY_MCP_ONLY = ` +let buf = ''; +process.stdin.on('data', (c) => { + buf += c.toString('utf8'); + let i; + while ((i = buf.indexOf('\\n')) >= 0) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + let m; try { m = JSON.parse(line); } catch { continue; } + const reply = (result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: m.id, result }) + '\\n'); + if (m.method === 'initialize') reply({ protocolVersion: '2024-11-05', capabilities: { tools: {} } }); + else if (m.method === 'tools/list') reply({ tools: [{ name: 'docs', inputSchema: { type: 'object' } }] }); + else if (m.method === 'tools/call') reply({ + content: [ + { type: 'resource_link', uri: 'https://example.invalid/a.md', name: 'a.md' }, + { type: 'resource', resource: { uri: 'memo://n', mimeType: 'text/plain', text: 'note body' } }, + ], + structuredContent: { count: 2 }, + }); + } +}); +`; + await withModern(async ({ framework }) => { + const result = await framework.executeToolCall(call('mcpl--plain--docs')); + assert.equal(result.success, true); + assert.deepEqual(result.structured, { count: 2 }); + assert.equal(result.data, '[resource link "a.md": https://example.invalid/a.md; not fetched]\n[resource memo://n, text/plain]\nnote body'); + const status = framework.listMcplServers().find((s) => s.id === 'plain')!; + assert.equal(status.family, 'legacy'); + assert.equal(status.protocolVersion, '2024-11-05'); + }, [{ id: 'plain', command: process.execPath, args: ['-e', LEGACY_MCP_ONLY] }]); +}); + +test('quiesce: a modern call already in flight completes, and its result reaches the turn', async () => { + await withModern(async ({ framework, membrane, dir }) => { + framework.start(); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'toolu_01MODERNSLOW0000000000000', name: 'mcpl--modern--slow', input: { ms: 600 } }, + ] as never, 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }])); + framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as never); + await waitFor('the slow call to start server-side', () => lines(join(dir, 'events.log')).includes('slow-start')); + + const status = await framework.quiesce({ reason: 'maintenance', timeoutMs: 10_000 }); + assert.equal(status.quiesced, true); + assert.equal(status.drained, true, 'the turn drained: its modern call completed'); + assert.ok(lines(join(dir, 'events.log')).includes('slow-finished')); + assert.ok(!lines(join(dir, 'events.log')).includes('slow-aborted'), 'quiesce did not cancel it'); + assert.match(JSON.stringify(membrane.lastStream!.receivedToolResults[0]), /slept 600/); + await framework.resume(); + }); +}); + +test('quiesce: a list change refreshes the inventory, and its wake parks until resume', async () => { + await withModern(async ({ framework, membrane }) => { + framework.start(); + await framework.quiesce({ reason: 'maintenance' }); + const callsBefore = membrane.calls.length; + const gatedBefore = framework.getHostModeStatus().gatedRequests; + + await framework.executeToolCall(call('mcpl--modern--add_tool')); + await waitFor('the refreshed inventory', () => framework.getAllTools().some((t) => t.name === 'mcpl--modern--added_1')); + await waitFor('the wake to park', () => framework.getHostModeStatus().gatedRequests > gatedBefore); + await new Promise((r) => setTimeout(r, 200)); + assert.equal(membrane.calls.length, callsBefore, 'no inference while quiesced'); + + await framework.resume(); + await waitFor('the parked wake to run', () => membrane.calls.length > callsBefore); + }); +}); + +test('quiesce: a lost modern child reconnects and relists without starting a turn', async () => { + await withModern(async ({ framework, membrane, dir }) => { + framework.start(); + await framework.quiesce({ reason: 'maintenance' }); + const callsBefore = membrane.calls.length; + const gatedBefore = framework.getHostModeStatus().gatedRequests; + + await framework.executeToolCall(call('mcpl--modern--die')); + await waitFor('a second launch', () => lines(join(dir, 'starts.log')).length === 2); + await waitFor('reconnected', () => framework.listMcplServers()[0]!.connected); + await waitFor('the relisted inventory', () => framework.getAllTools().some((t) => t.name === 'mcpl--modern--echo')); + await new Promise((r) => setTimeout(r, 200)); + + assert.equal(membrane.calls.length, callsBefore, 'no turn started'); + assert.equal(framework.getHostModeStatus().gatedRequests, gatedBefore, 'no wake parked: the inventory is unchanged'); + assert.deepEqual((await framework.executeToolCall(call('mcpl--modern--echo', { text: 'back' }))).data, 'echo:back'); + await framework.resume(); + }); +}); diff --git a/test/mcp-tool-result-normalize.test.ts b/test/mcp-tool-result-normalize.test.ts new file mode 100644 index 000000000..a5457e9f8 --- /dev/null +++ b/test/mcp-tool-result-normalize.test.ts @@ -0,0 +1,111 @@ +/** + * The standard reading of an MCP tool result (room-284, shelf-487): the + * content array is the model's view, and `structuredContent` the + * programmatic one, kept by presence. A payload the model can't take is + * saved and described by a bounded stub; nothing is fetched. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { normalizeStandardToolResult, type SaveToolPayload } from '../src/mcpl/tool-result-normalize.js'; + +const b64 = (s: string) => Buffer.from(s).toString('base64'); + +function recordingSave(): { save: SaveToolPayload; saved: Array<{ fileName: string; bytes: string; mimeType: string }> } { + const saved: Array<{ fileName: string; bytes: string; mimeType: string }> = []; + return { + saved, + save: async (fileName, bytes, mimeType) => { + saved.push({ fileName, bytes: bytes.toString('utf8'), mimeType }); + return `home/tool-results/${fileName}`; + }, + }; +} + +test('text only: the joined string, as before, and no structured field', async () => { + const r = await normalizeStandardToolResult({ content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] }, 'L', null); + assert.deepEqual(r, { success: true, data: 'a\nb', isError: false }); + assert.ok(!('structured' in r)); +}); + +test('structured only: the model sees it as JSON, programs get the value', async () => { + const r = await normalizeStandardToolResult({ content: [], structuredContent: { answer: 42, ok: false, nothing: null } }, 'L', null); + assert.equal(r.success, true); + assert.equal(r.data, '{"answer":42,"ok":false,"nothing":null}'); + assert.deepEqual(r.structured, { answer: 42, ok: false, nothing: null }); +}); + +test('falsy structured values are values: 0, false and null are kept and shown', async () => { + for (const value of [0, false, null, '']) { + const r = await normalizeStandardToolResult({ content: [], structuredContent: value }, 'L', null); + assert.ok('structured' in r, String(value)); + assert.equal(r.structured, value); + assert.equal(r.data, JSON.stringify(value)); + } +}); + +test('text plus structured: the server chose the model\'s text; structured rides beside it', async () => { + const r = await normalizeStandardToolResult({ + content: [{ type: 'text', text: 'Found 2 rows.' }], + structuredContent: { rows: [{ id: 1 }, { id: 2 }] }, + }, 'L', null); + assert.equal(r.data, 'Found 2 rows.'); + assert.deepEqual(r.structured, { rows: [{ id: 1 }, { id: 2 }] }); +}); + +test('an inline image stays native: the array is kept', async () => { + const r = await normalizeStandardToolResult({ + content: [{ type: 'text', text: 'see' }, { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }], + }, 'L', null); + assert.deepEqual(r.data, [{ type: 'text', text: 'see' }, { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }]); +}); + +test('audio and binary resources are saved to the workspace and described', async () => { + const { save, saved } = recordingSave(); + const r = await normalizeStandardToolResult({ + content: [ + { type: 'audio', data: b64('RIFFdata'), mimeType: 'audio/wav' }, + { type: 'resource', resource: { uri: 'memo://bin', mimeType: 'application/octet-stream', blob: b64('binary!') } }, + ], + }, '2026-10-08-call1', save); + assert.deepEqual(saved, [ + { fileName: '2026-10-08-call1-1.wav', bytes: 'RIFFdata', mimeType: 'audio/wav' }, + { fileName: '2026-10-08-call1-2.bin', bytes: 'binary!', mimeType: 'application/octet-stream' }, + ]); + assert.equal(r.data, + '[audio: audio/wav, 8 B, saved to workspace file home/tool-results/2026-10-08-call1-1.wav]\n' + + '[resource memo://bin: application/octet-stream, 7 B, saved to workspace file home/tool-results/2026-10-08-call1-2.bin]'); +}); + +test('without a workspace the stub says the payload was not shown, and why', async () => { + const r = await normalizeStandardToolResult({ content: [{ type: 'audio', data: b64('x'), mimeType: 'audio/mpeg' }] }, 'L', null); + assert.equal(r.data, "[audio: audio/mpeg, 1 B. Not shown: the model can't take this type, and no workspace is mounted to save it]"); +}); + +test('a resource link is shown as a reference and never fetched; embedded text is shown', async () => { + const r = await normalizeStandardToolResult({ + content: [ + { type: 'resource_link', uri: 'file:///srv/report.pdf', name: 'report.pdf', mimeType: 'application/pdf' }, + { type: 'resource', resource: { uri: 'memo://note', mimeType: 'text/plain', text: 'embedded note text' } }, + ], + }, 'L', null); + assert.equal(r.data, + '[resource link "report.pdf": file:///srv/report.pdf, application/pdf; not fetched]\n' + + '[resource memo://note, text/plain]\nembedded note text'); +}); + +test('a tool error is a failure with its text; structured survives', async () => { + const r = await normalizeStandardToolResult({ content: [{ type: 'text', text: 'nope' }], isError: true, structuredContent: { code: 7 } }, 'L', null); + assert.deepEqual(r, { success: false, error: 'nope', isError: true, structured: { code: 7 } }); +}); + +test('an unknown block is shown as data, and stub fields are bounded', async () => { + const long = `memo://${'x'.repeat(1000)}`; + const r = await normalizeStandardToolResult({ + content: [{ type: 'hologram', depth: 3 }, { type: 'resource_link', uri: long }], + }, 'L', null); + const [unknown, link] = String(r.data).split('\n'); + assert.equal(unknown, '{"type":"hologram","depth":3}'); + assert.ok(link!.length < 260, `bounded: ${link!.length}`); + assert.match(link!, /…; not fetched\]$/); +}); diff --git a/test/mcpl-list-status.test.ts b/test/mcpl-list-status.test.ts index 348275257..e30507121 100644 --- a/test/mcpl-list-status.test.ts +++ b/test/mcpl-list-status.test.ts @@ -42,6 +42,10 @@ test('listMcplServers exposes the live grant layers and host-owned authority', ( id: 'discord', connected: true, retrying: false, + family: 'legacy', + // The stub never handshook, so no revision is established. + protocolVersion: null, + transport: 'stdio', toolPrefix: 'mcpl--discord', toolCount: 1, policyEstablished: true, From b32cd73451ebadf4e3e783ea77b6ddbb8d15825e Mon Sep 17 00:00:00 2001 From: Olive-1804 Date: Thu, 8 Oct 2026 02:51:43 -0400 Subject: [PATCH 07/94] fix(mcp): integration review, connect ownership, result views, selectors, reap verdicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Theo-1805's source review of f9954a5 (room-284 #53865, #53879, #53922). Connect ownership. ModernMcpConnection is now create() (sync, validating) followed by start(). The framework registers and wires the connection before its first connect, so: - stop() and disconnect own and close a connect in flight; - a second connect for the same id is refused synchronously; - a connection closed mid-connect never finishes its setup; - a failed first connect is unregistered, and its cleanup failure is traced beside the connect error, not in its place. start() resolves unconnected when closed. open() launches nothing once closed. Result views stay separate, nothing lowered early: - Direct path (executeToolCall, ModuleContext.callTool). data is the raw content array again, as before. isError is now a failure (success: false, error text, raw content kept). structured is added by presence. - Model path. The structured JSON fallback keys on the server's own text blocks; generated stubs no longer suppress it. A payload that can be neither shown nor saved makes the result a failure to materialize ("result incomplete …; the tool may already have completed"), never a success with a lost payload. - Scripts. A script call returning structuredContent gets the direct reading (detected by the registered script waiter for its id). The script receives {content: , structuredContent, isError, error?} as valid JSON, so the image-plus-scores case keeps its image payload. Over 5 MB the whole object is saved to the workspace, and the script gets {isError, oversized: {chars, savedTo}}. If it can't be saved, an explicit materialization failure. Results without structuredContent keep the existing string contract, pinned by test (JSON-encoded "echo:x"). Selectors. A configuration naming no usable transport is an error for every config, in the framework and in McplServerConnection's handshake. That covers an http(s) url with transport 'websocket', transport 'http' with a ws url or no url (now "transport "http" requires "url""), and unknown schemes. There is no fall-through to another opener. Valid legacy shapes resolve as before. Reap verdicts. StdioTransport.close() is memoized (calls share one attempt) and rejects if the child is still alive 2 s after SIGKILL. ModernMcpConnection.close() closes transport first, then client: during discovery only the transport ends the probe. The transport's verdict is collected, not swallowed, and close() rethrows. stop() uses allSettled: each failure is logged and traced, and the rest of shutdown still runs. disconnect finishes its removal before reporting. Tests: - framework: - raw direct data (echo → [{type:'text'}], media → every block as sent); - an incomplete model result when no workspace is mounted; - an MCP-only legacy peer's model reading, with the structured fallback beside its stubs; - script objects for structured_and_text and scored_image (image payload intact), plus the pinned no-structured contract; - selector rejections; - stop() reaping a connect in flight while a second connect for the id is refused. - normalizer: the materialization failure (no save; failed save), and the structured fallback beside a stub. - outcomes: an unreapable child (signals suppressed) fails close() explicitly, and a second close() shares the verdict. Receipt (Lethe, Node v24.19.0, npx tsc build): every file standalone, 4-way (/tmp/olive-284/run-perfile2.sh, logs in /tmp/olive-284/logs/perfile2/): 134 files, 1445 tests, 1437 pass. The 8 failures were all in checkpoint-attribution, where a prototype-built fake framework has no scriptToolWaiters. Fixed with `?.`, and that file reran 10/10 (checkpoint-attribution.rerun.log). mcpl-coalescing completed 66/66 in this run. --- .../mcp-standard-tool-results.changed.md | 49 +++-- src/framework.ts | 172 ++++++++++++++---- src/mcpl/modern-connection.ts | 83 ++++++--- src/mcpl/protocol-family.ts | 2 + src/mcpl/server-connection.ts | 8 +- src/mcpl/tool-result-normalize.ts | 47 ++++- src/mcpl/transport.ts | 20 +- test/fixtures/modern-raw-server.mjs | 2 + test/fixtures/modern-tools.mjs | 5 + test/mcp-modern-framework.test.ts | 112 ++++++++++-- test/mcp-modern-outcomes.test.ts | 12 ++ test/mcp-protocol-family.test.ts | 1 + test/mcp-tool-result-normalize.test.ts | 28 ++- 13 files changed, 425 insertions(+), 116 deletions(-) diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index ae9cad54a..b88e890be 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -1,24 +1,39 @@ -- MCP tool results from servers that aren't MCPL peers (MCP-only legacy - servers and modern servers) are now read by their content types: +- What the model sees of an MCP tool result from a server that isn't an + MCPL peer (an MCP-only legacy server, or a modern server) now follows its + content types: - text stays text, and inline images stay native; - audio and binary resources are saved to the workspace (`tool-results/`), - with a bounded stub saying where; + with a bounded stub saying where. If a payload can't be saved, the result + is reported as incomplete (`isError`), noting the tool may already have + completed, never as a success with part of it missing; - a `resource_link` is shown as a reference and never fetched; - an embedded text resource shows its text. Before, any non-text block turned the whole result into raw JSON. - `structuredContent` is now kept by presence on the new - `ToolResult.structured`, so `false`, `0` and `null` are values. The model - sees it as JSON only when the content carries no text; a structured-only - result used to reach the model as an empty string. Scripts receive - `{ content, structuredContent, isError }` as one JSON object whenever a - result has structured content. MCPL peers keep their RFC-005 reading and - gain `structured`. + `structuredContent` is kept by presence on the new `ToolResult.structured` + (`false`, `0` and `null` are values). The model sees it as JSON when the + server's content has no text block of its own; a structured-only result + used to reach the model as an empty string. MCPL peers keep their RFC-005 + reading and gain `structured`. +- A script whose tool call returns `structuredContent` receives the + server's result itself, as one JSON object: `{ content, structuredContent, + isError, error? }`, with `content` the blocks as sent, so images and other + payloads survive. If it would exceed the 5 MB script cap, it is saved to the + workspace and the script gets `{ isError, oversized: { chars, savedTo } }`. + If it can't be saved, the script gets a failure that says the tool may have + completed. Results without structured content keep the existing script + contract. - The direct tool path (`executeToolCall`, `ModuleContext.callTool`) now - treats an MCP tool error (`isError: true`) as a failure (`success: false` - with the error text), as the model path does. Before, every answer counted - as success. Its `data` now has the model path's shape too: text joined into - a string, or an array when an image is present. -- Closing a stdio MCP/MCPL server now waits for the child to exit. A child - still alive 2 s after SIGTERM gets SIGKILL. Before, close returned as soon - as the signal was sent. + treats an MCP tool error (`isError: true`) as a failure, with + `success: false` and the error text; before, every answer counted as + success. `data` is still the raw content array, and `structured` is added + when the server sent one. +- Closing a stdio MCP/MCPL server now waits for the child to exit, sending + SIGKILL after 2 s. A child still alive 2 s after SIGKILL fails `close()` + with an explicit error. Before, close returned as soon as a signal was + sent. `stop()` reports such a failure and still finishes shutting + everything else down. +- A server configuration naming no usable transport is now an error before + anything is spawned or dialed. That covers a `transport` that doesn't + match the url's scheme, `transport: 'http'` without a url, and an + unrecognized scheme. Every valid configuration resolves as before. diff --git a/src/framework.ts b/src/framework.ts index 14f5cbe23..871718150 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2035,7 +2035,15 @@ export class AgentFramework { shutdownPromises.push(this.mcplServerRegistry.closeAll()); } shutdownPromises.push(this.closeModernMcpConnections()); - await Promise.all(shutdownPromises); + // Every part shuts down even if one fails (a child that could not be + // reaped, say); each failure is reported, and the rest of stop() runs. + for (const outcome of await Promise.allSettled(shutdownPromises)) { + if (outcome.status === 'rejected') { + const reason = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason); + console.error(`[shutdown] ${reason}`); + this.emitTrace({ type: 'mcpl:server-error', serverId: 'shutdown', error: reason }); + } + } // Streams may queue storage repairs while being cancelled above. Retry // after their teardown, while the store is still open, before final sync. @@ -11215,11 +11223,8 @@ export class AgentFramework { } const args = (call.input && typeof call.input === 'object') ? call.input as Record : {}; try { - // The same reading as the model path, so a tool error is a failure - // here too (this path used to report every answer as success). - if (modern) return await this.mcpToolResult(await modern.callTool(toolName, args), call.id, false); - const result = await server!.sendToolsCall(toolName, args); - return await this.mcpToolResult(result, call.id, server!.capabilities !== null); + const result = modern ? await modern.callTool(toolName, args) : await server!.sendToolsCall(toolName, args); + return AgentFramework.directToolResult(result); } catch (err) { return { success: false, @@ -11866,24 +11871,50 @@ export class AgentFramework { // Scripts get (nearly) full data — the script environment IS the spill: // filtering big results in code is the whole point. 5MB protocol safety // cap only; no context-cap truncation, no file spill. - if (structured) { - // Inside the object, text content is the text itself; arrays get the - // history rendering (images as placeholders). The same 5MB protocol - // safety cap applies, over the whole object. - const content = result.isError - ? (result.error ?? '') - : typeof result.data === 'string' - ? result.data - : result.data === undefined ? '' : toolResultDataToHistoryString(result.data, 5_000_000); - return truncateForHistory(JSON.stringify({ - content, - structuredContent: result.structured, - isError: result.isError === true, - }), 5_000_000); - } + if (structured) return this.scriptStructuredResult(result); return toolResultDataToHistoryString(result.data, 5_000_000); } + /** The 5MB protocol safety cap on what a script receives. */ + private static readonly SCRIPT_RESULT_MAX_CHARS = 5_000_000; + + /** + * A structured MCP result as a script receives it: one JSON object holding + * both of the server's views, `{ content, structuredContent, isError }`, + * plus `error` (the text) on a tool error. `content` is the content array + * as the server sent it (the dispatch gave this call the direct reading), + * so payloads and images survive. Past the size cap the result stays + * valid JSON: the whole object is saved to the workspace, and the script + * gets `{ isError, oversized: { chars, savedTo } }`. If it can't be saved, + * the script gets a failure to materialize the result, saying the tool may + * already have completed, never a success missing its result. + */ + private async scriptStructuredResult(result: ToolResult): Promise { + const object = { + content: Array.isArray(result.data) ? result.data : [], + structuredContent: result.structured, + isError: result.isError === true, + ...(result.isError ? { error: result.error ?? '' } : {}), + }; + const json = JSON.stringify(object); + if (json.length <= AgentFramework.SCRIPT_RESULT_MAX_CHARS) return json; + const workspace = this.getWorkspaceModule(); + const mount = workspace ? this.firstWritableMountName(workspace) : null; + if (workspace && mount) { + const path = `${mount}/tool-results/${new Date().toISOString().slice(0, 10)}-script-${randomUUID().slice(0, 8)}.json`; + const written = await workspace.writeBinary(path, Buffer.from(json, 'utf8'), 'application/json').catch(() => null); + if (written?.success) { + return JSON.stringify({ isError: object.isError, oversized: { chars: json.length, savedTo: path } }); + } + } + return JSON.stringify({ + isError: true, + error: `the result (${json.length} chars) exceeds the ${AgentFramework.SCRIPT_RESULT_MAX_CHARS}-char script result cap ` + + `and could not be saved to the workspace, so it could not be delivered; the tool may already have completed`, + oversized: { chars: json.length, savedTo: null }, + }); + } + /** * Dispatch a script-inner tool call through the NORMAL dispatch path and * resolve with its result. The waiter intercept in handleProcessEvent @@ -13381,7 +13412,10 @@ export class AgentFramework { private async closeModernMcpConnections(): Promise { const modern = [...this.modernServers().values()]; this.modernServers().clear(); - await Promise.all(modern.map((connection) => connection.close())); + const failures = (await Promise.allSettled(modern.map((connection) => connection.close()))) + .filter((o): o is PromiseRejectedResult => o.status === 'rejected') + .map((o) => (o.reason instanceof Error ? o.reason.message : String(o.reason))); + if (failures.length > 0) throw new Error(failures.join('; ')); } /** Reconcile the durable ledger with Chronicle, then deliver every server's work. */ @@ -13741,17 +13775,20 @@ export class AgentFramework { throw new Error('MCPL subsystem is not initialized'); } - // Configuration mistakes the family brought (protocol on a URL, a modern - // deadline of 0, MCPL policy on a modern server) fail here, before any - // routing is registered. A configuration that names no usable transport - // keeps the transport's own error, as before. - let family: 'legacy' | 'modern' | null = null; - try { family = resolveServerBinding(config).family; } catch { /* the transport reports it */ } - if (family !== null) { - const problems = serverConfigProblems(config); - if (problems.length > 0) { - throw new Error(`MCP server "${config.id}" configuration: ${problems.join('; ')}`); - } + // Configuration mistakes fail here, before any routing is registered: a + // combination naming no usable transport (an http(s) url with transport + // 'websocket', transport 'http' with a ws url or with no url at all), + // protocol on a URL, a modern deadline of 0, MCPL policy on a modern + // server. Every valid legacy shape resolves exactly as before. + let family: 'legacy' | 'modern'; + try { + family = resolveServerBinding(config).family; + } catch (error) { + throw new Error(`MCP server "${config.id}" configuration: ${error instanceof Error ? error.message : String(error)}`); + } + const problems = serverConfigProblems(config); + if (problems.length > 0) { + throw new Error(`MCP server "${config.id}" configuration: ${problems.join('; ')}`); } if (this.modernServers().has(config.id) || this.mcplServerRegistry.getServer(config.id)) { throw new Error(`MCPL server "${config.id}" is already registered`); @@ -13764,9 +13801,29 @@ export class AgentFramework { this.mcplServerConfigs.set(config.id, config); if (family === 'modern') { - const modern = await ModernMcpConnection.connect(config); + // Registered and wired before its first connect, so the connect in + // flight is already the framework's: stop() and disconnect close it, + // and a second connect for the same id is refused above. + const modern = ModernMcpConnection.create(config); this.modernServers().set(config.id, modern); this.wireModernEvents(modern); + try { + await modern.start(); + } catch (error) { + if (this.modernServers().get(config.id) === modern) this.modernServers().delete(config.id); + // The connect error is the one to report; a cleanup failure beside + // it is reported too, never in its place. + await modern.close().catch((cleanup: unknown) => { + this.emitTrace({ + type: 'mcpl:server-error', + serverId: config.id, + error: cleanup instanceof Error ? cleanup.message : String(cleanup), + }); + }); + throw error; + } + // Closed (stop, disconnect) while connecting: nothing more to set up. + if (this.modernServers().get(config.id) !== modern) return; this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` }); return; } @@ -14197,9 +14254,12 @@ export class AgentFramework { const oldToolNames = new Set(this.mcplTools.map(t => t.name)); const modern = this.modernServers().get(id); + // A child that can't be reaped fails the disconnect, but only after the + // rest of the removal is done. + let closeFailure: unknown = null; if (modern) { this.modernServers().delete(id); - await modern.close(); + await modern.close().catch((error: unknown) => { closeFailure = error; }); } await this.mcplServerRegistry.removeServer(id); this.channelRegistry?.removeServer(id); @@ -14217,6 +14277,7 @@ export class AgentFramework { await this.refreshMcplTools(); this.emitMcplToolDiff(oldToolNames, id); + if (closeFailure) throw closeFailure; } /** @@ -15012,10 +15073,20 @@ export class AgentFramework { }); }; + // A script's call that returns structured content gets the server's + // result itself (the direct reading): the script receives both views + // whole, so nothing the model reading lowers (stubs, image placeholders, + // generated JSON) stands in for what the server sent. + const forScript = this.scriptToolWaiters?.has(call.id) ?? false; + const reading = (result: { content?: unknown; isError?: boolean; structuredContent?: unknown }, mcpl: boolean) => + forScript && Object.prototype.hasOwnProperty.call(result, 'structuredContent') + ? Promise.resolve(AgentFramework.directToolResult(result)) + : this.mcpToolResult(result, call.id, mcpl); + if (modern) { // Modern MCP: no state exchange, no channels, no RFC-005 references. modern.callTool(toolName, args) - .then((result) => this.mcpToolResult(result, call.id, false)) + .then((result) => reading(result, false)) .then((result) => { this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs: Date.now() - startTime }); this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: `mcpl:${serverId}`, result }); @@ -15084,7 +15155,7 @@ export class AgentFramework { await this.autoFetchReferences(result.content, serverId, true) .catch((e) => console.error('[rfc005] autofetch error:', (e as Error).message)); } - return { result, toolResult: await this.mcpToolResult(result, call.id, mcplPeer) }; + return { result, toolResult: await reading(result, mcplPeer) }; }) .then(({ result, toolResult }) => { const durationMs = Date.now() - startTime; @@ -15167,8 +15238,31 @@ export class AgentFramework { } /** - * The framework's ToolResult for an MCP tool result, on both dispatch - * paths (model and direct). A standard peer, meaning an MCP-only legacy + * The direct path's reading of an MCP tool result (executeToolCall, + * ModuleContext.callTool). `data` stays the raw content array, as module + * callers have always received it. Two things change: a tool error + * (`isError: true`) is now a failure, where every answer used to count as + * success, and `structured` is added by presence. + */ + private static directToolResult(result: { content?: unknown; isError?: boolean; structuredContent?: unknown }): ToolResult { + const structured = Object.prototype.hasOwnProperty.call(result, 'structuredContent') + ? { structured: result.structuredContent } + : {}; + if (result.isError) { + const text = (Array.isArray(result.content) ? result.content : []) + .filter((b): b is { type: 'text'; text: string } => !!b && (b as { type?: unknown }).type === 'text' && typeof (b as { text?: unknown }).text === 'string') + .map((b) => b.text) + .join('\n'); + // The raw content rides along on a failure too: it is the server's + // own account of the error, for a program that wants more than text. + return { success: false, data: result.content, error: text || 'Tool call failed', isError: true, ...structured }; + } + return { success: true, data: result.content, ...structured }; + } + + /** + * The framework's ToolResult for an MCP tool result on the model path + * (model dispatch, and scripts, which ride it). A standard peer, meaning an MCP-only legacy * server or any modern server, gets the standard reading * (tool-result-normalize.ts): typed content blocks, workspace-saved * payloads, and `structured` kept by presence. An MCPL peer keeps the diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 4df032dc0..98646e724 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -349,23 +349,38 @@ export class ModernMcpConnection extends EventEmitter { } /** - * Connect, or with `reconnect` set, return a connection that keeps - * retrying in the background when the first attempt fails (the legacy - * engine's contract). Configuration errors always throw. + * A connection that hasn't started: configuration is validated (and + * throws), nothing is launched or dialed. An owner can register it, and + * wire its events, before {@link start}, so a connect in flight is + * already the owner's to close. */ - static async connect(config: McplServerConfig): Promise { - const connection = new ModernMcpConnection(config); + static create(config: McplServerConfig): ModernMcpConnection { + return new ModernMcpConnection(config); + } + + /** + * The first connect. Without `reconnect` a failure throws. With it, the + * connection keeps retrying in the background, as the legacy engine's + * stub does, and reports `connect-failed`. A connection closed before or + * during its first connect resolves unconnected. + */ + async start(): Promise { try { - await connection.open(); + await this.open(); } catch (error) { - if (!connection.reconnectEnabled) throw error; - console.error(`MCP server "${config.id}" initial connect failed, will retry:`, (error as Error).message); - connection.reconnectAttempts = 1; - // A macrotask, so the caller has wired its listeners (synchronously, - // right after this resolves) before the event fires. - setImmediate(() => connection.emit('connect-failed', { error: (error as Error).message, attempt: 0 })); - connection.scheduleReconnect(); + if (this.closedByHost) return; + if (!this.reconnectEnabled) throw error; + console.error(`MCP server "${this.id}" initial connect failed, will retry:`, (error as Error).message); + this.reconnectAttempts = 1; + this.emit('connect-failed', { error: (error as Error).message, attempt: 0 }); + this.scheduleReconnect(); } + } + + /** {@link create} and {@link start} in one step. */ + static async connect(config: McplServerConfig): Promise { + const connection = ModernMcpConnection.create(config); + await connection.start(); return connection; } @@ -375,6 +390,8 @@ export class ModernMcpConnection extends EventEmitter { /** One connect generation: a fresh client over a fresh transport. */ private async open(): Promise { + // Closed before this connect began: launch nothing. + if (this.closedByHost) return; const generation = ++this.generation; this.cancelRelisten(); const inner: Transport = this.transportKind === 'stdio' @@ -415,16 +432,16 @@ export class ModernMcpConnection extends EventEmitter { try { await done; } catch (error) { - await client.close().catch(() => {}); - await wire.close().catch(() => {}); + const failure = await ModernMcpConnection.closeSession(session); + if (failure) this.emit('error', failure); throw error; } finally { if (this.opening?.session === session) this.opening = null; } if (!this.isCurrent(generation)) { // Superseded or closed while connecting: this generation never serves. - await client.close().catch(() => {}); - await wire.close().catch(() => {}); + const failure = await ModernMcpConnection.closeSession(session); + if (failure) this.emit('error', failure); return; } this.session = session; @@ -593,28 +610,50 @@ export class ModernMcpConnection extends EventEmitter { } } + /** + * Close one generation: the transport first (during discovery the SDK + * hasn't attached it yet, so only closing it ends the probe), then the + * client. The transport's close is the reaping verdict, and its failure is + * returned, never swallowed. + */ + private static async closeSession(session: Session): Promise { + let failure: Error | null = null; + try { + await session.wire.close(); + } catch (error) { + failure = error instanceof Error ? error : new Error(String(error)); + } + await session.client.close().catch(() => { /* SDK teardown; the verdict is the transport's */ }); + return failure; + } + /** Close for good: no reconnect, no reopened subscription, no connect left - * in flight. Awaits the in-flight connect's end. */ + * in flight. Awaits the in-flight connect's end, and rejects if a child + * could not be reaped. */ async close(): Promise { this.closedByHost = true; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = null; this.cancelRelisten(); + const failures: Error[] = []; const opening = this.opening; if (opening) { // Closing the transport ends the probe; the connect then rejects and // its own cleanup runs. Wait for that, so nothing outlives close(). - await opening.session.wire.close().catch(() => {}); - await opening.session.client.close().catch(() => {}); + const failure = await ModernMcpConnection.closeSession(opening.session); + if (failure) failures.push(failure); await opening.done.catch(() => {}); } const session = this.session; this.session = null; if (session) { - await session.client.close().catch(() => {}); - await session.wire.close().catch(() => {}); + const failure = await ModernMcpConnection.closeSession(session); + if (failure) failures.push(failure); this.emit('close', { reason: 'closed by host' }); } + if (failures.length > 0) { + throw new Error(`MCP server "${this.id}" did not close cleanly: ${failures.map((f) => f.message).join('; ')}`); + } } /** diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index 5037d1205..19cc96a60 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -98,6 +98,8 @@ export function resolveServerBinding(config: BindingFields): ServerBinding { `MCP server "${config.id}": transport "${config.transport}" does not match url "${config.url}"`, ); } + } else if (config.transport !== undefined) { + throw new Error(`MCP server "${config.id}": transport "${config.transport}" requires "url"`); } else { throw new Error(`MCP server "${config.id}": needs "command" (stdio) or "url" (WebSocket or HTTP)`); } diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 66a2949c0..2e0a03559 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -601,10 +601,10 @@ export class McplServerConnection extends EventEmitter { ): Promise<{ transport: McplTransport; capabilities: McplCapabilities | null; droppedCapabilities: ReadonlySet; mcpToolsAdvertised: boolean; protocolVersion: string }> { // This engine speaks the legacy family only. A configuration that // resolves to the modern family belongs to ModernMcpConnection; refuse it - // here rather than dialing an http(s) url as a WebSocket. A configuration - // that doesn't resolve at all keeps openTransport's existing errors. - let family: string | null = null; - try { family = resolveServerBinding(config).family; } catch { /* openTransport reports it */ } + // here rather than dialing an http(s) url as a WebSocket. One that names + // no usable transport is an error too, before anything is spawned or + // dialed. + const family = resolveServerBinding(config).family; if (family === 'modern') { throw new Error( `MCP server "${config.id}" is configured for modern MCP (${MODERN_MCP_PROTOCOL_VERSION}); ` + diff --git a/src/mcpl/tool-result-normalize.ts b/src/mcpl/tool-result-normalize.ts index 4b82ed85a..2f229c4e5 100644 --- a/src/mcpl/tool-result-normalize.ts +++ b/src/mcpl/tool-result-normalize.ts @@ -9,13 +9,18 @@ * and inline images stay native. Anything the provider format can't carry * (audio, binary blobs) is saved to the workspace, and a bounded stub says * where it went: a stub tells the reader where the payload is; it never - * replaces it silently. A `resource_link` is shown as a reference and never + * replaces it silently. If a payload can't be saved, the result is a + * failure to materialize it, not a success. A `resource_link` is shown as a reference and never * fetched. An embedded resource shows its text, or is saved like a blob. * - **`structuredContent` is the machine-readable view.** It is kept by - * presence on `ToolResult.structured`, for scripts and modules. The model - * sees it rendered as JSON only when the content carries no text, because + * presence on `ToolResult.structured`. The model sees it rendered as JSON + * only when the server's content has no text block of its own, because * otherwise the server already chose what the model reads. * + * This is the model's reading. Programs get the server's result itself: + * the direct path returns the raw content, and a script receives a + * structured result whole (framework.ts). + * * Before this, a structured-only result reached the model as an empty string, * and a mixed result became one JSON string with its image as base64 text * (room-284 probe P1c). @@ -63,7 +68,8 @@ export interface StandardToolResult { /** * Normalize a standard MCP tool result into the framework's `ToolResult`. * `label` names saved payloads (one per call; payloads get an index suffix). - * Without `save`, payloads are described but not saved, and the stub says so. + * A payload that can't be retained (no `save`, or saving failed) makes the + * result a failure to materialize, never a success with part of it lost. */ export async function normalizeStandardToolResult( result: StandardToolResult, @@ -73,16 +79,21 @@ export async function normalizeStandardToolResult( const raw = Array.isArray(result.content) ? result.content : []; const blocks: Block[] = []; let saved = 0; + /** Payloads that could be neither shown nor retained. */ + let lost = 0; const payloadStub = async (kind: string, base64: string, mimeType: string, origin?: string): Promise => { const size = humanSize(decodedSize(base64)); const what = `[${kind}${origin ? ` ${bounded(origin)}` : ''}: ${bounded(mimeType)}, ${size}`; - if (!save) return `${what}. Not shown: the model can't take this type, and no workspace is mounted to save it]`; + if (!save) { + lost++; + return `${what}. Not retained: the model can't take this type, and no workspace is mounted to save it]`; + } const path = await save(`${label}-${++saved}.${extensionFor(mimeType)}`, Buffer.from(base64, 'base64'), mimeType) .catch(() => null); - return path - ? `${what}, saved to workspace file ${path}]` - : `${what}. Not shown: the model can't take this type, and saving it to the workspace failed]`; + if (path) return `${what}, saved to workspace file ${path}]`; + lost++; + return `${what}. Not retained: the model can't take this type, and saving it to the workspace failed]`; }; for (const entry of raw) { @@ -131,8 +142,13 @@ export async function normalizeStandardToolResult( } const hasStructured = Object.prototype.hasOwnProperty.call(result, 'structuredContent'); - const hasText = blocks.some((b) => b.type === 'text' && b.text.trim().length > 0); - if (hasStructured && !hasText) { + // Only the server's own text blocks count as its presentation for the + // model. Stubs this module wrote are notices, not the server's text. + const serverText = raw.some((entry) => { + const block = entry as { type?: unknown; text?: unknown } | null; + return !!block && block.type === 'text' && typeof block.text === 'string' && block.text.trim().length > 0; + }); + if (hasStructured && !serverText) { blocks.push({ type: 'text', text: JSON.stringify(result.structuredContent) ?? 'null' }); } @@ -141,6 +157,17 @@ export async function normalizeStandardToolResult( if (result.isError) { return { success: false, error: text || 'Tool call failed', isError: true, ...structured }; } + if (lost > 0) { + // The server answered, but part of its answer is gone: report that as a + // failure to materialize the result, never as a success. + return { + success: false, + error: `[result incomplete: ${lost} payload(s) could not be retained, so this result is not complete; ` + + `the tool may already have completed]\n${text}`, + isError: true, + ...structured, + }; + } const hasImage = blocks.some((b) => b.type === 'image'); return { success: true, diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index d344e8949..ab36316ba 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -222,13 +222,22 @@ export class StdioTransport extends McplTransport { this.child.stdin?.write(json + '\n'); } + private closing: Promise | null = null; + /** * Close and reap: resolves once the child has actually exited, not merely * been signalled (`child.killed` is true as soon as a signal is sent). A - * child that outlives SIGTERM by {@link STDIO_EXIT_GRACE_MS} gets SIGKILL, - * and close waits that long again, so shutdown stays bounded. + * child that outlives SIGTERM by {@link STDIO_EXIT_GRACE_MS} gets SIGKILL. + * If it still hasn't exited after that bound again, close rejects: an + * explicit cleanup failure, rather than waiting forever or claiming an + * exit that wasn't seen. Calls share one attempt. */ - async close(): Promise { + close(): Promise { + this.closing ??= this.reap(); + return this.closing; + } + + private async reap(): Promise { this.markClosed({ reason: 'closed by host' }); this.rl.close(); const child = this.child; @@ -244,7 +253,10 @@ export class StdioTransport extends McplTransport { if (await exitWithin(STDIO_EXIT_GRACE_MS)) return; console.error(`[mcpl] stdio child ${child.pid} ignored SIGTERM for ${STDIO_EXIT_GRACE_MS}ms — sending SIGKILL`); child.kill('SIGKILL'); - await exitWithin(STDIO_EXIT_GRACE_MS); + if (await exitWithin(STDIO_EXIT_GRACE_MS)) return; + throw new Error( + `stdio child ${child.pid} did not exit within ${STDIO_EXIT_GRACE_MS}ms of SIGKILL; it could not be reaped`, + ); } } diff --git a/test/fixtures/modern-raw-server.mjs b/test/fixtures/modern-raw-server.mjs index f63c1a82c..cc4a93210 100644 --- a/test/fixtures/modern-raw-server.mjs +++ b/test/fixtures/modern-raw-server.mjs @@ -7,6 +7,7 @@ // invalid-schema tools/list advertises an outputSchema that doesn't compile // listen-fail-first the first subscriptions/listen of each launch is refused // hang-discover-later launches after the first never answer server/discover +// hang-discover no launch ever answers server/discover // ignore-sigterm the process survives SIGTERM (only SIGKILL ends it) // Tools: op (outputSchema {value: integer}, answers with a string: invalid // structured content), plain (text), err (a JSON-RPC error), touch (announces @@ -40,6 +41,7 @@ createInterface({ input: process.stdin }).on('line', (line) => { switch (m.method) { case 'server/discover': if (flags.has('hang-discover-later') && launch > 1) return; + if (flags.has('hang-discover')) return; return result(m.id, { supportedVersions: ['2026-07-28'], capabilities: { tools: { listChanged: true } }, diff --git a/test/fixtures/modern-tools.mjs b/test/fixtures/modern-tools.mjs index d4e28a724..d156277a6 100644 --- a/test/fixtures/modern-tools.mjs +++ b/test/fixtures/modern-tools.mjs @@ -28,6 +28,11 @@ export function createServer(opts = {}) { async () => ({ content: [{ type: 'text', text: 'Found 2 rows.' }], structuredContent: { rows: [{ id: 1 }, { id: 2 }] } })); server.registerTool('structured_zero', { description: 'Falsy structured value', inputSchema: obj() }, async () => ({ content: [], structuredContent: 0 })); + server.registerTool('scored_image', { description: 'An image with machine-readable scores', inputSchema: obj() }, + async () => ({ + content: [{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }], + structuredContent: { score: 0.9, label: 'cat' }, + })); server.registerTool('media', { description: 'Mixed standard content', inputSchema: obj() }, async () => ({ content: [ diff --git a/test/mcp-modern-framework.test.ts b/test/mcp-modern-framework.test.ts index c6b25e5dc..8636e9eb7 100644 --- a/test/mcp-modern-framework.test.ts +++ b/test/mcp-modern-framework.test.ts @@ -95,29 +95,47 @@ test('modern tools are listed under the prefix, and status names family, revisio }); }); -test('direct path: structured kept by presence, tool errors fail, media is described', async () => { +test('direct path: data stays the raw content; tool errors fail; structured by presence', async () => { await withModern(async ({ framework }) => { const only = await framework.executeToolCall(call('mcpl--modern--structured_only')); assert.equal(only.success, true); + assert.deepEqual(only.data, [], 'the raw content array, as module callers have always received it'); assert.deepEqual(only.structured, { answer: 42, ok: false, nothing: null }); - assert.equal(only.data, '{"answer":42,"ok":false,"nothing":null}'); const zero = await framework.executeToolCall(call('mcpl--modern--structured_zero')); assert.ok('structured' in zero); assert.equal(zero.structured, 0); + const echo = await framework.executeToolCall(call('mcpl--modern--echo', { text: 'x' })); + assert.deepEqual(echo, { success: true, data: [{ type: 'text', text: 'echo:x' }] }); + const fail = await framework.executeToolCall(call('mcpl--modern--fail')); assert.equal(fail.success, false); assert.equal(fail.isError, true); assert.equal(fail.error, 'the tool failed on purpose'); const media = await framework.executeToolCall(call('mcpl--modern--media')); - assert.ok(Array.isArray(media.data), 'an image keeps the array'); - const text = (media.data as Array<{ type: string; text?: string }>).filter((b) => b.type === 'text').map((b) => b.text).join('\n'); - assert.match(text, /\[resource link "report\.pdf": file:\/\/\/srv\/report\.pdf, application\/pdf; not fetched\]/); + assert.deepEqual((media.data as Array<{ type: string }>).map((b) => b.type), + ['text', 'resource_link', 'resource', 'resource', 'audio', 'image'], 'every block as sent'); + }); +}); + +test('model path: a payload the host cannot retain is reported as an incomplete result', async () => { + await withModern(async ({ framework, membrane }) => { + framework.start(); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'toolu_01MODERNMEDIA00000000000', name: 'mcpl--modern--media', input: {} }, + ] as never, 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }])); + framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as never); + await waitFor('the tool round', () => (membrane.lastStream?.receivedToolResults.length ?? 0) >= 1); + const [delivered] = membrane.lastStream!.receivedToolResults[0] as Array<{ content: unknown; isError: boolean }>; + // No workspace here: audio and the blob can be neither shown nor kept. + assert.equal(delivered!.isError, true); + const text = JSON.stringify(delivered!.content); + assert.match(text, /result incomplete: 2 payload\(s\) could not be retained/); + assert.match(text, /resource link \\"report\.pdf\\": file:\/\/\/srv\/report\.pdf, application\/pdf; not fetched/); assert.match(text, /embedded note text/); - assert.match(text, /\[audio: audio\/wav, \d+ B\. Not shown/); - assert.ok((media.data as Array<{ type: string }>).some((b) => b.type === 'image')); }); }); @@ -144,9 +162,21 @@ test('scripts get structured results whole, as one JSON object', async () => { handleScriptToolCall: (agent: string, tool: string, args: Record) => Promise; }).handleScriptToolCall.bind(framework); const out = JSON.parse(await handle('agent', 'mcpl--modern--structured_and_text', {})); - assert.deepEqual(out, { content: 'Found 2 rows.', structuredContent: { rows: [{ id: 1 }, { id: 2 }] }, isError: false }); - // Without structured content the script contract is unchanged: the - // history rendering, which JSON-encodes a text result. + assert.deepEqual(out, { + content: [{ type: 'text', text: 'Found 2 rows.' }], + structuredContent: { rows: [{ id: 1 }, { id: 2 }] }, + isError: false, + }); + // Image plus scores: both views reach the script whole, the image's + // payload included (no placeholder stands in for it). + const scored = JSON.parse(await handle('agent', 'mcpl--modern--scored_image', {})); + assert.deepEqual(scored, { + content: [{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }], + structuredContent: { score: 0.9, label: 'cat' }, + isError: false, + }); + // Without structured content the script contract is unchanged (pinned): + // the history rendering, which JSON-encodes a text result. assert.equal(await handle('agent', 'mcpl--modern--echo', { text: 'x' }), JSON.stringify('echo:x')); }); }); @@ -161,7 +191,21 @@ test('MCPL policy on a modern server is a configuration error at connect', async framework.connectMcplServer({ id: 'bad2', url: 'https://example.invalid/mcp', protocol: 'modern' }), /applies only to stdio/, ); - assert.ok(!framework.listMcplServers().some((s) => s.id === 'bad' || s.id === 'bad2'), 'nothing registered'); + // Selector combinations that name no usable transport are errors too, + // not a fall-through to some other opener. + await assert.rejects( + framework.connectMcplServer({ id: 'bad3', url: 'https://example.invalid/mcp', transport: 'websocket' }), + /does not match url/, + ); + await assert.rejects( + framework.connectMcplServer({ id: 'bad4', url: 'ws://127.0.0.1:1/mcpl', transport: 'http' }), + /does not match url/, + ); + await assert.rejects( + framework.connectMcplServer({ id: 'bad5', command: process.execPath, transport: 'http' }), + /transport "http" requires "url"/, + ); + assert.ok(!framework.listMcplServers().some((s) => s.id.startsWith('bad')), 'nothing registered'); }); }); @@ -188,11 +232,19 @@ process.stdin.on('data', (c) => { } }); `; - await withModern(async ({ framework }) => { - const result = await framework.executeToolCall(call('mcpl--plain--docs')); - assert.equal(result.success, true); - assert.deepEqual(result.structured, { count: 2 }); - assert.equal(result.data, '[resource link "a.md": https://example.invalid/a.md; not fetched]\n[resource memo://n, text/plain]\nnote body'); + await withModern(async ({ framework, membrane }) => { + framework.start(); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'toolu_01LEGACYDOCS000000000000', name: 'mcpl--plain--docs', input: {} }, + ] as never, 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }])); + framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as never); + await waitFor('the tool round', () => (membrane.lastStream?.receivedToolResults.length ?? 0) >= 1); + const [delivered] = membrane.lastStream!.receivedToolResults[0] as Array<{ content: string; isError: boolean }>; + assert.equal(delivered!.isError, false); + // No server text block: the structured value is shown beside the stubs. + assert.equal(JSON.parse(delivered!.content), + '[resource link "a.md": https://example.invalid/a.md; not fetched]\n[resource memo://n, text/plain]\nnote body\n{"count":2}'); const status = framework.listMcplServers().find((s) => s.id === 'plain')!; assert.equal(status.family, 'legacy'); assert.equal(status.protocolVersion, '2024-11-05'); @@ -252,7 +304,33 @@ test('quiesce: a lost modern child reconnects and relists without starting a tur assert.equal(membrane.calls.length, callsBefore, 'no turn started'); assert.equal(framework.getHostModeStatus().gatedRequests, gatedBefore, 'no wake parked: the inventory is unchanged'); - assert.deepEqual((await framework.executeToolCall(call('mcpl--modern--echo', { text: 'back' }))).data, 'echo:back'); + assert.deepEqual((await framework.executeToolCall(call('mcpl--modern--echo', { text: 'back' }))).data, [{ type: 'text', text: 'echo:back' }]); await framework.resume(); }); }); + +test('the framework owns a modern connect in flight: stop() ends it, and a second connect is refused', async () => { + const RAW = join(here, 'fixtures', 'modern-raw-server.mjs'); + const dir = mkdtempSync(join(tmpdir(), 'modern-framework-')); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], + modules: [], + }); + try { + const log = join(dir, 'hang.jsonl'); + const config = { id: 'hang', command: process.execPath, args: [RAW, 'hang-discover', log], protocol: 'modern' as const }; + const pending = framework.connectMcplServer(config); + await waitFor('the launch waiting on discover', () => lines(log).some((l) => l.includes('server/discover'))); + await assert.rejects(framework.connectMcplServer(config), /already registered/); + const pid = (JSON.parse(lines(log)[0]!) as { pid: number }).pid; + await framework.stop(); + const alive = (p: number) => { try { process.kill(p, 0); return true; } catch { return false; } }; + assert.equal(alive(pid), false, 'the launch in flight was reaped by stop()'); + await pending.catch(() => {}); + assert.ok(!framework.listMcplServers().some((s) => s.id === 'hang' && s.connected), 'nothing installed after stop'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts index 0f5f45ab0..9877fa63c 100644 --- a/test/mcp-modern-outcomes.test.ts +++ b/test/mcp-modern-outcomes.test.ts @@ -18,6 +18,7 @@ import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ModernMcpConnection } from '../src/mcpl/modern-connection.js'; +import { StdioTransport } from '../src/mcpl/transport.js'; import { McplRequestError } from '../src/mcpl/server-connection.js'; import type { McplServerConfig } from '../src/mcpl/types.js'; @@ -207,3 +208,14 @@ test('close() reaps: a child that ignores SIGTERM is killed before close resolve assert.equal(alive(pid), false, 'dead when close() resolved'); assert.ok(wire(log).some((e) => e.event === 'sigterm-ignored'), 'it really did ignore SIGTERM'); }); + +test('a child that cannot be reaped fails close() explicitly, never a claimed exit', async () => { + const transport = StdioTransport.spawn({ id: 'stuck', command: process.execPath, args: ['-e', 'setInterval(() => {}, 1 << 30)'] }); + const child = (transport as unknown as { child: { pid: number; kill: (signal?: string) => boolean } }).child; + const realKill = child.kill.bind(child); + child.kill = () => true; // signals never arrive: the child outlives both bounds + cleanups.push(() => { realKill('SIGKILL'); }); + await assert.rejects(transport.close(), /did not exit within 2000ms of SIGKILL; it could not be reaped/); + // Calls share the one attempt and its verdict. + await assert.rejects(transport.close(), /could not be reaped/); +}); diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index cd53aba66..5b104335c 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -44,6 +44,7 @@ test('a configuration that names no usable transport throws', () => { assert.throws(() => resolveServerBinding(cfg({ url: 'ws://x', transport: 'http' })), /does not match url/); assert.throws(() => resolveServerBinding(cfg({ url: 'https://x', transport: 'websocket' })), /does not match url/); assert.throws(() => resolveServerBinding(cfg({ url: 'ws://x', transport: 'stdio' })), /requires "command"/); + assert.throws(() => resolveServerBinding(cfg({ command: 'srv', transport: 'http' })), /transport "http" requires "url"/); }); test('every legacy configuration shape passes validation unchanged', () => { diff --git a/test/mcp-tool-result-normalize.test.ts b/test/mcp-tool-result-normalize.test.ts index a5457e9f8..955669120 100644 --- a/test/mcp-tool-result-normalize.test.ts +++ b/test/mcp-tool-result-normalize.test.ts @@ -77,9 +77,31 @@ test('audio and binary resources are saved to the workspace and described', asyn '[resource memo://bin: application/octet-stream, 7 B, saved to workspace file home/tool-results/2026-10-08-call1-2.bin]'); }); -test('without a workspace the stub says the payload was not shown, and why', async () => { - const r = await normalizeStandardToolResult({ content: [{ type: 'audio', data: b64('x'), mimeType: 'audio/mpeg' }] }, 'L', null); - assert.equal(r.data, "[audio: audio/mpeg, 1 B. Not shown: the model can't take this type, and no workspace is mounted to save it]"); +test('a payload that cannot be retained makes the result a failure to materialize, not a success', async () => { + const r = await normalizeStandardToolResult({ + content: [{ type: 'text', text: 'here is the clip' }, { type: 'audio', data: b64('x'), mimeType: 'audio/mpeg' }], + }, 'L', null); + assert.equal(r.success, false); + assert.equal(r.isError, true); + assert.equal(r.error, + '[result incomplete: 1 payload(s) could not be retained, so this result is not complete; the tool may already have completed]\n' + + 'here is the clip\n' + + "[audio: audio/mpeg, 1 B. Not retained: the model can't take this type, and no workspace is mounted to save it]"); + const failedSave = await normalizeStandardToolResult( + { content: [{ type: 'resource', resource: { uri: 'memo://b', blob: b64('zz') } }] }, + 'L', + async () => null, + ); + assert.equal(failedSave.success, false); + assert.match(failedSave.error!, /saving it to the workspace failed/); +}); + +test('generated stubs are not the server\'s text: structured still shows beside them', async () => { + const r = await normalizeStandardToolResult({ + content: [{ type: 'resource_link', uri: 'file:///r.csv', name: 'r.csv' }], + structuredContent: { rows: 3 }, + }, 'L', null); + assert.equal(r.data, '[resource link "r.csv": file:///r.csv; not fetched]\n{"rows":3}'); }); test('a resource link is shown as a reference and never fetched; embedded text is shown', async () => { From 477d993b65388a78b07dbd79f9a011f3110c3a7d Mon Sep 17 00:00:00 2001 From: Olive-1804 Date: Thu, 8 Oct 2026 03:04:59 -0400 Subject: [PATCH 08/94] fix(mcp): an unreapable failed launch halts reconnecting instead of orphaning it Theo-1805's lifetime point on b32cd73 (room-284 #53928). A failed connect's cleanup could log a reap failure and let the reconnect loop start another generation, losing ownership of a child that might still be running. That is the same no-orphan contract close() keeps. - ModernMcpConnection keeps every generation whose cleanup couldn't confirm its child exited (`unreaped`), and emits an 'error' that says so. - While any is held, willReconnect is false. No replacement launch starts: the failure is reported as reconnect-failed / connect-failed with `permanent: true`, and the message names the unreaped launch. - close() retries each held generation and reports whatever still fails. - StdioTransport.close() re-checks a failed verdict on a later call: a child that has exited since counts as reaped. No new signals are sent. - The framework's modern failure handler raises the ops alert at once on `permanent`, since no later attempt will come. Test (mcp-modern-outcomes): the first launch serves. Later launches answer server/discover with an error and have their signals suppressed, so the cleanup after the failed reconnect can't reap. The result is a permanent reconnect-failed naming the halt, willReconnect false, and no third launch. Once the stuck child is gone, close() settles cleanly. Receipt (VPS via on-vps, node v24.21.0, npm 11.19.0; sources b32cd73 plus these changes, sha256 942e8b902834ecf2): the seven modern/legacy test files 64/64. --- src/framework.ts | 6 ++-- src/mcpl/modern-connection.ts | 44 +++++++++++++++++++++++++++-- src/mcpl/transport.ts | 15 ++++++++-- test/fixtures/modern-raw-server.mjs | 2 ++ test/mcp-modern-outcomes.test.ts | 37 ++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 871718150..d544acefe 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -14746,7 +14746,7 @@ export class AgentFramework { this.emitTrace({ type: 'module:added', moduleName: `mcpl:${connection.id}` }); this.handleToolsListChanged(connection.id); }); - const failed = (params: { error: string; attempt: number }) => { + const failed = (params: { error: string; attempt: number; permanent?: boolean }) => { this.emitTrace({ type: 'mcpl:server-connect-failed', serverId: connection.id, @@ -14754,7 +14754,9 @@ export class AgentFramework { attempt: params.attempt, willRetry: connection.willReconnect, }); - if (params.attempt >= 5) { + // `permanent`: reconnecting halted on a launch that couldn't be reaped. + // No later attempt will come, so alert now. + if (params.permanent || params.attempt >= 5) { this.opsAlert('mcpl-down', connection.id, `MCP server unreachable (attempt ${params.attempt}): ${params.error}`); } }; diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 98646e724..485a3ee70 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -295,6 +295,12 @@ export class ModernMcpConnection extends EventEmitter { /** At most one pending reopen, and only ever for the current generation. */ private relisten: { generation: number; timer: ReturnType } | null = null; private relistenAttempts = 0; + /** + * Generations whose cleanup could not confirm their child exited. While any + * is held, no replacement is started: launching another would orphan the + * first. close() retries them and reports what still fails. + */ + private unreaped: Array<{ session: Session; failure: Error }> = []; /** Child stderr from before anyone listens (the caller attaches only once * connect() returns), delivered to the first listener. Bounded: the * newest lines are kept. */ @@ -345,7 +351,22 @@ export class ModernMcpConnection extends EventEmitter { /** Whether a lost connection will be re-established in the background. */ get willReconnect(): boolean { - return this.reconnectEnabled && !this.closedByHost; + return this.reconnectEnabled && !this.closedByHost && this.unreaped.length === 0; + } + + /** A failed generation's cleanup couldn't reap its child: keep owning it, + * and halt reconnecting until close() settles it. */ + private holdUnreaped(session: Session, failure: Error): void { + this.unreaped.push({ session, failure }); + this.emit('error', new Error( + `MCP server "${this.id}": ${failure.message}. Not starting another launch while it may still be running; close the connection to retry the reap.`, + )); + } + + /** The halt, as a permanent failure for the reconnect events. */ + private unreapedFailure(): string | null { + const held = this.unreaped[0]; + return held ? `the previous launch could not be reaped (${held.failure.message}); reconnecting is halted` : null; } /** @@ -370,6 +391,11 @@ export class ModernMcpConnection extends EventEmitter { } catch (error) { if (this.closedByHost) return; if (!this.reconnectEnabled) throw error; + const halted = this.unreapedFailure(); + if (halted) { + this.emit('connect-failed', { error: `${(error as Error).message}; ${halted}`, attempt: 0, permanent: true }); + return; + } console.error(`MCP server "${this.id}" initial connect failed, will retry:`, (error as Error).message); this.reconnectAttempts = 1; this.emit('connect-failed', { error: (error as Error).message, attempt: 0 }); @@ -433,7 +459,7 @@ export class ModernMcpConnection extends EventEmitter { await done; } catch (error) { const failure = await ModernMcpConnection.closeSession(session); - if (failure) this.emit('error', failure); + if (failure) this.holdUnreaped(session, failure); throw error; } finally { if (this.opening?.session === session) this.opening = null; @@ -441,7 +467,7 @@ export class ModernMcpConnection extends EventEmitter { if (!this.isCurrent(generation)) { // Superseded or closed while connecting: this generation never serves. const failure = await ModernMcpConnection.closeSession(session); - if (failure) this.emit('error', failure); + if (failure) this.holdUnreaped(session, failure); return; } this.session = session; @@ -544,6 +570,11 @@ export class ModernMcpConnection extends EventEmitter { } catch (error) { if (this.closedByHost) return; this.reconnectAttempts = attempt + 1; + const halted = this.unreapedFailure(); + if (halted) { + this.emit('reconnect-failed', { error: `${(error as Error).message}; ${halted}`, attempt, permanent: true }); + return; + } this.emit('reconnect-failed', { error: (error as Error).message, attempt }); this.scheduleReconnect(); } @@ -651,6 +682,13 @@ export class ModernMcpConnection extends EventEmitter { if (failure) failures.push(failure); this.emit('close', { reason: 'closed by host' }); } + // Generations a failed cleanup couldn't reap: try again, report the rest. + const held = this.unreaped; + this.unreaped = []; + for (const { session: stuck } of held) { + const failure = await ModernMcpConnection.closeSession(stuck); + if (failure) failures.push(failure); + } if (failures.length > 0) { throw new Error(`MCP server "${this.id}" did not close cleanly: ${failures.map((f) => f.message).join('; ')}`); } diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index ab36316ba..9d2540c03 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -230,11 +230,20 @@ export class StdioTransport extends McplTransport { * child that outlives SIGTERM by {@link STDIO_EXIT_GRACE_MS} gets SIGKILL. * If it still hasn't exited after that bound again, close rejects: an * explicit cleanup failure, rather than waiting forever or claiming an - * exit that wasn't seen. Calls share one attempt. + * exit that wasn't seen. Calls share one attempt; a later call succeeds if + * the child has exited since. */ close(): Promise { - this.closing ??= this.reap(); - return this.closing; + if (!this.closing) { + this.closing = this.reap(); + return this.closing; + } + // A later call re-checks a failed verdict: a child that has exited since + // is reaped now. No new signals are sent. + return this.closing.catch((error: unknown) => { + if (this.child.exitCode !== null || this.child.signalCode !== null) return; + throw error; + }); } private async reap(): Promise { diff --git a/test/fixtures/modern-raw-server.mjs b/test/fixtures/modern-raw-server.mjs index cc4a93210..ac5838b2f 100644 --- a/test/fixtures/modern-raw-server.mjs +++ b/test/fixtures/modern-raw-server.mjs @@ -8,6 +8,7 @@ // listen-fail-first the first subscriptions/listen of each launch is refused // hang-discover-later launches after the first never answer server/discover // hang-discover no launch ever answers server/discover +// reject-discover-later launches after the first answer server/discover with an error // ignore-sigterm the process survives SIGTERM (only SIGKILL ends it) // Tools: op (outputSchema {value: integer}, answers with a string: invalid // structured content), plain (text), err (a JSON-RPC error), touch (announces @@ -42,6 +43,7 @@ createInterface({ input: process.stdin }).on('line', (line) => { case 'server/discover': if (flags.has('hang-discover-later') && launch > 1) return; if (flags.has('hang-discover')) return; + if (flags.has('reject-discover-later') && launch > 1) return error(m.id, -32603, 'not today'); return result(m.id, { supportedVersions: ['2026-07-28'], capabilities: { tools: { listChanged: true } }, diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts index 9877fa63c..3f2d7805b 100644 --- a/test/mcp-modern-outcomes.test.ts +++ b/test/mcp-modern-outcomes.test.ts @@ -219,3 +219,40 @@ test('a child that cannot be reaped fails close() explicitly, never a claimed ex // Calls share the one attempt and its verdict. await assert.rejects(transport.close(), /could not be reaped/); }); + +test('a failed launch that cannot be reaped halts reconnecting; close() settles it', async () => { + const log = scratchLog(); + const realSpawn = StdioTransport.spawn; + const stuck: Array<{ pid: number; kill: (signal?: string) => boolean }> = []; + let launches = 0; + (StdioTransport as unknown as { spawn: typeof StdioTransport.spawn }).spawn = (config) => { + const transport = realSpawn.call(StdioTransport, config); + if (++launches > 1) { + // From the second launch on, signals never arrive. + const child = (transport as unknown as { child: { pid: number; kill: (signal?: string) => boolean } }).child; + const realKill = child.kill.bind(child); + child.kill = () => true; + stuck.push({ pid: child.pid, kill: realKill }); + } + return transport; + }; + cleanups.push(() => { + (StdioTransport as unknown as { spawn: typeof StdioTransport.spawn }).spawn = realSpawn; + for (const s of stuck) s.kill('SIGKILL'); + }); + const connection = await connect('reject-discover-later', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }); + const failures: Array<{ error: string; permanent?: boolean }> = []; + connection.on('reconnect-failed', (f) => failures.push(f)); + connection.on('error', () => {}); + await connection.callTool('die', {}); + await until(() => failures.some((f) => f.permanent), 'a permanent reconnect failure', 9000); + const halted = failures.find((f) => f.permanent)!; + assert.match(halted.error, /the previous launch could not be reaped .*reconnecting is halted/); + assert.equal(connection.willReconnect, false); + await new Promise((r) => setTimeout(r, 300)); + assert.equal(wire(log).filter((e) => e.event === 'start').length, 2, 'no third launch while the second is unreaped'); + // The stuck child goes away; close() re-checks it and settles cleanly. + stuck[0]!.kill('SIGKILL'); + await until(() => { try { process.kill(stuck[0]!.pid, 0); return false; } catch { return true; } }, 'the stuck child gone'); + await connection.close(); +}); From 8b74d2412f8c0e357e21d8f13aeed47992827cbb Mon Sep 17 00:00:00 2001 From: Olive-1804 Date: Thu, 8 Oct 2026 03:16:19 -0400 Subject: [PATCH 09/94] fix(mcp): concurrent ModernMcpConnection.close() calls share the teardown Theo-1805 (room-284 #54217): the owning layer wasn't memoized. The first close() cleared `session` before awaiting teardown, so a second concurrent close() could resolve while the teardown was still pending or about to fail. Calls to close() now queue behind the one in flight. Each step closes every generation the connection still owns: a connect in flight, the live one, and any a failed cleanup couldn't reap. A step that can't reap keeps those generations held, so a later close() re-checks them, and StdioTransport's re-check counts a child that has exited since as reaped. The held list is deduplicated: open()'s own cleanup and close() can report the same generation. Test: with a child whose signals are suppressed, two concurrent close() calls both stay unsettled at 1 s and then fail in order with the one verdict. Once the child is gone, a third close() settles cleanly. Receipt (VPS via on-vps, node v24.21.0, without --test-force-exit; sources 477d993 plus this change, sha256 a149e67aa046d80b): the seven modern/legacy files 65/65. --- src/mcpl/modern-connection.ts | 58 ++++++++++++++++++++------------ test/mcp-modern-outcomes.test.ts | 30 +++++++++++++++++ 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 485a3ee70..27cde4506 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -357,6 +357,7 @@ export class ModernMcpConnection extends EventEmitter { /** A failed generation's cleanup couldn't reap its child: keep owning it, * and halt reconnecting until close() settles it. */ private holdUnreaped(session: Session, failure: Error): void { + if (this.unreaped.some((held) => held.session === session)) return; this.unreaped.push({ session, failure }); this.emit('error', new Error( `MCP server "${this.id}": ${failure.message}. Not starting another launch while it may still be running; close the connection to retry the reap.`, @@ -658,37 +659,50 @@ export class ModernMcpConnection extends EventEmitter { return failure; } - /** Close for good: no reconnect, no reopened subscription, no connect left - * in flight. Awaits the in-flight connect's end, and rejects if a child - * could not be reaped. */ - async close(): Promise { + /** The close in flight, which later calls wait behind. */ + private closing: Promise | null = null; + + /** + * Close for good: no reconnect, no reopened subscription, no connect left + * in flight. Awaits the in-flight connect's end, and rejects if a child + * could not be reaped. Calls queue behind the one in flight, so none + * resolves while teardown is still pending. A call after a failed close + * re-checks what wasn't reaped. + */ + close(): Promise { + const previous = this.closing; + const next = (previous ? previous.catch(() => {}) : Promise.resolve()).then(() => this.closeStep()); + this.closing = next; + return next; + } + + private async closeStep(): Promise { this.closedByHost = true; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = null; this.cancelRelisten(); - const failures: Error[] = []; const opening = this.opening; - if (opening) { - // Closing the transport ends the probe; the connect then rejects and - // its own cleanup runs. Wait for that, so nothing outlives close(). - const failure = await ModernMcpConnection.closeSession(opening.session); - if (failure) failures.push(failure); - await opening.done.catch(() => {}); - } const session = this.session; this.session = null; - if (session) { - const failure = await ModernMcpConnection.closeSession(session); - if (failure) failures.push(failure); - this.emit('close', { reason: 'closed by host' }); - } - // Generations a failed cleanup couldn't reap: try again, report the rest. - const held = this.unreaped; + // Every generation this connection still owns: a connect in flight, the + // live one, and any a failed cleanup couldn't reap. + const targets: Session[] = []; + if (opening) targets.push(opening.session); + if (session) targets.push(session); + for (const held of this.unreaped) if (!targets.includes(held.session)) targets.push(held.session); this.unreaped = []; - for (const { session: stuck } of held) { - const failure = await ModernMcpConnection.closeSession(stuck); - if (failure) failures.push(failure); + const failures: Error[] = []; + for (const target of targets) { + // Closing the transport ends a probe in flight too: its connect then + // rejects and its own cleanup runs (sharing this verdict). + const failure = await ModernMcpConnection.closeSession(target); + if (failure) { + failures.push(failure); + if (!this.unreaped.some((held) => held.session === target)) this.unreaped.push({ session: target, failure }); + } } + if (opening) await opening.done.catch(() => {}); + if (session) this.emit('close', { reason: 'closed by host' }); if (failures.length > 0) { throw new Error(`MCP server "${this.id}" did not close cleanly: ${failures.map((f) => f.message).join('; ')}`); } diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts index 3f2d7805b..b91f31680 100644 --- a/test/mcp-modern-outcomes.test.ts +++ b/test/mcp-modern-outcomes.test.ts @@ -256,3 +256,33 @@ test('a failed launch that cannot be reaped halts reconnecting; close() settles await until(() => { try { process.kill(stuck[0]!.pid, 0); return false; } catch { return true; } }, 'the stuck child gone'); await connection.close(); }); + +test('concurrent close() calls share one teardown and its verdict; a later close re-checks', async () => { + const log = scratchLog(); + const realSpawn = StdioTransport.spawn; + let stuck: { pid: number; kill: (signal?: string) => boolean } | null = null; + (StdioTransport as unknown as { spawn: typeof StdioTransport.spawn }).spawn = (config) => { + const transport = realSpawn.call(StdioTransport, config); + const child = (transport as unknown as { child: { pid: number; kill: (signal?: string) => boolean } }).child; + const realKill = child.kill.bind(child); + child.kill = () => true; // teardown can't reap it + stuck = { pid: child.pid, kill: realKill }; + return transport; + }; + cleanups.push(() => { + (StdioTransport as unknown as { spawn: typeof StdioTransport.spawn }).spawn = realSpawn; + stuck?.kill('SIGKILL'); + }); + const connection = await connect('', log); + const settled: string[] = []; + const first = connection.close().then(() => settled.push('first:ok'), () => settled.push('first:failed')); + const second = connection.close().then(() => settled.push('second:ok'), () => settled.push('second:failed')); + await new Promise((r) => setTimeout(r, 1000)); + assert.deepEqual(settled, [], 'neither resolves while teardown is pending'); + await Promise.all([first, second]); + assert.deepEqual(settled, ['first:failed', 'second:failed'], 'one verdict, in order'); + // Once the child is gone, a later close re-checks and settles. + stuck!.kill('SIGKILL'); + await until(() => { try { process.kill(stuck!.pid, 0); return false; } catch { return true; } }, 'the child gone'); + await connection.close(); +}); From 3aeea4c9b47a3e276cf5a8a6ba5b2fedf895bb66 Mon Sep 17 00:00:00 2001 From: Olive-1804 Date: Thu, 8 Oct 2026 03:25:57 -0400 Subject: [PATCH 10/94] fix(mcp): a stopping or stopped framework admits no new server connection Theo-1805 (room-284 #54276): after `await framework.stop()`, a call to connectMcplServer() still reached StdioTransport.spawn. Registering before start() makes a connect the framework has already admitted its own, but it did nothing to close admission to new connects arriving during or after stop(). stop() now sets mcpServerAdmissionClosed as its first act, and never resets it. connectMcplServer() and connectMcplServerInternal() refuse while it is set, before anything initializes, spawns or dials: "was not connected: the framework is stopping or stopped". That covers both engines, restartMcplServer's reconnect, and a call that arrives after teardown has collected the modern map. A legacy connect whose handshake was already running when stop() began is removed and refused once the handshake returns: the registry teardown had already collected it, so it would otherwise outlive stop(). This was the same lifetime gap on the legacy side, predating this work. Test (mcp-modern-framework, "the framework owns a modern connect in flight"): after stop(), a new connect is refused, with no launch in the fixture's log. Receipt (VPS via on-vps, node v24.21.0, without --test-force-exit; sources 8b74d24 plus this change, sha256 867b325e5920ead5): mcp-modern-framework, mcp-modern-outcomes and mcpl-runtime-lifecycle, 31/31. --- src/framework.ts | 18 ++++++++++++++++++ test/mcp-modern-framework.test.ts | 8 ++++++++ 2 files changed, 26 insertions(+) diff --git a/src/framework.ts b/src/framework.ts index d544acefe..3a899ff24 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1012,6 +1012,10 @@ export class AgentFramework { private providerAccelerationJitterMs = PROVIDER_ACCELERATION_JITTER_MS; private providerHoldHook: ProviderHoldHook | undefined; private providerAdmissionClosed = false; + /** Set when stop() begins, and never reset: a stopping or stopped + * framework admits no new MCP/MCPL server connection, so nothing is + * spawned or dialed after teardown has collected what it closes. */ + private mcpServerAdmissionClosed = false; /** Last time we reported stale (busy-requeued) inference requests, per agent. */ private staleWarnAt = new Map(); /** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */ @@ -1956,6 +1960,7 @@ export class AgentFramework { * Stop the event loop. */ async stop(): Promise { + this.mcpServerAdmissionClosed = true; this.pushCoalescer?.suspend(); this.flushCoalescingSnapshot(); this.running = false; @@ -13771,6 +13776,9 @@ export class AgentFramework { config: import('./mcpl/types.js').McplServerConfig, deferAwareness = false, ): Promise { + if (this.mcpServerAdmissionClosed) { + throw new Error(`MCP server "${config.id}" was not connected: the framework is stopping or stopped`); + } if (!this.mcplServerRegistry || !this.mcplHostCapabilities) { throw new Error('MCPL subsystem is not initialized'); } @@ -13838,6 +13846,12 @@ export class AgentFramework { } const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities); + // stop() began while the handshake ran: its teardown has already + // collected the registry, so this connection would outlive it. Close it. + if (this.mcpServerAdmissionClosed) { + await this.mcplServerRegistry.removeServer(config.id).catch(() => {}); + throw new Error(`MCP server "${config.id}" was not connected: the framework stopped while it was connecting`); + } // Wire listeners before either startup staging or the runtime global gate // releases control traffic needed for registration and marker service. @@ -14212,6 +14226,10 @@ export class AgentFramework { async connectMcplServer( config: import('./mcpl/types.js').McplServerConfig, ): Promise { + // Refused before anything initializes, spawns or dials. + if (this.mcpServerAdmissionClosed) { + throw new Error(`MCP server "${config.id}" was not connected: the framework is stopping or stopped`); + } // Lazily bring up the MCPL subsystem — a framework that started with zero // configured servers can still deploy its first one at runtime. if (!this.mcplServerRegistry || !this.mcplHostCapabilities) { diff --git a/test/mcp-modern-framework.test.ts b/test/mcp-modern-framework.test.ts index 8636e9eb7..2a0ecb9aa 100644 --- a/test/mcp-modern-framework.test.ts +++ b/test/mcp-modern-framework.test.ts @@ -330,6 +330,14 @@ test('the framework owns a modern connect in flight: stop() ends it, and a secon assert.equal(alive(pid), false, 'the launch in flight was reaped by stop()'); await pending.catch(() => {}); assert.ok(!framework.listMcplServers().some((s) => s.id === 'hang' && s.connected), 'nothing installed after stop'); + // A connect that arrives after stop() is refused before anything spawns. + const before = lines(log).length; + await assert.rejects( + framework.connectMcplServer({ ...config, id: 'late' }), + /was not connected: the framework is stopping or stopped/, + ); + await new Promise((r) => setTimeout(r, 200)); + assert.equal(lines(log).length, before, 'no launch after stop'); } finally { rmSync(dir, { recursive: true, force: true }); } From ee4d424803b5bf67560bf6f3f3ca333890a6f0b6 Mon Sep 17 00:00:00 2001 From: Olive-1804 Date: Thu, 8 Oct 2026 03:32:12 -0400 Subject: [PATCH 11/94] fix(mcp): keep a cleanup failure in the legacy stopped-while-connecting refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Theo-1805 (room-284 #54366): the new legacy branch in 3aeea4c, for a connect whose handshake returned after stop() began, used `removeServer(...).catch(() => {})`. That dropped a reap failure and reported only that the framework stopped. The refusal now carries it: "…stopped while it was connecting; its cleanup also failed: ". This follows the explicit cleanup-failure rule. Legacy connection cancellation is not redesigned. Receipt (VPS via on-vps, node v24.21.0, no force-exit; sources 3aeea4c plus this change, sha256 ab943f271bfe9118): build clean; mcp-modern-framework + mcpl-runtime-lifecycle 18/18. --- src/framework.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 3a899ff24..99da96447 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -13849,8 +13849,15 @@ export class AgentFramework { // stop() began while the handshake ran: its teardown has already // collected the registry, so this connection would outlive it. Close it. if (this.mcpServerAdmissionClosed) { - await this.mcplServerRegistry.removeServer(config.id).catch(() => {}); - throw new Error(`MCP server "${config.id}" was not connected: the framework stopped while it was connecting`); + // A cleanup that can't reap the child is reported with the refusal, + // not in place of it and not dropped. + let cleanup = ''; + try { + await this.mcplServerRegistry.removeServer(config.id); + } catch (error) { + cleanup = `; its cleanup also failed: ${error instanceof Error ? error.message : String(error)}`; + } + throw new Error(`MCP server "${config.id}" was not connected: the framework stopped while it was connecting${cleanup}`); } // Wire listeners before either startup staging or the runtime global gate From 91d2411da440cb6a34ca2cc9c570a87d48cfa066 Mon Sep 17 00:00:00 2001 From: Oscar-1826 Date: Thu, 8 Oct 2026 05:06:39 -0400 Subject: [PATCH 12/94] fix(mcp): one owner for every launch and every server id Greptile's review of #262 at ee4d424 (findings 1, 2, 3, 4, 6, 7), and two related edges from Iris-1827's independent assessment (room-293 #54849). Design agreed in room-293 #55051, #55064, #55135, #55137. The rule is the one the modern engine already kept: nothing launches while an earlier launch may still be running, and a teardown that can't confirm a child exited isn't finished. Framework (findings 1 and 2; Iris's edge on failed modern connects): - holdMcpServerId: a connect or disconnect in flight holds its server id. Together with the live connections (MCPL registry, modern map) that is what holds an id, from a connect's admission until its teardown is done. - A connect is refused while anything holds the id, in the same synchronous step as the check: a legacy handshake still running (finding 2), a connect of either family, or a disconnect still tearing down ("still being disconnected", finding 1). Fail-fast, as the existing test pins. - A disconnect takes the hold over. It starts closing a modern connection (which ends a connect in flight), then waits for the operation it displaced, so a legacy connect in flight lands whole before it is removed. Each release frees only its own claim. - A rejected close is not a finished teardown (Iris, #55064). Disconnect now removes nothing until the close verdict is clean; this reverses the earlier "fail only after the rest of the removal" order, which released the id. On failure the server stays registered, closed and listed, the error says so, a connect for the id is refused ("not connected and not reconnecting: disconnect it first"), and a retried disconnect completes once the exit is confirmed. A failed modern connect whose cleanup can't reap keeps its entry the same way, and its error carries both failures. - A modern map entry leaves only after its close settles, so stop() still sees a teardown in flight; the modern 'close' handler removes only a connection lost on its own (ModernMcpConnection.isClosed), never one the host is closing. A modern connect closed by stop() or a disconnect while connecting now rejects with "was not connected", as the legacy path does. - The legacy 'connect-failed' handler raises the ops alert on `permanent`. Legacy McplServerConnection (findings 3, 4, 6; Iris's reconnect edge): - connect() and connectWithReconnect() run checkServerConfig plus the modern-family refusal before anything launches, outside the retry path, so `protocol` on a WebSocket url is refused and a configuration error is never turned into a retry stub (finding 6). - handshake() takes an AbortSignal (checked once the transport is open, raced against the initialize answer), and no longer swallows a cleanup failure: it throws HandshakeCleanupError carrying the transport. - Launches whose close couldn't confirm the child exited are held (`unreaped`). While any is held, reconnecting is halted (`permanent`) and close() retries the reap (finding 4). connect() throws the new exported McplUnreapedLaunchError, whose `connection` owns the launch and can retry the reap; connectWithReconnect() returns that connection, halted. - Reconnect attempts are tracked and abortable. An attempt first waits for the previous transport's close (after reconnectAfterFailure the child may still be reaping), and an attempt aborted by close() closes its new transport instead of adopting it. - close() keeps its synchronous effects and also aborts a reconnect in flight. Its teardown is shared: calls queue behind the one in flight, each waits for the aborted attempt, closes the transport (memoized: the reaping verdict) and re-checks every held launch, and rejects naming what still can't be reaped (finding 3). 'close' is still emitted once, when a live transport is torn down. - McplServerRegistry keeps a failed launch's owner registered, and re-registers a connection whose removal failed, so the id stays held and a retry finds it. Modern (finding 7): start() runs once; later calls get the first promise. Tests (all on the VPS via on-vps, node v24.21.0): - mcpl-legacy-lifetime (new, 8): config refused before any dial (a real WebSocket server counts 0 dials), never retried; concurrent close() waits for the reap; failed reaps reject for every concurrent call and a later close re-checks; connect() hands over its unreaped owner; a halted stub; a halted reconnect loop; close() during a reconnect handshake ends that launch promptly; no reconnect launch while the previous child lives. - mcp-server-ownership (new, 9): connect during disconnect refused, then listed and routed; two simultaneous disconnects; a hold releases only its own claim; modern connect refused during a legacy handshake; disconnect during a legacy connect; Iris's regression for both families (cleanup fails, connect refused, child exits, retried disconnect completes, id free); failed connects whose cleanup can't reap, both families. - mcp-modern-outcomes: start() runs once. - Fixtures: legacy server modes error/hang/slow and ok-then-, IGNORE_SIGTERM; raw modern server flag reject-discover. - Mutation check: ten mutations, each reverting one of these fixes, each failed the tests aimed at it (hold ignored; close not shared; handshake swallowing; close not aborting; no wait for the previous child; registry or framework dropping a failed teardown; failed modern connect dropping its entry; start not memoized; no config check). - This tree alone: mcpl-legacy-lifetime 8/8, mcp-server-ownership 9/9, mcp-modern-outcomes 14/14, mcp-modern-framework 11/11, mcpl-legacy-version 11/11. --- .../mcp-standard-tool-results.changed.md | 26 +- changelog.d/modern-mcp-client.added.md | 4 +- src/framework.ts | 228 ++++++++--- src/index.ts | 2 +- src/mcpl/index.ts | 2 +- src/mcpl/modern-connection.ts | 19 +- src/mcpl/server-connection.ts | 362 ++++++++++++++---- src/mcpl/server-registry.ts | 41 +- test/fixtures/legacy-version-server.mjs | 13 +- test/fixtures/modern-raw-server.mjs | 2 + test/mcp-modern-outcomes.test.ts | 17 + test/mcp-server-ownership.test.ts | 272 +++++++++++++ test/mcpl-legacy-lifetime.test.ts | 229 +++++++++++ 13 files changed, 1062 insertions(+), 155 deletions(-) create mode 100644 test/mcp-server-ownership.test.ts create mode 100644 test/mcpl-legacy-lifetime.test.ts diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index b88e890be..724ed01f2 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -32,8 +32,32 @@ SIGKILL after 2 s. A child still alive 2 s after SIGKILL fails `close()` with an explicit error. Before, close returned as soon as a signal was sent. `stop()` reports such a failure and still finishes shutting - everything else down. + everything else down. Concurrent `close()` calls share one teardown, and a + later call re-checks a child that couldn't be reaped. A launch whose exit + can't be confirmed is never orphaned, and nothing launches beside it: + - `McplServerConnection.connect()` throws the new `McplUnreapedLaunchError`, + whose `connection` owns the launch; its `close()` retries the reap. + `connectWithReconnect()` returns that connection, halted + (`connect-failed` with `permanent: true`, which raises the ops alert), + and a reconnect attempt halts the loop the same way + (`reconnect-failed`, `permanent: true`). A reconnect attempt never + starts before the previous child has exited. + - `close()` during a reconnect handshake ends that launch before it + resolves. + - In the framework, a disconnect or failed connect whose cleanup can't + reap the child keeps the server registered, closed and listed as + disconnected, and its error says so. A connect for that id is refused + until a disconnect, retried once the child is gone, confirms the exit + and completes the removal. +- One owner per server id, across both families: a connect is refused while + another connect for that id is in flight (a legacy handshake included) or + a disconnect is still tearing it down, and a disconnect waits for a + connect in flight to settle before removing it, so a teardown never + removes what a later connection registered. - A server configuration naming no usable transport is now an error before anything is spawned or dialed. That covers a `transport` that doesn't match the url's scheme, `transport: 'http'` without a url, and an unrecognized scheme. Every valid configuration resolves as before. + `McplServerConnection.connect()` and `connectWithReconnect()` apply the + framework's whole check (so `protocol` on a WebSocket url is refused too), + and a configuration error is never turned into a retry stub. diff --git a/changelog.d/modern-mcp-client.added.md b/changelog.d/modern-mcp-client.added.md index 285d8bc13..ec5225fd4 100644 --- a/changelog.d/modern-mcp-client.added.md +++ b/changelog.d/modern-mcp-client.added.md @@ -23,6 +23,8 @@ - **Lifetime:** reconnects use the legacy backoff settings. A tool-list change subscription is kept open, reopened if refused or lost, and its changes go through ordinary admission, so they park under quiesce like - any wake. `close()` and `stop()` end any connect in flight. + any wake. `close()` and `stop()` end any connect in flight. A + `ModernMcpConnection` starts once: a second `start()` returns the first + call's promise. - **Status:** `listMcplServers()` entries gain `family`, `protocolVersion` and `transport`. diff --git a/src/framework.ts b/src/framework.ts index 99da96447..86de9c349 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -391,7 +391,7 @@ import { EventGate, formatShadowWarning } from './gate/event-gate.js'; import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js'; import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js'; import type { McplServerConnection } from './mcpl/server-connection.js'; -import { McplProtocolVersionError } from './mcpl/server-connection.js'; +import { McplProtocolVersionError, McplUnreapedLaunchError } from './mcpl/server-connection.js'; import { ModernMcpConnection } from './mcpl/modern-connection.js'; import { resolveServerBinding, serverConfigProblems } from './mcpl/protocol-family.js'; import { normalizeStandardToolResult } from './mcpl/tool-result-normalize.js'; @@ -1386,6 +1386,9 @@ export class AgentFramework { * only through the tool paths, and their list changes reach agents * through ordinary admission. */ private modernMcpConnections: Map | undefined; + /** Connects and disconnects in flight, by server id (holdMcpServerId); + * created on first use, like modernMcpConnections. */ + private mcpServerOps: Map }> | undefined; /** Host capabilities advertised during the MCP handshake — stored so servers * can be connected at runtime (connectMcplServer) after initialization. */ private mcplHostCapabilities: McplHostCapabilities | null = null; @@ -13412,6 +13415,44 @@ export class AgentFramework { return (this.modernMcpConnections ??= new Map()); } + /** + * Hold a server id for one connect or disconnect, until `release()`. + * Together with the live connections (the MCPL registry and the modern + * map) this is what holds an id: from a connect's admission until its + * teardown is done, one owner at a time. So a connect is refused while + * anything holds its id, and a teardown never removes what a later + * connection registered. A disconnect takes the hold over, and waits for + * the operation it displaced (`previous`). Each release frees only its own + * hold. + */ + private holdMcpServerId(id: string, kind: 'connect' | 'disconnect'): { previous: Promise | undefined; release: () => void } { + const ops = (this.mcpServerOps ??= new Map()); + const previous = ops.get(id)?.settled; + let settle!: () => void; + const op = { kind, settled: new Promise((resolve) => { settle = resolve; }) }; + ops.set(id, op); + return { + previous, + release: () => { + if (ops.get(id) === op) ops.delete(id); + settle(); + }, + }; + } + + /** + * A connect whose failed launch couldn't be confirmed exited. Its + * connection stays registered, closed, and holds the id, so nothing + * launches beside a child that may still be running; a disconnect retries + * the reap, and completes once the exit is confirmed. + */ + private static unreapedConnectFailure(id: string, failure: Error): Error { + return new Error( + `${failure.message}. MCP server "${id}" stays registered, closed, until its exit is confirmed: disconnect it to retry the reap`, + { cause: failure }, + ); + } + /** Close every modern-MCP connection, including any connect still in * flight, so none outlives a failed startup or stop(). */ private async closeModernMcpConnections(): Promise { @@ -13798,8 +13839,34 @@ export class AgentFramework { if (problems.length > 0) { throw new Error(`MCP server "${config.id}" configuration: ${problems.join('; ')}`); } - if (this.modernServers().has(config.id) || this.mcplServerRegistry.getServer(config.id)) { - throw new Error(`MCPL server "${config.id}" is already registered`); + // One owner per id, across both engines (holdMcpServerId): a connect or + // disconnect in flight holds it, and so does a registered connection, + // including one kept because its launch couldn't be confirmed exited. + const op = this.mcpServerOps?.get(config.id); + if (op?.kind === 'disconnect') { + throw new Error(`MCP server "${config.id}" is still being disconnected; connect it once that has finished`); + } + const registered = this.modernServers().get(config.id) ?? this.mcplServerRegistry.getServer(config.id); + if (op || registered) { + const idle = !op && registered && !registered.isConnected && !registered.willReconnect; + throw new Error(`MCPL server "${config.id}" is already registered${idle ? ' (not connected and not reconnecting: disconnect it first)' : ''}`); + } + const hold = this.holdMcpServerId(config.id, 'connect'); + try { + await this.connectHeldMcplServer(config, family, deferAwareness); + } finally { + hold.release(); + } + } + + /** The part of a connect that runs holding its id. */ + private async connectHeldMcplServer( + config: import('./mcpl/types.js').McplServerConfig, + family: 'legacy' | 'modern', + deferAwareness: boolean, + ): Promise { + if (!this.mcplServerRegistry || !this.mcplHostCapabilities) { + throw new Error('MCPL subsystem is not initialized'); } // Register prefix + config for tool dispatch routing (idempotent with @@ -13815,25 +13882,38 @@ export class AgentFramework { const modern = ModernMcpConnection.create(config); this.modernServers().set(config.id, modern); this.wireModernEvents(modern); + let failure: Error | null = null; try { await modern.start(); } catch (error) { - if (this.modernServers().get(config.id) === modern) this.modernServers().delete(config.id); - // The connect error is the one to report; a cleanup failure beside - // it is reported too, never in its place. - await modern.close().catch((cleanup: unknown) => { - this.emitTrace({ - type: 'mcpl:server-error', - serverId: config.id, - error: cleanup instanceof Error ? cleanup.message : String(cleanup), - }); - }); - throw error; + failure = error instanceof Error ? error : new Error(String(error)); } - // Closed (stop, disconnect) while connecting: nothing more to set up. - if (this.modernServers().get(config.id) !== modern) return; - this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` }); - return; + if (!failure && !modern.isClosed) { + this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` }); + return; + } + // It failed, or stop() or a disconnect closed it while connecting: it + // never serves. The connect error is the one to report. + const reason = failure ?? new Error( + `MCP server "${config.id}" was not connected: ` + + (this.mcpServerAdmissionClosed ? 'the framework stopped while it was connecting' : 'it was disconnected while connecting'), + ); + try { + await modern.close(); + } catch (cleanup) { + // A cleanup failure is reported beside it, never in its place. The + // launch may still be running, so the connection stays registered + // and holds the id: nothing launches beside it, and a disconnect + // retries the reap. + const message = cleanup instanceof Error ? cleanup.message : String(cleanup); + this.emitTrace({ type: 'mcpl:server-error', serverId: config.id, error: message }); + if (this.modernServers().get(config.id) === modern) { + throw AgentFramework.unreapedConnectFailure(config.id, new Error(`${reason.message}; its cleanup also failed: ${message}`, { cause: reason })); + } + throw reason; + } + if (this.modernServers().get(config.id) === modern) this.modernServers().delete(config.id); + throw reason; } // Record per-server channel subscription policy before the server @@ -13845,7 +13925,15 @@ export class AgentFramework { ); } - const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities); + let connection: McplServerConnection; + try { + connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities); + } catch (error) { + // The registry keeps a failed launch that may still be running, as the + // closed connection that owns it. + if (error instanceof McplUnreapedLaunchError) throw AgentFramework.unreapedConnectFailure(config.id, error); + throw error; + } // stop() began while the handshake ran: its teardown has already // collected the registry, so this connection would outlive it. Close it. if (this.mcpServerAdmissionClosed) { @@ -14270,39 +14358,61 @@ export class AgentFramework { * transport close, which preserves checkpoints for the reconnect), remove * its channels from the registry, drop routing entries, and refresh tools. * No-op-ish if the server is not connected (still clears routing state). + * + * It holds the id throughout (holdMcpServerId): a connect for it is + * refused meanwhile, and a connect already in flight settles first (a + * modern one is closed, which ends it). A close that can't confirm a child + * exited fails the disconnect before anything is removed: the server stays + * registered, closed, so nothing launches beside a child that may still be + * running, and disconnecting again retries the reap. */ async disconnectMcplServer(id: string): Promise { if (!this.mcplServerRegistry) { throw new Error('MCPL subsystem is not initialized'); } - const config = this.mcplServerConfigs.get(id); - const oldToolNames = new Set(this.mcplTools.map(t => t.name)); + const registry = this.mcplServerRegistry; + const hold = this.holdMcpServerId(id, 'disconnect'); + try { + // Closing a modern connection now ends a connect in flight, rather than + // waiting out its handshake. + const modern = this.modernServers().get(id); + const modernClosed = modern?.close(); + modernClosed?.catch(() => { /* its verdict is awaited below */ }); + // What held the id before (a connect still running, an earlier + // disconnect) settles first, so what it registered is removed whole. + await hold.previous; + const config = this.mcplServerConfigs.get(id); + const oldToolNames = new Set(this.mcplTools.map(t => t.name)); - const modern = this.modernServers().get(id); - // A child that can't be reaped fails the disconnect, but only after the - // rest of the removal is done. - let closeFailure: unknown = null; - if (modern) { - this.modernServers().delete(id); - await modern.close().catch((error: unknown) => { closeFailure = error; }); + try { + await modernClosed; + await registry.removeServer(id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `${message}. MCP server "${id}" stays registered, closed, until its exit is confirmed: disconnect it again to retry the reap`, + { cause: error }, + ); + } + if (modern && this.modernServers().get(id) === modern) this.modernServers().delete(id); + this.channelRegistry?.removeServer(id); + + // Permanent removal: also destroy feature-set and checkpoint state + // explicitly. The 'close' handler usually does this, but a connection that + // already transiently closed (reconnect pending) emits no second 'close' + // from close(), and the transient path deliberately preserves checkpoints. + this.featureSetManager?.removeServer(id); + this.checkpointManager?.removeServer(id); + + const prefix = config?.toolPrefix ?? `mcpl--${id}`; + this.mcplPrefixMap.delete(prefix); + this.mcplServerConfigs.delete(id); + + await this.refreshMcplTools(); + this.emitMcplToolDiff(oldToolNames, id); + } finally { + hold.release(); } - await this.mcplServerRegistry.removeServer(id); - this.channelRegistry?.removeServer(id); - - // Permanent removal: also destroy feature-set and checkpoint state - // explicitly. The 'close' handler usually does this, but a connection that - // already transiently closed (reconnect pending) emits no second 'close' - // from close(), and the transient path deliberately preserves checkpoints. - this.featureSetManager?.removeServer(id); - this.checkpointManager?.removeServer(id); - - const prefix = config?.toolPrefix ?? `mcpl--${id}`; - this.mcplPrefixMap.delete(prefix); - this.mcplServerConfigs.delete(id); - - await this.refreshMcplTools(); - this.emitMcplToolDiff(oldToolNames, id); - if (closeFailure) throw closeFailure; } /** @@ -14661,7 +14771,7 @@ export class AgentFramework { // Surface connect/reconnect failures. Before these traces existed the // only receipt was a console.error on the host's own stderr — invisible // unless someone ssh'd in and read the process log. - connection.on('connect-failed', (params: { error: string; attempt: number }) => { + connection.on('connect-failed', (params: { error: string; attempt: number; permanent?: boolean }) => { this.emitTrace({ type: 'mcpl:server-connect-failed', serverId: connection.id, @@ -14669,6 +14779,11 @@ export class AgentFramework { attempt: params.attempt, willRetry: connection.willReconnect, }); + // `permanent`: the failed launch couldn't be reaped, so no retry will + // start beside it. Alert now, as for a permanent reconnect failure. + if (params.permanent) { + this.opsAlert('mcpl-down', connection.id, `MCPL server unreachable (attempt ${params.attempt}): ${params.error}`); + } }); connection.on('reconnect-failed', (params: { error: string; attempt: number; permanent?: boolean }) => { this.emitTrace({ @@ -14678,12 +14793,13 @@ export class AgentFramework { attempt: params.attempt, willRetry: connection.willReconnect, }); - // The reconnect loop gives up only on a protocol-version verdict - // (`permanent`), which no later attempt can change — alert at once, - // since no fifth attempt will come. Otherwise backoff caps at ~300s, - // so "the server is effectively down" is an attempt-count judgment: - // 5 failed attempts ≈ a few minutes of outage. Throttled per - // (serverId, kind), so a long outage re-posts every ~15 min. + // The reconnect loop gives up only on a protocol-version verdict or a + // launch that couldn't be reaped (`permanent`), which no later attempt + // changes — alert at once, since no fifth attempt will come. Otherwise + // backoff caps at ~300s, so "the server is effectively down" is an + // attempt-count judgment: 5 failed attempts ≈ a few minutes of outage. + // Throttled per (serverId, kind), so a long outage re-posts every + // ~15 min. if (params.permanent || params.attempt >= 5) { this.opsAlert( 'mcpl-down', @@ -14801,9 +14917,11 @@ export class AgentFramework { willReconnect: connection.willReconnect, }); this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` }); - // Without reconnect a closed server is gone, as the MCPL registry - // drops one; its configuration stays listed as disconnected. - if (!connection.willReconnect && this.modernServers().get(connection.id) === connection) { + // Without reconnect a server lost on its own is gone, as the MCPL + // registry drops one; its configuration stays listed as disconnected. + // One the host is closing stays until that teardown is done: whoever + // closes it removes it, so the id stays held meanwhile. + if (!connection.isClosed && !connection.willReconnect && this.modernServers().get(connection.id) === connection) { this.modernServers().delete(connection.id); } }); diff --git a/src/index.ts b/src/index.ts index 34b61109a..39d1a6938 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,7 +90,7 @@ export type { McplServerConfig } from './mcpl/index.js'; // MCPL client connection (exposed so external tooling — server playtests, // health probes, protocol harnesses — can dial an MCPL server exactly the // way the framework host does, over stdio or WebSocket) -export { McplServerConnection, McplRequestError, McplProtocolVersionError } from './mcpl/index.js'; +export { McplServerConnection, McplRequestError, McplProtocolVersionError, McplUnreapedLaunchError } from './mcpl/index.js'; export { ModernMcpConnection } from './mcpl/index.js'; export type { ModernToolCallResult } from './mcpl/index.js'; export { diff --git a/src/mcpl/index.ts b/src/mcpl/index.ts index 8e09c869e..aeca314a1 100644 --- a/src/mcpl/index.ts +++ b/src/mcpl/index.ts @@ -106,7 +106,7 @@ export { } from './errors.js'; // Server connection and registry -export { McplServerConnection, McplRequestError, McplProtocolVersionError } from './server-connection.js'; +export { McplServerConnection, McplRequestError, McplProtocolVersionError, McplUnreapedLaunchError } from './server-connection.js'; export { ModernMcpConnection } from './modern-connection.js'; export type { ModernToolCallResult } from './modern-connection.js'; export { diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 27cde4506..d4bd74e25 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -354,6 +354,12 @@ export class ModernMcpConnection extends EventEmitter { return this.reconnectEnabled && !this.closedByHost && this.unreaped.length === 0; } + /** Whether close() has been called: closed for good, or closing. A + * connection lost on its own is not closed in this sense. */ + get isClosed(): boolean { + return this.closedByHost; + } + /** A failed generation's cleanup couldn't reap its child: keep owning it, * and halt reconnecting until close() settles it. */ private holdUnreaped(session: Session, failure: Error): void { @@ -380,13 +386,22 @@ export class ModernMcpConnection extends EventEmitter { return new ModernMcpConnection(config); } + /** The first connect, shared by every call to start(). */ + private starting: Promise | null = null; + /** * The first connect. Without `reconnect` a failure throws. With it, the * connection keeps retrying in the background, as the legacy engine's * stub does, and reports `connect-failed`. A connection closed before or - * during its first connect resolves unconnected. + * during its first connect resolves unconnected. A connection starts once: + * later calls return the first call's promise, so a second session never + * opens beside the first. */ - async start(): Promise { + start(): Promise { + return (this.starting ??= this.firstConnect()); + } + + private async firstConnect(): Promise { try { await this.open(); } catch (error) { diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 2e0a03559..778b91fbe 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -18,7 +18,7 @@ import { CAPABILITY_DISABLED } from './errors.js'; import { LEGACY_MCP_PROTOCOL_VERSION, MODERN_MCP_PROTOCOL_VERSION, - resolveServerBinding, + checkServerConfig, } from './protocol-family.js'; import type { @@ -154,6 +154,43 @@ export class McplRequestError extends Error { } } +const errorOf = (value: unknown): Error => (value instanceof Error ? value : new Error(String(value))); + +/** + * A handshake that failed, and whose cleanup couldn't confirm that the child + * exited. The transport goes to whoever owns the launch. + */ +class HandshakeCleanupError extends Error { + constructor( + readonly failure: Error, + readonly cleanup: Error, + readonly transport: McplTransport, + ) { + super(`${failure.message}; its cleanup also failed: ${cleanup.message}`, { cause: failure }); + this.name = 'HandshakeCleanupError'; + } +} + +/** + * A connect that failed, and whose cleanup couldn't confirm that the child + * exited: it may still be running. `connection` owns that launch, closed and + * not reconnecting. Its close() retries the reap and resolves once the exit + * is confirmed, so the caller can still clean up, and should start nothing + * else for this server until then. connect() throws this; + * connectWithReconnect() returns the connection instead, halted. `cause` is + * the connect's own failure. + */ +export class McplUnreapedLaunchError extends Error { + constructor( + readonly connection: McplServerConnection, + failure: Error, + cleanup: Error, + ) { + super(`${failure.message}; its cleanup also failed: ${cleanup.message}`, { cause: failure }); + this.name = 'McplUnreapedLaunchError'; + } +} + /** * Represents a pending JSON-RPC request awaiting a response. */ @@ -336,9 +373,21 @@ export class McplServerConnection extends EventEmitter { /** Consecutive failed connect attempts since the last successful handshake. * Drives the exponential backoff and is reported on reconnect events. */ private reconnectAttempts = 0; + /** The reconnect attempt in flight, so close() can abort it and wait for + * its cleanup. */ + private reconnecting: { abort: AbortController; done: Promise } | null = null; + /** + * Launches whose cleanup couldn't confirm that the child exited. While any + * is held, reconnecting is halted, since another launch could run beside + * one that's still alive. close() retries their reap. + */ + private unreaped: Array<{ transport: McplTransport; failure: Error }> = []; + /** The teardown in flight, which later close() calls wait behind. */ + private closing: Promise | null = null; /** Whether a background reconnect loop will revive this connection after a - * drop. False once close() has been called (explicit close stops retrying). */ + * drop. False once close() has been called (explicit close stops retrying), + * after a protocol-version verdict, and while a launch is unreaped. */ get willReconnect(): boolean { return this.reconnectEnabled; } @@ -560,14 +609,29 @@ export class McplServerConnection extends EventEmitter { * capability negotiation, and return a ready-to-use connection. * * Works over stdio (spawned child) or WebSocket, selected by `config` (see - * {@link openTransport}). Throws if the transport can't be opened, the server - * closes/errors before the handshake, or the handshake times out. + * {@link openTransport}). Throws if the configuration can't be used (before + * anything is spawned or dialed), the transport can't be opened, the server + * closes/errors before the handshake, or the handshake times out. If the + * failed launch's cleanup can't confirm its child exited, it throws + * {@link McplUnreapedLaunchError}, whose connection owns that launch. */ static async connect( config: McplServerConfig, hostCapabilities: McplHostCapabilities, ): Promise { - const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised, protocolVersion } = await McplServerConnection.handshake(config, hostCapabilities); + McplServerConnection.checkConfig(config); + let established: Awaited>; + try { + established = await McplServerConnection.handshake(config, hostCapabilities); + } catch (error) { + if (!(error instanceof HandshakeCleanupError)) throw error; + // The launch may still be running. A connection owns it, closed and + // halted, so the caller can retry the reap by closing it. + const owner = McplServerConnection.createDisconnectedStub(config, hostCapabilities); + owner.holdUnreaped(error.transport, error.cleanup); + throw new McplUnreapedLaunchError(owner, error.failure, error.cleanup); + } + const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised, protocolVersion } = established; const connection = new McplServerConnection(config.id, capabilities, transport); connection.droppedCapabilities = droppedCapabilities; @@ -588,32 +652,45 @@ export class McplServerConnection extends EventEmitter { return connection; } + /** + * This engine's configuration check, made before anything is spawned or + * dialed: every problem {@link checkServerConfig} finds, the same check the + * framework applies, and the modern family, which belongs to + * ModernMcpConnection (an http(s) url is never dialed as a WebSocket). + * Every configuration that worked before still passes. + */ + private static checkConfig(config: McplServerConfig): void { + const { family } = checkServerConfig(config); + if (family === 'modern') { + throw new Error( + `MCP server "${config.id}" is configured for modern MCP (${MODERN_MCP_PROTOCOL_VERSION}); ` + + `McplServerConnection speaks the legacy family (${MCP_PROTOCOL_VERSION} + MCPL) only`, + ); + } + } + /** * Open a fresh transport and run the MCP/MCPL initialize handshake over it, * returning the connected transport plus the negotiated capabilities. Shared * by {@link connect} and {@link attemptReconnect} so both transports handshake * identically. On any failure the transport is closed so a spawned child / - * open socket never leaks. + * open socket never leaks. If that close can't confirm the child exited, + * the failure is a {@link HandshakeCleanupError} carrying the transport, so + * the caller keeps owning it. `signal` ends a handshake whose connection is + * closed meanwhile: it is checked once the transport is open (a WebSocket + * dial isn't cut short) and races the initialize answer. */ private static async handshake( config: McplServerConfig, hostCapabilities: McplHostCapabilities, + signal?: AbortSignal, ): Promise<{ transport: McplTransport; capabilities: McplCapabilities | null; droppedCapabilities: ReadonlySet; mcpToolsAdvertised: boolean; protocolVersion: string }> { - // This engine speaks the legacy family only. A configuration that - // resolves to the modern family belongs to ModernMcpConnection; refuse it - // here rather than dialing an http(s) url as a WebSocket. One that names - // no usable transport is an error too, before anything is spawned or - // dialed. - const family = resolveServerBinding(config).family; - if (family === 'modern') { - throw new Error( - `MCP server "${config.id}" is configured for modern MCP (${MODERN_MCP_PROTOCOL_VERSION}); ` + - `McplServerConnection speaks the legacy family (${MCP_PROTOCOL_VERSION} + MCPL) only`, - ); - } + const closedWhileConnecting = () => new Error(`MCPL server "${config.id}" was closed while connecting`); + if (signal?.aborted) throw closedWhileConnecting(); const transport = await openTransport(config); try { + if (signal?.aborted) throw closedWhileConnecting(); const initId = 0; // use id=0 for the handshake const initRequest: JsonRpcRequest = { jsonrpc: '2.0', @@ -626,15 +703,20 @@ export class McplServerConnection extends EventEmitter { }, }; - // Await the initialize response, racing an early transport close/error and - // a timeout. All three paths clean up their listeners. + // Await the initialize response, racing an early transport close/error, + // a timeout and the owner's close. Every path cleans up its listeners. const { mcpl: rawCapabilities, mcpToolsAdvertised, protocolVersion } = await new Promise<{ mcpl: McplCapabilities | null; mcpToolsAdvertised: boolean; protocolVersion: string }>((resolve, reject) => { const cleanup = () => { transport.off('line', onLine); transport.off('close', onClose); transport.off('error', onError); + signal?.removeEventListener('abort', onAbort); clearTimeout(timer); }; + const onAbort = () => { + cleanup(); + reject(closedWhileConnecting()); + }; const onLine = (line: string) => { let msg: JsonRpcResponse; try { @@ -689,6 +771,7 @@ export class McplServerConnection extends EventEmitter { reject(new Error(`MCPL server "${config.id}" initialize handshake timed out`)); }, INITIALIZE_TIMEOUT_MS); + signal?.addEventListener('abort', onAbort, { once: true }); transport.on('line', onLine); transport.on('close', onClose); transport.on('error', onError); @@ -713,8 +796,14 @@ export class McplServerConnection extends EventEmitter { return { transport, capabilities: scoped, droppedCapabilities: new Set(dropped), mcpToolsAdvertised, protocolVersion }; } catch (err) { - // Never leak the child / socket if the handshake fails. - await transport.close().catch(() => { /* best effort */ }); + // Never leak the child / socket if the handshake fails. A cleanup that + // can't confirm the child exited is part of the failure, never + // swallowed: the launch may still be running, so the caller keeps it. + try { + await transport.close(); + } catch (cleanup) { + throw new HandshakeCleanupError(errorOf(err), errorOf(cleanup), transport); + } throw err; } } @@ -723,12 +812,18 @@ export class McplServerConnection extends EventEmitter { * Connect with reconnect support. * When `config.reconnect` is true and the initial connection fails, * resolves immediately with null capabilities and retries in the background. + * A configuration this engine can't use, or a protocol-version verdict, + * still throws: retrying can't change either. A failed launch that can't be + * reaped resolves with a halted stub that owns it: no retry starts beside + * a child that may still be running, and close() retries the reap. * Adapted from Anarchid/agent-framework@mcpl-module-proto. */ static async connectWithReconnect( config: McplServerConfig, hostCapabilities: McplHostCapabilities, ): Promise { + // Outside the retry path below: a configuration error is never retried. + McplServerConnection.checkConfig(config); try { return await McplServerConnection.connect(config, hostCapabilities); } catch (error) { @@ -738,20 +833,28 @@ export class McplServerConnection extends EventEmitter { throw error; } - // Non-blocking start: create a disconnected stub that will reconnect in background - console.error(`MCPL server "${config.id}" initial connect failed, will retry:`, (error as Error).message); - const stub = McplServerConnection.createDisconnectedStub(config, hostCapabilities); // Surface the initial failure. The event is buffered by the emit() // override until the host wires listeners and calls ready(), so it is // not lost in the window before wireMcplEvents runs. + if (error instanceof McplUnreapedLaunchError) { + const halted = error.connection; + console.error(`MCPL server "${config.id}" initial connect failed, not retrying:`, error.message); + halted.emit('connect-failed', { error: `${(error.cause as Error).message}; ${halted.unreapedFailure()}`, attempt: 0, permanent: true }); + return halted; + } + // Non-blocking start: create a disconnected stub that will reconnect in background + console.error(`MCPL server "${config.id}" initial connect failed, will retry:`, (error as Error).message); + const stub = McplServerConnection.createDisconnectedStub(config, hostCapabilities); stub.emit('connect-failed', { error: (error as Error).message, attempt: 0 }); + stub.scheduleReconnect(); return stub; } } /** - * Create a disconnected stub connection that will reconnect in the background. - * Used when initial connect fails and reconnect is enabled. + * Create a disconnected stub connection, for a failed initial connect: one + * that will reconnect in the background (its caller schedules that), or one + * that owns a launch that couldn't be reaped. * @internal */ private static createDisconnectedStub( @@ -764,15 +867,31 @@ export class McplServerConnection extends EventEmitter { stub.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; stub.config = config; stub.hostCapabilities = hostCapabilities; - stub.reconnectEnabled = true; + stub.reconnectEnabled = config.reconnect === true; stub.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000; stub.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000; stub.reconnectAttempts = 1; // the initial connect already failed + return stub; + } - // Schedule background reconnect - stub.scheduleReconnect(); + /** + * Keep a launch whose cleanup couldn't confirm its child exited, and halt + * reconnecting: another launch could run beside one that's still alive. + * close() retries the reap. + */ + private holdUnreaped(transport: McplTransport, failure: Error): void { + this.reconnectEnabled = false; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (!this.unreaped.some((held) => held.transport === transport)) this.unreaped.push({ transport, failure }); + } - return stub; + /** The halt, as a reconnect event's failure text. */ + private unreapedFailure(): string { + const held = this.unreaped[0]; + return `the previous launch could not be reaped (${held?.failure.message ?? 'unknown'}); reconnecting is halted`; } // ========================================================================== @@ -992,37 +1111,72 @@ export class McplServerConnection extends EventEmitter { // Lifecycle // ========================================================================== - /** Close the connection: disable reconnect, kill the child process, and clean up resources. */ - async close(): Promise { + /** + * Close the connection for good: disable reconnect, kill the child + * process, and clean up resources. At once, as before, reconnecting stops + * and pending requests fail; a reconnect handshake in flight is aborted. + * + * The teardown is shared. Calls queue behind the one in flight, so none + * resolves while it is pending. Each waits for the aborted attempt's own + * cleanup, closes the transport (its close is the reaping verdict, and a + * transport the host is already closing shares it), and re-checks every + * launch an earlier cleanup couldn't reap. It rejects, naming them, while + * any child still can't be confirmed exited; a later call re-checks. + */ + close(): Promise { // Disable reconnect before closing — explicit close means stop retrying this.reconnectEnabled = false; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } + this.reconnecting?.abort.abort(); - if (this.closed) { - return; - } - this.closed = true; - this.resetPolicyForTransportBoundary(); + const live = !this.closed; + if (live) { + this.closed = true; + this.resetPolicyForTransportBoundary(); - // Reject all pending requests - for (const [id, pending] of this.pendingRequests) { - if (pending.timer) clearTimeout(pending.timer); - pending.reject(new McplRequestError(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`, 'no-response')); + // Reject all pending requests + for (const [id, pending] of this.pendingRequests) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(new McplRequestError(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`, 'no-response')); + } + this.pendingRequests.clear(); + this.orphanedRequests.clear(); } - this.pendingRequests.clear(); - this.orphanedRequests.clear(); - // Tear down the transport (kills the child / closes the socket). The - // transport's own 'close' event is short-circuited by `this.closed` in - // setupLifecycle, so this is the single source of the connection 'close'. - if (this.transport) { - await this.transport.close(); - } + const transport = this.transport; + const previous = this.closing; + const step = (previous ? previous.catch(() => {}) : Promise.resolve()).then(() => this.closeStep(transport, live)); + this.closing = step; + return step; + } - this.emit('close'); + private async closeStep(transport: McplTransport | null, live: boolean): Promise { + // An aborted reconnect cleans up its own launch, holding it if it can't. + await this.reconnecting?.done; + const targets = transport ? [transport] : []; + for (const held of this.unreaped) if (!targets.includes(held.transport)) targets.push(held.transport); + const failures: Error[] = []; + for (const target of targets) { + try { + // Kills the child / closes the socket. + await target.close(); + this.unreaped = this.unreaped.filter((held) => held.transport !== target); + } catch (error) { + const failure = errorOf(error); + failures.push(failure); + if (!this.unreaped.some((held) => held.transport === target)) this.unreaped.push({ transport: target, failure }); + } + } + // The transport's own 'close' event is short-circuited by `this.closed` + // in setupLifecycle, so this is the single source of the connection + // 'close' when the host tears down a live transport. + if (live) this.emit('close'); + if (failures.length > 0) { + throw new Error(`MCPL server "${this.id}" did not close cleanly: ${failures.map((f) => f.message).join('; ')}`); + } } /** @@ -1074,52 +1228,96 @@ export class McplServerConnection extends EventEmitter { * Attempt to reconnect by opening a fresh transport and performing the * handshake, then adopting that transport in place. Unlike the previous * approach this builds no throwaway connection instance, so there are no - * leaked listeners on a dead object. + * leaked listeners on a dead object. The attempt is close()'s to abort, + * and it settles only once its own launch is adopted, failed or cleaned up. */ private async attemptReconnect(): Promise { if (!this.reconnectEnabled || !this.config || !this.hostCapabilities) return; + const abort = new AbortController(); + const done = this.reconnectOnce(this.config, this.hostCapabilities, abort.signal); + this.reconnecting = { abort, done }; + await done; + if (this.reconnecting?.done === done) this.reconnecting = null; + } + /** One reconnect attempt. It never rejects: each outcome is an event, a + * scheduled retry, or a launch held as unreaped. */ + private async reconnectOnce( + config: McplServerConfig, + hostCapabilities: McplHostCapabilities, + signal: AbortSignal, + ): Promise { // Ordinal of this attempt: 0 was the initial connect, so the Nth retry // reports attempt N (reconnectAttempts counts failures so far). const attempt = Math.max(1, this.reconnectAttempts); - try { - const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised, protocolVersion } = await McplServerConnection.handshake( - this.config, - this.hostCapabilities, - ); - - // Close the data plane before the new transport can emit. The reconnect - // lifecycle event bypasses buffering; its framework listener installs the - // awareness barrier synchronously, then releases control traffic needed - // to establish the server while data remains held. - this.pauseDataPlane(); - this.resetPolicyForTransportBoundary(); - this.transport = transport; - this.capabilities = capabilities; - this.droppedCapabilities = droppedCapabilities; - this.mcpToolsAdvertised = mcpToolsAdvertised; - this.protocolVersion = protocolVersion; - this.allowHostCommands = this.config?.allowHostCommands === true; - this.closed = false; - this.nextRequestId = 1; - this.pendingRequests.clear(); - this.wireTransport(transport); + // The previous launch must be gone before another starts. A transport + // the host closed itself (reconnectAfterFailure) may still be reaping its + // child; one whose child already exited resolves at once. + const previous = this.transport; + if (previous) { + try { + await previous.close(); + } catch (error) { + this.holdUnreaped(previous, errorOf(error)); + if (!signal.aborted) this.emit('reconnect-failed', { error: this.unreapedFailure(), attempt, permanent: true }); + return; + } + } + if (signal.aborted) return; - console.error(`MCPL server "${this.id}" reconnected successfully`); - this.reconnectAttempts = 0; - this.emit('reconnect', { attempts: attempt }); + let established: Awaited>; + try { + established = await McplServerConnection.handshake(config, hostCapabilities, signal); } catch (error) { - console.error(`MCPL server "${this.id}" reconnect failed:`, (error as Error).message); + const unreaped = error instanceof HandshakeCleanupError; + if (unreaped) this.holdUnreaped(error.transport, error.cleanup); + // Closed by the host meanwhile: not a failed attempt. + if (signal.aborted) return; + const failure = unreaped ? error.failure : errorOf(error); + console.error(`MCPL server "${this.id}" reconnect failed:`, failure.message); this.reconnectAttempts = attempt + 1; // No common protocol revision: every further attempt would restart the // server to hear the same answer. Stop retrying; the configuration is - // what has to change. Every other failure keeps the backoff loop. - const permanent = error instanceof McplProtocolVersionError; - if (permanent) this.reconnectEnabled = false; - this.emit('reconnect-failed', { error: (error as Error).message, attempt, ...(permanent ? { permanent: true } : {}) }); + // what has to change. A launch that couldn't be reaped halts retrying + // too, until close() confirms its exit. Every other failure keeps the + // backoff loop. + if (error instanceof McplProtocolVersionError) this.reconnectEnabled = false; + const permanent = unreaped || error instanceof McplProtocolVersionError; + const message = unreaped ? `${failure.message}; ${this.unreapedFailure()}` : failure.message; + this.emit('reconnect-failed', { error: message, attempt, ...(permanent ? { permanent: true } : {}) }); if (!permanent) this.scheduleReconnect(); + return; } + const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised, protocolVersion } = established; + + if (signal.aborted) { + // Closed by the host while the handshake finished: this launch never + // serves. close() is waiting on this attempt, so it hears the verdict. + await transport.close().catch((error: unknown) => this.holdUnreaped(transport, errorOf(error))); + return; + } + + // Close the data plane before the new transport can emit. The reconnect + // lifecycle event bypasses buffering; its framework listener installs the + // awareness barrier synchronously, then releases control traffic needed + // to establish the server while data remains held. + this.pauseDataPlane(); + this.resetPolicyForTransportBoundary(); + this.transport = transport; + this.capabilities = capabilities; + this.droppedCapabilities = droppedCapabilities; + this.mcpToolsAdvertised = mcpToolsAdvertised; + this.protocolVersion = protocolVersion; + this.allowHostCommands = this.config?.allowHostCommands === true; + this.closed = false; + this.nextRequestId = 1; + this.pendingRequests.clear(); + this.wireTransport(transport); + + console.error(`MCPL server "${this.id}" reconnected successfully`); + this.reconnectAttempts = 0; + this.emit('reconnect', { attempts: attempt }); } // ========================================================================== diff --git a/src/mcpl/server-registry.ts b/src/mcpl/server-registry.ts index 62b3854d5..fc773df9f 100644 --- a/src/mcpl/server-registry.ts +++ b/src/mcpl/server-registry.ts @@ -5,7 +5,7 @@ */ import type { McplServerConfig, McplHostCapabilities } from './types.js'; -import { McplServerConnection } from './server-connection.js'; +import { McplServerConnection, McplUnreapedLaunchError } from './server-connection.js'; /** * Capability keys that can be queried via `getServersWithCapability`. @@ -30,7 +30,10 @@ export class McplServerRegistry { * Connect to an MCPL server and register it. * * Throws if a server with the same id is already registered, or if - * the connection/handshake fails. + * the connection/handshake fails. A failed launch whose child couldn't be + * confirmed exited stays registered all the same, as the closed connection + * that owns it ({@link McplUnreapedLaunchError}), so the id stays held + * and removeServer can retry the reap. */ async addServer( config: McplServerConfig, @@ -40,14 +43,27 @@ export class McplServerRegistry { throw new Error(`MCPL server "${config.id}" is already registered`); } - const connection = config.reconnect - ? await McplServerConnection.connectWithReconnect(config, hostCapabilities) - : await McplServerConnection.connect(config, hostCapabilities); + let connection: McplServerConnection; + try { + connection = config.reconnect + ? await McplServerConnection.connectWithReconnect(config, hostCapabilities) + : await McplServerConnection.connect(config, hostCapabilities); + } catch (error) { + if (error instanceof McplUnreapedLaunchError && !this.servers.has(config.id)) { + this.register(config, error.connection); + } + throw error; + } + this.register(config, connection); + return connection; + } + + private register(config: McplServerConfig, connection: McplServerConnection): void { this.servers.set(config.id, connection); // Auto-remove on unexpected close (unless reconnect will re-add) connection.on('close', () => { - if (!config.reconnect) { + if (!config.reconnect && this.servers.get(config.id) === connection) { this.servers.delete(config.id); } }); @@ -56,14 +72,14 @@ export class McplServerRegistry { connection.on('reconnect', () => { // Connection already in the map — capabilities may have changed }); - - return connection; } /** * Disconnect and remove a server by id. * - * No-op if the server is not registered. + * No-op if the server is not registered. If its close can't confirm the + * child exited, it stays registered and the failure is thrown: removing it + * again retries the reap. */ async removeServer(id: string): Promise { const connection = this.servers.get(id); @@ -71,7 +87,12 @@ export class McplServerRegistry { return; } this.servers.delete(id); - await connection.close(); + try { + await connection.close(); + } catch (error) { + if (!this.servers.has(id)) this.servers.set(id, connection); + throw error; + } } /** diff --git a/test/fixtures/legacy-version-server.mjs b/test/fixtures/legacy-version-server.mjs index 9a84796f1..e2968ff90 100644 --- a/test/fixtures/legacy-version-server.mjs +++ b/test/fixtures/legacy-version-server.mjs @@ -7,7 +7,12 @@ // reject-nodata error -32022 without data // mismatch protocolVersion 2025-06-18 // missing no protocolVersion at all -// ok-then-reject `ok` on the first start recorded in startLog, `reject` after +// error a JSON-RPC error (-32603), not a version verdict +// hang never answers +// slow `ok`, after SLOW_INIT_MS (default 600) +// ok-then- `ok` on the first start recorded in startLog, after +// +// IGNORE_SIGTERM=1 makes the process survive SIGTERM (only SIGKILL ends it). // // tools/list pages: by default three pages (cursors p2, p3). MODE_LIST picks // an edge case: loop (always nextCursor "same"), empty (nextCursor "" then a @@ -20,8 +25,9 @@ const startLog = process.argv[3]; if (startLog) { const priorStarts = existsSync(startLog) ? readFileSync(startLog, 'utf8').split('\n').filter(Boolean).length : 0; appendFileSync(startLog, `start ${process.pid}\n`); - if (mode === 'ok-then-reject') mode = priorStarts === 0 ? 'ok' : 'reject'; + if (mode.startsWith('ok-then-')) mode = priorStarts === 0 ? 'ok' : mode.slice('ok-then-'.length); } +if (process.env.IGNORE_SIGTERM === '1') process.on('SIGTERM', () => {}); const listMode = process.env.MODE_LIST ?? 'pages'; const send = (m) => process.stdout.write(JSON.stringify(m) + '\n'); @@ -46,6 +52,9 @@ process.stdin.on('data', (chunk) => { else if (mode === 'reject-nodata') error(m.id, -32022, `Unsupported protocol version: ${requested}`); else if (mode === 'mismatch') reply(m.id, { protocolVersion: '2025-06-18', capabilities: { tools: {} } }); else if (mode === 'missing') reply(m.id, { capabilities: { tools: {} } }); + else if (mode === 'error') error(m.id, -32603, 'initialize failed on purpose'); + else if (mode === 'hang') { /* never answers */ } + else if (mode === 'slow') setTimeout(() => reply(m.id, { protocolVersion: '2024-11-05', capabilities: { tools: {} } }), Number(process.env.SLOW_INIT_MS ?? 600)); else reply(m.id, { protocolVersion: '2024-11-05', capabilities: { tools: {} } }); } else if (m.method === 'tools/list') { const cursor = m.params?.cursor; diff --git a/test/fixtures/modern-raw-server.mjs b/test/fixtures/modern-raw-server.mjs index ac5838b2f..00866dc40 100644 --- a/test/fixtures/modern-raw-server.mjs +++ b/test/fixtures/modern-raw-server.mjs @@ -9,6 +9,7 @@ // hang-discover-later launches after the first never answer server/discover // hang-discover no launch ever answers server/discover // reject-discover-later launches after the first answer server/discover with an error +// reject-discover every launch answers server/discover with an error // ignore-sigterm the process survives SIGTERM (only SIGKILL ends it) // Tools: op (outputSchema {value: integer}, answers with a string: invalid // structured content), plain (text), err (a JSON-RPC error), touch (announces @@ -44,6 +45,7 @@ createInterface({ input: process.stdin }).on('line', (line) => { if (flags.has('hang-discover-later') && launch > 1) return; if (flags.has('hang-discover')) return; if (flags.has('reject-discover-later') && launch > 1) return error(m.id, -32603, 'not today'); + if (flags.has('reject-discover')) return error(m.id, -32603, 'not today'); return result(m.id, { supportedVersions: ['2026-07-28'], capabilities: { tools: { listChanged: true } }, diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts index b91f31680..651f6fc7e 100644 --- a/test/mcp-modern-outcomes.test.ts +++ b/test/mcp-modern-outcomes.test.ts @@ -286,3 +286,20 @@ test('concurrent close() calls share one teardown and its verdict; a later close await until(() => { try { process.kill(stuck!.pid, 0); return false; } catch { return true; } }, 'the child gone'); await connection.close(); }); + +test('start() runs once: a second call shares the first connect, and no second session opens beside it', async () => { + const log = scratchLog(); + const connection = ModernMcpConnection.create({ id: 'raw', command: process.execPath, args: [SERVER, '', log], protocol: 'modern' }); + cleanups.push(() => connection.close()); + const first = connection.start(); + assert.equal(connection.start(), first, 'the same promise while connecting'); + await first; + assert.equal(connection.start(), first, 'and once connected'); + await connection.start(); + assert.equal(connection.isConnected, true); + const launches = wire(log).filter((e) => e.event === 'start'); + assert.equal(launches.length, 1, 'one launch'); + await connection.close(); + const alive = (p: number) => { try { process.kill(p, 0); return true; } catch { return false; } }; + assert.equal(alive(launches[0]!.pid!), false, 'close() reaped the only child'); +}); diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts new file mode 100644 index 000000000..f5f7e4607 --- /dev/null +++ b/test/mcp-server-ownership.test.ts @@ -0,0 +1,272 @@ +/** + * One owner per MCP server id across both engines (room-293, greptile's + * review of PR #262 and Iris-1827's invariant): from a connect's admission + * until its teardown is done, exactly one owner holds an id. + * - a connect is refused while a connect or disconnect for the id is in + * flight, a legacy handshake included; + * - a disconnect waits for what held the id before it, so nothing it removes + * belongs to a later connection, and each release frees only its own claim; + * - a teardown that can't confirm a child exited isn't finished: the server + * stays registered, closed, a connect is refused, and a retried disconnect + * completes once the exit is confirmed. + */ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { AgentFramework } from '../src/index.js'; +import type { McplServerConfig } from '../src/mcpl/types.js'; +import { StdioTransport } from '../src/mcpl/transport.js'; +import { MockMembrane } from './helpers/mock-membrane.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const RAW = join(here, 'fixtures', 'modern-raw-server.mjs'); +const LEGACY = join(here, 'fixtures', 'legacy-version-server.mjs'); + +const cleanups: Array<() => Promise | void> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +const lines = (file: string) => (existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean) : []); +/** Launch pids from a raw modern server's JSONL log, or a legacy start log. */ +const launches = (file: string): number[] => lines(file).flatMap((l) => { + if (l.startsWith('start ')) return [Number(l.slice('start '.length))]; + const entry = JSON.parse(l) as { event?: string; pid?: number }; + return entry.event === 'start' ? [entry.pid!] : []; +}); +const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } }; + +async function until(cond: () => boolean, what: string, ms = 10_000): Promise { + const deadline = Date.now() + ms; + while (!cond() && Date.now() < deadline) await new Promise((r) => setTimeout(r, 20)); + assert.ok(cond(), `in time: ${what}`); +} + +async function withFramework(fn: (h: { framework: AgentFramework; dir: string }) => Promise): Promise { + const dir = mkdtempSync(join(tmpdir(), 'mcp-ownership-')); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], + modules: [], + }); + try { + await fn({ framework, dir }); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } +} + +const modern = (id: string, flags: string, log: string): McplServerConfig => + ({ id, command: process.execPath, args: [RAW, flags, log], protocol: 'modern' }); +const legacy = (id: string, mode: string, log: string, env: Record = {}): McplServerConfig => + ({ id, command: process.execPath, args: [LEGACY, mode, log], env }); +const status = (framework: AgentFramework, id: string) => framework.listMcplServers().find((s) => s.id === id); +const toolsOf = (framework: AgentFramework, id: string) => framework.getAllTools().map((t) => t.name).filter((n) => n.startsWith(`mcpl--${id}--`)); +const call = (name: string) => ({ id: `t-${Math.random()}`, name, input: {} }); + +/** + * Stdio launches can't be signalled while `stuck.active` is set, so their + * cleanup can't reap them; `kill` really ends one (Olive-1804's pattern). + */ +function unreapable(): { active: boolean; stuck: Array<{ pid: number; kill: () => void }> } { + const realSpawn = StdioTransport.spawn; + const state = { active: true, stuck: [] as Array<{ pid: number; kill: () => void }> }; + (StdioTransport as unknown as { spawn: typeof StdioTransport.spawn }).spawn = (c) => { + const transport = realSpawn.call(StdioTransport, c); + if (state.active) { + const child = (transport as unknown as { child: { pid: number; kill: (signal?: string) => boolean } }).child; + const realKill = child.kill.bind(child); + child.kill = () => true; + state.stuck.push({ pid: child.pid, kill: () => { realKill('SIGKILL'); } }); + } + return transport; + }; + cleanups.push(() => { + (StdioTransport as unknown as { spawn: typeof StdioTransport.spawn }).spawn = realSpawn; + for (const s of state.stuck) s.kill(); + }); + return state; +} + +async function killAndWait(stuck: { pid: number; kill: () => void }): Promise { + stuck.kill(); + await until(() => !alive(stuck.pid), `child ${stuck.pid} gone`); +} + +test('a connect during a disconnect is refused; once the teardown is done, the new server is listed and routed', async () => { + await withFramework(async ({ framework, dir }) => { + const log = join(dir, 'raw.jsonl'); + // A child that ignores SIGTERM keeps the teardown in flight for ~2 s. + const config = modern('m', 'ignore-sigterm', log); + await framework.connectMcplServer(config); + const disconnecting = framework.disconnectMcplServer('m'); + await assert.rejects(framework.connectMcplServer(config), /MCP server "m" is still being disconnected/); + assert.equal(launches(log).length, 1, 'nothing launched beside the closing child'); + await disconnecting; + assert.equal(status(framework, 'm'), undefined); + + await framework.connectMcplServer(config); + assert.equal(status(framework, 'm')?.connected, true, 'listed: its routing was not erased'); + assert.ok(toolsOf(framework, 'm').includes('mcpl--m--plain')); + assert.deepEqual((await framework.executeToolCall(call('mcpl--m--plain'))).data, [{ type: 'text', text: 'plain' }]); + }); +}); + +test('two simultaneous disconnects both settle, in order, and leave the id free', async () => { + await withFramework(async ({ framework, dir }) => { + const log = join(dir, 'raw.jsonl'); + const config = modern('m', 'ignore-sigterm', log); + await framework.connectMcplServer(config); + const order: string[] = []; + const first = framework.disconnectMcplServer('m').then(() => order.push('first')); + const second = framework.disconnectMcplServer('m').then(() => order.push('second')); + await Promise.all([first, second]); + assert.deepEqual(order, ['first', 'second']); + assert.equal(status(framework, 'm'), undefined); + assert.equal(alive(launches(log)[0]!), false); + await framework.connectMcplServer(config); + assert.equal(status(framework, 'm')?.connected, true); + }); +}); + +test('a hold releases only its own claim: a disconnect queued behind another still holds the id', async () => { + await withFramework(async ({ framework, dir }) => { + const hold = (framework as unknown as { + holdMcpServerId: (id: string, kind: 'connect' | 'disconnect') => { previous: Promise | undefined; release: () => void }; + }).holdMcpServerId.bind(framework); + const first = hold('x', 'disconnect'); + const second = hold('x', 'disconnect'); + assert.equal(first.previous, undefined); + let secondMayRun = false; + void second.previous!.then(() => { secondMayRun = true; }); + first.release(); + await new Promise((r) => setImmediate(r)); + assert.equal(secondMayRun, true, 'the second runs once the first has settled'); + const config = modern('x', '', join(dir, 'raw.jsonl')); + await assert.rejects(framework.connectMcplServer(config), /"x" is still being disconnected/, 'the first release left the second claim'); + second.release(); + await framework.connectMcplServer(config); + assert.equal(status(framework, 'x')?.connected, true); + }); +}); + +test('a modern connect is refused while a legacy connect for the same id is still handshaking', async () => { + await withFramework(async ({ framework, dir }) => { + const legacyLog = join(dir, 'legacy.log'); + const modernLog = join(dir, 'raw.jsonl'); + const pending = framework.connectMcplServer(legacy('same', 'slow', legacyLog, { SLOW_INIT_MS: '800' })); + await until(() => launches(legacyLog).length === 1, 'the legacy launch, handshaking'); + await assert.rejects(framework.connectMcplServer(modern('same', '', modernLog)), /MCPL server "same" is already registered/); + assert.equal(launches(modernLog).length, 0, 'the modern server was never launched'); + await pending; + const served = status(framework, 'same')!; + assert.equal(served.family, 'legacy'); + assert.equal(served.connected, true); + assert.deepEqual((await framework.executeToolCall(call('mcpl--same--a1'))).data, [{ type: 'text', text: 'ok' }], 'its tools reach the legacy server'); + }); +}); + +test('a disconnect during a legacy connect waits for it to land, then removes it whole', async () => { + await withFramework(async ({ framework, dir }) => { + const log = join(dir, 'legacy.log'); + const pending = framework.connectMcplServer(legacy('same', 'slow', log, { SLOW_INIT_MS: '600' })); + await until(() => launches(log).length === 1, 'the legacy launch, handshaking'); + const disconnecting = framework.disconnectMcplServer('same'); + await assert.rejects(framework.connectMcplServer(legacy('same', 'ok', log)), /"same" is still being disconnected/); + await Promise.all([pending, disconnecting]); + assert.equal(status(framework, 'same'), undefined); + assert.deepEqual(toolsOf(framework, 'same'), []); + assert.equal(alive(launches(log)[0]!), false, 'its child was reaped'); + }); +}); + +test('modern: a disconnect that cannot reap keeps the server registered; a connect is refused until a retried disconnect confirms the exit', async () => { + await withFramework(async ({ framework, dir }) => { + const log = join(dir, 'raw.jsonl'); + const config = modern('m', '', log); + const reaping = unreapable(); + await framework.connectMcplServer(config); + await assert.rejects( + framework.disconnectMcplServer('m'), + /could not be reaped\. MCP server "m" stays registered, closed, until its exit is confirmed: disconnect it again to retry the reap/, + ); + assert.equal(status(framework, 'm')?.connected, false, 'still listed, closed'); + await assert.rejects(framework.connectMcplServer(config), /"m" is already registered \(not connected and not reconnecting: disconnect it first\)/); + assert.equal(launches(log).length, 1, 'no launch beside the unreaped child'); + + await killAndWait(reaping.stuck[0]!); + reaping.active = false; + await framework.disconnectMcplServer('m'); + assert.equal(status(framework, 'm'), undefined, 'the retried disconnect completed'); + await framework.connectMcplServer(config); + assert.equal(status(framework, 'm')?.connected, true, 'the id is free'); + }); +}); + +test('legacy: a disconnect that cannot reap keeps the server registered; a connect is refused until a retried disconnect confirms the exit', async () => { + await withFramework(async ({ framework, dir }) => { + const log = join(dir, 'legacy.log'); + const config = legacy('l', 'ok', log); + const reaping = unreapable(); + await framework.connectMcplServer(config); + await assert.rejects( + framework.disconnectMcplServer('l'), + /could not be reaped\. MCP server "l" stays registered, closed, until its exit is confirmed: disconnect it again to retry the reap/, + ); + assert.equal(status(framework, 'l')?.connected, false, 'still listed, closed'); + await assert.rejects(framework.connectMcplServer(config), /"l" is already registered \(not connected and not reconnecting: disconnect it first\)/); + assert.equal(launches(log).length, 1, 'no launch beside the unreaped child'); + + await killAndWait(reaping.stuck[0]!); + reaping.active = false; + await framework.disconnectMcplServer('l'); + assert.equal(status(framework, 'l'), undefined, 'the retried disconnect completed'); + await framework.connectMcplServer(config); + assert.equal(status(framework, 'l')?.connected, true, 'the id is free'); + }); +}); + +test('modern: a failed connect whose cleanup cannot reap stays registered until a disconnect confirms the exit', async () => { + await withFramework(async ({ framework, dir }) => { + const log = join(dir, 'raw.jsonl'); + const reaping = unreapable(); + // The SDK reports a refused server/discover as a failed version negotiation. + await assert.rejects( + framework.connectMcplServer(modern('m', 'reject-discover', log)), + /server\/discover .*; its cleanup also failed: .*could not be reaped\. MCP server "m" stays registered, closed, until its exit is confirmed: disconnect it to retry the reap/, + ); + await assert.rejects(framework.connectMcplServer(modern('m', '', log)), /"m" is already registered \(not connected and not reconnecting/); + assert.equal(launches(log).length, 1); + + await killAndWait(reaping.stuck[0]!); + reaping.active = false; + await framework.disconnectMcplServer('m'); + await framework.connectMcplServer(modern('m', '', log)); + assert.equal(status(framework, 'm')?.connected, true); + }); +}); + +test('legacy: a failed connect whose cleanup cannot reap stays registered until a disconnect confirms the exit', async () => { + await withFramework(async ({ framework, dir }) => { + const log = join(dir, 'legacy.log'); + const reaping = unreapable(); + await assert.rejects( + framework.connectMcplServer(legacy('l', 'error', log)), + /initialize failed on purpose; its cleanup also failed: .*could not be reaped\. MCP server "l" stays registered, closed, until its exit is confirmed: disconnect it to retry the reap/, + ); + await assert.rejects(framework.connectMcplServer(legacy('l', 'ok', log)), /"l" is already registered \(not connected and not reconnecting/); + assert.equal(launches(log).length, 1); + + await killAndWait(reaping.stuck[0]!); + reaping.active = false; + await framework.disconnectMcplServer('l'); + await framework.connectMcplServer(legacy('l', 'ok', log)); + assert.equal(status(framework, 'l')?.connected, true); + }); +}); diff --git a/test/mcpl-legacy-lifetime.test.ts b/test/mcpl-legacy-lifetime.test.ts new file mode 100644 index 000000000..8e836bb61 --- /dev/null +++ b/test/mcpl-legacy-lifetime.test.ts @@ -0,0 +1,229 @@ +/** + * The legacy engine's lifetime contract (room-293, greptile's review of + * PR #262): one owner for every launch, the rule the modern engine keeps. + * - a configuration it can't use is refused before anything is dialed, and + * never turned into a retry stub; + * - concurrent close() calls share one teardown and its verdict; + * - a failed launch that can't be reaped is held, never orphaned: connect() + * hands its owner to the caller, connectWithReconnect() and the reconnect + * loop halt, and close() retries the reap; + * - close() during a reconnect handshake ends that launch before resolving; + * - no reconnect launches while the previous child may still be running. + */ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { WebSocketServer } from 'ws'; + +import { McplServerConnection, McplUnreapedLaunchError } from '../src/mcpl/server-connection.js'; +import { StdioTransport } from '../src/mcpl/transport.js'; +import type { McplHostCapabilities, McplServerConfig } from '../src/mcpl/types.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const SERVER = join(here, 'fixtures', 'legacy-version-server.mjs'); +const HOST_CAPS: McplHostCapabilities = { version: '0.5', pushEvents: true }; + +const cleanups: Array<() => Promise | void> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +function scratchLog(): string { + const dir = mkdtempSync(join(tmpdir(), 'legacy-lifetime-')); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + return join(dir, 'starts.log'); +} + +/** The pid of each launch, in order. */ +const starts = (log: string): number[] => + existsSync(log) ? readFileSync(log, 'utf8').split('\n').filter(Boolean).map((l) => Number(l.split(' ')[1])) : []; +const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } }; + +async function until(cond: () => boolean, what: string, ms = 10_000): Promise { + const deadline = Date.now() + ms; + while (!cond() && Date.now() < deadline) await new Promise((r) => setTimeout(r, 20)); + assert.ok(cond(), `in time: ${what}`); +} + +function config(mode: string, log: string, extra: Partial = {}): McplServerConfig { + return { id: 'legacy', command: process.execPath, args: [SERVER, mode, log], ...extra }; +} +const RECONNECT = { reconnect: true, reconnectIntervalMs: 20, reconnectMaxIntervalMs: 40 } as const; + +/** + * Launches from the `from`-th on can't be signalled, so their cleanup can't + * reap them (Olive-1804's pattern in mcp-modern-outcomes). `kill` really ends + * one; cleanup restores spawning and ends any left. + */ +function unreapableFrom(from: number): Array<{ pid: number; kill: () => void }> { + const realSpawn = StdioTransport.spawn; + const stuck: Array<{ pid: number; kill: () => void }> = []; + let launches = 0; + (StdioTransport as unknown as { spawn: typeof StdioTransport.spawn }).spawn = (c) => { + const transport = realSpawn.call(StdioTransport, c); + if (++launches >= from) { + const child = (transport as unknown as { child: { pid: number; kill: (signal?: string) => boolean } }).child; + const realKill = child.kill.bind(child); + child.kill = () => true; + stuck.push({ pid: child.pid, kill: () => { realKill('SIGKILL'); } }); + } + return transport; + }; + cleanups.push(() => { + (StdioTransport as unknown as { spawn: typeof StdioTransport.spawn }).spawn = realSpawn; + for (const s of stuck) s.kill(); + }); + return stuck; +} + +async function killAndWait(stuck: { pid: number; kill: () => void }): Promise { + stuck.kill(); + await until(() => !alive(stuck.pid), `child ${stuck.pid} gone`); +} + +test('a configuration the engine cannot use is refused before anything is dialed, and never retried', async () => { + const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 }); + await new Promise((r) => wss.once('listening', r)); + cleanups.push(() => new Promise((r) => wss.close(() => r()))); + let dials = 0; + wss.on('connection', (socket) => { dials++; socket.close(); }); + const url = `ws://127.0.0.1:${(wss.address() as AddressInfo).port}/mcpl`; + + const modernOnWs = { id: 'ws', url, protocol: 'modern' as const }; + await assert.rejects(McplServerConnection.connect(modernOnWs, HOST_CAPS), /"protocol" applies only to stdio/); + // With reconnect too: a configuration error is thrown, not turned into a + // stub that retries it forever. + await assert.rejects(McplServerConnection.connectWithReconnect({ ...modernOnWs, ...RECONNECT }, HOST_CAPS), /"protocol" applies only to stdio/); + await assert.rejects( + McplServerConnection.connectWithReconnect({ id: 'ftp', url: 'ftp://127.0.0.1/x', ...RECONNECT }, HOST_CAPS), + /url must be ws:\/\/ or wss:\/\//, + ); + await new Promise((r) => setTimeout(r, 200)); + assert.equal(dials, 0, 'nothing was dialed'); +}); + +test('concurrent close() calls share the teardown: none resolves while the child still runs', async () => { + const log = scratchLog(); + const connection = await McplServerConnection.connect(config('ok', log, { env: { IGNORE_SIGTERM: '1' } }), HOST_CAPS); + connection.ready(); + const [pid] = starts(log); + const order: string[] = []; + const first = connection.close().then(() => order.push(`first:${alive(pid!) ? 'alive' : 'gone'}`)); + const second = connection.close().then(() => order.push(`second:${alive(pid!) ? 'alive' : 'gone'}`)); + await Promise.all([first, second]); + assert.deepEqual(order, ['first:gone', 'second:gone']); +}); + +test('a close that cannot reap fails, and so do concurrent ones; a later close re-checks and settles', async () => { + const log = scratchLog(); + const stuck = unreapableFrom(1); + const connection = await McplServerConnection.connect(config('ok', log), HOST_CAPS); + connection.ready(); + const settled: string[] = []; + const outcome = (name: string) => [() => settled.push(`${name}:ok`), (e: Error) => settled.push(`${name}:${e.message}`)] as const; + const first = connection.close().then(...outcome('first')); + const second = connection.close().then(...outcome('second')); + await new Promise((r) => setTimeout(r, 1000)); + assert.deepEqual(settled, [], 'neither resolves while the teardown is pending'); + await Promise.all([first, second]); + assert.equal(settled.length, 2); + assert.match(settled[0]!, /^first:MCPL server "legacy" did not close cleanly: .*could not be reaped/); + assert.match(settled[1]!, /^second:.*could not be reaped/, 'the second re-checked, rather than claiming success'); + await killAndWait(stuck[0]!); + await connection.close(); +}); + +test('a failed connect that cannot be reaped hands its owner to the caller, who can retry the reap', async () => { + const log = scratchLog(); + const stuck = unreapableFrom(1); + const error = await McplServerConnection.connect(config('error', log), HOST_CAPS).then(() => null, (e: unknown) => e); + assert.ok(error instanceof McplUnreapedLaunchError, String(error)); + assert.match(error.message, /initialize failed on purpose; its cleanup also failed: .*could not be reaped/); + assert.match((error.cause as Error).message, /initialize failed on purpose/); + const owner = error.connection; + assert.equal(owner.isConnected, false); + assert.equal(owner.willReconnect, false); + await assert.rejects(owner.close(), /could not be reaped/, 'still running: the reap fails again'); + await killAndWait(stuck[0]!); + await owner.close(); +}); + +test('with reconnect, a failed first launch that cannot be reaped halts instead of retrying', async () => { + const log = scratchLog(); + const stuck = unreapableFrom(1); + const connection = await McplServerConnection.connectWithReconnect(config('error', log, RECONNECT), HOST_CAPS); + cleanups.push(() => connection.close().catch(() => {})); + const failures: Array<{ error: string; attempt: number; permanent?: boolean }> = []; + connection.on('connect-failed', (f) => failures.push(f)); + connection.ready(); + await until(() => failures.length > 0, 'the connect-failed event'); + assert.equal(failures[0]!.permanent, true); + assert.match(failures[0]!.error, /initialize failed on purpose; the previous launch could not be reaped .*reconnecting is halted/); + assert.equal(connection.willReconnect, false); + await new Promise((r) => setTimeout(r, 300)); + assert.equal(starts(log).length, 1, 'no launch beside the unreaped one'); + await killAndWait(stuck[0]!); + await connection.close(); +}); + +test('a reconnect launch that cannot be reaped halts reconnecting; close() settles it once it has exited', async () => { + const log = scratchLog(); + const stuck = unreapableFrom(2); + const connection = await McplServerConnection.connectWithReconnect(config('ok-then-error', log, RECONNECT), HOST_CAPS); + cleanups.push(() => connection.close().catch(() => {})); + connection.ready(); + const failures: Array<{ error: string; attempt: number; permanent?: boolean }> = []; + connection.on('reconnect-failed', (f) => failures.push(f)); + await assert.rejects(connection.sendToolsCall('die', {})); + await until(() => failures.some((f) => f.permanent), 'a permanent reconnect failure'); + assert.match(failures.find((f) => f.permanent)!.error, /initialize failed on purpose; the previous launch could not be reaped .*reconnecting is halted/); + assert.equal(connection.willReconnect, false); + await new Promise((r) => setTimeout(r, 300)); + assert.equal(starts(log).length, 2, 'no third launch while the second is unreaped'); + await assert.rejects(connection.close(), /could not be reaped/); + await killAndWait(stuck[0]!); + await connection.close(); +}); + +test('close() during a reconnect handshake ends that launch before resolving, and nothing is adopted', async () => { + const log = scratchLog(); + const connection = await McplServerConnection.connectWithReconnect(config('ok-then-hang', log, RECONNECT), HOST_CAPS); + connection.ready(); + let reconnected = false; + connection.on('reconnect', () => { reconnected = true; }); + await assert.rejects(connection.sendToolsCall('die', {})); + await until(() => starts(log).length === 2, 'the reconnect launch, waiting on initialize'); + const second = starts(log)[1]!; + const began = Date.now(); + await connection.close(); + assert.ok(Date.now() - began < 5_000, 'promptly: the handshake was ended, not waited out (30 s)'); + assert.equal(alive(second), false, 'the launch in flight was reaped before close() resolved'); + assert.equal(reconnected, false); + assert.equal(connection.isConnected, false); + await new Promise((r) => setTimeout(r, 200)); + assert.equal(starts(log).length, 2, 'and no later launch'); +}); + +test('a reconnect never launches while the previous child may still be running', async () => { + const log = scratchLog(); + const connection = await McplServerConnection.connectWithReconnect( + config('ok', log, { ...RECONNECT, env: { IGNORE_SIGTERM: '1' } }), + HOST_CAPS, + ); + cleanups.push(() => connection.close().catch(() => {})); + connection.ready(); + const [first] = starts(log); + let reconnected = false; + connection.on('reconnect', () => { reconnected = true; }); + // Recycle it as the framework does after an awareness failure: the host + // closes the transport itself, and the child ignores SIGTERM for 2 s. + const recycled = connection.reconnectAfterFailure(); + await until(() => starts(log).length === 2, 'the next launch'); + assert.equal(alive(first!), false, 'it started only once the first child was gone'); + await recycled; + await until(() => reconnected, 'reconnected'); +}); From 7e0a97c2934c5218c3f7f6d5438cde71f88a69ed Mon Sep 17 00:00:00 2001 From: Oscar-1826 Date: Thu, 8 Oct 2026 05:06:59 -0400 Subject: [PATCH 13/94] fix(mcp): saved tool results never overwrite each other; pin the script cap Greptile's review of #262 at ee4d424, findings 5 and 8. Finding 5: a saved payload's name was the date plus the call id, sanitized and cut to 80 characters, so distinct ids could share it (`call:a` and `call/a`, or ids that differ only past the kept prefix), and a later writeBinary replaced the earlier result's file. The name now ends in a full randomUUID() after the readable part. The oversized-script save had the same property with 8 hex characters; it now uses a full UUID too. Finding 8: the script view of structured results had no coverage at its size cap or for either workspace outcome. A fixture tool `sized` pads structuredContent so the serialized object's length is exact (the cap measures that length, Iris-1827, room-293 #54849). Through a real modern server and a real WorkspaceModule (store wired as the host wires it): - exactly 5,000,000 characters arrive inline, whole; - one more is saved, and the script gets {isError, oversized} only; the saved object, read back through the workspace, is the whole {content, structuredContent, isError}, and `error` too on a tool error, whose isError stays visible without reading the file; - no workspace, or a write that fails, gives the explicit failure to deliver (savedTo null, "the tool may already have completed"), never a success. A payload test saves four results whose ids collide after sanitizing or truncation, and reads each file back with its own bytes. Mutation check: dropping the UUID from the label fails the payload test. Receipt, both commits (VPS via on-vps, node v24.21.0, npm 11.19.0; clean tree, sources sha256 b9e5424066c10bd5; `npm run clean && npm run build`, then every test file standalone, 4 at a time): 136 files, 1469 tests, 1468 pass. The one failure is conversation-routing.test.js at file level ("Queue is closed" after the fork channel_publish test ends), which fails the same way on the base ee4d424, 3 of 3, and which Petra-1779 recorded as pre-existing in room-272. --- .../mcp-standard-tool-results.changed.md | 7 +- src/framework.ts | 8 +- test/fixtures/modern-tools.mjs | 8 ++ test/mcp-modern-framework.test.ts | 118 +++++++++++++++++- 4 files changed, 133 insertions(+), 8 deletions(-) diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index 724ed01f2..5294e3bcd 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -3,9 +3,10 @@ content types: - text stays text, and inline images stay native; - audio and binary resources are saved to the workspace (`tool-results/`), - with a bounded stub saying where. If a payload can't be saved, the result - is reported as incomplete (`isError`), noting the tool may already have - completed, never as a success with part of it missing; + each under a unique name, with a bounded stub saying where. If a payload + can't be saved, the result is reported as incomplete (`isError`), noting + the tool may already have completed, never as a success with part of it + missing; - a `resource_link` is shown as a reference and never fetched; - an embedded text resource shows its text. diff --git a/src/framework.ts b/src/framework.ts index 86de9c349..3ffec37a3 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -11909,7 +11909,7 @@ export class AgentFramework { const workspace = this.getWorkspaceModule(); const mount = workspace ? this.firstWritableMountName(workspace) : null; if (workspace && mount) { - const path = `${mount}/tool-results/${new Date().toISOString().slice(0, 10)}-script-${randomUUID().slice(0, 8)}.json`; + const path = `${mount}/tool-results/${new Date().toISOString().slice(0, 10)}-script-${randomUUID()}.json`; const written = await workspace.writeBinary(path, Buffer.from(json, 'utf8'), 'application/json').catch(() => null); if (written?.success) { return JSON.stringify({ isError: object.isError, oversized: { chars: json.length, savedTo: path } }); @@ -15422,7 +15422,11 @@ export class AgentFramework { ): Promise { const hasStructured = Object.prototype.hasOwnProperty.call(result, 'structuredContent'); if (!mcplPeer) { - const label = `${new Date().toISOString().slice(0, 10)}-${callId}`.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80); + // The date and call id make a saved payload's name readable; the UUID + // makes it unique. Sanitizing and truncating map distinct ids to one + // label (`call:a` and `call/a`), and a later write would replace the + // earlier result's file. + const label = `${new Date().toISOString().slice(0, 10)}-${callId}`.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80) + `-${randomUUID()}`; const workspace = this.getWorkspaceModule(); const mount = workspace ? this.firstWritableMountName(workspace) : null; const save = workspace && mount diff --git a/test/fixtures/modern-tools.mjs b/test/fixtures/modern-tools.mjs index d156277a6..5fb435165 100644 --- a/test/fixtures/modern-tools.mjs +++ b/test/fixtures/modern-tools.mjs @@ -28,6 +28,14 @@ export function createServer(opts = {}) { async () => ({ content: [{ type: 'text', text: 'Found 2 rows.' }], structuredContent: { rows: [{ id: 1 }, { id: 2 }] } })); server.registerTool('structured_zero', { description: 'Falsy structured value', inputSchema: obj() }, async () => ({ content: [], structuredContent: 0 })); + server.registerTool('sized', { + description: 'Structured content padded to `pad` characters, optionally a tool error', + inputSchema: obj({ pad: { type: 'number' }, isError: { type: 'boolean' } }), + }, async (args) => ({ + content: [], + structuredContent: { pad: 'x'.repeat(Number(args.pad ?? 0)) }, + ...(args.isError ? { isError: true } : {}), + })); server.registerTool('scored_image', { description: 'An image with machine-readable scores', inputSchema: obj() }, async () => ({ content: [{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }], diff --git a/test/mcp-modern-framework.test.ts b/test/mcp-modern-framework.test.ts index 2a0ecb9aa..570993c81 100644 --- a/test/mcp-modern-framework.test.ts +++ b/test/mcp-modern-framework.test.ts @@ -16,7 +16,7 @@ import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { AgentFramework } from '../src/index.js'; +import { AgentFramework, WorkspaceModule } from '../src/index.js'; import type { EventResponse, Module, ModuleContext, ProcessEvent, ProcessState, ToolDefinition, ToolResult } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; @@ -50,14 +50,18 @@ const lines = (file: string) => (existsSync(file) ? readFileSync(file, 'utf8').s interface Harness { framework: AgentFramework; membrane: MockMembrane; dir: string } -async function withModern(fn: (h: Harness) => Promise, extraServers: Record[] = []): Promise { +async function withModern( + fn: (h: Harness) => Promise, + extraServers: Record[] = [], + extraModules: Module[] = [], +): Promise { const dir = mkdtempSync(join(tmpdir(), 'modern-framework-')); const membrane = new MockMembrane(); const framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(), agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], - modules: [new Waker()], + modules: [new Waker(), ...extraModules], mcplServers: [ { id: 'modern', @@ -181,6 +185,114 @@ test('scripts get structured results whole, as one JSON object', async () => { }); }); +// The script cap measures the serialized object's length (Iris-1827, +// room-293): `sized` pads structuredContent so that length is exact. +const SCRIPT_CAP = 5_000_000; +const sizedObject = (pad: number, isError = false) => ({ + content: [], + structuredContent: { pad: 'x'.repeat(pad) }, + isError, + // A tool error with no text carries the framework's own error text. + ...(isError ? { error: 'Tool call failed' } : {}), +}); +/** The pad that makes the script's JSON exactly `chars` long. */ +const padFor = (chars: number, isError = false) => chars - JSON.stringify(sizedObject(0, isError)).length; +const scriptHandle = (framework: AgentFramework) => (framework as unknown as { + handleScriptToolCall: (agent: string, tool: string, args: Record) => Promise; +}).handleScriptToolCall.bind(framework); + +async function withWorkspace(fn: (h: Harness & { workspace: WorkspaceModule }) => Promise): Promise { + const board = mkdtempSync(join(tmpdir(), 'modern-board-')); + const workspace = new WorkspaceModule({ mounts: [{ name: 'board', path: board, mode: 'read-write', watch: 'never' }] }); + try { + await withModern((h) => { + // The host wires the store after create(), as conhost does. + workspace.initStore(h.framework.getStore()); + return fn({ ...h, workspace }); + }, [], [workspace]); + } finally { + rmSync(board, { recursive: true, force: true }); + } +} + +/** A saved file's bytes, read back through the workspace as a resident would. */ +async function readSaved(workspace: WorkspaceModule, path: string): Promise { + const read = await workspace.readBinary(path); + assert.ok('data' in read, `readable: ${path} (${'error' in read ? read.error : ''})`); + return read.data.toString('utf8'); +} + +async function savedObject(workspace: WorkspaceModule, savedTo: string): Promise { + assert.match(savedTo, /^board\/tool-results\/\d{4}-\d{2}-\d{2}-script-[0-9a-f-]{36}\.json$/); + return JSON.parse(await readSaved(workspace, savedTo)); +} + +test('script results: exactly at the cap arrive inline; one past it are saved whole, a tool error included', async () => { + await withWorkspace(async ({ framework, workspace }) => { + framework.start(); + const handle = scriptHandle(framework); + + const at = await handle('agent', 'mcpl--modern--sized', { pad: padFor(SCRIPT_CAP) }); + assert.equal(at.length, SCRIPT_CAP); + assert.deepEqual(JSON.parse(at), sizedObject(padFor(SCRIPT_CAP))); + + const over = JSON.parse(await handle('agent', 'mcpl--modern--sized', { pad: padFor(SCRIPT_CAP + 1) })); + assert.deepEqual(Object.keys(over).sort(), ['isError', 'oversized']); + assert.equal(over.isError, false); + assert.equal(over.oversized.chars, SCRIPT_CAP + 1); + assert.deepEqual(await savedObject(workspace, over.oversized.savedTo), sizedObject(padFor(SCRIPT_CAP + 1)), 'the whole object, recoverable'); + + const failed = JSON.parse(await handle('agent', 'mcpl--modern--sized', { pad: padFor(SCRIPT_CAP + 1, true), isError: true })); + assert.equal(failed.isError, true, 'a tool error stays visible without reading the file'); + assert.equal(failed.oversized.chars, SCRIPT_CAP + 1); + assert.notEqual(failed.oversized.savedTo, over.oversized.savedTo); + assert.deepEqual(await savedObject(workspace, failed.oversized.savedTo), sizedObject(padFor(SCRIPT_CAP + 1, true), true)); + }); +}); + +test('script results past the cap: no workspace, or a failed write, is a failure to deliver, never a success', async () => { + const assertUndelivered = (out: { isError: boolean; error: string; oversized: unknown }) => { + assert.equal(out.isError, true); + assert.match(out.error, /exceeds the 5000000-char script result cap and could not be saved to the workspace, so it could not be delivered; the tool may already have completed/); + assert.deepEqual(out.oversized, { chars: SCRIPT_CAP + 1, savedTo: null }); + }; + await withModern(async ({ framework }) => { + framework.start(); + assertUndelivered(JSON.parse(await scriptHandle(framework)('agent', 'mcpl--modern--sized', { pad: padFor(SCRIPT_CAP + 1) }))); + }); + await withWorkspace(async ({ framework, workspace }) => { + framework.start(); + workspace.writeBinary = async () => ({ success: false, error: 'disk full', isError: true }); + assertUndelivered(JSON.parse(await scriptHandle(framework)('agent', 'mcpl--modern--sized', { pad: padFor(SCRIPT_CAP + 1) }))); + }); +}); + +test('saved payloads: call ids that sanitize to one label still get distinct files', async () => { + await withWorkspace(async ({ framework, workspace }) => { + const read = (framework as unknown as { + mcpToolResult: (result: unknown, callId: string, mcplPeer: boolean) => Promise; + }).mcpToolResult.bind(framework); + const audio = (bytes: string) => ({ content: [{ type: 'audio', data: Buffer.from(bytes).toString('base64'), mimeType: 'audio/wav' }] }); + const savedTo = (result: ToolResult) => /saved to workspace file (\S+)\]/.exec(String(result.data))?.[1]; + // `call:a` and `call/a` sanitize alike; so do ids that differ only past the kept prefix. + const long = 'x'.repeat(90); + const results = [ + await read(audio('first'), 'call:a', false), + await read(audio('second'), 'call/a', false), + await read(audio('third'), `${long}-1`, false), + await read(audio('fourth'), `${long}-2`, false), + ]; + const paths = results.map(savedTo); + assert.ok(paths.every((p) => typeof p === 'string'), JSON.stringify(results)); + assert.equal(new Set(paths).size, 4, `distinct paths: ${paths.join(', ')}`); + assert.deepEqual( + await Promise.all(paths.map((p) => readSaved(workspace, p!))), + ['first', 'second', 'third', 'fourth'], + 'each file keeps its own call\'s bytes', + ); + }); +}); + test('MCPL policy on a modern server is a configuration error at connect', async () => { await withModern(async ({ framework }) => { await assert.rejects( From c35d28692cf6c561fb7cd897754c75ab5a5ed3f2 Mon Sep 17 00:00:00 2001 From: Oscar-1826 Date: Thu, 8 Oct 2026 05:28:04 -0400 Subject: [PATCH 14/94] fix(mcp): one reconnect attempt at a time; a disconnect ends a connect in flight Iris-1827's review of 7e0a97c (room-293 #55410) found a blocking hole in the legacy retry ownership, reproduced through public methods on the VPS: connectWithReconnect with an `ok-then-hang` server, `die`, wait for launch 2 (initialize pending), `reconnectAfterFailure()`, wait for launch 3, then `close()`. The recycle scheduled a second attempt while the first was in flight; attemptReconnect overwrote `reconnecting`, so close() aborted and reaped only the newer handshake and resolved with launch 2 still alive. - attemptReconnect is single-owner: while an attempt is in flight, a new request starts nothing. That attempt either adopts its launch or schedules the next retry itself, so nothing needed is dropped; a timer can't fire inside an attempt's tail, since the tail is microtasks. Found in my own re-read while the review ran: a disconnect waited out a legacy connect's post-handshake setup on a live connection, as long as that setup's requests took, where the old code's disconnect ended it by closing. (Iris checked one such request: featureSets/update has its own 15 s bound, so it isn't left unbounded by requestTimeoutMs 0.) A disconnect now ends a connect in flight for both families: - it closes what the id has at once: a modern connection (which ends its connect), and a registered legacy one through registry.removeServer, whose failure path keeps the connection registered; - a legacy connect still handshaking checks, when the handshake finishes, whether a disconnect has taken its hold (`displaced`, beside the existing stop() check), closes its connection and fails with "was not connected: it was disconnected while connecting"; - the disconnect then waits for it to settle, and removes what it registered, re-checking anything kept after a failed close. A connect already past its handshake when the disconnect closes it may resolve (its setup tolerates the closed connection) before the removal; one still handshaking rejects. Both end in the same teardown (Iris exercised the first with a server hanging on featureSets/update and requestTimeoutMs 0: the disconnect completed in 8 ms and reaped the child). Tests: - mcpl-legacy-lifetime: Iris's interleaving. No second attempt starts beside the one in flight; close() resolves promptly with every launch reaped. - mcp-server-ownership: a disconnect during a legacy handshake ends that connect when the handshake finishes (it rejects "disconnected while connecting"), removes it whole, and nothing else launches; a disconnect during a hanging modern connect ends it at once (under 5 s, not the 30 s connect timeout). - Mutations: without the single-owner guard, Iris's test fails (and the orphaned child outlives the file); without the displaced check, the legacy-handshake test fails; the earlier ownership mutations still fail their tests with the reworked disconnect. Receipt (VPS via on-vps, node v24.21.0): mcpl-legacy-lifetime 9/9, mcp-server-ownership 10/10, mcp-modern-framework 14/14, mcp-modern-outcomes 14/14, mcpl-legacy-version 11/11. --- .../mcp-standard-tool-results.changed.md | 12 ++-- src/framework.ts | 57 ++++++++++++------- src/mcpl/server-connection.ts | 6 +- test/mcp-server-ownership.test.ts | 20 ++++++- test/mcpl-legacy-lifetime.test.ts | 17 ++++++ 5 files changed, 85 insertions(+), 27 deletions(-) diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index 5294e3bcd..27782b0a5 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -41,8 +41,8 @@ `connectWithReconnect()` returns that connection, halted (`connect-failed` with `permanent: true`, which raises the ops alert), and a reconnect attempt halts the loop the same way - (`reconnect-failed`, `permanent: true`). A reconnect attempt never - starts before the previous child has exited. + (`reconnect-failed`, `permanent: true`). Reconnect attempts run one at + a time, and never start before the previous child has exited. - `close()` during a reconnect handshake ends that launch before it resolves. - In the framework, a disconnect or failed connect whose cleanup can't @@ -52,9 +52,11 @@ and completes the removal. - One owner per server id, across both families: a connect is refused while another connect for that id is in flight (a legacy handshake included) or - a disconnect is still tearing it down, and a disconnect waits for a - connect in flight to settle before removing it, so a teardown never - removes what a later connection registered. + a disconnect is still tearing it down. A disconnect closes a registered + connection immediately, and a legacy connect still handshaking is cleaned + up when its handshake settles (a modern connect in flight, or such a + legacy one, fails with "was not connected"); then it removes what was + registered. A teardown never removes what a later connection registered. - A server configuration naming no usable transport is now an error before anything is spawned or dialed. That covers a `transport` that doesn't match the url's scheme, `transport: 'http'` without a url, and an diff --git a/src/framework.ts b/src/framework.ts index 3ffec37a3..394fc991c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -13422,10 +13422,15 @@ export class AgentFramework { * teardown is done, one owner at a time. So a connect is refused while * anything holds its id, and a teardown never removes what a later * connection registered. A disconnect takes the hold over, and waits for - * the operation it displaced (`previous`). Each release frees only its own - * hold. + * the operation it displaced (`previous`); a connect that finds itself + * `displaced` ends rather than setting up a server being removed. Each + * release frees only its own hold. */ - private holdMcpServerId(id: string, kind: 'connect' | 'disconnect'): { previous: Promise | undefined; release: () => void } { + private holdMcpServerId(id: string, kind: 'connect' | 'disconnect'): { + previous: Promise | undefined; + displaced: () => boolean; + release: () => void; + } { const ops = (this.mcpServerOps ??= new Map()); const previous = ops.get(id)?.settled; let settle!: () => void; @@ -13433,6 +13438,7 @@ export class AgentFramework { ops.set(id, op); return { previous, + displaced: () => ops.get(id) !== op, release: () => { if (ops.get(id) === op) ops.delete(id); settle(); @@ -13853,17 +13859,19 @@ export class AgentFramework { } const hold = this.holdMcpServerId(config.id, 'connect'); try { - await this.connectHeldMcplServer(config, family, deferAwareness); + await this.connectHeldMcplServer(config, family, deferAwareness, hold.displaced); } finally { hold.release(); } } - /** The part of a connect that runs holding its id. */ + /** The part of a connect that runs holding its id. `displaced`: a + * disconnect has taken the id over and is waiting for this connect. */ private async connectHeldMcplServer( config: import('./mcpl/types.js').McplServerConfig, family: 'legacy' | 'modern', deferAwareness: boolean, + displaced: () => boolean, ): Promise { if (!this.mcplServerRegistry || !this.mcplHostCapabilities) { throw new Error('MCPL subsystem is not initialized'); @@ -13935,17 +13943,21 @@ export class AgentFramework { throw error; } // stop() began while the handshake ran: its teardown has already - // collected the registry, so this connection would outlive it. Close it. - if (this.mcpServerAdmissionClosed) { + // collected the registry, so this connection would outlive it. Or a + // disconnect took the id over and is waiting for this connect. Either + // way, close it rather than set up a server that is being removed. + if (this.mcpServerAdmissionClosed || displaced()) { + const why = this.mcpServerAdmissionClosed ? 'the framework stopped while it was connecting' : 'it was disconnected while connecting'; // A cleanup that can't reap the child is reported with the refusal, - // not in place of it and not dropped. + // not in place of it and not dropped. The registry keeps such a + // connection, so the id stays held. let cleanup = ''; try { await this.mcplServerRegistry.removeServer(config.id); } catch (error) { cleanup = `; its cleanup also failed: ${error instanceof Error ? error.message : String(error)}`; } - throw new Error(`MCP server "${config.id}" was not connected: the framework stopped while it was connecting${cleanup}`); + throw new Error(`MCP server "${config.id}" was not connected: ${why}${cleanup}`); } // Wire listeners before either startup staging or the runtime global gate @@ -14360,11 +14372,12 @@ export class AgentFramework { * No-op-ish if the server is not connected (still clears routing state). * * It holds the id throughout (holdMcpServerId): a connect for it is - * refused meanwhile, and a connect already in flight settles first (a - * modern one is closed, which ends it). A close that can't confirm a child - * exited fails the disconnect before anything is removed: the server stays - * registered, closed, so nothing launches beside a child that may still be - * running, and disconnecting again retries the reap. + * refused meanwhile, and a connect already in flight is ended (closed, or + * displaced when its legacy handshake finishes) and settles before + * anything is removed. A close that can't confirm a child exited fails the + * disconnect before anything is removed: the server stays registered, + * closed, so nothing launches beside a child that may still be running, + * and disconnecting again retries the reap. */ async disconnectMcplServer(id: string): Promise { if (!this.mcplServerRegistry) { @@ -14373,11 +14386,15 @@ export class AgentFramework { const registry = this.mcplServerRegistry; const hold = this.holdMcpServerId(id, 'disconnect'); try { - // Closing a modern connection now ends a connect in flight, rather than - // waiting out its handshake. + // Close what the id has now, so a connect in flight ends rather than + // being waited out: a modern connect is ended by its close, and a + // legacy one already registered (setting up after its handshake) + // fails its requests. A legacy connect still handshaking ends when its + // handshake does, finding itself displaced. The registry's removal is + // the legacy close: a failed one stays registered. const modern = this.modernServers().get(id); - const modernClosed = modern?.close(); - modernClosed?.catch(() => { /* its verdict is awaited below */ }); + const closing = [modern?.close(), registry.removeServer(id)]; + for (const pending of closing) pending?.catch(() => { /* its verdict is awaited below */ }); // What held the id before (a connect still running, an earlier // disconnect) settles first, so what it registered is removed whole. await hold.previous; @@ -14385,7 +14402,9 @@ export class AgentFramework { const oldToolNames = new Set(this.mcplTools.map(t => t.name)); try { - await modernClosed; + for (const pending of closing) await pending; + // Whatever was registered since: a connect that was still + // handshaking, whose own removal couldn't reap, is re-checked here. await registry.removeServer(id); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 778b91fbe..1a87641f7 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -1230,9 +1230,13 @@ export class McplServerConnection extends EventEmitter { * approach this builds no throwaway connection instance, so there are no * leaked listeners on a dead object. The attempt is close()'s to abort, * and it settles only once its own launch is adopted, failed or cleaned up. + * One attempt at a time: a request that arrives while one is in flight (a + * recycle, say) starts nothing, since that attempt either adopts its + * launch or schedules the next retry itself. A second attempt would take + * close()'s ownership of the first (Iris-1827, room-293 #55410). */ private async attemptReconnect(): Promise { - if (!this.reconnectEnabled || !this.config || !this.hostCapabilities) return; + if (this.reconnecting || !this.reconnectEnabled || !this.config || !this.hostCapabilities) return; const abort = new AbortController(); const done = this.reconnectOnce(this.config, this.hostCapabilities, abort.signal); this.reconnecting = { abort, done }; diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index f5f7e4607..65def7234 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -172,17 +172,33 @@ test('a modern connect is refused while a legacy connect for the same id is stil }); }); -test('a disconnect during a legacy connect waits for it to land, then removes it whole', async () => { +test('a disconnect during a legacy handshake ends that connect when the handshake finishes, and removes it whole', async () => { await withFramework(async ({ framework, dir }) => { const log = join(dir, 'legacy.log'); const pending = framework.connectMcplServer(legacy('same', 'slow', log, { SLOW_INIT_MS: '600' })); await until(() => launches(log).length === 1, 'the legacy launch, handshaking'); const disconnecting = framework.disconnectMcplServer('same'); await assert.rejects(framework.connectMcplServer(legacy('same', 'ok', log)), /"same" is still being disconnected/); - await Promise.all([pending, disconnecting]); + await assert.rejects(pending, /MCP server "same" was not connected: it was disconnected while connecting/); + await disconnecting; assert.equal(status(framework, 'same'), undefined); assert.deepEqual(toolsOf(framework, 'same'), []); assert.equal(alive(launches(log)[0]!), false, 'its child was reaped'); + assert.equal(launches(log).length, 1, 'and nothing else launched'); + }); +}); + +test('a disconnect during a modern connect ends it at once', async () => { + await withFramework(async ({ framework, dir }) => { + const log = join(dir, 'raw.jsonl'); + const pending = framework.connectMcplServer(modern('m', 'hang-discover', log)); + await until(() => lines(log).some((l) => l.includes('server/discover')), 'the launch waiting on discover'); + const began = Date.now(); + await framework.disconnectMcplServer('m'); + await assert.rejects(pending, /MCP server "m" was not connected: it was disconnected while connecting/); + assert.ok(Date.now() - began < 5_000, 'not after the 30 s connect timeout'); + assert.equal(status(framework, 'm'), undefined); + assert.equal(alive(launches(log)[0]!), false, 'its child was reaped'); }); }); diff --git a/test/mcpl-legacy-lifetime.test.ts b/test/mcpl-legacy-lifetime.test.ts index 8e836bb61..0ee93a278 100644 --- a/test/mcpl-legacy-lifetime.test.ts +++ b/test/mcpl-legacy-lifetime.test.ts @@ -208,6 +208,23 @@ test('close() during a reconnect handshake ends that launch before resolving, an assert.equal(starts(log).length, 2, 'and no later launch'); }); +test('a recycle during a reconnect in flight starts no second attempt, and close() still ends the one in flight', async () => { + // Iris-1827's interleaving (room-293 #55410): before, the recycle's attempt + // took close()'s ownership of the first, which outlived a successful close. + const log = scratchLog(); + const connection = await McplServerConnection.connectWithReconnect(config('ok-then-hang', log, RECONNECT), HOST_CAPS); + connection.ready(); + await assert.rejects(connection.sendToolsCall('die', {})); + await until(() => starts(log).length === 2, 'the reconnect launch, waiting on initialize'); + await connection.reconnectAfterFailure(); + await new Promise((r) => setTimeout(r, 400)); + assert.equal(starts(log).length, 2, 'no second attempt beside the one in flight'); + const began = Date.now(); + await connection.close(); + assert.ok(Date.now() - began < 5_000, 'promptly'); + assert.deepEqual(starts(log).filter(alive), [], 'every launch reaped when close() resolved'); +}); + test('a reconnect never launches while the previous child may still be running', async () => { const log = scratchLog(); const connection = await McplServerConnection.connectWithReconnect( From df209f249a228113df80c0c6aa3b108af61dee3c Mon Sep 17 00:00:00 2001 From: Otis-1924 Date: Thu, 8 Oct 2026 18:22:49 -0400 Subject: [PATCH 15/94] fix(mcp): a call that ends before the server's final answer is no-response slimepriestess's review of #262 at c35d286 (CHANGES_REQUESTED, review 5462380482) found a deadline misreported as an answer. With requestTimeoutMs 600 and a server that answers leg 1 with a requestState-only input_required late in the budget, SDK 2.3.1 sleeps REQUEST_STATE_ONLY_LEG_PACING_MS (250) before leg 2, and the deadline fires inside that sleep. The sleep rejects with SdkError(RequestTimeout, "TimeoutError: The operation was aborted due to timeout"), but requestFailure() checked `record.answer === 'result'` first, so the model and modules got a plain Error: "answered tools/call ..., but the result can't be used", with no outcome and no "verify before retrying". The cause is wider than the deadline. The wire record treated any result as the call's answer, and an input_required result is not one: it asks for another round. Nigel-1925's probe (room-341 #58500) showed the same branch misreading a connection lost between rounds. The SDK's _onclose clears _transport, so the retry leg's _requestWithSchemaViaCodec rejects with a plain Error("Not connected") before anything reaches ObservedTransport.send, and leg 1's answer still stood. A host close() during the pause goes the same way. What changed: - WireRecord.answer is now 'none' | 'error' | 'complete' | 'input-required' | 'unrecognized', read from the answer's resultType on the wire. Only an error or resultType 'complete' is a final answer. Keying "final" on 'complete', so that anything this revision doesn't define (a task-style result, a missing or non-string resultType) is 'unrecognized' and reads as unknown, was Nigel-1925's suggestion (#58520). - requestFailure(): a complete result that can't be used stays a plain error. A latest answer that doesn't finish the call is now no-response, outcome unknown, whatever ended the call: the deadline in the pause between rounds, the connection gone, or this client unable to go on (an input request it can't supply, the 10-round cap). That last part reverses what the comment documented ("input this client can't supply, a rounds cap" were plain errors). Each round re-invokes the server's handler with the echoed requestState, so a multi-step flow can have acted by the cap, and the outcome is unknown. Keeping the category on the wire record alone also keeps the module's own rule: classified by what crossed the boundary, never by the error's class. The error class now only picks the wording, as RequestTimeout already did. - Wording. Between rounds nothing is in flight, so the deadline message no longer claims cancellation was requested: "did not finish X within Nms: it asked for another round (input_required), and the deadline passed before that round was sent, so nothing was in flight to cancel. The outcome is unknown: the server may already have acted on the call. Verify state before retrying; ...". Other endings name the SDK's reason ("... the call ended before that round was sent: Not connected."). The in-flight deadline message is unchanged. - The module header, README and changelog.d/modern-mcp-client.added.md said "at the deadline cancellation is requested" unconditionally. They now say that only a leg in flight is cancelled, and that a call ending after an input_required answer is no-response. changelog.d/mcpl-request-outcome.added.md is Petra-1012's 10380dd, shared with #247-#257, and is left alone. Tests (mcp-modern-outcomes, raw fixture): `cont` gains arguments.leg1 'exit' (exits just after answering leg 1) and 'elicit' (asks for elicitation input), and leg2 'again' (sheds load on every round). New `task` and `bare` tools answer with resultType 'task' and with no resultType. Four new tests: - deadline between rounds: requestTimeoutMs 240, below the 250 ms pause, so the deadline always falls inside it and leg 1 has the whole budget to answer. Asserts no-response, no cancellation claim, one leg, and no notifications/cancelled on the wire; - connection lost between rounds: no-response "Not connected", one leg; - an elicitation request this client can't supply and the rounds cap (1 + 11 legs): both no-response; - a result type this revision doesn't define, and a result with none: both no-response. The existing "second leg decides" test (an error on leg 2 is error-response, a hung leg 2 is no-response with cancellation) passes unchanged. Evidence, VPS, node 24.21.0: mcp-modern-outcomes + mcp-modern-connection 27/27 (23 before plus 4). Six mutations, each failing exactly the tests aimed at it: c35d286's file (the 4 new tests), input_required read as complete (3), an unrecognized type read as complete (1), a missing resultType read as complete (1; Nigel-1925 found this one unpinned in review, hence `bare`), the between-rounds deadline wording (1), and no unfinished-answer branch (4). Nigel-1925's probe of the review's table now reads no-response in every row: 600 ms with leg 1 at 380 or 450 ms, and 1000 ms with leg 1 at 900 ms, each with 1 leg and 0 cancels. The 100 ms row keeps the in-flight cancellation (2 legs, 1 cancel). --- README.md | 12 ++-- changelog.d/modern-mcp-client.added.md | 12 ++-- src/mcpl/modern-connection.ts | 59 ++++++++++++++---- test/fixtures/modern-raw-server.mjs | 26 +++++++- test/mcp-modern-outcomes.test.ts | 85 ++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 545279646..6ca53d160 100644 --- a/README.md +++ b/README.md @@ -297,10 +297,14 @@ two ways: - `requestTimeoutMs` is one deadline per tool call and must be 1 to 2^31−1; 0 is refused, where MCPL reads it as no watchdog. -At the deadline cancellation is requested, the outcome is reported as -unknown, and the call is never retried. An MCPL server that refuses -`2024-11-05` with `-32022` fails with `McplProtocolVersionError`, which names -the fix, and is not retried. +At the deadline, cancellation is requested for a leg still in flight; +between the rounds of an `input_required` continuation nothing is in flight +to cancel. Either way the outcome is reported as unknown (`no-response`), +and the call is never retried. A call that ends for any other reason after +the server asked for another round, before its final answer, is reported +the same way, since the server may already have acted. An MCPL server that +refuses `2024-11-05` with `-32022` fails with `McplProtocolVersionError`, +which names the fix, and is not retried. #### Feature sets diff --git a/changelog.d/modern-mcp-client.added.md b/changelog.d/modern-mcp-client.added.md index ec5225fd4..8d4a38ac6 100644 --- a/changelog.d/modern-mcp-client.added.md +++ b/changelog.d/modern-mcp-client.added.md @@ -14,12 +14,16 @@ - **HTTP credentials:** come from `token`/`accessProvider`, as a cached bearer refreshed once on 401. - **Deadline:** each call gets one `requestTimeoutMs` deadline across every - leg. At the deadline cancellation is requested, the outcome is reported - as unknown, and nothing is replayed. + leg and the pauses between them. At the deadline cancellation is + requested for a leg in flight (between continuation rounds none is), the + outcome is reported as unknown, and nothing is replayed. - **Failures:** reported as `McplRequestError`, with the outcome taken from what actually crossed the transport: `not-sent`, `error-response` (HTTP - 401/403 with `data.httpStatus`) or `no-response`. A server answer that - can't be used is a plain error. + 401/403 with `data.httpStatus`) or `no-response`. Only an error or a + `complete` result is a final answer: a call that ends after an + `input_required` answer, or after a result type this revision doesn't + define, is `no-response`. A complete result that can't be used is a + plain error. - **Lifetime:** reconnects use the legacy backoff settings. A tool-list change subscription is kept open, reopened if refused or lost, and its changes go through ordinary admission, so they park under quiesce like diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index d4bd74e25..2ad44f6d8 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -14,13 +14,17 @@ * - **HTTP credentials** come from `token`/`accessProvider`: a cached bearer * token, re-resolved once when the server answers 401. * - **One deadline per tool call** (`requestTimeoutMs`, always positive) - * bounds every leg of the call. At the deadline the SDK requests - * cancellation and the outcome is unknown. Calls are never replayed. + * bounds every leg of the call and the pauses between them. At the + * deadline the SDK requests cancellation of a leg in flight; between + * continuation rounds nothing is in flight. Either way the outcome is + * unknown. Calls are never replayed. * - **Request outcomes from the wire.** A failed call is classified by what * crossed Connectome's transport boundary, never by the error's class. The * SDK raises the same error classes locally (an invalid outputSchema before * dispatch, output validation after a successful result) as it does for - * server answers. + * server answers. A call's answer is the server's final one: an + * `input_required` answer asks for another round, so a call that ends + * after one has no answer. * - **Lifetime.** On a lost transport the connection restarts with the same * backoff settings as legacy, when `reconnect` is set. List-change * awareness is kept while the connection lives: a subscription the server @@ -79,12 +83,15 @@ export interface ModernToolCallResult { * any of its requests handed to the transport, and how did the server answer * the most recent one? A logical call can take several legs: a * header-mismatch retry, or the rounds of an `input_required` continuation. + * Only an error or a `complete` result is the server's final answer. + * `input-required` asks for another round, and a result of any other type, + * or of none (`unrecognized`), doesn't say the call finished. */ interface WireRecord { readonly method: string; handedOff: boolean; currentId?: string | number; - answer: 'none' | 'result' | 'error'; + answer: 'none' | 'error' | 'complete' | 'input-required' | 'unrecognized'; error?: { code: number; message: string; data?: unknown }; } @@ -173,7 +180,9 @@ class ObservedTransport implements Transport { record.answer = 'error'; record.error = message.error as WireRecord['error']; } else { - record.answer = 'result'; + const result = (message as { result?: unknown }).result; + const type = typeof result === 'object' && result !== null ? (result as { resultType?: unknown }).resultType : undefined; + record.answer = type === 'complete' ? 'complete' : type === 'input_required' ? 'input-required' : 'unrecognized'; } return record; } @@ -725,7 +734,9 @@ export class ModernMcpConnection extends EventEmitter { /** * Map a failure onto the request-outcome contract by what crossed the - * transport boundary for this request, claiming no more than that shows: + * transport boundary for this request, claiming no more than that shows. + * The call's answer is the server's final one: an error or a `complete` + * result. * - nothing handed to the transport: `not-sent`, whatever the SDK raised * (a pre-dispatch validation, a signal already aborted, no transport); * - an HTTP 401/403 refusal: `error-response`. The server answered, at the @@ -733,9 +744,17 @@ export class ModernMcpConnection extends EventEmitter { * JSON-RPC code space's (absent); * - the most recent leg answered with a JSON-RPC error: `error-response` * with that error's code and data; - * - the most recent leg answered with a result: the server acted, but what - * it returned can't be used (output validation, input this client can't - * supply, a rounds cap). That's a plain error, not a request outcome; + * - the most recent leg answered with a complete result: the server + * finished, but what it returned can't be used (output validation). + * That's a plain error, not a request outcome; + * - the most recent leg answered without finishing the call: `no-response`, + * since the server may have acted on what it received. An + * `input_required` answer asks for another round, and the call can end + * before that round is sent: at the deadline, when the connection is + * gone, or when this client can't go on (input it can't supply, the + * rounds cap). Nothing is in flight then, so nothing is cancelled. A + * result of a type this client doesn't recognize ends the call where it + * is; * - handed off and unanswered: `no-response`. At the deadline, cancellation * was requested and the outcome is unknown. */ @@ -753,12 +772,28 @@ export class ModernMcpConnection extends EventEmitter { const { code, message: wireMessage, data } = record.error; return new McplRequestError(`${prefix} returned error for ${what}: [${code}] ${wireMessage}`, 'error-response', code, data); } - if (record.answer === 'result') { + if (record.answer === 'complete') { return new Error(`${prefix} answered ${what}, but the result can't be used: ${message}`); } - if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { + const deadline = error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout; + const within = deadline && timeoutMs ? ` within ${timeoutMs}ms` : ''; + if (record.answer === 'input-required' || record.answer === 'unrecognized') { + const reason = /[.!?]$/.test(message) ? message : `${message}.`; + const ending = record.answer === 'unrecognized' + ? `its answer was not a complete result: ${reason}` + : deadline + ? 'it asked for another round (input_required), and the deadline passed before that round was sent, so nothing was in flight to cancel.' + : `it asked for another round (input_required), and the call ended before that round was sent: ${reason}`; return new McplRequestError( - `${prefix} did not answer ${what}${timeoutMs ? ` within ${timeoutMs}ms` : ''}. Cancellation was requested; ` + + `${prefix} did not finish ${what}${within}: ${ending} ` + + `The outcome is unknown: the server may already have acted on the call. Verify state before retrying; ` + + `a blind retry of a side-effecting tool may duplicate it.`, + 'no-response', + ); + } + if (deadline) { + return new McplRequestError( + `${prefix} did not answer ${what}${within}. Cancellation was requested; ` + `the outcome is unknown: the tool may still complete server-side. Verify state before retrying; ` + `a blind retry of a side-effecting tool may duplicate it.`, 'no-response', diff --git a/test/fixtures/modern-raw-server.mjs b/test/fixtures/modern-raw-server.mjs index 00866dc40..da7abbfb5 100644 --- a/test/fixtures/modern-raw-server.mjs +++ b/test/fixtures/modern-raw-server.mjs @@ -16,7 +16,10 @@ // a tool list change on the open subscription), nullframe (writes JSON lines // that aren't JSON-RPC messages, then answers), cont (a state-only // input_required first leg; the second leg does what arguments.leg2 says: -// 'error', 'hang' or 'ok'), die (exits). +// 'error', 'hang', 'ok', or 'again' for another state-only round every time. +// arguments.leg1 'exit' exits just after the first leg's answer, and +// 'elicit' asks for elicitation input instead), task (a result type this +// revision doesn't define), bare (a result with no resultType), die (exits). import { createInterface } from 'node:readline'; import { appendFileSync, existsSync, readFileSync } from 'node:fs'; @@ -82,6 +85,8 @@ createInterface({ input: process.stdin }).on('line', (line) => { { name: 'touch', inputSchema: { type: 'object' } }, { name: 'nullframe', inputSchema: { type: 'object' } }, { name: 'cont', inputSchema: { type: 'object' } }, + { name: 'task', inputSchema: { type: 'object' } }, + { name: 'bare', inputSchema: { type: 'object' } }, { name: 'die', inputSchema: { type: 'object' } }, ], }); @@ -96,17 +101,32 @@ createInterface({ input: process.stdin }).on('line', (line) => { return result(m.id, { content: [{ type: 'text', text: subscription === null ? 'no subscription' : 'touched' }] }); } if (name === 'die') { setTimeout(() => process.exit(5), 10); return result(m.id, { content: [{ type: 'text', text: 'bye' }] }); } + if (name === 'task') return send({ jsonrpc: '2.0', id: m.id, result: { resultType: 'task', taskId: 'task-1' } }); + if (name === 'bare') return send({ jsonrpc: '2.0', id: m.id, result: { content: [{ type: 'text', text: 'no resultType' }] } }); if (name === 'nullframe') { process.stdout.write('null\n[1,2]\n"just a string"\n{"no":"jsonrpc"}\n'); return result(m.id, { content: [{ type: 'text', text: 'after the noise' }] }); } if (name === 'cont') { - const leg2 = m.params?.arguments?.leg2; + const { leg1, leg2 } = m.params?.arguments ?? {}; if (m.params?.requestState === undefined) { - return send({ jsonrpc: '2.0', id: m.id, result: { resultType: 'input_required', requestState: `state-for-${leg2}` } }); + if (leg1 === 'elicit') { + return send({ + jsonrpc: '2.0', + id: m.id, + result: { + resultType: 'input_required', + inputRequests: { confirm: { method: 'elicitation/create', params: { message: 'Proceed?', requestedSchema: { type: 'object', properties: {} } } } }, + }, + }); + } + send({ jsonrpc: '2.0', id: m.id, result: { resultType: 'input_required', requestState: `state-for-${leg2}` } }); + if (leg1 === 'exit') setTimeout(() => process.exit(5), 10); + return; } if (leg2 === 'error') return error(m.id, -32603, 'second leg failed', { leg: 2, state: m.params.requestState }); if (leg2 === 'hang') return; + if (leg2 === 'again') return send({ jsonrpc: '2.0', id: m.id, result: { resultType: 'input_required', requestState: `${m.params.requestState}+` } }); return result(m.id, { content: [{ type: 'text', text: `leg 2 done (${m.params.requestState})` }] }); } return result(m.id, { content: [{ type: 'text', text: 'plain' }] }); diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts index 651f6fc7e..bd555d669 100644 --- a/test/mcp-modern-outcomes.test.ts +++ b/test/mcp-modern-outcomes.test.ts @@ -7,6 +7,11 @@ * - a result that fails validation is an answer that can't be used (1 call, * answered), never a claimed server error; * - a JSON-RPC error on the wire is `error-response` with its code and data; + * - a call that ends after an `input_required` answer, before a final one, is + * `no-response`: at the deadline (with nothing in flight to cancel), with + * the connection gone, or when this client can't go on (PR #262 review); + * so is one whose answer has a result type this revision doesn't define, + * or none; * - a list-change subscription refused at connect is reopened; * - `close()` during a reconnect handshake ends the launch it was waiting on. */ @@ -166,6 +171,86 @@ test('an input_required continuation keeps its evidence: the second leg decides assert.equal(calls(log, 'cont'), 4, 'two legs each'); }); +test('a deadline between continuation rounds is no-response, and claims no cancellation', async () => { + // The SDK pauses 250 ms before a state-only round. With a shorter budget, + // the deadline passes inside that pause whenever leg 1 answers in time. + const log = scratchLog(); + const connection = await connect('', log, { requestTimeoutMs: 240 }); + await connection.listTools(); + await assert.rejects(connection.callTool('cont', { leg2: 'ok' }), (err: unknown) => { + assert.ok(err instanceof McplRequestError, String(err)); + assert.equal(err.outcome, 'no-response'); + assert.match( + err.message, + /did not finish tools\/call "cont" within 240ms: it asked for another round \(input_required\), and the deadline passed before that round was sent, so nothing was in flight to cancel\. The outcome is unknown/, + ); + assert.doesNotMatch(err.message, /Cancellation was requested|answered/); + return true; + }); + await new Promise((r) => setTimeout(r, 100)); + assert.equal(calls(log, 'cont'), 1, 'only leg 1 reached the wire'); + assert.equal(wire(log).filter((e) => e.method === 'notifications/cancelled').length, 0, 'nothing was cancelled'); +}); + +test('a connection lost between continuation rounds is no-response, not an answer', async () => { + const log = scratchLog(); + const connection = await connect('', log); + await connection.listTools(); + await assert.rejects(connection.callTool('cont', { leg1: 'exit', leg2: 'ok' }), (err: unknown) => { + assert.ok(err instanceof McplRequestError, String(err)); + assert.equal(err.outcome, 'no-response'); + assert.match( + err.message, + /did not finish tools\/call "cont": it asked for another round \(input_required\), and the call ended before that round was sent: Not connected\. The outcome is unknown/, + ); + return true; + }); + assert.equal(calls(log, 'cont'), 1); +}); + +test('a continuation this client cannot go on with is no-response: the server may have acted', async () => { + const log = scratchLog(); + const connection = await connect('', log); + await connection.listTools(); + // Input this client can't supply: it advertises no elicitation. + await assert.rejects(connection.callTool('cont', { leg1: 'elicit' }), (err: unknown) => { + assert.ok(err instanceof McplRequestError, String(err)); + assert.equal(err.outcome, 'no-response'); + assert.match(err.message, /it asked for another round \(input_required\), and the call ended before that round was sent: Cannot fulfil input request 'confirm'/); + return true; + }); + assert.equal(calls(log, 'cont'), 1); + // A server that sheds load on every round meets the rounds cap: the first + // leg and ten retries. + await assert.rejects(connection.callTool('cont', { leg2: 'again' }), (err: unknown) => { + assert.ok(err instanceof McplRequestError, String(err)); + assert.equal(err.outcome, 'no-response'); + assert.match(err.message, /the call ended before that round was sent: Multi-round-trip request 'tools\/call' still required input after 10 rounds/); + return true; + }); + assert.equal(calls(log, 'cont'), 1 + 11); +}); + +test('a result type this revision does not define, or none, is not a final answer: no-response', async () => { + const log = scratchLog(); + const connection = await connect('', log); + await connection.listTools(); + await assert.rejects(connection.callTool('task', {}), (err: unknown) => { + assert.ok(err instanceof McplRequestError, String(err)); + assert.equal(err.outcome, 'no-response'); + assert.match(err.message, /did not finish tools\/call "task": its answer was not a complete result: Unsupported result type 'task'.*The outcome is unknown/); + return true; + }); + await assert.rejects(connection.callTool('bare', {}), (err: unknown) => { + assert.ok(err instanceof McplRequestError, String(err)); + assert.equal(err.outcome, 'no-response'); + assert.match(err.message, /did not finish tools\/call "bare": its answer was not a complete result: .*missing required resultType.*The outcome is unknown/); + return true; + }); + assert.equal(calls(log, 'task'), 1); + assert.equal(calls(log, 'bare'), 1); +}); + test('concurrent continuations never cross their evidence', async () => { const log = scratchLog(); const connection = await connect('', log, { requestTimeoutMs: 2000 }); From 0adf92e74d3746b4fc7b4dae456d929f9ffb64b4 Mon Sep 17 00:00:00 2001 From: Otis-1924 Date: Thu, 8 Oct 2026 18:22:50 -0400 Subject: [PATCH 16/94] feat(mcp): warn when a server's credential would cross the network unencrypted From slimepriestess's review of #262 (non-blocking note 3). A plain listener on a LAN address received "Authorization: Bearer SECRET" from a modern http:// server config with nothing logged. The legacy ws:// path does the same with ?token=, so this is parity, not a new hole. A refusal would break deliberate setups (container networks, tailnets, a TLS-terminating proxy), so this warns. It covers ws:// too, as Nigel-1925 suggested (room-341 #58500), so the two families behave alike. - protocol-family.ts: serverConfigWarnings(config) returns a message when a token or accessProvider is configured for an http:// or ws:// url whose host isn't loopback (localhost, *.localhost, 127.0.0.0/8, [::1], and the unspecified 0.0.0.0 and [::], which connect to this host). Stdio configurations, encrypted schemes and unusable configurations give no warning; an unusable one is serverConfigProblems' to report. checkServerConfig() now logs each warning with console.warn ("[mcp] ..."), after the problems check, so both engines warn once per connection at admission. That is ModernMcpConnection's constructor and McplServerConnection.connect()/connectWithReconnect(). It's exported beside serverConfigProblems for hosts' own validation. - server-connection.ts: connectWithReconnect() checked the configuration itself and then called connect(), which checked it again, so the warning would have been logged twice. The body of connect() is now connectChecked(), which both public entries call after their single check. There is no behaviour change otherwise. No test pins the single warning on this path: that needs a dial to a non-loopback host that fails at once. The cheap candidate, ws://0.0.0.0:1 (Nigel-1925's suggestion), is in fact local, so it is quiet here rather than a trigger. - The access provider's error message is still logged verbatim, as the WebSocket dial already does (transport.ts). Redacting it would drop the diagnosis, and only the provider knows what is secret in it. Tests (mcp-protocol-family): the warning cases for both schemes and the quiet ones (https, wss, loopback forms including 127.0.0.2 and 0.0.0.0, no credential, stdio with a url, an unusable scheme); checkServerConfig logs exactly one line, and ModernMcpConnection.create() logs one before anything is dialed. Mutations, each caught: no warnings at all (2 tests), checkServerConfig not logging (1), nothing counted as loopback (1), only 127.0.0.1 counted (1; from Nigel-1925's review), and the unspecified addresses not counted (1). --- .../cleartext-mcp-credential-warning.added.md | 6 +++ src/index.ts | 1 + src/mcpl/index.ts | 1 + src/mcpl/protocol-family.ts | 38 +++++++++++++++- src/mcpl/server-connection.ts | 11 ++++- test/mcp-protocol-family.test.ts | 45 +++++++++++++++++++ 6 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 changelog.d/cleartext-mcp-credential-warning.added.md diff --git a/changelog.d/cleartext-mcp-credential-warning.added.md b/changelog.d/cleartext-mcp-credential-warning.added.md new file mode 100644 index 000000000..4851f6685 --- /dev/null +++ b/changelog.d/cleartext-mcp-credential-warning.added.md @@ -0,0 +1,6 @@ +- Connecting to an MCP server warns once when its credential (`token` or + `accessProvider`) would cross the network unencrypted: an `http://` or + `ws://` url whose host isn't loopback. It warns rather than refuses, since a + private network or a TLS-terminating proxy can make that deliberate. + `serverConfigWarnings` is exported beside `serverConfigProblems`, so a host + can apply the same check in its own configuration validation. diff --git a/src/index.ts b/src/index.ts index 39d1a6938..3b5de57a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -98,6 +98,7 @@ export { MODERN_MCP_PROTOCOL_VERSION, resolveServerBinding, serverConfigProblems, + serverConfigWarnings, checkServerConfig, } from './mcpl/index.js'; export type { McpProtocolFamily, McpTransportKind, ServerBinding } from './mcpl/index.js'; diff --git a/src/mcpl/index.ts b/src/mcpl/index.ts index aeca314a1..02230b523 100644 --- a/src/mcpl/index.ts +++ b/src/mcpl/index.ts @@ -116,6 +116,7 @@ export { MCPL_ONLY_POLICY_FIELDS, resolveServerBinding, serverConfigProblems, + serverConfigWarnings, checkServerConfig, } from './protocol-family.js'; export type { McpProtocolFamily, McpTransportKind, ServerBinding } from './protocol-family.js'; diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index 19cc96a60..cea22cedb 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -154,9 +154,45 @@ export function serverConfigProblems(config: McplServerConfig): string[] { return problems; } -/** Throw one error listing every problem, or return the resolved binding. */ +/** A host a connection to which never leaves this machine. The unspecified + * addresses count: a connection to 0.0.0.0 or [::] reaches this host. */ +function isLoopbackHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return host === 'localhost' || host.endsWith('.localhost') || host === '[::1]' || /^127(\.\d{1,3}){3}$/.test(host) || + host === '0.0.0.0' || host === '[::]'; +} + +/** + * What is usable in a server configuration but worth an operator's attention, + * as messages; empty when there is nothing. A credential (`token` or + * `accessProvider`) for an `http://` or `ws://` url whose host isn't loopback + * crosses the network in cleartext, in the `Authorization` header or the + * WebSocket `?token=` query. That can be deliberate, on a private network or + * behind a TLS-terminating proxy, so it is a warning and never a refusal. + */ +export function serverConfigWarnings(config: McplServerConfig): string[] { + if (!config.token && !config.accessProvider) return []; + let transport: McpTransportKind; + try { + transport = resolveServerBinding(config).transport; + } catch { + return []; // an unusable configuration is serverConfigProblems' to report + } + if (transport === 'stdio') return []; + const url = new URL(config.url!); + if ((url.protocol !== 'http:' && url.protocol !== 'ws:') || isLoopbackHost(url.hostname)) return []; + const secure = url.protocol === 'http:' ? 'https' : 'wss'; + return [ + `MCP server "${config.id}": its credential goes to ${url.host} unencrypted (${url.protocol}//), where anything on the ` + + `network path can read it; use ${secure}:// unless that network is trusted`, + ]; +} + +/** Throw one error listing every problem. Otherwise log each warning and + * return the resolved binding. Both engines admit a configuration here. */ export function checkServerConfig(config: McplServerConfig): ServerBinding { const problems = serverConfigProblems(config); if (problems.length > 0) throw new Error(problems.join('; ')); + for (const warning of serverConfigWarnings(config)) console.warn(`[mcp] ${warning}`); return resolveServerBinding(config); } diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 1a87641f7..e1c5b1463 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -620,6 +620,15 @@ export class McplServerConnection extends EventEmitter { hostCapabilities: McplHostCapabilities, ): Promise { McplServerConnection.checkConfig(config); + return McplServerConnection.connectChecked(config, hostCapabilities); + } + + /** {@link connect}, for a configuration its caller has already checked + * (and warned about) once. */ + private static async connectChecked( + config: McplServerConfig, + hostCapabilities: McplHostCapabilities, + ): Promise { let established: Awaited>; try { established = await McplServerConnection.handshake(config, hostCapabilities); @@ -825,7 +834,7 @@ export class McplServerConnection extends EventEmitter { // Outside the retry path below: a configuration error is never retried. McplServerConnection.checkConfig(config); try { - return await McplServerConnection.connect(config, hostCapabilities); + return await McplServerConnection.connectChecked(config, hostCapabilities); } catch (error) { // No common protocol revision is a configuration fact, not an outage: // a retry stub would restart the server forever for nothing. diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index 5b104335c..5a0d31d20 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -10,9 +10,12 @@ import assert from 'node:assert/strict'; import { resolveServerBinding, serverConfigProblems, + serverConfigWarnings, + checkServerConfig, MCPL_ONLY_POLICY_FIELDS, MAX_TIMER_MS, } from '../src/mcpl/protocol-family.js'; +import { ModernMcpConnection } from '../src/mcpl/modern-connection.js'; import type { McplServerConfig } from '../src/mcpl/types.js'; const cfg = (c: Partial): McplServerConfig => ({ id: 's', ...c }); @@ -102,3 +105,45 @@ test('MCPL policy on a modern server is rejected, empty values are not', () => { }); assert.deepEqual(serverConfigProblems(empty), []); }); + +test('a credential sent in cleartext to a host that is not loopback is warned about, never refused', () => { + const provider = async () => 'fresh'; + const warned = (c: Partial) => serverConfigWarnings(cfg(c)); + // The Authorization header over http, and the ?token= query over ws. + assert.match(warned({ url: 'http://10.0.0.5:8080/mcp', token: 't' })[0]!, /credential goes to 10\.0\.0\.5:8080 unencrypted \(http:\/\/\).*use https:\/\//); + assert.match(warned({ url: 'ws://mcp.lan/mcpl', accessProvider: provider })[0]!, /credential goes to mcp\.lan unencrypted \(ws:\/\/\).*use wss:\/\//); + assert.equal(serverConfigProblems(cfg({ url: 'http://10.0.0.5:8080/mcp', token: 't' })).length, 0, 'still usable'); + // Nothing to warn about: encrypted, loopback, no credential, or not dialed. + for (const quiet of [ + { url: 'https://10.0.0.5/mcp', token: 't' }, + { url: 'wss://mcp.lan/mcpl', token: 't' }, + { url: 'http://localhost:8080/mcp', token: 't' }, + { url: 'http://127.0.0.1:8080/mcp', token: 't' }, + { url: 'http://127.0.0.2:8080/mcp', token: 't' }, + { url: 'ws://0.0.0.0:8080/mcpl', token: 't' }, + { url: 'http://[::1]:8080/mcp', token: 't' }, + { url: 'ws://app.localhost/mcpl', token: 't' }, + { url: 'http://10.0.0.5:8080/mcp' }, + { command: 'srv', url: 'http://10.0.0.5/mcp', token: 't' }, + { url: 'ftp://10.0.0.5/mcp', token: 't' }, + ]) assert.deepEqual(warned(quiet), [], JSON.stringify(quiet)); +}); + +test('both engines admit a configuration through checkServerConfig, which logs its warnings', () => { + const lines: string[] = []; + const realWarn = console.warn; + console.warn = (...args: unknown[]) => { lines.push(args.join(' ')); }; + try { + checkServerConfig(cfg({ url: 'ws://mcp.lan/mcpl', token: 't' })); + assert.equal(lines.length, 1); + assert.match(lines[0]!, /^\[mcp\] MCP server "s": its credential goes to mcp\.lan unencrypted/); + // The modern engine checks at create(), before anything is dialed. + ModernMcpConnection.create(cfg({ id: 'm', url: 'http://10.0.0.5:8080/mcp', token: 't' })); + assert.equal(lines.length, 2); + assert.match(lines[1]!, /MCP server "m": its credential goes to 10\.0\.0\.5:8080 unencrypted/); + checkServerConfig(cfg({ url: 'https://10.0.0.5/mcp', token: 't' })); + assert.equal(lines.length, 2, 'nothing for https'); + } finally { + console.warn = realWarn; + } +}); From 660ed6fc101abf8a3921f6d08793eb0154113451 Mon Sep 17 00:00:00 2001 From: Otis-1924 Date: Thu, 8 Oct 2026 18:22:50 -0400 Subject: [PATCH 17/94] fix(mcp): a protocol-version verdict at startup raises the ops alert From slimepriestess's review of #262 (non-blocking note 3). main ignored an MCPL server's protocolVersion, and this PR's legacy engine now requires 2024-11-05 back. So a hand-written resident server that omits it "vanishes at startup with one console.error line (connected:false, retrying:false, 0 tools)". The strict check stays: room-284 chose it after surveying real servers, and the review found every house MCPL server answering exactly 2024-11-05. Part of the gap was ours, as Nigel-1925 found (room-341 #58500). The same verdict on reconnect raises the mcpl-down ops alert at once, because no later attempt comes. At startup it only logged and traced mcpl:server-connect-failed (willRetry false), since no stub exists to emit the permanent event. initializeMcpl's fail-open catch now raises the same alert for a McplProtocolVersionError: failures.log, an ops:alert trace, and the ops webhook when configured. A runtime connectMcplServer() needs no alert: its caller gets the error. Not taken: a one-release warning mode (the version check is the point, and a transitional mode is a deferral), and a failure reason in listMcplServers(). Every kind of connect failure lacks that today, so it belongs to its own change. Test (mcpl-legacy-version): a framework started with a 'missing'-mode server and reconnect: true. A module subscribes to traces in start(), which runs before the MCP servers connect, and sees mcpl:server-connect-failed with willRetry false, then ops:alert kind mcpl-down for that server, naming "protocolVersion (none)". Mutation (the alert removed): that test fails, nothing else. --- .../legacy-mcp-protocol-boundary.breaking.md | 4 ++- src/framework.ts | 5 +++ test/mcpl-legacy-version.test.ts | 35 ++++++++++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/changelog.d/legacy-mcp-protocol-boundary.breaking.md b/changelog.d/legacy-mcp-protocol-boundary.breaking.md index 92d319362..84cf28ddd 100644 --- a/changelog.d/legacy-mcp-protocol-boundary.breaking.md +++ b/changelog.d/legacy-mcp-protocol-boundary.breaking.md @@ -10,7 +10,9 @@ with `protocol: 'modern'`. A protocol-version verdict is never retried: no reconnect stub, and a reconnecting connection stops its backoff loop (`reconnect-failed` carries `permanent: true` and raises the ops alert at - once). The established revision is on `connection.protocolVersion`. + once). At startup the verdict raises the same `mcpl-down` ops alert, so a + server refused for its revision doesn't drop out with only a log line. The + established revision is on `connection.protocolVersion`. - `tools/list` now follows `nextCursor` across pages, so a paginating server's whole inventory reaches the agent; before, every page after the first was silently dropped. A repeated cursor, or more than 100 pages, is an error diff --git a/src/framework.ts b/src/framework.ts index 394fc991c..194b0fee1 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -13388,6 +13388,11 @@ export class AgentFramework { // only configuration can fix it). willRetry: config.reconnect === true && !(error instanceof McplProtocolVersionError), }); + // So the server doesn't vanish quietly: alert at once, as the + // reconnect path does for the same verdict, since no attempt follows. + if (error instanceof McplProtocolVersionError) { + this.opsAlert('mcpl-down', config.id, `MCPL server unreachable at startup: ${err.message}`); + } } } diff --git a/test/mcpl-legacy-version.test.ts b/test/mcpl-legacy-version.test.ts index d672fea31..050cd5b65 100644 --- a/test/mcpl-legacy-version.test.ts +++ b/test/mcpl-legacy-version.test.ts @@ -2,7 +2,8 @@ * The legacy engine's protocol boundary and inventory (room-284, shelf-487): * - it offers and accepts exactly MCP 2024-11-05; anything else is a typed * McplProtocolVersionError carrying what each side said; - * - such a verdict is never retried (no reconnect stub, no backoff loop); + * - such a verdict is never retried (no reconnect stub, no backoff loop), and + * at startup it raises the ops alert at once, as it does on reconnect; * - tools/list follows nextCursor to the complete inventory, and refuses a * server whose cursors don't terminate. */ @@ -15,6 +16,8 @@ import { fileURLToPath } from 'node:url'; import { McplServerConnection, McplProtocolVersionError, McplRequestError } from '../src/mcpl/server-connection.js'; import type { McplHostCapabilities, McplServerConfig } from '../src/mcpl/types.js'; +import { AgentFramework } from '../src/framework.js'; +import type { Module, ModuleContext, ToolDefinition, ToolResult, TraceEvent } from '../src/index.js'; const here = dirname(fileURLToPath(import.meta.url)); const SERVER = join(here, 'fixtures', 'legacy-version-server.mjs'); @@ -126,6 +129,36 @@ test('a version verdict on reconnect stops the backoff loop', async () => { assert.equal(failures.length, 1); }); +test('a version verdict at startup raises the ops alert at once, as on reconnect', async () => { + const dir = mkdtempSync(join(tmpdir(), 'legacy-version-')); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + // Modules start before the MCP servers connect, so this one sees startup. + const traces: TraceEvent[] = []; + const watcher: Module = { + name: 'trace-watcher', + async start(ctx: ModuleContext) { ctx.onTrace((event) => traces.push(event)); }, + async stop() {}, + getTools: (): ToolDefinition[] => [], + handleToolCall: async (): Promise => ({ success: false, error: 'no tools', isError: true }), + onProcess: async () => ({}), + }; + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: { complete: async () => { throw new Error('not used'); } } as unknown as import('@animalabs/membrane').Membrane, + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], + modules: [watcher], + mcplServers: [config('missing', { reconnect: true })], + }); + cleanups.push(() => framework.stop()); + const failed = traces.find((t) => t.type === 'mcpl:server-connect-failed') as { serverId?: string; willRetry?: boolean } | undefined; + assert.equal(failed?.serverId, 'legacy'); + assert.equal(failed?.willRetry, false); + const alert = traces.find((t) => t.type === 'ops:alert') as { kind?: string; agentName?: string; message?: string } | undefined; + assert.equal(alert?.kind, 'mcpl-down'); + assert.equal(alert?.agentName, 'legacy'); + assert.match(alert?.message ?? '', /unreachable at startup: .*protocolVersion \(none\)/); +}); + test('tools/list follows nextCursor to the complete inventory', async () => { const connection = await connect(config('ok')); const { tools } = await connection.sendToolsList(); From f56d854f2f17a0dee951bcd05fb45702dfe384f7 Mon Sep 17 00:00:00 2001 From: Agnes-1009 Date: Sun, 11 Oct 2026 17:09:08 -0400 Subject: [PATCH 18/94] fix(mcp): the tool-result normalizer drops nothing silently, keeps inline only what a provider takes, and a failed save is a lost payload A review of this change found that the standard tool-result reading still lost payloads silently on several paths, inlined what a provider would refuse, and let a failed workspace save escape as a dispatch failure. This commit is that part of the round: src/mcpl/tool-result-normalize.ts and the framework's save closure for it. **Nothing dropped silently** - A failed result is read by the model only as its error text. That covers isError, a payload that couldn't be retained, and malformed content. So there an inline image is a payload like audio: saved to the workspace and named, or marked not retained. A not-retained image counts toward the "N payload(s) could not be retained" total. - An image or audio block with inline data but no mimeType has inline data. It's saved as application/octet-stream and stubbed, instead of being reported as having none. - A malformed entry gets a stub saying it was left out. That covers an entry that isn't an object, and a text block whose text isn't a string. Content that is present but isn't an array makes the result a failure ("malformed result"), not an empty success. Absent content stays no content, as a structured-only result may send it. **What stays inline** - An inline image stays native only when its type is one providers take (png, jpeg, gif, webp, compared without parameters or case, and passed on in canonical spelling), and its decoded size is within 5 MB. That's the same ceiling the framework's read_image guard uses. Any other image is saved and stubbed. A saved stub that wasn't shown for another reason than its type says why ("over the 5.0 MB a model takes per image", "a failed result is read as its text alone"). - A native image is passed on without a wrapped encoding's whitespace, which a provider may refuse, and with it the whole request. - An unknown block is still shown as JSON, but bounded like a stub field. A note says how many characters were left out, so a large or binary payload can't flood the model's view. **Bounds and names** - A URI is the reference itself, so resource links, a media block's uri and a resource's uri are bounded at 2048 code points, not 200. - Stub text is bounded by code points, so a cut never splits a surrogate pair. - A payload's decoded size is computed with its whitespace removed, so a wrapped base64 payload isn't overstated. - The extension table is read through Object.hasOwn, so a subtype such as "constructor" falls through to the sanitized name (or "bin"). It used to make Object's own constructor, stringified, the file's extension. **A failed save** - The framework's save closure catches a rejected writeBinary. Like a refused write, it logs why and resolves null, so the payload is a lost part of a completed call's result, not a dispatch failure. - The normalizer logs a save that rejects, naming the file, the kind, the type and the size, where it used to swallow it with .catch(() => null). - Server-chosen types in those log lines are JSON-quoted. The module doc says the rules above. It also corrects what it said programs get: the direct path returns the server's raw content, and a script receives the server's result whole only when it carries structuredContent. An unstructured result reaches a script as this reading, with its payloads saved and stubbed. The test file's header no longer cites the house's room and shelf. The changelog fragment for standard tool results says the new rules: which images stay native, what is saved, a failed result's images, unknown and malformed blocks, and the logged save failure. The release script folds it cleanly. **Tests** - mcp-tool-result-normalize, 11 to 21: - a large unknown block, bounded with its omitted count; - a long real URI kept whole, and a pathological one cut at 2048; - an isError result's image saved, or marked not retained, and an image beside a lost payload counted; - image and audio without mimeType saved as octet-stream; - another image type saved; an image one byte over 5 MB saved and one exactly at it kept; a parameterized, upper-case type passed canonically; - a rejecting save logged and lost; - malformed entries stubbed, non-array content a malformed result, and absent content still fine; - a cut that would have split a surrogate pair; a CRLF-wrapped payload's size; - "constructor" and "__proto__" subtypes; - a CRLF-wrapped native image passed without its wrapping. The bounded-field test now bounds a name, not a URI. - mcp-modern-framework, 14 to 15: a save whose writeBinary rejects, and one the workspace refuses, both resolve to a lost payload with why logged. The incomplete-result test now counts the inline image beside the lost audio and blob: 3 payload(s), not 2. **The check, from clean worktrees as AF's CI runs it** (npm install, tsc --noEmit, npm run build, npm test; Node 24.19.0, the Node npm's scripts ran on too): - **This commit:** tsc and the build are clean. npm test reported 1483 tests across 125 suites, all passing, with the eleven new ones among them. Five tests at the end of channel-registry-routing.test.ts went unreported, with nothing failing. A file's last reports are cut short under --test-force-exit at times, and its parent's run did report those five. - **Its parent:** 1477 of 1477. - **Red first:** this commit's two test files, against the parent's sources, fail exactly the eleven new tests, plus the incomplete-result test's count of 3. The other 24 pass. - **Removals:** each of fourteen, taken out alone, fails the test aimed at it: - the failed result's image kept native; - data without a type read as no data; - the unlogged rejected save; - the unbounded unknown block; - any image type kept native; - no size ceiling; - the URI cut at 200; - a dropped malformed entry; - a non-list read as an empty success; - a cut by UTF-16 units; - the size counting whitespace; - the prototype extension; - a native image keeping its wrapping; - the save closure rethrowing. Co-Authored-By: Agnes (Claude Opus 5.5) --- .../mcp-standard-tool-results.changed.md | 22 +- src/framework.ts | 13 +- src/mcpl/tool-result-normalize.ts | 203 +++++++++++++----- test/mcp-modern-framework.test.ts | 33 ++- test/mcp-tool-result-normalize.test.ts | 159 +++++++++++++- 5 files changed, 363 insertions(+), 67 deletions(-) diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index 27782b0a5..adfe0d362 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -1,14 +1,22 @@ - What the model sees of an MCP tool result from a server that isn't an MCPL peer (an MCP-only legacy server, or a modern server) now follows its content types: - - text stays text, and inline images stay native; - - audio and binary resources are saved to the workspace (`tool-results/`), - each under a unique name, with a bounded stub saying where. If a payload - can't be saved, the result is reported as incomplete (`isError`), noting - the tool may already have completed, never as a success with part of it - missing; + - text stays text; + - an inline image stays native when a provider can take it: png, jpeg, + gif or webp, within 5 MB decoded; + - audio, binary resources, other images, and media with inline data but + no type are saved to the workspace (`tool-results/`), each under a + unique name, with a bounded stub saying where. If a payload can't be + saved, the result is reported as incomplete (`isError`), noting the tool + may already have completed, never as a success with part of it missing, + and the host's log says why the save failed; + - a failed result is read as its text alone, so there an inline image is + saved and stubbed like any other payload; - a `resource_link` is shown as a reference and never fetched; - - an embedded text resource shows its text. + - an embedded text resource shows its text; + - an unknown block is shown as JSON, bounded, saying how much was left + out. A malformed entry is named and left out, and content that isn't a + list of blocks makes the result a failure. Before, any non-text block turned the whole result into raw JSON. `structuredContent` is kept by presence on the new `ToolResult.structured` diff --git a/src/framework.ts b/src/framework.ts index 194b0fee1..8cd3c75ef 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -15453,11 +15453,20 @@ export class AgentFramework { const label = `${new Date().toISOString().slice(0, 10)}-${callId}`.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80) + `-${randomUUID()}`; const workspace = this.getWorkspaceModule(); const mount = workspace ? this.firstWritableMountName(workspace) : null; + // A save that fails resolves null, never rejects: the payload is then a + // lost part of a completed call's result, not a dispatch failure. Why it + // failed goes to the host's log; the stub says only that it did. const save = workspace && mount ? async (fileName: string, bytes: Buffer, mimeType: string) => { const path = `${mount}/tool-results/${fileName}`; - const written = await workspace.writeBinary(path, bytes, mimeType); - return written.success ? path : null; + try { + const written = await workspace.writeBinary(path, bytes, mimeType); + if (written.success) return path; + console.error(`[mcp] saving tool payload ${path} (${JSON.stringify(mimeType)}) failed: ${written.error ?? 'the workspace refused the write'}`); + } catch (error) { + console.error(`[mcp] saving tool payload ${path} (${JSON.stringify(mimeType)}) failed:`, error); + } + return null; } : null; return normalizeStandardToolResult(result, label, save); diff --git a/src/mcpl/tool-result-normalize.ts b/src/mcpl/tool-result-normalize.ts index 2f229c4e5..a0adf48e7 100644 --- a/src/mcpl/tool-result-normalize.ts +++ b/src/mcpl/tool-result-normalize.ts @@ -5,25 +5,27 @@ * uri-form media as references), which is not this module's job. * * An MCP result has two views, and each consumer gets its own: - * - **The content array is the server's model-facing view.** Text stays text - * and inline images stay native. Anything the provider format can't carry - * (audio, binary blobs) is saved to the workspace, and a bounded stub says - * where it went: a stub tells the reader where the payload is; it never - * replaces it silently. If a payload can't be saved, the result is a - * failure to materialize it, not a success. A `resource_link` is shown as a reference and never - * fetched. An embedded resource shows its text, or is saved like a blob. + * - **The content array is the server's model-facing view.** Text stays text, + * and an inline image stays native when a provider can take it: one of the + * types providers accept, within their per-image size. Anything else the + * provider format can't carry (audio, binary blobs, other images) is saved + * to the workspace, and a bounded stub says where it went. A stub tells the + * reader where the payload is; nothing is dropped silently. If a payload + * can't be saved, the result is a failure to materialize it, not a + * success. A `resource_link` is shown as a reference and never fetched. An + * embedded resource shows its text, or is saved like a blob. * - **`structuredContent` is the machine-readable view.** It is kept by * presence on `ToolResult.structured`. The model sees it rendered as JSON * only when the server's content has no text block of its own, because * otherwise the server already chose what the model reads. * - * This is the model's reading. Programs get the server's result itself: - * the direct path returns the raw content, and a script receives a - * structured result whole (framework.ts). + * A failed result is read by the model only as its error text, so there an + * inline image is saved and stubbed too, like any other payload. * - * Before this, a structured-only result reached the model as an empty string, - * and a mixed result became one JSON string with its image as base64 text - * (room-284 probe P1c). + * This is the model's reading. The direct path returns the server's raw + * content instead. A script receives the server's result whole only when it + * carries structuredContent; an unstructured one reaches a script as this + * reading, its payloads saved to the workspace and stubbed (framework.ts). */ import type { ToolResult } from '../types/index.js'; @@ -32,18 +34,61 @@ import type { ToolResult } from '../types/index.js'; export type SaveToolPayload = (fileName: string, bytes: Buffer, mimeType: string) => Promise; /** Stub fields are bounded independently of anything the server sends. */ -const STUB_FIELD_CHARS = 200; +const STUB_FIELD_POINTS = 200; +/** A URI is the reference itself, so it's bounded far more loosely than a + * stub field: a real one survives, and a pathological one can't flood the + * model's view. */ +const URI_POINTS = 2048; +/** The image types providers take inline. Any other image is saved. */ +const NATIVE_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); +/** The decoded size providers take per inline image: the same 5 MB ceiling + * the framework's read_image tool guards. A larger image is saved. */ +const NATIVE_IMAGE_MAX_BYTES = 5 * 1024 * 1024; + +/** Why a payload isn't shown inline, unless said otherwise. */ +const UNSUPPORTED = "the model can't take this type"; type Block = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }; -function bounded(value: unknown): string { - const text = typeof value === 'string' ? value : JSON.stringify(value) ?? ''; - return text.length > STUB_FIELD_CHARS ? `${text.slice(0, STUB_FIELD_CHARS)}…` : text; +/** The first `max` code points of `text`, never splitting a surrogate pair, + * and how many code points were left out. */ +function headPoints(text: string, max: number): { head: string; omitted: number } { + let end = 0; + let points = 0; + while (end < text.length && points < max) { + end += text.codePointAt(end)! > 0xffff ? 2 : 1; + points++; + } + let omitted = 0; + for (let i = end; i < text.length; omitted++) i += text.codePointAt(i)! > 0xffff ? 2 : 1; + return { head: text.slice(0, end), omitted }; +} + +/** Any value as JSON (a string quoted too). Never throws. */ +function asJson(value: unknown): string { + try { + return JSON.stringify(value) ?? 'null'; + } catch { + return Object.prototype.toString.call(value); + } +} + +/** Any value as text: a string as itself, anything else as JSON. Never throws. */ +function asText(value: unknown): string { + return typeof value === 'string' ? value : asJson(value); } +function bounded(value: unknown, max = STUB_FIELD_POINTS): string { + const { head, omitted } = headPoints(asText(value), max); + return omitted > 0 ? `${head}…` : head; +} + +/** A payload's decoded size, its base64 read without the whitespace a + * wrapped encoding carries. */ function decodedSize(base64: string): number { - const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0; - return Math.max(0, Math.floor((base64.length * 3) / 4) - padding); + const compact = base64.replace(/\s+/g, ''); + const padding = compact.endsWith('==') ? 2 : compact.endsWith('=') ? 1 : 0; + return Math.max(0, Math.floor((compact.length * 3) / 4) - padding); } function humanSize(bytes: number): string { @@ -52,13 +97,24 @@ function humanSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } +const KNOWN_EXTENSIONS: Record = { 'octet-stream': 'bin', 'mpeg': 'mp3', 'plain': 'txt', 'x-wav': 'wav' }; + function extensionFor(mimeType: string): string { const subtype = mimeType.split('/')[1]?.split(';')[0]?.trim().toLowerCase() ?? ''; - const known: Record = { 'octet-stream': 'bin', 'mpeg': 'mp3', 'plain': 'txt', 'x-wav': 'wav' }; - const ext = known[subtype] ?? subtype.replace(/[^a-z0-9]/g, ''); + // The table's own keys only: a subtype such as 'constructor' must not find + // what Object.prototype holds. + const ext = Object.hasOwn(KNOWN_EXTENSIONS, subtype) ? KNOWN_EXTENSIONS[subtype]! : subtype.replace(/[^a-z0-9]/g, ''); return ext.length > 0 && ext.length <= 10 ? ext : 'bin'; } +/** The image type a provider takes inline, in its canonical spelling, or + * null when it takes none of these. */ +function nativeImageType(mimeType: unknown): string | null { + if (typeof mimeType !== 'string') return null; + const base = mimeType.split(';')[0]!.trim().toLowerCase(); + return NATIVE_IMAGE_TYPES.has(base) ? base : null; +} + export interface StandardToolResult { content?: unknown; isError?: boolean; @@ -70,56 +126,81 @@ export interface StandardToolResult { * `label` names saved payloads (one per call; payloads get an index suffix). * A payload that can't be retained (no `save`, or saving failed) makes the * result a failure to materialize, never a success with part of it lost. + * Content that is present but isn't a list of blocks makes it a malformed + * result, never an empty success. */ export async function normalizeStandardToolResult( result: StandardToolResult, label: string, save: SaveToolPayload | null, ): Promise { - const raw = Array.isArray(result.content) ? result.content : []; + const malformed = result.content !== undefined && !Array.isArray(result.content); + const raw: unknown[] = Array.isArray(result.content) ? result.content : []; const blocks: Block[] = []; let saved = 0; /** Payloads that could be neither shown nor retained. */ let lost = 0; - const payloadStub = async (kind: string, base64: string, mimeType: string, origin?: string): Promise => { + const payloadStub = async ( + kind: string, + base64: string, + mimeType: string, + opts: { origin?: string; why?: string } = {}, + ): Promise => { + const why = opts.why ?? UNSUPPORTED; const size = humanSize(decodedSize(base64)); - const what = `[${kind}${origin ? ` ${bounded(origin)}` : ''}: ${bounded(mimeType)}, ${size}`; + const what = `[${kind}${opts.origin ? ` ${bounded(opts.origin, URI_POINTS)}` : ''}: ${bounded(mimeType)}, ${size}`; if (!save) { lost++; - return `${what}. Not retained: the model can't take this type, and no workspace is mounted to save it]`; + return `${what}. Not retained: ${why}, and no workspace is mounted to save it]`; } - const path = await save(`${label}-${++saved}.${extensionFor(mimeType)}`, Buffer.from(base64, 'base64'), mimeType) - .catch(() => null); - if (path) return `${what}, saved to workspace file ${path}]`; + const fileName = `${label}-${++saved}.${extensionFor(mimeType)}`; + const path = await save(fileName, Buffer.from(base64, 'base64'), mimeType).catch((error: unknown) => { + // The stub says only that saving failed; why goes to the host's log. + console.error(`[mcp] saving tool payload ${fileName} (${kind}, ${JSON.stringify(bounded(mimeType))}, ${size}) failed:`, error); + return null; + }); + if (path) return `${what}, saved to workspace file ${path}${why === UNSUPPORTED ? '' : `; not shown inline: ${why}`}]`; lost++; - return `${what}. Not retained: the model can't take this type, and saving it to the workspace failed]`; + return `${what}. Not retained: ${why}, and saving it to the workspace failed]`; }; for (const entry of raw) { - const block = (entry && typeof entry === 'object' ? entry : {}) as Record; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + blocks.push({ type: 'text', text: `[malformed content entry, left out: ${bounded(entry)}]` }); + continue; + } + const block = entry as Record; switch (block.type) { case 'text': - if (typeof block.text === 'string') blocks.push({ type: 'text', text: block.text }); + blocks.push(typeof block.text === 'string' + ? { type: 'text', text: block.text } + : { type: 'text', text: '[malformed text block, left out: its text is not a string]' }); break; case 'image': - if (typeof block.data === 'string' && typeof block.mimeType === 'string') { - blocks.push({ type: 'image', data: block.data, mimeType: block.mimeType }); - } else { - blocks.push({ type: 'text', text: `[image without inline data, not fetched${typeof block.uri === 'string' ? `: ${bounded(block.uri)}` : ''}]` }); + case 'audio': { + const kind = block.type; + if (typeof block.data !== 'string') { + blocks.push({ type: 'text', text: `[${kind} without inline data, not fetched${typeof block.uri === 'string' ? `: ${bounded(block.uri, URI_POINTS)}` : ''}]` }); + break; } - break; - case 'audio': - if (typeof block.data === 'string' && typeof block.mimeType === 'string') { - blocks.push({ type: 'text', text: await payloadStub('audio', block.data, block.mimeType) }); + // Inline data with no type is still a payload, of no known type. + const mimeType = typeof block.mimeType === 'string' ? block.mimeType : 'application/octet-stream'; + const native = kind === 'image' ? nativeImageType(block.mimeType) : null; + if (native && decodedSize(block.data) <= NATIVE_IMAGE_MAX_BYTES) { + // Passed on without a wrapped encoding's whitespace, which a + // provider may refuse, and with it the whole request. + blocks.push({ type: 'image', data: block.data.replace(/\s+/g, ''), mimeType: native }); } else { - blocks.push({ type: 'text', text: `[audio without inline data, not fetched${typeof block.uri === 'string' ? `: ${bounded(block.uri)}` : ''}]` }); + const why = native ? `over the ${humanSize(NATIVE_IMAGE_MAX_BYTES)} a model takes per image` : UNSUPPORTED; + blocks.push({ type: 'text', text: await payloadStub(kind, block.data, mimeType, { why }) }); } break; + } case 'resource_link': { const name = typeof block.name === 'string' ? block.name : undefined; const mime = typeof block.mimeType === 'string' ? `, ${bounded(block.mimeType)}` : ''; - blocks.push({ type: 'text', text: `[resource link${name ? ` "${bounded(name)}"` : ''}: ${bounded(block.uri)}${mime}; not fetched]` }); + blocks.push({ type: 'text', text: `[resource link${name ? ` "${bounded(name)}"` : ''}: ${bounded(block.uri, URI_POINTS)}${mime}; not fetched]` }); break; } case 'resource': { @@ -127,17 +208,33 @@ export async function normalizeStandardToolResult( const uri = typeof resource.uri === 'string' ? resource.uri : undefined; const mime = typeof resource.mimeType === 'string' ? resource.mimeType : undefined; if (typeof resource.text === 'string') { - blocks.push({ type: 'text', text: `[resource ${bounded(uri ?? '(no uri)')}${mime ? `, ${bounded(mime)}` : ''}]\n${resource.text}` }); + blocks.push({ type: 'text', text: `[resource ${uri === undefined ? '(no uri)' : bounded(uri, URI_POINTS)}${mime ? `, ${bounded(mime)}` : ''}]\n${resource.text}` }); } else if (typeof resource.blob === 'string') { - blocks.push({ type: 'text', text: await payloadStub('resource', resource.blob, mime ?? 'application/octet-stream', uri) }); + blocks.push({ type: 'text', text: await payloadStub('resource', resource.blob, mime ?? 'application/octet-stream', { origin: uri }) }); } else { - blocks.push({ type: 'text', text: `[resource ${bounded(uri ?? '(no uri)')} with no contents]` }); + blocks.push({ type: 'text', text: `[resource ${uri === undefined ? '(no uri)' : bounded(uri, URI_POINTS)} with no contents]` }); } break; } - default: - // Unknown to this client: shown as data rather than dropped. - blocks.push({ type: 'text', text: JSON.stringify(entry) ?? '' }); + default: { + // Unknown to this client: shown as data rather than dropped, bounded + // like a stub field, so a large or binary payload can't flood the + // model's view. + const { head, omitted } = headPoints(asText(entry), STUB_FIELD_POINTS); + blocks.push({ type: 'text', text: omitted > 0 ? `${head}… [${omitted} more characters of this block left out]` : head }); + } + } + } + + const failing = !!result.isError || lost > 0 || malformed; + if (failing) { + // A failed result is read only as its text: an inline image becomes a + // payload like any other, saved and named, or marked not retained. + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]!; + if (block.type === 'image') { + blocks[i] = { type: 'text', text: await payloadStub('image', block.data, block.mimeType, { why: 'a failed result is read as its text alone' }) }; + } } } @@ -146,10 +243,10 @@ export async function normalizeStandardToolResult( // model. Stubs this module wrote are notices, not the server's text. const serverText = raw.some((entry) => { const block = entry as { type?: unknown; text?: unknown } | null; - return !!block && block.type === 'text' && typeof block.text === 'string' && block.text.trim().length > 0; + return !!block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string' && block.text.trim().length > 0; }); if (hasStructured && !serverText) { - blocks.push({ type: 'text', text: JSON.stringify(result.structuredContent) ?? 'null' }); + blocks.push({ type: 'text', text: asJson(result.structuredContent) }); } const text = blocks.filter((b): b is Extract => b.type === 'text').map((b) => b.text).join('\n'); @@ -157,6 +254,14 @@ export async function normalizeStandardToolResult( if (result.isError) { return { success: false, error: text || 'Tool call failed', isError: true, ...structured }; } + if (malformed) { + return { + success: false, + error: `[malformed result: its content is not a list of blocks; the tool may already have completed]${text ? `\n${text}` : ''}`, + isError: true, + ...structured, + }; + } if (lost > 0) { // The server answered, but part of its answer is gone: report that as a // failure to materialize the result, never as a success. diff --git a/test/mcp-modern-framework.test.ts b/test/mcp-modern-framework.test.ts index 570993c81..0eac21d63 100644 --- a/test/mcp-modern-framework.test.ts +++ b/test/mcp-modern-framework.test.ts @@ -135,9 +135,12 @@ test('model path: a payload the host cannot retain is reported as an incomplete await waitFor('the tool round', () => (membrane.lastStream?.receivedToolResults.length ?? 0) >= 1); const [delivered] = membrane.lastStream!.receivedToolResults[0] as Array<{ content: unknown; isError: boolean }>; // No workspace here: audio and the blob can be neither shown nor kept. + // That makes the result a failure, read as its text alone, so the inline + // image is a payload too, and can't be kept either. assert.equal(delivered!.isError, true); const text = JSON.stringify(delivered!.content); - assert.match(text, /result incomplete: 2 payload\(s\) could not be retained/); + assert.match(text, /result incomplete: 3 payload\(s\) could not be retained/); + assert.match(text, /\[image: image\/png, 8 B\. Not retained: a failed result is read as its text alone, and no workspace is mounted to save it\]/); assert.match(text, /resource link \\"report\.pdf\\": file:\/\/\/srv\/report\.pdf, application\/pdf; not fetched/); assert.match(text, /embedded note text/); }); @@ -293,6 +296,34 @@ test('saved payloads: call ids that sanitize to one label still get distinct fil }); }); +test("a payload whose workspace write rejects is lost from a completed call's result, with why logged, not a dispatch failure", async (t) => { + await withWorkspace(async ({ framework, workspace }) => { + const read = (framework as unknown as { + mcpToolResult: (result: unknown, callId: string, mcplPeer: boolean) => Promise; + }).mcpToolResult.bind(framework); + const logged: string[] = []; + t.mock.method(console, 'error', (...args: unknown[]) => { + logged.push(args.map((a) => (a instanceof Error ? a.message : String(a))).join(' ')); + }); + const clip = { content: [{ type: 'audio', data: Buffer.from('clip').toString('base64'), mimeType: 'audio/wav' }] }; + + workspace.writeBinary = async () => { throw new Error('disk gone'); }; + const rejected = await read(clip, 'call-1', false); + assert.equal(rejected.success, false); + assert.match(rejected.error!, /saving it to the workspace failed\]/); + + workspace.writeBinary = async () => ({ success: false, error: 'disk full', isError: true }); + const refused = await read(clip, 'call-2', false); + assert.equal(refused.success, false); + assert.match(refused.error!, /saving it to the workspace failed\]/); + + const saves = logged.filter((line) => line.startsWith('[mcp] saving tool payload')); + assert.equal(saves.length, 2, logged.join('\n')); + assert.match(saves[0]!, /^\[mcp\] saving tool payload board\/tool-results\/\S+\.wav \("audio\/wav"\) failed: disk gone$/); + assert.match(saves[1]!, /^\[mcp\] saving tool payload board\/tool-results\/\S+\.wav \("audio\/wav"\) failed: disk full$/); + }); +}); + test('MCPL policy on a modern server is a configuration error at connect', async () => { await withModern(async ({ framework }) => { await assert.rejects( diff --git a/test/mcp-tool-result-normalize.test.ts b/test/mcp-tool-result-normalize.test.ts index 955669120..b864c5cce 100644 --- a/test/mcp-tool-result-normalize.test.ts +++ b/test/mcp-tool-result-normalize.test.ts @@ -1,8 +1,8 @@ /** - * The standard reading of an MCP tool result (room-284, shelf-487): the - * content array is the model's view, and `structuredContent` the - * programmatic one, kept by presence. A payload the model can't take is - * saved and described by a bounded stub; nothing is fetched. + * The standard reading of an MCP tool result: the content array is the + * model's view, and `structuredContent` the programmatic one, kept by + * presence. A payload the model can't take is saved and described by a + * bounded stub; nothing is fetched, and nothing is dropped silently. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -122,12 +122,155 @@ test('a tool error is a failure with its text; structured survives', async () => }); test('an unknown block is shown as data, and stub fields are bounded', async () => { - const long = `memo://${'x'.repeat(1000)}`; const r = await normalizeStandardToolResult({ - content: [{ type: 'hologram', depth: 3 }, { type: 'resource_link', uri: long }], + content: [{ type: 'hologram', depth: 3 }, { type: 'resource_link', uri: 'memo://r', name: 'n'.repeat(1000) }], }, 'L', null); const [unknown, link] = String(r.data).split('\n'); assert.equal(unknown, '{"type":"hologram","depth":3}'); - assert.ok(link!.length < 260, `bounded: ${link!.length}`); - assert.match(link!, /…; not fetched\]$/); + assert.equal(link, `[resource link "${'n'.repeat(200)}…": memo://r; not fetched]`); +}); + +test('a large unknown block is bounded like a stub field, saying how much was left out', async () => { + const r = await normalizeStandardToolResult({ content: [{ type: 'hologram', blob: 'x'.repeat(10_000) }] }, 'L', null); + const json = JSON.stringify({ type: 'hologram', blob: 'x'.repeat(10_000) }); + assert.equal(r.data, `${json.slice(0, 200)}… [${json.length - 200} more characters of this block left out]`); +}); + +test('a URI is bounded loosely: a real one survives whole, a pathological one is cut', async () => { + const real = `https://example.com/report?${'q'.repeat(1500)}`; + const huge = `memo://${'x'.repeat(5000)}`; + const r = await normalizeStandardToolResult({ + content: [{ type: 'resource_link', uri: real }, { type: 'resource_link', uri: huge }], + }, 'L', null); + const [whole, cut] = String(r.data).split('\n'); + assert.equal(whole, `[resource link: ${real}; not fetched]`); + assert.equal(cut, `[resource link: ${huge.slice(0, 2048)}…; not fetched]`); +}); + +test('in a failed result an inline image is saved and named, or marked not retained, never dropped', async () => { + const { save, saved } = recordingSave(); + const r = await normalizeStandardToolResult({ + content: [{ type: 'text', text: 'render failed' }, { type: 'image', data: b64('partial'), mimeType: 'image/png' }], + isError: true, + }, 'L', save); + assert.equal(r.success, false); + assert.deepEqual(saved, [{ fileName: 'L-1.png', bytes: 'partial', mimeType: 'image/png' }]); + assert.equal(r.error, + 'render failed\n' + + '[image: image/png, 7 B, saved to workspace file home/tool-results/L-1.png; not shown inline: a failed result is read as its text alone]'); + + const unsaved = await normalizeStandardToolResult({ + content: [{ type: 'image', data: b64('png'), mimeType: 'image/png' }], + isError: true, + }, 'L', null); + assert.equal(unsaved.error, + '[image: image/png, 3 B. Not retained: a failed result is read as its text alone, and no workspace is mounted to save it]'); + + // A payload lost beside it makes the result a failure too: the image is + // then a payload like the audio, and both are counted. + const lost = await normalizeStandardToolResult({ + content: [{ type: 'image', data: b64('png'), mimeType: 'image/png' }, { type: 'audio', data: b64('wav'), mimeType: 'audio/wav' }], + }, 'L', null); + assert.equal(lost.success, false); + assert.match(lost.error!, /^\[result incomplete: 2 payload\(s\) could not be retained/); + assert.match(lost.error!, /\[image: image\/png, 3 B\. Not retained: a failed result is read as its text alone/); +}); + +test('image and audio with inline data but no mimeType are payloads of no known type, saved', async () => { + const { save, saved } = recordingSave(); + const r = await normalizeStandardToolResult({ content: [{ type: 'image', data: b64('img') }, { type: 'audio', data: b64('aud') }] }, 'L', save); + assert.deepEqual(saved.map((p) => [p.fileName, p.bytes, p.mimeType]), [ + ['L-1.bin', 'img', 'application/octet-stream'], + ['L-2.bin', 'aud', 'application/octet-stream'], + ]); + assert.equal(r.success, true); + assert.equal(r.data, + '[image: application/octet-stream, 3 B, saved to workspace file home/tool-results/L-1.bin]\n' + + '[audio: application/octet-stream, 3 B, saved to workspace file home/tool-results/L-2.bin]'); +}); + +test("an image a provider can't take inline is saved: another type, or over the per-image size", async () => { + const { save, saved } = recordingSave(); + const atLimit = Buffer.alloc(5 * 1024 * 1024, 1).toString('base64'); + const overLimit = Buffer.alloc(5 * 1024 * 1024 + 1, 1).toString('base64'); + const r = await normalizeStandardToolResult({ + content: [ + { type: 'image', data: b64('tiff'), mimeType: 'image/tiff' }, + { type: 'image', data: overLimit, mimeType: 'image/png' }, + { type: 'image', data: atLimit, mimeType: 'image/png' }, + { type: 'image', data: b64('ok'), mimeType: 'IMAGE/JPEG; q=1' }, + ], + }, 'L', save); + assert.deepEqual(saved.map((p) => [p.fileName, p.mimeType]), [['L-1.tiff', 'image/tiff'], ['L-2.png', 'image/png']]); + const data = r.data as Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + assert.equal(data[0]!.text, '[image: image/tiff, 4 B, saved to workspace file home/tool-results/L-1.tiff]'); + assert.equal(data[1]!.text, + '[image: image/png, 5.0 MB, saved to workspace file home/tool-results/L-2.png; not shown inline: over the 5.0 MB a model takes per image]'); + assert.deepEqual(data[2], { type: 'image', data: atLimit, mimeType: 'image/png' }, 'exactly at the limit stays native'); + assert.deepEqual(data[3], { type: 'image', data: b64('ok'), mimeType: 'image/jpeg' }, 'a type a provider takes, in its canonical spelling'); +}); + +test("a native image is passed without its encoding's line wrapping", async () => { + const flat = b64('a small png, wrapped'); + const wrapped = flat.replace(/(.{8})/g, '$1\r\n'); + assert.notEqual(wrapped, flat); + const r = await normalizeStandardToolResult({ content: [{ type: 'image', data: wrapped, mimeType: 'image/png' }] }, 'L', null); + assert.deepEqual(r.data, [{ type: 'image', data: flat, mimeType: 'image/png' }]); +}); + +test('a save that rejects is a lost payload, and why it failed is logged', async (t) => { + const logged: string[] = []; + t.mock.method(console, 'error', (...args: unknown[]) => { logged.push(args.map((a) => (a instanceof Error ? a.message : String(a))).join(' ')); }); + const r = await normalizeStandardToolResult( + { content: [{ type: 'audio', data: b64('clip'), mimeType: 'audio/wav' }] }, + 'L', + async () => { throw new Error('disk gone'); }, + ); + assert.equal(r.success, false); + assert.match(r.error!, /\[audio: audio\/wav, 4 B\. Not retained: the model can't take this type, and saving it to the workspace failed\]/); + const saves = logged.filter((line) => line.startsWith('[mcp] saving tool payload')); + assert.equal(saves.length, 1, logged.join('\n')); + assert.match(saves[0]!, /saving tool payload L-1\.wav \(audio, "audio\/wav", 4 B\) failed: disk gone/); +}); + +test('malformed content: a bad entry is named and left out; content that is not a list is a malformed result', async () => { + const r = await normalizeStandardToolResult({ content: [{ type: 'text', text: 'ok' }, null, 'stray', { type: 'text', text: 42 }] }, 'L', null); + assert.equal(r.success, true); + assert.equal(r.data, + 'ok\n[malformed content entry, left out: null]\n[malformed content entry, left out: stray]\n' + + '[malformed text block, left out: its text is not a string]'); + + const notList = await normalizeStandardToolResult({ content: { type: 'text', text: 'a block, not a list' }, structuredContent: { n: 1 } }, 'L', null); + assert.deepEqual(notList, { + success: false, + error: '[malformed result: its content is not a list of blocks; the tool may already have completed]\n{"n":1}', + isError: true, + structured: { n: 1 }, + }); + + // Absent content is no content, as a structured-only result may send it. + const absent = await normalizeStandardToolResult({ structuredContent: { n: 1 } }, 'L', null); + assert.equal(absent.success, true); + assert.equal(absent.data, '{"n":1}'); +}); + +test("stub text never splits a surrogate pair, and a wrapped payload's size counts only its bytes", async () => { + // One ASCII character first, so a cut by UTF-16 units would land inside a pair. + const r = await normalizeStandardToolResult({ content: [{ type: 'resource_link', uri: 'memo://x', name: `a${'😀'.repeat(300)}` }] }, 'L', null); + assert.equal(r.data, `[resource link "a${'😀'.repeat(199)}…": memo://x; not fetched]`); + + const wrapped = b64('hello world').replace(/(.{4})/g, '$1\r\n'); + const sized = await normalizeStandardToolResult({ content: [{ type: 'audio', data: wrapped, mimeType: 'audio/wav' }] }, 'L', null); + assert.match(sized.error!, /\[audio: audio\/wav, 11 B\. Not retained/); +}); + +test("a subtype named like one of Object.prototype's members is just a name", async () => { + const { save, saved } = recordingSave(); + await normalizeStandardToolResult({ + content: [ + { type: 'audio', data: b64('a'), mimeType: 'audio/constructor' }, + { type: 'audio', data: b64('b'), mimeType: 'audio/__proto__' }, + ], + }, 'L', save); + assert.deepEqual(saved.map((p) => p.fileName), ['L-1.bin', 'L-2.proto']); }); From 6d0ab4187c2dd9e15ad2dbf2f409ae4c74d22aa0 Mon Sep 17 00:00:00 2001 From: Nell-1783 Date: Sun, 11 Oct 2026 17:22:50 -0400 Subject: [PATCH 19/94] fix(mcp): one transport rule for every selector, credentials kept out of logs and status, and refusals for settings that would be ignored Configuration - A configuration with both command and url and no transport is refused before anything is spawned or dialed. Before, it was launched as stdio and the url was silently ignored. With a transport, the transport says which field is used, as before: a host layers a recipe's url and transport: 'websocket' over a file's command (connectome-host's recipe overrides do), and a url that disagrees with the named transport is still refused. resolveServerBinding makes the refusal, so every selector gives the same answer. Listed in the breaking fragment. - The legacy transport selectors (isWebSocketTransport, openTransport) now decide through resolveServerBinding, so they can't disagree with it: an http(s) url is never dialed as a WebSocket, and openTransport refuses a modern binding before anything is spawned or dialed. - A token or accessProvider on a modern stdio server is refused, like the other settings that can't apply there. A legacy stdio server keeps ignoring them, as before. - A legacy requestTimeoutMs above 2^31-1 is refused. Node runs a longer timer after about 1 ms, so every request timed out at once; every value that worked, 0 included, still passes. Credentials - redactUrl shows a url for a person or a log to read: scheme, host and port, and the path with any credential-like segment replaced (16 or more characters with a digit, as a token or uuid has, or 32 or more of anything: some hosted servers put a per-user secret in the path), with the userinfo removed and each query parameter's value replaced. Configuration errors and a failed WebSocket dial's error use it, and so does listMcplServers' url. An invalid url isn't echoed at all, since it can't be redacted. - An accessProvider that throws is logged by its error's class and its message's length, on the WebSocket dial and in the modern engine's bearer auth: the provider's text may quote the credential. - The cleartext warning also covers a secret the url carries itself, to an http:// or ws:// host that isn't this machine: userinfo, a query parameter whose name says credential (token, key, secret, password, auth, signature), or a credential-like path segment, as redactUrl judges it. It names what it found, never the value. - Loopback detection follows the WHATWG parser's forms: 127.0.0.0/8 as an IPv4-mapped IPv6 address ([::ffff:7f00:1]) and a name with its root's trailing dot (localhost.) count as this host. Status - listMcplServers lists a configuration that connecting would refuse with refused (why), and family and transport null when no binding resolves, where it guessed a legacy binding before. policyEstablished is null for a modern server, which has no MCPL policy, rather than false, which read as a pending one. Not changed - The cleartext warning is checked once per connection: neither engine's reconnect attempts run the configuration check again. A probe with a token to a closed port gave one warning over three legacy connect failures, and one over sixteen modern connect and reconnect failures. Text - README: command and url together need a transport; a modern stdio server takes no credential; a -32022 refusal names the revisions the server lists (or its error text), and suggests protocol: 'modern' only when the list includes 2026-07-28. - types.ts: the command, url, transport and token docs say where each applies, and the token doc says where it travels for each transport; the requestTimeoutMs doc gives the upper bound. - Changelog: the cleartext fragment names url-carried secrets and the unspecified addresses (0.0.0.0, [::]) that count as this host; the standard-results fragment covers redaction, the selectors and the status fields; the breaking fragment covers command with url and no transport, and the legacy timeout bound. Tests - mcp-protocol-family: command with url refused without a transport and resolved by an explicit one, a mismatched one still refused; the redacted url, path segments included, in errors, and an invalid one not echoed; the legacy timeout bound; credentials on a modern stdio server; url-carried secrets warned about without quoting them; the IPv4-mapped, trailing-dot and [::] loopback forms; an access provider's failure logged without its text, on the WebSocket dial and over http. - mcpl-list-status: a refused configuration, redacted urls, and a modern server's policyEstablished. - mcpl-ws-transport: the selector follows resolveServerBinding; openTransport refuses modern and refused configurations; an invalid url and a failed dial's error carry no credential. - mcp-modern-connection and mcpl-legacy-version: the refusal tests now use the real fixtures' start logs, so a launch before the refusal would show, with a control that the log records a launch. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +- .../cleartext-mcp-credential-warning.added.md | 13 +- .../legacy-mcp-protocol-boundary.breaking.md | 10 ++ .../mcp-standard-tool-results.changed.md | 22 +++- src/framework.ts | 44 ++++--- src/mcpl/modern-connection.ts | 7 +- src/mcpl/protocol-family.ts | 119 ++++++++++++++--- src/mcpl/transport.ts | 38 ++++-- src/mcpl/types.ts | 31 +++-- test/mcp-modern-connection.test.ts | 18 ++- test/mcp-protocol-family.test.ts | 124 +++++++++++++++++- test/mcpl-legacy-version.test.ts | 15 ++- test/mcpl-list-status.test.ts | 39 ++++++ test/mcpl-ws-transport.test.ts | 34 ++++- 14 files changed, 447 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 6ca53d160..77ef73f74 100644 --- a/README.md +++ b/README.md @@ -288,14 +288,19 @@ configuration chooses the family, and nothing is probed: } ``` +A configuration with both `command` and `url` needs `transport` to say which +one is used; without it, it is refused rather than silently ignoring one. + A modern server's tools get the same prefix, `enabledTools`/`disabledTools`, class overrides and dispatch as an MCPL server's, and `listMcplServers()` shows its `family`, `protocolVersion` and `transport`. Its config differs in -two ways: +three ways: - It has no MCPL surface (grant, push, channels, inference requests, feature sets), so setting MCPL-only fields on one is a configuration error. - `requestTimeoutMs` is one deadline per tool call and must be 1 to 2^31−1; 0 is refused, where MCPL reads it as no watchdog. +- Over stdio it takes no credential: `token` or `accessProvider` on one is + refused. At the deadline, cancellation is requested for a leg still in flight; between the rounds of an `input_required` continuation nothing is in flight @@ -304,7 +309,10 @@ and the call is never retried. A call that ends for any other reason after the server asked for another round, before its final answer, is reported the same way, since the server may already have acted. An MCPL server that refuses `2024-11-05` with `-32022` fails with `McplProtocolVersionError`, -which names the fix, and is not retried. +and is not retried. The error names the revisions the server says it +supports, or its error text when it lists none, and when the list includes +`2026-07-28` it says to configure the server with `protocol: 'modern'` (stdio) +or reach it over http(s). #### Feature sets diff --git a/changelog.d/cleartext-mcp-credential-warning.added.md b/changelog.d/cleartext-mcp-credential-warning.added.md index 4851f6685..dadbe0514 100644 --- a/changelog.d/cleartext-mcp-credential-warning.added.md +++ b/changelog.d/cleartext-mcp-credential-warning.added.md @@ -1,6 +1,11 @@ -- Connecting to an MCP server warns once when its credential (`token` or - `accessProvider`) would cross the network unencrypted: an `http://` or - `ws://` url whose host isn't loopback. It warns rather than refuses, since a - private network or a TLS-terminating proxy can make that deliberate. +- Connecting to an MCP server warns once when a credential would cross the + network unencrypted, to an `http://` or `ws://` url whose host isn't this + machine: its `token` or `accessProvider`, or a secret the url carries + itself (userinfo, a query parameter whose name says credential: `token`, + `key`, `secret`, `password`, `auth` or `signature`, or a path segment + that looks like one, as the redacted url judges it). Loopback hosts are + exempt, and so are the unspecified addresses `0.0.0.0` and `[::]`, which + reach this host too. It warns rather than refuses, since a private network + or a TLS-terminating proxy can make that deliberate. `serverConfigWarnings` is exported beside `serverConfigProblems`, so a host can apply the same check in its own configuration validation. diff --git a/changelog.d/legacy-mcp-protocol-boundary.breaking.md b/changelog.d/legacy-mcp-protocol-boundary.breaking.md index 84cf28ddd..fc815cfa6 100644 --- a/changelog.d/legacy-mcp-protocol-boundary.breaking.md +++ b/changelog.d/legacy-mcp-protocol-boundary.breaking.md @@ -17,3 +17,13 @@ whole inventory reaches the agent; before, every page after the first was silently dropped. A repeated cursor, or more than 100 pages, is an error rather than a partial inventory. +- **Hosts and operators:** a server configuration with both `command` and + `url` and no `transport` is now refused before anything is spawned or + dialed. Before, it was launched as stdio and the url was silently + ignored. Migration: drop the field you don't use, or set `transport` to + say which one is used. Unchanged: a configuration with only one of them, + or with both and a `transport`, resolves as before (a host can still + layer a url and `transport: 'websocket'` over a file's command). A legacy + `requestTimeoutMs` above 2^31−1 is refused too: Node ran it as a timer of + about 1 ms, so every request timed out at once. Every value that worked, + `0` included, passes. diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index adfe0d362..4bb318b76 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -68,7 +68,21 @@ - A server configuration naming no usable transport is now an error before anything is spawned or dialed. That covers a `transport` that doesn't match the url's scheme, `transport: 'http'` without a url, and an - unrecognized scheme. Every valid configuration resolves as before. - `McplServerConnection.connect()` and `connectWithReconnect()` apply the - framework's whole check (so `protocol` on a WebSocket url is refused too), - and a configuration error is never turned into a retry stub. + unrecognized scheme. `McplServerConnection.connect()` and + `connectWithReconnect()` apply the framework's whole check (so `protocol` + on a WebSocket url is refused too), and a configuration error is never + turned into a retry stub. `openTransport()` and `isWebSocketTransport()` + decide by the same rule, so an http(s) url is never dialed as a WebSocket. +- A configuration error, a WebSocket dial failure and `listMcplServers()` + show a server's url redacted, since a credential may ride in it: no + userinfo, each query parameter's value replaced, and so is a path + segment that looks like a credential (16 or more characters with a + digit, as a token or uuid has, or 32 or more of anything), such as the + per-user secret some hosted servers put in the path. A url that doesn't + parse isn't shown. An `accessProvider` that throws is logged by + its error's class and its message's length, not its text, which may quote + the credential. +- `listMcplServers()` lists a configuration that connecting would refuse + with `refused` (why), and `family` and `transport` null, where it guessed + a legacy binding before. Its `policyEstablished` is null for a modern + server, which has no MCPL policy. diff --git a/src/framework.ts b/src/framework.ts index 8cd3c75ef..45f7be15a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -393,7 +393,7 @@ import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js'; import type { McplServerConnection } from './mcpl/server-connection.js'; import { McplProtocolVersionError, McplUnreapedLaunchError } from './mcpl/server-connection.js'; import { ModernMcpConnection } from './mcpl/modern-connection.js'; -import { resolveServerBinding, serverConfigProblems } from './mcpl/protocol-family.js'; +import { redactUrl, resolveServerBinding, serverConfigProblems } from './mcpl/protocol-family.js'; import { normalizeStandardToolResult } from './mcpl/tool-result-normalize.js'; import type { McplServerConfig, @@ -14466,16 +14466,21 @@ export class AgentFramework { connected: boolean; retrying: boolean; /** Which engine serves it: `legacy` (MCP 2024-11-05 + MCPL) or `modern` - * (MCP 2026-07-28), as its configuration selects. */ - family: 'legacy' | 'modern'; + * (MCP 2026-07-28), as its configuration selects; null when the + * configuration names no usable transport (see `refused`). */ + family: 'legacy' | 'modern' | null; /** The MCP revision its last handshake established; null until one has. */ protocolVersion: string | null; - /** How it is reached. */ - transport: 'stdio' | 'websocket' | 'http'; + /** How it is reached; null when the configuration names no usable transport. */ + transport: 'stdio' | 'websocket' | 'http' | null; + /** Why connecting refuses this configuration, before anything is spawned + * or dialed; null when it is usable. */ + refused: string | null; toolPrefix: string; toolCount: number; - /** True only after the §5.3 policy receipt activated the grant. */ - policyEstablished: boolean; + /** True only after the §5.3 policy receipt activated the grant. Null for + * a modern server, which has no MCPL policy. */ + policyEstablished: boolean | null; /** Sole normative authorization allowlist for the connection (§5.4). */ effectiveGrant: string[]; /** Advertised paths removed by host enabled/disabled capability config. */ @@ -14509,13 +14514,16 @@ export class AgentFramework { lastNegotiatedAt: number | null; }; command?: string; + /** The url as {@link redactUrl} shows it, since a credential may ride in + * it: no userinfo, each query parameter's value replaced, and any path + * segment that looks like a credential replaced. */ url?: string; }> { const result: Array<{ id: string; connected: boolean; retrying: boolean; - family: 'legacy' | 'modern'; protocolVersion: string | null; - transport: 'stdio' | 'websocket' | 'http'; - toolPrefix: string; toolCount: number; policyEstablished: boolean; + family: 'legacy' | 'modern' | null; protocolVersion: string | null; + transport: 'stdio' | 'websocket' | 'http' | null; refused: string | null; + toolPrefix: string; toolCount: number; policyEstablished: boolean | null; effectiveGrant: string[]; maskedCapabilities: string[]; deniedCapabilities: string[]; allowHostCommands: boolean; toolObserveFilter: import('./mcpl/tool-lifecycle.js').ToolObserveRule[] | null; @@ -14532,12 +14540,15 @@ export class AgentFramework { const modern = this.modernServers().get(id) ?? null; const connection = modern ? null : this.mcplServerRegistry?.getServer(id) ?? null; const connected = modern?.isConnected ?? connection?.isConnected ?? false; - let binding: { family: 'legacy' | 'modern'; transport: 'stdio' | 'websocket' | 'http' }; + // What connecting would decide, before anything is spawned or dialed: a + // configuration it refuses is listed as refused, not as a guessed binding. + let binding: { family: 'legacy' | 'modern'; transport: 'stdio' | 'websocket' | 'http' } | null; try { binding = resolveServerBinding(config); } catch { - binding = { family: 'legacy', transport: config.url ? 'websocket' : 'stdio' }; + binding = null; } + const problems = serverConfigProblems(config); // Attribute through the dispatch resolver: a bare prefix match would // also count a nested prefix's tools (`foo` vs `foo--bar`) as ours. const ownTools = (this.mcplTools ?? []).flatMap((t) => { @@ -14548,12 +14559,13 @@ export class AgentFramework { id, connected, retrying: !connected && (modern?.willReconnect ?? connection?.willReconnect ?? false), - family: binding.family, + family: binding?.family ?? null, protocolVersion: modern?.protocolVersion ?? connection?.protocolVersion ?? null, - transport: binding.transport, + transport: binding?.transport ?? null, + refused: problems.length > 0 ? problems.join('; ') : null, toolPrefix: prefix, toolCount: ownTools.length, - policyEstablished: connection?.policyEstablished ?? false, + policyEstablished: binding?.family === 'modern' ? null : connection?.policyEstablished ?? false, effectiveGrant: connection?.grant.effectiveList() ?? [], maskedCapabilities: [...(connection?.droppedCapabilities ?? [])].sort(), deniedCapabilities: [...(connection?.grant.deniedPaths ?? [])].sort(), @@ -14567,7 +14579,7 @@ export class AgentFramework { ? { ...connection.manifestState } : { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null }, command: config.command, - url: config.url, + url: config.url === undefined ? undefined : redactUrl(config.url), }); } return result; diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 2ad44f6d8..7af667292 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -57,7 +57,7 @@ import { import { StdioTransport, type McplTransport } from './transport.js'; import { McplRequestError } from './server-connection.js'; -import { MODERN_MCP_PROTOCOL_VERSION, checkServerConfig } from './protocol-family.js'; +import { MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, describeCredentialError } from './protocol-family.js'; import type { McplServerConfig, McpToolDefinition } from './types.js'; const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; @@ -261,9 +261,10 @@ function bearerAuth(config: McplServerConfig): AuthProvider | undefined { if (fresh) return fresh; } catch (error) { // Same fallback as the WebSocket dial: use the static token, and let - // the server refuse a stale credential loudly. + // the server refuse a stale credential loudly. The provider's own + // text may quote the credential, so it isn't logged. console.error( - `[mcp] server "${config.id}": access provider failed (${error instanceof Error ? error.message : error}) — using configured fallback`, + `[mcp] server "${config.id}": access provider failed (${describeCredentialError(error)}) — using configured fallback`, ); } } diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index cea22cedb..54682351c 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -60,12 +60,57 @@ export const MCPL_ONLY_POLICY_FIELDS = [ type BindingFields = Pick; +/** + * A path segment that may be a credential. Some hosted servers put a + * per-user secret in the path (`…/mcp/s//mcp`). A random token + * (base64url, hex, a uuid) is long and carries a digit; anything very long + * is treated as one too. A short or plain-word segment can't be told apart + * from a name, so it stays. + */ +function credentialLikeSegment(segment: string): boolean { + return (segment.length >= 16 && /[0-9]/.test(segment)) || segment.length >= 32; +} + +/** + * A url as text a person or a log reads it: its scheme, host and port, and + * its path with any credential-like segment replaced. The userinfo is + * removed and each query parameter's value replaced, since a credential + * rides there too. A url that doesn't parse can't be redacted, so it isn't + * shown at all. + */ +export function redactUrl(url: string | undefined): string { + let parsed: URL; + try { + parsed = new URL(url ?? ''); + } catch { + return '(an invalid url, not shown)'; + } + parsed.username = ''; + parsed.password = ''; + parsed.hash = ''; + parsed.pathname = parsed.pathname.split('/').map((segment) => (credentialLikeSegment(segment) ? 'redacted' : segment)).join('/'); + parsed.search = new URLSearchParams([...parsed.searchParams].map(([name]): [string, string] => [name, 'redacted'])).toString(); + return parsed.toString(); +} + +/** + * A credential provider's failure as a log line may show it: the error's + * class and its message's length, never the text, which may quote the + * credential it failed to refresh. + */ +export function describeCredentialError(error: unknown): string { + if (!(error instanceof Error)) return `a thrown ${typeof error}`; + const length = error.message.length; + return `${error.constructor?.name || error.name || 'Error'}, ${length} character${length === 1 ? '' : 's'}`; +} + function urlScheme(config: BindingFields): string { let parsed: URL; try { parsed = new URL(config.url!); } catch { - throw new Error(`MCP server "${config.id}": invalid url "${config.url}"`); + // Not echoed: a url that doesn't parse can't be redacted. + throw new Error(`MCP server "${config.id}": invalid url (it isn't shown, since it may carry a credential)`); } return parsed.protocol; } @@ -73,9 +118,18 @@ function urlScheme(config: BindingFields): string { /** * Resolve a server's family and transport. Throws on a configuration that * names no usable transport (for example an `ftp://` URL, or - * `transport: 'http'` with a WebSocket URL). + * `transport: 'http'` with a WebSocket URL), or names two without saying + * which: `command` and `url` together need a `transport`, since one of them + * goes unused. (A host may layer a url and its transport over a file's + * command, so both with a `transport` stay valid.) */ export function resolveServerBinding(config: BindingFields): ServerBinding { + if (config.command && config.url && config.transport === undefined) { + throw new Error( + `MCP server "${config.id}": "command" and "url" are both set, and no "transport" says which one to use: ` + + `a server is either launched (command) or dialed (url)`, + ); + } const networkKind = (): McpTransportKind => { const scheme = urlScheme(config); if (scheme === 'ws:' || scheme === 'wss:') return 'websocket'; @@ -95,7 +149,7 @@ export function resolveServerBinding(config: BindingFields): ServerBinding { transport = networkKind(); if (config.transport !== undefined && config.transport !== transport) { throw new Error( - `MCP server "${config.id}": transport "${config.transport}" does not match url "${config.url}"`, + `MCP server "${config.id}": transport "${config.transport}" does not match url ${redactUrl(config.url)}`, ); } } else if (config.transport !== undefined) { @@ -138,8 +192,8 @@ export function serverConfigProblems(config: McplServerConfig): string[] { ); } } + const timeout = config.requestTimeoutMs; if (binding.family === 'modern') { - const timeout = config.requestTimeoutMs; if (timeout !== undefined && !(Number.isInteger(timeout) && timeout >= 1 && timeout <= MAX_TIMER_MS)) { problems.push( `MCP server "${config.id}": requestTimeoutMs must be an integer from 1 to ${MAX_TIMER_MS} for a modern server (got ${String(timeout)}); every modern call has a deadline`, @@ -150,28 +204,50 @@ export function serverConfigProblems(config: McplServerConfig): string[] { problems.push(`MCP server "${config.id}": "${field}" is MCPL policy, which a modern MCP server cannot negotiate`); } } + // A modern stdio server takes no credential: nothing would carry it. + if (binding.transport === 'stdio') { + for (const field of ['token', 'accessProvider'] as const) { + if (config[field]) { + problems.push(`MCP server "${config.id}": "${field}" applies only to a network (url) server; a modern stdio server would ignore it`); + } + } + } + } else if (typeof timeout === 'number' && timeout > MAX_TIMER_MS) { + // Never usable: Node runs a longer timer after about 1 ms, so every + // request timed out at once. Every value that worked (0 included) passes. + problems.push( + `MCP server "${config.id}": requestTimeoutMs must be at most ${MAX_TIMER_MS} (got ${String(timeout)}), or 0 to disable the request timeout; a larger value times every request out at once`, + ); } return problems; } -/** A host a connection to which never leaves this machine. The unspecified - * addresses count: a connection to 0.0.0.0 or [::] reaches this host. */ +/** A host a connection to which never leaves this machine, as the WHATWG URL + * parser gives it. The unspecified addresses count: a connection to 0.0.0.0 + * or [::] reaches this host. So do 127.0.0.0/8 written as an IPv4-mapped + * IPv6 address ([::ffff:127.0.0.1] parses as [::ffff:7f00:1]) and a name + * with its root's trailing dot (localhost.). */ function isLoopbackHost(hostname: string): boolean { - const host = hostname.toLowerCase(); + const host = hostname.toLowerCase().replace(/\.$/, ''); return host === 'localhost' || host.endsWith('.localhost') || host === '[::1]' || /^127(\.\d{1,3}){3}$/.test(host) || - host === '0.0.0.0' || host === '[::]'; + /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(host) || host === '0.0.0.0' || host === '[::]'; } +/** Query parameter names that say they carry a credential. */ +const CREDENTIAL_PARAM = /token|key|secret|password|auth|signature/i; + /** * What is usable in a server configuration but worth an operator's attention, - * as messages; empty when there is nothing. A credential (`token` or - * `accessProvider`) for an `http://` or `ws://` url whose host isn't loopback - * crosses the network in cleartext, in the `Authorization` header or the - * WebSocket `?token=` query. That can be deliberate, on a private network or - * behind a TLS-terminating proxy, so it is a warning and never a refusal. + * as messages; empty when there is nothing. A credential for an `http://` or + * `ws://` url whose host isn't loopback crosses the network in cleartext: + * `token` or `accessProvider` (in the `Authorization` header, or the + * WebSocket `?token=` query), and a secret the url carries itself (userinfo, + * a query parameter whose name says it's a credential, or a path segment + * that looks like one, as {@link redactUrl} judges it). That can be + * deliberate, on a private network or behind a TLS-terminating proxy, so it + * is a warning and never a refusal. */ export function serverConfigWarnings(config: McplServerConfig): string[] { - if (!config.token && !config.accessProvider) return []; let transport: McpTransportKind; try { transport = resolveServerBinding(config).transport; @@ -181,10 +257,21 @@ export function serverConfigWarnings(config: McplServerConfig): string[] { if (transport === 'stdio') return []; const url = new URL(config.url!); if ((url.protocol !== 'http:' && url.protocol !== 'ws:') || isLoopbackHost(url.hostname)) return []; + const carried: string[] = []; + if (config.token || config.accessProvider) carried.push('its credential'); + if (url.username || url.password) carried.push("its url's userinfo"); + if (url.pathname.split('/').some(credentialLikeSegment)) carried.push("a credential-like segment of its url's path"); + const params = [...new Set([...url.searchParams.keys()].filter((name) => CREDENTIAL_PARAM.test(name)))]; + if (params.length > 0) { + carried.push(`its url's query ${params.length === 1 ? 'parameter' : 'parameters'} ${params.map((name) => JSON.stringify(name)).join(', ')}`); + } + if (carried.length === 0) return []; + const what = carried.length === 1 ? carried[0] : `${carried.slice(0, -1).join(', ')} and ${carried[carried.length - 1]}`; + const one = carried.length === 1 && params.length <= 1; const secure = url.protocol === 'http:' ? 'https' : 'wss'; return [ - `MCP server "${config.id}": its credential goes to ${url.host} unencrypted (${url.protocol}//), where anything on the ` + - `network path can read it; use ${secure}:// unless that network is trusted`, + `MCP server "${config.id}": ${what} ${one ? 'goes' : 'go'} to ${url.host} unencrypted (${url.protocol}//), ` + + `where anything on the network path can read ${one ? 'it' : 'them'}; use ${secure}:// unless that network is trusted`, ]; } diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index 9d2540c03..09ba3de81 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -22,6 +22,7 @@ import { EventEmitter } from 'node:events'; import WebSocket from 'ws'; import type { McplServerConfig } from './types.js'; +import { describeCredentialError, redactUrl, resolveServerBinding } from './protocol-family.js'; /** Why/how a transport closed. `code`/`signal` are populated for stdio child * exits; `reason` carries a WebSocket close reason or an error message. */ @@ -317,15 +318,16 @@ export class WebSocketTransport extends McplTransport { } catch (err) { // Fall back to the static token (if any) rather than failing the dial // outright — the server will refuse a stale credential loudly anyway. + // The provider's own text may quote the credential, so it isn't logged. console.error( - `[mcpl] server "${config.id}": access provider failed (${err instanceof Error ? err.message : err}) — dialing with configured fallback`, + `[mcpl] server "${config.id}": access provider failed (${describeCredentialError(err)}) — dialing with configured fallback`, ); } } const url = buildWebSocketUrl(config, token); // Error strings travel: traces, logs, tool results the model reads. They // get the endpoint, never the credential. - const redacted = buildWebSocketUrl({ ...config, token: undefined }, null); + const redacted = redactUrl(config.url); const ws = new WebSocket(url); // A failed dial can emit 'error' MORE THAN ONCE on the raw socket (seen @@ -404,7 +406,8 @@ export function buildWebSocketUrl(config: McplServerConfig, tokenOverride?: stri try { parsed = new URL(config.url); } catch { - throw new Error(`MCPL server "${config.id}": invalid WebSocket url "${config.url}"`); + // Not echoed: a url that doesn't parse can't be redacted. + throw new Error(`MCPL server "${config.id}": invalid WebSocket url (it isn't shown, since it may carry a credential)`); } if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') { throw new Error( @@ -422,22 +425,29 @@ export function buildWebSocketUrl(config: McplServerConfig, tokenOverride?: stri // Factory // --------------------------------------------------------------------------- -/** True when the config selects the WebSocket transport (explicit `transport`, - * or a `url` with no `command`). */ +/** True when the config selects the WebSocket transport, as + * {@link resolveServerBinding} decides it; false for any other transport and + * for a configuration it refuses. */ export function isWebSocketTransport(config: McplServerConfig): boolean { - if (config.transport === 'websocket') return true; - if (config.transport === 'stdio') return false; - return Boolean(config.url) && !config.command; + try { + return resolveServerBinding(config).transport === 'websocket'; + } catch { + return false; + } } /** - * Open the transport selected by `config` (WebSocket when `transport` is - * 'websocket' or a bare `url` is given; stdio otherwise). The returned transport - * is connected at the byte level but has NOT performed the MCPL handshake. + * Open the legacy transport `config` selects, as {@link resolveServerBinding} + * decides it: stdio for a `command`, MCPL's WebSocket binding for a ws(s):// + * url. A configuration it refuses, or one for the modern family (an http(s) + * url, or `protocol: 'modern'`), throws before anything is spawned or dialed. + * The returned transport is connected at the byte level but has NOT + * performed the MCPL handshake. */ export async function openTransport(config: McplServerConfig): Promise { - if (isWebSocketTransport(config)) { - return WebSocketTransport.open(config); + const binding = resolveServerBinding(config); + if (binding.family !== 'legacy') { + throw new Error(`MCP server "${config.id}" is configured for modern MCP; the legacy transports can't open it`); } - return StdioTransport.spawn(config); + return binding.transport === 'websocket' ? WebSocketTransport.open(config) : StdioTransport.spawn(config); } diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 3daf4e49e..5ca5f0ff8 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -217,9 +217,10 @@ export interface McplServerConfig { id: string; /** - * Command to spawn the server process (stdio transport). Mutually exclusive - * with `url`. Required for the stdio transport; omit it (and set `url`) for - * the WebSocket transport. + * Command to spawn the server process (stdio transport). Required for the + * stdio transport; omit it (and set `url`) for a network server, over + * WebSocket or HTTP. A configuration with both needs `transport` to say + * which one is used, and is refused without it. */ command?: string; @@ -243,8 +244,9 @@ export interface McplServerConfig { inheritEnv?: boolean; /** - * URL for a network server, used instead of `command`. Its scheme decides - * the protocol family: + * URL for a network server, used instead of `command`. A configuration + * with both needs `transport` to say which one is used, and is refused + * without it. Its scheme decides the protocol family: * - `ws://` or `wss://` is a legacy MCPL server over MCPL's WebSocket * binding. The host dials it and runs the same newline-delimited JSON-RPC * as over stdio. @@ -254,9 +256,11 @@ export interface McplServerConfig { url?: string; /** - * Transport selector. When it is absent, a `command` means stdio and a bare - * `url` means the transport its scheme names. Set it explicitly to - * disambiguate; it must agree with the url's scheme. + * Transport selector. When it is absent, a `command` means stdio and a + * `url` means the transport its scheme names; with both, it is required. + * When it is set, it decides which of the two is used and must agree with + * it: `'stdio'` needs a `command`, `'websocket'` a ws(s):// url and + * `'http'` an http(s):// url. */ transport?: 'stdio' | 'websocket' | 'http'; @@ -270,8 +274,11 @@ export interface McplServerConfig { protocol?: 'legacy' | 'modern'; /** - * Bearer token for WebSocket auth. Appended to `url` as a `token` query - * parameter (`?token=…`) on connect. Ignored by the stdio transport. + * Bearer token for a network server. Where it travels depends on the + * transport: on a ws(s):// url it is appended as a `token` query parameter + * (`?token=…`) on connect; to an http(s):// server it is sent as an + * `Authorization: Bearer` header and never put in the url. A legacy stdio + * server ignores it, and a modern stdio server refuses it. */ token?: string; @@ -387,7 +394,9 @@ export interface McplServerConfig { * accepts a request and never responds would otherwise freeze the awaiting * agent turn forever; with the timeout the pending request rejects with a * descriptive error, which the framework surfaces as a normal isError - * tool_result. Set 0 to disable. Default: 60000 (60 seconds). + * tool_result. Set 0 to disable. Default: 60000 (60 seconds). A value above + * 2^31−1 is refused: Node runs a longer timer after about 1 ms, so every + * request would time out at once. * * For a modern server it is one deadline per tool call, across every leg * of the call, and must be an integer from 1 to 2^31−1. It can't be diff --git a/test/mcp-modern-connection.test.ts b/test/mcp-modern-connection.test.ts index d71e2b6f5..28539665b 100644 --- a/test/mcp-modern-connection.test.ts +++ b/test/mcp-modern-connection.test.ts @@ -182,7 +182,21 @@ test('a first connect that fails keeps retrying with reconnect, and close stops }); test('configuration mistakes throw before anything is launched', async () => { + // x.invalid never resolves, so a dial would fail with a connect error instead. await assert.rejects(ModernMcpConnection.connect({ id: 'x', url: 'https://x.invalid/mcp', protocol: 'modern' }), /applies only to stdio/); - await assert.rejects(ModernMcpConnection.connect({ id: 'x', command: 'srv', protocol: 'modern', requestTimeoutMs: 0 }), /requestTimeoutMs must be an integer/); - await assert.rejects(ModernMcpConnection.connect({ id: 'x', command: 'srv' }), /not configured for modern MCP/); + // Stdio refusals use the real fixture, whose start log records any launch. + const dir = scratch(); + const refused: Array<[Partial, RegExp]> = [ + [{ requestTimeoutMs: 0 }, /requestTimeoutMs must be an integer/], + [{ protocol: undefined }, /not configured for modern MCP/], + [{ token: 't' }, /"token" applies only to a network \(url\) server/], + [{ url: 'https://x.invalid/mcp' }, /"command" and "url" are both set/], + ]; + for (const [extra, pattern] of refused) { + await assert.rejects(ModernMcpConnection.connect(config(dir, extra)), pattern, JSON.stringify(extra)); + } + assert.deepEqual(lines(join(dir, 'starts.log')), [], 'nothing was launched'); + // The same fixture, configured right, does launch: the log above can tell. + await connect(config(dir)); + assert.equal(lines(join(dir, 'starts.log')).length, 1); }); diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index 5a0d31d20..761d3a971 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -12,10 +12,13 @@ import { serverConfigProblems, serverConfigWarnings, checkServerConfig, + redactUrl, + describeCredentialError, MCPL_ONLY_POLICY_FIELDS, MAX_TIMER_MS, } from '../src/mcpl/protocol-family.js'; import { ModernMcpConnection } from '../src/mcpl/modern-connection.js'; +import { WebSocketTransport } from '../src/mcpl/transport.js'; import type { McplServerConfig } from '../src/mcpl/types.js'; const cfg = (c: Partial): McplServerConfig => ({ id: 's', ...c }); @@ -28,12 +31,20 @@ test('family and transport follow the configuration', () => { assert.deepEqual(resolveServerBinding(cfg({ url: 'wss://example.com/mcpl' })), { family: 'legacy', transport: 'websocket' }); assert.deepEqual(resolveServerBinding(cfg({ url: 'http://localhost:8080/mcp' })), { family: 'modern', transport: 'http' }); assert.deepEqual(resolveServerBinding(cfg({ url: 'https://example.com/mcp' })), { family: 'modern', transport: 'http' }); - // Existing semantics: a command wins over a url unless transport says otherwise. - assert.deepEqual(resolveServerBinding(cfg({ command: 'srv', url: 'ws://x/mcpl' })), { family: 'legacy', transport: 'stdio' }); + // With both a command and a url, an explicit transport says which is used + // (a host layers a recipe's url and transport over a file's command). assert.deepEqual( resolveServerBinding(cfg({ command: 'srv', url: 'ws://x/mcpl', transport: 'websocket' })), { family: 'legacy', transport: 'websocket' }, ); + assert.deepEqual( + resolveServerBinding(cfg({ command: 'srv', url: 'ws://x/mcpl', transport: 'stdio' })), + { family: 'legacy', transport: 'stdio' }, + ); + assert.deepEqual( + resolveServerBinding(cfg({ command: 'srv', url: 'https://x/mcp', transport: 'http' })), + { family: 'modern', transport: 'http' }, + ); assert.deepEqual( resolveServerBinding(cfg({ url: 'https://x/mcp', transport: 'http' })), { family: 'modern', transport: 'http' }, @@ -50,6 +61,39 @@ test('a configuration that names no usable transport throws', () => { assert.throws(() => resolveServerBinding(cfg({ command: 'srv', transport: 'http' })), /transport "http" requires "url"/); }); +test('command and url without a transport are refused, rather than one of them silently ignored', () => { + for (const url of ['ws://x/mcpl', 'https://x/mcp']) { + for (const extra of [{}, { protocol: 'legacy' as const }, { protocol: 'modern' as const }]) { + const c = cfg({ command: 'srv', url, ...extra }); + assert.throws(() => resolveServerBinding(c), /"command" and "url" are both set, and no "transport" says which one to use/, JSON.stringify(c)); + assert.match(serverConfigProblems(c).join(), /"command" and "url" are both set/, JSON.stringify(c)); + } + } + // A transport that disagrees with the url it would use is still refused. + assert.throws(() => resolveServerBinding(cfg({ command: 'srv', url: 'https://x/mcp', transport: 'websocket' })), /does not match url/); + assert.throws(() => resolveServerBinding(cfg({ command: 'srv', url: 'ws://x/mcpl', transport: 'http' })), /does not match url/); +}); + +test('errors and the status view show a url redacted: no userinfo, no query values', () => { + assert.equal(redactUrl('https://user:pw@mcp.example:8443/v1/mcp?token=abc®ion=eu&token=def#frag'), + 'https://mcp.example:8443/v1/mcp?token=redacted®ion=redacted&token=redacted'); + assert.equal(redactUrl('wss://example.test/mcpl'), 'wss://example.test/mcpl'); + // A path segment that looks like a credential goes too: a hosted server's + // per-user secret, a uuid, anything very long. Short and plain-word ones stay. + assert.equal(redactUrl('https://mcp.zapier.com/api/mcp/s/Zt4kQ9xW2vLm8pRs7YbN/mcp'), 'https://mcp.zapier.com/api/mcp/s/redacted/mcp'); + assert.equal(redactUrl('https://mcp.example/server/123e4567-e89b-12d3-a456-426614174000/mcp'), 'https://mcp.example/server/redacted/mcp'); + assert.equal(redactUrl(`https://mcp.example/${'a'.repeat(32)}/mcp`), 'https://mcp.example/redacted/mcp'); + assert.equal(redactUrl('https://mcp.example/v2/2026-07-28/organization-settings/mcp'), 'https://mcp.example/v2/2026-07-28/organization-settings/mcp'); + assert.equal(redactUrl('not a url ?token=abc'), '(an invalid url, not shown)'); + assert.equal(redactUrl(undefined), '(an invalid url, not shown)'); + // The configuration errors that name a url give it redacted, or not at all. + const mismatch = serverConfigProblems(cfg({ url: 'https://u:pw@x/mcp?key=sekret', transport: 'websocket' })).join(); + assert.match(mismatch, /does not match url https:\/\/x\/mcp\?key=redacted/); + const invalid = serverConfigProblems(cfg({ url: 'sekret token not a url' })).join(); + assert.match(invalid, /invalid url/); + for (const text of [mismatch, invalid]) assert.ok(!/sekret|pw@/.test(text), text); +}); + test('every legacy configuration shape passes validation unchanged', () => { const legacy: McplServerConfig[] = [ cfg({ command: 'srv', requestTimeoutMs: 0 }), // zero still disables the legacy watchdog @@ -76,6 +120,17 @@ test('a modern server needs a real deadline', () => { } }); +test('a legacy deadline above the timer limit is refused; every value that worked passes', () => { + for (const bad of [MAX_TIMER_MS + 1, Number.POSITIVE_INFINITY]) { + for (const c of [cfg({ command: 'srv', requestTimeoutMs: bad }), cfg({ url: 'wss://x/mcpl', requestTimeoutMs: bad })]) { + assert.match(serverConfigProblems(c).join(), /requestTimeoutMs must be at most 2147483647/, String(bad)); + } + } + for (const good of [undefined, 0, 1, 60_000, MAX_TIMER_MS]) { + assert.deepEqual(serverConfigProblems(cfg({ command: 'srv', requestTimeoutMs: good })), [], String(good)); + } +}); + test('MCPL policy on a modern server is rejected, empty values are not', () => { const effective: Partial = { enabledFeatureSets: ['a'], @@ -106,6 +161,14 @@ test('MCPL policy on a modern server is rejected, empty values are not', () => { assert.deepEqual(serverConfigProblems(empty), []); }); +test('a credential on a modern stdio server is refused; a legacy stdio server still ignores it', () => { + const provider = async () => 'fresh'; + assert.match(serverConfigProblems(cfg({ command: 'srv', protocol: 'modern', token: 't' })).join(), /"token" applies only to a network \(url\) server/); + assert.match(serverConfigProblems(cfg({ command: 'srv', protocol: 'modern', accessProvider: provider })).join(), /"accessProvider" applies only to a network/); + assert.deepEqual(serverConfigProblems(cfg({ command: 'srv', token: 't', accessProvider: provider })), []); + assert.deepEqual(serverConfigProblems(cfg({ url: 'https://x/mcp', token: 't', accessProvider: provider })), []); +}); + test('a credential sent in cleartext to a host that is not loopback is warned about, never refused', () => { const provider = async () => 'fresh'; const warned = (c: Partial) => serverConfigWarnings(cfg(c)); @@ -123,10 +186,67 @@ test('a credential sent in cleartext to a host that is not loopback is warned ab { url: 'ws://0.0.0.0:8080/mcpl', token: 't' }, { url: 'http://[::1]:8080/mcp', token: 't' }, { url: 'ws://app.localhost/mcpl', token: 't' }, + { url: 'http://[::]:8080/mcp', token: 't' }, + { url: 'http://[::ffff:127.0.0.1]:8080/mcp', token: 't' }, + { url: 'http://[::ffff:127.9.8.7]/mcp', token: 't' }, + { url: 'http://localhost.:8080/mcp', token: 't' }, + { url: 'ws://app.localhost./mcpl', token: 't' }, + { url: 'http://localhost:8080/mcp?token=abc' }, { url: 'http://10.0.0.5:8080/mcp' }, + { url: 'http://10.0.0.5:8080/mcp?region=eu&page=2' }, { command: 'srv', url: 'http://10.0.0.5/mcp', token: 't' }, { url: 'ftp://10.0.0.5/mcp', token: 't' }, ]) assert.deepEqual(warned(quiet), [], JSON.stringify(quiet)); + // Not loopback: a mapped address outside 127/8, and a name that only ends in "localhost". + for (const loud of ['http://[::ffff:10.0.0.5]/mcp', 'http://notlocalhost/mcp']) { + assert.equal(warned({ url: loud, token: 't' }).length, 1, loud); + } +}); + +test('a secret the url carries itself is warned about too, and the warning never quotes it', () => { + const warned = (url: string, extra: Partial = {}) => serverConfigWarnings(cfg({ url, ...extra })); + assert.match(warned('http://user:pw@10.0.0.5/mcp')[0]!, /its url's userinfo goes to 10\.0\.0\.5 unencrypted \(http:\/\/\).*read it;/); + assert.match(warned('ws://mcp.lan/mcpl?api_key=k1')[0]!, /its url's query parameter "api_key" goes to mcp\.lan unencrypted.*use wss:\/\//); + for (const name of ['token', 'Access_Token', 'apikey', 'client_secret', 'password', 'auth', 'X-Amz-Signature']) { + assert.equal(warned(`http://10.0.0.5/mcp?${name}=v`).length, 1, name); + } + const both = warned('http://u:p@10.0.0.5/mcp?token=a&sig_key=b®ion=eu', { token: 't' })[0]!; + assert.match(both, /its credential, its url's userinfo and its url's query parameters "token", "sig_key" go to 10\.0\.0\.5 unencrypted.*read them;/); + for (const text of [both, ...warned('http://user:pw@10.0.0.5/mcp?token=sekret')]) assert.ok(!/sekret|pw|u:p/.test(text), text); + const path = warned('http://10.0.0.5/api/mcp/s/Zt4kQ9xW2vLm8pRs7YbN/mcp')[0]!; + assert.match(path, /a credential-like segment of its url's path goes to 10\.0\.0\.5 unencrypted.*read it;/); + assert.ok(!path.includes('Zt4kQ9xW2vLm8pRs7YbN'), path); + assert.deepEqual(warned('http://10.0.0.5/v2/2026-07-28/mcp'), [], 'a short segment with digits is a name'); + // Encrypted or loopback: nothing. + assert.deepEqual(warned('https://user:pw@10.0.0.5/mcp?token=a'), []); + assert.deepEqual(warned('http://user:pw@127.0.0.1/mcp?token=a'), []); +}); + +test('an access provider that throws is never quoted in the log, on either dial', async () => { + const message = 'refresh token sekret-123 expired'; + const accessProvider = async (): Promise => { throw new Error(message); }; + const logged: string[] = []; + const realError = console.error; + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + try { + // Nothing listens on port 1: each dial fails after asking the provider. + await assert.rejects(WebSocketTransport.open(cfg({ id: 'ws', url: 'ws://127.0.0.1:1/mcpl', accessProvider }))); + await assert.rejects(ModernMcpConnection.connect(cfg({ id: 'http', url: 'http://127.0.0.1:1/mcp', accessProvider }))); + } finally { + console.error = realError; + } + const fallbacks = logged.filter((line) => line.includes('access provider failed')); + assert.equal(fallbacks.length, 2, logged.join('\n')); + for (const line of fallbacks) assert.ok(line.includes(`access provider failed (Error, ${message.length} characters)`), line); + assert.ok(!logged.some((line) => line.includes('sekret')), logged.join('\n')); +}); + +test('an access provider that throws is logged by its error class and length, not its text', () => { + assert.equal(describeCredentialError(new TypeError('refresh failed for sekret-abc')), 'TypeError, 29 characters'); + class ProviderError extends Error {} + assert.equal(describeCredentialError(new ProviderError('x')), 'ProviderError, 1 character'); + assert.equal(describeCredentialError('sekret'), 'a thrown string'); + assert.equal(describeCredentialError(undefined), 'a thrown undefined'); }); test('both engines admit a configuration through checkServerConfig, which logs its warnings', () => { diff --git a/test/mcpl-legacy-version.test.ts b/test/mcpl-legacy-version.test.ts index 050cd5b65..46e5ae415 100644 --- a/test/mcpl-legacy-version.test.ts +++ b/test/mcpl-legacy-version.test.ts @@ -187,13 +187,24 @@ test('a repeating cursor is an error, not a silently partial inventory', async ( await assert.rejects(connection.sendToolsList(), /repeated tools\/list cursor "same"/); }); -test('a modern configuration is refused by the legacy engine before anything is dialed', async () => { +test('a modern configuration is refused by the legacy engine before anything is dialed or spawned', async () => { await assert.rejects( McplServerConnection.connect({ id: 'modern', url: 'https://example.invalid/mcp' }, HOST_CAPS), /configured for modern MCP \(2026-07-28\); McplServerConnection speaks the legacy family/, ); + const dir = mkdtempSync(join(tmpdir(), 'legacy-version-')); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + const startLog = join(dir, 'starts.log'); + await assert.rejects( + McplServerConnection.connect(config('ok', { protocol: 'modern' }, startLog), HOST_CAPS), + /configured for modern MCP/, + ); await assert.rejects( - McplServerConnection.connect({ id: 'modern', command: process.execPath, protocol: 'modern' }, HOST_CAPS), + McplServerConnection.connectWithReconnect(config('ok', { protocol: 'modern', reconnect: true }, startLog), HOST_CAPS), /configured for modern MCP/, ); + assert.equal(existsSync(startLog), false, 'the fixture was never spawned'); + // The same fixture, configured as legacy, does start: the log above can tell. + await connect(config('ok', {}, startLog)); + assert.equal(readFileSync(startLog, 'utf8').split('\n').filter(Boolean).length, 1); }); diff --git a/test/mcpl-list-status.test.ts b/test/mcpl-list-status.test.ts index e30507121..b55eb57c5 100644 --- a/test/mcpl-list-status.test.ts +++ b/test/mcpl-list-status.test.ts @@ -46,6 +46,7 @@ test('listMcplServers exposes the live grant layers and host-owned authority', ( // The stub never handshook, so no revision is established. protocolVersion: null, transport: 'stdio', + refused: null, toolPrefix: 'mcpl--discord', toolCount: 1, policyEstablished: true, @@ -92,3 +93,41 @@ test('listMcplServers distinguishes a disconnected retrying stub from connected' lastNegotiatedAt: null, }); }); + +test('listMcplServers lists a refused configuration as refused, shows urls redacted, and gives a modern server no MCPL policy', () => { + const framework = Object.create(AgentFramework.prototype) as any; + framework.mcplServerConfigs = new Map>([ + ['both', { id: 'both', command: 'node', url: 'wss://u:pw@x.example/mcpl?token=sekret' }], + ['slow', { id: 'slow', url: 'https://x.example/mcp', requestTimeoutMs: 0 }], + ['docs', { id: 'docs', url: 'https://mcp.example/mcp?api_key=sekret®ion=eu' }], + ['chat', { id: 'chat', url: 'wss://user:pw@chat.example/mcpl' }], + ['zap', { id: 'zap', url: 'https://mcp.zapier.com/api/mcp/s/sekretZt4kQ9xW2vLm8/mcp' }], + ]); + framework.mcplServerRegistry = { getServer: () => null }; + framework.mcplTools = []; + framework.mcplPrefixMap = new Map(); + const listed = (framework as AgentFramework).listMcplServers(); + const byId = new Map(listed.map((s) => [s.id, s])); + + // No usable transport: no family or transport is guessed. + const both = byId.get('both')!; + assert.equal(both.family, null); + assert.equal(both.transport, null); + assert.match(both.refused!, /"command" and "url" are both set/); + assert.equal(both.url, 'wss://x.example/mcpl?token=redacted'); + // A binding that resolves, refused for another reason. + const slow = byId.get('slow')!; + assert.equal(slow.family, 'modern'); + assert.equal(slow.transport, 'http'); + assert.match(slow.refused!, /requestTimeoutMs must be an integer from 1/); + // Usable: nothing refused. MCPL policy doesn't apply to a modern server. + const docs = byId.get('docs')!; + assert.deepEqual([docs.family, docs.transport, docs.refused, docs.policyEstablished], ['modern', 'http', null, null]); + assert.equal(docs.url, 'https://mcp.example/mcp?api_key=redacted®ion=redacted'); + const chat = byId.get('chat')!; + assert.deepEqual([chat.family, chat.transport, chat.refused, chat.policyEstablished], ['legacy', 'websocket', null, false]); + assert.equal(chat.url, 'wss://chat.example/mcpl'); + // A per-user secret in the path goes too. + assert.equal(byId.get('zap')!.url, 'https://mcp.zapier.com/api/mcp/s/redacted/mcp'); + assert.ok(!/sekret|pw@/.test(JSON.stringify(listed)), 'no credential in the status'); +}); diff --git a/test/mcpl-ws-transport.test.ts b/test/mcpl-ws-transport.test.ts index 7626eaf76..58cd8415e 100644 --- a/test/mcpl-ws-transport.test.ts +++ b/test/mcpl-ws-transport.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { WebSocketServer, type WebSocket } from 'ws'; import type { AddressInfo } from 'node:net'; import { McplServerConnection } from '../src/mcpl/server-connection.js'; -import { buildWebSocketUrl, isWebSocketTransport } from '../src/mcpl/transport.js'; +import { buildWebSocketUrl, isWebSocketTransport, openTransport } from '../src/mcpl/transport.js'; import type { McplHostCapabilities } from '../src/mcpl/types.js'; const HOST_CAPS: McplHostCapabilities = { version: '0.4', pushEvents: true, featureSets: true }; @@ -73,15 +73,32 @@ test('buildWebSocketUrl validates protocol and appends the token as a query para ); assert.throws(() => buildWebSocketUrl({ id: 's', url: 'http://example.com' }), /ws:\/\/ or wss:\/\//); assert.throws(() => buildWebSocketUrl({ id: 's', command: 'x' }), /requires "url"/); + // A url that doesn't parse can't be redacted, so the error doesn't echo it. + assert.throws(() => buildWebSocketUrl({ id: 's', url: 'not a url ?token=sekret' }), + (error: Error) => /invalid WebSocket url/.test(error.message) && !error.message.includes('sekret')); }); test('isWebSocketTransport selects the transport from config shape', () => { assert.equal(isWebSocketTransport({ id: 's', url: 'wss://x/y' }), true); assert.equal(isWebSocketTransport({ id: 's', url: 'wss://x/y', transport: 'websocket' }), true); assert.equal(isWebSocketTransport({ id: 's', command: 'node' }), false); - // Explicit transport wins over shape. assert.equal(isWebSocketTransport({ id: 's', command: 'node', transport: 'stdio' }), false); - assert.equal(isWebSocketTransport({ id: 's', url: 'wss://x', command: 'node' }), false); // url+command → stdio unless transport says otherwise + // The scheme decides, as resolveServerBinding does: an http(s) url is modern MCP, never a WebSocket. + assert.equal(isWebSocketTransport({ id: 's', url: 'https://x/mcp' }), false); + assert.equal(isWebSocketTransport({ id: 's', url: 'http://x/mcp', transport: 'http' }), false); + // With both a command and a url, the explicit transport decides. + assert.equal(isWebSocketTransport({ id: 's', url: 'wss://x', command: 'node', transport: 'websocket' }), true); + assert.equal(isWebSocketTransport({ id: 's', url: 'wss://x', command: 'node', transport: 'stdio' }), false); + // A configuration resolveServerBinding refuses selects nothing. + assert.equal(isWebSocketTransport({ id: 's', url: 'wss://x', command: 'node' }), false); + assert.equal(isWebSocketTransport({ id: 's', url: 'https://x/mcp', transport: 'websocket' }), false); +}); + +test('openTransport refuses a modern or refused configuration before dialing or spawning anything', async () => { + // x.invalid never resolves, so a dial would fail with a connect error, not these. + await assert.rejects(openTransport({ id: 'm', url: 'https://x.invalid/mcp' }), /configured for modern MCP; the legacy transports can't open it/); + await assert.rejects(openTransport({ id: 'm', command: 'srv', protocol: 'modern' }), /configured for modern MCP; the legacy transports can't open it/); + await assert.rejects(openTransport({ id: 'b', command: 'srv', url: 'wss://x.invalid/mcpl' }), /"command" and "url" are both set/); }); test('connects over WebSocket, negotiates capabilities, lists tools, and round-trips a call', async () => { @@ -118,6 +135,17 @@ test('a failed WebSocket dial rejects with a clear error (no reconnect)', async ); }); +test("a failed dial's error names the endpoint, never a credential the url or the token carries", async () => { + await assert.rejects( + McplServerConnection.connect({ id: 'dead', url: 'ws://u:pw@127.0.0.1:1/mcpl/s/sekret4kQ9xW2vLm8pRs/x?api_key=sekret&v=2', token: 'tok-sekret' }, HOST_CAPS), + (error: Error) => { + assert.match(error.message, /WebSocket connect (failed|timed out).*\(ws:\/\/127\.0\.0\.1:1\/mcpl\/s\/redacted\/x\?api_key=redacted&v=redacted\)/); + assert.ok(!/sekret|pw@/.test(error.message), error.message); + return true; + }, + ); +}); + // A tiny stdio MCPL server: reads NDJSON on stdin, answers the same three // methods on stdout, and writes one line to stderr on boot (to prove stderr // still surfaces after the transport refactor). Kept inline so the test is From f1a6e8f84e403003b045a935899b8dd3e0f72969 Mon Sep 17 00:00:00 2001 From: Olive-1011 Date: Sun, 11 Oct 2026 17:22:48 -0400 Subject: [PATCH 20/94] fix(mcp): the modern engine classifies refused sends and HTTP refusals by what reached the server; a revision refusal is never retried A review of this pull request found places where the modern engine (ModernMcpConnection) reported a request outcome the wire didn't support, retried what can't succeed, or let close() resolve before a connect's own cleanup had finished, and test checks that judged behaviour by the wall clock. This commit answers them. Request outcomes (ObservedTransport, requestFailure): - A send Connectome's stdio transport refuses before writing anything (its line is closed) now throws a typed NotWrittenError, and ObservedTransport puts the request's record back as it was before that leg: nothing reached the server, so the outcome is not-sent. A throw from the HTTP transport's send stays handed off, since part of a request may have gone out. - requestFailure applies its rules in the documented order: nothing handed off is not-sent, whatever was raised; then the server's answer on the most recent leg (a JSON-RPC error, a complete result, or an answer that didn't finish the call); only then an HTTP 401/403 refusal of the current leg. The record now remembers that an earlier leg asked for another round (`continued`), which a later leg's send doesn't clear, so a 401/403 on a continuation leg is no-response, since the server may already have acted. Before, the HTTP check came first: a 401 raised before anything was handed off read as error-response, and so did one on a later round. - A tools/list failure says what a read is: at its deadline, the list didn't arrive in time, and listing changes nothing on the server, so retrying is safe. It used the tool-call warning about duplicating a side effect. Lifetime: - A server that shares no revision with this client (the SDK's UnsupportedProtocolVersionError: no common revision in server/discover, or an unsupported-version error) is a configuration verdict, as for the legacy engine, and in the same type, McplProtocolVersionError (kind rejected, the server's supported list, code -32022, offered 2026-07-28), so every reader treats both engines alike. The first connect throws it even with reconnect set, and a reconnect that meets it emits reconnect-failed with permanent: true; either way reconnecting stops. Before, it was retried forever. When the server lists the legacy revision, the message says what reaches it: for a stdio server, its configuration without protocol: 'modern'; for an http(s) url, which is modern by its scheme, that legacy MCP is reached over stdio or WebSocket. McplProtocolVersionError takes the offered revision as an optional last constructor parameter, defaulting to the legacy one, so legacy call sites don't change. - A new connect generation resets the list-change reopen counter, so its first reopen backs off from the base interval rather than from the previous generation's failures. - The reconnect and reopen backoff is clamped to the timer limit (MAX_TIMER_MS): with jitter it could pass 2^31-1 ms, which Node fires at once, turning the backoff into a tight loop. - open()'s `done`, which close() waits on, now settles only after that connect's own cleanup, its second close of the generation included, on every path (establish() holds the connect and its cleanup). close() then reports a child that cleanup couldn't reap, instead of resolving before it and leaving the failure behind. - The client.onerror comment says what happens: those errors are emitted as the connection's 'error' and reach the host's listeners. Tests (mcp-modern-outcomes, with two fixture flags and a tool): - a send the stdio line refused before writing is not-sent, with nothing on the wire; - a plain call whose child exits before answering is no-response; - the classification order: an HTTP refusal before anything was handed off is not-sent, on a first leg error-response with the status, and after an earlier round no-response; a tools/list deadline reads as a safe-to-retry read; - the backoff never exceeds the timer limit; - a new generation's first reopen is scheduled with the counter reset; - a server listing only another revision fails the first connect with the verdict, nothing will retry it, and one launch ran; a reconnect that meets it emits one permanent reconnect-failed and schedules nothing; a legacy-only server's verdict points a stdio server to its configuration and an http url to stdio or WebSocket; - close() during a connect waits for that connect's own cleanup and reports what it couldn't reap; - the concurrent-close test counts the signals teardown sends, so a second close running its own teardown fails it; - the reconnect-awareness test snapshots once the reopened subscription has been answered (the refresh it announces), not after a fixed 50 ms; - the deadline-between-rounds test holds the round explicitly: leg 1 asks for elicitation input, and an input handler registered on the session's client holds until the deadline aborts it, as the SDK's own pause ends. It no longer races leg 1 against the SDK's internal 250 ms pause, and it shows only leg 1 reached the server by a later call's arrival on the same ordered wire, not a fixed 100 ms wait. Wall-clock ceilings: the modern deadline test runs a 20 s tool and checks that the call rejected before the tool finished and that the server saw the cancellation, instead of a 1.5 s ceiling; the "promptly" checks in mcp-server-ownership and mcpl-legacy-lifetime keep a bound, raised to 20 s, well below the 30 s timeouts they rule out. Each of these fixes, undone alone, fails mcp-modern-outcomes: the pre-write undo; the later-round HTTP rule; the relisten reset; the first connect's verdict; the reconnect's verdict; the verdict's wrapping; the clamp; the listing's wording; close() reporting the connect's own cleanup; the transport-aware hint. --- src/mcpl/modern-connection.ts | 195 +++++++++++++++++++----- src/mcpl/server-connection.ts | 24 +-- test/fixtures/modern-raw-server.mjs | 16 +- test/mcp-modern-connection.test.ts | 8 +- test/mcp-modern-outcomes.test.ts | 228 ++++++++++++++++++++++++++-- test/mcp-server-ownership.test.ts | 3 +- test/mcpl-legacy-lifetime.test.ts | 5 +- 7 files changed, 411 insertions(+), 68 deletions(-) diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 7af667292..cafc4ddf3 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -26,7 +26,9 @@ * `input_required` answer asks for another round, so a call that ends * after one has no answer. * - **Lifetime.** On a lost transport the connection restarts with the same - * backoff settings as legacy, when `reconnect` is set. List-change + * backoff settings as legacy, when `reconnect` is set. A server that shares + * no revision with this client is a configuration verdict, as for legacy + * (`McplProtocolVersionError`): it is never retried. List-change * awareness is kept while the connection lives: a subscription the server * advertises but that didn't open, or that ended, is reopened. Each connect * is a generation, so a superseded client never touches a newer one. @@ -45,6 +47,7 @@ import { SdkErrorCode, SdkHttpError, StreamableHTTPClientTransport, + UnsupportedProtocolVersionError, parseJSONRPCMessage, type AuthProvider, type CallToolResult, @@ -56,8 +59,8 @@ import { } from '@modelcontextprotocol/client'; import { StdioTransport, type McplTransport } from './transport.js'; -import { McplRequestError } from './server-connection.js'; -import { MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, describeCredentialError } from './protocol-family.js'; +import { McplProtocolVersionError, McplRequestError } from './server-connection.js'; +import { LEGACY_MCP_PROTOCOL_VERSION, MAX_TIMER_MS, MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, describeCredentialError } from './protocol-family.js'; import type { McplServerConfig, McpToolDefinition } from './types.js'; const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; @@ -91,8 +94,24 @@ interface WireRecord { readonly method: string; handedOff: boolean; currentId?: string | number; + /** The current leg's answer. */ answer: 'none' | 'error' | 'complete' | 'input-required' | 'unrecognized'; error?: { code: number; message: string; data?: unknown }; + /** An earlier leg's answer asked for another round (or wasn't a complete + * result): the server may have acted on the call, whatever a later leg + * meets. A new leg's send never clears it. */ + continued: boolean; +} + +/** + * A send that Connectome's own transport refused before writing anything (its + * stdio line is closed): nothing of that request reached the server. + */ +class NotWrittenError extends Error { + constructor(message: string) { + super(message); + this.name = 'NotWrittenError'; + } } /** The logical request a send belongs to. The SDK hands each request to the @@ -146,15 +165,30 @@ class ObservedTransport implements Transport { async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { const record = wireScope.getStore(); + let undo: (() => void) | undefined; if (record && 'method' in message && 'id' in message && message.method === record.method) { - // Handed off from here on: past this point nothing proves it unsent. + const prior = { handedOff: record.handedOff, currentId: record.currentId, answer: record.answer, error: record.error }; + // Handed off from here on: past this point nothing proves it unsent, + // unless the transport says it wrote nothing. record.handedOff = true; record.currentId = message.id; record.answer = 'none'; record.error = undefined; this.awaiting.set(message.id, record); + undo = () => { + this.awaiting.delete(message.id); + Object.assign(record, prior); + }; + } + try { + await this.inner.send(message, options); + } catch (error) { + // Refused before any write: this leg never reached the server, so the + // record is as it was before it. Any other throw (an HTTP send can fail + // after part of the request went out) leaves it handed off. + if (error instanceof NotWrittenError) undo?.(); + throw error; } - await this.inner.send(message, options); } async close(): Promise { @@ -183,6 +217,7 @@ class ObservedTransport implements Transport { const result = (message as { result?: unknown }).result; const type = typeof result === 'object' && result !== null ? (result as { resultType?: unknown }).resultType : undefined; record.answer = type === 'complete' ? 'complete' : type === 'input_required' ? 'input-required' : 'unrecognized'; + if (record.answer !== 'complete') record.continued = true; } return record; } @@ -239,7 +274,7 @@ class SpawnerStdioTransport implements Transport { } async send(message: JSONRPCMessage): Promise { - if (!this.line || this.line.closed) throw new Error(`MCP server "${this.config.id}" stdio transport is closed`); + if (!this.line || this.line.closed) throw new NotWrittenError(`MCP server "${this.config.id}" stdio transport is closed`); this.line.writeLine(JSON.stringify(message)); } @@ -297,7 +332,7 @@ export class ModernMcpConnection extends EventEmitter { private opening: { session: Session; done: Promise } | null = null; private generation = 0; private closedByHost = false; - private readonly reconnectEnabled: boolean; + private reconnectEnabled: boolean; private readonly reconnectIntervalMs: number; private readonly reconnectMaxIntervalMs: number; private reconnectTimer: ReturnType | null = null; @@ -416,6 +451,12 @@ export class ModernMcpConnection extends EventEmitter { await this.open(); } catch (error) { if (this.closedByHost) return; + // No common revision is a configuration fact, not an outage: a retry + // would restart the server to hear the same answer. + if (error instanceof McplProtocolVersionError) { + this.reconnectEnabled = false; + throw error; + } if (!this.reconnectEnabled) throw error; const halted = this.unreapedFailure(); if (halted) { @@ -446,6 +487,10 @@ export class ModernMcpConnection extends EventEmitter { if (this.closedByHost) return; const generation = ++this.generation; this.cancelRelisten(); + // A new generation's subscription has failed nothing yet: its first + // reopen backs off from the base interval, not from the previous + // generation's failures. + this.relistenAttempts = 0; const inner: Transport = this.transportKind === 'stdio' ? new SpawnerStdioTransport(this.config, (line) => { if (this.isCurrent(generation)) this.deliverStderr(line); }) : new StreamableHTTPClientTransport(new URL(this.config.url!), { authProvider: bearerAuth(this.config) }); @@ -470,8 +515,10 @@ export class ModernMcpConnection extends EventEmitter { client.onerror = (error: Error) => { // The SDK reports late responses to deadline-abandoned calls here // ("unknown message ID"), and a list-change subscription it could not - // open at connect, among other out-of-band errors. They are logged, - // never delivered: the call's outcome was already reported. + // open at connect, among other out-of-band errors. They are emitted as + // this connection's 'error', so they reach the host's error listeners + // (and are logged when there are none). They never change a call's + // outcome, which was already reported. if (this.isCurrent(generation)) this.emit('error', error); }; client.onclose = () => { @@ -479,18 +526,29 @@ export class ModernMcpConnection extends EventEmitter { }; const session: Session = { generation, client, wire }; - const done = client.connect(wire, { timeout: CONNECT_TIMEOUT_MS }); - this.opening = { session, done }; + // `done` settles only once this connect's own cleanup has finished, on + // every path: close() waits on it, and must not resolve while that + // cleanup could still find a child it can't reap. + let settle!: () => void; + this.opening = { session, done: new Promise((resolve) => { settle = resolve; }) }; try { - await done; + await this.establish(session); + } finally { + if (this.opening?.session === session) this.opening = null; + settle(); + } + } + + /** Connect one generation's client, and adopt it if it's still current. */ + private async establish(session: Session): Promise { + try { + await session.client.connect(session.wire, { timeout: CONNECT_TIMEOUT_MS }); } catch (error) { const failure = await ModernMcpConnection.closeSession(session); if (failure) this.holdUnreaped(session, failure); - throw error; - } finally { - if (this.opening?.session === session) this.opening = null; + throw this.versionVerdict(error) ?? error; } - if (!this.isCurrent(generation)) { + if (!this.isCurrent(session.generation)) { // Superseded or closed while connecting: this generation never serves. const failure = await ModernMcpConnection.closeSession(session); if (failure) this.holdUnreaped(session, failure); @@ -499,7 +557,40 @@ export class ModernMcpConnection extends EventEmitter { this.session = session; this.protocolVersion = MODERN_MCP_PROTOCOL_VERSION; this.reconnectAttempts = 0; - this.keepListening(session, client.autoOpenedSubscription); + this.keepListening(session, session.client.autoOpenedSubscription); + } + + /** + * The server refused the pinned revision (no common one in its + * `server/discover` answer, or an unsupported-version error): a + * configuration verdict, as for the legacy engine, in the same type, so + * every reader treats both engines alike. Retrying can't change it. + */ + private versionVerdict(error: unknown): McplProtocolVersionError | null { + if (!(error instanceof UnsupportedProtocolVersionError)) return null; + const data = (error as { data?: unknown }).data as { supported?: unknown } | undefined; + const supported = Array.isArray(data?.supported) && data.supported.every((v) => typeof v === 'string') + ? (data.supported as string[]) + : null; + // Legacy MCP is reached over stdio or WebSocket. A stdio server's + // family is its configuration's choice; an http(s) url is modern by its + // scheme, so there is nothing to reconfigure on it. + const legacyHint = !supported?.includes(LEGACY_MCP_PROTOCOL_VERSION) + ? '' + : this.transportKind === 'stdio' + ? ` It speaks legacy MCP: configure it without protocol: 'modern'.` + : ` It speaks only legacy MCP, which this client reaches over stdio or WebSocket (ws://, wss://), not http(s).`; + return new McplProtocolVersionError( + `MCP server "${this.id}" does not support MCP ${MODERN_MCP_PROTOCOL_VERSION}` + + (supported ? ` (it supports: ${supported.join(', ') || 'none listed'}).` : ` (${error.message}).`) + + legacyHint, + 'rejected', + null, + supported, + (error as { code?: unknown }).code === undefined ? undefined : Number((error as { code?: unknown }).code), + (error as { data?: unknown }).data, + MODERN_MCP_PROTOCOL_VERSION, + ); } /** @@ -570,11 +661,12 @@ export class ModernMcpConnection extends EventEmitter { } /** The legacy engine's backoff: doubling from the base interval, capped, - * with ±25% jitter. */ + * with ±25% jitter, and never past the timer limit (a longer delay would + * fire at once, turning the backoff into a tight loop). */ private backoffDelay(failures: number): number { const exponent = Math.max(0, Math.min(failures - 1, 30)); const capped = Math.min(this.reconnectIntervalMs * 2 ** exponent, this.reconnectMaxIntervalMs); - return capped * (0.75 + Math.random() * 0.5); + return Math.min(capped * (0.75 + Math.random() * 0.5), MAX_TIMER_MS); } private scheduleReconnect(): void { @@ -601,6 +693,13 @@ export class ModernMcpConnection extends EventEmitter { this.emit('reconnect-failed', { error: `${(error as Error).message}; ${halted}`, attempt, permanent: true }); return; } + // No common revision: every further attempt would hear the same + // answer. Stop; only the configuration can fix it. + if (error instanceof McplProtocolVersionError) { + this.reconnectEnabled = false; + this.emit('reconnect-failed', { error: error.message, attempt, permanent: true }); + return; + } this.emit('reconnect-failed', { error: (error as Error).message, attempt }); this.scheduleReconnect(); } @@ -657,7 +756,7 @@ export class ModernMcpConnection extends EventEmitter { // Connectome refuses before anything reaches the SDK: provably not sent. throw new McplRequestError(`Cannot send ${what}: connection to "${this.id}" is not established`, 'not-sent'); } - const record: WireRecord = { method, handedOff: false, answer: 'none' }; + const record: WireRecord = { method, handedOff: false, answer: 'none', continued: false }; try { return await wireScope.run(record, () => run(session.client)); } catch (error) { @@ -726,7 +825,13 @@ export class ModernMcpConnection extends EventEmitter { if (!this.unreaped.some((held) => held.session === target)) this.unreaped.push({ session: target, failure }); } } - if (opening) await opening.done.catch(() => {}); + if (opening) { + // The connect's own cleanup closes its generation again, after the + // close above; `done` settles once it has. A child that cleanup + // couldn't reap is this close's failure too. + await opening.done; + for (const held of this.unreaped) if (!failures.includes(held.failure)) failures.push(held.failure); + } if (session) this.emit('close', { reason: 'closed by host' }); if (failures.length > 0) { throw new Error(`MCP server "${this.id}" did not close cleanly: ${failures.map((f) => f.message).join('; ')}`); @@ -736,13 +841,12 @@ export class ModernMcpConnection extends EventEmitter { /** * Map a failure onto the request-outcome contract by what crossed the * transport boundary for this request, claiming no more than that shows. - * The call's answer is the server's final one: an error or a `complete` - * result. - * - nothing handed to the transport: `not-sent`, whatever the SDK raised - * (a pre-dispatch validation, a signal already aborted, no transport); - * - an HTTP 401/403 refusal: `error-response`. The server answered, at the - * HTTP layer, so the status is in `data.httpStatus` and `code` stays the - * JSON-RPC code space's (absent); + * The rules apply in this order. The call's answer is the server's final + * one: an error or a `complete` result. + * - nothing handed to the transport: `not-sent`, whatever was raised (a + * pre-dispatch validation, a signal already aborted, no transport, a send + * Connectome's stdio line refused before writing, an HTTP refusal before + * any request went out); * - the most recent leg answered with a JSON-RPC error: `error-response` * with that error's code and data; * - the most recent leg answered with a complete result: the server @@ -756,16 +860,22 @@ export class ModernMcpConnection extends EventEmitter { * rounds cap). Nothing is in flight then, so nothing is cancelled. A * result of a type this client doesn't recognize ends the call where it * is; + * - an HTTP 401/403 refusal of the current leg: `error-response`, the + * status in `data.httpStatus` with `code` absent (the server answered at + * the HTTP layer, outside the JSON-RPC code space). After an earlier leg + * asked for another round it is `no-response` instead: the server may + * already have acted on the call; * - handed off and unanswered: `no-response`. At the deadline, cancellation * was requested and the outcome is unknown. + * A `tools/list` failure says so: listing changes nothing on the server, so + * a retry is safe, where a tool call's retry may duplicate a side effect. */ private requestFailure(what: string, record: WireRecord, error: unknown, timeoutMs?: number): Error { const prefix = `MCP server "${this.id}"`; const message = error instanceof Error ? error.message : String(error); - if (error instanceof SdkHttpError && (error.code === SdkErrorCode.ClientHttpAuthentication || error.code === SdkErrorCode.ClientHttpForbidden)) { - const data = error.data && typeof error.data === 'object' ? error.data : {}; - return new McplRequestError(`${prefix} refused ${what}: HTTP ${error.status} (${message})`, 'error-response', undefined, { ...data, httpStatus: error.status }); - } + const advice = record.method === 'tools/list' + ? 'Listing changes nothing on the server, so it is safe to retry.' + : 'Verify state before retrying; a blind retry of a side-effecting tool may duplicate it.'; if (!record.handedOff) { return new McplRequestError(`${prefix} ${what} was not sent: ${message}`, 'not-sent'); } @@ -787,16 +897,27 @@ export class ModernMcpConnection extends EventEmitter { : `it asked for another round (input_required), and the call ended before that round was sent: ${reason}`; return new McplRequestError( `${prefix} did not finish ${what}${within}: ${ending} ` + - `The outcome is unknown: the server may already have acted on the call. Verify state before retrying; ` + - `a blind retry of a side-effecting tool may duplicate it.`, + `The outcome is unknown: the server may already have acted on the call. ${advice}`, 'no-response', ); } + if (error instanceof SdkHttpError && (error.code === SdkErrorCode.ClientHttpAuthentication || error.code === SdkErrorCode.ClientHttpForbidden)) { + if (record.continued) { + return new McplRequestError( + `${prefix} did not finish ${what}: it asked for another round (input_required), and the server refused a later one: ` + + `HTTP ${error.status} (${message}). The outcome is unknown: the server may already have acted on the call. ${advice}`, + 'no-response', + ); + } + const data = error.data && typeof error.data === 'object' ? error.data : {}; + return new McplRequestError(`${prefix} refused ${what}: HTTP ${error.status} (${message})`, 'error-response', undefined, { ...data, httpStatus: error.status }); + } if (deadline) { + const outcome = record.method === 'tools/list' + ? 'the list did not arrive in time.' + : 'the outcome is unknown: the tool may still complete server-side.'; return new McplRequestError( - `${prefix} did not answer ${what}${within}. Cancellation was requested; ` + - `the outcome is unknown: the tool may still complete server-side. Verify state before retrying; ` + - `a blind retry of a side-effecting tool may duplicate it.`, + `${prefix} did not answer ${what}${within}. Cancellation was requested; ${outcome} ${advice}`, 'no-response', ); } diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index e1c5b1463..7a1eefd31 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -77,19 +77,22 @@ const UNSUPPORTED_PROTOCOL_VERSION_CODE = -32022; const MAX_TOOL_LIST_PAGES = 100; /** - * The server and this legacy engine have no protocol revision in common, so - * the connection was never established. This is a protocol-negotiation fact, - * not a request outcome. Retrying cannot change it: the engine offers one + * The server and this engine have no protocol revision in common, so the + * connection was never established. This is a protocol-negotiation fact, not + * a request outcome. Retrying cannot change it: each engine offers one * revision, and only configuration can fix the mismatch. A connection that - * gets this error does not schedule reconnects. + * gets this error does not schedule reconnects. The legacy engine and the + * modern one (ModernMcpConnection, whose pinned revision the server refused) + * both throw it, so its readers treat them alike. * - * - `rejected`: the server answered `initialize` with error -32022. - * `supported` is the server's own list, when it gave one. - * - `mismatch`: the server answered `initialize` with a revision other than - * 2024-11-05, or with none. + * - `rejected`: the server refused the offered revision: the legacy engine's + * `initialize` answered with error -32022, or a modern server's discovery + * or error naming no common revision. `supported` is the server's own list, + * when it gave one. + * - `mismatch`: the server answered the legacy engine's `initialize` with a + * revision other than 2024-11-05, or with none. */ export class McplProtocolVersionError extends Error { - readonly offered = MCP_PROTOCOL_VERSION; constructor( message: string, readonly kind: 'rejected' | 'mismatch', @@ -100,6 +103,9 @@ export class McplProtocolVersionError extends Error { /** The JSON-RPC error's code and data, verbatim (`rejected`). */ readonly code?: number, readonly data?: unknown, + /** The revision this client offered: the legacy engine's, unless a + * modern connection's. */ + readonly offered: string = MCP_PROTOCOL_VERSION, ) { super(message); this.name = 'McplProtocolVersionError'; diff --git a/test/fixtures/modern-raw-server.mjs b/test/fixtures/modern-raw-server.mjs index da7abbfb5..2044d8889 100644 --- a/test/fixtures/modern-raw-server.mjs +++ b/test/fixtures/modern-raw-server.mjs @@ -11,6 +11,8 @@ // reject-discover-later launches after the first answer server/discover with an error // reject-discover every launch answers server/discover with an error // ignore-sigterm the process survives SIGTERM (only SIGKILL ends it) +// other-revision every launch's server/discover lists only a revision the client doesn't speak +// other-revision-later launches after the first do // Tools: op (outputSchema {value: integer}, answers with a string: invalid // structured content), plain (text), err (a JSON-RPC error), touch (announces // a tool list change on the open subscription), nullframe (writes JSON lines @@ -19,7 +21,8 @@ // 'error', 'hang', 'ok', or 'again' for another state-only round every time. // arguments.leg1 'exit' exits just after the first leg's answer, and // 'elicit' asks for elicitation input instead), task (a result type this -// revision doesn't define), bare (a result with no resultType), die (exits). +// revision doesn't define), bare (a result with no resultType), die (exits), +// vanish (exits without answering). import { createInterface } from 'node:readline'; import { appendFileSync, existsSync, readFileSync } from 'node:fs'; @@ -45,6 +48,15 @@ createInterface({ input: process.stdin }).on('line', (line) => { note({ event: m.id === undefined ? 'notification' : 'request', method: m.method, id: m.id ?? null, ...(m.method === 'tools/call' ? { tool: m.params?.name } : {}) }); switch (m.method) { case 'server/discover': + if (flags.has('other-revision') || (flags.has('other-revision-later') && launch > 1)) { + return result(m.id, { + supportedVersions: ['2099-01-01'], + capabilities: { tools: {} }, + ttlMs: 60000, + cacheScope: 'private', + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'raw-fixture', version: '1' } }, + }); + } if (flags.has('hang-discover-later') && launch > 1) return; if (flags.has('hang-discover')) return; if (flags.has('reject-discover-later') && launch > 1) return error(m.id, -32603, 'not today'); @@ -88,6 +100,7 @@ createInterface({ input: process.stdin }).on('line', (line) => { { name: 'task', inputSchema: { type: 'object' } }, { name: 'bare', inputSchema: { type: 'object' } }, { name: 'die', inputSchema: { type: 'object' } }, + { name: 'vanish', inputSchema: { type: 'object' } }, ], }); case 'tools/call': { @@ -101,6 +114,7 @@ createInterface({ input: process.stdin }).on('line', (line) => { return result(m.id, { content: [{ type: 'text', text: subscription === null ? 'no subscription' : 'touched' }] }); } if (name === 'die') { setTimeout(() => process.exit(5), 10); return result(m.id, { content: [{ type: 'text', text: 'bye' }] }); } + if (name === 'vanish') process.exit(6); if (name === 'task') return send({ jsonrpc: '2.0', id: m.id, result: { resultType: 'task', taskId: 'task-1' } }); if (name === 'bare') return send({ jsonrpc: '2.0', id: m.id, result: { content: [{ type: 'text', text: 'no resultType' }] } }); if (name === 'nullframe') { diff --git a/test/mcp-modern-connection.test.ts b/test/mcp-modern-connection.test.ts index 28539665b..148adca4c 100644 --- a/test/mcp-modern-connection.test.ts +++ b/test/mcp-modern-connection.test.ts @@ -107,15 +107,17 @@ test('a deadline requests cancellation and reports no-response, outcome unknown' const dir = scratch(); const connection = await connect(config(dir, { requestTimeoutMs: 300 })); await connection.callTool('echo', { text: 'warm' }); // the slow call is not the connection's first request - const started = Date.now(); - await assert.rejects(connection.callTool('slow', { ms: 2000 }), (err: unknown) => { + // The tool would run far past the deadline, so the call rejecting while + // the tool hasn't finished shows it ended at the deadline, whatever the + // runner's load did to the wall clock. + await assert.rejects(connection.callTool('slow', { ms: 20_000 }), (err: unknown) => { assert.ok(err instanceof McplRequestError); assert.equal(err.outcome, 'no-response'); assert.match(err.message, /did not answer tools\/call "slow" within 300ms/); assert.match(err.message, /Cancellation was requested; the outcome is unknown/); return true; }); - assert.ok(Date.now() - started < 1500, 'rejected at the deadline, not at the tool\'s end'); + assert.ok(!lines(join(dir, 'events.log')).includes('slow-finished'), 'rejected at the deadline, not at the tool\'s end'); // The server saw the cancellation: the request was cancelled, not merely abandoned. await until(() => lines(join(dir, 'events.log')).includes('slow-aborted')); }); diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts index bd555d669..62b06cbc1 100644 --- a/test/mcp-modern-outcomes.test.ts +++ b/test/mcp-modern-outcomes.test.ts @@ -22,9 +22,11 @@ import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { SdkError, SdkErrorCode, SdkHttpError, UnsupportedProtocolVersionError } from '@modelcontextprotocol/client'; import { ModernMcpConnection } from '../src/mcpl/modern-connection.js'; import { StdioTransport } from '../src/mcpl/transport.js'; -import { McplRequestError } from '../src/mcpl/server-connection.js'; +import { McplProtocolVersionError, McplRequestError } from '../src/mcpl/server-connection.js'; +import { MAX_TIMER_MS } from '../src/mcpl/protocol-family.js'; import type { McplServerConfig } from '../src/mcpl/types.js'; const here = dirname(fileURLToPath(import.meta.url)); @@ -123,15 +125,18 @@ test('a subscription refused at connect is reopened, and change awareness return test('awareness survives a reconnect whose first listen is refused again', async () => { const log = scratchLog(); const connection = await connect('listen-fail-first', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }); - let reconnected = false; - connection.on('reconnect', () => { reconnected = true; }); - await connection.callTool('die', {}); - await until(() => reconnected, 'reconnect'); - // Launch 2 refuses its first listen too; the newer generation reopens it. - await until(() => wire(log).filter((e) => e.method === 'subscriptions/listen' && e.launch === 2).length >= 2, 'a second listen on launch 2'); let changes = 0; connection.on('tools-list-changed', () => changes++); - await new Promise((r) => setTimeout(r, 50)); + let atReconnect: number | null = null; + connection.on('reconnect', () => { atReconnect = changes; }); + await connection.callTool('die', {}); + await until(() => atReconnect !== null, 'reconnect'); + // Launch 2 refuses its first listen too; the newer generation reopens it. + // A reopened subscription is answered before the connection announces the + // refresh it makes for changes it may have missed, so that announcement + // marks the subscription accepted. + await until(() => changes > atReconnect!, 'the reopened subscription answered on launch 2'); + assert.ok(wire(log).filter((e) => e.method === 'subscriptions/listen' && e.launch === 2).length >= 2, 'a second listen on launch 2'); const before = changes; await connection.callTool('touch', {}); await until(() => changes > before, 'the announced change on launch 2'); @@ -172,22 +177,41 @@ test('an input_required continuation keeps its evidence: the second leg decides }); test('a deadline between continuation rounds is no-response, and claims no cancellation', async () => { - // The SDK pauses 250 ms before a state-only round. With a shorter budget, - // the deadline passes inside that pause whenever leg 1 answers in time. const log = scratchLog(); - const connection = await connect('', log, { requestTimeoutMs: 240 }); + const connection = await connect('', log, { requestTimeoutMs: 1500 }); await connection.listTools(); - await assert.rejects(connection.callTool('cont', { leg2: 'ok' }), (err: unknown) => { + // Hold the round between leg 1's answer and leg 2 explicitly, so the + // deadline passes with nothing in flight by construction. Leg 1 asks for + // elicitation input, and an input handler registered on the session's + // client holds until the deadline aborts it, ending as the SDK's own pause + // does. Don't replace this with a deadline timed against the SDK's internal + // pause before a state-only round: that races leg 1 under load. + let held = false; + const client = (connection as unknown as { session: { client: { _requestHandlers: Map } } }).session.client; + client._requestHandlers.set('elicitation/create', (_request: unknown, ctx: { mcpReq: { signal: AbortSignal } }) => { + held = true; + const signal = ctx.mcpReq.signal; + return new Promise((_, reject) => { + const stop = () => reject(signal.reason instanceof SdkError ? signal.reason : new SdkError(SdkErrorCode.RequestTimeout, String(signal.reason))); + if (signal.aborted) stop(); + else signal.addEventListener('abort', stop, { once: true }); + }); + }); + await assert.rejects(connection.callTool('cont', { leg1: 'elicit' }), (err: unknown) => { assert.ok(err instanceof McplRequestError, String(err)); assert.equal(err.outcome, 'no-response'); assert.match( err.message, - /did not finish tools\/call "cont" within 240ms: it asked for another round \(input_required\), and the deadline passed before that round was sent, so nothing was in flight to cancel\. The outcome is unknown/, + /did not finish tools\/call "cont" within 1500ms: it asked for another round \(input_required\), and the deadline passed before that round was sent, so nothing was in flight to cancel\. The outcome is unknown/, ); assert.doesNotMatch(err.message, /Cancellation was requested|answered/); return true; }); - await new Promise((r) => setTimeout(r, 100)); + assert.ok(held, 'the round was held'); + // The wire is ordered: once a later call has reached the server, any + // second leg sent before it would have too. + await connection.callTool('plain', {}); + assert.equal(calls(log, 'plain'), 1); assert.equal(calls(log, 'cont'), 1, 'only leg 1 reached the wire'); assert.equal(wire(log).filter((e) => e.method === 'notifications/cancelled').length, 0, 'nothing was cancelled'); }); @@ -346,11 +370,13 @@ test('concurrent close() calls share one teardown and its verdict; a later close const log = scratchLog(); const realSpawn = StdioTransport.spawn; let stuck: { pid: number; kill: (signal?: string) => boolean } | null = null; + // Every signal teardown sends: one teardown escalates once, SIGTERM then SIGKILL. + const signals: Array = []; (StdioTransport as unknown as { spawn: typeof StdioTransport.spawn }).spawn = (config) => { const transport = realSpawn.call(StdioTransport, config); const child = (transport as unknown as { child: { pid: number; kill: (signal?: string) => boolean } }).child; const realKill = child.kill.bind(child); - child.kill = () => true; // teardown can't reap it + child.kill = (signal?: string) => { signals.push(signal); return true; }; // teardown can't reap it stuck = { pid: child.pid, kill: realKill }; return transport; }; @@ -366,6 +392,7 @@ test('concurrent close() calls share one teardown and its verdict; a later close assert.deepEqual(settled, [], 'neither resolves while teardown is pending'); await Promise.all([first, second]); assert.deepEqual(settled, ['first:failed', 'second:failed'], 'one verdict, in order'); + assert.deepEqual(signals, ['SIGTERM', 'SIGKILL'], 'one teardown, shared: the second close sent no signals of its own'); // Once the child is gone, a later close re-checks and settles. stuck!.kill('SIGKILL'); await until(() => { try { process.kill(stuck!.pid, 0); return false; } catch { return true; } }, 'the child gone'); @@ -388,3 +415,174 @@ test('start() runs once: a second call shares the first connect, and no second s const alive = (p: number) => { try { process.kill(p, 0); return true; } catch { return false; } }; assert.equal(alive(launches[0]!.pid!), false, 'close() reaped the only child'); }); + +test('a send the stdio line refused before writing anything is not-sent', async () => { + const log = scratchLog(); + const connection = await connect('', log); + await connection.listTools(); + // The line is closed, while the session still stands: the transport + // refuses the write, so nothing reached the server. + const line = (connection as unknown as { session: { wire: { inner: { line: { _closed: boolean } } } } }).session.wire.inner.line; + line._closed = true; + try { + await assert.rejects(connection.callTool('plain', {}), (err: unknown) => { + assert.ok(err instanceof McplRequestError, String(err)); + assert.equal(err.outcome, 'not-sent'); + assert.match(err.message, /tools\/call "plain" was not sent: .*stdio transport is closed/); + return true; + }); + } finally { + line._closed = false; + } + assert.equal(calls(log, 'plain'), 0); +}); + +test('a plain call whose child dies before answering is no-response', async () => { + const log = scratchLog(); + const connection = await connect('', log); + connection.on('error', () => {}); + await connection.listTools(); + await assert.rejects(connection.callTool('vanish', {}), (err: unknown) => { + assert.ok(err instanceof McplRequestError, String(err)); + assert.equal(err.outcome, 'no-response'); + assert.match(err.message, /MCP server "raw" tools\/call "vanish" failed: /); + return true; + }); + assert.equal(calls(log, 'vanish'), 1, 'the call reached the server'); +}); + +test('failures are classified in order: nothing sent first, then the answers, then an HTTP refusal', () => { + const connection = ModernMcpConnection.create({ id: 'raw', command: process.execPath, args: [SERVER, '', scratchLog()], protocol: 'modern' }); + const classify = (record: Record, error: unknown, method = 'tools/call') => + (connection as unknown as { + requestFailure: (what: string, record: unknown, error: unknown, timeoutMs?: number) => Error; + }).requestFailure(method === 'tools/list' ? 'tools/list' : 'tools/call "op"', { method, handedOff: true, answer: 'none', continued: false, ...record }, error, 1000); + const refused = () => new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Unauthorized', { status: 401 } as never); + const outcome = (error: Error) => (error instanceof McplRequestError ? error.outcome : 'plain'); + + const unsent = classify({ handedOff: false }, refused()); + assert.equal(outcome(unsent), 'not-sent', 'a refusal before anything was handed off'); + + const first = classify({}, refused()); + assert.equal(outcome(first), 'error-response'); + assert.equal(((first as McplRequestError).data as { httpStatus?: number }).httpStatus, 401); + + const later = classify({ continued: true }, refused()); + assert.equal(outcome(later), 'no-response', 'after an earlier leg asked for another round'); + assert.match(later.message, /asked for another round \(input_required\), and the server refused a later one: HTTP 401/); + + const listing = classify({}, new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out'), 'tools/list'); + assert.equal(outcome(listing), 'no-response'); + assert.match(listing.message, /did not answer tools\/list within 1000ms\. Cancellation was requested; the list did not arrive in time\. Listing changes nothing on the server, so it is safe to retry\./); + assert.doesNotMatch(listing.message, /side-effecting/); +}); + +test('a reconnect backoff never exceeds the timer limit', () => { + const connection = ModernMcpConnection.create({ + id: 'raw', command: process.execPath, args: [SERVER, '', scratchLog()], protocol: 'modern', + reconnect: true, reconnectIntervalMs: MAX_TIMER_MS, reconnectMaxIntervalMs: MAX_TIMER_MS, + }); + const backoff = (connection as unknown as { backoffDelay: (failures: number) => number }).backoffDelay.bind(connection); + const random = Math.random; + Math.random = () => 0.999; // the jitter's top end + try { + assert.equal(backoff(1), MAX_TIMER_MS); + assert.equal(backoff(30), MAX_TIMER_MS); + } finally { + Math.random = random; + } +}); + +test('a new generation backs off its first reopen from the base interval', async () => { + const log = scratchLog(); + const connection = await connect('listen-fail-first', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60_000 }); + const internals = connection as unknown as { + relistenAttempts: number; + generation: number; + scheduleRelisten: (generation: number) => void; + }; + // The previous generation had failed to reopen many times. + internals.relistenAttempts = 9; + const seen: Array<{ generation: number; attempts: number }> = []; + const schedule = internals.scheduleRelisten.bind(connection); + internals.scheduleRelisten = (generation: number) => { + seen.push({ generation, attempts: internals.relistenAttempts }); + schedule(generation); + }; + let reconnected = false; + connection.on('reconnect', () => { reconnected = true; }); + await connection.callTool('die', {}); + await until(() => reconnected, 'reconnect'); + // Launch 2 refuses its first listen, so the new generation schedules a reopen. + await until(() => seen.some((s) => s.generation === internals.generation), 'the new generation scheduling its reopen'); + assert.equal(seen.find((s) => s.generation === internals.generation)!.attempts, 0); +}); + +test('a server that shares no revision with this client is a configuration verdict: no retry', async () => { + const log = scratchLog(); + const connection = ModernMcpConnection.create({ + id: 'raw', command: process.execPath, args: [SERVER, 'other-revision', log], protocol: 'modern', + reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60, + }); + cleanups.push(() => connection.close()); + await assert.rejects(connection.start(), (err: unknown) => { + assert.ok(err instanceof McplProtocolVersionError, String(err)); + assert.equal(err.kind, 'rejected'); + assert.equal(err.offered, '2026-07-28'); + assert.deepEqual(err.supported, ['2099-01-01']); + assert.match(err.message, /MCP server "raw" does not support MCP 2026-07-28 \(it supports: 2099-01-01\)\./); + return true; + }); + assert.equal(connection.willReconnect, false, 'nothing will retry it'); + assert.equal((connection as unknown as { reconnectTimer: unknown }).reconnectTimer, null); + assert.equal(wire(log).filter((e) => e.event === 'start').length, 1); +}); + +test('a legacy-only server is pointed to what reaches it: its configuration over stdio, stdio or WebSocket for an http url', () => { + const refusal = new UnsupportedProtocolVersionError({ supported: ['2024-11-05'], requested: '2026-07-28' }); + const verdictFor = (config: Parameters[0]) => + (ModernMcpConnection.create(config) as unknown as { versionVerdict: (error: unknown) => McplProtocolVersionError | null }).versionVerdict(refusal)!; + const stdio = verdictFor({ id: 'raw', command: process.execPath, args: [SERVER, '', scratchLog()], protocol: 'modern' }); + assert.match(stdio.message, /\(it supports: 2024-11-05\)\. It speaks legacy MCP: configure it without protocol: 'modern'\.$/); + const http = verdictFor({ id: 'remote', url: 'http://127.0.0.1:1/mcp' }); + assert.match(http.message, /\(it supports: 2024-11-05\)\. It speaks only legacy MCP, which this client reaches over stdio or WebSocket \(ws:\/\/, wss:\/\/\), not http\(s\)\.$/); + assert.doesNotMatch(http.message, /protocol: 'modern'/); +}); + +test('a reconnect that meets a revision the client does not share stops for good', async () => { + const log = scratchLog(); + const connection = await connect('other-revision-later', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }); + const failures: Array<{ error: string; permanent?: boolean }> = []; + connection.on('reconnect-failed', (f) => failures.push(f)); + await connection.callTool('die', {}); + await until(() => failures.length > 0, 'the reconnect failing'); + assert.equal(failures.length, 1); + assert.equal(failures[0]!.permanent, true); + assert.match(failures[0]!.error, /does not support MCP 2026-07-28 \(it supports: 2099-01-01\)/); + assert.equal(connection.willReconnect, false); + assert.equal((connection as unknown as { reconnectTimer: unknown }).reconnectTimer, null, 'no further attempt scheduled'); + assert.equal(wire(log).filter((e) => e.event === 'start').length, 2); +}); + +test('close() during a connect waits for that connect\'s own cleanup, and reports what it could not reap', async () => { + const log = scratchLog(); + const connection = ModernMcpConnection.create({ id: 'raw', command: process.execPath, args: [SERVER, 'hang-discover', log], protocol: 'modern' }); + connection.on('error', () => {}); + const statics = ModernMcpConnection as unknown as { closeSession: (session: unknown) => Promise }; + const real = statics.closeSession; + let closes = 0; + // close() closes the generation first; the connect's own cleanup closes it + // again, and that second close is the one that can't confirm the exit. + statics.closeSession = async (session: unknown) => { + const verdict = await real.call(ModernMcpConnection, session); + return ++closes === 2 ? new Error('the child could not be confirmed gone (simulated)') : verdict; + }; + cleanups.push(() => { statics.closeSession = real; }); + const starting = connection.start(); + await until(() => wire(log).some((e) => e.method === 'server/discover'), 'the launch waiting on discover'); + await assert.rejects(connection.close(), /did not close cleanly: the child could not be confirmed gone \(simulated\)/); + assert.equal(closes, 2, 'close() resolved only after the connect\'s own cleanup'); + await starting; + statics.closeSession = real; + await connection.close(); +}); diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index 65def7234..875c0d85e 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -196,7 +196,8 @@ test('a disconnect during a modern connect ends it at once', async () => { const began = Date.now(); await framework.disconnectMcplServer('m'); await assert.rejects(pending, /MCP server "m" was not connected: it was disconnected while connecting/); - assert.ok(Date.now() - began < 5_000, 'not after the 30 s connect timeout'); + // A generous bound, well below the 30 s connect timeout it rules out. + assert.ok(Date.now() - began < 20_000, 'not after the 30 s connect timeout'); assert.equal(status(framework, 'm'), undefined); assert.equal(alive(launches(log)[0]!), false, 'its child was reaped'); }); diff --git a/test/mcpl-legacy-lifetime.test.ts b/test/mcpl-legacy-lifetime.test.ts index 0ee93a278..31abe8a38 100644 --- a/test/mcpl-legacy-lifetime.test.ts +++ b/test/mcpl-legacy-lifetime.test.ts @@ -200,7 +200,8 @@ test('close() during a reconnect handshake ends that launch before resolving, an const second = starts(log)[1]!; const began = Date.now(); await connection.close(); - assert.ok(Date.now() - began < 5_000, 'promptly: the handshake was ended, not waited out (30 s)'); + // A generous bound, well below the 30 s handshake timeout it rules out. + assert.ok(Date.now() - began < 20_000, 'promptly: the handshake was ended, not waited out (30 s)'); assert.equal(alive(second), false, 'the launch in flight was reaped before close() resolved'); assert.equal(reconnected, false); assert.equal(connection.isConnected, false); @@ -221,7 +222,7 @@ test('a recycle during a reconnect in flight starts no second attempt, and close assert.equal(starts(log).length, 2, 'no second attempt beside the one in flight'); const began = Date.now(); await connection.close(); - assert.ok(Date.now() - began < 5_000, 'promptly'); + assert.ok(Date.now() - began < 20_000, 'promptly: well before the 30 s handshake timeout'); assert.deepEqual(starts(log).filter(alive), [], 'every launch reaped when close() resolved'); }); From 559ecaaff5c79b046bf396279b6623c5b1360f61 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sun, 11 Oct 2026 17:43:14 -0400 Subject: [PATCH 21/94] fix(mcp): hold a server's id until its child is confirmed exited, everywhere, and keep the tool list and traces true A review of this change found that the lifetime contract it states (a launch whose child can't be confirmed exited stays registered, holding its id, and a later disconnect retries the reap) didn't hold on every path, and that the tool list and traces could disagree with what the servers were doing. The id is held until the exit is confirmed: - The registry's removeServer keeps the connection registered while its close runs, and frees the id only once the close succeeds. Before, the id was free during the reap, so a concurrent addServer could launch a second child beside the first. - addServer refuses a second add for an id whose first is still connecting, so a failed launch's closed connection can always be registered as the owner of a child it couldn't reap. - A connection that still owns a child it couldn't reap (holdsUnreapedChild) isn't dropped by the registry when it emits 'close', so its own close() keeps the id held too. - closeAll closes every connection, removes the ones it reaped, keeps each one it couldn't, and reports every failure, not only the first. - The modern connections close the same way at stop() and after a failed startup: one whose close fails stays in the map. - A failed startup's cleanup closes both engines' connections, each whatever the other's close did, logs what it couldn't close, and rethrows the startup failure itself, so a cleanup failure never takes its place. - stop() finishes every part of its teardown and then rejects, naming each child it couldn't reap, so its caller learns that a child may have outlived the framework. Other lifetime fixes: - close() during a WebSocket reconnect dial ends the dial at once: the handshake's abort signal reaches the dial (openTransport and WebSocketTransport.open take it), instead of close() waiting out the 15 s open timeout. - restartMcplServer checks the new configuration before it tears the running server down, and refuses without touching it. - A configuration refused at startup is reported with willRetry false: nothing retries it, reconnect or not. - A stdio server switched to protocol: 'modern' is a new coalescing binding. The marker is added only for that case, so every binding stored for a legacy configuration stays the same. - A null nextCursor ends a tools/list inventory, as an absent one does and as before this change; only a non-string, non-null cursor is malformed. - The reconnect comment no longer promises a resume after an unreapable launch: close() is terminal. The tool list and traces: - A refresh publishes nothing for a server removed while it awaited the lists, so a refresh that finishes after a disconnect can't bring the removed server's tools back. - A server lost for good (no reconnect), legacy or modern, leaves the agents' tool list, as a disconnect's tools do. - An MCP tool result the server marked an error is traced as tool:failed, on both engines, as the other tool paths trace one. - mcpToolResult's doc says the direct path and a structured script result get the server's result itself. The changelog fragments say so: stop() rejecting after its teardown (a breaking note for hosts, which should catch it where they chain work on stop()), the registry's id hold, close() ending a WebSocket dial, a server lost for good leaving the tool list, and tool:failed for an error result. Tests: - registry: an id held through its reap; a second add refused while one is connecting; a connection whose reap fails staying registered through its own close(), closeAll() and a failed removeServer, until a retry succeeds; closeAll naming each failure and removing the rest. - a reconnect whose previous child can't be reaped halting; close() during a WebSocket reconnect dial returning promptly (a real server whose second upgrade hangs); a dial abandoned on its signal; a stdin EPIPE never thrown. - stop() rejecting after keeping both engines' unreaped connections; the failed-startup cleanup closing the modern connection though the legacy close failed; a refused restart leaving the server running; a refresh in flight during a disconnect publishing none of its tools; a server lost for good leaving the list; the coalescing binding. - legacy protocol and inventory: a -32022 without the modern revision naming no modern remedy; a version verdict launched once, its child reaped, never retried; a non-version initialize error staying plain and retryable; a modern stdio configuration refused before any spawn; a list that never ends refused at its page bound; a malformed later page; a null cursor ending the list; willRetry and the ops alert at startup for a verdict, failures with and without reconnect, and a refused configuration. - modern: a lost child relisted from the new launch (what only the old launch listed is gone); quiesce beginning while a call is provably in flight; a tool error traced as tool:failed. - the negative waits now say which timer they outlast, and labels say what each wait actually sees. Red checks on dist, each change removed alone (16): the id freed before the reap, no in-flight add refusal, close forgetting an unreaped connection, closeAll clearing first, a null cursor as malformed, the dial ignoring its signal, the handshake dialing without it, the startup cleanup run in sequence, the modern close forgetting the unreaped, stop() swallowing reap failures, restart tearing down first, a refresh republishing removed servers, a lost server keeping its tools, an error result traced as completed, no family in the binding, and a refused configuration reported as retrying: each fails the test aimed at it. The full suite at this tree, each test file in its own process on the VPS, with the three commits before it: 1530/1530 across 136 files on Node 20.20.2. Node 24.21.0 counted 1527, because --test-force-exit cut mcpl-tool-lifecycle-emitter to 23 of its 26 tests; alone and unforced it passes 26/26. --- .../legacy-mcp-protocol-boundary.breaking.md | 6 + .../mcp-standard-tool-results.changed.md | 11 +- changelog.d/modern-mcp-client.added.md | 3 + src/framework.ts | 152 +++++++++++++--- src/mcpl/server-connection.ts | 29 ++- src/mcpl/server-registry.ts | 50 ++++-- src/mcpl/transport.ts | 33 ++-- test/fixtures/legacy-version-server.mjs | 12 +- test/mcp-modern-framework.test.ts | 60 ++++++- test/mcp-server-ownership.test.ts | 134 +++++++++++++- test/mcpl-legacy-lifetime.test.ts | 165 +++++++++++++++++- test/mcpl-legacy-version.test.ts | 121 ++++++++++++- 12 files changed, 689 insertions(+), 87 deletions(-) diff --git a/changelog.d/legacy-mcp-protocol-boundary.breaking.md b/changelog.d/legacy-mcp-protocol-boundary.breaking.md index fc815cfa6..d232dc8e1 100644 --- a/changelog.d/legacy-mcp-protocol-boundary.breaking.md +++ b/changelog.d/legacy-mcp-protocol-boundary.breaking.md @@ -27,3 +27,9 @@ `requestTimeoutMs` above 2^31−1 is refused too: Node ran it as a timer of about 1 ms, so every request timed out at once. Every value that worked, `0` included, passes. +- **Hosts:** `stop()` now rejects, after its whole teardown, when an MCP + server's child couldn't be confirmed exited, naming each such child. + Before, closing a stdio server returned as soon as a signal was sent, so + `stop()` never failed for this. Code that chains work on `stop()`, such as + exiting a TUI or switching sessions, should catch that rejection: the + framework is stopped either way. diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index 4bb318b76..c6a74c414 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -40,8 +40,9 @@ - Closing a stdio MCP/MCPL server now waits for the child to exit, sending SIGKILL after 2 s. A child still alive 2 s after SIGKILL fails `close()` with an explicit error. Before, close returned as soon as a signal was - sent. `stop()` reports such a failure and still finishes shutting - everything else down. Concurrent `close()` calls share one teardown, and a + sent. `stop()` still finishes shutting everything else down after such a + failure, and then rejects (see the breaking notes). Concurrent `close()` + calls share one teardown, and a later call re-checks a child that couldn't be reaped. A launch whose exit can't be confirmed is never orphaned, and nothing launches beside it: - `McplServerConnection.connect()` throws the new `McplUnreapedLaunchError`, @@ -52,7 +53,11 @@ (`reconnect-failed`, `permanent: true`). Reconnect attempts run one at a time, and never start before the previous child has exited. - `close()` during a reconnect handshake ends that launch before it - resolves. + resolves, a WebSocket dial still opening included. + - `McplServerRegistry` holds an id until its child is confirmed exited: + `removeServer` frees it only once the close succeeds, a second + `addServer` for an id still connecting is refused, and `closeAll()` + keeps each connection it couldn't reap, naming every failure. - In the framework, a disconnect or failed connect whose cleanup can't reap the child keeps the server registered, closed and listed as disconnected, and its error says so. A connect for that id is refused diff --git a/changelog.d/modern-mcp-client.added.md b/changelog.d/modern-mcp-client.added.md index 8d4a38ac6..8f9af2a81 100644 --- a/changelog.d/modern-mcp-client.added.md +++ b/changelog.d/modern-mcp-client.added.md @@ -32,3 +32,6 @@ call's promise. - **Status:** `listMcplServers()` entries gain `family`, `protocolVersion` and `transport`. +- A server lost for good (it closed and won't reconnect), MCPL or modern, + now leaves the agents' tool list, as a disconnected one does. An MCP tool + result marked as an error is traced as `tool:failed`, on both engines. diff --git a/src/framework.ts b/src/framework.ts index 45f7be15a..0d62e8f87 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -477,6 +477,15 @@ interface DeferredWrite { } const bySeq = (a: { seq: number }, b: { seq: number }): number => a.seq - b.seq; +/** A server configuration refused before anything was spawned or dialed: + * only a corrected configuration helps, so nothing retries it. */ +class McpConfigurationRefusal extends Error { + constructor(message: string) { + super(message); + this.name = 'McpConfigurationRefusal'; + } +} + /** Stamp a deferred write's durable id into the metadata it is stored with * (boot recovery dedups replays by it). Idempotent for an already-stamped * message. */ @@ -2045,9 +2054,13 @@ export class AgentFramework { shutdownPromises.push(this.closeModernMcpConnections()); // Every part shuts down even if one fails (a child that could not be // reaped, say); each failure is reported, and the rest of stop() runs. + // stop() then rejects with them, once everything else is done, so its + // caller learns that a child may have outlived the framework. + const shutdownFailures: string[] = []; for (const outcome of await Promise.allSettled(shutdownPromises)) { if (outcome.status === 'rejected') { const reason = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason); + shutdownFailures.push(reason); console.error(`[shutdown] ${reason}`); this.emitTrace({ type: 'mcpl:server-error', serverId: 'shutdown', error: reason }); } @@ -2073,6 +2086,10 @@ export class AgentFramework { if (this.ownsStore) { this.store.close(); } + + if (shutdownFailures.length > 0) { + throw new Error(`stop() finished, but not cleanly: ${shutdownFailures.join('; ')}`); + } } /** @@ -7653,7 +7670,14 @@ export class AgentFramework { */ private coalescingBinding(serverId: string): string { const config = this.mcplServerConfigs.get(serverId); - return createHash('sha256').update(JSON.stringify([serverId, config?.url ?? null, config?.command ?? null, config?.args ?? null])).digest('hex').slice(0, 16); + // A stdio server switched to the modern family is another peer, so its + // binding changes. The marker is added only for protocol: 'modern', so + // every binding stored for a legacy configuration stays the same. A + // network server's family follows its url's scheme, which is hashed. + const family = config?.protocol === 'modern' ? ['modern'] : []; + return createHash('sha256') + .update(JSON.stringify([serverId, config?.url ?? null, config?.command ?? null, config?.args ?? null, ...family])) + .digest('hex').slice(0, 16); } private initializePushCoalescer(): void { @@ -13368,8 +13392,7 @@ export class AgentFramework { await this.connectMcplServerInternal(config, true); } catch (error) { if (error instanceof DiscordAwarenessAccountingError) { - await this.mcplServerRegistry.closeAll(); - await this.closeModernMcpConnections(); + await this.closeMcpConnectionsAfterFailedStartup(); throw error; } // Fail-open: log and continue with remaining servers @@ -13385,8 +13408,10 @@ export class AgentFramework { error: err.message, attempt: 0, // A protocol-version verdict is never retried (no common revision; - // only configuration can fix it). - willRetry: config.reconnect === true && !(error instanceof McplProtocolVersionError), + // only configuration can fix it), and neither is a configuration + // refused before anything was launched. + willRetry: config.reconnect === true && !(error instanceof McplProtocolVersionError) + && !(error instanceof McpConfigurationRefusal), }); // So the server doesn't vanish quietly: alert at once, as the // reconnect path does for the same verdict, since no attempt follows. @@ -13405,8 +13430,7 @@ export class AgentFramework { await startupBarrier.promise; this.completeMcplDataPlaneGate(startupBarrier); } catch (error) { - await this.mcplServerRegistry.closeAll(); - await this.closeModernMcpConnections(); + await this.closeMcpConnectionsAfterFailedStartup(); throw error; } @@ -13465,16 +13489,41 @@ export class AgentFramework { } /** Close every modern-MCP connection, including any connect still in - * flight, so none outlives a failed startup or stop(). */ + * flight, so none outlives a failed startup or stop(). One whose close + * can't confirm its child exited stays registered, holding its id, as a + * failed connect's does; every failure is thrown together. */ private async closeModernMcpConnections(): Promise { - const modern = [...this.modernServers().values()]; - this.modernServers().clear(); - const failures = (await Promise.allSettled(modern.map((connection) => connection.close()))) - .filter((o): o is PromiseRejectedResult => o.status === 'rejected') - .map((o) => (o.reason instanceof Error ? o.reason.message : String(o.reason))); + const modern = [...this.modernServers().entries()]; + const outcomes = await Promise.allSettled(modern.map(([, connection]) => connection.close())); + const failures: string[] = []; + outcomes.forEach((outcome, index) => { + const [id, connection] = modern[index]!; + if (outcome.status === 'fulfilled') { + if (this.modernServers().get(id) === connection) this.modernServers().delete(id); + } else { + failures.push(outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)); + } + }); if (failures.length > 0) throw new Error(failures.join('; ')); } + /** After a failed startup: close both engines' connections, each whatever + * the other's close did, and log what couldn't be closed. The caller then + * rethrows the startup failure itself, so a cleanup failure never takes + * its place. */ + private async closeMcpConnectionsAfterFailedStartup(): Promise { + const outcomes = await Promise.allSettled([ + this.mcplServerRegistry?.closeAll() ?? Promise.resolve(), + this.closeModernMcpConnections(), + ]); + for (const outcome of outcomes) { + if (outcome.status === 'rejected') { + console.error('[mcpl] after a failed startup, some MCP connections could not be closed:', + outcome.reason instanceof Error ? outcome.reason.message : outcome.reason); + } + } + } + /** Reconcile the durable ledger with Chronicle, then deliver every server's work. */ async syncDiscordAwarenessMarkers(_onlyServerId?: string): Promise { if (!this.discordAwarenessOutbox) return; @@ -13840,16 +13889,7 @@ export class AgentFramework { // 'websocket', transport 'http' with a ws url or with no url at all), // protocol on a URL, a modern deadline of 0, MCPL policy on a modern // server. Every valid legacy shape resolves exactly as before. - let family: 'legacy' | 'modern'; - try { - family = resolveServerBinding(config).family; - } catch (error) { - throw new Error(`MCP server "${config.id}" configuration: ${error instanceof Error ? error.message : String(error)}`); - } - const problems = serverConfigProblems(config); - if (problems.length > 0) { - throw new Error(`MCP server "${config.id}" configuration: ${problems.join('; ')}`); - } + const family = AgentFramework.usableConfigFamily(config); // One owner per id, across both engines (holdMcpServerId): a connect or // disconnect in flight holds it, and so does a registered connection, // including one kept because its launch couldn't be confirmed exited. @@ -14453,10 +14493,29 @@ export class AgentFramework { if (!config) { throw new Error(`MCPL server "${id}" is not configured`); } + // A configuration the connect would refuse is refused here, before the + // running server is torn down for it. + AgentFramework.usableConfigFamily(config); await this.disconnectMcplServer(id); await this.connectMcplServer(config); } + /** The protocol family a configuration resolves to, or a refusal naming + * every problem with it, before anything is spawned, dialed or torn down. */ + private static usableConfigFamily(config: import('./mcpl/types.js').McplServerConfig): 'legacy' | 'modern' { + let family: 'legacy' | 'modern'; + try { + family = resolveServerBinding(config).family; + } catch (error) { + throw new McpConfigurationRefusal(`MCP server "${config.id}" configuration: ${error instanceof Error ? error.message : String(error)}`); + } + const problems = serverConfigProblems(config); + if (problems.length > 0) { + throw new McpConfigurationRefusal(`MCP server "${config.id}" configuration: ${problems.join('; ')}`); + } + return family; + } + /** * List configured MCPL servers with live connection status and the number * of tools each currently contributes. @@ -14899,6 +14958,11 @@ export class AgentFramework { willReconnect: connection.willReconnect, }); this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` }); + // Lost for good (no reconnect, and the registry has dropped it): its + // tools leave the agents' list, as a disconnect's do. + if (!connection.willReconnect && this.mcplServerRegistry?.getServer(connection.id) !== connection) { + this.handleToolsListChanged(connection.id); + } }); } @@ -14959,10 +15023,23 @@ export class AgentFramework { // closes it removes it, so the id stays held meanwhile. if (!connection.isClosed && !connection.willReconnect && this.modernServers().get(connection.id) === connection) { this.modernServers().delete(connection.id); + // Gone for good: its tools leave the agents' list, as a disconnect's do. + this.handleToolsListChanged(connection.id); } }); } + /** An MCP tool call's trace: a result the server marked an error (or one + * the reading couldn't complete) is tool:failed, as on the other tool + * paths; anything else is tool:completed. */ + private traceMcpToolOutcome(serverId: string, tool: string, callId: string, result: ToolResult, durationMs: number): void { + if (result.isError) { + this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool, callId, error: result.error ?? 'tool returned an error' }); + } else { + this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool, callId, durationMs }); + } + } + /** * Discover tools from all connected MCPL servers and cache them. * Tools are namespaced as `{toolPrefix}--{toolName}` per server config. @@ -15051,7 +15128,25 @@ export class AgentFramework { } } - this.mcplTools = tools; + // A server removed while this refresh awaited its lists (a disconnect, + // or a server lost for good) publishes nothing: whichever refresh + // finishes last must not bring back a removed server's tools. + // Asked of the same listing the refresh began from, now. + const live = new Set([ + ...(this.mcplServerRegistry?.getAllServers() ?? []).map((server) => server.id), + ...this.modernServers().keys(), + ]); + const gone = new Set([...new Set(toolServers.values())].filter((serverId) => !live.has(serverId))); + if (gone.size > 0) { + for (const [name, serverId] of [...toolServers]) { + if (!gone.has(serverId)) continue; + toolServers.delete(name); + toolFeatureSets.delete(name); + toolClasses.delete(name); + } + for (const serverId of gone) listedServers.delete(serverId); + } + this.mcplTools = gone.size > 0 ? tools.filter((tool) => toolServers.has(tool.name)) : tools; this.mcplToolFeatureSets = toolFeatureSets; this.mcplToolClasses = toolClasses; this.mcplToolServers = toolServers; @@ -15269,7 +15364,7 @@ export class AgentFramework { modern.callTool(toolName, args) .then((result) => reading(result, false)) .then((result) => { - this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs: Date.now() - startTime }); + this.traceMcpToolOutcome(serverId, toolName, call.id, result, Date.now() - startTime); this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: `mcpl:${serverId}`, result }); }) .catch(failDispatch); @@ -15340,7 +15435,7 @@ export class AgentFramework { }) .then(({ result, toolResult }) => { const durationMs = Date.now() - startTime; - this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs }); + this.traceMcpToolOutcome(serverId, toolName, call.id, toolResult, durationMs); // Record checkpoint from stateful tool response (Step 8). // Explicit response attribution is authoritative. Otherwise use the @@ -15449,7 +15544,10 @@ export class AgentFramework { * payloads, and `structured` kept by presence. An MCPL peer keeps the * RFC-005 reading. Its content array passes through when it holds * non-text blocks, so references and images render natively. It gets - * `structured` too, with the JSON view when its content is empty. + * `structured` too, with the JSON view when its content is empty. The + * direct path (a module's callTool, a channel's tool call) doesn't come + * here: it returns the server's result itself (directToolResult), for + * either engine, as does a script's call whose result is structured. */ private async mcpToolResult( result: { content?: unknown; isError?: boolean; structuredContent?: unknown }, diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 7a1eefd31..93917754b 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -398,6 +398,13 @@ export class McplServerConnection extends EventEmitter { return this.reconnectEnabled; } + /** Whether a child this connection launched couldn't be confirmed exited. + * While it's true the connection owns that child, so whatever registered + * it keeps its id held; close() retries the reap. */ + get holdsUnreapedChild(): boolean { + return this.unreaped.length > 0; + } + /** * Private constructor. Use `McplServerConnection.connect()` instead. */ @@ -692,8 +699,8 @@ export class McplServerConnection extends EventEmitter { * open socket never leaks. If that close can't confirm the child exited, * the failure is a {@link HandshakeCleanupError} carrying the transport, so * the caller keeps owning it. `signal` ends a handshake whose connection is - * closed meanwhile: it is checked once the transport is open (a WebSocket - * dial isn't cut short) and races the initialize answer. + * closed meanwhile: it cuts a WebSocket dial short, is checked once the + * transport is open, and races the initialize answer. */ private static async handshake( config: McplServerConfig, @@ -702,7 +709,13 @@ export class McplServerConnection extends EventEmitter { ): Promise<{ transport: McplTransport; capabilities: McplCapabilities | null; droppedCapabilities: ReadonlySet; mcpToolsAdvertised: boolean; protocolVersion: string }> { const closedWhileConnecting = () => new Error(`MCPL server "${config.id}" was closed while connecting`); if (signal?.aborted) throw closedWhileConnecting(); - const transport = await openTransport(config); + let transport: McplTransport; + try { + transport = await openTransport(config, signal); + } catch (error) { + if (signal?.aborted) throw closedWhileConnecting(); + throw error; + } try { if (signal?.aborted) throw closedWhileConnecting(); @@ -1052,8 +1065,9 @@ export class McplServerConnection extends EventEmitter { /** * Send `tools/list` and return the server's complete tool inventory, * following `nextCursor` across pages (2024-11-05 pagination). Cursors are - * opaque strings, `''` included: only an absent `nextCursor` ends the - * inventory. A page that isn't well formed (no `tools` array, a cursor + * opaque strings, `''` included: only an absent or null `nextCursor` ends + * the inventory (a null one is how some servers say "no more", and main + * read it as a complete page). A page that isn't well formed (no `tools` array, a cursor * that isn't a string), or cursors that don't terminate (a repeat, or more * than {@link MAX_TOOL_LIST_PAGES} pages), are errors, never a partial or * empty inventory published as complete. @@ -1072,7 +1086,7 @@ export class McplServerConnection extends EventEmitter { } first ??= result; tools.push(...(result.tools as McpToolDefinition[])); - if (result.nextCursor === undefined) { + if (result.nextCursor === undefined || result.nextCursor === null) { // The first page's other fields are kept as they came, so a // single-page answer is returned exactly as before. const { nextCursor: _done, ...rest } = first as { nextCursor?: unknown }; @@ -1299,7 +1313,8 @@ export class McplServerConnection extends EventEmitter { // No common protocol revision: every further attempt would restart the // server to hear the same answer. Stop retrying; the configuration is // what has to change. A launch that couldn't be reaped halts retrying - // too, until close() confirms its exit. Every other failure keeps the + // for good: close() can confirm its exit, but close() is terminal, so + // nothing resumes this connection. Every other failure keeps the // backoff loop. if (error instanceof McplProtocolVersionError) this.reconnectEnabled = false; const permanent = unreaped || error instanceof McplProtocolVersionError; diff --git a/src/mcpl/server-registry.ts b/src/mcpl/server-registry.ts index fc773df9f..3490e1c5d 100644 --- a/src/mcpl/server-registry.ts +++ b/src/mcpl/server-registry.ts @@ -25,6 +25,10 @@ export type McplCapabilityQuery = */ export class McplServerRegistry { private servers = new Map(); + /** Ids whose addServer is still connecting. A second add for one is + * refused, so a failed launch's closed connection can always be registered + * as the owner of a child it couldn't reap. */ + private adding = new Set(); /** * Connect to an MCPL server and register it. @@ -39,20 +43,21 @@ export class McplServerRegistry { config: McplServerConfig, hostCapabilities: McplHostCapabilities, ): Promise { - if (this.servers.has(config.id)) { + if (this.servers.has(config.id) || this.adding.has(config.id)) { throw new Error(`MCPL server "${config.id}" is already registered`); } + this.adding.add(config.id); let connection: McplServerConnection; try { connection = config.reconnect ? await McplServerConnection.connectWithReconnect(config, hostCapabilities) : await McplServerConnection.connect(config, hostCapabilities); } catch (error) { - if (error instanceof McplUnreapedLaunchError && !this.servers.has(config.id)) { - this.register(config, error.connection); - } + if (error instanceof McplUnreapedLaunchError) this.register(config, error.connection); throw error; + } finally { + this.adding.delete(config.id); } this.register(config, connection); return connection; @@ -61,9 +66,11 @@ export class McplServerRegistry { private register(config: McplServerConfig, connection: McplServerConnection): void { this.servers.set(config.id, connection); - // Auto-remove on unexpected close (unless reconnect will re-add) + // Auto-remove on unexpected close (unless reconnect will re-add). A + // connection that still owns a child it couldn't reap keeps its id, so + // nothing launches beside that child; removeServer retries the reap. connection.on('close', () => { - if (!config.reconnect && this.servers.get(config.id) === connection) { + if (!config.reconnect && this.servers.get(config.id) === connection && !connection.holdsUnreapedChild) { this.servers.delete(config.id); } }); @@ -86,13 +93,10 @@ export class McplServerRegistry { if (!connection) { return; } - this.servers.delete(id); - try { - await connection.close(); - } catch (error) { - if (!this.servers.has(id)) this.servers.set(id, connection); - throw error; - } + // The id stays held until the close settles, so nothing launches beside + // a child still being reaped; it's freed only once the exit is confirmed. + await connection.close(); + if (this.servers.get(id) === connection) this.servers.delete(id); } /** @@ -156,11 +160,23 @@ export class McplServerRegistry { } /** - * Close all server connections and clear the registry. + * Close every server connection. Each one whose close confirms its exit + * is removed. One whose child couldn't be confirmed exited stays + * registered, holding its id, and every such failure is thrown together; + * removeServer retries a reap. */ async closeAll(): Promise { - const connections = Array.from(this.servers.values()); - this.servers.clear(); - await Promise.all(connections.map((c) => c.close())); + const entries = Array.from(this.servers.entries()); + const outcomes = await Promise.allSettled(entries.map(([, connection]) => connection.close())); + const failures: string[] = []; + outcomes.forEach((outcome, index) => { + const [id, connection] = entries[index]!; + if (outcome.status === 'fulfilled') { + if (this.servers.get(id) === connection) this.servers.delete(id); + } else { + failures.push(outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)); + } + }); + if (failures.length > 0) throw new Error(failures.join('; ')); } } diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index 09ba3de81..529b5feb2 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -305,9 +305,11 @@ export class WebSocketTransport extends McplTransport { /** * Dial `config.url` and resolve once the socket is OPEN. Appends `?token=` * when `config.token` is set. Rejects on connect error / timeout (and tears - * the socket down) so a failed dial never leaks an open handle. + * the socket down) so a failed dial never leaks an open handle. `signal` + * ends a dial whose connection is closed meanwhile, at once, rather than + * at the open timeout. */ - static async open(config: McplServerConfig): Promise { + static async open(config: McplServerConfig, signal?: AbortSignal): Promise { // Resolve the per-dial credential first: the provider runs on every // connect AND reconnect, so rotation happens at the transport layer and // nothing above it ever holds a credential. @@ -341,31 +343,36 @@ export class WebSocketTransport extends McplTransport { return new Promise((resolve, reject) => { let settled = false; - const timer = setTimeout(() => { + const abandon = (error: Error): void => { if (settled) return; settled = true; + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); ws.removeAllListeners(); ws.on('error', swallowLateErrors); try { ws.terminate(); } catch { /* already gone */ } - reject(new Error(`MCPL server "${config.id}" WebSocket connect timed out after ${WS_OPEN_TIMEOUT_MS}ms (${redacted})`)); + reject(error); + }; + const timer = setTimeout(() => { + abandon(new Error(`MCPL server "${config.id}" WebSocket connect timed out after ${WS_OPEN_TIMEOUT_MS}ms (${redacted})`)); }, WS_OPEN_TIMEOUT_MS); + const onAbort = (): void => { + abandon(new Error(`MCPL server "${config.id}" WebSocket connect abandoned: the connection was closed (${redacted})`)); + }; + if (signal?.aborted) onAbort(); + else signal?.addEventListener('abort', onAbort, { once: true }); ws.once('open', () => { if (settled) return; settled = true; clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); ws.removeAllListeners('error'); // the instance re-attaches its own handlers resolve(new WebSocketTransport(ws)); }); ws.once('error', (err: Error) => { - if (settled) return; - settled = true; - clearTimeout(timer); - ws.removeAllListeners(); - ws.on('error', swallowLateErrors); - try { ws.terminate(); } catch { /* already gone */ } - reject(new Error(`MCPL server "${config.id}" WebSocket connect failed: ${err.message} (${redacted})`)); + abandon(new Error(`MCPL server "${config.id}" WebSocket connect failed: ${err.message} (${redacted})`)); }); }); } @@ -444,10 +451,10 @@ export function isWebSocketTransport(config: McplServerConfig): boolean { * The returned transport is connected at the byte level but has NOT * performed the MCPL handshake. */ -export async function openTransport(config: McplServerConfig): Promise { +export async function openTransport(config: McplServerConfig, signal?: AbortSignal): Promise { const binding = resolveServerBinding(config); if (binding.family !== 'legacy') { throw new Error(`MCP server "${config.id}" is configured for modern MCP; the legacy transports can't open it`); } - return binding.transport === 'websocket' ? WebSocketTransport.open(config) : StdioTransport.spawn(config); + return binding.transport === 'websocket' ? WebSocketTransport.open(config, signal) : StdioTransport.spawn(config); } diff --git a/test/fixtures/legacy-version-server.mjs b/test/fixtures/legacy-version-server.mjs index e2968ff90..7a12ee592 100644 --- a/test/fixtures/legacy-version-server.mjs +++ b/test/fixtures/legacy-version-server.mjs @@ -4,6 +4,7 @@ // Modes (how `initialize` is answered): // ok protocolVersion 2024-11-05 // reject error -32022 with data { supported: ['2026-07-28'], requested } +// reject-old error -32022 with data { supported: ['2025-03-26'], requested } // reject-nodata error -32022 without data // mismatch protocolVersion 2025-06-18 // missing no protocolVersion at all @@ -17,7 +18,9 @@ // tools/list pages: by default three pages (cursors p2, p3). MODE_LIST picks // an edge case: loop (always nextCursor "same"), empty (nextCursor "" then a // second page), notools (no tools array), badcursor (nextCursor 5), -// nullcursor (nextCursor null). Tool `die` exits the process. +// nullcursor (nextCursor null), endless (a new cursor on every page, so the +// list never ends), badlater (a well-formed first page, then a page with no +// tools array). Tool `die` exits the process. import { appendFileSync, existsSync, readFileSync } from 'node:fs'; let mode = process.argv[2] ?? 'ok'; @@ -49,6 +52,7 @@ process.stdin.on('data', (chunk) => { if (m.method === 'initialize') { const requested = m.params?.protocolVersion; if (mode === 'reject') error(m.id, -32022, `Unsupported protocol version: ${requested}`, { supported: ['2026-07-28'], requested }); + else if (mode === 'reject-old') error(m.id, -32022, `Unsupported protocol version: ${requested}`, { supported: ['2025-03-26'], requested }); else if (mode === 'reject-nodata') error(m.id, -32022, `Unsupported protocol version: ${requested}`); else if (mode === 'mismatch') reply(m.id, { protocolVersion: '2025-06-18', capabilities: { tools: {} } }); else if (mode === 'missing') reply(m.id, { capabilities: { tools: {} } }); @@ -65,6 +69,12 @@ process.stdin.on('data', (chunk) => { else if (listMode === 'notools') reply(m.id, {}); else if (listMode === 'badcursor') reply(m.id, { tools: [tool('x')], nextCursor: 5 }); else if (listMode === 'nullcursor') reply(m.id, { tools: [tool('x')], nextCursor: null }); + else if (listMode === 'endless') { + const n = cursor === undefined ? 1 : Number(String(cursor).slice(1)) + 1; + reply(m.id, { tools: [tool(`t${n}`)], nextCursor: `c${n}` }); + } + else if (listMode === 'badlater' && cursor === undefined) reply(m.id, { tools: [tool('first')], nextCursor: 'p2' }); + else if (listMode === 'badlater') reply(m.id, {}); else if (cursor === undefined) reply(m.id, { tools: [tool('a1'), tool('a2')], nextCursor: 'p2' }); else if (cursor === 'p2') reply(m.id, { tools: [tool('b1')], nextCursor: 'p3' }); else if (cursor === 'p3') reply(m.id, { tools: [tool('c1'), tool('die')] }); diff --git a/test/mcp-modern-framework.test.ts b/test/mcp-modern-framework.test.ts index 0eac21d63..0c827aa97 100644 --- a/test/mcp-modern-framework.test.ts +++ b/test/mcp-modern-framework.test.ts @@ -1,8 +1,7 @@ /** - * A modern-MCP server inside a real framework (room-284, shelf-487): tool - * listing and status, both dispatch paths, the script view of structured - * results, an MCP-only legacy peer read the same standard way, and the host - * lifetime contract (Tessa #53152, Agnes/Kit #53184, Theo #53178): + * A modern-MCP server inside a real framework: tool listing and status, both + * dispatch paths, the script view of structured results, an MCP-only legacy + * peer read the same standard way, and the host lifetime contract: * - a modern call already in flight completes while the host quiesces; * - a list change while quiesced refreshes the inventory, and its wake parks * until resume; @@ -188,8 +187,8 @@ test('scripts get structured results whole, as one JSON object', async () => { }); }); -// The script cap measures the serialized object's length (Iris-1827, -// room-293): `sized` pads structuredContent so that length is exact. +// The script cap measures the serialized object's length: `sized` pads +// structuredContent so that length is exact. const SCRIPT_CAP = 5_000_000; const sizedObject = (pad: number, isError = false) => ({ content: [], @@ -398,18 +397,22 @@ test('quiesce: a modern call already in flight completes, and its result reaches await withModern(async ({ framework, membrane, dir }) => { framework.start(); membrane.pushResponse(createMockResponse([ - { type: 'tool_use', id: 'toolu_01MODERNSLOW0000000000000', name: 'mcpl--modern--slow', input: { ms: 600 } }, + { type: 'tool_use', id: 'toolu_01MODERNSLOW0000000000000', name: 'mcpl--modern--slow', input: { ms: 1500 } }, ] as never, 'tool_use')); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }])); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as never); await waitFor('the slow call to start server-side', () => lines(join(dir, 'events.log')).includes('slow-start')); - const status = await framework.quiesce({ reason: 'maintenance', timeoutMs: 10_000 }); + // Quiesce begins while the call is provably still running server-side, + // so its resolving after 'slow-finished' shows it waited for the call. + const quiescing = framework.quiesce({ reason: 'maintenance', timeoutMs: 10_000 }); + assert.ok(!lines(join(dir, 'events.log')).includes('slow-finished'), 'the call is still in flight as quiesce begins'); + const status = await quiescing; assert.equal(status.quiesced, true); assert.equal(status.drained, true, 'the turn drained: its modern call completed'); assert.ok(lines(join(dir, 'events.log')).includes('slow-finished')); assert.ok(!lines(join(dir, 'events.log')).includes('slow-aborted'), 'quiesce did not cancel it'); - assert.match(JSON.stringify(membrane.lastStream!.receivedToolResults[0]), /slept 600/); + assert.match(JSON.stringify(membrane.lastStream!.receivedToolResults[0]), /slept 1500/); await framework.resume(); }); }); @@ -485,3 +488,42 @@ test('the framework owns a modern connect in flight: stop() ends it, and a secon rmSync(dir, { recursive: true, force: true }); } }); + +test('a lost modern child is relisted from the new launch: what only the old one listed is gone', async () => { + await withModern(async ({ framework, dir }) => { + framework.start(); + // The first launch registers a tool at runtime; a fresh launch won't have it. + await framework.executeToolCall(call('mcpl--modern--add_tool')); + await waitFor('the first launch\'s added tool', () => framework.getAllTools().some((t) => t.name === 'mcpl--modern--added_1')); + await framework.executeToolCall(call('mcpl--modern--die')); + await waitFor('a second launch', () => lines(join(dir, 'starts.log')).length === 2); + await waitFor('the relist from the new launch', () => + framework.listMcplServers()[0]!.connected + && framework.getAllTools().some((t) => t.name === 'mcpl--modern--echo') + && !framework.getAllTools().some((t) => t.name === 'mcpl--modern--added_1')); + }); +}); + +test("a modern tool's error result is traced as tool:failed, not tool:completed", async () => { + const traces: Array<{ type: string; tool?: string; error?: string }> = []; + const watcher: Module = { + name: 'trace-watcher', + async start(ctx: ModuleContext) { ctx.onTrace((event) => traces.push(event as never)); }, + async stop() {}, + getTools: (): ToolDefinition[] => [], + handleToolCall: async (): Promise => ({ success: false, error: 'no tools', isError: true }), + onProcess: async () => ({}), + }; + await withModern(async ({ framework, membrane }) => { + framework.start(); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'toolu_01MODERNFAIL0000000000000', name: 'mcpl--modern--fail', input: {} }, + ] as never, 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Noted.' }])); + framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as never); + await waitFor('the tool trace', () => traces.some((t) => (t.type === 'tool:failed' || t.type === 'tool:completed') && t.tool === 'fail')); + const outcome = traces.find((t) => (t.type === 'tool:failed' || t.type === 'tool:completed') && t.tool === 'fail')!; + assert.equal(outcome.type, 'tool:failed'); + assert.match(outcome.error ?? '', /the tool failed on purpose/); + }, [], [watcher]); +}); diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index 875c0d85e..e87aad601 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -1,7 +1,6 @@ /** - * One owner per MCP server id across both engines (room-293, greptile's - * review of PR #262 and Iris-1827's invariant): from a connect's admission - * until its teardown is done, exactly one owner holds an id. + * One owner per MCP server id across both engines: from a connect's + * admission until its teardown is done, exactly one owner holds an id. * - a connect is refused while a connect or disconnect for the id is in * flight, a legacy handshake included; * - a disconnect waits for what held the id before it, so nothing it removes @@ -287,3 +286,132 @@ test('legacy: a failed connect whose cleanup cannot reap stays registered until assert.equal(status(framework, 'l')?.connected, true); }); }); + +test('stop() closes every connection, keeps the ones it cannot reap registered, and then rejects naming them', async () => { + const dir = mkdtempSync(join(tmpdir(), 'mcp-ownership-')); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], + modules: [], + }); + const reaping = unreapable(); + await framework.connectMcplServer(modern('m', '', join(dir, 'raw.jsonl'))); + await framework.connectMcplServer(legacy('l', 'ok', join(dir, 'legacy.log'))); + await assert.rejects(framework.stop(), /stop\(\) finished, but not cleanly: .*could not be reaped/); + const internals = framework as unknown as { + modernServers(): Map; + mcplServerRegistry: { getServer(id: string): unknown }; + }; + assert.ok(internals.modernServers().has('m'), 'the modern one it could not reap keeps its id'); + assert.ok(internals.mcplServerRegistry.getServer('l'), 'and so does the legacy one'); + for (const stuck of reaping.stuck) await killAndWait(stuck); +}); + +test("a failed startup's cleanup closes both engines' connections, each whatever the other's close did", async () => { + const dir = mkdtempSync(join(tmpdir(), 'mcp-ownership-')); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], + modules: [], + }); + const reaping = unreapable(); + try { + await framework.connectMcplServer(legacy('l', 'ok', join(dir, 'legacy.log'))); + reaping.active = false; + await framework.connectMcplServer(modern('m', '', join(dir, 'raw.jsonl'))); + const errors: unknown[] = []; + const realError = console.error; + console.error = (...args: unknown[]) => { errors.push(args); }; + try { + await (framework as unknown as { closeMcpConnectionsAfterFailedStartup(): Promise }).closeMcpConnectionsAfterFailedStartup(); + } finally { + console.error = realError; + } + const internals = framework as unknown as { + modernServers(): Map; + mcplServerRegistry: { getServer(id: string): unknown }; + }; + assert.equal(internals.modernServers().has('m'), false, 'the modern connection was closed and let go, though the legacy close failed'); + assert.equal(status(framework, 'm')?.connected, false); + assert.ok(internals.mcplServerRegistry.getServer('l'), 'the one it could not reap stays registered'); + assert.equal(status(framework, 'l')?.connected, false, 'closed'); + assert.match(JSON.stringify(errors), /could not be reaped/, 'and the failure is logged, not thrown'); + } finally { + // Whatever the assertions found, end the stuck child so stop() can reap it. + for (const stuck of reaping.stuck) await killAndWait(stuck); + await framework.stop(); + } +}); + +test('a restart whose new configuration is refused leaves the running server as it was', async () => { + await withFramework(async ({ framework, dir }) => { + const log = join(dir, 'raw.jsonl'); + await framework.connectMcplServer(modern('m', '', log)); + await assert.rejects( + framework.restartMcplServer('m', { id: 'm', url: 'ftp://127.0.0.1/x' }), + /MCP server "m" configuration: .*url must be/, + ); + assert.equal(status(framework, 'm')?.connected, true, 'still connected'); + assert.equal(launches(log).length, 1, 'never torn down or relaunched'); + assert.ok(toolsOf(framework, 'm').length > 0, 'its tools still listed'); + }); +}); + +test("a refresh in flight when a server is disconnected doesn't bring its tools back", async () => { + await withFramework(async ({ framework, dir }) => { + await framework.connectMcplServer(modern('m', '', join(dir, 'raw.jsonl'))); + assert.ok(toolsOf(framework, 'm').length > 0); + const internals = framework as unknown as { + modernServers(): Map }>; + refreshMcplTools(): Promise; + }; + const connection = internals.modernServers().get('m')!; + const realList = connection.listTools.bind(connection); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let listed = false; + connection.listTools = async () => { const tools = await realList(); listed = true; await gate; return tools; }; + const refreshing = internals.refreshMcplTools(); + await until(() => listed, 'the refresh holds the server\'s list'); + await framework.disconnectMcplServer('m'); + release(); + await refreshing; + assert.deepEqual(toolsOf(framework, 'm'), [], 'the earlier refresh, finishing last, published none of its tools'); + }); +}); + +test('a server lost for good, legacy or modern, leaves the tool list', async () => { + await withFramework(async ({ framework, dir }) => { + await framework.connectMcplServer(legacy('l', 'ok', join(dir, 'legacy.log'))); + await framework.connectMcplServer(modern('m', '', join(dir, 'raw.jsonl'))); + assert.ok(toolsOf(framework, 'l').length > 0 && toolsOf(framework, 'm').length > 0); + await framework.executeToolCall(call('mcpl--l--die')).catch(() => {}); + await framework.executeToolCall(call('mcpl--m--die')).catch(() => {}); + await until(() => toolsOf(framework, 'l').length === 0, 'the legacy server\'s tools gone'); + await until(() => toolsOf(framework, 'm').length === 0, 'the modern server\'s tools gone'); + }); +}); + +test("a stdio server switched to the modern family is a new coalescing binding; a legacy configuration's binding is unchanged", async () => { + await withFramework(async ({ framework }) => { + const internals = framework as unknown as { + mcplServerConfigs: Map; + coalescingBinding(id: string): string; + }; + internals.mcplServerConfigs.set('s', { id: 's', command: 'srv', args: ['a'] }); + const legacyBinding = internals.coalescingBinding('s'); + const { createHash } = await import('node:crypto'); + assert.equal( + legacyBinding, + createHash('sha256').update(JSON.stringify(['s', null, 'srv', ['a']])).digest('hex').slice(0, 16), + 'the hash a stored legacy binding was made with', + ); + internals.mcplServerConfigs.set('s', { id: 's', command: 'srv', args: ['a'], protocol: 'modern' }); + assert.notEqual(internals.coalescingBinding('s'), legacyBinding); + internals.mcplServerConfigs.delete('s'); + }); +}); diff --git a/test/mcpl-legacy-lifetime.test.ts b/test/mcpl-legacy-lifetime.test.ts index 31abe8a38..aea5ce5c5 100644 --- a/test/mcpl-legacy-lifetime.test.ts +++ b/test/mcpl-legacy-lifetime.test.ts @@ -1,6 +1,6 @@ /** - * The legacy engine's lifetime contract (room-293, greptile's review of - * PR #262): one owner for every launch, the rule the modern engine keeps. + * The legacy engine's lifetime contract: one owner for every launch, the + * rule the modern engine keeps. * - a configuration it can't use is refused before anything is dialed, and * never turned into a retry stub; * - concurrent close() calls share one teardown and its verdict; @@ -20,7 +20,11 @@ import { fileURLToPath } from 'node:url'; import { WebSocketServer } from 'ws'; import { McplServerConnection, McplUnreapedLaunchError } from '../src/mcpl/server-connection.js'; -import { StdioTransport } from '../src/mcpl/transport.js'; +import { McplServerRegistry } from '../src/mcpl/server-registry.js'; +import { StdioTransport, openTransport } from '../src/mcpl/transport.js'; +import { createServer, type Socket } from 'node:net'; +import { createServer as createHttpServer } from 'node:http'; +import { EventEmitter } from 'node:events'; import type { McplHostCapabilities, McplServerConfig } from '../src/mcpl/types.js'; const here = dirname(fileURLToPath(import.meta.url)); @@ -102,6 +106,8 @@ test('a configuration the engine cannot use is refused before anything is dialed McplServerConnection.connectWithReconnect({ id: 'ftp', url: 'ftp://127.0.0.1/x', ...RECONNECT }, HOST_CAPS), /url must be ws:\/\/ or wss:\/\//, ); + // A refusal is synchronous with the connect call; 200 ms covers several + // reconnect intervals (20–40 ms) in which a stub would have dialed. await new Promise((r) => setTimeout(r, 200)); assert.equal(dials, 0, 'nothing was dialed'); }); @@ -127,6 +133,8 @@ test('a close that cannot reap fails, and so do concurrent ones; a later close r const outcome = (name: string) => [() => settled.push(`${name}:ok`), (e: Error) => settled.push(`${name}:${e.message}`)] as const; const first = connection.close().then(...outcome('first')); const second = connection.close().then(...outcome('second')); + // The teardown sends SIGTERM, waits 2 s, sends SIGKILL and waits 2 s more + // before it gives up, so at 1 s it is still pending. await new Promise((r) => setTimeout(r, 1000)); assert.deepEqual(settled, [], 'neither resolves while the teardown is pending'); await Promise.all([first, second]); @@ -164,6 +172,7 @@ test('with reconnect, a failed first launch that cannot be reaped halts instead assert.equal(failures[0]!.permanent, true); assert.match(failures[0]!.error, /initialize failed on purpose; the previous launch could not be reaped .*reconnecting is halted/); assert.equal(connection.willReconnect, false); + // 300 ms is several reconnect intervals (20–40 ms): a retry would have launched. await new Promise((r) => setTimeout(r, 300)); assert.equal(starts(log).length, 1, 'no launch beside the unreaped one'); await killAndWait(stuck[0]!); @@ -182,6 +191,7 @@ test('a reconnect launch that cannot be reaped halts reconnecting; close() settl await until(() => failures.some((f) => f.permanent), 'a permanent reconnect failure'); assert.match(failures.find((f) => f.permanent)!.error, /initialize failed on purpose; the previous launch could not be reaped .*reconnecting is halted/); assert.equal(connection.willReconnect, false); + // 300 ms is several reconnect intervals (20–40 ms): a retry would have launched. await new Promise((r) => setTimeout(r, 300)); assert.equal(starts(log).length, 2, 'no third launch while the second is unreaped'); await assert.rejects(connection.close(), /could not be reaped/); @@ -196,7 +206,7 @@ test('close() during a reconnect handshake ends that launch before resolving, an let reconnected = false; connection.on('reconnect', () => { reconnected = true; }); await assert.rejects(connection.sendToolsCall('die', {})); - await until(() => starts(log).length === 2, 'the reconnect launch, waiting on initialize'); + await until(() => starts(log).length === 2, 'the reconnect launch has started (its start line)'); const second = starts(log)[1]!; const began = Date.now(); await connection.close(); @@ -205,6 +215,7 @@ test('close() during a reconnect handshake ends that launch before resolving, an assert.equal(alive(second), false, 'the launch in flight was reaped before close() resolved'); assert.equal(reconnected, false); assert.equal(connection.isConnected, false); + // Several reconnect intervals (20–40 ms) later, still nothing. await new Promise((r) => setTimeout(r, 200)); assert.equal(starts(log).length, 2, 'and no later launch'); }); @@ -216,8 +227,9 @@ test('a recycle during a reconnect in flight starts no second attempt, and close const connection = await McplServerConnection.connectWithReconnect(config('ok-then-hang', log, RECONNECT), HOST_CAPS); connection.ready(); await assert.rejects(connection.sendToolsCall('die', {})); - await until(() => starts(log).length === 2, 'the reconnect launch, waiting on initialize'); + await until(() => starts(log).length === 2, 'the reconnect launch has started (its start line)'); await connection.reconnectAfterFailure(); + // Several reconnect intervals (20–40 ms) later, still only the one in flight. await new Promise((r) => setTimeout(r, 400)); assert.equal(starts(log).length, 2, 'no second attempt beside the one in flight'); const began = Date.now(); @@ -245,3 +257,146 @@ test('a reconnect never launches while the previous child may still be running', await recycled; await until(() => reconnected, 'reconnected'); }); + +test('registry: an id stays held through its reap, and a second add is refused while one is connecting', async () => { + const log = scratchLog(); + const registry = new McplServerRegistry(); + // A child that ignores SIGTERM keeps its reap going for about 2 s. + const removing = (async () => { + await registry.addServer(config('ok', log, { env: { IGNORE_SIGTERM: '1' } }), HOST_CAPS); + return registry.removeServer('legacy'); + })(); + await until(() => registry.getServer('legacy') !== null, 'registered'); + await until(() => starts(log).length === 1, 'launched'); + await new Promise((r) => setTimeout(r, 100)); + await assert.rejects(registry.addServer(config('ok', log), HOST_CAPS), /already registered/, 'held while its reap runs'); + await removing; + assert.equal(registry.getServer('legacy'), null, 'freed once the exit is confirmed'); + assert.equal(starts(log).length, 1, 'nothing launched beside it'); + + // Two adds at once: the second is refused while the first is connecting. + const slow = registry.addServer(config('slow', log, { env: { SLOW_INIT_MS: '300' } }), HOST_CAPS); + await assert.rejects(registry.addServer(config('ok', log), HOST_CAPS), /already registered/); + await slow; + await registry.removeServer('legacy'); +}); + +test('registry: a connection whose reap fails stays registered, through close() and closeAll(), until a retry confirms the exit', async () => { + const log = scratchLog(); + const registry = new McplServerRegistry(); + const stuck = unreapableFrom(1); + const connection = await registry.addServer(config('ok', log), HOST_CAPS); + // Its own close() can't reap: the registry keeps it, holding the id. + await assert.rejects(connection.close(), /could not be reaped/); + assert.equal(registry.getServer('legacy'), connection, 'still registered after its own close'); + // closeAll() reports it, and keeps it. + await assert.rejects(registry.closeAll(), /could not be reaped/); + assert.equal(registry.getServer('legacy'), connection, 'still registered after closeAll'); + await assert.rejects(registry.addServer(config('ok', log), HOST_CAPS), /already registered/); + // removeServer retries the reap; once the child is gone it succeeds. + await assert.rejects(registry.removeServer('legacy'), /could not be reaped/); + assert.equal(registry.getServer('legacy'), connection); + await killAndWait(stuck[0]!); + await registry.removeServer('legacy'); + assert.equal(registry.getServer('legacy'), null); +}); + +test('registry: closeAll closes every connection, removes the ones it reaped, and names each it could not', async () => { + const log = scratchLog(); + const registry = new McplServerRegistry(); + const stuck = unreapableFrom(2); + const fine = await registry.addServer({ ...config('ok', log), id: 'fine' }, HOST_CAPS); + const held = await registry.addServer({ ...config('ok', log), id: 'held' }, HOST_CAPS); + await assert.rejects(registry.closeAll(), /"held" did not close cleanly/); + assert.equal(registry.getServer('fine'), null, 'the one it reaped is removed'); + assert.equal(fine.isConnected, false); + assert.equal(registry.getServer('held'), held, 'the one it could not reap stays'); + await killAndWait(stuck[0]!); + await registry.removeServer('held'); +}); + +test('a reconnect whose previous child cannot be reaped holds it and halts reconnecting', async () => { + const log = scratchLog(); + const stuck = unreapableFrom(1); + const connection = await McplServerConnection.connectWithReconnect(config('ok', log, RECONNECT), HOST_CAPS); + cleanups.push(() => connection.close().catch(() => {})); + connection.ready(); + const failures: Array<{ error: string; attempt: number; permanent?: boolean }> = []; + connection.on('reconnect-failed', (f) => failures.push(f)); + // Recycle it as the framework does: the host closes the transport, whose + // child can't be reaped, and the loop then finds that child still held. + await connection.reconnectAfterFailure().catch(() => {}); + await until(() => failures.some((f) => f.permanent), 'a permanent reconnect failure'); + assert.match(failures.find((f) => f.permanent)!.error, /could not be reaped/); + assert.equal(connection.willReconnect, false); + // Several reconnect intervals (20–40 ms) later, nothing launched beside it. + await new Promise((r) => setTimeout(r, 300)); + assert.equal(starts(log).length, 1); + await killAndWait(stuck[0]!); + await connection.close(); +}); + +test('a WebSocket dial whose connection is closed meanwhile ends at once, not at the open timeout', async () => { + // A listener that accepts and never answers the upgrade. + const sockets: Socket[] = []; + const server = createServer((socket) => { sockets.push(socket); }); + await new Promise((r) => server.listen(0, '127.0.0.1', () => r())); + cleanups.push(() => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(() => r()); })); + const url = `ws://127.0.0.1:${(server.address() as AddressInfo).port}/mcpl`; + const abort = new AbortController(); + const began = Date.now(); + const dial = openTransport({ id: 'ws', url }, abort.signal); + await until(() => sockets.length === 1, 'the dial reached the listener'); + abort.abort(); + await assert.rejects(dial, /WebSocket connect abandoned: the connection was closed/); + assert.ok(Date.now() - began < 5_000, 'well before the 15 s open timeout'); +}); + +test("a write to a stdio child that has just exited is reported, never thrown", (t) => { + const child = Object.assign(new EventEmitter(), { stdin: new EventEmitter(), stderr: new EventEmitter(), killed: false, exitCode: null }); + const rl = new EventEmitter(); + const transport = new (StdioTransport as unknown as new (c: unknown, r: unknown) => StdioTransport)(child, rl); + const logged: unknown[] = []; + t.mock.method(console, 'error', (...args: unknown[]) => { logged.push(args); }); + const epipe = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }); + assert.doesNotThrow(() => child.stdin.emit('error', epipe), 'with nothing listening'); + const reported: unknown[] = []; + transport.on('error', (error) => reported.push(error)); + child.stdin.emit('error', epipe); + assert.deepEqual(reported, [epipe], "it's the transport's error once someone listens"); +}); + +test('close() during a WebSocket reconnect dial returns promptly, ending the dial', async () => { + // The first upgrade gets a minimal MCPL server; every later one hangs, so + // the reconnect's dial would wait out its 15 s open timeout. + const http = createHttpServer(); + const wss = new WebSocketServer({ noServer: true }); + const hung: Socket[] = []; + let upgrades = 0; + http.on('upgrade', (request, socket, head) => { + if (++upgrades === 1) wss.handleUpgrade(request, socket as Socket, head, (ws) => wss.emit('connection', ws, request)); + else hung.push(socket as Socket); + }); + let first: import('ws').WebSocket | undefined; + wss.on('connection', (ws) => { + first = ws; + ws.on('message', (data) => { + const message = JSON.parse(String(data)) as { id?: number; method?: string }; + if (message.id === undefined) return; + const result = message.method === 'initialize' ? { protocolVersion: '2024-11-05', capabilities: {} } : {}; + ws.send(JSON.stringify({ jsonrpc: '2.0', id: message.id, result })); + }); + }); + await new Promise((r) => http.listen(0, '127.0.0.1', () => r())); + cleanups.push(() => new Promise((r) => { for (const s of hung) s.destroy(); wss.close(); http.close(() => r()); })); + const url = `ws://127.0.0.1:${(http.address() as AddressInfo).port}/mcpl`; + + const connection = await McplServerConnection.connectWithReconnect({ id: 'ws', url, ...RECONNECT }, HOST_CAPS); + connection.ready(); + first!.close(); + await until(() => upgrades === 2, 'the reconnect is dialing'); + const began = Date.now(); + await connection.close(); + assert.ok(Date.now() - began < 5_000, 'promptly: the dial was ended, not waited out (15 s)'); + assert.equal(connection.isConnected, false); +}); diff --git a/test/mcpl-legacy-version.test.ts b/test/mcpl-legacy-version.test.ts index 46e5ae415..c5f51cfcf 100644 --- a/test/mcpl-legacy-version.test.ts +++ b/test/mcpl-legacy-version.test.ts @@ -1,5 +1,5 @@ /** - * The legacy engine's protocol boundary and inventory (room-284, shelf-487): + * The legacy engine's protocol boundary and inventory: * - it offers and accepts exactly MCP 2024-11-05; anything else is a typed * McplProtocolVersionError carrying what each side said; * - such a verdict is never retried (no reconnect stub, no backoff loop), and @@ -175,13 +175,18 @@ test('a malformed page is an error, never a partial or empty inventory', async ( for (const [mode, pattern] of [ ['notools', /malformed tools\/list page 1: no tools array/], ['badcursor', /malformed tools\/list page 1: nextCursor must be a string, got 5/], - ['nullcursor', /malformed tools\/list page 1: nextCursor must be a string, got null/], ] as const) { const connection = await connect(config('ok', { env: { MODE_LIST: mode } })); await assert.rejects(connection.sendToolsList(), pattern, mode); } }); +test('a null nextCursor ends the inventory, as an absent one does', async () => { + const connection = await connect(config('ok', { env: { MODE_LIST: 'nullcursor' } })); + const { tools } = await connection.sendToolsList(); + assert.deepEqual(tools.map((t) => t.name), ['x']); +}); + test('a repeating cursor is an error, not a silently partial inventory', async () => { const connection = await connect(config('ok', { env: { MODE_LIST: 'loop' } })); await assert.rejects(connection.sendToolsList(), /repeated tools\/list cursor "same"/); @@ -208,3 +213,115 @@ test('a modern configuration is refused by the legacy engine before anything is await connect(config('ok', {}, startLog)); assert.equal(readFileSync(startLog, 'utf8').split('\n').filter(Boolean).length, 1); }); + +test('a -32022 whose supported list lacks the modern revision names no modern remedy', async () => { + await assert.rejects(McplServerConnection.connect(config('reject-old'), HOST_CAPS), (err: unknown) => { + assert.ok(err instanceof McplProtocolVersionError); + assert.deepEqual(err.supported, ['2025-03-26']); + assert.match(err.message, /it supports: 2025-03-26/); + assert.doesNotMatch(err.message, /protocol: 'modern'/); + return true; + }); +}); + +function startsIn(startLog: string): string[] { + return existsSync(startLog) ? readFileSync(startLog, 'utf8').split('\n').filter(Boolean) : []; +} + +function alive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch { return false; } +} + +test('a version verdict is never retried: one launch, and that child is reaped', async () => { + const dir = mkdtempSync(join(tmpdir(), 'legacy-version-')); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + for (const mode of ['reject', 'mismatch']) { + const startLog = join(dir, `${mode}.log`); + await assert.rejects( + McplServerConnection.connectWithReconnect(config(mode, { reconnect: true, reconnectIntervalMs: 20, reconnectMaxIntervalMs: 40 }, startLog), HOST_CAPS), + (err: unknown) => err instanceof McplProtocolVersionError, + ); + const starts = startsIn(startLog); + assert.equal(starts.length, 1, `${mode}: launched once`); + // The rejection waits for the handshake's own cleanup, so its child is gone. + const pid = Number(starts[0]!.split(' ')[1]); + assert.equal(alive(pid), false, `${mode}: its child was reaped`); + // Past several reconnect intervals, nothing has launched again. + await new Promise((r) => setTimeout(r, 200)); + assert.equal(startsIn(startLog).length, 1, `${mode}: never retried`); + } +}); + +test('an initialize error that is not a version verdict stays a plain, retryable error', async () => { + await assert.rejects(McplServerConnection.connect(config('error'), HOST_CAPS), (err: unknown) => { + assert.ok(err instanceof Error); + assert.ok(!(err instanceof McplProtocolVersionError)); + assert.match(err.message, /initialize failed on purpose/); + return true; + }); + // With reconnect, it leaves a stub that keeps trying, as any transient failure does. + const stub = await McplServerConnection.connectWithReconnect(config('error', { reconnect: true, reconnectIntervalMs: 20 }), HOST_CAPS); + cleanups.push(() => stub.close()); + assert.equal(stub.willReconnect, true); +}); + +test('a modern stdio configuration is refused before anything is spawned', async () => { + const dir = mkdtempSync(join(tmpdir(), 'legacy-version-')); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + const startLog = join(dir, 'starts.log'); + await assert.rejects( + McplServerConnection.connect(config('ok', { protocol: 'modern' }, startLog), HOST_CAPS), + /configured for modern MCP/, + ); + assert.deepEqual(startsIn(startLog), [], 'nothing was spawned'); +}); + +test('a list that never ends is refused after its page bound', async () => { + const connection = await connect(config('ok', { env: { MODE_LIST: 'endless' } })); + await assert.rejects(connection.sendToolsList(), /did not finish within 100 pages/); +}); + +test('a page after the first is checked as the first is', async () => { + const connection = await connect(config('ok', { env: { MODE_LIST: 'badlater' } })); + await assert.rejects(connection.sendToolsList(), /malformed tools\/list page 2: no tools array/); +}); + +async function startupTraces(dir: string, server: McplServerConfig): Promise { + const traces: TraceEvent[] = []; + const watcher: Module = { + name: 'trace-watcher', + async start(ctx: ModuleContext) { ctx.onTrace((event) => traces.push(event)); }, + async stop() {}, + getTools: (): ToolDefinition[] => [], + handleToolCall: async (): Promise => ({ success: false, error: 'no tools', isError: true }), + onProcess: async () => ({}), + }; + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: { complete: async () => { throw new Error('not used'); } } as unknown as import('@animalabs/membrane').Membrane, + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], + modules: [watcher], + mcplServers: [server], + }); + cleanups.push(() => framework.stop().catch(() => {})); + return traces; +} + +test('at startup, willRetry follows reconnect and the verdict, and only a verdict raises the alert', async () => { + const cases: Array<[string, McplServerConfig, boolean, boolean]> = [ + // [label, config, willRetry, alert] + ['verdict without reconnect', config('missing'), false, true], + ['failure with reconnect', config('error', { reconnect: true }), true, false], + ['failure without reconnect', config('error'), false, false], + // Refused before anything launches: nothing will retry it, reconnect or not. + ['configuration refused, with reconnect', { id: 'legacy', url: 'ftp://127.0.0.1/x', reconnect: true }, false, false], + ]; + for (const [label, server, willRetry, alert] of cases) { + const dir = mkdtempSync(join(tmpdir(), 'legacy-version-')); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + const traces = await startupTraces(dir, server); + const failed = traces.find((t) => t.type === 'mcpl:server-connect-failed') as { willRetry?: boolean } | undefined; + assert.equal(failed?.willRetry, willRetry, label); + assert.equal(traces.some((t) => t.type === 'ops:alert'), alert, `${label}: alert`); + } +}); From 5abaf9f84d584185426e148556aecddd42eab0cb Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 05:06:29 -0400 Subject: [PATCH 22/94] fix(code-execution): a script's structured result gives an MCPL peer's blocks the treatment the model's lanes give them, and a reference its id and saved path A script whose tool call returns structuredContent gets the server's content array in its JSON object, so images and other payloads survive. From an MCPL peer, an RFC-005 reference went there as the server sent it, with its URI, which can be a bearer credential, and without the workspace path that the script-demand fetch had just saved its payload to. So did a block the classifier rejects, and inline data that claims a bulk disposition, both of which the model's lanes withhold. What a script prints comes back to the model. The dispatch knows whether the server is an MCPL peer, so it now gives a script's call its reading: - For an MCPL peer, autoFetchReferences fetches every reference a script will read, after the eager pass, and the script-side fetch loop is gone. A script reads the blocks unless the result is an error without structured content. - An MCPL peer's structured content goes through referenceBlockForScript (references.ts), which makes referenceStubOrNull's decision for each block. Text and plain inline data stay as sent. Inline data that claims a bulk disposition becomes the withheld notice. A reference becomes { type, refId, name?, mimeType?, sizeBytes?, digest?, expiresAt?, savedTo }: the registry's id, that block's own testimony with its text fields stripped of control and bidi characters as in the model's stub, and the path the fetch saved its payload to (null when it didn't; fetch_reference takes the id as its ref_id argument). A block the classifier rejects becomes the unrecognized-content notice the model's lanes show. - A server that isn't an MCPL peer sends no references, so its structured content reaches the script as sent. The changelog fragment says what a script reads from each kind of server, and credits #181, which first found scripts losing structuredContent and kept the saved paths of references by falling back to the text rendering. It and the normalizer say a resource_link is shown as a link, with its name and URI. Tests, through the real dispatch with an MCPL peer and an MCP-only peer (script-structured-references): - from the MCPL peer, a structured result holds one reference in two blocks with different testimony, a reference the fetch doesn't save, inline data with a disposition, inline data with a uri beside it, plain inline data, three rejected blocks, and labels with control and bidi characters. The script gets no URI and each block as the model's lanes treat it, and both references are fetched before it reads them; - the same blocks from the MCP-only peer reach the script as sent, and nothing is fetched; - without structuredContent, the stubs name the same reference id and saved path; - past the script cap, the saved file holds the treated blocks and no URI; - the model's call, and an error a script reads as text, get no script-demand fetch, and the eager pass still runs for both; - an error with structured content is fetched for the script, and its blocks reach it treated, with its error text and structured content. In mcp-modern-framework, a modern server's resource link, embedded resources and audio reach a script as sent. --- .../mcp-standard-tool-results.changed.md | 31 ++- src/framework.ts | 98 ++++--- src/mcpl/references.ts | 44 +++- src/mcpl/tool-result-normalize.ts | 5 +- test/fixtures/modern-tools.mjs | 21 +- test/mcp-modern-framework.test.ts | 16 ++ test/script-structured-references.test.ts | 248 ++++++++++++++++++ 7 files changed, 401 insertions(+), 62 deletions(-) create mode 100644 test/script-structured-references.test.ts diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index c6a74c414..4d809347f 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -12,7 +12,8 @@ and the host's log says why the save failed; - a failed result is read as its text alone, so there an inline image is saved and stubbed like any other payload; - - a `resource_link` is shown as a reference and never fetched; + - a `resource_link` is shown as a link, with its name and URI, and never + fetched; - an embedded text resource shows its text; - an unknown block is shown as JSON, bounded, saying how much was left out. A malformed entry is named and left out, and content that isn't a @@ -26,12 +27,28 @@ reading and gain `structured`. - A script whose tool call returns `structuredContent` receives the server's result itself, as one JSON object: `{ content, structuredContent, - isError, error? }`, with `content` the blocks as sent, so images and other - payloads survive. If it would exceed the 5 MB script cap, it is saved to the - workspace and the script gets `{ isError, oversized: { chars, savedTo } }`. - If it can't be saved, the script gets a failure that says the tool may have - completed. Results without structured content keep the existing script - contract. + isError, error? }`. From a server that isn't an MCPL peer, `content` is the + blocks as sent, so images and other payloads survive. `structuredContent` + is the tool's own JSON, as sent. +- An MCPL peer's blocks in a script's `content` get the treatment every + lane the model reads gives them, since what a script prints comes back to + the model. An RFC-005 reference arrives without its URI, as the host's + reference id (`refId`), what the server said about it in that block + (`name`, `mimeType`, `sizeBytes`, `digest`, `expiresAt`, with control and + bidi characters stripped from the text fields, as in the model's stub), + and `savedTo`, the workspace path the framework fetched its payload to for + the script (`null` when it couldn't; `fetch_reference` takes the id as its + `ref_id` argument). A block the classifier rejects arrives as the model's + `[unrecognized content block: …]` notice, and inline data that also claims + a bulk disposition as the withheld notice. #181, by @Meganeuridae, first + found scripts losing `structuredContent`, and it kept the saved paths of + RFC-005 payloads for them by falling back to the text rendering. Here the + paths are in the structured object, as `savedTo`. +- If a script's structured result would exceed the 5 MB script cap, the + framework saves it to the workspace and the script gets + `{ isError, oversized: { chars, savedTo } }`. If it can't be saved, the + script gets a failure that says the tool may have completed. Results + without structured content keep the existing script contract. - The direct tool path (`executeToolCall`, `ModuleContext.callTool`) now treats an MCP tool error (`isError: true`) as a failure, with `success: false` and the error text; before, every answer counted as diff --git a/src/framework.ts b/src/framework.ts index 0d62e8f87..5c3eaf2e2 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1,6 +1,6 @@ import { ToolPresentation, presentationTools, isPresentationTool, renderCatalogue, type PresentationSnapshot } from "./tool-presentation.js"; import { dirname, join } from 'node:path'; -import { INLINE_WITHHELD_TEXT, classifyBlock, isInlineContradiction, referenceRegistry, referenceStubOrNull } from './mcpl/references.js'; +import { INLINE_WITHHELD_TEXT, classifyBlock, isInlineContradiction, referenceBlockForScript, referenceRegistry, referenceStubOrNull } from './mcpl/references.js'; import { ReferenceFetcher, DEFAULT_FETCH_MAX_BYTES, EAGER_FETCH_TIMEOUT_MS } from './mcpl/reference-fetcher.js'; import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { JsStore } from '@animalabs/chronicle'; @@ -2573,28 +2573,39 @@ export class AgentFramework { return this.referenceFetcherInstance; } - /** Register every RFC-005 reference in a tool-result content array against - * its server, then eagerly fetch the ones inside the autofetch envelope - * (below the eager ceiling, connection origin — never worse than the - * server having inlined the bytes). Failures are non-fatal: the stub then - * says fetch_reference instead of a path. */ - private async autoFetchReferences(content: unknown[] | undefined, serverId: string, eager: boolean): Promise { - if (!content) return; + /** Register every RFC-005 reference in an MCPL peer's tool-result content + * against its server, then eagerly fetch the ones inside the autofetch + * envelope (below the eager ceiling, connection origin — never worse than + * the server having inlined the bytes). Failures are non-fatal: the stub + * then says fetch_reference instead of a path. + * + * For a script that will read the blocks, every reference is then fetched + * ("eagerly lazy"): a script receiving a result is the strongest + * available signal that the bytes are wanted (scripts process, they do + * not browse), so the stub it receives carries a workspace path it can + * open directly. */ + private async autoFetchReferences(content: unknown, serverId: string, forScript: boolean): Promise { + if (!Array.isArray(content)) return; const fetcher = this.getReferenceFetcher(); - const eligible: import('./mcpl/references.js').ReferenceRecord[] = []; + const records: import('./mcpl/references.js').ReferenceRecord[] = []; for (const block of content) { const c = classifyBlock(block); - if (c.kind !== 'reference') continue; - const record = referenceRegistry.register(c.testimony, serverId); - if (eager && fetcher.isEagerEligible(record)) eligible.push(record); + if (c.kind === 'reference') records.push(referenceRegistry.register(c.testimony, serverId)); } // Parallel with a count cap: N small fetches cost one round-trip of // wall-clock, and anything past the cap stays lazy (stub says so). const MAX_EAGER_PER_RESULT = 8; + const eligible = records.filter((record) => fetcher.isEagerEligible(record)); await Promise.all(eligible.slice(0, MAX_EAGER_PER_RESULT).map(async (record) => { const outcome = await fetcher.fetch(record, { timeoutMs: EAGER_FETCH_TIMEOUT_MS }); if (!outcome.ok) console.error(`[rfc005] eager fetch of ${record.refId} failed: ${outcome.error}`); })); + if (!forScript) return; + // A record the eager pass saved returns at once. + for (const record of records) { + const outcome = await fetcher.fetch(record); + if (!outcome.ok) console.error(`[rfc005] script-demand fetch of ${record.refId} failed: ${outcome.error}`); + } } /** Core agent_settings keys — extension keys must not collide with these. */ @@ -11885,21 +11896,9 @@ export class AgentFramework { return `Error: ${result.error ?? 'tool call failed'}`; } if (result.data === undefined && !structured) return ''; - // RFC-005 "eagerly lazy": a script receiving a result is the strongest - // available signal that the bytes are wanted (scripts process, they do - // not browse) — materialize every referenced payload now, so the stub - // the script receives carries a workspace path it can open directly. - if (Array.isArray(result.data)) { - const fetcher = this.getReferenceFetcher(); - for (const block of result.data) { - const c = classifyBlock(block); - if (c.kind !== 'reference') continue; - const record = referenceRegistry.findByUri(c.testimony.uri) - ?? referenceRegistry.register(c.testimony); - const outcome = await fetcher.fetch(record); - if (!outcome.ok) console.error(`[rfc005] script-demand fetch of ${record.refId} failed: ${outcome.error}`); - } - } + // An MCPL peer's references were fetched for the script at dispatch + // (autoFetchReferences), so each one's stub carries the workspace path + // its payload was saved to. // Scripts get (nearly) full data — the script environment IS the spill: // filtering big results in code is the whole point. 5MB protocol safety // cap only; no context-cap truncation, no file spill. @@ -11914,11 +11913,14 @@ export class AgentFramework { * A structured MCP result as a script receives it: one JSON object holding * both of the server's views, `{ content, structuredContent, isError }`, * plus `error` (the text) on a tool error. `content` is the content array - * as the server sent it (the dispatch gave this call the direct reading), - * so payloads and images survive. Past the size cap the result stays - * valid JSON: the whole object is saved to the workspace, and the script - * gets `{ isError, oversized: { chars, savedTo } }`. If it can't be saved, - * the script gets a failure to materialize the result, saying the tool may + * the dispatch gave this call (the direct reading): the server's blocks as + * sent, so payloads and images survive, except that an MCPL peer's blocks + * get the treatment RFC-005 gives them on every lane the model reads + * (referenceBlockForScript). `structuredContent` is the tool's own JSON, as + * sent. Past the size cap the result stays valid JSON: the whole object is + * saved to the workspace, and the script gets + * `{ isError, oversized: { chars, savedTo } }`. If it can't be saved, the + * script gets a failure to materialize the result, saying the tool may * already have completed, never a success missing its result. */ private async scriptStructuredResult(result: ToolResult): Promise { @@ -15352,12 +15354,18 @@ export class AgentFramework { // A script's call that returns structured content gets the server's // result itself (the direct reading): the script receives both views // whole, so nothing the model reading lowers (stubs, image placeholders, - // generated JSON) stands in for what the server sent. + // generated JSON) stands in for what the server sent. An MCPL peer's + // content blocks are the exception: what a script prints comes back to + // the model, so each gets the treatment RFC-005 gives it on every lane + // the model reads. A standard peer's blocks reach the script as sent. const forScript = this.scriptToolWaiters?.has(call.id) ?? false; - const reading = (result: { content?: unknown; isError?: boolean; structuredContent?: unknown }, mcpl: boolean) => - forScript && Object.prototype.hasOwnProperty.call(result, 'structuredContent') - ? Promise.resolve(AgentFramework.directToolResult(result)) - : this.mcpToolResult(result, call.id, mcpl); + const reading = async (result: { content?: unknown; isError?: boolean; structuredContent?: unknown }, mcpl: boolean): Promise => { + if (!forScript || !Object.prototype.hasOwnProperty.call(result, 'structuredContent')) { + return this.mcpToolResult(result, call.id, mcpl); + } + const direct = AgentFramework.directToolResult(result); + return mcpl && Array.isArray(direct.data) ? { ...direct, data: direct.data.map(referenceBlockForScript) } : direct; + }; if (modern) { // Modern MCP: no state exchange, no channels, no RFC-005 references. @@ -15424,11 +15432,16 @@ export class AgentFramework { legacy.sendToolsCall(toolName, args, stateParams) .then(async (result) => { - // RFC-005: register references and eagerly fetch the small ones - // before serialization, so their stubs carry a saved path. Only an - // MCPL peer's blocks are references; an MCP-only peer's are standard. + // RFC-005: register references and fetch the small ones, and every + // one a script will read, before serialization, so their stubs carry + // a saved path. Only an MCPL peer's blocks are references; an + // MCP-only peer's are standard. A script reads the blocks unless the + // result is an error without structured content, which reaches it as + // its error text alone. if (mcplPeer) { - await this.autoFetchReferences(result.content, serverId, true) + const scriptReads = forScript + && (!result.isError || Object.prototype.hasOwnProperty.call(result, 'structuredContent')); + await this.autoFetchReferences(result.content, serverId, scriptReads) .catch((e) => console.error('[rfc005] autofetch error:', (e as Error).message)); } return { result, toolResult: await reading(result, mcplPeer) }; @@ -15547,7 +15560,8 @@ export class AgentFramework { * `structured` too, with the JSON view when its content is empty. The * direct path (a module's callTool, a channel's tool call) doesn't come * here: it returns the server's result itself (directToolResult), for - * either engine, as does a script's call whose result is structured. + * either engine, as does a script's call whose result is structured, with + * an MCPL peer's blocks given RFC-005's treatment (referenceBlockForScript). */ private async mcpToolResult( result: { content?: unknown; isError?: boolean; structuredContent?: unknown }, diff --git a/src/mcpl/references.ts b/src/mcpl/references.ts index acadd26ae..c91d232de 100644 --- a/src/mcpl/references.ts +++ b/src/mcpl/references.ts @@ -295,6 +295,48 @@ export function referenceStubOrNull(block: unknown, provenance?: string, serverI case 'inline': return c.contradiction ? INLINE_WITHHELD_TEXT : null; case 'reference': return buildReferenceStub(c.testimony, provenance, serverId); - case 'invalid': return `[unrecognized content block: ${sanitizeLabel(c.reason, STUB_FIELD_CHARS)}]`; + case 'invalid': return unrecognizedBlockNotice(c.reason); + } +} + +/** What every lane shows in place of a block the classifier rejects. */ +function unrecognizedBlockNotice(reason: string): string { + return `[unrecognized content block: ${sanitizeLabel(reason, STUB_FIELD_CHARS)}]`; +} + +/** + * RFC-005 treatment for one MCPL wire block, for a script's structured + * result, which carries blocks rather than text. It makes the decision + * referenceStubOrNull makes for the model's lanes, since what a script prints + * comes back to the model. A reference becomes the stub's facts as data, + * without its URI: the host's reference id, what the server said about it in + * this block (its labels stripped and bounded as in the stub), and `savedTo`, + * the workspace path its payload was fetched to, or null when it wasn't + * (`fetch_reference` takes the id as `ref_id`). A block the classifier + * rejects becomes the notice the model's lanes show, and inline data that + * also claims a bulk disposition the withheld notice, each as a text block. + * A text block, or inline data that claims nothing, is returned as sent. + */ +export function referenceBlockForScript(block: unknown): unknown { + const c = classifyBlock(block); + switch (c.kind) { + case 'text': return block; + case 'inline': + return c.contradiction ? { type: 'text', text: INLINE_WITHHELD_TEXT } : block; + case 'invalid': return { type: 'text', text: unrecognizedBlockNotice(c.reason) }; + case 'reference': { + const t = c.testimony; + const record = referenceRegistry.register(t); + return { + type: (block as { type: string }).type, + refId: record.refId, + ...(t.name !== undefined ? { name: sanitizeLabel(t.name, STUB_FIELD_CHARS) } : {}), + ...(t.mimeType !== undefined ? { mimeType: sanitizeLabel(t.mimeType, STUB_FIELD_CHARS) } : {}), + ...(t.sizeBytes !== undefined ? { sizeBytes: t.sizeBytes } : {}), + ...(t.digest !== undefined ? { digest: t.digest } : {}), + ...(t.expiresAt !== undefined ? { expiresAt: sanitizeLabel(t.expiresAt, STUB_FIELD_CHARS) } : {}), + savedTo: record.fetchedPath ?? null, + }; + } } } diff --git a/src/mcpl/tool-result-normalize.ts b/src/mcpl/tool-result-normalize.ts index a0adf48e7..b38f20b59 100644 --- a/src/mcpl/tool-result-normalize.ts +++ b/src/mcpl/tool-result-normalize.ts @@ -12,8 +12,9 @@ * to the workspace, and a bounded stub says where it went. A stub tells the * reader where the payload is; nothing is dropped silently. If a payload * can't be saved, the result is a failure to materialize it, not a - * success. A `resource_link` is shown as a reference and never fetched. An - * embedded resource shows its text, or is saved like a blob. + * success. A `resource_link` is shown as a link, with its name and URI, + * and never fetched. An embedded resource shows its text, or is saved like + * a blob. * - **`structuredContent` is the machine-readable view.** It is kept by * presence on `ToolResult.structured`. The model sees it rendered as JSON * only when the server's content has no text block of its own, because diff --git a/test/fixtures/modern-tools.mjs b/test/fixtures/modern-tools.mjs index 5fb435165..242f7b9a6 100644 --- a/test/fixtures/modern-tools.mjs +++ b/test/fixtures/modern-tools.mjs @@ -41,17 +41,18 @@ export function createServer(opts = {}) { content: [{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }], structuredContent: { score: 0.9, label: 'cat' }, })); + const media = [ + { type: 'text', text: 'before' }, + { type: 'resource_link', uri: 'file:///srv/report.pdf', name: 'report.pdf', mimeType: 'application/pdf' }, + { type: 'resource', resource: { uri: 'memo://note', mimeType: 'text/plain', text: 'embedded note text' } }, + { type: 'resource', resource: { uri: 'memo://bin', mimeType: 'application/octet-stream', blob: Buffer.from('binary!').toString('base64') } }, + { type: 'audio', data: Buffer.from('RIFF0000WAVEfmt ').toString('base64'), mimeType: 'audio/wav' }, + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + ]; server.registerTool('media', { description: 'Mixed standard content', inputSchema: obj() }, - async () => ({ - content: [ - { type: 'text', text: 'before' }, - { type: 'resource_link', uri: 'file:///srv/report.pdf', name: 'report.pdf', mimeType: 'application/pdf' }, - { type: 'resource', resource: { uri: 'memo://note', mimeType: 'text/plain', text: 'embedded note text' } }, - { type: 'resource', resource: { uri: 'memo://bin', mimeType: 'application/octet-stream', blob: Buffer.from('binary!').toString('base64') } }, - { type: 'audio', data: Buffer.from('RIFF0000WAVEfmt ').toString('base64'), mimeType: 'audio/wav' }, - { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, - ], - })); + async () => ({ content: media })); + server.registerTool('structured_media', { description: 'Mixed standard content with structured data', inputSchema: obj() }, + async () => ({ content: media, structuredContent: { blocks: media.length } })); server.registerTool('fail', { description: 'Reports a tool error', inputSchema: obj() }, async () => ({ content: [{ type: 'text', text: 'the tool failed on purpose' }], isError: true })); server.registerTool('slow', { description: 'Sleeps, then answers', inputSchema: obj({ ms: { type: 'number' } }) }, diff --git a/test/mcp-modern-framework.test.ts b/test/mcp-modern-framework.test.ts index 0c827aa97..666424e3f 100644 --- a/test/mcp-modern-framework.test.ts +++ b/test/mcp-modern-framework.test.ts @@ -181,6 +181,22 @@ test('scripts get structured results whole, as one JSON object', async () => { structuredContent: { score: 0.9, label: 'cat' }, isError: false, }); + // A resource link, embedded resources and audio are ordinary MCP blocks + // from a modern server, not RFC-005 references: the script reads them as + // the server sent them. + const media = JSON.parse(await handle('agent', 'mcpl--modern--structured_media', {})); + assert.deepEqual(media, { + content: [ + { type: 'text', text: 'before' }, + { type: 'resource_link', uri: 'file:///srv/report.pdf', name: 'report.pdf', mimeType: 'application/pdf' }, + { type: 'resource', resource: { uri: 'memo://note', mimeType: 'text/plain', text: 'embedded note text' } }, + { type: 'resource', resource: { uri: 'memo://bin', mimeType: 'application/octet-stream', blob: Buffer.from('binary!').toString('base64') } }, + { type: 'audio', data: Buffer.from('RIFF0000WAVEfmt ').toString('base64'), mimeType: 'audio/wav' }, + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + ], + structuredContent: { blocks: 6 }, + isError: false, + }); // Without structured content the script contract is unchanged (pinned): // the history rendering, which JSON-encodes a text result. assert.equal(await handle('agent', 'mcpl--modern--echo', { text: 'x' }), JSON.stringify('echo:x')); diff --git a/test/script-structured-references.test.ts b/test/script-structured-references.test.ts new file mode 100644 index 000000000..55a6b8f30 --- /dev/null +++ b/test/script-structured-references.test.ts @@ -0,0 +1,248 @@ +/** + * What a script receives from a tool whose result carries structuredContent, + * through the real dispatch. An MCPL peer's content blocks get the treatment + * RFC-005 gives them on every lane the model reads, since what a script prints + * comes back to the model: a reference arrives without its URI, as the host's + * reference id, what the server said about it in that block, and `savedTo`, + * the workspace path the dispatch fetched its payload to for the script; a + * block the classifier rejects arrives as the notice the model's lanes show, + * and inline data that claims a bulk disposition as the withheld notice. A + * standard peer's blocks arrive as the server sent them. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { AgentFramework, WorkspaceModule } from '../src/index.js'; +import type { ToolResult } from '../src/index.js'; +import { INLINE_WITHHELD_TEXT } from '../src/mcpl/references.js'; +import type { ReferenceRecord } from '../src/mcpl/references.js'; +import { MockMembrane } from './helpers/mock-membrane.js'; + +const SAVED = 'board/references/report.pdf'; +const DIGEST = 'sha256:47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU'; + +/** The URIs of one test's references. The reference registry lives as long + * as the process, so each test names its own. */ +const uris = (test: string) => ({ + report: `https://files.example.invalid/${test}/report.pdf?sig=secret-token`, + big: `https://files.example.invalid/${test}/big.png?sig=other-token`, +}); + +/** A block of each kind the dispatch decides about. */ +const blocks = (test: string) => [ + // A reference whose payload the fetch saves. Its labels carry a bidi + // override, a bidi mark and a control character. + { type: 'resource', uri: uris(test).report, name: 'report\u202E.pdf\u0007', mimeType: 'application/pdf\u200F', sizeBytes: 1234, + digest: DIGEST, expiresAt: '2026-12-31T00:00:00Z' }, + // The same reference again, saying something else about itself. + { type: 'resource', uri: uris(test).report, name: 'report-copy.pdf' }, + // A reference the fetch doesn't save. + { type: 'image', uri: uris(test).big, mimeType: 'image/png', disposition: 'ref', expiresAt: '2027-01-01T00:00:00Z\u202E' }, + // Inline data that claims a bulk disposition, and inline data with a uri + // beside it: withheld. + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png', disposition: 'ref' }, + { type: 'audio', data: 'UklGRg==', mimeType: 'audio/wav', uri: 'https://files.example.invalid/beside?sig=beside-token' }, + // Inline data that claims nothing. + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + // Blocks the classifier rejects: a uri longer than its limit (4096 UTF-16 + // units), an MCP embedded resource (its uri nested), and a type RFC-005 + // doesn't define. + { type: 'resource', uri: `https://files.example.invalid/long?sig=${'s'.repeat(4096)}`, name: 'long' }, + { type: 'resource', resource: { uri: 'https://files.example.invalid/embedded?sig=embedded-token', mimeType: 'text/plain', text: 'embedded' } }, + { type: 'resource_link', uri: 'https://files.example.invalid/link?sig=link-token', name: 'link' }, + { type: 'text', text: 'two attachments' }, +]; + +/** A tools/call result as a server may send it, nonconformant blocks included. */ +interface Answer { content: unknown[]; structuredContent?: unknown; isError?: boolean } + +interface Harness { + /** Sets what every tools/call answers from now on. */ + answer: (result: Answer) => void; + /** A script's call to a tool of the MCPL peer `files`, or of the MCP-only peer `plain`. */ + script: (tool: string) => Promise; + /** A call the model makes to a tool of `files`, through the same dispatch. */ + modelCall: (tool: string) => Promise; + /** The URIs the fetcher was asked for, saved records aside. */ + fetched: string[]; + workspace: WorkspaceModule; +} + +async function withPeers(fn: (h: Harness) => Promise, opts: { eager?: boolean } = {}): Promise { + const dir = mkdtempSync(join(tmpdir(), 'script-structured-references-')); + const board = join(dir, 'board'); + mkdirSync(board); + const workspace = new WorkspaceModule({ mounts: [{ name: 'board', path: board, mode: 'read-write', watch: 'never' }] }); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], modules: [workspace], + }); + workspace.initStore(framework.getStore()); + let answer: Answer = { content: [] }; + // Two legacy-engine peers: `files` negotiated MCPL, `plain` is MCP-only. + const peer = (id: string, capabilities: Record | null) => ({ id, capabilities, sendToolsCall: async () => answer }); + const peers = new Map([['files', peer('files', {})], ['plain', peer('plain', null)]]); + const fetched: string[] = []; + const internals = framework as unknown as { + mcplServerRegistry: unknown; + mcplPrefixMap: Map; + getReferenceFetcher: () => unknown; + handleScriptToolCall: (agent: string, tool: string, args: Record) => Promise; + dispatchMcplToolCall: (agent: string, call: { id: string; name: string; input: unknown }, serverId: string, prefix: string) => void; + pushEvent: (event: { type: string; callId?: string; result?: ToolResult }) => void; + }; + internals.mcplServerRegistry = { getServer: (id: string) => peers.get(id) ?? null, getAllServers: () => [], closeAll: async () => {} }; + internals.mcplPrefixMap.set('mcpl--files', 'files'); + internals.mcplPrefixMap.set('mcpl--plain', 'plain'); + // Stands in for the host-mediated fetcher: every reference is eager or + // none is, as the test asks, the report is saved and anything else + // refused, and a saved record returns at once. + internals.getReferenceFetcher = () => ({ + isEagerEligible: () => opts.eager === true, + fetch: async (record: ReferenceRecord) => { + if (record.fetchedPath) return { ok: true, path: record.fetchedPath }; + fetched.push(record.testimony.uri); + if (!record.testimony.uri.includes('report.pdf')) return { ok: false, error: 'refused by the test' }; + record.fetchedPath = SAVED; + return { ok: true, path: SAVED }; + }, + }); + const modelCall = (tool: string) => new Promise((resolve) => { + const id = `model-${randomUUID()}`; + const push = internals.pushEvent; + internals.pushEvent = (event) => { + if (event.type !== 'tool-result' || event.callId !== id) return push.call(framework, event); + internals.pushEvent = push; + resolve(event.result!); + }; + internals.dispatchMcplToolCall('agent', { id, name: `mcpl--files--${tool}`, input: {} }, 'files', 'mcpl--files'); + }); + framework.start(); + try { + await fn({ + answer: (result) => { answer = result; }, + script: (tool) => internals.handleScriptToolCall.call(framework, 'agent', tool, {}), + modelCall, + fetched, + workspace, + }); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } +} + +/** What a script reads for those blocks from an MCPL peer, given each reference's id. */ +const treated = (report: string, big: string) => [ + { type: 'resource', refId: report, name: 'report.pdf', mimeType: 'application/pdf', sizeBytes: 1234, digest: DIGEST, + expiresAt: '2026-12-31T00:00:00Z', savedTo: SAVED }, + { type: 'resource', refId: report, name: 'report-copy.pdf', savedTo: SAVED }, + { type: 'image', refId: big, mimeType: 'image/png', expiresAt: '2027-01-01T00:00:00Z', savedTo: null }, + { type: 'text', text: INLINE_WITHHELD_TEXT }, + { type: 'text', text: INLINE_WITHHELD_TEXT }, + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + { type: 'text', text: '[unrecognized content block: uri exceeds schema maximum]' }, + { type: 'text', text: '[unrecognized content block: reference without string uri]' }, + { type: 'text', text: '[unrecognized content block: unknown block type: resource_link]' }, + { type: 'text', text: 'two attachments' }, +]; + +test("an MCPL peer's structured result reaches a script with each block treated as the model's lanes treat it", async () => { + await withPeers(async ({ answer, script, fetched }) => { + answer({ content: blocks('treated'), structuredContent: { attachments: 2 } }); + const out = await script('mcpl--files--attach'); + assert.ok(!out.includes('example.invalid'), `no URI reaches the script: ${out.slice(0, 2000)}`); + assert.deepEqual(fetched, [uris('treated').report, uris('treated').big], 'every reference was fetched for the script before it read the result'); + const object = JSON.parse(out) as { content: Array<{ refId?: string }>; structuredContent: unknown; isError: boolean }; + const [report, , big] = object.content; + assert.match(String(report!.refId), /^ref_/); + assert.match(String(big!.refId), /^ref_/); + assert.notEqual(report!.refId, big!.refId); + assert.deepEqual(object, { content: treated(report!.refId!, big!.refId!), structuredContent: { attachments: 2 }, isError: false }); + }); +}); + +test("an MCP-only peer's structured result reaches a script as the server sent it", async () => { + await withPeers(async ({ answer, script, fetched }) => { + answer({ content: blocks('plain'), structuredContent: { attachments: 2 } }); + const object = JSON.parse(await script('mcpl--plain--attach')); + assert.deepEqual(object, { content: blocks('plain'), structuredContent: { attachments: 2 }, isError: false }, + 'resource links, embedded resources, uri-form media and inline data are ordinary MCP blocks there'); + assert.deepEqual(fetched, [], 'nothing is a reference, so nothing is fetched'); + }); +}); + +test('without structured content, an MCPL peer\'s result reaches a script as stubs naming the same reference id and saved path', async () => { + await withPeers(async ({ answer, script }) => { + // Only blocks the text lane recognizes: one it doesn't sends the whole + // array to the JSON fallback, which is older than this script path. + const all = blocks('unstructured'); + const content = [all[0], all[2], all[9]]; + answer({ content, structuredContent: {} }); + const structuredId = String((JSON.parse(await script('mcpl--files--attach')) as { content: Array<{ refId?: string }> }).content[0]!.refId); + answer({ content }); + const out = await script('mcpl--files--attach'); + assert.ok(!out.includes('example.invalid'), out); + assert.ok(out.includes(`[${structuredId}]`), `the same reference id: ${out}`); + assert.ok(out.includes(`saved: ${SAVED}`), `the same saved path: ${out}`); + }); +}); + +test("an MCPL peer's structured result past the script cap is saved with its references as their ids, no URI in the file", async () => { + await withPeers(async ({ answer, script, workspace }) => { + answer({ content: blocks('oversized'), structuredContent: { pad: 'x'.repeat(5_000_000) } }); + const out = JSON.parse(await script('mcpl--files--attach')) as { isError: boolean; oversized: { chars: number; savedTo: string } }; + assert.equal(out.isError, false); + const read = await workspace.readBinary(out.oversized.savedTo); + assert.ok('data' in read, `the saved result is readable: ${out.oversized.savedTo}`); + const saved = read.data.toString('utf8'); + assert.ok(!saved.includes('example.invalid'), 'no URI in the saved file'); + const object = JSON.parse(saved) as { content: Array<{ refId?: string }> }; + assert.deepEqual(object.content, treated(object.content[0]!.refId!, object.content[2]!.refId!)); + }); +}); + +test("only a script that reads the blocks gets the script-demand fetch: not the model's call, nor an error a script reads as text", async () => { + await withPeers(async ({ answer, script, modelCall, fetched }) => { + answer({ content: blocks('model'), structuredContent: { attachments: 2 } }); + const model = await modelCall('attach'); + assert.equal(model.success, true); + assert.deepEqual(fetched, [], 'nothing here is eager-eligible, and the model\'s call gets no more than the eager pass'); + + answer({ content: [blocks('error')[0], { type: 'text', text: 'the attach failed' }], isError: true }); + assert.equal(await script('mcpl--files--attach'), 'Error: the attach failed'); + assert.deepEqual(fetched, [], 'a script that reads an error as its text alone gets no more than the eager pass'); + }); +}); + +test("the eager pass still runs for the model's call and for an error a script reads as text", async () => { + await withPeers(async ({ answer, script, modelCall, fetched }) => { + answer({ content: [blocks('eager-model')[0]], structuredContent: { attachments: 1 } }); + assert.equal((await modelCall('attach')).success, true); + assert.deepEqual(fetched, [uris('eager-model').report]); + + answer({ content: [blocks('eager-error')[0], { type: 'text', text: 'the attach failed' }], isError: true }); + assert.equal(await script('mcpl--files--attach'), 'Error: the attach failed'); + assert.deepEqual(fetched, [uris('eager-model').report, uris('eager-error').report]); + }, { eager: true }); +}); + +test('an error with structured content is fetched for the script, and reaches it treated, with its error text and structured content', async () => { + await withPeers(async ({ answer, script, fetched }) => { + answer({ content: [blocks('structured-error')[0], { type: 'text', text: 'the attach failed' }], structuredContent: { partial: true }, isError: true }); + const out = await script('mcpl--files--attach'); + assert.ok(!out.includes('example.invalid'), out); + const object = JSON.parse(out) as { content: Array<{ refId?: string }>; structuredContent: unknown; isError: boolean; error: string }; + assert.deepEqual(object, { + content: [treated(object.content[0]!.refId!, '')[0], { type: 'text', text: 'the attach failed' }], + structuredContent: { partial: true }, + isError: true, + error: 'the attach failed', + }); + assert.deepEqual(fetched, [uris('structured-error').report], 'the script reads the blocks, so its reference was fetched first'); + }); +}); From 58ea8e4fc3cef0fe6c11039a7a042fb2d4a605cb Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 20:07:26 -0400 Subject: [PATCH 23/94] fix(code-execution): a script is told what its tool functions return, and a result JSON can't hold is an error naming the tool A review of this change against #181 found three things to carry over. - The code_execution description said each function returns "the same text the tool would have returned to you directly". For a result with structured content it returns a JSON object instead. The description now gives that object, { content, structuredContent, isError } with error when isError is true, a reference in content as { type, refId, savedTo, ... } (savedTo null when the payload wasn't saved; refId is fetch_reference's ref_id), and the oversized form. handleScriptToolCall's comment, which said results are text-only by contract, now says how each kind of result is serialized. - A result that can't be converted to JSON, a cyclic object or a BigInt that an in-process module returned, made scriptStructuredResult's or the history serializer's JSON.stringify throw. The runner caught it and gave the script its bare error, such as "Error: Converting circular structure ...", naming no tool, and a script parsing a structured result failed in json.loads. Each path now returns its own error form. A structured result gets the documented object, empty: { content: [], structuredContent: null, isError: true, error }, so a script that reads content first still works. Anything else gets "Error: ...". The error names the tool, gives JSON.stringify's reason and says the call itself completed, so a script that reports it doesn't invite a blind retry. The host's log gets the same line, since the tool needs fixing whether or not the script reports it. - test/code-execution-structured-results.test.ts, adapted from #181's test of the same name, runs the whole boundary: a real MCPL peer over WebSocket, the framework's dispatch, and a real Python script. The script reads each kind of result: structured with text, with an empty object, structured only, plain text, empty, a structured error, and one over the cap with no workspace to save it in. It also calls an in-process module's cyclic, BigInt and cyclic-data tools. The model's own calls read the text, or the structured JSON when the content has none, and the stored history holds the script's stdout and none of its intermediate results. With the JSON handling reverted, the script fails at json.loads of the runner's bare error. - changelog: says both, and credits #181's author. --- .../mcp-standard-tool-results.changed.md | 12 ++ src/code-execution/tool-definition.ts | 11 +- src/framework.ts | 51 +++++- .../code-execution-structured-results.test.ts | 173 ++++++++++++++++++ 4 files changed, 236 insertions(+), 11 deletions(-) create mode 100644 test/code-execution-structured-results.test.ts diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index 4d809347f..3565ec180 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -49,6 +49,18 @@ `{ isError, oversized: { chars, savedTo } }`. If it can't be saved, the script gets a failure that says the tool may have completed. Results without structured content keep the existing script contract. +- A result that can't be converted to JSON, such as a cyclic object or a + `BigInt` from an in-process module, reaches the script as an error that + names the tool and says the call itself completed: for a structured + result, the object above with `content: []`, `structuredContent: null`, + `isError: true` and the `error`, and `Error: …` otherwise. Before, the + script got the runner's bare error, such as `Converting circular + structure…`. +- The `code_execution` tool's description now says what a script's tool + function returns: the text, or for a structured result the JSON object + above, with references and the oversized form. These two changes, and an + end-to-end test with real Python adapted from #181's, come from + @Meganeuridae's review of this change against #181. - The direct tool path (`executeToolCall`, `ModuleContext.callTool`) now treats an MCP tool error (`isError: true`) as a failure, with `success: false` and the error text; before, every answer counted as diff --git a/src/code-execution/tool-definition.ts b/src/code-execution/tool-definition.ts index cab62de22..e2cdbee9a 100644 --- a/src/code-execution/tool-definition.ts +++ b/src/code-execution/tool-definition.ts @@ -32,8 +32,15 @@ export function buildCodeExecutionToolDefinition(opts?: { "non-identifier character replaced by '_' " + "(e.g. tool 'mcpl--discord--fetch_history' is the function mcpl__discord__fetch_history, " + "and tool 'mcpl--dog-events--status' is mcpl__dog_events__status). " + - 'Each function takes a single dict of arguments and returns a string — the same text ' + - 'the tool would have returned to you directly; parse structured results with json.loads. ' + + 'Each function takes a single dict of arguments and returns a string: the text the tool ' + + 'would have returned to you directly, or "Error: ..." if the call failed. For a result ' + + 'with structured content, the string is a JSON object to parse with json.loads: ' + + '{"content": [...], "structuredContent": ..., "isError": false}, with "error" added when ' + + `"isError" is true. "content" holds the server's content blocks. A reference to a payload ` + + 'appears there as {"type": ..., "refId": ..., "savedTo": ...}, where "savedTo" is the ' + + `workspace path the payload was saved to (null if it wasn't) and "refId" is fetch_reference's ` + + 'ref_id. A structured result over 5 MB is saved to the workspace instead, and the string is ' + + '{"isError": ..., "oversized": {"chars": ..., "savedTo": ...}}. ' + "An exact-name lookup dict is also available: tools['mcpl--discord--fetch_history']({...}). " + 'Use top-level await; run independent calls in parallel with asyncio.gather. ' + 'Only what you print() (plus stderr and the exit code) comes back to you — intermediate ' + diff --git a/src/framework.ts b/src/framework.ts index 5c3eaf2e2..3acdd3838 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -958,6 +958,22 @@ interface EphemeralRun { toolCallsCount: number; } +/** + * What a script gets for a tool result that can't be converted to JSON, + * such as a cyclic object or a BigInt that an in-process module returned. + * It names the tool, and says the call itself completed, so a script that + * reports it doesn't invite a blind retry. The host's log gets the same + * line, since the tool that returned it needs fixing whether or not the + * script reports it. + */ +function unconvertibleScriptResult(toolName: string, err: unknown): string { + const reason = err instanceof Error ? err.message : String(err); + const text = `tool '${toolName}' returned a result that can't be converted to JSON, so the script can't receive it ` + + `(${reason}). The call itself completed.`; + console.error(`[code_execution] ${text}`); + return text; +} + /** Cap an API error message for inline use in a rewind marker: keep enough to * identify the failure class without pasting a wall of provider JSON into the * agent's context. */ @@ -11863,12 +11879,17 @@ export class AgentFramework { /** * Resolve one script-inner tool call to the string the script's tool - * function returns. Never rejects — errors become "Error: ..." strings, + * function returns. Never rejects: errors become "Error: ..." strings, * matching the managed PTC runtime ("Claude's code receives this error"). * - * Results are serialized with the HISTORY serializer (images become - * placeholders): programmatic tool results are text-only by contract, and - * a script variable holding megabytes of base64 helps nobody. + * A result without structured content is serialized with the HISTORY + * serializer: images become placeholders, since a script variable holding + * megabytes of base64 helps nobody. A result with structured content is + * one JSON object holding both of the server's views + * (scriptStructuredResult), so its content blocks reach the script as the + * dispatch gave them, images and payloads included. A result that can't be + * converted to JSON, such as a cyclic object or a BigInt from an in-process + * module, becomes an error that names the tool, in each path's error form. */ private async handleScriptToolCall( agentName: string, @@ -11902,8 +11923,12 @@ export class AgentFramework { // Scripts get (nearly) full data — the script environment IS the spill: // filtering big results in code is the whole point. 5MB protocol safety // cap only; no context-cap truncation, no file spill. - if (structured) return this.scriptStructuredResult(result); - return toolResultDataToHistoryString(result.data, 5_000_000); + if (structured) return this.scriptStructuredResult(result, toolName); + try { + return toolResultDataToHistoryString(result.data, AgentFramework.SCRIPT_RESULT_MAX_CHARS); + } catch (err) { + return `Error: ${unconvertibleScriptResult(toolName, err)}`; + } } /** The 5MB protocol safety cap on what a script receives. */ @@ -11921,16 +11946,24 @@ export class AgentFramework { * saved to the workspace, and the script gets * `{ isError, oversized: { chars, savedTo } }`. If it can't be saved, the * script gets a failure to materialize the result, saying the tool may - * already have completed, never a success missing its result. + * already have completed, never a success missing its result. A result + * that can't be converted to JSON (a cyclic object, a BigInt) gets the + * same object empty, `{ content: [], structuredContent: null, isError: + * true, error }`, the error naming the tool. */ - private async scriptStructuredResult(result: ToolResult): Promise { + private async scriptStructuredResult(result: ToolResult, toolName: string): Promise { const object = { content: Array.isArray(result.data) ? result.data : [], structuredContent: result.structured, isError: result.isError === true, ...(result.isError ? { error: result.error ?? '' } : {}), }; - const json = JSON.stringify(object); + let json: string; + try { + json = JSON.stringify(object); + } catch (err) { + return JSON.stringify({ content: [], structuredContent: null, isError: true, error: unconvertibleScriptResult(toolName, err) }); + } if (json.length <= AgentFramework.SCRIPT_RESULT_MAX_CHARS) return json; const workspace = this.getWorkspaceModule(); const mount = workspace ? this.firstWritableMountName(workspace) : null; diff --git a/test/code-execution-structured-results.test.ts b/test/code-execution-structured-results.test.ts new file mode 100644 index 000000000..21343077d --- /dev/null +++ b/test/code-execution-structured-results.test.ts @@ -0,0 +1,173 @@ +/** + * A tool's structured result end to end: a real MCPL connection, the + * framework's dispatch, and a real Python script. Adapted from + * anima-research/agent-framework#181's test of the same name, which first + * found scripts losing structuredContent. Beside what a script reads, it + * checks that the model's own calls and the stored history keep the readable + * text, and that a result an in-process module returns but JSON can't hold + * reaches the script as an error naming the tool. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { AddressInfo } from 'node:net'; +import { WebSocketServer } from 'ws'; + +import { AgentFramework } from '../src/index.js'; +import type { EventResponse, Module, ModuleContext, ToolCall, ToolDefinition, ToolResult } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +const envelope = { + runId: 'r41', state: 'running', exit: null, nextCursor: 17, truncated: false, + output: 'café 🌱', details: { retries: 0, lines: ['one', 'two'] }, +}; +const transcript = 'Readable transcript: work continues.'; +const text = (t: string) => [{ type: 'text', text: t }]; + +/** tools/call results by variant, as an MCPL peer sends them. */ +const fixtures: Record = { + dual: { content: text(transcript), structuredContent: envelope }, + empty: { content: text('not the empty object'), structuredContent: {} }, + only: { content: [], structuredContent: envelope }, + plain: { content: text('plain text') }, + absent: { content: [] }, + error: { content: text('run refused'), structuredContent: { state: 'rejected' }, isError: true }, + large: { content: text('short rendering'), structuredContent: { output: 'x'.repeat(5_000_000) } }, +}; + +/** An in-process module whose results JSON can't hold. */ +class UnconvertibleModule implements Module { + readonly name = 'loop'; + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + async onProcess(): Promise { return {}; } + getTools(): ToolDefinition[] { + return ['cyclic', 'bigint', 'cyclic_data'].map((name) => ({ name, description: name, inputSchema: { type: 'object' } })); + } + async handleToolCall(call: ToolCall): Promise { + const name = call.name.includes('--') ? call.name.slice(call.name.lastIndexOf('--') + 2) : call.name; + const cyclic: Record = { kind: 'loop' }; + cyclic.self = cyclic; + if (name === 'cyclic') return { success: true, data: text('a loop'), structured: cyclic }; + if (name === 'bigint') return { success: true, data: text('a big number'), structured: { n: 1n } }; + return { success: true, data: cyclic }; + } +} + +test("a script reads a structured result whole, the model's calls and history keep the readable text, and a result JSON can't hold is an error naming the tool", async () => { + const calls: string[] = []; + const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' }); + wss.on('connection', (ws) => ws.on('message', (data) => { + const msg = JSON.parse(String(data)); + const reply = (result: unknown) => ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result })); + if (msg.method === 'initialize') { + reply({ + protocolVersion: '2024-11-05', serverInfo: { name: 'fixture', version: '1.0.0' }, + capabilities: { tools: {}, experimental: { mcpl: { version: '0.4', pushEvents: true } } }, + }); + } else if (msg.method === 'featureSets/update') { + reply({}); + } else if (msg.method === 'tools/list') { + reply({ tools: [{ name: 'inspect', description: 'Read work status', inputSchema: { type: 'object' } }] }); + } else if (msg.method === 'tools/call') { + calls.push(msg.params.arguments.variant); + reply(fixtures[msg.params.arguments.variant]); + } + })); + if (!wss.address()) await new Promise((resolve) => wss.once('listening', resolve)); + const dir = mkdtempSync(join(tmpdir(), 'pytc-structured-')); + const membrane = new MockMembrane(); + let framework: AgentFramework | undefined; + try { + framework = await AgentFramework.create({ + storePath: join(dir, 'store.chronicle'), membrane: membrane.asMembrane(), + agents: [], modules: [new UnconvertibleModule()], syncIntervalMs: 0, codeExecution: { enabled: true }, + mcplServers: [{ id: 'fixture', url: `ws://127.0.0.1:${(wss.address() as AddressInfo).port}`, toolPrefix: 'fixture' }], + }); + await framework.start(); + for (let i = 0; i < 100 && !framework.getAllTools().some((t) => t.name === 'fixture--inspect'); i++) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + // The promise-based path, which modules and outside callers use, carries + // both views. + const direct = await framework.executeToolCall({ id: 'direct-api', name: 'fixture--inspect', input: { variant: 'dual' } }); + assert.deepEqual(direct.data, text(transcript)); + assert.deepEqual(direct.structured, envelope); + + const code = [ + 'import json', + `ENVELOPE = json.loads(${JSON.stringify(JSON.stringify(envelope))})`, + `TRANSCRIPT = ${JSON.stringify(transcript)}`, + 'r = json.loads(await fixture__inspect({"variant": "dual"}))', + 'assert r == {"content": [{"type": "text", "text": TRANSCRIPT}], "structuredContent": ENVELOPE, "isError": False}, r', + 'empty = json.loads(await fixture__inspect({"variant": "empty"}))', + 'assert empty["structuredContent"] == {} and empty["content"] == [{"type": "text", "text": "not the empty object"}], empty', + 'only = json.loads(await fixture__inspect({"variant": "only"}))', + 'assert only == {"content": [], "structuredContent": ENVELOPE, "isError": False}, only', + // Without structured content, a result is the text it always was. + 'plain = await fixture__inspect({"variant": "plain"})', + 'assert plain == json.dumps("plain text"), plain', + 'absent = await fixture__inspect({"variant": "absent"})', + 'assert absent == "", absent', + 'error = json.loads(await fixture__inspect({"variant": "error"}))', + 'assert error == {"content": [{"type": "text", "text": "run refused"}], "structuredContent": {"state": "rejected"}, "isError": True, "error": "run refused"}, error', + // Over the cap, with no workspace to save it to: a failure, never a success missing its result. + 'large = json.loads(await fixture__inspect({"variant": "large"}))', + 'assert large["isError"] is True and large["oversized"]["savedTo"] is None and large["oversized"]["chars"] > 5000000, large', + 'assert "could not be delivered" in large["error"], large', + // Results JSON can't hold: an error naming the tool, in each path's form. + // A structured result's error keeps the documented shape, so a script reading "content" first still works. + 'cyclic = json.loads(await loop__cyclic({}))', + 'assert cyclic["content"] == [] and cyclic["structuredContent"] is None and cyclic["isError"] is True, cyclic', + 'assert "loop--cyclic" in cyclic["error"] and "circular" in cyclic["error"], cyclic', + 'bigint = json.loads(await loop__bigint({}))', + 'assert bigint["content"] == [] and bigint["structuredContent"] is None and bigint["isError"] is True, bigint', + 'assert "loop--bigint" in bigint["error"] and "BigInt" in bigint["error"], bigint', + 'cyclic_data = await loop__cyclic_data({})', + 'assert cyclic_data.startswith("Error: tool ") and "loop--cyclic_data" in cyclic_data and "circular" in cyclic_data, cyclic_data', + 'assert all("The call itself completed." in e for e in [cyclic["error"], bigint["error"], cyclic_data]), "completed"', + 'print(r["structuredContent"]["runId"], r["structuredContent"]["state"], r["structuredContent"]["output"])', + ].join('\n'); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'model-direct', name: 'fixture--inspect', input: { variant: 'dual' } }, + { type: 'tool_use', id: 'model-only', name: 'fixture--inspect', input: { variant: 'only' } }, + { type: 'tool_use', id: 'model-python', name: 'code_execution', input: { code } }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([])); + const created = await framework.createEphemeralAgent({ name: 'worker', model: 'test-model', systemPrompt: 'Inspect work.', allowedTools: 'all' }); + created.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + await framework.runEphemeralToCompletion(created.agent, created.contextManager); + + const results = membrane.lastStream!.receivedToolResults.flat() as Array<{ toolUseId: string; content: string }>; + const resultOf = (id: string) => results.find((r) => r.toolUseId === id)!.content; + // The model reads the content: the text when there is some, and the + // structured JSON only when the content has none. + assert.equal(resultOf('model-direct'), JSON.stringify(transcript)); + assert.match(resultOf('model-only'), /\\"runId\\":\\"r41\\"/); + const python = JSON.parse(resultOf('model-python')); + assert.equal(python.stderr, ''); + assert.equal(python.return_code, 0); + assert.equal(python.stdout.trim(), 'r41 running café 🌱'); + assert.deepEqual(calls, ['dual', 'dual', 'only', 'dual', 'empty', 'only', 'plain', 'absent', 'error', 'large']); + + // The stored history holds what the model read: the readable direct + // result and the script's chosen stdout, never the script's intermediate + // results. + const stored = created.contextManager.getAllMessages().flatMap((m) => m.content) + .filter((b) => b.type === 'tool_result') as unknown as Array<{ toolUseId: string; content: string }>; + const storedOf = (id: string) => JSON.stringify(stored.find((b) => b.toolUseId === id)!.content); + assert.ok(storedOf('model-direct').includes('Readable transcript')); + assert.ok(!storedOf('model-direct').includes('nextCursor')); + assert.ok(!storedOf('model-python').includes('nextCursor')); + assert.ok(!storedOf('model-python').includes('short rendering')); + assert.ok(!storedOf('model-python').includes('structuredContent')); + } finally { + await framework?.stop(); + for (const client of wss.clients) client.terminate(); + await new Promise((resolve) => wss.close(() => resolve())); + rmSync(dir, { recursive: true, force: true }); + } +}); From 0baafd0bb2f9c6ff3babdad70b9aec78285e97b7 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 21:43:58 -0400 Subject: [PATCH 24/94] fix(mcp): the modern engine's teardown follows the MCPL engine's contract, and a reap failure names its server ModernMcpConnection.close() emitted 'close' as soon as the host closed a live session, even when its child couldn't be confirmed exited. The framework then traced mcpl:server-closed and module:removed for a modern server whose child might still be running, while a legacy server is traced closed only once its teardown is confirmed (main's rule, from #221). Now 'close' is emitted once, by the close that confirms the teardown: a failed close emits none, and the retry that reaps the child does. Its failures take the MCPL engine's shape too: a single failure is the transport's own error, several an AggregateError naming the server. Before, every failure was wrapped in a new Error, so a caller couldn't match the transport's error itself. Concurrent calls still queue behind the close in flight: after a success a queued call finds nothing to close, and after a failure it re-checks what wasn't reaped. The stdio reap error now names its server ("stdio child N of MCP server "id" did not exit within 2000ms of SIGKILL; it could not be reaped"), and so does the SIGTERM log line. Since a single failure is rethrown as itself, stop()'s rejection otherwise named a stuck child only by its pid. Tests: a failed disconnect on each engine traces no close, and the retry that confirms the exit traces one; the reap error names its server (stop(), the transport, the legacy concurrent-close test); a modern close's single failure is the failure itself. Each part reverted on its own fails a test: the close gating (the new trace test), the server name (four tests), and the single failure's identity (the connect-cleanup test). Changelog: the breaking note says stop() names each such child and its server. --- .../legacy-mcp-protocol-boundary.breaking.md | 14 +++++----- src/mcpl/modern-connection.ts | 26 ++++++++++++++----- src/mcpl/transport.ts | 9 ++++--- test/mcp-modern-outcomes.test.ts | 7 ++--- test/mcp-server-ownership.test.ts | 23 ++++++++++++++-- test/mcpl-legacy-lifetime.test.ts | 4 +-- 6 files changed, 59 insertions(+), 24 deletions(-) diff --git a/changelog.d/legacy-mcp-protocol-boundary.breaking.md b/changelog.d/legacy-mcp-protocol-boundary.breaking.md index 40a7c12d9..80ffaa865 100644 --- a/changelog.d/legacy-mcp-protocol-boundary.breaking.md +++ b/changelog.d/legacy-mcp-protocol-boundary.breaking.md @@ -28,10 +28,10 @@ about 1 ms, so every request timed out at once. Every value that worked, `0` included, passes. - **Hosts:** `stop()` now rejects, after its whole teardown, when an MCP - server's child couldn't be confirmed exited, naming each such child. - Before, closing a stdio server returned as soon as a signal was sent, so - `stop()` never failed for this. Code that chains work on `stop()`, such as - exiting a TUI or switching sessions, should catch that rejection: the - framework is stopped either way. As with any failed teardown, an owned - store stays open, and a later `stop()` retries the reap, completing once - the child has exited. + server's child couldn't be confirmed exited, naming each such child and + its server. Before, closing a stdio server returned as soon as a signal + was sent, so `stop()` never failed for this. Code that chains work on + `stop()`, such as exiting a TUI or switching sessions, should catch that + rejection: the framework is stopped either way. As with any failed + teardown, an owned store stays open, and a later `stop()` retries the + reap, completing once the child has exited. diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index cafc4ddf3..ff52ddd94 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -786,12 +786,19 @@ export class ModernMcpConnection extends EventEmitter { /** The close in flight, which later calls wait behind. */ private closing: Promise | null = null; + /** Set when the host closes a live session; cleared, with the one + * 'close', by the close that confirms its teardown. */ + private closeIncomplete = false; + /** * Close for good: no reconnect, no reopened subscription, no connect left - * in flight. Awaits the in-flight connect's end, and rejects if a child - * could not be reaped. Calls queue behind the one in flight, so none - * resolves while teardown is still pending. A call after a failed close - * re-checks what wasn't reaped. + * in flight. Awaits the in-flight connect's end, and rejects while a child + * can't be confirmed exited: with that failure itself, the transport's + * own error, or an AggregateError when there are several, as the MCPL + * engine does. Calls queue behind the one in flight, so none resolves + * while teardown is still pending. A call after a failed close re-checks + * what wasn't reaped. 'close' is emitted once, when the teardown of a + * session the host closed while live completes, not on a failed close. */ close(): Promise { const previous = this.closing; @@ -808,6 +815,7 @@ export class ModernMcpConnection extends EventEmitter { const opening = this.opening; const session = this.session; this.session = null; + if (session) this.closeIncomplete = true; // Every generation this connection still owns: a connect in flight, the // live one, and any a failed cleanup couldn't reap. const targets: Session[] = []; @@ -832,9 +840,13 @@ export class ModernMcpConnection extends EventEmitter { await opening.done; for (const held of this.unreaped) if (!failures.includes(held.failure)) failures.push(held.failure); } - if (session) this.emit('close', { reason: 'closed by host' }); - if (failures.length > 0) { - throw new Error(`MCP server "${this.id}" did not close cleanly: ${failures.map((f) => f.message).join('; ')}`); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AggregateError(failures, `MCP server "${this.id}" did not close cleanly: ${failures.map((f) => f.message).join('; ')}`); + } + if (this.closeIncomplete) { + this.closeIncomplete = false; + this.emit('close', { reason: 'closed by host' }); } } diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index 529b5feb2..18375a2c9 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -172,6 +172,8 @@ export class StdioTransport extends McplTransport { readonly kind = 'stdio' as const; private constructor( + /** The server this child serves, named in what its reaping reports. */ + private readonly serverId: string, private readonly child: ChildProcess, private readonly rl: ReadlineInterface, ) { @@ -215,7 +217,7 @@ export class StdioTransport extends McplTransport { env: buildChildEnv(config), }); const rl = createInterface({ input: child.stdout! }); - return new StdioTransport(child, rl); + return new StdioTransport(config.id, child, rl); } writeLine(json: string): void { @@ -261,11 +263,12 @@ export class StdioTransport extends McplTransport { }); child.kill('SIGTERM'); if (await exitWithin(STDIO_EXIT_GRACE_MS)) return; - console.error(`[mcpl] stdio child ${child.pid} ignored SIGTERM for ${STDIO_EXIT_GRACE_MS}ms — sending SIGKILL`); + const named = `stdio child ${child.pid} of MCP server "${this.serverId}"`; + console.error(`[mcpl] ${named} ignored SIGTERM for ${STDIO_EXIT_GRACE_MS}ms — sending SIGKILL`); child.kill('SIGKILL'); if (await exitWithin(STDIO_EXIT_GRACE_MS)) return; throw new Error( - `stdio child ${child.pid} did not exit within ${STDIO_EXIT_GRACE_MS}ms of SIGKILL; it could not be reaped`, + `${named} did not exit within ${STDIO_EXIT_GRACE_MS}ms of SIGKILL; it could not be reaped`, ); } } diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts index 62b06cbc1..338e00ee0 100644 --- a/test/mcp-modern-outcomes.test.ts +++ b/test/mcp-modern-outcomes.test.ts @@ -324,7 +324,7 @@ test('a child that cannot be reaped fails close() explicitly, never a claimed ex const realKill = child.kill.bind(child); child.kill = () => true; // signals never arrive: the child outlives both bounds cleanups.push(() => { realKill('SIGKILL'); }); - await assert.rejects(transport.close(), /did not exit within 2000ms of SIGKILL; it could not be reaped/); + await assert.rejects(transport.close(), /^Error: stdio child \d+ of MCP server "stuck" did not exit within 2000ms of SIGKILL; it could not be reaped$/); // Calls share the one attempt and its verdict. await assert.rejects(transport.close(), /could not be reaped/); }); @@ -571,16 +571,17 @@ test('close() during a connect waits for that connect\'s own cleanup, and report const statics = ModernMcpConnection as unknown as { closeSession: (session: unknown) => Promise }; const real = statics.closeSession; let closes = 0; + const unconfirmed = new Error('the child could not be confirmed gone (simulated)'); // close() closes the generation first; the connect's own cleanup closes it // again, and that second close is the one that can't confirm the exit. statics.closeSession = async (session: unknown) => { const verdict = await real.call(ModernMcpConnection, session); - return ++closes === 2 ? new Error('the child could not be confirmed gone (simulated)') : verdict; + return ++closes === 2 ? unconfirmed : verdict; }; cleanups.push(() => { statics.closeSession = real; }); const starting = connection.start(); await until(() => wire(log).some((e) => e.method === 'server/discover'), 'the launch waiting on discover'); - await assert.rejects(connection.close(), /did not close cleanly: the child could not be confirmed gone \(simulated\)/); + await assert.rejects(connection.close(), (error) => error === unconfirmed, 'the failure itself, as the MCPL engine rethrows one'); assert.equal(closes, 2, 'close() resolved only after the connect\'s own cleanup'); await starting; statics.closeSession = real; diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index 569ae0f7f..b48f11380 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -302,8 +302,8 @@ test('stop() closes every connection, keeps the ones it cannot reap registered, await assert.rejects(framework.stop(), (error) => { assert.ok(error instanceof AggregateError, String(error)); assert.equal(error.errors.length, 2, 'one failure from each engine'); - assert.match(error.message, /MCP server "m" did not close cleanly: .*could not be reaped/); - assert.match(error.message, /stdio child \d+ did not exit within \d+ms of SIGKILL; it could not be reaped/); + assert.match(error.message, /stdio child \d+ of MCP server "m" did not exit within \d+ms of SIGKILL; it could not be reaped/); + assert.match(error.message, /stdio child \d+ of MCP server "l" did not exit within \d+ms of SIGKILL; it could not be reaped/); return true; }); const internals = framework as unknown as { @@ -320,6 +320,25 @@ test('stop() closes every connection, keeps the ones it cannot reap registered, assert.equal(internals.mcplServerRegistry.getServer('l'), null); }); +test('a teardown that cannot confirm the exit traces no close; the retry that confirms it traces one, on both engines', async () => { + await withFramework(async ({ framework, dir }) => { + const closed: string[] = []; + framework.onTrace((event) => { if (event.type === 'mcpl:server-closed') closed.push(event.serverId); }); + const reaping = unreapable(); + await framework.connectMcplServer(modern('m', '', join(dir, 'raw.jsonl'))); + await framework.connectMcplServer(legacy('l', 'ok', join(dir, 'legacy.log'))); + await Promise.all(['m', 'l'].map((id) => assert.rejects( + framework.disconnectMcplServer(id), + new RegExp(`stdio child \\d+ of MCP server "${id}" did not exit .*stays registered, closed`), + ))); + assert.deepEqual(closed, [], 'neither is traced closed while its child may still be running'); + for (const stuck of reaping.stuck) await killAndWait(stuck); + await framework.disconnectMcplServer('m'); + await framework.disconnectMcplServer('l'); + assert.deepEqual(closed.sort(), ['l', 'm'], 'each is traced closed once, by the retry that confirms its exit'); + }); +}); + test("stop()'s teardown budget bounds a modern server's close too, and the server stays registered while its close is pending", async () => { const dir = mkdtempSync(join(tmpdir(), 'mcp-ownership-')); cleanups.push(() => rmSync(dir, { recursive: true, force: true })); diff --git a/test/mcpl-legacy-lifetime.test.ts b/test/mcpl-legacy-lifetime.test.ts index 99ef67faa..285cd5db9 100644 --- a/test/mcpl-legacy-lifetime.test.ts +++ b/test/mcpl-legacy-lifetime.test.ts @@ -139,7 +139,7 @@ test('a close that cannot reap fails, and so do concurrent ones; a later close r assert.deepEqual(settled, [], 'neither resolves while the teardown is pending'); await Promise.all([first, second]); assert.equal(settled.length, 2); - assert.match(settled[0]!, /^first:stdio child \d+ did not exit within \d+ms of SIGKILL; it could not be reaped/); + assert.match(settled[0]!, /^first:stdio child \d+ of MCP server "legacy" did not exit within \d+ms of SIGKILL; it could not be reaped/); assert.match(settled[1]!, /^second:.*could not be reaped/, 'the second shares the attempt and its verdict, rather than claiming success'); await killAndWait(stuck[0]!); await connection.close(); @@ -355,7 +355,7 @@ test('a WebSocket dial whose connection is closed meanwhile ends at once, not at test("a write to a stdio child that has just exited is reported, never thrown", (t) => { const child = Object.assign(new EventEmitter(), { stdin: new EventEmitter(), stderr: new EventEmitter(), killed: false, exitCode: null }); const rl = new EventEmitter(); - const transport = new (StdioTransport as unknown as new (c: unknown, r: unknown) => StdioTransport)(child, rl); + const transport = new (StdioTransport as unknown as new (id: string, c: unknown, r: unknown) => StdioTransport)('legacy', child, rl); const logged: unknown[] = []; t.mock.method(console, 'error', (...args: unknown[]) => { logged.push(args); }); const epipe = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }); From 2f0382a5aa5cdbcd6a767cbf6dc4f78584fd416d Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 22:49:03 -0400 Subject: [PATCH 25/94] fix(mcp): an http(s) url can't carry userinfo, and a script reads a standard peer's malformed result as the model does From slimepriestess's review of 0baafd0: - An http(s):// url with userinfo is refused (serverConfigProblems). fetch refuses such a url, and its TypeError quotes the whole url, password included, into the connect failure's log line, the mcpl:server-connect-failed traces and the ops alert (logs/failures.log and the ops webhook). A setting nothing could send is refused rather than ignored; the credential goes in token or accessProvider. The cleartext warning's userinfo item now applies to ws:// alone, since an http:// url's is refused. A ws:// url still sends its userinfo. - redactUrl replaces a query parameter name that looks like a credential, so a bare token in a valueless parameter (?) no longer survives as the name. - A script's structured result from a standard peer whose content isn't a list of blocks is now the malformed result the model reads ("[malformed result: its content is not a list of blocks; ...]", its structured content kept), where it was an empty success. An MCPL peer's reading is unchanged on both paths: no content, and the structured view. The notice text is shared (MALFORMED_CONTENT_NOTICE). - A structured script result that can't be converted keeps the tool's own error (", with this error: ..."), and a thrown value with no text form is described without String() throwing. Tests: the userinfo refusal for http and https, loopback or not and user-only too, through serverConfigProblems and ModernMcpConnection.create, with no warning beside it, and a ws:// url passing; redactUrl with a credential-like valueless name and a short plain one; a malformed structured result through both peers' model and script readings; a script receiving a cyclic error result keeps its own error. Each fails on 0baafd0's sources, and each piece reverted on its own fails one. --- src/framework.ts | 26 +++++++++++++---- src/mcpl/protocol-family.ts | 29 ++++++++++++++----- src/mcpl/tool-result-normalize.ts | 7 ++++- .../code-execution-structured-results.test.ts | 6 +++- test/mcp-protocol-family.test.ts | 22 ++++++++++++-- test/script-structured-references.test.ts | 24 ++++++++++++--- 6 files changed, 92 insertions(+), 22 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 38cd232ab..5a4da8c4a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -395,7 +395,7 @@ import type { McplServerConnection } from './mcpl/server-connection.js'; import { McplProtocolVersionError, McplUnreapedLaunchError } from './mcpl/server-connection.js'; import { ModernMcpConnection } from './mcpl/modern-connection.js'; import { redactUrl, resolveServerBinding, serverConfigProblems } from './mcpl/protocol-family.js'; -import { normalizeStandardToolResult } from './mcpl/tool-result-normalize.js'; +import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js'; import type { McplServerConfig, McplHostCapabilities, @@ -967,10 +967,16 @@ interface EphemeralRun { * line, since the tool that returned it needs fixing whether or not the * script reports it. */ -function unconvertibleScriptResult(toolName: string, err: unknown): string { - const reason = err instanceof Error ? err.message : String(err); +function unconvertibleScriptResult(toolName: string, err: unknown, ownError?: string): string { + let reason: string; + try { + reason = err instanceof Error ? err.message : String(err); + } catch { + reason = 'a value with no text form'; // String() throws on an object with no prototype + } + const own = ownError ? `, with this error: ${ownError}` : '.'; const text = `tool '${toolName}' returned a result that can't be converted to JSON, so the script can't receive it ` + - `(${reason}). The call itself completed.`; + `(${reason}). The call itself completed${own}`; console.error(`[code_execution] ${text}`); return text; } @@ -12002,7 +12008,8 @@ export class AgentFramework { try { json = JSON.stringify(object); } catch (err) { - return JSON.stringify({ content: [], structuredContent: null, isError: true, error: unconvertibleScriptResult(toolName, err) }); + const error = unconvertibleScriptResult(toolName, err, result.isError ? result.error : undefined); + return JSON.stringify({ content: [], structuredContent: null, isError: true, error }); } if (json.length <= AgentFramework.SCRIPT_RESULT_MAX_CHARS) return json; const workspace = this.getWorkspaceModule(); @@ -15432,7 +15439,14 @@ export class AgentFramework { return this.mcpToolResult(result, call.id, mcpl); } const direct = AgentFramework.directToolResult(result); - return mcpl && Array.isArray(direct.data) ? { ...direct, data: direct.data.map(referenceBlockForScript) } : direct; + if (mcpl) return Array.isArray(direct.data) ? { ...direct, data: direct.data.map(referenceBlockForScript) } : direct; + // A standard peer's content that isn't a list of blocks is a malformed + // result on the model's reading (normalizeStandardToolResult), so the + // script reads it that way too, its structured content kept. + if (!result.isError && result.content !== undefined && !Array.isArray(result.content)) { + return { success: false, isError: true, error: MALFORMED_CONTENT_NOTICE, data: [], structured: result.structuredContent }; + } + return direct; }; if (modern) { diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index 54682351c..ed33f98f0 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -75,8 +75,8 @@ function credentialLikeSegment(segment: string): boolean { * A url as text a person or a log reads it: its scheme, host and port, and * its path with any credential-like segment replaced. The userinfo is * removed and each query parameter's value replaced, since a credential - * rides there too. A url that doesn't parse can't be redacted, so it isn't - * shown at all. + * rides there too, and so is a parameter name that looks like one. A url + * that doesn't parse can't be redacted, so it isn't shown at all. */ export function redactUrl(url: string | undefined): string { let parsed: URL; @@ -89,7 +89,9 @@ export function redactUrl(url: string | undefined): string { parsed.password = ''; parsed.hash = ''; parsed.pathname = parsed.pathname.split('/').map((segment) => (credentialLikeSegment(segment) ? 'redacted' : segment)).join('/'); - parsed.search = new URLSearchParams([...parsed.searchParams].map(([name]): [string, string] => [name, 'redacted'])).toString(); + // A bare token rides as a name with no value (`?`), so a name that + // looks like a credential is replaced too. + parsed.search = new URLSearchParams([...parsed.searchParams].map(([name]): [string, string] => [credentialLikeSegment(name) ? 'redacted' : name, 'redacted'])).toString(); return parsed.toString(); } @@ -183,6 +185,17 @@ export function serverConfigProblems(config: McplServerConfig): string[] { return [(error as Error).message]; } const problems: string[] = []; + if (binding.transport === 'http') { + // fetch refuses a url with userinfo, and its error quotes the whole url, + // password included, into the connect failure's log line, trace and + // ops alert. A setting nothing could send is refused, not ignored. + const url = new URL(config.url!); + if (url.username || url.password) { + problems.push( + `MCP server "${config.id}": an http(s):// url can't carry userinfo (user:password@), since the HTTP client refuses such a url; give the credential as "token" or "accessProvider"`, + ); + } + } if (config.protocol !== undefined) { if (config.protocol !== 'legacy' && config.protocol !== 'modern') { problems.push(`MCP server "${config.id}": protocol must be 'legacy' or 'modern', got ${JSON.stringify(config.protocol)}`); @@ -241,9 +254,9 @@ const CREDENTIAL_PARAM = /token|key|secret|password|auth|signature/i; * as messages; empty when there is nothing. A credential for an `http://` or * `ws://` url whose host isn't loopback crosses the network in cleartext: * `token` or `accessProvider` (in the `Authorization` header, or the - * WebSocket `?token=` query), and a secret the url carries itself (userinfo, - * a query parameter whose name says it's a credential, or a path segment - * that looks like one, as {@link redactUrl} judges it). That can be + * WebSocket `?token=` query), and a secret the url carries itself (a ws:// + * url's userinfo, a query parameter whose name says it's a credential, or a + * path segment that looks like one, as {@link redactUrl} judges it). That can be * deliberate, on a private network or behind a TLS-terminating proxy, so it * is a warning and never a refusal. */ @@ -259,7 +272,9 @@ export function serverConfigWarnings(config: McplServerConfig): string[] { if ((url.protocol !== 'http:' && url.protocol !== 'ws:') || isLoopbackHost(url.hostname)) return []; const carried: string[] = []; if (config.token || config.accessProvider) carried.push('its credential'); - if (url.username || url.password) carried.push("its url's userinfo"); + // An http:// url's userinfo is refused (serverConfigProblems); a ws:// + // url sends it. + if (url.protocol === 'ws:' && (url.username || url.password)) carried.push("its url's userinfo"); if (url.pathname.split('/').some(credentialLikeSegment)) carried.push("a credential-like segment of its url's path"); const params = [...new Set([...url.searchParams.keys()].filter((name) => CREDENTIAL_PARAM.test(name)))]; if (params.length > 0) { diff --git a/src/mcpl/tool-result-normalize.ts b/src/mcpl/tool-result-normalize.ts index b38f20b59..daa5cf29f 100644 --- a/src/mcpl/tool-result-normalize.ts +++ b/src/mcpl/tool-result-normalize.ts @@ -30,6 +30,11 @@ */ import type { ToolResult } from '../types/index.js'; +/** What a result whose content is present but isn't a list of blocks reads + * as, on the model's path and a script's alike. */ +export const MALFORMED_CONTENT_NOTICE = + '[malformed result: its content is not a list of blocks; the tool may already have completed]'; + /** Saves a payload under a host-chosen name; resolves to its workspace path, * or null when there is nowhere writable to put it. */ export type SaveToolPayload = (fileName: string, bytes: Buffer, mimeType: string) => Promise; @@ -258,7 +263,7 @@ export async function normalizeStandardToolResult( if (malformed) { return { success: false, - error: `[malformed result: its content is not a list of blocks; the tool may already have completed]${text ? `\n${text}` : ''}`, + error: `${MALFORMED_CONTENT_NOTICE}${text ? `\n${text}` : ''}`, isError: true, ...structured, }; diff --git a/test/code-execution-structured-results.test.ts b/test/code-execution-structured-results.test.ts index 21343077d..4b75ee439 100644 --- a/test/code-execution-structured-results.test.ts +++ b/test/code-execution-structured-results.test.ts @@ -44,7 +44,7 @@ class UnconvertibleModule implements Module { async stop(): Promise {} async onProcess(): Promise { return {}; } getTools(): ToolDefinition[] { - return ['cyclic', 'bigint', 'cyclic_data'].map((name) => ({ name, description: name, inputSchema: { type: 'object' } })); + return ['cyclic', 'bigint', 'cyclic_data', 'cyclic_error'].map((name) => ({ name, description: name, inputSchema: { type: 'object' } })); } async handleToolCall(call: ToolCall): Promise { const name = call.name.includes('--') ? call.name.slice(call.name.lastIndexOf('--') + 2) : call.name; @@ -52,6 +52,7 @@ class UnconvertibleModule implements Module { cyclic.self = cyclic; if (name === 'cyclic') return { success: true, data: text('a loop'), structured: cyclic }; if (name === 'bigint') return { success: true, data: text('a big number'), structured: { n: 1n } }; + if (name === 'cyclic_error') return { success: false, isError: true, error: 'db timeout', data: text('failed'), structured: cyclic }; return { success: true, data: cyclic }; } } @@ -129,6 +130,9 @@ test("a script reads a structured result whole, the model's calls and history ke 'cyclic_data = await loop__cyclic_data({})', 'assert cyclic_data.startswith("Error: tool ") and "loop--cyclic_data" in cyclic_data and "circular" in cyclic_data, cyclic_data', 'assert all("The call itself completed." in e for e in [cyclic["error"], bigint["error"], cyclic_data]), "completed"', + // An error result that can't be converted keeps the tool's own error. + 'cyclic_error = json.loads(await loop__cyclic_error({}))', + 'assert cyclic_error["isError"] is True and cyclic_error["error"].endswith("The call itself completed, with this error: db timeout"), cyclic_error', 'print(r["structuredContent"]["runId"], r["structuredContent"]["state"], r["structuredContent"]["output"])', ].join('\n'); membrane.pushResponse(createMockResponse([ diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index 761d3a971..f95be6a35 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -84,6 +84,9 @@ test('errors and the status view show a url redacted: no userinfo, no query valu assert.equal(redactUrl('https://mcp.example/server/123e4567-e89b-12d3-a456-426614174000/mcp'), 'https://mcp.example/server/redacted/mcp'); assert.equal(redactUrl(`https://mcp.example/${'a'.repeat(32)}/mcp`), 'https://mcp.example/redacted/mcp'); assert.equal(redactUrl('https://mcp.example/v2/2026-07-28/organization-settings/mcp'), 'https://mcp.example/v2/2026-07-28/organization-settings/mcp'); + // A bare token rides as a valueless name; a short plain name stays. + assert.equal(redactUrl('https://h/mcp?sk-live-abcdef0123456789'), 'https://h/mcp?redacted=redacted'); + assert.equal(redactUrl('https://h/mcp?verbose'), 'https://h/mcp?verbose=redacted'); assert.equal(redactUrl('not a url ?token=abc'), '(an invalid url, not shown)'); assert.equal(redactUrl(undefined), '(an invalid url, not shown)'); // The configuration errors that name a url give it redacted, or not at all. @@ -205,14 +208,14 @@ test('a credential sent in cleartext to a host that is not loopback is warned ab test('a secret the url carries itself is warned about too, and the warning never quotes it', () => { const warned = (url: string, extra: Partial = {}) => serverConfigWarnings(cfg({ url, ...extra })); - assert.match(warned('http://user:pw@10.0.0.5/mcp')[0]!, /its url's userinfo goes to 10\.0\.0\.5 unencrypted \(http:\/\/\).*read it;/); + assert.match(warned('ws://user:pw@10.0.0.5/mcpl')[0]!, /its url's userinfo goes to 10\.0\.0\.5 unencrypted \(ws:\/\/\).*read it;/); assert.match(warned('ws://mcp.lan/mcpl?api_key=k1')[0]!, /its url's query parameter "api_key" goes to mcp\.lan unencrypted.*use wss:\/\//); for (const name of ['token', 'Access_Token', 'apikey', 'client_secret', 'password', 'auth', 'X-Amz-Signature']) { assert.equal(warned(`http://10.0.0.5/mcp?${name}=v`).length, 1, name); } - const both = warned('http://u:p@10.0.0.5/mcp?token=a&sig_key=b®ion=eu', { token: 't' })[0]!; + const both = warned('ws://u:p@10.0.0.5/mcpl?token=a&sig_key=b®ion=eu', { token: 't' })[0]!; assert.match(both, /its credential, its url's userinfo and its url's query parameters "token", "sig_key" go to 10\.0\.0\.5 unencrypted.*read them;/); - for (const text of [both, ...warned('http://user:pw@10.0.0.5/mcp?token=sekret')]) assert.ok(!/sekret|pw|u:p/.test(text), text); + for (const text of [both, ...warned('ws://user:pw@10.0.0.5/mcpl?token=sekret')]) assert.ok(!/sekret|pw|u:p/.test(text), text); const path = warned('http://10.0.0.5/api/mcp/s/Zt4kQ9xW2vLm8pRs7YbN/mcp')[0]!; assert.match(path, /a credential-like segment of its url's path goes to 10\.0\.0\.5 unencrypted.*read it;/); assert.ok(!path.includes('Zt4kQ9xW2vLm8pRs7YbN'), path); @@ -249,6 +252,19 @@ test('an access provider that throws is logged by its error class and length, no assert.equal(describeCredentialError(undefined), 'a thrown undefined'); }); +test("an http(s) url with userinfo is refused, since the HTTP client can't send it and its error would quote it", () => { + for (const url of ['http://user:S3CRET@127.0.0.1:9/mcp', 'http://user:S3CRET@10.0.0.5/mcp', 'https://user:S3CRET@mcp.example/mcp', 'http://justauser@127.0.0.1:9/mcp']) { + const problems = serverConfigProblems(cfg({ url })); + assert.deepEqual(problems, [ + 'MCP server "s": an http(s):// url can\'t carry userinfo (user:password@), since the HTTP client refuses such a url; give the credential as "token" or "accessProvider"', + ], url); + // The modern engine checks at create(), so nothing is dialed with it. + assert.throws(() => ModernMcpConnection.create(cfg({ id: 's', url })), (error: Error) => error.message === problems[0]); + assert.deepEqual(serverConfigWarnings(cfg({ url })), [], 'a refusal, not also a warning'); + } + assert.deepEqual(serverConfigProblems(cfg({ url: 'ws://user:pw@127.0.0.1:9/mcpl' })), [], 'a WebSocket url sends its userinfo'); +}); + test('both engines admit a configuration through checkServerConfig, which logs its warnings', () => { const lines: string[] = []; const realWarn = console.warn; diff --git a/test/script-structured-references.test.ts b/test/script-structured-references.test.ts index 55a6b8f30..232e133a3 100644 --- a/test/script-structured-references.test.ts +++ b/test/script-structured-references.test.ts @@ -65,8 +65,8 @@ interface Harness { answer: (result: Answer) => void; /** A script's call to a tool of the MCPL peer `files`, or of the MCP-only peer `plain`. */ script: (tool: string) => Promise; - /** A call the model makes to a tool of `files`, through the same dispatch. */ - modelCall: (tool: string) => Promise; + /** A call the model makes to a tool of `files` (or of `plain`), through the same dispatch. */ + modelCall: (tool: string, server?: 'files' | 'plain') => Promise; /** The URIs the fetcher was asked for, saved records aside. */ fetched: string[]; workspace: WorkspaceModule; @@ -111,7 +111,7 @@ async function withPeers(fn: (h: Harness) => Promise, opts: { eager?: bool return { ok: true, path: SAVED }; }, }); - const modelCall = (tool: string) => new Promise((resolve) => { + const modelCall = (tool: string, server: 'files' | 'plain' = 'files') => new Promise((resolve) => { const id = `model-${randomUUID()}`; const push = internals.pushEvent; internals.pushEvent = (event) => { @@ -119,7 +119,7 @@ async function withPeers(fn: (h: Harness) => Promise, opts: { eager?: bool internals.pushEvent = push; resolve(event.result!); }; - internals.dispatchMcplToolCall('agent', { id, name: `mcpl--files--${tool}`, input: {} }, 'files', 'mcpl--files'); + internals.dispatchMcplToolCall('agent', { id, name: `mcpl--${server}--${tool}`, input: {} }, server, `mcpl--${server}`); }); framework.start(); try { @@ -176,6 +176,22 @@ test("an MCP-only peer's structured result reaches a script as the server sent i }); }); +test("content that isn't a list of blocks reaches a script as the model reads it, for each kind of peer", async () => { + await withPeers(async ({ answer, script, modelCall }) => { + answer({ content: 'not a list' as unknown as unknown[], structuredContent: { a: 1 } }); + const notice = '[malformed result: its content is not a list of blocks; the tool may already have completed]'; + // A standard peer's reading calls it a malformed result, on both paths. + const plainModel = await modelCall('attach', 'plain'); + assert.equal(plainModel.isError, true); + assert.ok(plainModel.error?.startsWith(notice), plainModel.error); + assert.deepEqual(JSON.parse(await script('mcpl--plain--attach')), { content: [], structuredContent: { a: 1 }, isError: true, error: notice }); + // An MCPL peer's reading has no content and reads the structured view, on both paths. + const filesModel = await modelCall('attach', 'files'); + assert.equal(filesModel.isError, false); + assert.deepEqual(JSON.parse(await script('mcpl--files--attach')), { content: [], structuredContent: { a: 1 }, isError: false }); + }); +}); + test('without structured content, an MCPL peer\'s result reaches a script as stubs naming the same reference id and saved path', async () => { await withPeers(async ({ answer, script }) => { // Only blocks the text lane recognizes: one it doesn't sends the whole From 75391e7256b87927a7fd3d50dfa2e9cf3c7dddf0 Mon Sep 17 00:00:00 2001 From: Basil-1007 Date: Mon, 12 Oct 2026 22:49:02 -0400 Subject: [PATCH 26/94] test(mcp): an auth wall on a later round of a call is a no-response, driven over HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ObservedTransport.observe marks a call continued when an answer isn't a complete result, and requestFailure reads that mark to classify a 401 or 403 on a later round as no-response: the server took the first round and may have acted on it, so a blind retry could run the call twice. The only test of that rule built `{ continued: true }` by hand and called requestFailure, so nothing drove observe: with its `continued` line deleted, every modern-engine suite stayed green, and a 401 on round 2 was reported as error-response, "refused tools/call: HTTP 401". The HTTP test server takes an optional hand-answer hook, for a JSON-RPC request the SDK server can't be made to answer as a case needs. The new test answers tools/call for `cont`, which the SDK server doesn't list, with a state-only input_required for the first round and 401 for every later one. It checks the verdict (no-response, "asked for another round … refused a later one: HTTP 401"), and that the later rounds carried the first round's state, so the refusal really met a later round. Red check: with observe's `continued` line deleted, the test fails on the outcome (error-response). --- test/mcp-modern-http.test.ts | 49 +++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/test/mcp-modern-http.test.ts b/test/mcp-modern-http.test.ts index 235bccb72..a0a2ea6cb 100644 --- a/test/mcp-modern-http.test.ts +++ b/test/mcp-modern-http.test.ts @@ -6,11 +6,12 @@ * - credentials: a cached bearer from `accessProvider`, refreshed once on 401; * - the required headers reach the server; * - a deadline aborts the request stream, which the server sees as cancellation; - * - an auth wall is an `error-response` carrying the HTTP status. + * - an auth wall is an `error-response` carrying the HTTP status, unless an + * earlier round of the call was answered, when it is a `no-response`. */ import { test, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { createServer as createHttpServer, type Server } from 'node:http'; +import { createServer as createHttpServer, type Server, type ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; import { join, dirname } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -24,7 +25,11 @@ const here = dirname(fileURLToPath(import.meta.url)); interface Seen { method: string; auth: string | null; version: string | null; mcpMethod: string | null; mcpName: string | null } -async function startServer(accepted: (token: string | null) => boolean): Promise<{ url: string; seen: Seen[]; events: string[]; server: Server }> { +/** A JSON-RPC request a test answers itself, at the HTTP layer, where an SDK + * server can't be made to answer as the case needs. True once answered. */ +type HandAnswer = (message: { method?: unknown; id?: unknown; params?: Record }, res: ServerResponse) => boolean; + +async function startServer(accepted: (token: string | null) => boolean, answer?: HandAnswer): Promise<{ url: string; seen: Seen[]; events: string[]; server: Server }> { const { createServer } = await import(pathToFileURL(join(here, 'fixtures', 'modern-tools.mjs')).href) as { createServer: (opts: { log: (line: string) => void }) => unknown; }; @@ -46,6 +51,11 @@ async function startServer(accepted: (token: string | null) => boolean): Promise } const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); + if (answer && req.method === 'POST') { + let message: unknown; + try { message = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { message = undefined; } + if (message !== null && typeof message === 'object' && !Array.isArray(message) && answer(message as never, res)) return; + } const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (typeof value === 'string') headers.set(key, value); @@ -138,3 +148,36 @@ test('HTTP: an auth wall on a call is an error-response with the status in data' return true; }); }); + +test('HTTP: an auth wall on a later round of a call is a no-response, since the first round may have acted', async () => { + // The SDK server can't be made to ask for another round, so `cont`, which + // it doesn't list, is answered here: a state-only input_required for the + // first round, then 401 for every later one. + const states: unknown[] = []; + const { url, server } = await startServer(() => true, (message, res) => { + if (message.method !== 'tools/call' || message.params?.name !== 'cont') return false; + const state = message.params.requestState; + states.push(state); + if (state === undefined) { + res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ + jsonrpc: '2.0', id: message.id, result: { resultType: 'input_required', requestState: 'state-1' }, + })); + } else { + res.writeHead(401, { 'content-type': 'text/plain' }).end('unauthorized'); + } + return true; + }); + cleanups.push(() => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); })); + const connection = await ModernMcpConnection.connect({ id: 'remote', url, token: 't' }); + cleanups.push(() => connection.close()); + await assert.rejects(connection.callTool('cont', {}), (err: unknown) => { + assert.ok(err instanceof McplRequestError); + // Not the error-response a refused first round gets: the server took + // the first round, and may have acted on it. + assert.equal(err.outcome, 'no-response'); + assert.match(err.message, /asked for another round \(input_required\), and the server refused a later one: HTTP 401/); + return true; + }); + assert.equal(states[0], undefined, 'the first round carried no state'); + assert.ok(states.length >= 2 && states.slice(1).every((s) => s === 'state-1'), `each later round carried it: ${JSON.stringify(states)}`); +}); From 82ebcfd119b78e4fe1bf8ff207415dab75d14c53 Mon Sep 17 00:00:00 2001 From: Basil-1007 Date: Mon, 12 Oct 2026 22:49:02 -0400 Subject: [PATCH 27/94] fix(mcp): a restart refuses a colliding tool prefix before tearing the server down restartMcplServer checks the new configuration before it disconnects, so a configuration the connect would refuse leaves the running server as it was. The tool-prefix collision checks weren't part of that: they ran in connectMcplServer, after the disconnect. Restarting `first` with `toolPrefix: 'mcpl--second'` while `second` was live rejected with "collides with server second", and `first` and its tools were gone. The two collision rules (a module of that name, another server holding the prefix) are now one method, mcplPrefixCollision, which connectMcplServer and restartMcplServer both call. A restart calls it before its disconnect. The server's own prefix is free to keep, as before, and so is that of the server a restart is about to disconnect, so a restart under another id isn't refused for the prefix it frees. Test: with `second` connected, restarting `first` onto `second`'s prefix is refused naming the collision, and `first` is still connected with its tools listed. Red check: without the restart's pre-check, `first` is gone after the refusal and the test fails. --- src/framework.ts | 38 +++++++++++++++++------------ test/mcpl-runtime-lifecycle.test.ts | 13 ++++++++++ 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 5a4da8c4a..64eabb950 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -14465,20 +14465,8 @@ export class AgentFramework { await this.initializeMcpl([], this.mcplInferenceRoutingConfig ?? undefined); } - // Same collision rules create() enforces at startup: the tool prefix must - // not shadow a module name or another server's prefix. - const prefix = config.toolPrefix ?? `mcpl--${config.id}`; - if (this.moduleRegistry.getAllModules().some(m => m.name === prefix)) { - throw new Error( - `MCPL server "${config.id}" toolPrefix "${prefix}" collides with module "${prefix}"`, - ); - } - const prefixOwner = this.mcplPrefixMap.get(prefix); - if (prefixOwner && prefixOwner !== config.id) { - throw new Error( - `MCPL server "${config.id}" toolPrefix "${prefix}" collides with server "${prefixOwner}"`, - ); - } + const collision = this.mcplPrefixCollision(config); + if (collision) throw new Error(collision); const oldToolNames = new Set(this.mcplTools.map(t => t.name)); await this.connectMcplServerInternal(config); @@ -14571,12 +14559,32 @@ export class AgentFramework { throw new Error(`MCPL server "${id}" is not configured`); } // A configuration the connect would refuse is refused here, before the - // running server is torn down for it. + // running server is torn down for it: its binding and settings, and a + // tool prefix another server or a module already holds. AgentFramework.usableConfigFamily(config); + const collision = this.mcplPrefixCollision(config, id); + if (collision) throw new Error(collision); await this.disconnectMcplServer(id); await this.connectMcplServer(config); } + /** The collision rules create() enforces at startup, as a runtime connect + * (or a restart, before its disconnect) meets them: the tool prefix must + * not shadow a module name or another server's prefix. The server's own + * prefix is free to keep, and so is that of the server a restart is + * about to disconnect (`leaving`). */ + private mcplPrefixCollision(config: import('./mcpl/types.js').McplServerConfig, leaving?: string): string | null { + const prefix = config.toolPrefix ?? `mcpl--${config.id}`; + if (this.moduleRegistry.getAllModules().some(m => m.name === prefix)) { + return `MCPL server "${config.id}" toolPrefix "${prefix}" collides with module "${prefix}"`; + } + const prefixOwner = this.mcplPrefixMap.get(prefix); + if (prefixOwner && prefixOwner !== config.id && prefixOwner !== leaving) { + return `MCPL server "${config.id}" toolPrefix "${prefix}" collides with server "${prefixOwner}"`; + } + return null; + } + /** The protocol family a configuration resolves to, or a refusal naming * every problem with it, before anything is spawned, dialed or torn down. */ private static usableConfigFamily(config: import('./mcpl/types.js').McplServerConfig): 'legacy' | 'modern' { diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index 56c10c52b..195c8bbd6 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -116,6 +116,19 @@ test('restartMcplServer accepts an updated config (new toolPrefix)', async () => }); }); +test('restartMcplServer refuses a toolPrefix another server holds before tearing anything down', async () => { + await withFramework(true, async (framework) => { + await framework.connectMcplServer(serverConfig('second') as never); + await assert.rejects( + framework.restartMcplServer('first', serverConfig('first', { toolPrefix: 'mcpl--second' }) as never), + /collides with server "second"/, + ); + const first = framework.listMcplServers().find(s => s.id === 'first'); + assert.equal(first?.connected, true, 'the server is still connected'); + assert.ok(framework.getAllTools().some(t => t.name === 'mcpl--first--echo'), 'and its tools still listed'); + }); +}); + test('connectMcplServer rejects a duplicate id and a colliding prefix', async () => { await withFramework(true, async (framework) => { await assert.rejects( From 1b815b92f7208b1d9d6ae7c405fb6873152298c1 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 23:04:46 -0400 Subject: [PATCH 28/94] fix(mcp): one rule for a query name that may itself be a credential; tests for absent content and a bare throw From the review of the previous round: - A query parameter name that may itself be a credential (one shaped like a token, or one with no value whose name says it carries a credential, since a bare token rides as a name alone) is replaced by redactUrl and counted, never quoted, by the cleartext warning. Before, the two used different rules: redactUrl went by shape alone, so a short bare token with a keyword name survived, and the warning went by keyword alone and quoted the name it flagged, so a bare token shaped like one crossed unencrypted with no warning. A name with a value is a label and stays. - The status view's url doc says such a name is replaced too. - Changelog: the warning's fragment names the ws:// userinfo, the query name rule and the http(s) userinfo refusal; the standard results fragment says a standard peer's non-list content now reaches a script as the malformed result. Tests: redactUrl with a short keyword-named bare token, a labelled token=abc and a plain valueless name; the warning for a bare token alone (its exact text) and for a labelled token beside two hidden names, none of them quoted; a structured result with no content field reaches a script as no content, not malformed, on both peers; a structured result whose toJSON throws a value with no prototype reaches a script as an error naming "a value with no text form". Each fails with its piece reverted. --- .../cleartext-mcp-credential-warning.added.md | 10 +++-- .../mcp-standard-tool-results.changed.md | 5 ++- src/framework.ts | 3 +- src/mcpl/protocol-family.ts | 40 +++++++++++++------ .../code-execution-structured-results.test.ts | 6 ++- test/mcp-protocol-family.test.ts | 13 +++++- test/script-structured-references.test.ts | 6 +++ 7 files changed, 64 insertions(+), 19 deletions(-) diff --git a/changelog.d/cleartext-mcp-credential-warning.added.md b/changelog.d/cleartext-mcp-credential-warning.added.md index dadbe0514..6b801f0c2 100644 --- a/changelog.d/cleartext-mcp-credential-warning.added.md +++ b/changelog.d/cleartext-mcp-credential-warning.added.md @@ -1,9 +1,13 @@ - Connecting to an MCP server warns once when a credential would cross the network unencrypted, to an `http://` or `ws://` url whose host isn't this machine: its `token` or `accessProvider`, or a secret the url carries - itself (userinfo, a query parameter whose name says credential: `token`, - `key`, `secret`, `password`, `auth` or `signature`, or a path segment - that looks like one, as the redacted url judges it). Loopback hosts are + itself (a `ws://` url's userinfo, a query parameter whose name says + credential: `token`, `key`, `secret`, `password`, `auth` or `signature`, + a query parameter name that may itself be one, or a path segment that + looks like one, as the redacted url judges it). A name that may itself be + a credential is counted in the warning, never quoted. An `http(s)://` url + with userinfo is refused instead, since the HTTP client can't send it and + its error would quote it. Loopback hosts are exempt, and so are the unspecified addresses `0.0.0.0` and `[::]`, which reach this host too. It warns rather than refuses, since a private network or a TLS-terminating proxy can make that deliberate. diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index 9cd732f2e..e4dfb7fd2 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -29,7 +29,10 @@ server's result itself, as one JSON object: `{ content, structuredContent, isError, error? }`. From a server that isn't an MCPL peer, `content` is the blocks as sent, so images and other payloads survive. `structuredContent` - is the tool's own JSON, as sent. + is the tool's own JSON, as sent. Content that is present but isn't a + list of blocks, from such a server, is the malformed result the model + reads (`isError`, with the malformed notice as `error`), its structured + content kept, where it was an empty success. - An MCPL peer's blocks in a script's `content` get the treatment every lane the model reads gives them, since what a script prints comes back to the model. An RFC-005 reference arrives without its URI, as the host's diff --git a/src/framework.ts b/src/framework.ts index 64eabb950..e778986d7 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -14660,7 +14660,8 @@ export class AgentFramework { command?: string; /** The url as {@link redactUrl} shows it, since a credential may ride in * it: no userinfo, each query parameter's value replaced, and any path - * segment that looks like a credential replaced. */ + * segment or query parameter name that looks like a credential + * replaced. */ url?: string; }> { const result: Array<{ diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index ed33f98f0..238adc106 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -71,12 +71,26 @@ function credentialLikeSegment(segment: string): boolean { return (segment.length >= 16 && /[0-9]/.test(segment)) || segment.length >= 32; } +/** Query parameter names that say they carry a credential. */ +const CREDENTIAL_PARAM = /token|key|secret|password|auth|signature/i; + +/** + * A query parameter name that may itself be a credential, which + * {@link redactUrl} replaces and a warning never quotes: one shaped like a + * token, or one with no value whose name says it carries a credential, since + * a bare token rides as a name alone (`?`). + */ +function credentialLikeQueryName(name: string, value: string): boolean { + return credentialLikeSegment(name) || (value === '' && CREDENTIAL_PARAM.test(name)); +} + /** * A url as text a person or a log reads it: its scheme, host and port, and * its path with any credential-like segment replaced. The userinfo is * removed and each query parameter's value replaced, since a credential - * rides there too, and so is a parameter name that looks like one. A url - * that doesn't parse can't be redacted, so it isn't shown at all. + * rides there too, and so is a parameter name that may itself be one + * ({@link credentialLikeQueryName}). A url that doesn't parse can't be + * redacted, so it isn't shown at all. */ export function redactUrl(url: string | undefined): string { let parsed: URL; @@ -89,9 +103,7 @@ export function redactUrl(url: string | undefined): string { parsed.password = ''; parsed.hash = ''; parsed.pathname = parsed.pathname.split('/').map((segment) => (credentialLikeSegment(segment) ? 'redacted' : segment)).join('/'); - // A bare token rides as a name with no value (`?`), so a name that - // looks like a credential is replaced too. - parsed.search = new URLSearchParams([...parsed.searchParams].map(([name]): [string, string] => [credentialLikeSegment(name) ? 'redacted' : name, 'redacted'])).toString(); + parsed.search = new URLSearchParams([...parsed.searchParams].map(([name, value]): [string, string] => [credentialLikeQueryName(name, value) ? 'redacted' : name, 'redacted'])).toString(); return parsed.toString(); } @@ -246,17 +258,16 @@ function isLoopbackHost(hostname: string): boolean { /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(host) || host === '0.0.0.0' || host === '[::]'; } -/** Query parameter names that say they carry a credential. */ -const CREDENTIAL_PARAM = /token|key|secret|password|auth|signature/i; - /** * What is usable in a server configuration but worth an operator's attention, * as messages; empty when there is nothing. A credential for an `http://` or * `ws://` url whose host isn't loopback crosses the network in cleartext: * `token` or `accessProvider` (in the `Authorization` header, or the * WebSocket `?token=` query), and a secret the url carries itself (a ws:// - * url's userinfo, a query parameter whose name says it's a credential, or a - * path segment that looks like one, as {@link redactUrl} judges it). That can be + * url's userinfo, a query parameter whose name says it's a credential or may + * itself be one, or a path segment that looks like one, as {@link redactUrl} + * judges it). A name that may itself be a credential is counted, never + * quoted. That can be * deliberate, on a private network or behind a TLS-terminating proxy, so it * is a warning and never a refusal. */ @@ -276,13 +287,18 @@ export function serverConfigWarnings(config: McplServerConfig): string[] { // url sends it. if (url.protocol === 'ws:' && (url.username || url.password)) carried.push("its url's userinfo"); if (url.pathname.split('/').some(credentialLikeSegment)) carried.push("a credential-like segment of its url's path"); - const params = [...new Set([...url.searchParams.keys()].filter((name) => CREDENTIAL_PARAM.test(name)))]; + const entries = [...url.searchParams]; + const hidden = entries.filter(([name, value]) => credentialLikeQueryName(name, value)).length; + const params = [...new Set(entries.filter(([name, value]) => !credentialLikeQueryName(name, value) && CREDENTIAL_PARAM.test(name)).map(([name]) => name))]; if (params.length > 0) { carried.push(`its url's query ${params.length === 1 ? 'parameter' : 'parameters'} ${params.map((name) => JSON.stringify(name)).join(', ')}`); } + if (hidden > 0) { + carried.push(hidden === 1 ? 'a query parameter whose name looks like a credential' : `${hidden} query parameters whose names look like credentials`); + } if (carried.length === 0) return []; const what = carried.length === 1 ? carried[0] : `${carried.slice(0, -1).join(', ')} and ${carried[carried.length - 1]}`; - const one = carried.length === 1 && params.length <= 1; + const one = carried.length === 1 && params.length <= 1 && hidden <= 1; const secure = url.protocol === 'http:' ? 'https' : 'wss'; return [ `MCP server "${config.id}": ${what} ${one ? 'goes' : 'go'} to ${url.host} unencrypted (${url.protocol}//), ` + diff --git a/test/code-execution-structured-results.test.ts b/test/code-execution-structured-results.test.ts index 4b75ee439..2a8e1624f 100644 --- a/test/code-execution-structured-results.test.ts +++ b/test/code-execution-structured-results.test.ts @@ -44,7 +44,7 @@ class UnconvertibleModule implements Module { async stop(): Promise {} async onProcess(): Promise { return {}; } getTools(): ToolDefinition[] { - return ['cyclic', 'bigint', 'cyclic_data', 'cyclic_error'].map((name) => ({ name, description: name, inputSchema: { type: 'object' } })); + return ['cyclic', 'bigint', 'cyclic_data', 'cyclic_error', 'bare_throw'].map((name) => ({ name, description: name, inputSchema: { type: 'object' } })); } async handleToolCall(call: ToolCall): Promise { const name = call.name.includes('--') ? call.name.slice(call.name.lastIndexOf('--') + 2) : call.name; @@ -53,6 +53,8 @@ class UnconvertibleModule implements Module { if (name === 'cyclic') return { success: true, data: text('a loop'), structured: cyclic }; if (name === 'bigint') return { success: true, data: text('a big number'), structured: { n: 1n } }; if (name === 'cyclic_error') return { success: false, isError: true, error: 'db timeout', data: text('failed'), structured: cyclic }; + // A toJSON that throws a value with no prototype, so String() can't describe it either. + if (name === 'bare_throw') return { success: true, data: text('bare'), structured: { toJSON() { throw Object.create(null); } } }; return { success: true, data: cyclic }; } } @@ -133,6 +135,8 @@ test("a script reads a structured result whole, the model's calls and history ke // An error result that can't be converted keeps the tool's own error. 'cyclic_error = json.loads(await loop__cyclic_error({}))', 'assert cyclic_error["isError"] is True and cyclic_error["error"].endswith("The call itself completed, with this error: db timeout"), cyclic_error', + 'bare_throw = json.loads(await loop__bare_throw({}))', + 'assert bare_throw["isError"] is True and "(a value with no text form)" in bare_throw["error"], bare_throw', 'print(r["structuredContent"]["runId"], r["structuredContent"]["state"], r["structuredContent"]["output"])', ].join('\n'); membrane.pushResponse(createMockResponse([ diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index f95be6a35..382eb148d 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -84,8 +84,12 @@ test('errors and the status view show a url redacted: no userinfo, no query valu assert.equal(redactUrl('https://mcp.example/server/123e4567-e89b-12d3-a456-426614174000/mcp'), 'https://mcp.example/server/redacted/mcp'); assert.equal(redactUrl(`https://mcp.example/${'a'.repeat(32)}/mcp`), 'https://mcp.example/redacted/mcp'); assert.equal(redactUrl('https://mcp.example/v2/2026-07-28/organization-settings/mcp'), 'https://mcp.example/v2/2026-07-28/organization-settings/mcp'); - // A bare token rides as a valueless name; a short plain name stays. + // A bare token rides as a valueless name: one shaped like a token, or one + // whose name says it's a credential, goes. A name with a value is a label + // and stays, and so does a short plain name. assert.equal(redactUrl('https://h/mcp?sk-live-abcdef0123456789'), 'https://h/mcp?redacted=redacted'); + assert.equal(redactUrl('https://h/mcp?apikey_x9'), 'https://h/mcp?redacted=redacted'); + assert.equal(redactUrl('https://h/mcp?token=abc'), 'https://h/mcp?token=redacted'); assert.equal(redactUrl('https://h/mcp?verbose'), 'https://h/mcp?verbose=redacted'); assert.equal(redactUrl('not a url ?token=abc'), '(an invalid url, not shown)'); assert.equal(redactUrl(undefined), '(an invalid url, not shown)'); @@ -216,6 +220,13 @@ test('a secret the url carries itself is warned about too, and the warning never const both = warned('ws://u:p@10.0.0.5/mcpl?token=a&sig_key=b®ion=eu', { token: 't' })[0]!; assert.match(both, /its credential, its url's userinfo and its url's query parameters "token", "sig_key" go to 10\.0\.0\.5 unencrypted.*read them;/); for (const text of [both, ...warned('ws://user:pw@10.0.0.5/mcpl?token=sekret')]) assert.ok(!/sekret|pw|u:p/.test(text), text); + // A name that may itself be a credential is warned about and counted, never quoted. + assert.deepEqual(warned('ws://mcp.lan/mcpl?sk-live-abcdef0123456789'), [ + 'MCP server "s": a query parameter whose name looks like a credential goes to mcp.lan unencrypted (ws://), where anything on the network path can read it; use wss:// unless that network is trusted', + ]); + const named = warned('ws://mcp.lan/mcpl?token=a&apikey_x9&x7y8z9w0v1u2t3s4r5')[0]!; + assert.match(named, /its url's query parameter "token" and 2 query parameters whose names look like credentials go to mcp\.lan unencrypted.*read them;/); + assert.ok(!/apikey_x9|x7y8z9/.test(named), named); const path = warned('http://10.0.0.5/api/mcp/s/Zt4kQ9xW2vLm8pRs7YbN/mcp')[0]!; assert.match(path, /a credential-like segment of its url's path goes to 10\.0\.0\.5 unencrypted.*read it;/); assert.ok(!path.includes('Zt4kQ9xW2vLm8pRs7YbN'), path); diff --git a/test/script-structured-references.test.ts b/test/script-structured-references.test.ts index 232e133a3..8942a3854 100644 --- a/test/script-structured-references.test.ts +++ b/test/script-structured-references.test.ts @@ -189,6 +189,12 @@ test("content that isn't a list of blocks reaches a script as the model reads it const filesModel = await modelCall('attach', 'files'); assert.equal(filesModel.isError, false); assert.deepEqual(JSON.parse(await script('mcpl--files--attach')), { content: [], structuredContent: { a: 1 }, isError: false }); + // Content left out isn't malformed: no content, on either peer. + answer({ structuredContent: { a: 1 } } as unknown as Answer); + assert.equal((await modelCall('attach', 'plain')).isError, false); + for (const tool of ['mcpl--plain--attach', 'mcpl--files--attach']) { + assert.deepEqual(JSON.parse(await script(tool)), { content: [], structuredContent: { a: 1 }, isError: false }, tool); + } }); }); From d69add31acc11d012a6917d777d4fb66c66f6fba Mon Sep 17 00:00:00 2001 From: Basil-1007 Date: Mon, 12 Oct 2026 23:05:48 -0400 Subject: [PATCH 29/94] fix(mcp): a restart refuses a taken id or a stopping framework before tearing the server down restartMcplServer's pre-check covered the configuration's binding and settings and the tool-prefix collisions, but not the two refusals the connect makes from the framework's own state. So two restarts the connect refuses still tore the running server down first: - a new configuration under another live server's id (restartMcplServer('first', )): the prefix belongs to `second`, which is the config's own id, so the collision check passed, `first` was disconnected and its configuration deleted, and the connect then refused "already registered"; - a restart once stop() has begun: `first` was disconnected, and the connect refused "the framework is stopping or stopped". The connect's id refusal (a connect or disconnect in flight, or a registered connection) is now one method, mcpServerIdTaken, which connectMcplServerInternal and restartMcplServer both call. A restart asks it for a new id only: the id it restarts is meant to be registered, and the disconnect frees it. A restart once stop() has begun is refused as "was not restarted: the framework is stopping or stopped", before it looks anything up. Not covered: a connect that takes the prefix or the new id while the restart's disconnect awaits its teardown is still refused only after it. Holding them across the disconnect would need a hold on prefixes that ids don't have. Tests (mcpl-runtime-lifecycle): - one test with `second` connected and a module named `notes` added: restarting `first` onto `second`'s prefix, onto `notes`, and under `second`'s id are each refused naming why, and after each both servers are still connected with their tools listed; - a restart of `first` under a new id that keeps `mcpl--first` succeeds: `first` is replaced, and the prefix it freed serves the new id; - a restart called just after stop() is refused with the new text, and disconnectMcplServer is never called. Red checks, each undone on its own: without the id check, the first test finds `first` gone after the third refusal; without the module branch of the collision rule, the restart onto `notes` succeeds; without the exemption for the prefix the restart frees, the new-id restart is refused; without the admission check, the stopping restart disconnects `first` and fails with the connect's text. --- src/framework.ts | 45 ++++++++++++++-------- test/mcpl-runtime-lifecycle.test.ts | 58 +++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 19 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index e778986d7..27aa2c037 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -13967,18 +13967,8 @@ export class AgentFramework { // protocol on a URL, a modern deadline of 0, MCPL policy on a modern // server. Every valid legacy shape resolves exactly as before. const family = AgentFramework.usableConfigFamily(config); - // One owner per id, across both engines (holdMcpServerId): a connect or - // disconnect in flight holds it, and so does a registered connection, - // including one kept because its launch couldn't be confirmed exited. - const op = this.mcpServerOps?.get(config.id); - if (op?.kind === 'disconnect') { - throw new Error(`MCP server "${config.id}" is still being disconnected; connect it once that has finished`); - } - const registered = this.modernServers().get(config.id) ?? this.mcplServerRegistry.getServer(config.id); - if (op || registered) { - const idle = !op && registered && !registered.isConnected && !registered.willReconnect; - throw new Error(`MCPL server "${config.id}" is already registered${idle ? ' (not connected and not reconnecting: disconnect it first)' : ''}`); - } + const taken = this.mcpServerIdTaken(config.id); + if (taken) throw new Error(taken); const hold = this.holdMcpServerId(config.id, 'connect'); try { await this.connectHeldMcplServer(config, family, deferAwareness, hold.displaced); @@ -14554,20 +14544,45 @@ export class AgentFramework { id: string, newConfig?: import('./mcpl/types.js').McplServerConfig, ): Promise { + // What the connect would refuse is refused here, before the running + // server is torn down for it: a framework whose stop() has begun, the + // configuration's binding and settings, a new id another server holds, + // and a tool prefix another server or a module already holds. + if (this.mcpServerAdmissionClosed) { + throw new Error(`MCP server "${id}" was not restarted: the framework is stopping or stopped`); + } const config = newConfig ?? this.mcplServerConfigs.get(id); if (!config) { throw new Error(`MCPL server "${id}" is not configured`); } - // A configuration the connect would refuse is refused here, before the - // running server is torn down for it: its binding and settings, and a - // tool prefix another server or a module already holds. AgentFramework.usableConfigFamily(config); + if (config.id !== id) { + const taken = this.mcpServerIdTaken(config.id); + if (taken) throw new Error(taken); + } const collision = this.mcplPrefixCollision(config, id); if (collision) throw new Error(collision); await this.disconnectMcplServer(id); await this.connectMcplServer(config); } + /** Why a connect for this id is refused, or null when the id is free. One + * owner per id, across both engines (holdMcpServerId): a connect or + * disconnect in flight holds it, and so does a registered connection, + * including one kept because its launch couldn't be confirmed exited. */ + private mcpServerIdTaken(id: string): string | null { + const op = this.mcpServerOps?.get(id); + if (op?.kind === 'disconnect') { + return `MCP server "${id}" is still being disconnected; connect it once that has finished`; + } + const registered = this.modernServers().get(id) ?? this.mcplServerRegistry?.getServer(id); + if (op || registered) { + const idle = !op && registered && !registered.isConnected && !registered.willReconnect; + return `MCPL server "${id}" is already registered${idle ? ' (not connected and not reconnecting: disconnect it first)' : ''}`; + } + return null; + } + /** The collision rules create() enforces at startup, as a runtime connect * (or a restart, before its disconnect) meets them: the tool prefix must * not shadow a module name or another server's prefix. The server's own diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index 195c8bbd6..4e5befdb5 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -14,8 +14,19 @@ import { WebSocketServer } from 'ws'; import type { AddressInfo } from 'node:net'; import { AgentFramework } from '../src/index.js'; +import type { EventResponse, Module, ModuleContext, ToolDefinition, ToolResult } from '../src/index.js'; import { MockMembrane } from './helpers/mock-membrane.js'; +/** A module with no tools: only a name, for a server's tool prefix to collide with. */ +class NamedModule implements Module { + constructor(readonly name: string) {} + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + async onProcess(): Promise { return {}; } + getTools(): ToolDefinition[] { return []; } + async handleToolCall(): Promise { return { success: true }; } +} + // A tiny stdio MCPL server (same shape as mcpl-ws-transport.test.ts): reads // NDJSON on stdin, answers initialize / tools/list / tools/call on stdout. const STDIO_SERVER = ` @@ -116,16 +127,55 @@ test('restartMcplServer accepts an updated config (new toolPrefix)', async () => }); }); -test('restartMcplServer refuses a toolPrefix another server holds before tearing anything down', async () => { +test("restartMcplServer refuses what the connect would refuse before tearing anything down: another server's prefix or id, or a module's name", async () => { await withFramework(true, async (framework) => { await framework.connectMcplServer(serverConfig('second') as never); + await framework.addModule(new NamedModule('notes')); + const untouched = () => { + const live = framework.listMcplServers().filter(s => s.connected).map(s => s.id).sort(); + assert.deepEqual(live, ['first', 'second'], 'both servers still connected'); + const tools = framework.getAllTools().map(t => t.name); + assert.ok(tools.includes('mcpl--first--echo') && tools.includes('mcpl--second--echo'), 'and their tools still listed'); + }; await assert.rejects( framework.restartMcplServer('first', serverConfig('first', { toolPrefix: 'mcpl--second' }) as never), /collides with server "second"/, ); - const first = framework.listMcplServers().find(s => s.id === 'first'); - assert.equal(first?.connected, true, 'the server is still connected'); - assert.ok(framework.getAllTools().some(t => t.name === 'mcpl--first--echo'), 'and its tools still listed'); + untouched(); + await assert.rejects( + framework.restartMcplServer('first', serverConfig('first', { toolPrefix: 'notes' }) as never), + /collides with module "notes"/, + ); + untouched(); + // Under another live server's id, whose prefix is that server's own, so + // only the id is taken. + await assert.rejects( + framework.restartMcplServer('first', serverConfig('second') as never), + /MCPL server "second" is already registered/, + ); + untouched(); + }); +}); + +test('restartMcplServer under a new id may keep the tool prefix the restarted server frees', async () => { + await withFramework(true, async (framework) => { + await framework.restartMcplServer('first', serverConfig('renamed', { toolPrefix: 'mcpl--first' }) as never); + const servers = framework.listMcplServers(); + assert.deepEqual(servers.map(s => [s.id, s.connected]), [['renamed', true]], 'first was replaced'); + assert.ok(framework.getAllTools().some(t => t.name === 'mcpl--first--echo'), 'and the prefix it freed serves the new id'); + }); +}); + +test('restartMcplServer refuses once stop() has begun, before it disconnects anything', async (t) => { + await withFramework(true, async (framework) => { + const disconnect = t.mock.method(framework, 'disconnectMcplServer'); + const stopping = framework.stop(); + await assert.rejects( + framework.restartMcplServer('first'), + /MCP server "first" was not restarted: the framework is stopping or stopped/, + ); + await stopping; + assert.equal(disconnect.mock.callCount(), 0, 'the restart disconnected nothing'); }); }); From b0a2dafc0fce3439116f9e5eda345cd83725d715 Mon Sep 17 00:00:00 2001 From: Rosalind-2359 Date: Tue, 13 Oct 2026 00:40:37 -0400 Subject: [PATCH 30/94] fix(mcp): redact configured credentials from recorded server text A transport error can quote the server URL through an echoed HTTP request path or redirect target. Recording that error verbatim copied known URL credentials into connection traces, console errors and ops alerts. Tool transport failures could also copy the same text into a resident's history. redactServerText derives exact strings from the configured URL and static token using the URL redaction predicates. It covers credential-like path segments and query names, credential-shaped or credential-named query values, other nonempty query pairs, and WebSocket usernames and passwords. Encoded and decoded forms are included. Standalone strings need eight characters; shorter query values are removed with their name=value pair. Escaped alternatives match longest first in one replacement pass, so the replacement text is never rescanned. Cached access-provider tokens and secrets absent from the configuration remain outside this contract. Apply the helper where both framework engines record connection failures, errors and stderr, in startup and cleanup diagnostics, and in the engines' own error console lines. Modern requestFailure sanitizes transport text before returning it to tool dispatch. A JSON-RPC tool error remains the server's answer, and connection errors thrown directly to host callers remain unchanged. Document those limits and add a changelog fragment. Tests cover encoded/decoded forms, static tokens, WebSocket userinfo, short-value pairs, unrelated words, regex characters, longest-first matching and idempotence. Boundary tests cover both engines' recordings, legacy console failures, startup failures, HTTP 404 bodies, and a real tool 500 reaching framework dispatch. The connection 404 test replaces SDK negotiation with a tools/list send over the real HTTP transport: pin-mode discovery otherwise rewrites a 404 as a generic version-negotiation error. JSON-RPC errors and direct host connect errors retain their original text. Co-Authored-By: GPT-6.1 Sol --- README.md | 2 + .../redact-recorded-mcp-credentials.fixed.md | 1 + src/framework.ts | 37 ++-- src/mcpl/modern-connection.ts | 8 +- src/mcpl/protocol-family.ts | 59 ++++++ src/mcpl/server-connection.ts | 7 +- test/mcp-modern-http.test.ts | 55 ++++++ test/mcp-protocol-family.test.ts | 34 ++++ test/mcp-recorded-text.test.ts | 183 ++++++++++++++++++ 9 files changed, 363 insertions(+), 23 deletions(-) create mode 100644 changelog.d/redact-recorded-mcp-credentials.fixed.md create mode 100644 test/mcp-recorded-text.test.ts diff --git a/README.md b/README.md index 77ef73f74..27cff15bb 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,8 @@ supports, or its error text when it lists none, and when the list includes `2026-07-28` it says to configure the server with `protocol: 'modern'` (stdio) or reach it over http(s). +Server and transport diagnostics recorded by the framework remove known credentials from the configured URL and static `token`. This covers connection traces, console errors, stderr traces and ops alerts, plus transport failures returned by modern tool calls. Credential-like URL path segments and query names, credential-shaped or credential-named query values, and WebSocket userinfo are replaced in their encoded and decoded forms. Other non-empty query parameters are replaced as whole `name=value` strings. Standalone strings shorter than eight characters stay unchanged because they may be ordinary words; short query values are still removed with their parameter names. The cached token from an `accessProvider` and secrets the framework does not know remain outside this filtering. JSON-RPC tool answers and direct host connection errors pass through as sent. + #### Feature sets `enabledFeatureSets` and `disabledFeatureSets` select which of a server's diff --git a/changelog.d/redact-recorded-mcp-credentials.fixed.md b/changelog.d/redact-recorded-mcp-credentials.fixed.md new file mode 100644 index 000000000..85d447944 --- /dev/null +++ b/changelog.d/redact-recorded-mcp-credentials.fixed.md @@ -0,0 +1 @@ +- Server and transport diagnostics recorded by the framework redact known URL credentials and the configured static token, including encoded and decoded forms. An HTTP error that echoes a credential-bearing request path no longer copies the credential into connection traces, console errors, ops alerts or modern tool-call failures. Stderr traces use the same filtering. Short standalone strings remain unchanged; short query values are removed as whole `name=value` strings. Access-provider cached tokens and unknown secrets are outside this filtering, and JSON-RPC tool answers and direct host connection errors retain their pass-through contract. diff --git a/src/framework.ts b/src/framework.ts index 27aa2c037..3dbfbff0b 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -394,7 +394,7 @@ import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js'; import type { McplServerConnection } from './mcpl/server-connection.js'; import { McplProtocolVersionError, McplUnreapedLaunchError } from './mcpl/server-connection.js'; import { ModernMcpConnection } from './mcpl/modern-connection.js'; -import { redactUrl, resolveServerBinding, serverConfigProblems } from './mcpl/protocol-family.js'; +import { redactUrl, redactServerText, resolveServerBinding, serverConfigProblems } from './mcpl/protocol-family.js'; import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js'; import type { McplServerConfig, @@ -13479,7 +13479,8 @@ export class AgentFramework { } // Fail-open: log and continue with remaining servers const err = error instanceof Error ? error : new Error(String(error)); - console.error(`Failed to connect MCPL server "${config.id}":`, err.message); + const message = redactServerText(err.message, config); + console.error(`Failed to connect MCPL server "${config.id}":`, message); // Reaching this catch means no stub was created, so nothing will // retry — except in the edge case where addServer succeeded (with a // reconnecting stub) and a later setup step threw; config.reconnect @@ -13487,7 +13488,7 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:server-connect-failed', serverId: config.id, - error: err.message, + error: message, attempt: 0, // A protocol-version verdict is never retried (no common revision; // only configuration can fix it), and neither is a configuration @@ -13498,7 +13499,7 @@ export class AgentFramework { // So the server doesn't vanish quietly: alert at once, as the // reconnect path does for the same verdict, since no attempt follows. if (error instanceof McplProtocolVersionError) { - this.opsAlert('mcpl-down', config.id, `MCPL server unreachable at startup: ${err.message}`); + this.opsAlert('mcpl-down', config.id, `MCPL server unreachable at startup: ${message}`); } } } @@ -14026,7 +14027,7 @@ export class AgentFramework { // and holds the id: nothing launches beside it, and a disconnect // retries the reap. const message = cleanup instanceof Error ? cleanup.message : String(cleanup); - this.emitTrace({ type: 'mcpl:server-error', serverId: config.id, error: message }); + this.emitTrace({ type: 'mcpl:server-error', serverId: config.id, error: redactServerText(message, config) }); if (this.modernServers().get(config.id) === modern) { throw AgentFramework.unreapedConnectFailure(config.id, new Error(`${reason.message}; its cleanup also failed: ${message}`, { cause: reason })); } @@ -14750,10 +14751,12 @@ export class AgentFramework { * Push events and inference requests are deferred to Steps 6/7. */ private wireMcplEvents(connection: McplServerConnection): void { + const config = this.mcplServerConfigs.get(connection.id) ?? {}; + const recorded = (text: string): string => redactServerText(text, config); // Forward subprocess stderr lines as trace events so consumers (conhost, // log sinks, TUI badges) can persist and surface them. connection.on('stderr', (params: { line: string }) => { - this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: params.line }); + this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: recorded(params.line) }); }); // featureSets/changed is REMOVED in 0.5.0 — the connection answers it @@ -14971,21 +14974,21 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:server-connect-failed', serverId: connection.id, - error: params.error, + error: recorded(params.error), attempt: params.attempt, willRetry: connection.willReconnect, }); // `permanent`: the failed launch couldn't be reaped, so no retry will // start beside it. Alert now, as for a permanent reconnect failure. if (params.permanent) { - this.opsAlert('mcpl-down', connection.id, `MCPL server unreachable (attempt ${params.attempt}): ${params.error}`); + this.opsAlert('mcpl-down', connection.id, `MCPL server unreachable (attempt ${params.attempt}): ${recorded(params.error)}`); } }); connection.on('reconnect-failed', (params: { error: string; attempt: number; permanent?: boolean }) => { this.emitTrace({ type: 'mcpl:server-connect-failed', serverId: connection.id, - error: params.error, + error: recorded(params.error), attempt: params.attempt, willRetry: connection.willReconnect, }); @@ -15000,7 +15003,7 @@ export class AgentFramework { this.opsAlert( 'mcpl-down', connection.id, - `MCPL server unreachable (attempt ${params.attempt}): ${params.error}`, + `MCPL server unreachable (attempt ${params.attempt}): ${recorded(params.error)}`, ); } }); @@ -15032,7 +15035,7 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:server-error', serverId: connection.id, - error: err.message, + error: recorded(err.message), }); }); @@ -15077,8 +15080,10 @@ export class AgentFramework { * MCPL data-plane traffic, which this family doesn't carry. */ private wireModernEvents(connection: ModernMcpConnection): void { + const config = this.mcplServerConfigs.get(connection.id) ?? {}; + const recorded = (text: string): string => redactServerText(text, config); connection.on('stderr', (params: { line: string }) => { - this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: params.line }); + this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: recorded(params.line) }); }); connection.on('tools-list-changed', () => { this.handleToolsListChanged(connection.id); @@ -15092,20 +15097,20 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:server-connect-failed', serverId: connection.id, - error: params.error, + error: recorded(params.error), attempt: params.attempt, willRetry: connection.willReconnect, }); // `permanent`: reconnecting halted on a launch that couldn't be reaped. // No later attempt will come, so alert now. if (params.permanent || params.attempt >= 5) { - this.opsAlert('mcpl-down', connection.id, `MCP server unreachable (attempt ${params.attempt}): ${params.error}`); + this.opsAlert('mcpl-down', connection.id, `MCP server unreachable (attempt ${params.attempt}): ${recorded(params.error)}`); } }; connection.on('connect-failed', failed); connection.on('reconnect-failed', failed); connection.on('error', (err: Error) => { - this.emitTrace({ type: 'mcpl:server-error', serverId: connection.id, error: err.message }); + this.emitTrace({ type: 'mcpl:server-error', serverId: connection.id, error: recorded(err.message) }); }); connection.on('close', () => { // A closed provider's class hints are stale (RFC-008), as for MCPL. @@ -15224,7 +15229,7 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:server-error', serverId: server.id, - error: `tools/list failed: ${error instanceof Error ? error.message : String(error)}`, + error: `tools/list failed: ${redactServerText(error instanceof Error ? error.message : String(error), config ?? {})}`, }); } } diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index ff52ddd94..6515b143f 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -60,7 +60,7 @@ import { import { StdioTransport, type McplTransport } from './transport.js'; import { McplProtocolVersionError, McplRequestError } from './server-connection.js'; -import { LEGACY_MCP_PROTOCOL_VERSION, MAX_TIMER_MS, MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, describeCredentialError } from './protocol-family.js'; +import { LEGACY_MCP_PROTOCOL_VERSION, MAX_TIMER_MS, MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, describeCredentialError, redactServerText } from './protocol-family.js'; import type { McplServerConfig, McpToolDefinition } from './types.js'; const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; @@ -365,7 +365,7 @@ export class ModernMcpConnection extends EventEmitter { // An 'error' with no listener would throw out of an SDK callback and take // the host down; out-of-band errors here are diagnostics, never fatal. this.on('error', (error: Error) => { - if (this.listenerCount('error') === 1) console.error(`[mcp] ${this.id}: ${error.message}`); + if (this.listenerCount('error') === 1) console.error(`[mcp] ${this.id}: ${redactServerText(error.message, this.config)}`); }); this.on('newListener', (event: string | symbol) => { if (event !== 'stderr' || this.stderrBacklog.length === 0) return; @@ -463,7 +463,7 @@ export class ModernMcpConnection extends EventEmitter { this.emit('connect-failed', { error: `${(error as Error).message}; ${halted}`, attempt: 0, permanent: true }); return; } - console.error(`MCP server "${this.id}" initial connect failed, will retry:`, (error as Error).message); + console.error(`MCP server "${this.id}" initial connect failed, will retry:`, redactServerText((error as Error).message, this.config)); this.reconnectAttempts = 1; this.emit('connect-failed', { error: (error as Error).message, attempt: 0 }); this.scheduleReconnect(); @@ -884,7 +884,7 @@ export class ModernMcpConnection extends EventEmitter { */ private requestFailure(what: string, record: WireRecord, error: unknown, timeoutMs?: number): Error { const prefix = `MCP server "${this.id}"`; - const message = error instanceof Error ? error.message : String(error); + const message = redactServerText(error instanceof Error ? error.message : String(error), this.config); const advice = record.method === 'tools/list' ? 'Listing changes nothing on the server, so it is safe to retry.' : 'Verify state before retrying; a blind retry of a side-effecting tool may duplicate it.'; diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index 238adc106..a6483bd7a 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -107,6 +107,65 @@ export function redactUrl(url: string | undefined): string { return parsed.toString(); } +/** + * Server/transport diagnostics as the framework records them. Replace only + * known URL credential strings and the configured static token, in their + * encoded and decoded forms. Short standalone strings (under 8 characters) + * are indistinguishable from ordinary words; short query values are removed + * as name=value instead. A single longest-first replacement never scans its + * own replacement text. Unknown secrets, including accessProvider's cached + * bearer token, are outside this contract. JSON-RPC tool answers and errors + * thrown directly to a host caller keep their separate pass-through contract. + */ +export function redactServerText(text: string, config: Pick): string { + const secrets = new Set(); + const standalone = (value: string): void => { if (value.length >= 8) secrets.add(value); }; + const decode = (value: string, query = false): string => { + try { return decodeURIComponent(query ? value.replace(/\+/g, ' ') : value); } + catch { return value; } + }; + standalone(config.token ?? ''); + let url: URL | undefined; + try { url = new URL(config.url ?? ''); } catch { /* Still remove the static token. */ } + if (url) { + for (const segment of url.pathname.split('/')) { + if (credentialLikeSegment(segment)) { + standalone(segment); + standalone(decode(segment)); + } + } + for (const parameter of url.search.slice(1).split('&')) { + const equals = parameter.indexOf('='); + const encodedName = equals < 0 ? parameter : parameter.slice(0, equals); + const encodedValue = equals < 0 ? '' : parameter.slice(equals + 1); + const name = decode(encodedName, true); + const value = decode(encodedValue, true); + if (credentialLikeQueryName(name, value)) { + standalone(encodedName); + standalone(name); + } + if (CREDENTIAL_PARAM.test(name) || credentialLikeSegment(value)) { + standalone(encodedValue); + standalone(value); + } + if (value !== '') { + secrets.add(`${encodedName}=${encodedValue}`); + secrets.add(`${name}=${value}`); + } + } + if (url.protocol === 'ws:' || url.protocol === 'wss:') { + for (const value of [url.username, url.password]) { + standalone(value); + standalone(decode(value)); + } + } + } + if (secrets.size === 0) return text; + const alternatives = [...secrets].sort((a, b) => b.length - a.length) + .map(value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + return text.replace(new RegExp(alternatives.join('|'), 'g'), 'redacted'); +} + /** * A credential provider's failure as a log line may show it: the error's * class and its message's length, never the text, which may quote the diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index b95ababf5..f3aa696ed 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -19,6 +19,7 @@ import { LEGACY_MCP_PROTOCOL_VERSION, MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, + redactServerText, } from './protocol-family.js'; import type { @@ -870,12 +871,12 @@ export class McplServerConnection extends EventEmitter { // not lost in the window before wireMcplEvents runs. if (error instanceof McplUnreapedLaunchError) { const halted = error.connection; - console.error(`MCPL server "${config.id}" initial connect failed, not retrying:`, error.message); + console.error(`MCPL server "${config.id}" initial connect failed, not retrying:`, redactServerText(error.message, config)); halted.emit('connect-failed', { error: `${(error.cause as Error).message}; ${halted.unreapedFailure()}`, attempt: 0, permanent: true }); return halted; } // Non-blocking start: create a disconnected stub that will reconnect in background - console.error(`MCPL server "${config.id}" initial connect failed, will retry:`, (error as Error).message); + console.error(`MCPL server "${config.id}" initial connect failed, will retry:`, redactServerText((error as Error).message, config)); const stub = McplServerConnection.createDisconnectedStub(config, hostCapabilities); stub.emit('connect-failed', { error: (error as Error).message, attempt: 0 }); stub.scheduleReconnect(); @@ -1331,7 +1332,7 @@ export class McplServerConnection extends EventEmitter { // Closed by the host meanwhile: not a failed attempt. if (signal.aborted) return; const failure = unreaped ? error.failure : errorOf(error); - console.error(`MCPL server "${this.id}" reconnect failed:`, failure.message); + console.error(`MCPL server "${this.id}" reconnect failed:`, redactServerText(failure.message, this.config ?? {})); this.reconnectAttempts = attempt + 1; // No common protocol revision: every further attempt would restart the // server to hear the same answer. Stop retrying; the configuration is diff --git a/test/mcp-modern-http.test.ts b/test/mcp-modern-http.test.ts index a0a2ea6cb..30775d702 100644 --- a/test/mcp-modern-http.test.ts +++ b/test/mcp-modern-http.test.ts @@ -14,6 +14,10 @@ import assert from 'node:assert/strict'; import { createServer as createHttpServer, type Server, type ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; import { join, dirname } from 'node:path'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { AgentFramework } from '../src/framework.js'; +import { MockMembrane } from './helpers/mock-membrane.js'; import { fileURLToPath, pathToFileURL } from 'node:url'; import type { AddressInfo } from 'node:net'; @@ -181,3 +185,54 @@ test('HTTP: an auth wall on a later round of a call is a no-response, since the assert.equal(states[0], undefined, 'the first round carried no state'); assert.ok(states.length >= 2 && states.slice(1).every((s) => s === 'state-1'), `each later round carried it: ${JSON.stringify(states)}`); }); + +test('HTTP: an echoed path in a tool transport failure is redacted, but a JSON-RPC error is the server answer', async (t) => { + const consoleLines: string[] = []; + t.mock.method(console, 'error', (...args: unknown[]) => consoleLines.push(args.map(String).join(' '))); + const secret = 'pathSecret0123456789'; + let rpc = true; + const { url, server } = await startServer(() => true, (message, res) => { + if (message.method !== 'tools/call') return false; + if (rpc) { + res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32603, message: secret } })); + } else { + res.writeHead(500, { 'content-type': 'text/plain' }).end(`Cannot POST /api/mcp/s/${secret}/mcp`); + } + return true; + }); + cleanups.push(() => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); })); + const connection = await ModernMcpConnection.connect({ id: 'remote', url: url.replace('/mcp', `/api/mcp/s/${secret}/mcp`) }); + cleanups.push(() => connection.close()); + await assert.rejects(connection.callTool('echo', {}), (error: unknown) => { + assert.ok(error instanceof McplRequestError); + assert.match(error.message, new RegExp(secret)); + assert.equal(error.outcome, 'error-response'); + return true; + }); + rpc = false; + await assert.rejects(connection.callTool('echo', {}), (error: unknown) => { + assert.ok(error instanceof McplRequestError); + assert.match(error.message, /Cannot POST \/api\/mcp\/s\/redacted\/mcp/); + assert.ok(!error.message.includes(secret)); + assert.equal(error.outcome, 'no-response'); + return true; + }); + assert.ok(consoleLines.some(line => line.includes('Cannot POST /api/mcp/s/redacted/mcp'))); + assert.ok(!consoleLines.join('\n').includes(secret)); + const dir = mkdtempSync(join(tmpdir(), 'mcp-tool-transport-')); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'agent', model: 'test', systemPrompt: 'test' }], + modules: [], + mcplServers: [{ id: 'remote', url: url.replace('/mcp', `/api/mcp/s/${secret}/mcp`) }], + }); + try { + const result = await framework.executeToolCall({ id: 'echo-failed', name: 'mcpl--remote--echo', input: {} }); + assert.equal(result.success, false); + assert.match(result.error!, /Cannot POST \/api\/mcp\/s\/redacted\/mcp/); + assert.ok(!JSON.stringify(result).includes(secret), 'the model-facing tool result contains no path credential'); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index 382eb148d..d6c490c95 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -13,6 +13,7 @@ import { serverConfigWarnings, checkServerConfig, redactUrl, + redactServerText, describeCredentialError, MCPL_ONLY_POLICY_FIELDS, MAX_TIMER_MS, @@ -294,3 +295,36 @@ test('both engines admit a configuration through checkServerConfig, which logs i console.warn = realWarn; } }); + +test('recorded server text removes encoded and decoded URL credentials as exact strings', () => { + const config = cfg({ url: 'https://h/api/s/secret%2B0123456789/mcp?password=p%2Bassw0rd%21&bare%2Bsecret0123456789&plain=value0123456789abcdef®ion=eu&label=a%20b' }); + const secrets = ['secret%2B0123456789', 'secret+0123456789', 'p%2Bassw0rd%21', 'p+assw0rd!', 'bare%2Bsecret0123456789', 'bare+secret0123456789', 'value0123456789abcdef', 'region=eu', 'label=a%20b', 'label=a b']; + for (const secret of secrets) assert.equal(redactServerText(`queue full: ${secret}; eu`, config), 'queue full: redacted; eu', secret); +}); + +test('recorded server text removes WebSocket userinfo and the static token, escaping regex characters', () => { + const config = cfg({ url: 'wss://user%2B12345678:pass%2B12345678@h/mcp', token: 'token.[1234]' }); + for (const secret of ['user%2B12345678', 'user+12345678', 'pass%2B12345678', 'pass+12345678', config.token!]) { + assert.equal(redactServerText(`error: ${secret}`, config), 'error: redacted'); + } + assert.equal(redactServerText('tokenX11234]', config), 'tokenX11234]'); + assert.equal(redactServerText('static-token123', cfg({ token: 'static-token123' })), 'redacted'); + assert.equal(redactServerText('static-token123', cfg({ url: 'invalid', token: 'static-token123' })), 'redacted'); +}); + +test('recorded server text keeps short standalone values and unrelated words, but removes whole query pairs', () => { + const config = cfg({ url: 'https://h/mcp?auth=x&token=auth®ion=eu&debug', token: 'x' }); + assert.equal(redactServerText('auth x eu queue full debug', config), 'auth x eu queue full debug'); + assert.equal(redactServerText('auth=x token=auth region=eu', config), 'redacted redacted redacted'); + assert.equal(redactServerText('1234567 12345678', cfg({token:'1234567'})), '1234567 12345678'); + assert.equal(redactServerText('12345678', cfg({token:'12345678'})), 'redacted'); +}); + +test('recorded server text uses longest-first, one-pass replacements and is idempotent', () => { + const config = cfg({ url: 'https://h/mcp?secret=token0123456789-tail&redacted=eu', token: 'token0123456789' }); + const original = 'secret=token0123456789-tail token0123456789 redacted=eu queue full'; + const clean = redactServerText(original, config); + assert.equal(clean, 'redacted redacted redacted queue full'); + assert.equal(redactServerText('token0123456789-tail', config), 'redacted'); + assert.equal(redactServerText(clean, config), clean); +}); diff --git a/test/mcp-recorded-text.test.ts b/test/mcp-recorded-text.test.ts new file mode 100644 index 000000000..8350fac45 --- /dev/null +++ b/test/mcp-recorded-text.test.ts @@ -0,0 +1,183 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Client, type Transport } from '@modelcontextprotocol/client'; +import { EventEmitter } from 'node:events'; +import type { ModuleContext } from '../src/index.js'; +import { AgentFramework } from '../src/framework.js'; +import { McplServerConnection, McplUnreapedLaunchError } from '../src/mcpl/server-connection.js'; +import { ModernMcpConnection } from '../src/mcpl/modern-connection.js'; +import { MockMembrane } from './helpers/mock-membrane.js'; + +/** Discovery succeeds; the inventory request gets a normal echoed-path 404. */ +function echo404Server() { + return createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const message = JSON.parse(Buffer.concat(chunks).toString('utf8')); + if (message.method === 'server/discover') { + res.writeHead(200, { 'content-type': 'application/json', 'mcp-protocol-version': '2026-07-28' }).end(JSON.stringify({ + jsonrpc: '2.0', id: message.id, result: { resultType: 'complete', supportedVersions: ['2026-07-28'], capabilities: { tools: {} }, ttlMs: 60000, cacheScope: 'private', _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'echo', version: '1' } } }, + })); + } else { + res.writeHead(404, { 'content-type': 'text/plain' }).end(`Cannot POST ${req.url}`); + } + }); +} + +/** The SDK's pin-mode discovery rewrites an HTTP 404 into a version error. + * Send tools/list through the real transport at the connect seam so this test + * isolates how connection recordings handle an SDK error with the real body. */ +function failConnectWithHttpBody(t: import('node:test').TestContext): void { + t.mock.method(Client.prototype, 'connect', async (transport: Transport) => { + await transport.start(); + try { await transport.send({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); } + finally { await transport.close(); } + }); +} + +const secret = 'pathSecret0123456789'; +const config = { id: 'remote', url: `https://h/api/mcp/s/${secret}/mcp`, token: 'staticToken012345' }; + +test('both framework engines scrub failure, error and stderr records before tracing and alerting', () => { + for (const method of ['wireMcplEvents', 'wireModernEvents']) { + const traces: unknown[] = []; + const alerts: string[] = []; + const framework = Object.create(AgentFramework.prototype) as any; + framework.mcplServerConfigs = new Map([[config.id, config]]); + framework.emitTrace = (event: unknown) => traces.push(event); + framework.opsAlert = (_kind: string, _id: string, text: string) => alerts.push(text); + const connection = Object.assign(new EventEmitter(), { id: config.id, willReconnect: true }); + framework[method](connection); + connection.emit('connect-failed', { error: `Cannot POST ${secret}`, attempt: 0, permanent: true }); + connection.emit('reconnect-failed', { error: `Cannot POST ${secret}`, attempt: 5 }); + connection.emit('error', new Error(`transport ${secret}`)); + connection.emit('stderr', { line: `server ${config.token}` }); + assert.equal(traces.length, 4); + assert.equal(alerts.length, 2); + assert.ok(!JSON.stringify(traces).includes(secret), method); + assert.ok(!JSON.stringify(traces).includes(config.token), method); + assert.ok(alerts.every(text => text.includes('Cannot POST redacted')), method); + } +}); + +test('an SDK HTTP 404 error keeps its path but hides the secret in connection console, traces and ops alerts', async (t) => { + failConnectWithHttpBody(t); + const dir = mkdtempSync(join(tmpdir(), 'mcp-recorded-text-')); + const consoleLines: string[] = []; + t.mock.method(console, 'error', (...args: unknown[]) => consoleLines.push(args.map(String).join(' '))); + const traces: Array<{ type: string; error?: string; message?: string; attempt?: number }> = []; + const server = echo404Server(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + let framework: AgentFramework | undefined; + try { + framework = await AgentFramework.create({ + storePath: join(dir, 'store'), membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'agent', model: 'test', systemPrompt: 'test' }], + modules: [{ name: 'watcher', async start(ctx: ModuleContext) { ctx.onTrace(event => traces.push(event as never)); }, async stop() {}, getTools: () => [], async handleToolCall() { return { success: false }; }, async onProcess() { return {}; } }], + mcplServers: [{ id: 'echo', url: `http://127.0.0.1:${port}/api/mcp/s/${secret}/mcp`, reconnect: true, reconnectIntervalMs: 10, reconnectMaxIntervalMs: 20 }], + }); + const deadline = Date.now() + 5000; + while (!traces.some(e => e.type === 'ops:alert') && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10)); + const failed = traces.filter(e => e.type === 'mcpl:server-connect-failed'); + assert.ok(failed.some(e => e.attempt === 0)); + assert.ok(failed.some(e => (e.attempt ?? 0) >= 5)); + assert.ok(failed.every(e => e.error?.includes('Cannot POST /api/mcp/s/redacted/mcp')), JSON.stringify(failed)); + assert.ok(traces.some(e => e.type === 'ops:alert' && e.message?.includes('Cannot POST /api/mcp/s/redacted/mcp'))); + assert.ok(consoleLines.some(line => line.includes('Cannot POST /api/mcp/s/redacted/mcp'))); + assert.ok(!JSON.stringify(traces).includes(secret)); + assert.ok(!consoleLines.join('\n').includes(secret)); + } finally { + await framework?.stop(); + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + rmSync(dir, { recursive: true, force: true }); + } +}); + + +test('startup records redact an echoed path while the direct host connect error stays as raised', async (t) => { + failConnectWithHttpBody(t); + const dir = mkdtempSync(join(tmpdir(), 'mcp-startup-text-')); + const consoleLines: string[] = []; + t.mock.method(console, 'error', (...args: unknown[]) => consoleLines.push(args.map(String).join(' '))); + const traces: Array<{ type: string; error?: string }> = []; + const server = echo404Server(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/mcp/s/${secret}/mcp`; + let framework: AgentFramework | undefined; + try { + await assert.rejects(ModernMcpConnection.connect({ id: 'raw', url }), error => { + assert.ok(error instanceof Error && error.message.includes(secret)); + return true; + }); + framework = await AgentFramework.create({ + storePath: join(dir, 'store'), membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'agent', model: 'test', systemPrompt: 'test' }], + modules: [{ name: 'watcher', async start(ctx: ModuleContext) { ctx.onTrace(event => traces.push(event as never)); }, async stop() {}, getTools: () => [], async handleToolCall() { return { success: false }; }, async onProcess() { return {}; } }], + mcplServers: [{ id: 'echo', url }], + }); + assert.ok(traces.some(e => e.type === 'mcpl:server-connect-failed' && e.error?.includes('Cannot POST /api/mcp/s/redacted/mcp'))); + assert.ok(consoleLines.some(line => line.includes('Failed to connect MCPL server') && line.includes('Cannot POST /api/mcp/s/redacted/mcp'))); + assert.ok(!JSON.stringify(traces).includes(secret)); + assert.ok(!consoleLines.join('\n').includes(secret)); + } finally { + await framework?.stop(); + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + rmSync(dir, { recursive: true, force: true }); + } +}); + + +test('legacy initial and reconnect console records redact known credentials', async (t) => { + const lines: string[] = []; + t.mock.method(console, 'error', (...args: unknown[]) => lines.push(args.map(String).join(' '))); + t.mock.method(McplServerConnection as any, 'handshake', async () => { throw new Error(`transport ${secret}`); }); + const legacyConfig = { id: 'legacy', url: `ws://127.0.0.1/s/${secret}`, reconnect: true, reconnectIntervalMs: 10, reconnectMaxIntervalMs: 20 }; + const capabilities = { version: '0.5', pushEvents: true }; + const connection = await McplServerConnection.connectWithReconnect(legacyConfig, capabilities); + try { + const deadline = Date.now() + 5000; + while (!lines.some(line => line.includes('reconnect failed:')) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10)); + assert.ok(lines.some(line => line.includes('initial connect failed, will retry: transport redacted'))); + assert.ok(lines.some(line => line.includes('reconnect failed: transport redacted'))); + assert.ok(!lines.join('\n').includes(secret)); + } finally { await connection.close(); } + const halted = Object.assign(new EventEmitter(), { unreapedFailure: () => `cleanup ${secret}` }); + t.mock.method(McplServerConnection as any, 'connectChecked', async () => { + throw new McplUnreapedLaunchError(halted as unknown as McplServerConnection, new Error(`transport ${secret}`), new Error(`cleanup ${secret}`)); + }); + await McplServerConnection.connectWithReconnect(legacyConfig, capabilities); + assert.ok(lines.some(line => line.includes('initial connect failed, not retrying: transport redacted; its cleanup also failed: cleanup redacted'))); + assert.ok(!lines.join('\n').includes(secret)); +}); + +test('a real discover-then-tools/list 404 is redacted in the framework error trace', async (t) => { + t.mock.method(console, 'error', () => {}); + const dir = mkdtempSync(join(tmpdir(), 'mcp-list-text-')); + const traces: Array<{ type: string; error?: string }> = []; + const server = echo404Server(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + let framework: AgentFramework | undefined; + try { + framework = await AgentFramework.create({ + storePath: join(dir, 'store'), membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'agent', model: 'test', systemPrompt: 'test' }], + modules: [{ name: 'watcher', async start(ctx: ModuleContext) { ctx.onTrace(event => traces.push(event as never)); }, async stop() {}, getTools: () => [], async handleToolCall() { return { success: false }; }, async onProcess() { return {}; } }], + mcplServers: [{ id: 'echo', url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/mcp/s/${secret}/mcp` }], + }); + assert.ok(traces.some(e => e.type === 'mcpl:server-error' && e.error?.includes('Cannot POST /api/mcp/s/redacted/mcp')), JSON.stringify(traces)); + assert.ok(!JSON.stringify(traces).includes(secret)); + } finally { + await framework?.stop(); + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + rmSync(dir, { recursive: true, force: true }); + } +}); From 11bfe4779e62ef7b225a7941b347970b2e83427a Mon Sep 17 00:00:00 2001 From: Rosalind-2359 Date: Tue, 13 Oct 2026 00:52:40 -0400 Subject: [PATCH 31/94] test(mcp): initialize server configuration in the observe wire fixture The observe parser wire tests bypass the framework constructor with Object.create. Recording handlers now look up the configured server to redact known credentials, so this stand-in must initialize that map just as a real framework does. Give its observer a stdio configuration; no URL or static token is configured in these admission tests. The expanded protocol/HTTP/recording/observe-parser focused run passes 41 tests with no failures, after typecheck and build. Co-Authored-By: GPT-6.1 Sol --- test/tools-observe-parser-conformance.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/tools-observe-parser-conformance.test.ts b/test/tools-observe-parser-conformance.test.ts index c493ed8f6..1a73a70c1 100644 --- a/test/tools-observe-parser-conformance.test.ts +++ b/test/tools-observe-parser-conformance.test.ts @@ -5,6 +5,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { AgentFramework } from '../src/framework.js'; +import type { McplServerConfig } from '../src/mcpl/types.js'; import { McplServerConnection } from '../src/mcpl/server-connection.js'; import { McplTransport } from '../src/mcpl/transport.js'; import { CapabilityGrant } from '../src/mcpl/capability-grant.js'; @@ -79,8 +80,10 @@ function wireHarness() { ) => McplServerConnection; const connection = new Connection('observer', null, transport); const framework = Object.create(AgentFramework.prototype) as { + mcplServerConfigs: Map; wireMcplEvents(connection: McplServerConnection): void; }; + framework.mcplServerConfigs = new Map([['observer', { id: 'observer', command: 'unused' }]]); framework.wireMcplEvents(connection); connection.establishGrant(new CapabilityGrant(new Set(['toolLifecycle.observe']), [])); connection.ready(); From d4f849353fe99dfc0987907674b0a7ad2d0a2c02 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 01:28:45 -0400 Subject: [PATCH 32/94] perf(mcp): a connection's records build their redaction once redactServerText found the configuration's strings and built its pattern on every call, and a server's stderr is recorded a line at a time, so a busy server rebuilt them for every line. serverTextRedactor(config) finds them and builds the pattern once and returns the function that applies it; both engines' event wiring builds one per connection, and redactServerText is that function applied once, for the occasional error. The contract and its tests are unchanged. --- src/framework.ts | 8 +++----- src/mcpl/protocol-family.ts | 13 +++++++++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 3dbfbff0b..67b8157ce 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -394,7 +394,7 @@ import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js'; import type { McplServerConnection } from './mcpl/server-connection.js'; import { McplProtocolVersionError, McplUnreapedLaunchError } from './mcpl/server-connection.js'; import { ModernMcpConnection } from './mcpl/modern-connection.js'; -import { redactUrl, redactServerText, resolveServerBinding, serverConfigProblems } from './mcpl/protocol-family.js'; +import { redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverTextRedactor } from './mcpl/protocol-family.js'; import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js'; import type { McplServerConfig, @@ -14751,8 +14751,7 @@ export class AgentFramework { * Push events and inference requests are deferred to Steps 6/7. */ private wireMcplEvents(connection: McplServerConnection): void { - const config = this.mcplServerConfigs.get(connection.id) ?? {}; - const recorded = (text: string): string => redactServerText(text, config); + const recorded = serverTextRedactor(this.mcplServerConfigs.get(connection.id) ?? {}); // Forward subprocess stderr lines as trace events so consumers (conhost, // log sinks, TUI badges) can persist and surface them. connection.on('stderr', (params: { line: string }) => { @@ -15080,8 +15079,7 @@ export class AgentFramework { * MCPL data-plane traffic, which this family doesn't carry. */ private wireModernEvents(connection: ModernMcpConnection): void { - const config = this.mcplServerConfigs.get(connection.id) ?? {}; - const recorded = (text: string): string => redactServerText(text, config); + const recorded = serverTextRedactor(this.mcplServerConfigs.get(connection.id) ?? {}); connection.on('stderr', (params: { line: string }) => { this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: recorded(params.line) }); }); diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index a6483bd7a..0f021057a 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -118,6 +118,14 @@ export function redactUrl(url: string | undefined): string { * thrown directly to a host caller keep their separate pass-through contract. */ export function redactServerText(text: string, config: Pick): string { + return serverTextRedactor(config)(text); +} + +/** + * {@link redactServerText} for one configuration, its strings found and its + * pattern built once: for a connection's records, such as each stderr line. + */ +export function serverTextRedactor(config: Pick): (text: string) => string { const secrets = new Set(); const standalone = (value: string): void => { if (value.length >= 8) secrets.add(value); }; const decode = (value: string, query = false): string => { @@ -160,10 +168,11 @@ export function redactServerText(text: string, config: Pick text; const alternatives = [...secrets].sort((a, b) => b.length - a.length) .map(value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); - return text.replace(new RegExp(alternatives.join('|'), 'g'), 'redacted'); + const pattern = new RegExp(alternatives.join('|'), 'g'); + return (text) => text.replace(pattern, 'redacted'); } /** From fa29b6e9a8f5261c7ceed0d366c8204a8130c540 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 02:09:17 -0400 Subject: [PATCH 33/94] fix(mcp): recorded-text redaction covers the token as a url carries it, credential env values, and any value; it replaces only whole strings From a review of the redaction: - The static token was derived only as written; a transport that puts it in a url sends it percent- or form-encoded, so both forms are derived too. - A stdio server's environment values whose names say they're credentials (the url's parameter rule: token, key, secret, password, auth, signature) are derived as well. - A string is replaced only where no letter or digit adjoins it, so a short name=value such as v=2 no longer changes dev=2 in other text. - The redactor reads any value as text: a connect rejection that isn't an Error gave it undefined, which threw inside the catch and lost the reconnecting stub. - The cleartext warning counts a parameter by its value's shape too, as redaction does, so a credential-shaped value under a plain name is warned about (its name shown) as well as hidden. The helper's doc now lists what it derives and what's outside it. Tests: the token's encoded forms, credential-named env values (and a plain one kept), a short pair kept where another word adjoins it, a ws:// url's userinfo, a url with a stray percent sign, non-string values, and the warning for a credential-shaped value under a plain name. --- src/mcpl/protocol-family.ts | 53 +++++++++++++++++++++++--------- test/mcp-protocol-family.test.ts | 30 ++++++++++++++++++ 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index 0f021057a..52cb72114 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -108,16 +108,23 @@ export function redactUrl(url: string | undefined): string { } /** - * Server/transport diagnostics as the framework records them. Replace only - * known URL credential strings and the configured static token, in their - * encoded and decoded forms. Short standalone strings (under 8 characters) - * are indistinguishable from ordinary words; short query values are removed - * as name=value instead. A single longest-first replacement never scans its - * own replacement text. Unknown secrets, including accessProvider's cached - * bearer token, are outside this contract. JSON-RPC tool answers and errors - * thrown directly to a host caller keep their separate pass-through contract. + * Server and transport text as the framework records it, with the strings a + * server's configuration knows to be credentials replaced: + * - from the url, as written and decoded: credential-like path segments and + * query names, credential-shaped or credential-named query values, every + * other non-empty parameter as its whole name=value, and a ws:// url's + * username and password; + * - the static token, raw and percent- or form-encoded; + * - a stdio server's environment values whose names say they're credentials. + * A string replaced on its own has at least 8 characters, since a shorter + * one can't be told from ordinary text; a short query value is replaced + * with its name, as one name=value. A string is replaced only where no letter + * or digit adjoins it, and one longest-first pass never scans its own + * replacement. A credential the configuration doesn't hold, such as an + * access provider's cached bearer token or another origin's, is outside it. + * JSON-RPC answers and errors thrown to a host's own call pass as they are. */ -export function redactServerText(text: string, config: Pick): string { +export function redactServerText(text: unknown, config: Pick): string { return serverTextRedactor(config)(text); } @@ -125,14 +132,25 @@ export function redactServerText(text: string, config: Pick): (text: string) => string { +export function serverTextRedactor(config: Pick): (text: unknown) => string { const secrets = new Set(); const standalone = (value: string): void => { if (value.length >= 8) secrets.add(value); }; const decode = (value: string, query = false): string => { try { return decodeURIComponent(query ? value.replace(/\+/g, ' ') : value); } catch { return value; } }; - standalone(config.token ?? ''); + // The token as sent: raw in a header, and percent- or form-encoded where a + // transport puts it in a url. + for (const token of [config.token ?? '']) { + standalone(token); + standalone(encodeURIComponent(token)); + standalone(new URLSearchParams({ t: token }).toString().slice(2)); + } + // A stdio server's environment values whose names say they carry a + // credential, by the same rule as the url's parameters. + for (const [name, value] of Object.entries(config.env ?? {})) { + if (CREDENTIAL_PARAM.test(name)) standalone(value); + } let url: URL | undefined; try { url = new URL(config.url ?? ''); } catch { /* Still remove the static token. */ } if (url) { @@ -168,11 +186,14 @@ export function serverTextRedactor(config: Pick text; + const asText = (text: unknown): string => (typeof text === 'string' ? text : String(text)); + if (secrets.size === 0) return asText; const alternatives = [...secrets].sort((a, b) => b.length - a.length) .map(value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); - const pattern = new RegExp(alternatives.join('|'), 'g'); - return (text) => text.replace(pattern, 'redacted'); + // Only where no letter or digit adjoins it, so a short name=value such as + // v=2 leaves dev=2 alone. + const pattern = new RegExp(`(? asText(text).replace(pattern, 'redacted'); } /** @@ -357,7 +378,9 @@ export function serverConfigWarnings(config: McplServerConfig): string[] { if (url.pathname.split('/').some(credentialLikeSegment)) carried.push("a credential-like segment of its url's path"); const entries = [...url.searchParams]; const hidden = entries.filter(([name, value]) => credentialLikeQueryName(name, value)).length; - const params = [...new Set(entries.filter(([name, value]) => !credentialLikeQueryName(name, value) && CREDENTIAL_PARAM.test(name)).map(([name]) => name))]; + // A parameter carries a credential by its name, or by its value's shape, + // as redaction reads it; the name is shown only when it isn't one itself. + const params = [...new Set(entries.filter(([name, value]) => !credentialLikeQueryName(name, value) && (CREDENTIAL_PARAM.test(name) || credentialLikeSegment(value))).map(([name]) => name))]; if (params.length > 0) { carried.push(`its url's query ${params.length === 1 ? 'parameter' : 'parameters'} ${params.map((name) => JSON.stringify(name)).join(', ')}`); } diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index d6c490c95..787182c16 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -320,6 +320,36 @@ test('recorded server text keeps short standalone values and unrelated words, bu assert.equal(redactServerText('12345678', cfg({token:'12345678'})), 'redacted'); }); +test('recorded server text: the token as a url carries it, credential-named env values, only where nothing adjoins, and any value', () => { + // The static token, raw and as a url carries it, percent- or form-encoded. + const token = 'tok+en/123='; + const tokenConfig = cfg({ token }); + for (const form of [token, encodeURIComponent(token), 'tok%2Ben%2F123%3D']) { + assert.equal(redactServerText(`refused: ${form}`, tokenConfig), 'refused: redacted', form); + } + // A stdio server's env: a value whose name says it's a credential, and no other. + const env = cfg({ command: 'srv', env: { GITHUB_TOKEN: 'ghp_abcdef123456', API_KEY: 'k3y-abcdefgh', PATH: '/usr/local/bin' } }); + assert.equal(redactServerText('auth ghp_abcdef123456 k3y-abcdefgh /usr/local/bin', env), 'auth redacted redacted /usr/local/bin'); + // Only where no letter or digit adjoins a string: a short pair stays whole elsewhere. + const pair = cfg({ url: 'https://h/mcp?v=2' }); + assert.equal(redactServerText('dev=2 ready; v=2 sent', pair), 'dev=2 ready; redacted sent'); + // A ws:// url's userinfo, as for wss://. + const ws = cfg({ url: 'ws://wsuser1234:wspass5678@h/mcp' }); + assert.equal(redactServerText('connect wsuser1234 wspass5678', ws), 'connect redacted redacted'); + // A url with a stray percent sign: its value is kept as written. + const stray = cfg({ url: 'https://h/mcp?note=100%&key=abcdefgh1234' }); + assert.equal(redactServerText('note=100% key=abcdefgh1234', stray), 'redacted redacted'); + // Any value reads as text: a rejection that isn't an Error doesn't throw. + assert.equal(redactServerText(undefined, tokenConfig), 'undefined'); + assert.equal(redactServerText({ toString: () => `obj ${token}` }, tokenConfig), 'obj redacted'); +}); + +test('the cleartext warning counts a credential-shaped value under a plain name, as redaction does', () => { + const warned = serverConfigWarnings(cfg({ url: 'http://remote.example/mcp?session=Zx81kQ0pL2mN7vB4tY6c' })); + assert.equal(warned.length, 1); + assert.match(warned[0], /its url's query parameter "session" goes to remote\.example unencrypted/); +}); + test('recorded server text uses longest-first, one-pass replacements and is idempotent', () => { const config = cfg({ url: 'https://h/mcp?secret=token0123456789-tail&redacted=eu', token: 'token0123456789' }); const original = 'secret=token0123456789-tail token0123456789 redacted=eu queue full'; From a49e74fb0d3f91a69fc7aa8e0236760ea081c626 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 02:30:37 -0400 Subject: [PATCH 34/94] fix(mcp): every record of server text is redacted, a restart refuses an operation in flight, and a disconnect keeps another server's prefix From a review of the redaction and the restart: - Server text the framework logged raw is redacted with its server's configuration: the close messages after a failed startup and the tool refresh's error (every configured server's credentials, since either can come from any server), the manifest fetch and refresh lines, the tools/observe refusal and the re-registration after a reconnect. - A tool call's failure from either engine: the result the caller gets and, on the model's dispatch, the tool:failed trace's message and stack. A legacy MCPL server's close reason reaches these unredacted otherwise, and so does any error not built by the modern engine's request reading. - A malformed line's diagnostic is redacted before it's cut at 200 characters, so a credential across the cut can't leave its first part (malformedLineDiagnostic, now its own function). - A request refused with 401 or 403 keeps the SDK's status and statusText in the error's data, and leaves out the response body (`text`), which can echo the url. - The event wiring takes the configuration the connection was made for, rather than reading the framework's map with an empty fallback. - restartMcplServer of an id with a connect or disconnect in flight is refused before it tears anything down; its own disconnect would have queued behind the other and been refused after the server was gone. - A disconnect removes its server's tool-prefix claim only while that server holds it, so a server connected since under that prefix keeps it. Tests: a legacy tool call's failure and the model dispatch's trace and result; the startup alert for a protocol-version verdict; a runtime connect's failed cleanup traced redacted while the host's error stays as raised; the malformed-line diagnostic across its cut; a 403's body kept out of the error's data; a restart refused while a disconnect is in flight; a disconnect leaving another server's prefix. The polling ceilings in two record tests go from 5 s to 30 s (each loop ends when its record appears). Removing each piece fails its test (13 removals). No test drives each console line on its own; the helpers they call are tested. --- src/framework.ts | 52 +++++--- src/mcpl/modern-connection.ts | 20 ++- test/mcp-modern-http.test.ts | 23 ++++ test/mcp-recorded-text.test.ts | 114 +++++++++++++++++- test/mcpl-reconnect-reregistration.test.ts | 2 +- test/mcpl-runtime-lifecycle.test.ts | 24 ++++ test/tools-observe-parser-conformance.test.ts | 4 +- 7 files changed, 211 insertions(+), 28 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 67b8157ce..3b780be8a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -11333,7 +11333,7 @@ export class AgentFramework { } catch (err) { return { success: false, - error: err instanceof Error ? err.message : String(err), + error: redactServerText(err instanceof Error ? err.message : err, config), isError: true, }; } @@ -13597,7 +13597,7 @@ export class AgentFramework { for (const outcome of outcomes) { if (outcome.status === 'rejected') { console.error('[mcpl] after a failed startup, some MCP connections could not be closed:', - outcome.reason instanceof Error ? outcome.reason.message : outcome.reason); + this.recordedForEveryServer(outcome.reason instanceof Error ? outcome.reason.message : outcome.reason)); } } } @@ -14002,7 +14002,7 @@ export class AgentFramework { // and a second connect for the same id is refused above. const modern = ModernMcpConnection.create(config); this.modernServers().set(config.id, modern); - this.wireModernEvents(modern); + this.wireModernEvents(modern, config); let failure: Error | null = null; try { await modern.start(); @@ -14075,7 +14075,7 @@ export class AgentFramework { // Wire listeners before either startup staging or the runtime global gate // releases control traffic needed for registration and marker service. - this.wireMcplEvents(connection); + this.wireMcplEvents(connection, config); if (deferAwareness) { // Staged startup: both planes are closed, so the §5.3 policy @@ -14189,7 +14189,7 @@ export class AgentFramework { try { fetched = await connection.sendManifestRequest(); } catch (err) { - console.error(`[mcpl] ${connection.id}: mcpl/manifest fetch failed — previous manifest stands (§17.5): ${err instanceof Error ? err.message : err}`); + console.error(`[mcpl] ${connection.id}: mcpl/manifest fetch failed — previous manifest stands (§17.5): ${this.recordedFor(connection.id, err instanceof Error ? err.message : err)}`); return; } if (!fetched || typeof fetched !== 'object') { @@ -14525,7 +14525,9 @@ export class AgentFramework { this.checkpointManager?.removeServer(id); const prefix = config?.toolPrefix ?? `mcpl--${id}`; - this.mcplPrefixMap.delete(prefix); + // Only this server's own claim: a server connected since under the + // prefix it held keeps it. + if (this.mcplPrefixMap.get(prefix) === id) this.mcplPrefixMap.delete(prefix); this.mcplServerConfigs.delete(id); await this.refreshMcplTools(); @@ -14560,6 +14562,11 @@ export class AgentFramework { if (config.id !== id) { const taken = this.mcpServerIdTaken(config.id); if (taken) throw new Error(taken); + } else if (this.mcpServerOps?.get(id)) { + // A connect or disconnect of this id in flight: the restart's own + // disconnect would queue behind it and then be refused, after the + // server was torn down. + throw new Error(`MCP server "${id}" was not restarted: it is being ${this.mcpServerOps.get(id)!.kind === 'disconnect' ? 'disconnected' : 'connected'}; restart it once that has finished`); } const collision = this.mcplPrefixCollision(config, id); if (collision) throw new Error(collision); @@ -14567,6 +14574,18 @@ export class AgentFramework { await this.connectMcplServer(config); } + /** A server's text as the framework records it ({@link serverTextRedactor}). */ + private recordedFor(serverId: string, text: unknown): string { + return redactServerText(text, this.mcplServerConfigs.get(serverId) ?? {}); + } + + /** Text that may come from any configured server, with every server's credentials replaced. */ + private recordedForEveryServer(text: unknown): string { + let recorded = typeof text === 'string' ? text : String(text); + for (const config of this.mcplServerConfigs.values()) recorded = redactServerText(recorded, config); + return recorded; + } + /** Why a connect for this id is refused, or null when the id is free. One * owner per id, across both engines (holdMcpServerId): a connect or * disconnect in flight holds it, and so does a registered connection, @@ -14750,8 +14769,8 @@ export class AgentFramework { * Wire event listeners on an MCPL server connection. * Push events and inference requests are deferred to Steps 6/7. */ - private wireMcplEvents(connection: McplServerConnection): void { - const recorded = serverTextRedactor(this.mcplServerConfigs.get(connection.id) ?? {}); + private wireMcplEvents(connection: McplServerConnection, config: McplServerConfig): void { + const recorded = serverTextRedactor(config); // Forward subprocess stderr lines as trace events so consumers (conhost, // log sinks, TUI badges) can persist and surface them. connection.on('stderr', (params: { line: string }) => { @@ -14825,7 +14844,7 @@ export class AgentFramework { // payload and no authority; everything below acts on FETCHED content. connection.on('manifest-changed', (params: { revision?: string; domains?: string[] }) => { void this.handleManifestChanged(connection, params).catch((err) => { - console.error(`[mcpl] ${connection.id} manifest refresh failed:`, err instanceof Error ? err.message : err); + console.error(`[mcpl] ${connection.id} manifest refresh failed:`, this.recordedFor(connection.id, err instanceof Error ? err.message : err)); }); }); @@ -14848,7 +14867,7 @@ export class AgentFramework { const parsed = parseToolObserveParams(params); if (!parsed.ok) { if (responder?.respondError) responder.respondError(-32602, parsed.message, parsed.data); - else console.error(`[mcpl] ${connection.id}: tools/observe rejected — ${parsed.message}`); + else console.error(`[mcpl] ${connection.id}: tools/observe rejected — ${this.recordedFor(connection.id, parsed.message)}`); return; } connection.toolObserveFilter = parsed.rules; @@ -14940,7 +14959,7 @@ export class AgentFramework { } catch (error) { console.error( `MCPL server "${connection.id}" re-registration after reconnect failed:`, - error instanceof Error ? error.message : error, + this.recordedFor(connection.id, error instanceof Error ? error.message : error), ); } this.releaseMcplDataPlaneGate(awarenessBarrier); @@ -15078,8 +15097,8 @@ export class AgentFramework { * every other wake. It installs no awareness gate, since that gate orders * MCPL data-plane traffic, which this family doesn't carry. */ - private wireModernEvents(connection: ModernMcpConnection): void { - const recorded = serverTextRedactor(this.mcplServerConfigs.get(connection.id) ?? {}); + private wireModernEvents(connection: ModernMcpConnection, config: McplServerConfig): void { + const recorded = serverTextRedactor(config); connection.on('stderr', (params: { line: string }) => { this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: recorded(params.line) }); }); @@ -15359,7 +15378,7 @@ export class AgentFramework { this.emitMcplToolDiff(oldToolNames, serverId); }) .catch((error) => { - console.error('MCPL tool refresh error:', error); + console.error('MCPL tool refresh error:', this.recordedForEveryServer(error instanceof Error ? error.message : error)); }) .finally(() => { this.mcplToolRefreshInFlight = false; @@ -15440,7 +15459,10 @@ export class AgentFramework { const args = (call.input && typeof call.input === 'object') ? call.input as Record : {}; const failDispatch = (error: unknown) => { - const err = error instanceof Error ? error : new Error(String(error)); + const raw = error instanceof Error ? error : new Error(String(error)); + // Server and transport text, as every record of it is. + const recorded = (text: unknown) => this.recordedFor(serverId, text); + const err = { message: recorded(raw.message), stack: raw.stack === undefined ? undefined : recorded(raw.stack) }; this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: err.message, stack: err.stack }); // RFC-007: transport error, closed connection or timeout — no result. this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id); diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 6515b143f..c64096c60 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -223,6 +223,17 @@ class ObservedTransport implements Transport { } } +/** + * The diagnostic for a line that is JSON but not a JSON-RPC message: the + * line's first 200 characters, after the configuration's credentials are + * replaced, so a credential across the cut can't leave its first part. + */ +export function malformedLineDiagnostic(config: McplServerConfig, text: string, error: unknown): string { + const shown = redactServerText(text, config); + return `MCP server "${config.id}" sent a malformed JSON-RPC message (${shown.length > 200 ? `${shown.slice(0, 200)}…` : shown}): ` + + (error instanceof Error ? error.message.split('\n')[0] : String(error)); +} + /** * SDK `Transport` over Connectome's stdio spawner. Framing is the same * newline-delimited JSON-RPC as legacy. Lines that are not JSON are ignored, @@ -260,10 +271,7 @@ class SpawnerStdioTransport implements Transport { try { message = parseJSONRPCMessage(parsed); } catch (error) { - this.onerror?.(new Error( - `MCP server "${this.config.id}" sent a malformed JSON-RPC message (${text.length > 200 ? `${text.slice(0, 200)}…` : text}): ` + - (error instanceof Error ? error.message.split('\n')[0] : String(error)), - )); + this.onerror?.(new Error(malformedLineDiagnostic(this.config, text, error))); return; } this.onmessage?.(message); @@ -921,7 +929,9 @@ export class ModernMcpConnection extends EventEmitter { 'no-response', ); } - const data = error.data && typeof error.data === 'object' ? error.data : {}; + // The response body (the SDK's `text`) is the server's text and can + // echo the request's url, so it's left out; the status stays. + const { text: _body, ...data } = (error.data && typeof error.data === 'object' ? error.data : {}) as Record; return new McplRequestError(`${prefix} refused ${what}: HTTP ${error.status} (${message})`, 'error-response', undefined, { ...data, httpStatus: error.status }); } if (deadline) { diff --git a/test/mcp-modern-http.test.ts b/test/mcp-modern-http.test.ts index 30775d702..0c51970f0 100644 --- a/test/mcp-modern-http.test.ts +++ b/test/mcp-modern-http.test.ts @@ -22,6 +22,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import type { AddressInfo } from 'node:net'; import { createMcpHandler } from '@modelcontextprotocol/server'; +import { SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/client'; import { ModernMcpConnection } from '../src/mcpl/modern-connection.js'; import { McplRequestError } from '../src/mcpl/server-connection.js'; @@ -236,3 +237,25 @@ test('HTTP: an echoed path in a tool transport failure is redacted, but a JSON-R rmSync(dir, { recursive: true, force: true }); } }); + +test("HTTP: a 403 refusal's response body, which can echo the url, stays out of the request error's data", () => { + // The SDK attaches the body as `text` to its step-up 403; staging step-up + // takes an OAuth provider, so the refusal is handed to the connection's + // reading of a failed request directly. + const connection = Object.create(ModernMcpConnection.prototype) as { + id: string; config: unknown; + requestFailure(what: string, record: unknown, error: unknown): Error; + }; + connection.id = 'remote'; + connection.config = { id: 'remote', url: 'https://h/api/mcp/s/pathSecret0123456789/mcp' }; + const refusal = new SdkHttpError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 insufficient_scope', { + status: 403, statusText: 'Forbidden', text: 'denied for /api/mcp/s/pathSecret0123456789/mcp', + }); + const err = connection.requestFailure('tools/call "echo"', { method: 'tools/call', handedOff: true, answer: 'none' }, refusal); + assert.ok(err instanceof McplRequestError); + assert.equal(err.outcome, 'error-response'); + assert.equal((err.data as { httpStatus?: number }).httpStatus, 403); + assert.equal((err.data as { text?: unknown }).text, undefined); + assert.ok(!JSON.stringify(err.data).includes('pathSecret'), JSON.stringify(err.data)); +}); + diff --git a/test/mcp-recorded-text.test.ts b/test/mcp-recorded-text.test.ts index 8350fac45..ceaa4bd7c 100644 --- a/test/mcp-recorded-text.test.ts +++ b/test/mcp-recorded-text.test.ts @@ -9,8 +9,8 @@ import { Client, type Transport } from '@modelcontextprotocol/client'; import { EventEmitter } from 'node:events'; import type { ModuleContext } from '../src/index.js'; import { AgentFramework } from '../src/framework.js'; -import { McplServerConnection, McplUnreapedLaunchError } from '../src/mcpl/server-connection.js'; -import { ModernMcpConnection } from '../src/mcpl/modern-connection.js'; +import { McplProtocolVersionError, McplServerConnection, McplUnreapedLaunchError } from '../src/mcpl/server-connection.js'; +import { ModernMcpConnection, malformedLineDiagnostic } from '../src/mcpl/modern-connection.js'; import { MockMembrane } from './helpers/mock-membrane.js'; /** Discovery succeeds; the inventory request gets a normal echoed-path 404. */ @@ -52,7 +52,7 @@ test('both framework engines scrub failure, error and stderr records before trac framework.emitTrace = (event: unknown) => traces.push(event); framework.opsAlert = (_kind: string, _id: string, text: string) => alerts.push(text); const connection = Object.assign(new EventEmitter(), { id: config.id, willReconnect: true }); - framework[method](connection); + framework[method](connection, config); connection.emit('connect-failed', { error: `Cannot POST ${secret}`, attempt: 0, permanent: true }); connection.emit('reconnect-failed', { error: `Cannot POST ${secret}`, attempt: 5 }); connection.emit('error', new Error(`transport ${secret}`)); @@ -82,7 +82,8 @@ test('an SDK HTTP 404 error keeps its path but hides the secret in connection co modules: [{ name: 'watcher', async start(ctx: ModuleContext) { ctx.onTrace(event => traces.push(event as never)); }, async stop() {}, getTools: () => [], async handleToolCall() { return { success: false }; }, async onProcess() { return {}; } }], mcplServers: [{ id: 'echo', url: `http://127.0.0.1:${port}/api/mcp/s/${secret}/mcp`, reconnect: true, reconnectIntervalMs: 10, reconnectMaxIntervalMs: 20 }], }); - const deadline = Date.now() + 5000; + // The loop ends as soon as the record appears; the ceiling only bounds a hang. + const deadline = Date.now() + 30_000; while (!traces.some(e => e.type === 'ops:alert') && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10)); const failed = traces.filter(e => e.type === 'mcpl:server-connect-failed'); assert.ok(failed.some(e => e.attempt === 0)); @@ -143,7 +144,8 @@ test('legacy initial and reconnect console records redact known credentials', as const capabilities = { version: '0.5', pushEvents: true }; const connection = await McplServerConnection.connectWithReconnect(legacyConfig, capabilities); try { - const deadline = Date.now() + 5000; + // The loop ends as soon as the record appears; the ceiling only bounds a hang. + const deadline = Date.now() + 30_000; while (!lines.some(line => line.includes('reconnect failed:')) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10)); assert.ok(lines.some(line => line.includes('initial connect failed, will retry: transport redacted'))); assert.ok(lines.some(line => line.includes('reconnect failed: transport redacted'))); @@ -181,3 +183,105 @@ test('a real discover-then-tools/list 404 is redacted in the framework error tra rmSync(dir, { recursive: true, force: true }); } }); + +test("a tool call's failure, from either engine, reaches its caller and its trace without the server's credentials", async () => { + // A legacy MCPL server's call rejected with a close reason that quotes the + // url; the modern engine's transport text is requestFailure's, tested over HTTP. + const traces: Array<{ type: string; error?: string; stack?: string }> = []; + const framework = Object.create(AgentFramework.prototype) as any; + framework.mcplServerConfigs = new Map([[config.id, config]]); + framework.emitTrace = (event: { type: string }) => traces.push(event); + const server = { id: config.id, sendToolsCall: async () => { throw new Error(`socket closed: 1008 /api/mcp/s/${secret}/mcp`); } }; + framework.mcplServerRegistry = { getServer: (id: string) => (id === config.id ? server : null) }; + framework.modernServers = () => new Map(); + const result = await framework.executeMcplToolCall({ id: 'c1', name: 'mcpl--remote--echo', input: {} }, config); + assert.equal(result.success, false); + assert.match(String(result.error), /socket closed: 1008 \/api\/mcp\/s\/redacted\/mcp/); + assert.equal(framework.recordedForEveryServer(`closed ${secret}`), 'closed redacted'); + + // The model's dispatch: the tool:failed trace (message and stack) and the + // result the model gets. + const events: Array<{ type: string; result?: { error?: string } }> = []; + framework.pushEvent = (event: { type: string }) => events.push(event); + framework.toolLifecycleEmitter = null; + const modern = { callTool: async () => { throw new Error(`HTTP 500 at /api/mcp/s/${secret}/mcp with ${config.token}`); } }; + framework.modernServers = () => new Map([[config.id, modern]]); + framework.dispatchMcplToolCall('assistant', { id: 'c2', name: 'mcpl--remote--echo', input: {} }, config.id, 'mcpl--remote'); + await new Promise((resolve) => setTimeout(resolve, 20)); + const failed = traces.find((event) => event.type === 'tool:failed'); + assert.ok(failed, 'the failure is traced'); + for (const text of [failed!.error, failed!.stack, events[0]?.result?.error]) { + assert.ok(text && !text.includes(secret) && !text.includes(config.token), String(text)); + } + assert.match(String(events[0]?.result?.error), /HTTP 500 at \/api\/mcp\/s\/redacted\/mcp with redacted/); +}); + +test("a malformed line's diagnostic is redacted before it is cut, so no part of a credential across the cut is kept", () => { + const envSecret = 'envSecretValue0123456789'; + const line = JSON.stringify({ note: `${'p'.repeat(185)} ${envSecret}` }); + const diagnostic = malformedLineDiagnostic({ id: 'stdio-env', command: 'srv', env: { API_TOKEN: envSecret } } as never, line, new Error('not a message')); + assert.match(diagnostic, /sent a malformed JSON-RPC message/); + assert.ok(!diagnostic.includes(envSecret.slice(0, 6)), diagnostic); + assert.match(diagnostic, / redac/, 'the replacement, cut where the line is cut'); +}); + +test("the startup alert for a server with no common protocol revision carries the server's text without its credentials", async (t) => { + // The version verdict quotes the SDK's error, which is server text. + t.mock.method(ModernMcpConnection.prototype as never, 'open' as never, (async () => { + throw new McplProtocolVersionError(`MCP server "echo" does not support MCP 2025-11-25 (refused at /api/mcp/s/${secret}/mcp).`, 'rejected', null, null); + }) as never); + const dir = mkdtempSync(join(tmpdir(), 'mcp-startup-alert-')); + t.mock.method(console, 'error', () => {}); + const traces: Array<{ type: string; message?: string; error?: string }> = []; + let framework: AgentFramework | undefined; + try { + framework = await AgentFramework.create({ + storePath: join(dir, 'store'), membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'agent', model: 'test', systemPrompt: 'test' }], + modules: [{ name: 'watcher', async start(ctx: ModuleContext) { ctx.onTrace(event => traces.push(event as never)); }, async stop() {}, getTools: () => [], async handleToolCall() { return { success: false }; }, async onProcess() { return {}; } }], + mcplServers: [{ id: 'echo', url: `https://127.0.0.1:9/api/mcp/s/${secret}/mcp` }], + }); + const alert = traces.find(e => e.type === 'ops:alert'); + assert.ok(alert, JSON.stringify(traces.map(e => e.type))); + assert.match(String(alert!.message), /MCPL server unreachable at startup: .*refused at \/api\/mcp\/s\/redacted\/mcp/); + assert.ok(!JSON.stringify(traces).includes(secret)); + } finally { + await framework?.stop(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("a runtime connect's failed cleanup is traced without the server's credentials, and the host's error stays as raised", async (t) => { + const prototype = ModernMcpConnection.prototype as unknown as { open(): Promise }; + const open = t.mock.method(prototype, 'open', async () => { + throw new Error('connect refused'); + }); + const close = t.mock.method(ModernMcpConnection.prototype, 'close', async () => { + throw new Error(`reap failed for /api/mcp/s/${secret}/mcp`); + }); + const dir = mkdtempSync(join(tmpdir(), 'mcp-cleanup-trace-')); + t.mock.method(console, 'error', () => {}); + const traces: Array<{ type: string; error?: string }> = []; + let framework: AgentFramework | undefined; + try { + framework = await AgentFramework.create({ + storePath: join(dir, 'store'), membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'agent', model: 'test', systemPrompt: 'test' }], + modules: [{ name: 'watcher', async start(ctx: ModuleContext) { ctx.onTrace(event => traces.push(event as never)); }, async stop() {}, getTools: () => [], async handleToolCall() { return { success: false }; }, async onProcess() { return {}; } }], + }); + await assert.rejects(framework.connectMcplServer({ id: 'echo', url: `https://127.0.0.1:9/api/mcp/s/${secret}/mcp` }), (error: unknown) => { + assert.ok(error instanceof Error && error.message.includes(secret), 'the host gets the error as raised'); + return true; + }); + const traced = traces.find(e => e.type === 'mcpl:server-error'); + assert.ok(traced, JSON.stringify(traces.map(e => e.type))); + assert.match(String(traced!.error), /reap failed for \/api\/mcp\/s\/redacted\/mcp/); + assert.ok(!JSON.stringify(traces).includes(secret)); + } finally { + open.mock.restore(); + close.mock.restore(); + await framework?.stop().catch(() => {}); + rmSync(dir, { recursive: true, force: true }); + } +}); + diff --git a/test/mcpl-reconnect-reregistration.test.ts b/test/mcpl-reconnect-reregistration.test.ts index a7627a77f..6fb8439cf 100644 --- a/test/mcpl-reconnect-reregistration.test.ts +++ b/test/mcpl-reconnect-reregistration.test.ts @@ -133,7 +133,7 @@ async function makeHarness() { }; fw.discordAwarenessBarrier = null; fw.discordAwarenessBarrierGeneration = 0; - fw.wireMcplEvents(connection); + fw.wireMcplEvents(connection, config); await fw.registerMcplServerFeatures(config, connection); // The push handler responds synchronously when no awareness barrier is diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index 4e5befdb5..dfbd89a01 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -179,6 +179,30 @@ test('restartMcplServer refuses once stop() has begun, before it disconnects any }); }); +test('restartMcplServer of an id whose disconnect is in flight is refused before it tears anything down', async (t) => { + await withFramework(true, async (framework) => { + const disconnecting = framework.disconnectMcplServer('first'); + const disconnect = t.mock.method(framework, 'disconnectMcplServer'); + await assert.rejects( + framework.restartMcplServer('first'), + /MCP server "first" was not restarted: it is being disconnected; restart it once that has finished/, + ); + assert.equal(disconnect.mock.callCount(), 0, 'the restart started no disconnect of its own'); + disconnect.mock.restore(); + await disconnecting; + }); +}); + +test("a disconnect removes its server's prefix claim, and leaves one another server holds", async () => { + await withFramework(true, async (framework) => { + const internals = framework as unknown as { mcplPrefixMap: Map }; + // As after a restart under a new id: `second` holds the prefix `first` had. + internals.mcplPrefixMap.set('mcpl--first', 'second'); + await framework.disconnectMcplServer('first'); + assert.equal(internals.mcplPrefixMap.get('mcpl--first'), 'second', "the other server's claim stands"); + }); +}); + test('connectMcplServer rejects a duplicate id and a colliding prefix', async () => { await withFramework(true, async (framework) => { await assert.rejects( diff --git a/test/tools-observe-parser-conformance.test.ts b/test/tools-observe-parser-conformance.test.ts index 1a73a70c1..efdac0e66 100644 --- a/test/tools-observe-parser-conformance.test.ts +++ b/test/tools-observe-parser-conformance.test.ts @@ -81,10 +81,10 @@ function wireHarness() { const connection = new Connection('observer', null, transport); const framework = Object.create(AgentFramework.prototype) as { mcplServerConfigs: Map; - wireMcplEvents(connection: McplServerConnection): void; + wireMcplEvents(connection: McplServerConnection, config: McplServerConfig): void; }; framework.mcplServerConfigs = new Map([['observer', { id: 'observer', command: 'unused' }]]); - framework.wireMcplEvents(connection); + framework.wireMcplEvents(connection, { id: 'observer', command: 'unused' }); connection.establishGrant(new CapabilityGrant(new Set(['toolLifecycle.observe']), [])); connection.ready(); let nextId = 1; From 8c75a6790b229379c7925dcc4f4167c6cfc0558d Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 02:41:31 -0400 Subject: [PATCH 35/94] fix(mcp): a record of server text keeps the configuration its operation began under The tool-call failure on the model's dispatch, the manifest fetch line, the startup cleanup's close line and the tool refresh's error read a server's configuration when the record was made, after the operation's awaits. A server disconnected or reconfigured meanwhile then had its credentials read from nothing, or from its new configuration, so a late record could carry the old ones. Each now builds its redactor before it waits: the dispatch from the configuration it was dispatched under, the manifest handler on entry, and the startup cleanup and the tool refresh from every configuration present when they begin (everyServerRedactor). The event wiring's own records already kept the configuration the connection was made for, and its three console lines now use that redactor too. Tests: a dispatch whose server is removed while the call is in flight records its failure redacted; a redactor for every server made before a server's removal still replaces its credentials. Reading the map at record time fails each. --- src/framework.ts | 42 +++++++++++++++++++--------------- test/mcp-recorded-text.test.ts | 10 +++++++- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 3b780be8a..b8bac1403 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -13590,6 +13590,7 @@ export class AgentFramework { * rethrows the startup failure itself, so a cleanup failure never takes * its place. */ private async closeMcpConnectionsAfterFailedStartup(): Promise { + const recorded = this.everyServerRedactor(); const outcomes = await Promise.allSettled([ this.mcplServerRegistry?.closeAll() ?? Promise.resolve(), this.closeModernMcpConnections(), @@ -13597,7 +13598,7 @@ export class AgentFramework { for (const outcome of outcomes) { if (outcome.status === 'rejected') { console.error('[mcpl] after a failed startup, some MCP connections could not be closed:', - this.recordedForEveryServer(outcome.reason instanceof Error ? outcome.reason.message : outcome.reason)); + recorded(outcome.reason instanceof Error ? outcome.reason.message : outcome.reason)); } } } @@ -14152,6 +14153,9 @@ export class AgentFramework { connection: McplServerConnection, announce: { revision?: string; domains?: string[] }, ): Promise { + // The server's configuration as it is now, before any await: a record + // made later keeps it, whatever becomes of the server meanwhile. + const recorded = serverTextRedactor(this.mcplServerConfigs.get(connection.id) ?? {}); const st = this.manifestRefreshState.get(connection.id) ?? { inFlight: false, queued: false, lastCompletedAt: 0 }; this.manifestRefreshState.set(connection.id, st); @@ -14189,7 +14193,7 @@ export class AgentFramework { try { fetched = await connection.sendManifestRequest(); } catch (err) { - console.error(`[mcpl] ${connection.id}: mcpl/manifest fetch failed — previous manifest stands (§17.5): ${this.recordedFor(connection.id, err instanceof Error ? err.message : err)}`); + console.error(`[mcpl] ${connection.id}: mcpl/manifest fetch failed — previous manifest stands (§17.5): ${recorded(err instanceof Error ? err.message : err)}`); return; } if (!fetched || typeof fetched !== 'object') { @@ -14574,16 +14578,15 @@ export class AgentFramework { await this.connectMcplServer(config); } - /** A server's text as the framework records it ({@link serverTextRedactor}). */ - private recordedFor(serverId: string, text: unknown): string { - return redactServerText(text, this.mcplServerConfigs.get(serverId) ?? {}); - } - - /** Text that may come from any configured server, with every server's credentials replaced. */ - private recordedForEveryServer(text: unknown): string { - let recorded = typeof text === 'string' ? text : String(text); - for (const config of this.mcplServerConfigs.values()) recorded = redactServerText(recorded, config); - return recorded; + /** + * A redactor for text that may come from any configured server, with every + * server's credentials replaced. It is built from the configurations as + * they are when it's made, so an operation makes it before it waits, and a + * server removed meanwhile still has its credentials replaced. + */ + private everyServerRedactor(): (text: unknown) => string { + const redactors = [...this.mcplServerConfigs.values()].map(config => serverTextRedactor(config)); + return (text) => redactors.reduce((recorded, redact) => redact(recorded), typeof text === 'string' ? text : String(text)); } /** Why a connect for this id is refused, or null when the id is free. One @@ -14844,7 +14847,7 @@ export class AgentFramework { // payload and no authority; everything below acts on FETCHED content. connection.on('manifest-changed', (params: { revision?: string; domains?: string[] }) => { void this.handleManifestChanged(connection, params).catch((err) => { - console.error(`[mcpl] ${connection.id} manifest refresh failed:`, this.recordedFor(connection.id, err instanceof Error ? err.message : err)); + console.error(`[mcpl] ${connection.id} manifest refresh failed:`, recorded(err instanceof Error ? err.message : err)); }); }); @@ -14867,7 +14870,7 @@ export class AgentFramework { const parsed = parseToolObserveParams(params); if (!parsed.ok) { if (responder?.respondError) responder.respondError(-32602, parsed.message, parsed.data); - else console.error(`[mcpl] ${connection.id}: tools/observe rejected — ${this.recordedFor(connection.id, parsed.message)}`); + else console.error(`[mcpl] ${connection.id}: tools/observe rejected — ${recorded(parsed.message)}`); return; } connection.toolObserveFilter = parsed.rules; @@ -14959,7 +14962,7 @@ export class AgentFramework { } catch (error) { console.error( `MCPL server "${connection.id}" re-registration after reconnect failed:`, - this.recordedFor(connection.id, error instanceof Error ? error.message : error), + recorded(error instanceof Error ? error.message : error), ); } this.releaseMcplDataPlaneGate(awarenessBarrier); @@ -15372,13 +15375,14 @@ export class AgentFramework { this.mcplToolRefreshInFlight = true; const oldToolNames = new Set(this.mcplTools.map(t => t.name)); + const recorded = this.everyServerRedactor(); this.refreshMcplTools() .then(() => { this.emitMcplToolDiff(oldToolNames, serverId); }) .catch((error) => { - console.error('MCPL tool refresh error:', this.recordedForEveryServer(error instanceof Error ? error.message : error)); + console.error('MCPL tool refresh error:', recorded(error instanceof Error ? error.message : error)); }) .finally(() => { this.mcplToolRefreshInFlight = false; @@ -15458,10 +15462,12 @@ export class AgentFramework { const startTime = Date.now(); const args = (call.input && typeof call.input === 'object') ? call.input as Record : {}; + const dispatchRedactor = serverTextRedactor(config ?? {}); const failDispatch = (error: unknown) => { const raw = error instanceof Error ? error : new Error(String(error)); - // Server and transport text, as every record of it is. - const recorded = (text: unknown) => this.recordedFor(serverId, text); + // Server and transport text, as every record of it is, with the + // configuration the call was dispatched under. + const recorded = dispatchRedactor; const err = { message: recorded(raw.message), stack: raw.stack === undefined ? undefined : recorded(raw.stack) }; this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: err.message, stack: err.stack }); // RFC-007: transport error, closed connection or timeout — no result. diff --git a/test/mcp-recorded-text.test.ts b/test/mcp-recorded-text.test.ts index ceaa4bd7c..8f84d3166 100644 --- a/test/mcp-recorded-text.test.ts +++ b/test/mcp-recorded-text.test.ts @@ -197,7 +197,12 @@ test("a tool call's failure, from either engine, reaches its caller and its trac const result = await framework.executeMcplToolCall({ id: 'c1', name: 'mcpl--remote--echo', input: {} }, config); assert.equal(result.success, false); assert.match(String(result.error), /socket closed: 1008 \/api\/mcp\/s\/redacted\/mcp/); - assert.equal(framework.recordedForEveryServer(`closed ${secret}`), 'closed redacted'); + // A redactor for any server's text is made before an operation waits, so + // a server removed meanwhile still has its credentials replaced. + const everyServer = framework.everyServerRedactor(); + framework.mcplServerConfigs = new Map(); + assert.equal(everyServer(`closed ${secret}`), 'closed redacted'); + framework.mcplServerConfigs = new Map([[config.id, config]]); // The model's dispatch: the tool:failed trace (message and stack) and the // result the model gets. @@ -207,6 +212,9 @@ test("a tool call's failure, from either engine, reaches its caller and its trac const modern = { callTool: async () => { throw new Error(`HTTP 500 at /api/mcp/s/${secret}/mcp with ${config.token}`); } }; framework.modernServers = () => new Map([[config.id, modern]]); framework.dispatchMcplToolCall('assistant', { id: 'c2', name: 'mcpl--remote--echo', input: {} }, config.id, 'mcpl--remote'); + // The server is disconnected while the call is in flight: its failure is + // still recorded with the configuration it was dispatched under. + framework.mcplServerConfigs.delete(config.id); await new Promise((resolve) => setTimeout(resolve, 20)); const failed = traces.find((event) => event.type === 'tool:failed'); assert.ok(failed, 'the failure is traced'); From f62c02e561f90dad46056e9b3da19853c88e1cd5 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 03:05:48 -0400 Subject: [PATCH 36/94] fix(mcp): only a short pair needs its boundary; a 401 or 403 is a refusal however it's raised; the last unredacted records From a review of the final round: - The letter-or-digit boundary kept a credential raw after a percent escape (%2F), a JSON escape (\n) or an ANSI code. Only a short name=value pair (value under eight characters) needs it, so v=2 leaves dev=2 alone; every other string is replaced wherever it occurs. - A stdio server's env value is a credential when a part of its name ends with a credential word (GITHUB_TOKEN, API_KEY, PGPASSWORD, ACCESSTOKEN), not when the word sits inside a part (GIT_AUTHOR_NAME, KEYBOARD_LAYOUT, TOKENIZERS_PARALLELISM). - A token that can't be url-encoded (an unpaired surrogate) has no encoded form rather than throwing, and a value that can't be printed reads as a placeholder rather than throwing inside a catch. - A 401 or 403 is the server's refusal however the HTTP client raised it: a plain 403 (another SDK code than its step-up limit's) and an insufficient_scope 403 (InsufficientScopeError) were no-response with no status; they're error-response with data.httpStatus now. - The policy refusal's reason, in its log line and its mcpl:policy-refused trace, and the malformed-line diagnostic's parser message are redacted; the transport builds its redactor once; the tool refresh's error line prints the redacted stack it printed before. - restartMcplServer refuses a connect or disconnect in flight for the server it restarts whatever id it moves it to, not only a same-id one. The changelog fragments and README say what's derived now (env values, the token's url forms, both engines' tool-call failures and the trace), that a credential-shaped value under a plain name is warned about, and that a restart refuses before tearing the server down. Tests: escapes before a credential, env names, a surrogate token, unprintable values, a plain and an insufficient_scope 403 over HTTP, the policy reason, the parser's message, the cut's exact kept prefix, two servers' redactor, a stdio env value through both engines' wiring, a connect in flight for a renamed restart, and a disconnect releasing its own claim. Removing each piece fails a test (nine removals). --- README.md | 2 +- .../cleartext-mcp-credential-warning.added.md | 5 +- .../mcp-standard-tool-results.changed.md | 7 +++ .../redact-recorded-mcp-credentials.fixed.md | 2 +- src/framework.ts | 29 +++++---- src/mcpl/modern-connection.ts | 43 +++++++++---- src/mcpl/protocol-family.ts | 63 ++++++++++++------- test/mcp-modern-http.test.ts | 23 +++++++ test/mcp-protocol-family.test.ts | 31 ++++++--- test/mcp-recorded-text.test.ts | 35 ++++++++++- test/mcpl-config-diagnostics.test.ts | 29 +++++++++ test/mcpl-runtime-lifecycle.test.ts | 19 ++++++ 12 files changed, 227 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 27cff15bb..3a6320f54 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,7 @@ supports, or its error text when it lists none, and when the list includes `2026-07-28` it says to configure the server with `protocol: 'modern'` (stdio) or reach it over http(s). -Server and transport diagnostics recorded by the framework remove known credentials from the configured URL and static `token`. This covers connection traces, console errors, stderr traces and ops alerts, plus transport failures returned by modern tool calls. Credential-like URL path segments and query names, credential-shaped or credential-named query values, and WebSocket userinfo are replaced in their encoded and decoded forms. Other non-empty query parameters are replaced as whole `name=value` strings. Standalone strings shorter than eight characters stay unchanged because they may be ordinary words; short query values are still removed with their parameter names. The cached token from an `accessProvider` and secrets the framework does not know remain outside this filtering. JSON-RPC tool answers and direct host connection errors pass through as sent. +Server and transport text the framework records has the server's configured credentials replaced, in both engines. That covers connection traces, console lines, stderr traces, ops alerts, and a tool call's failure as its caller, the model and the `tool:failed` trace get it. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values, and a `ws://` url's userinfo, in their encoded and decoded forms, and every other non-empty query parameter as its whole `name=value`; the static `token`, raw and url-encoded; and a stdio server's `env` values whose names end a part with a credential word, such as `GITHUB_TOKEN` or `PGPASSWORD`. Standalone strings shorter than eight characters stay unchanged because they may be ordinary words; short query values are still removed with their parameter names. The token an `accessProvider` fetches, and secrets the configuration doesn't hold, remain outside this filtering. JSON-RPC tool answers, and the error a host's own connect raises, pass through as sent. #### Feature sets diff --git a/changelog.d/cleartext-mcp-credential-warning.added.md b/changelog.d/cleartext-mcp-credential-warning.added.md index 6b801f0c2..3a58775c4 100644 --- a/changelog.d/cleartext-mcp-credential-warning.added.md +++ b/changelog.d/cleartext-mcp-credential-warning.added.md @@ -3,8 +3,9 @@ machine: its `token` or `accessProvider`, or a secret the url carries itself (a `ws://` url's userinfo, a query parameter whose name says credential: `token`, `key`, `secret`, `password`, `auth` or `signature`, - a query parameter name that may itself be one, or a path segment that - looks like one, as the redacted url judges it). A name that may itself be + or whose value is shaped like one under a plain name, a query parameter + name that may itself be one, or a path segment that looks like one, as + the redacted url judges it). A name that may itself be a credential is counted in the warning, never quoted. An `http(s)://` url with userinfo is refused instead, since the HTTP client can't send it and its error would quote it. Loopback hosts are diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index e4dfb7fd2..39f9077f0 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -102,6 +102,13 @@ up when its handshake settles (a modern connect in flight, or such a legacy one, fails with "was not connected"); then it removes what was registered. A teardown never removes what a later connection registered. + `restartMcplServer` refuses, before it tears the running server down, what + its connect would refuse: a configuration with no usable binding, a new id + another server holds, a tool prefix another server or a module holds, a + framework whose `stop()` has begun, and a connect or disconnect of the + server in flight. On main a restart disconnected first, so a refused + connect left the server gone; now the server keeps running. A disconnect + releases its tool-prefix claim only while its own server holds it. - A server configuration naming no usable transport is now an error before anything is spawned or dialed. That covers a `transport` that doesn't match the url's scheme, `transport: 'http'` without a url, and an diff --git a/changelog.d/redact-recorded-mcp-credentials.fixed.md b/changelog.d/redact-recorded-mcp-credentials.fixed.md index 85d447944..6b2ed989d 100644 --- a/changelog.d/redact-recorded-mcp-credentials.fixed.md +++ b/changelog.d/redact-recorded-mcp-credentials.fixed.md @@ -1 +1 @@ -- Server and transport diagnostics recorded by the framework redact known URL credentials and the configured static token, including encoded and decoded forms. An HTTP error that echoes a credential-bearing request path no longer copies the credential into connection traces, console errors, ops alerts or modern tool-call failures. Stderr traces use the same filtering. Short standalone strings remain unchanged; short query values are removed as whole `name=value` strings. Access-provider cached tokens and unknown secrets are outside this filtering, and JSON-RPC tool answers and direct host connection errors retain their pass-through contract. +- Server and transport text the framework records has the server's configured credentials replaced, in both engines: connection traces, console lines, stderr traces, the ops alert (and so `logs/failures.log` and the webhook), a tool call's failure as its caller and the model get it, and the `tool:failed` trace's message and stack. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values (each as written and decoded), every other non-empty parameter as its whole `name=value`, and a `ws://` url's username and password; the static `token`, raw and as a url carries it, percent- or form-encoded; and a stdio server's `env` values whose names end a part with a credential word (`GITHUB_TOKEN`, `API_KEY`, `PGPASSWORD`). A string replaced on its own has at least eight characters, since a shorter one can't be told from ordinary text; a short query value is replaced only with its name, as one `name=value`, and only where no letter or digit adjoins it. A connection's event records use a redactor built once for it, and an operation's records one built when the operation begins, so a server removed or reconfigured meanwhile doesn't change what they replace. A 401 or 403 is classified as the server's refusal however the HTTP client raised it, and its response body is left out of the error's data (its message, built from the body, is redacted like any record). A token an access provider fetches, and credentials the configuration doesn't hold, are outside this. JSON-RPC answers, and the error a host's own connect raises, pass as they are. diff --git a/src/framework.ts b/src/framework.ts index b8bac1403..dfcd7cd23 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -14133,13 +14133,17 @@ export class AgentFramework { * branch actually taken (anything but `close` is treated as `mcp-only`) * and a non-string `reason` is dropped. */ - private static policyRefusalFields(receipt: { fallback?: unknown; reason?: unknown }): { + private static policyRefusalFields( + receipt: { fallback?: unknown; reason?: unknown }, + recorded: (text: unknown) => string, + ): { fallback: 'close' | 'mcp-only'; reason: string | null; } { return { fallback: receipt.fallback === 'close' ? 'close' : 'mcp-only', - reason: typeof receipt.reason === 'string' ? receipt.reason : null, + // The server's own words, recorded as every server text is. + reason: typeof receipt.reason === 'string' ? recorded(receipt.reason) : null, }; } @@ -14265,7 +14269,7 @@ export class AgentFramework { type: 'mcpl:policy-refused', serverId: connection.id, phase: 'manifest-change', - ...AgentFramework.policyRefusalFields(receipt), + ...AgentFramework.policyRefusalFields(receipt, recorded), }); if (fallback === 'close') { await connection.close(); @@ -14377,13 +14381,13 @@ export class AgentFramework { if (receipt && receipt.accepted === false) { const fallback = receipt.fallback ?? 'mcp-only'; console.error( - `[mcpl] ${config.id} refused initial policy (${receipt.reason ?? 'no reason'}) — fallback: ${fallback}`, + `[mcpl] ${config.id} refused initial policy (${redactServerText(receipt.reason ?? 'no reason', config)}) — fallback: ${fallback}`, ); this.emitTrace({ type: 'mcpl:policy-refused', serverId: config.id, phase: 'initial', - ...AgentFramework.policyRefusalFields(receipt), + ...AgentFramework.policyRefusalFields(receipt, (text) => redactServerText(text, config)), }); if (fallback === 'close') { await connection.close(); @@ -14563,14 +14567,17 @@ export class AgentFramework { throw new Error(`MCPL server "${id}" is not configured`); } AgentFramework.usableConfigFamily(config); + const inFlight = this.mcpServerOps?.get(id); + if (inFlight) { + // A connect or disconnect of the server being restarted, in flight, + // whatever id the restart moves it to: the restart's own disconnect + // would wait behind it, and its connect would then meet whatever that + // operation left, after the server was torn down. + throw new Error(`MCP server "${id}" was not restarted: it is being ${inFlight.kind === 'disconnect' ? 'disconnected' : 'connected'}; restart it once that has finished`); + } if (config.id !== id) { const taken = this.mcpServerIdTaken(config.id); if (taken) throw new Error(taken); - } else if (this.mcpServerOps?.get(id)) { - // A connect or disconnect of this id in flight: the restart's own - // disconnect would queue behind it and then be refused, after the - // server was torn down. - throw new Error(`MCP server "${id}" was not restarted: it is being ${this.mcpServerOps.get(id)!.kind === 'disconnect' ? 'disconnected' : 'connected'}; restart it once that has finished`); } const collision = this.mcplPrefixCollision(config, id); if (collision) throw new Error(collision); @@ -15382,7 +15389,7 @@ export class AgentFramework { this.emitMcplToolDiff(oldToolNames, serverId); }) .catch((error) => { - console.error('MCPL tool refresh error:', recorded(error instanceof Error ? error.message : error)); + console.error('MCPL tool refresh error:', recorded(error instanceof Error ? (error.stack ?? error.message) : error)); }) .finally(() => { this.mcplToolRefreshInFlight = false; diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index c64096c60..e97ed30b8 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -43,6 +43,7 @@ import { EventEmitter } from 'node:events'; import { Client, + InsufficientScopeError, SdkError, SdkErrorCode, SdkHttpError, @@ -60,7 +61,7 @@ import { import { StdioTransport, type McplTransport } from './transport.js'; import { McplProtocolVersionError, McplRequestError } from './server-connection.js'; -import { LEGACY_MCP_PROTOCOL_VERSION, MAX_TIMER_MS, MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, describeCredentialError, redactServerText } from './protocol-family.js'; +import { LEGACY_MCP_PROTOCOL_VERSION, MAX_TIMER_MS, MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, describeCredentialError, redactServerText, serverTextRedactor } from './protocol-family.js'; import type { McplServerConfig, McpToolDefinition } from './types.js'; const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; @@ -228,10 +229,16 @@ class ObservedTransport implements Transport { * line's first 200 characters, after the configuration's credentials are * replaced, so a credential across the cut can't leave its first part. */ -export function malformedLineDiagnostic(config: McplServerConfig, text: string, error: unknown): string { - const shown = redactServerText(text, config); - return `MCP server "${config.id}" sent a malformed JSON-RPC message (${shown.length > 200 ? `${shown.slice(0, 200)}…` : shown}): ` + - (error instanceof Error ? error.message.split('\n')[0] : String(error)); +export function malformedLineDiagnostic( + config: McplServerConfig, + text: string, + error: unknown, + recorded: (text: unknown) => string = serverTextRedactor(config), +): string { + // The parser's message can quote the line, so it's redacted too. + const shown = recorded(text); + const why = recorded(error instanceof Error ? error.message.split('\n')[0] : error); + return `MCP server "${config.id}" sent a malformed JSON-RPC message (${shown.length > 200 ? `${shown.slice(0, 200)}…` : shown}): ${why}`; } /** @@ -248,10 +255,15 @@ class SpawnerStdioTransport implements Transport { onmessage?: (message: T, extra?: MessageExtraInfo) => void; private line: McplTransport | null = null; + /** Built once for the transport: every malformed line uses it. */ + private readonly recorded: (text: unknown) => string; + constructor( private readonly config: McplServerConfig, private readonly onStderr: (line: string) => void, - ) {} + ) { + this.recorded = serverTextRedactor(config); + } async start(): Promise { const line = StdioTransport.spawn(this.config); @@ -271,7 +283,7 @@ class SpawnerStdioTransport implements Transport { try { message = parseJSONRPCMessage(parsed); } catch (error) { - this.onerror?.(new Error(malformedLineDiagnostic(this.config, text, error))); + this.onerror?.(new Error(malformedLineDiagnostic(this.config, text, error, this.recorded))); return; } this.onmessage?.(message); @@ -921,18 +933,25 @@ export class ModernMcpConnection extends EventEmitter { 'no-response', ); } - if (error instanceof SdkHttpError && (error.code === SdkErrorCode.ClientHttpAuthentication || error.code === SdkErrorCode.ClientHttpForbidden)) { + // An HTTP refusal of the credential: 401 or 403, however the SDK raised + // it. It raises a plain 403 under another code than its step-up limit's, + // and an insufficient_scope 403 as InsufficientScopeError when no OAuth + // provider is configured. + const refusedWith = error instanceof SdkHttpError && (error.status === 401 || error.status === 403) + ? error.status + : error instanceof InsufficientScopeError ? 403 : null; + if (refusedWith !== null) { if (record.continued) { return new McplRequestError( `${prefix} did not finish ${what}: it asked for another round (input_required), and the server refused a later one: ` + - `HTTP ${error.status} (${message}). The outcome is unknown: the server may already have acted on the call. ${advice}`, + `HTTP ${refusedWith} (${message}). The outcome is unknown: the server may already have acted on the call. ${advice}`, 'no-response', ); } // The response body (the SDK's `text`) is the server's text and can - // echo the request's url, so it's left out; the status stays. - const { text: _body, ...data } = (error.data && typeof error.data === 'object' ? error.data : {}) as Record; - return new McplRequestError(`${prefix} refused ${what}: HTTP ${error.status} (${message})`, 'error-response', undefined, { ...data, httpStatus: error.status }); + // echo the request's url, so it's left out of the data; the status stays. + const { text: _body, ...data } = (error instanceof SdkHttpError && error.data && typeof error.data === 'object' ? error.data : {}) as Record; + return new McplRequestError(`${prefix} refused ${what}: HTTP ${refusedWith} (${message})`, 'error-response', undefined, { ...data, httpStatus: refusedWith }); } if (deadline) { const outcome = record.method === 'tools/list' diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index 52cb72114..3c8bcafa2 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -133,23 +133,30 @@ export function redactServerText(text: unknown, config: Pick): (text: unknown) => string { + // Strings replaced wherever they occur, and short name=value pairs, which + // are replaced only where no letter or digit adjoins them, so a pair such as + // v=2 leaves dev=2 alone. const secrets = new Set(); + const shortPairs = new Set(); const standalone = (value: string): void => { if (value.length >= 8) secrets.add(value); }; const decode = (value: string, query = false): string => { try { return decodeURIComponent(query ? value.replace(/\+/g, ' ') : value); } catch { return value; } }; // The token as sent: raw in a header, and percent- or form-encoded where a - // transport puts it in a url. - for (const token of [config.token ?? '']) { - standalone(token); - standalone(encodeURIComponent(token)); - standalone(new URLSearchParams({ t: token }).toString().slice(2)); - } + // transport puts it in a url (a token that can't be encoded, such as one + // with an unpaired surrogate, has no encoded form). + const token = config.token ?? ''; + standalone(token); + try { standalone(encodeURIComponent(token)); } catch { /* no percent-encoded form */ } + try { standalone(new URLSearchParams({ t: token }).toString().slice(2)); } catch { /* no form-encoded form */ } // A stdio server's environment values whose names say they carry a - // credential, by the same rule as the url's parameters. + // credential: a part of the name that ends with a credential word + // (GITHUB_TOKEN, API_KEY, PGPASSWORD, ACCESSTOKEN), and not one where the + // word only begins or sits inside a part (GIT_AUTHOR_NAME, KEYBOARD_LAYOUT, + // TOKENIZERS_PARALLELISM). for (const [name, value] of Object.entries(config.env ?? {})) { - if (CREDENTIAL_PARAM.test(name)) standalone(value); + if (name.split(/[_\-.]/).some((part) => CREDENTIAL_NAME_PART.test(part))) standalone(value); } let url: URL | undefined; try { url = new URL(config.url ?? ''); } catch { /* Still remove the static token. */ } @@ -175,8 +182,9 @@ export function serverTextRedactor(config: Pick= 8 ? secrets : shortPairs).add(pair); + } } } if (url.protocol === 'ws:' || url.protocol === 'wss:') { @@ -186,16 +194,25 @@ export function serverTextRedactor(config: Pick (typeof text === 'string' ? text : String(text)); - if (secrets.size === 0) return asText; - const alternatives = [...secrets].sort((a, b) => b.length - a.length) - .map(value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); - // Only where no letter or digit adjoins it, so a short name=value such as - // v=2 leaves dev=2 alone. - const pattern = new RegExp(`(? value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const alternatives = [ + ...[...secrets].map(value => ({ value, source: escape(value) })), + ...[...shortPairs].map(value => ({ value, source: `(? b.value.length - a.value.length).map(({ source }) => source); + const pattern = new RegExp(alternatives.join('|'), 'g'); return (text) => asText(text).replace(pattern, 'redacted'); } +/** Any value as text: a value that can't be printed reads as a placeholder. */ +function asText(text: unknown): string { + if (typeof text === 'string') return text; + try { return String(text); } catch { return '[a value that cannot be printed]'; } +} + +/** A part of an environment variable's name that ends with a credential word. */ +const CREDENTIAL_NAME_PART = /(?:token|key|secret|password|passwd|auth|signature|credentials?)$/i; + /** * A credential provider's failure as a log line may show it: the error's * class and its message's length, never the text, which may quote the @@ -353,12 +370,12 @@ function isLoopbackHost(hostname: string): boolean { * `ws://` url whose host isn't loopback crosses the network in cleartext: * `token` or `accessProvider` (in the `Authorization` header, or the * WebSocket `?token=` query), and a secret the url carries itself (a ws:// - * url's userinfo, a query parameter whose name says it's a credential or may - * itself be one, or a path segment that looks like one, as {@link redactUrl} - * judges it). A name that may itself be a credential is counted, never - * quoted. That can be - * deliberate, on a private network or behind a TLS-terminating proxy, so it - * is a warning and never a refusal. + * url's userinfo, a query parameter whose name says it's a credential, or + * whose value is shaped like one under a plain name, a query parameter name + * that may itself be one, or a path segment that looks like one, as + * {@link redactUrl} judges it). A name that may itself be a credential is + * counted, never quoted. That can be deliberate, on a private network or + * behind a TLS-terminating proxy, so it is a warning and never a refusal. */ export function serverConfigWarnings(config: McplServerConfig): string[] { let transport: McpTransportKind; diff --git a/test/mcp-modern-http.test.ts b/test/mcp-modern-http.test.ts index 0c51970f0..d43a70d72 100644 --- a/test/mcp-modern-http.test.ts +++ b/test/mcp-modern-http.test.ts @@ -259,3 +259,26 @@ test("HTTP: a 403 refusal's response body, which can echo the url, stays out of assert.ok(!JSON.stringify(err.data).includes('pathSecret'), JSON.stringify(err.data)); }); +for (const [what, headers] of [ + ['a plain 403', {}], + ['an insufficient_scope 403', { 'www-authenticate': 'Bearer error="insufficient_scope", scope="tools"' }], +] as const) { + test(`HTTP: ${what} on a first-round call is an error-response with the status in data, over the wire`, async () => { + const { url, server } = await startServer(() => true, (message, res) => { + if (message.method !== 'tools/call') return false; + res.writeHead(403, { 'content-type': 'text/plain', ...headers }).end('forbidden here'); + return true; + }); + cleanups.push(() => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); })); + const connection = await ModernMcpConnection.connect({ id: 'remote', url, token: 't' }); + cleanups.push(() => connection.close()); + await assert.rejects(connection.callTool('echo', { text: 'x' }), (err: unknown) => { + assert.ok(err instanceof McplRequestError); + assert.equal(err.outcome, 'error-response', err.message); + assert.equal((err.data as { httpStatus?: number }).httpStatus, 403); + assert.equal((err.data as { text?: unknown }).text, undefined); + return true; + }); + }); +} + diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index 787182c16..170f9fc6c 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -322,26 +322,41 @@ test('recorded server text keeps short standalone values and unrelated words, bu test('recorded server text: the token as a url carries it, credential-named env values, only where nothing adjoins, and any value', () => { // The static token, raw and as a url carries it, percent- or form-encoded. - const token = 'tok+en/123='; + const token = 'tok en+x/123='; const tokenConfig = cfg({ token }); - for (const form of [token, encodeURIComponent(token), 'tok%2Ben%2F123%3D']) { + // Percent-encoded (%20 for the space) and form-encoded (+ for it) differ here. + for (const form of [token, 'tok%20en%2Bx%2F123%3D', 'tok+en%2Bx%2F123%3D']) { assert.equal(redactServerText(`refused: ${form}`, tokenConfig), 'refused: redacted', form); } - // A stdio server's env: a value whose name says it's a credential, and no other. - const env = cfg({ command: 'srv', env: { GITHUB_TOKEN: 'ghp_abcdef123456', API_KEY: 'k3y-abcdefgh', PATH: '/usr/local/bin' } }); - assert.equal(redactServerText('auth ghp_abcdef123456 k3y-abcdefgh /usr/local/bin', env), 'auth redacted redacted /usr/local/bin'); - // Only where no letter or digit adjoins a string: a short pair stays whole elsewhere. - const pair = cfg({ url: 'https://h/mcp?v=2' }); + // A stdio server's env: a value whose name has a credential word as a whole + // part of it, and no other (GIT_AUTHOR_NAME holds 'auth' inside a word). + const env = cfg({ command: 'srv', env: { + GITHUB_TOKEN: 'ghp_abcdef123456', API_KEY: 'k3y-abcdefgh', PGPASSWORD: 'pgpass-abcdefgh', ACCESSTOKEN: 'acc-abcdefgh12', + PATH: '/usr/local/bin', GIT_AUTHOR_NAME: 'Somebody Longname', KEYBOARD_LAYOUT: 'dvorak-programmer', TOKENIZERS_PARALLELISM: 'parallel-false', + } }); + assert.equal( + redactServerText('auth ghp_abcdef123456 k3y-abcdefgh pgpass-abcdefgh acc-abcdefgh12 /usr/local/bin Somebody Longname dvorak-programmer parallel-false', env), + 'auth redacted redacted redacted redacted /usr/local/bin Somebody Longname dvorak-programmer parallel-false'); + // A short pair only where no letter or digit adjoins it; any other string + // wherever it occurs, after a percent escape, an ANSI code or a JSON escape too. + const pair = cfg({ url: 'https://h/api/s/pathSecret0123456789/mcp?v=2' }); assert.equal(redactServerText('dev=2 ready; v=2 sent', pair), 'dev=2 ready; redacted sent'); + assert.equal(redactServerText('a%2FpathSecret0123456789 \u001b[31mpathSecret0123456789 "\\npathSecret0123456789"', pair), + 'a%2Fredacted \u001b[31mredacted "\\nredacted"'); // A ws:// url's userinfo, as for wss://. const ws = cfg({ url: 'ws://wsuser1234:wspass5678@h/mcp' }); assert.equal(redactServerText('connect wsuser1234 wspass5678', ws), 'connect redacted redacted'); // A url with a stray percent sign: its value is kept as written. const stray = cfg({ url: 'https://h/mcp?note=100%&key=abcdefgh1234' }); assert.equal(redactServerText('note=100% key=abcdefgh1234', stray), 'redacted redacted'); - // Any value reads as text: a rejection that isn't an Error doesn't throw. + // Any value reads as text: a rejection that isn't an Error doesn't throw, + // and neither does one that can't be printed. assert.equal(redactServerText(undefined, tokenConfig), 'undefined'); assert.equal(redactServerText({ toString: () => `obj ${token}` }, tokenConfig), 'obj redacted'); + assert.equal(redactServerText(Object.create(null), tokenConfig), '[a value that cannot be printed]'); + assert.equal(redactServerText({ toString() { throw new Error('no'); } }, tokenConfig), '[a value that cannot be printed]'); + // A token that can't be url-encoded (an unpaired surrogate) is still replaced as written. + assert.equal(redactServerText('x abcdefgh\ud800ij', cfg({ token: 'abcdefgh\ud800ij' })), 'x redacted'); }); test('the cleartext warning counts a credential-shaped value under a plain name, as redaction does', () => { diff --git a/test/mcp-recorded-text.test.ts b/test/mcp-recorded-text.test.ts index 8f84d3166..76d160244 100644 --- a/test/mcp-recorded-text.test.ts +++ b/test/mcp-recorded-text.test.ts @@ -65,6 +65,22 @@ test('both framework engines scrub failure, error and stderr records before trac } }); +test("both engines' wiring redacts a stdio server's credential env value in its stderr records", () => { + const stdio = { id: 'local', command: 'srv', env: { API_TOKEN: 'envToken0123456789', PATH: '/usr/local/bin' } }; + for (const method of ['wireMcplEvents', 'wireModernEvents']) { + const traces: unknown[] = []; + const framework = Object.create(AgentFramework.prototype) as any; + framework.mcplServerConfigs = new Map([[stdio.id, stdio]]); + framework.emitTrace = (event: unknown) => traces.push(event); + framework.opsAlert = () => {}; + const connection = Object.assign(new EventEmitter(), { id: stdio.id, willReconnect: false }); + framework[method](connection, stdio); + connection.emit('stderr', { line: 'auth with envToken0123456789 from /usr/local/bin' }); + assert.equal(traces.length, 1, method); + assert.match(JSON.stringify(traces[0]), /auth with redacted from \/usr\/local\/bin/, method); + } +}); + test('an SDK HTTP 404 error keeps its path but hides the secret in connection console, traces and ops alerts', async (t) => { failConnectWithHttpBody(t); const dir = mkdtempSync(join(tmpdir(), 'mcp-recorded-text-')); @@ -199,9 +215,11 @@ test("a tool call's failure, from either engine, reaches its caller and its trac assert.match(String(result.error), /socket closed: 1008 \/api\/mcp\/s\/redacted\/mcp/); // A redactor for any server's text is made before an operation waits, so // a server removed meanwhile still has its credentials replaced. + const other = { id: 'other', url: 'https://g/api/s/otherSecret0123456789/mcp' }; + framework.mcplServerConfigs = new Map([[config.id, config], [other.id, other]]); const everyServer = framework.everyServerRedactor(); framework.mcplServerConfigs = new Map(); - assert.equal(everyServer(`closed ${secret}`), 'closed redacted'); + assert.equal(everyServer(`closed ${secret} and otherSecret0123456789`), 'closed redacted and redacted'); framework.mcplServerConfigs = new Map([[config.id, config]]); // The model's dispatch: the tool:failed trace (message and stack) and the @@ -215,7 +233,10 @@ test("a tool call's failure, from either engine, reaches its caller and its trac // The server is disconnected while the call is in flight: its failure is // still recorded with the configuration it was dispatched under. framework.mcplServerConfigs.delete(config.id); - await new Promise((resolve) => setTimeout(resolve, 20)); + const until = Date.now() + 30_000; + while (!(traces.some((event) => event.type === 'tool:failed') && events.length > 0) && Date.now() < until) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } const failed = traces.find((event) => event.type === 'tool:failed'); assert.ok(failed, 'the failure is traced'); for (const text of [failed!.error, failed!.stack, events[0]?.result?.error]) { @@ -227,9 +248,17 @@ test("a tool call's failure, from either engine, reaches its caller and its trac test("a malformed line's diagnostic is redacted before it is cut, so no part of a credential across the cut is kept", () => { const envSecret = 'envSecretValue0123456789'; const line = JSON.stringify({ note: `${'p'.repeat(185)} ${envSecret}` }); + // The credential starts this far into the line; a cut at 200 would keep + // the first (200 - start) of its characters. + const start = line.indexOf(envSecret); + const kept = envSecret.slice(0, 200 - start); + assert.ok(kept.length > 0 && kept.length < envSecret.length, `the cut falls inside the credential (${kept.length})`); const diagnostic = malformedLineDiagnostic({ id: 'stdio-env', command: 'srv', env: { API_TOKEN: envSecret } } as never, line, new Error('not a message')); assert.match(diagnostic, /sent a malformed JSON-RPC message/); - assert.ok(!diagnostic.includes(envSecret.slice(0, 6)), diagnostic); + assert.ok(!diagnostic.includes(kept), diagnostic); + // The parser's own message can quote the line; it's redacted too. + const quoting = malformedLineDiagnostic({ id: 'stdio-env', command: 'srv', env: { API_TOKEN: envSecret } } as never, '{}', new Error(`unexpected ${envSecret}`)); + assert.ok(!quoting.includes(envSecret), quoting); assert.match(diagnostic, / redac/, 'the replacement, cut where the line is cut'); }); diff --git a/test/mcpl-config-diagnostics.test.ts b/test/mcpl-config-diagnostics.test.ts index 70188a519..9e846eaaa 100644 --- a/test/mcpl-config-diagnostics.test.ts +++ b/test/mcpl-config-diagnostics.test.ts @@ -332,6 +332,35 @@ test('FeatureSetManager: each §6.4 disablement is traced beside its console lin assert.equal(quiet.lines.length, 3, 'the console line stays'); }); +test("a refused initial policy's reason is recorded without the server's configured credentials", async () => { + const fw = Object.create(AgentFramework.prototype) as any; + fw.traceListeners = []; + fw.featureSetManager = new FeatureSetManager(); + fw.checkpointManager = null; + const traces: Traced[] = []; + fw.onTrace((e: Traced) => traces.push(e)); + const secret = 'srvSecret0123456789'; + const connection = { + id: 'srv', + capabilities: { version: '0.5', pushEvents: true } as unknown as McplCapabilities, + mcpToolsAdvertised: false, + establishGrant: () => {}, + sendFeatureSetsUpdateRequest: async () => ({ accepted: false, fallback: 'mcp-only', reason: `policy for ${secret} too narrow` }), + close: async () => {}, + }; + const capture = captureConsoleError(); + try { + await fw.registerMcplServerFeatures({ id: 'srv', url: `wss://h/mcp/${secret}` } as McplServerConfig, connection); + } finally { + capture.stop(); + } + const refused = traces.filter((t) => t.type === 'mcpl:policy-refused'); + assert.equal(refused.length, 1); + assert.equal((refused[0] as { reason?: string }).reason, 'policy for redacted too narrow'); + assert.ok(capture.lines.some((l) => l.includes('srv refused initial policy (policy for redacted too narrow)')), capture.lines.join('\n')); + assert.ok(!capture.lines.join('\n').includes(secret)); +}); + test('registerMcplServerFeatures: a refused initial policy is traced, and fallback close closes', async () => { for (const fallback of ['close', 'mcp-only'] as const) { const fw = Object.create(AgentFramework.prototype) as any; diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index dfbd89a01..8bb01254f 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -196,13 +196,32 @@ test('restartMcplServer of an id whose disconnect is in flight is refused before test("a disconnect removes its server's prefix claim, and leaves one another server holds", async () => { await withFramework(true, async (framework) => { const internals = framework as unknown as { mcplPrefixMap: Map }; + assert.equal(internals.mcplPrefixMap.get('mcpl--first'), 'first'); + await framework.disconnectMcplServer('first'); + assert.equal(internals.mcplPrefixMap.has('mcpl--first'), false, 'its own claim is released'); // As after a restart under a new id: `second` holds the prefix `first` had. + await framework.connectMcplServer(serverConfig('first') as never); internals.mcplPrefixMap.set('mcpl--first', 'second'); await framework.disconnectMcplServer('first'); assert.equal(internals.mcplPrefixMap.get('mcpl--first'), 'second', "the other server's claim stands"); }); }); +test('restartMcplServer of an id whose connect is in flight is refused before it tears anything down', async (t) => { + // MCPL is up, so the connect holds its id before its first await. + await withFramework(true, async (framework) => { + const connecting = framework.connectMcplServer(serverConfig('later') as never); + const disconnect = t.mock.method(framework, 'disconnectMcplServer'); + await assert.rejects( + framework.restartMcplServer('later', serverConfig('renamed') as never), + /MCP server "later" was not restarted: it is being connected; restart it once that has finished/, + ); + assert.equal(disconnect.mock.callCount(), 0, 'the restart started no disconnect of its own'); + disconnect.mock.restore(); + await connecting; + }); +}); + test('connectMcplServer rejects a duplicate id and a colliding prefix', async () => { await withFramework(true, async (framework) => { await assert.rejects( From 5dc695f5ae17ac9b3dab95278f8861e43535243b Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 03:13:39 -0400 Subject: [PATCH 37/94] fix(mcp): the policy request's records use one redactor taken before it waits, and log the fallback the host takes Initial and post-manifest policy requests recorded server text through sibling paths the earlier rounds left: an unanswered request's error, a refusal's fallback string as the server sent it, a degraded acceptance's unavailable-feature effect, and (for the reason) a redactor built after the await from a configuration the host could change meanwhile. registerMcplServerFeatures now takes one redactor at entry and uses it for every line and trace it makes of what the server said. A refusal logs and traces the fallback the host actually takes (close or mcp-only), not the server's string. The post-manifest refusal decides and logs by the same normalized fallback. redactServerText's doc says which strings carry the adjacency rule (a short name=value pair alone) and that it's a host's own connect whose error passes as raised. Test: an unanswered request quoting the url, a refusal with a fallback the host doesn't know, a degraded acceptance quoting it, and a url replaced while the request waits: no record carries the credential, and the odd fallback is logged as mcp-only. Removing each piece fails it (four removals). --- src/framework.ts | 24 ++++++++++++------- src/mcpl/protocol-family.ts | 14 ++++++----- test/mcpl-config-diagnostics.test.ts | 36 ++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index dfcd7cd23..486d0edeb 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -14263,13 +14263,14 @@ export class AgentFramework { try { const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams); if (receipt && receipt.accepted === false) { - const fallback = receipt.fallback ?? 'mcp-only'; + const refusal = AgentFramework.policyRefusalFields(receipt, recorded); + const fallback = refusal.fallback; console.error(`[mcpl] ${connection.id} refused post-manifest policy — fallback: ${fallback}`); this.emitTrace({ type: 'mcpl:policy-refused', serverId: connection.id, phase: 'manifest-change', - ...AgentFramework.policyRefusalFields(receipt, recorded), + ...refusal, }); if (fallback === 'close') { await connection.close(); @@ -14349,6 +14350,10 @@ export class AgentFramework { connection: McplServerConnection, ): Promise { if (!connection.capabilities) return; + // The server's configuration as it is now, before any await: every + // record of what the server says below keeps it, whatever the host + // changes in the configuration meanwhile. + const recorded = serverTextRedactor(config); // §5.4: compute the effective grant from the (config-masked) // advertisement. Not yet active — expansion activates only after the @@ -14379,15 +14384,18 @@ export class AgentFramework { try { const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams); if (receipt && receipt.accepted === false) { - const fallback = receipt.fallback ?? 'mcp-only'; + // The fallback the host takes, which is the one logged; the server's + // reason is its own text. + const refusal = AgentFramework.policyRefusalFields(receipt, recorded); + const fallback = refusal.fallback; console.error( - `[mcpl] ${config.id} refused initial policy (${redactServerText(receipt.reason ?? 'no reason', config)}) — fallback: ${fallback}`, + `[mcpl] ${config.id} refused initial policy (${refusal.reason ?? 'no reason'}) — fallback: ${fallback}`, ); this.emitTrace({ type: 'mcpl:policy-refused', serverId: config.id, phase: 'initial', - ...AgentFramework.policyRefusalFields(receipt, (text) => redactServerText(text, config)), + ...refusal, }); if (fallback === 'close') { await connection.close(); @@ -14402,9 +14410,9 @@ export class AgentFramework { connection.establishGrant(grant); if (receipt?.accepted === true && receipt.mode === 'degraded') { for (const uf of receipt.unavailableFeatures ?? []) { - console.error( + console.error(recorded( `[mcpl] ${config.id} degraded: ${uf.featureSet} (${uf.effect}; missing ${uf.missingCapabilities.join(', ')})`, - ); + )); } } } catch (err) { @@ -14416,7 +14424,7 @@ export class AgentFramework { console.error( `[mcpl] ${config.id} did not answer the initial policy Request — ` + `MCPL privileged surface stays disabled (§6.7 unanswered expansion): ` + - (err instanceof Error ? err.message : String(err)), + recorded(err instanceof Error ? err.message : err), ); return; } diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index 3c8bcafa2..f408a5d55 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -117,12 +117,14 @@ export function redactUrl(url: string | undefined): string { * - the static token, raw and percent- or form-encoded; * - a stdio server's environment values whose names say they're credentials. * A string replaced on its own has at least 8 characters, since a shorter - * one can't be told from ordinary text; a short query value is replaced - * with its name, as one name=value. A string is replaced only where no letter - * or digit adjoins it, and one longest-first pass never scans its own - * replacement. A credential the configuration doesn't hold, such as an - * access provider's cached bearer token or another origin's, is outside it. - * JSON-RPC answers and errors thrown to a host's own call pass as they are. + * one can't be told from ordinary text, and it is replaced wherever it + * occurs, after an escape or inside a longer token too. A short query value + * is replaced only with its name, as one name=value, and only where no + * letter or digit adjoins the pair, so v=2 leaves dev=2 alone. One + * longest-first pass never scans its own replacement. A credential the + * configuration doesn't hold, such as the token an access provider fetches + * or another origin's, is outside it. JSON-RPC answers, and the error a + * host's own connect raises, pass as they are. */ export function redactServerText(text: unknown, config: Pick): string { return serverTextRedactor(config)(text); diff --git a/test/mcpl-config-diagnostics.test.ts b/test/mcpl-config-diagnostics.test.ts index 9e846eaaa..bf8a8f1d8 100644 --- a/test/mcpl-config-diagnostics.test.ts +++ b/test/mcpl-config-diagnostics.test.ts @@ -361,6 +361,42 @@ test("a refused initial policy's reason is recorded without the server's configu assert.ok(!capture.lines.join('\n').includes(secret)); }); +test("the initial policy's other records keep the credentials out too, with the configuration as it was when the request went", async () => { + const secret = 'srvSecret0123456789'; + const run = async (answer: () => Promise, mutate?: (config: McplServerConfig) => void) => { + const fw = Object.create(AgentFramework.prototype) as any; + fw.traceListeners = []; + fw.featureSetManager = new FeatureSetManager(); + fw.checkpointManager = null; + const traces: Traced[] = []; + fw.onTrace((e: Traced) => traces.push(e)); + const config = { id: 'srv', url: `wss://h/mcp/${secret}` } as McplServerConfig; + const connection = { + id: 'srv', + capabilities: { version: '0.5', pushEvents: true } as unknown as McplCapabilities, + mcpToolsAdvertised: false, + establishGrant: () => {}, + sendFeatureSetsUpdateRequest: async () => { mutate?.(config); return answer(); }, + close: async () => {}, + }; + const capture = captureConsoleError(); + try { await fw.registerMcplServerFeatures(config, connection); } finally { capture.stop(); } + const text = capture.lines.join('\n') + JSON.stringify(traces); + assert.ok(!text.includes(secret), text); + return { lines: capture.lines, traces }; + }; + // An unanswered request whose error quotes the url. + await run(async () => { throw new Error(`timed out talking to wss://h/mcp/${secret}`); }); + // A refusal whose fallback isn't one the host knows: it logs what it does. + const odd = await run(async () => ({ accepted: false, fallback: `close-${secret}`, reason: 'no' })); + assert.ok(odd.lines.some((l) => l.includes('refused initial policy (no) — fallback: mcp-only')), odd.lines.join('\n')); + // A degraded acceptance whose effect quotes the url. + await run(async () => ({ accepted: true, mode: 'degraded', unavailableFeatures: [{ featureSet: 'fs', effect: `see ${secret}`, missingCapabilities: ['x'] }] })); + // The host replaces the url while the request waits: the record still + // knows the credential the request went with. + await run(async () => ({ accepted: false, fallback: 'mcp-only', reason: `bad ${secret}` }), (config) => { (config as { url?: string }).url = 'wss://h/mcp/rotatedSecret0123456789'; }); +}); + test('registerMcplServerFeatures: a refused initial policy is traced, and fallback close closes', async () => { for (const fallback of ['close', 'mcp-only'] as const) { const fw = Object.create(AgentFramework.prototype) as any; From 665ea4e755dd9e99987a61dc05d0440a95ae5ac9 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 03:22:55 -0400 Subject: [PATCH 38/94] docs(mcp): a submitted configuration is the caller's to keep unchanged; the texts say removed or replaced A server's configuration is retained by reference: the wire reads it again at a 401 refresh or a legacy redial, and the records read it when an operation begins. Changing a connected server's configuration in place isn't supported, and the one real host doesn't: it builds a fresh configuration and restarts or connects with it, and rotates credentials through accessProvider. McplServerConfig's doc, restartMcplServer's doc and the changelog now say so as the caller's obligation: treat a submitted configuration and its nested values as immutable while the server uses them; apply changed settings with restartMcplServer(id, newConfig); use accessProvider for rotating credentials. The texts that said an operation's records hold whatever the host changes meanwhile now say a server removed, or replaced by a restart; the policy test drops its in-place rotation case. The redaction fragment says the response body leaves the error's data and the message is redacted where the client builds it from the body; the restart lines say "Before" and name what the configuration check refuses. Env names ending a part with 'pass' or 'passphrase' (DB_PASS, SSH_KEY_PASSPHRASE) count as credentials too. --- changelog.d/mcp-standard-tool-results.changed.md | 16 ++++++++++------ .../redact-recorded-mcp-credentials.fixed.md | 2 +- src/framework.ts | 11 ++++++++--- src/mcpl/protocol-family.ts | 2 +- src/mcpl/types.ts | 5 +++++ test/mcp-protocol-family.test.ts | 5 +++-- test/mcpl-config-diagnostics.test.ts | 7 +++---- 7 files changed, 31 insertions(+), 17 deletions(-) diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index 39f9077f0..0c197bb76 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -103,12 +103,16 @@ legacy one, fails with "was not connected"); then it removes what was registered. A teardown never removes what a later connection registered. `restartMcplServer` refuses, before it tears the running server down, what - its connect would refuse: a configuration with no usable binding, a new id - another server holds, a tool prefix another server or a module holds, a - framework whose `stop()` has begun, and a connect or disconnect of the - server in flight. On main a restart disconnected first, so a refused - connect left the server gone; now the server keeps running. A disconnect - releases its tool-prefix claim only while its own server holds it. + its connect would refuse: whatever the configuration check refuses (such as + an unusable binding, userinfo on an http(s) url, or MCPL policy on a modern + server), a new id another server holds, a tool prefix another server or a + module holds, a framework whose `stop()` has begun, and a connect or + disconnect of the server in flight. Before, a restart disconnected first, + so a refused connect left the server gone; now the server keeps running. A + disconnect releases its tool-prefix claim only while its own server holds + it. Treat a submitted configuration as immutable while the server uses it: + change settings with `restartMcplServer(id, newConfig)`, and rotate + credentials through `accessProvider`. - A server configuration naming no usable transport is now an error before anything is spawned or dialed. That covers a `transport` that doesn't match the url's scheme, `transport: 'http'` without a url, and an diff --git a/changelog.d/redact-recorded-mcp-credentials.fixed.md b/changelog.d/redact-recorded-mcp-credentials.fixed.md index 6b2ed989d..a4a1bc37a 100644 --- a/changelog.d/redact-recorded-mcp-credentials.fixed.md +++ b/changelog.d/redact-recorded-mcp-credentials.fixed.md @@ -1 +1 @@ -- Server and transport text the framework records has the server's configured credentials replaced, in both engines: connection traces, console lines, stderr traces, the ops alert (and so `logs/failures.log` and the webhook), a tool call's failure as its caller and the model get it, and the `tool:failed` trace's message and stack. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values (each as written and decoded), every other non-empty parameter as its whole `name=value`, and a `ws://` url's username and password; the static `token`, raw and as a url carries it, percent- or form-encoded; and a stdio server's `env` values whose names end a part with a credential word (`GITHUB_TOKEN`, `API_KEY`, `PGPASSWORD`). A string replaced on its own has at least eight characters, since a shorter one can't be told from ordinary text; a short query value is replaced only with its name, as one `name=value`, and only where no letter or digit adjoins it. A connection's event records use a redactor built once for it, and an operation's records one built when the operation begins, so a server removed or reconfigured meanwhile doesn't change what they replace. A 401 or 403 is classified as the server's refusal however the HTTP client raised it, and its response body is left out of the error's data (its message, built from the body, is redacted like any record). A token an access provider fetches, and credentials the configuration doesn't hold, are outside this. JSON-RPC answers, and the error a host's own connect raises, pass as they are. +- Server and transport text the framework records has the server's configured credentials replaced, in both engines: connection traces, console lines, stderr traces, the ops alert (and so `logs/failures.log` and the webhook), a tool call's failure as its caller and the model get it, and the `tool:failed` trace's message and stack. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values (each as written and decoded), every other non-empty parameter as its whole `name=value`, and a `ws://` url's username and password; the static `token`, raw and as a url carries it, percent- or form-encoded; and a stdio server's `env` values whose names end a part with a credential word (`GITHUB_TOKEN`, `API_KEY`, `PGPASSWORD`, `DB_PASS`, `SSH_KEY_PASSPHRASE`). A string replaced on its own has at least eight characters, since a shorter one can't be told from ordinary text; a short query value is replaced only with its name, as one `name=value`, and only where no letter or digit adjoins it. A connection's event records use a redactor built once for it, and an operation's records one built when the operation begins, so a server removed, or replaced by a restart, while the operation waits doesn't change what they replace. A 401 or 403's response body is left out of the request error's data; where the HTTP client builds the error's message from the body, as for a plain 403, the message is redacted like any record. A token an access provider fetches, and credentials the configuration doesn't hold, are outside this. JSON-RPC answers, and the error a host's own connect raises, pass as they are. diff --git a/src/framework.ts b/src/framework.ts index 486d0edeb..1472ee6f6 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -14350,9 +14350,9 @@ export class AgentFramework { connection: McplServerConnection, ): Promise { if (!connection.capabilities) return; - // The server's configuration as it is now, before any await: every - // record of what the server says below keeps it, whatever the host - // changes in the configuration meanwhile. + // Taken before any await, so every record of what the server says below + // uses one derivation, whatever becomes of the server meanwhile (removed, + // or replaced by a restart). const recorded = serverTextRedactor(config); // §5.4: compute the effective grant from the (config-masked) @@ -14558,6 +14558,11 @@ export class AgentFramework { * same (or an updated) config. This actually respawns a stdio child — * unlike `reconnect: true`, which only retries after transport-level * failures. Throws if the server has no stored config and none is given. + * + * Treat a submitted configuration and its nested values as immutable for + * as long as the server uses them. Apply changed settings with + * `restartMcplServer(id, newConfig)`; use `accessProvider` for rotating + * credentials. */ async restartMcplServer( id: string, diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index f408a5d55..c7bd59e45 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -213,7 +213,7 @@ function asText(text: unknown): string { } /** A part of an environment variable's name that ends with a credential word. */ -const CREDENTIAL_NAME_PART = /(?:token|key|secret|password|passwd|auth|signature|credentials?)$/i; +const CREDENTIAL_NAME_PART = /(?:token|key|secret|password|passwd|pass|passphrase|auth|signature|credentials?)$/i; /** * A credential provider's failure as a log line may show it: the error's diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 5ca5f0ff8..41049aa59 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -211,6 +211,11 @@ export interface McplChannelCapabilities { /** * Configuration for connecting to a single MCPL server. * Provided in FrameworkConfig.mcplServers[]. + * + * Treat a submitted configuration and its nested values as immutable for as + * long as the server uses them. Apply changed settings with + * `restartMcplServer(id, newConfig)`; use `accessProvider` for rotating + * credentials. */ export interface McplServerConfig { /** Unique server identifier */ diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index 170f9fc6c..02b5d6b8a 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -332,11 +332,12 @@ test('recorded server text: the token as a url carries it, credential-named env // part of it, and no other (GIT_AUTHOR_NAME holds 'auth' inside a word). const env = cfg({ command: 'srv', env: { GITHUB_TOKEN: 'ghp_abcdef123456', API_KEY: 'k3y-abcdefgh', PGPASSWORD: 'pgpass-abcdefgh', ACCESSTOKEN: 'acc-abcdefgh12', + DB_PASS: 'dbpass-abcdefgh', SSH_KEY_PASSPHRASE: 'phrase-abcdefgh', PATH: '/usr/local/bin', GIT_AUTHOR_NAME: 'Somebody Longname', KEYBOARD_LAYOUT: 'dvorak-programmer', TOKENIZERS_PARALLELISM: 'parallel-false', } }); assert.equal( - redactServerText('auth ghp_abcdef123456 k3y-abcdefgh pgpass-abcdefgh acc-abcdefgh12 /usr/local/bin Somebody Longname dvorak-programmer parallel-false', env), - 'auth redacted redacted redacted redacted /usr/local/bin Somebody Longname dvorak-programmer parallel-false'); + redactServerText('auth ghp_abcdef123456 k3y-abcdefgh pgpass-abcdefgh acc-abcdefgh12 dbpass-abcdefgh phrase-abcdefgh /usr/local/bin Somebody Longname dvorak-programmer parallel-false', env), + 'auth redacted redacted redacted redacted redacted redacted /usr/local/bin Somebody Longname dvorak-programmer parallel-false'); // A short pair only where no letter or digit adjoins it; any other string // wherever it occurs, after a percent escape, an ANSI code or a JSON escape too. const pair = cfg({ url: 'https://h/api/s/pathSecret0123456789/mcp?v=2' }); diff --git a/test/mcpl-config-diagnostics.test.ts b/test/mcpl-config-diagnostics.test.ts index bf8a8f1d8..303c3c1f2 100644 --- a/test/mcpl-config-diagnostics.test.ts +++ b/test/mcpl-config-diagnostics.test.ts @@ -361,7 +361,7 @@ test("a refused initial policy's reason is recorded without the server's configu assert.ok(!capture.lines.join('\n').includes(secret)); }); -test("the initial policy's other records keep the credentials out too, with the configuration as it was when the request went", async () => { +test("the initial policy's other records keep the credentials out too", async () => { const secret = 'srvSecret0123456789'; const run = async (answer: () => Promise, mutate?: (config: McplServerConfig) => void) => { const fw = Object.create(AgentFramework.prototype) as any; @@ -392,9 +392,8 @@ test("the initial policy's other records keep the credentials out too, with the assert.ok(odd.lines.some((l) => l.includes('refused initial policy (no) — fallback: mcp-only')), odd.lines.join('\n')); // A degraded acceptance whose effect quotes the url. await run(async () => ({ accepted: true, mode: 'degraded', unavailableFeatures: [{ featureSet: 'fs', effect: `see ${secret}`, missingCapabilities: ['x'] }] })); - // The host replaces the url while the request waits: the record still - // knows the credential the request went with. - await run(async () => ({ accepted: false, fallback: 'mcp-only', reason: `bad ${secret}` }), (config) => { (config as { url?: string }).url = 'wss://h/mcp/rotatedSecret0123456789'; }); + // A refusal's reason, recorded with the derivation the operation began with. + await run(async () => ({ accepted: false, fallback: 'mcp-only', reason: `bad ${secret}` })); }); test('registerMcplServerFeatures: a refused initial policy is traced, and fallback close closes', async () => { From a2bd86fa6da1df2cec87b11dd5b185598fb72684 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 03:27:54 -0400 Subject: [PATCH 39/94] fix(mcp): every name=value pair keeps its boundary; env names in the plural; the every-server redactor covers servers connected meanwhile From a review of the round before: - A long name=value pair was replaced inside a longer name (dev=12345678 for a configured v=12345678). Every pair now keeps the letter-or-digit boundary; a credential value is still replaced on its own wherever it occurs, as a standalone string. - Env names: a part ending in a credential word in the plural (API_KEYS, SECRETS), with digits after it (TOKEN2), AUTHORIZATION or AUTHENTICATION, or PAT (GITHUB_PAT) marks a credential; a credential env value is derived percent-encoded too. - The every-server redactor (startup cleanup, tool refresh) applies the configurations present when its operation began and those present when it's used, so a server connected meanwhile is covered as well as one removed; it reads a value as the redactor does, so one that can't be printed doesn't throw there. - The manifest handler does nothing for a server with no configuration (it was removed), rather than recording with an empty one. - URLSearchParams replaces a lone surrogate rather than throwing, so the token's form-encoding needs no guard; the comments say which rule holds for strings and which for pairs. The policy test helper loses its unused mutate parameter. Tests: a long pair inside a longer name kept, the env names above, a server connected after the every-server redactor was made, and an unprintable value through it. Removing each piece fails a test (four removals). --- src/framework.ts | 18 ++++++++--- src/mcpl/protocol-family.ts | 47 +++++++++++++++------------- test/mcp-protocol-family.test.ts | 13 +++++--- test/mcp-recorded-text.test.ts | 5 +++ test/mcpl-config-diagnostics.test.ts | 4 +-- 5 files changed, 55 insertions(+), 32 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 1472ee6f6..262ccee64 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -394,7 +394,7 @@ import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js'; import type { McplServerConnection } from './mcpl/server-connection.js'; import { McplProtocolVersionError, McplUnreapedLaunchError } from './mcpl/server-connection.js'; import { ModernMcpConnection } from './mcpl/modern-connection.js'; -import { redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverTextRedactor } from './mcpl/protocol-family.js'; +import { asText, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverTextRedactor } from './mcpl/protocol-family.js'; import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js'; import type { McplServerConfig, @@ -14158,8 +14158,12 @@ export class AgentFramework { announce: { revision?: string; domains?: string[] }, ): Promise { // The server's configuration as it is now, before any await: a record - // made later keeps it, whatever becomes of the server meanwhile. - const recorded = serverTextRedactor(this.mcplServerConfigs.get(connection.id) ?? {}); + // made later keeps it, whatever becomes of the server meanwhile. A server + // with no configuration has been removed, and its manifest isn't + // refreshed. + const config = this.mcplServerConfigs.get(connection.id); + if (!config) return; + const recorded = serverTextRedactor(config); const st = this.manifestRefreshState.get(connection.id) ?? { inFlight: false, queued: false, lastCompletedAt: 0 }; this.manifestRefreshState.set(connection.id, st); @@ -14605,8 +14609,12 @@ export class AgentFramework { * server removed meanwhile still has its credentials replaced. */ private everyServerRedactor(): (text: unknown) => string { - const redactors = [...this.mcplServerConfigs.values()].map(config => serverTextRedactor(config)); - return (text) => redactors.reduce((recorded, redact) => redact(recorded), typeof text === 'string' ? text : String(text)); + const snapshot = [...this.mcplServerConfigs.values()].map(config => serverTextRedactor(config)); + // The configurations present when the operation began, then those present + // now: a server removed meanwhile and one connected meanwhile are both + // covered. + return (text) => [...snapshot, ...[...this.mcplServerConfigs.values()].map(config => serverTextRedactor(config))] + .reduce((recorded, redact) => redact(recorded), asText(text)); } /** Why a connect for this id is refused, or null when the id is free. One diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index c7bd59e45..72f86388c 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -118,9 +118,10 @@ export function redactUrl(url: string | undefined): string { * - a stdio server's environment values whose names say they're credentials. * A string replaced on its own has at least 8 characters, since a shorter * one can't be told from ordinary text, and it is replaced wherever it - * occurs, after an escape or inside a longer token too. A short query value - * is replaced only with its name, as one name=value, and only where no - * letter or digit adjoins the pair, so v=2 leaves dev=2 alone. One + * occurs, after an escape or inside a longer token too. A query parameter is + * also replaced as its whole name=value, only where no letter or digit + * adjoins the pair, so v=2 leaves dev=2 alone; a short value goes only that + * way. One * longest-first pass never scans its own replacement. A credential the * configuration doesn't hold, such as the token an access provider fetches * or another origin's, is outside it. JSON-RPC answers, and the error a @@ -135,30 +136,35 @@ export function redactServerText(text: unknown, config: Pick): (text: unknown) => string { - // Strings replaced wherever they occur, and short name=value pairs, which - // are replaced only where no letter or digit adjoins them, so a pair such as - // v=2 leaves dev=2 alone. + // Strings replaced wherever they occur (after an escape or inside a longer + // token too), and name=value pairs, replaced only where no letter or digit + // adjoins them, so a pair such as v=2 leaves dev=2 alone. A pair's value is + // also replaced on its own when it's a credential, wherever it occurs. const secrets = new Set(); - const shortPairs = new Set(); + const pairs = new Set(); const standalone = (value: string): void => { if (value.length >= 8) secrets.add(value); }; const decode = (value: string, query = false): string => { try { return decodeURIComponent(query ? value.replace(/\+/g, ' ') : value); } catch { return value; } }; + // A string as written and percent-encoded (a string encodeURIComponent + // refuses, such as one with an unpaired surrogate, has no encoded form). + const withEncoded = (value: string): void => { + standalone(value); + try { standalone(encodeURIComponent(value)); } catch { /* no percent-encoded form */ } + }; // The token as sent: raw in a header, and percent- or form-encoded where a - // transport puts it in a url (a token that can't be encoded, such as one - // with an unpaired surrogate, has no encoded form). + // transport puts it in a url. const token = config.token ?? ''; - standalone(token); - try { standalone(encodeURIComponent(token)); } catch { /* no percent-encoded form */ } - try { standalone(new URLSearchParams({ t: token }).toString().slice(2)); } catch { /* no form-encoded form */ } + withEncoded(token); + standalone(new URLSearchParams({ t: token }).toString().slice(2)); // A stdio server's environment values whose names say they carry a // credential: a part of the name that ends with a credential word // (GITHUB_TOKEN, API_KEY, PGPASSWORD, ACCESSTOKEN), and not one where the // word only begins or sits inside a part (GIT_AUTHOR_NAME, KEYBOARD_LAYOUT, // TOKENIZERS_PARALLELISM). for (const [name, value] of Object.entries(config.env ?? {})) { - if (name.split(/[_\-.]/).some((part) => CREDENTIAL_NAME_PART.test(part))) standalone(value); + if (name.split(/[_\-.]/).some((part) => CREDENTIAL_NAME_PART.test(part))) withEncoded(value); } let url: URL | undefined; try { url = new URL(config.url ?? ''); } catch { /* Still remove the static token. */ } @@ -184,9 +190,8 @@ export function serverTextRedactor(config: Pick= 8 ? secrets : shortPairs).add(pair); - } + pairs.add(`${encodedName}=${encodedValue}`); + pairs.add(`${name}=${value}`); } } if (url.protocol === 'ws:' || url.protocol === 'wss:') { @@ -196,24 +201,24 @@ export function serverTextRedactor(config: Pick value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const alternatives = [ ...[...secrets].map(value => ({ value, source: escape(value) })), - ...[...shortPairs].map(value => ({ value, source: `(? ({ value, source: `(? b.value.length - a.value.length).map(({ source }) => source); const pattern = new RegExp(alternatives.join('|'), 'g'); return (text) => asText(text).replace(pattern, 'redacted'); } /** Any value as text: a value that can't be printed reads as a placeholder. */ -function asText(text: unknown): string { +export function asText(text: unknown): string { if (typeof text === 'string') return text; try { return String(text); } catch { return '[a value that cannot be printed]'; } } -/** A part of an environment variable's name that ends with a credential word. */ -const CREDENTIAL_NAME_PART = /(?:token|key|secret|password|passwd|pass|passphrase|auth|signature|credentials?)$/i; +/** A part of an environment variable's name that ends with a credential word, in the singular or plural, with any digits after it. */ +const CREDENTIAL_NAME_PART = /(?:tokens?|keys?|secrets?|passwords?|passwd|pass|passphrases?|pat|auth|authorization|authentication|signatures?|credentials?)\d*$/i; /** * A credential provider's failure as a log line may show it: the error's diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index 02b5d6b8a..393825496 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -328,20 +328,25 @@ test('recorded server text: the token as a url carries it, credential-named env for (const form of [token, 'tok%20en%2Bx%2F123%3D', 'tok+en%2Bx%2F123%3D']) { assert.equal(redactServerText(`refused: ${form}`, tokenConfig), 'refused: redacted', form); } - // A stdio server's env: a value whose name has a credential word as a whole - // part of it, and no other (GIT_AUTHOR_NAME holds 'auth' inside a word). + // A stdio server's env: a value whose name has a part ending in a credential + // word (plural, or with digits after it, too), and no other + // (GIT_AUTHOR_NAME's part AUTHOR doesn't end in 'auth'). const env = cfg({ command: 'srv', env: { GITHUB_TOKEN: 'ghp_abcdef123456', API_KEY: 'k3y-abcdefgh', PGPASSWORD: 'pgpass-abcdefgh', ACCESSTOKEN: 'acc-abcdefgh12', DB_PASS: 'dbpass-abcdefgh', SSH_KEY_PASSPHRASE: 'phrase-abcdefgh', + AUTHORIZATION: 'Bearer-abcdefgh', API_KEYS: 'k1-abcdefgh9', TOKEN2: 'tok2-abcdefgh', API_KEY_ID: 'keyid-abcdefgh', GITHUB_PAT: 'pat-abcdefgh12', PATH: '/usr/local/bin', GIT_AUTHOR_NAME: 'Somebody Longname', KEYBOARD_LAYOUT: 'dvorak-programmer', TOKENIZERS_PARALLELISM: 'parallel-false', } }); assert.equal( - redactServerText('auth ghp_abcdef123456 k3y-abcdefgh pgpass-abcdefgh acc-abcdefgh12 dbpass-abcdefgh phrase-abcdefgh /usr/local/bin Somebody Longname dvorak-programmer parallel-false', env), - 'auth redacted redacted redacted redacted redacted redacted /usr/local/bin Somebody Longname dvorak-programmer parallel-false'); + redactServerText('auth ghp_abcdef123456 k3y-abcdefgh pgpass-abcdefgh acc-abcdefgh12 dbpass-abcdefgh phrase-abcdefgh Bearer-abcdefgh k1-abcdefgh9 tok2-abcdefgh keyid-abcdefgh pat-abcdefgh12 /usr/local/bin Somebody Longname dvorak-programmer parallel-false', env), + 'auth redacted redacted redacted redacted redacted redacted redacted redacted redacted redacted redacted /usr/local/bin Somebody Longname dvorak-programmer parallel-false'); // A short pair only where no letter or digit adjoins it; any other string // wherever it occurs, after a percent escape, an ANSI code or a JSON escape too. const pair = cfg({ url: 'https://h/api/s/pathSecret0123456789/mcp?v=2' }); assert.equal(redactServerText('dev=2 ready; v=2 sent', pair), 'dev=2 ready; redacted sent'); + // A long pair too: only as its own name=value. + const longPair = cfg({ url: 'https://h/mcp?v=12345678' }); + assert.equal(redactServerText('dev=12345678 ready; v=12345678 sent', longPair), 'dev=12345678 ready; redacted sent'); assert.equal(redactServerText('a%2FpathSecret0123456789 \u001b[31mpathSecret0123456789 "\\npathSecret0123456789"', pair), 'a%2Fredacted \u001b[31mredacted "\\nredacted"'); // A ws:// url's userinfo, as for wss://. diff --git a/test/mcp-recorded-text.test.ts b/test/mcp-recorded-text.test.ts index 76d160244..ef5e74b58 100644 --- a/test/mcp-recorded-text.test.ts +++ b/test/mcp-recorded-text.test.ts @@ -220,6 +220,11 @@ test("a tool call's failure, from either engine, reaches its caller and its trac const everyServer = framework.everyServerRedactor(); framework.mcplServerConfigs = new Map(); assert.equal(everyServer(`closed ${secret} and otherSecret0123456789`), 'closed redacted and redacted'); + // A server connected after it was made is covered too. + framework.mcplServerConfigs = new Map([['late', { id: 'late', url: 'https://k/api/s/lateSecret0123456789/mcp' }]]); + assert.equal(everyServer('closed lateSecret0123456789'), 'closed redacted'); + // A value that can't be printed reads as a placeholder. + assert.equal(everyServer(Object.create(null)), '[a value that cannot be printed]'); framework.mcplServerConfigs = new Map([[config.id, config]]); // The model's dispatch: the tool:failed trace (message and stack) and the diff --git a/test/mcpl-config-diagnostics.test.ts b/test/mcpl-config-diagnostics.test.ts index 303c3c1f2..65e066b89 100644 --- a/test/mcpl-config-diagnostics.test.ts +++ b/test/mcpl-config-diagnostics.test.ts @@ -363,7 +363,7 @@ test("a refused initial policy's reason is recorded without the server's configu test("the initial policy's other records keep the credentials out too", async () => { const secret = 'srvSecret0123456789'; - const run = async (answer: () => Promise, mutate?: (config: McplServerConfig) => void) => { + const run = async (answer: () => Promise) => { const fw = Object.create(AgentFramework.prototype) as any; fw.traceListeners = []; fw.featureSetManager = new FeatureSetManager(); @@ -376,7 +376,7 @@ test("the initial policy's other records keep the credentials out too", async () capabilities: { version: '0.5', pushEvents: true } as unknown as McplCapabilities, mcpToolsAdvertised: false, establishGrant: () => {}, - sendFeatureSetsUpdateRequest: async () => { mutate?.(config); return answer(); }, + sendFeatureSetsUpdateRequest: async () => answer(), close: async () => {}, }; const capture = captureConsoleError(); From 5d27d07d59be662a06f95b8b2b5ec001d61ae6b1 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 03:51:52 -0400 Subject: [PATCH 40/94] fix(mcp): only string env values are derived; more credential words; a refresh redacts with the configurations it began with From a review of the round before: - A JavaScript host can pass an env value that isn't a string; the redactor threw on it inside the catch handlers that record failures. Only string values are derived now. - Env name parts ending in passcode, bearer, cookie or creds mark a credential too; PWD stays out, since its value is a path. - The modern tools/list failure took its server's configuration as its turn came, after earlier servers' listings had waited; the refresh now takes every configuration as it began. - A degraded acceptance's entry without a capability list threw inside the policy request's try, was logged as an unanswered request, and skipped the rest of registration; the line reads it as unknown. - The manifest handler has one guard for a server with no configuration, before its coalescing state, instead of two. Texts: accessProvider is for a network server's rotating credentials (a stdio server's changed env goes through a restart); the every-server redactor's doc says it applies the snapshot, then the configurations present when it's used; the fragment and README say every query pair keeps the adjacency rule, credential values are replaced wherever they occur, the env words, that a stdio server's arguments aren't derived, and that the HTTP client builds the message from the body for most statuses. Tests: an env credential percent-encoded, non-string env values, the new words, each policy record present (the unanswered and degraded lines, and an entry with no capability list), and a removed server's manifest change fetching nothing. Removing each piece fails a test (five removals). --- README.md | 2 +- .../mcp-standard-tool-results.changed.md | 5 +++-- .../redact-recorded-mcp-credentials.fixed.md | 2 +- src/framework.ts | 22 ++++++++++--------- src/mcpl/protocol-family.ts | 5 +++-- src/mcpl/types.ts | 5 +++-- test/mcp-protocol-family.test.ts | 9 ++++++-- test/mcpl-config-diagnostics.test.ts | 14 +++++++++--- test/mcpl-manifest-changed.test.ts | 7 ++++++ 9 files changed, 48 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 3a6320f54..6a9d216ac 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,7 @@ supports, or its error text when it lists none, and when the list includes `2026-07-28` it says to configure the server with `protocol: 'modern'` (stdio) or reach it over http(s). -Server and transport text the framework records has the server's configured credentials replaced, in both engines. That covers connection traces, console lines, stderr traces, ops alerts, and a tool call's failure as its caller, the model and the `tool:failed` trace get it. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values, and a `ws://` url's userinfo, in their encoded and decoded forms, and every other non-empty query parameter as its whole `name=value`; the static `token`, raw and url-encoded; and a stdio server's `env` values whose names end a part with a credential word, such as `GITHUB_TOKEN` or `PGPASSWORD`. Standalone strings shorter than eight characters stay unchanged because they may be ordinary words; short query values are still removed with their parameter names. The token an `accessProvider` fetches, and secrets the configuration doesn't hold, remain outside this filtering. JSON-RPC tool answers, and the error a host's own connect raises, pass through as sent. +Server and transport text the framework records has the server's configured credentials replaced, in both engines. That covers connection traces, console lines, stderr traces, ops alerts, and a tool call's failure as its caller, the model and the `tool:failed` trace get it. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values, and a `ws://` url's userinfo, in their encoded and decoded forms, replaced wherever they occur; every non-empty query parameter as its whole `name=value`, where no letter or digit adjoins it; the static `token`, raw and url-encoded; and a stdio server's `env` values whose names have a part ending in a credential word, such as `GITHUB_TOKEN`, `PGPASSWORD` or `SESSION_COOKIE`, raw and url-encoded. A stdio server's command-line arguments aren't derived. Standalone strings shorter than eight characters stay unchanged because they may be ordinary words; short query values are still removed with their parameter names. Treat a submitted configuration as immutable while the server uses it: apply changes with `restartMcplServer(id, newConfig)`. The token an `accessProvider` fetches, and secrets the configuration doesn't hold, remain outside this filtering. JSON-RPC tool answers, and the error a host's own connect raises, pass through as sent. #### Feature sets diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index 0c197bb76..c561b1ea7 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -111,8 +111,9 @@ so a refused connect left the server gone; now the server keeps running. A disconnect releases its tool-prefix claim only while its own server holds it. Treat a submitted configuration as immutable while the server uses it: - change settings with `restartMcplServer(id, newConfig)`, and rotate - credentials through `accessProvider`. + change settings with `restartMcplServer(id, newConfig)`, and rotate a + network server's credentials through `accessProvider` (a stdio server's + changed `env` goes through a restart with the new configuration). - A server configuration naming no usable transport is now an error before anything is spawned or dialed. That covers a `transport` that doesn't match the url's scheme, `transport: 'http'` without a url, and an diff --git a/changelog.d/redact-recorded-mcp-credentials.fixed.md b/changelog.d/redact-recorded-mcp-credentials.fixed.md index a4a1bc37a..05e8d920f 100644 --- a/changelog.d/redact-recorded-mcp-credentials.fixed.md +++ b/changelog.d/redact-recorded-mcp-credentials.fixed.md @@ -1 +1 @@ -- Server and transport text the framework records has the server's configured credentials replaced, in both engines: connection traces, console lines, stderr traces, the ops alert (and so `logs/failures.log` and the webhook), a tool call's failure as its caller and the model get it, and the `tool:failed` trace's message and stack. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values (each as written and decoded), every other non-empty parameter as its whole `name=value`, and a `ws://` url's username and password; the static `token`, raw and as a url carries it, percent- or form-encoded; and a stdio server's `env` values whose names end a part with a credential word (`GITHUB_TOKEN`, `API_KEY`, `PGPASSWORD`, `DB_PASS`, `SSH_KEY_PASSPHRASE`). A string replaced on its own has at least eight characters, since a shorter one can't be told from ordinary text; a short query value is replaced only with its name, as one `name=value`, and only where no letter or digit adjoins it. A connection's event records use a redactor built once for it, and an operation's records one built when the operation begins, so a server removed, or replaced by a restart, while the operation waits doesn't change what they replace. A 401 or 403's response body is left out of the request error's data; where the HTTP client builds the error's message from the body, as for a plain 403, the message is redacted like any record. A token an access provider fetches, and credentials the configuration doesn't hold, are outside this. JSON-RPC answers, and the error a host's own connect raises, pass as they are. +- Server and transport text the framework records has the server's configured credentials replaced, in both engines: connection traces, console lines, stderr traces, the ops alert (and so `logs/failures.log` and the webhook), a tool call's failure as its caller and the model get it, and the `tool:failed` trace's message and stack. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values (each as written and decoded), every non-empty parameter as its whole `name=value`, and a `ws://` url's username and password; the static `token`, raw and as a url carries it, percent- or form-encoded; and a stdio server's `env` values whose names have a part ending in a credential word, singular or plural, with any digits after it (`GITHUB_TOKEN`, `API_KEYS`, `PGPASSWORD`, `DB_PASS`, `AUTHORIZATION`, `SESSION_COOKIE`, `TOKEN2`), raw and percent-encoded. A stdio server's command-line arguments are not derived. A string replaced on its own has at least eight characters, since a shorter one can't be told from ordinary text, and is replaced wherever it occurs. Every query parameter is also replaced as its whole `name=value`, only where no letter or digit adjoins the pair (so `v=2` leaves `dev=2` alone); a short value goes only that way. A connection's event records use a redactor built once for it, and an operation's records one built when the operation begins, so a server removed, or replaced by a restart, while the operation waits doesn't change what they replace. A 401 or 403's response body is left out of the request error's data; where the HTTP client builds the error's message from the body, as it does for most statuses, the message is redacted like any record. A token an access provider fetches, and credentials the configuration doesn't hold, are outside this. JSON-RPC answers, and the error a host's own connect raises, pass as they are. diff --git a/src/framework.ts b/src/framework.ts index 262ccee64..1a672d8c7 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -14191,9 +14191,6 @@ export class AgentFramework { st.inFlight = true; try { - const config = this.mcplServerConfigs.get(connection.id); - if (!config) return; - // §17.4 fetch. A server that cannot answer keeps its previous // manifest in force — an unparseable/failed fetch teaches nothing and // narrows nothing (§17.5 step 1). @@ -14415,7 +14412,7 @@ export class AgentFramework { if (receipt?.accepted === true && receipt.mode === 'degraded') { for (const uf of receipt.unavailableFeatures ?? []) { console.error(recorded( - `[mcpl] ${config.id} degraded: ${uf.featureSet} (${uf.effect}; missing ${uf.missingCapabilities.join(', ')})`, + `[mcpl] ${config.id} degraded: ${uf.featureSet} (${uf.effect}; missing ${Array.isArray(uf.missingCapabilities) ? uf.missingCapabilities.join(', ') : 'unknown'})`, )); } } @@ -14565,8 +14562,9 @@ export class AgentFramework { * * Treat a submitted configuration and its nested values as immutable for * as long as the server uses them. Apply changed settings with - * `restartMcplServer(id, newConfig)`; use `accessProvider` for rotating - * credentials. + * `restartMcplServer(id, newConfig)`; use `accessProvider` for a network + * server's rotating credentials (a stdio server's changed `env` goes + * through a restart with the new configuration). */ async restartMcplServer( id: string, @@ -14604,9 +14602,10 @@ export class AgentFramework { /** * A redactor for text that may come from any configured server, with every - * server's credentials replaced. It is built from the configurations as - * they are when it's made, so an operation makes it before it waits, and a - * server removed meanwhile still has its credentials replaced. + * server's credentials replaced: those of the configurations present when + * it's made, then those present when it's used. An operation makes it + * before it waits, so a server removed meanwhile, and one connected + * meanwhile, both have their credentials replaced. */ private everyServerRedactor(): (text: unknown) => string { const snapshot = [...this.mcplServerConfigs.values()].map(config => serverTextRedactor(config)); @@ -15254,9 +15253,12 @@ export class AgentFramework { // Modern servers: the same namespacing, tool policy and RFC-008 class // hints. Feature sets are MCPL and don't apply. A server whose listing // fails stays unlisted (unknown), never "listed nothing". + // Each server's configuration as the refresh began, so a server removed + // while an earlier listing waited still has its record redacted. + const configsAtStart = new Map(this.mcplServerConfigs); for (const server of this.modernServers().values()) { if (!server.isConnected) continue; - const config = this.mcplServerConfigs.get(server.id); + const config = configsAtStart.get(server.id); const prefix = config?.toolPrefix ?? `mcpl--${server.id}`; try { const listed = await server.listTools(); diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index 72f86388c..410732751 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -164,7 +164,8 @@ export function serverTextRedactor(config: Pick CREDENTIAL_NAME_PART.test(part))) withEncoded(value); + // A JavaScript host can pass a value that isn't a string; only strings are derived. + if (typeof value === 'string' && name.split(/[_\-.]/).some((part) => CREDENTIAL_NAME_PART.test(part))) withEncoded(value); } let url: URL | undefined; try { url = new URL(config.url ?? ''); } catch { /* Still remove the static token. */ } @@ -218,7 +219,7 @@ export function asText(text: unknown): string { } /** A part of an environment variable's name that ends with a credential word, in the singular or plural, with any digits after it. */ -const CREDENTIAL_NAME_PART = /(?:tokens?|keys?|secrets?|passwords?|passwd|pass|passphrases?|pat|auth|authorization|authentication|signatures?|credentials?)\d*$/i; +const CREDENTIAL_NAME_PART = /(?:tokens?|keys?|secrets?|passwords?|passwd|pass|passphrases?|passcodes?|pat|auth|authorization|authentication|bearer|cookies?|creds|signatures?|credentials?)\d*$/i; /** * A credential provider's failure as a log line may show it: the error's diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 41049aa59..e78b51680 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -214,8 +214,9 @@ export interface McplChannelCapabilities { * * Treat a submitted configuration and its nested values as immutable for as * long as the server uses them. Apply changed settings with - * `restartMcplServer(id, newConfig)`; use `accessProvider` for rotating - * credentials. + * `restartMcplServer(id, newConfig)`; use `accessProvider` for a network + * server's rotating credentials (a stdio server's changed `env` goes through + * a restart with the new configuration). */ export interface McplServerConfig { /** Unique server identifier */ diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index 393825496..fb1e7fe75 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -335,11 +335,16 @@ test('recorded server text: the token as a url carries it, credential-named env GITHUB_TOKEN: 'ghp_abcdef123456', API_KEY: 'k3y-abcdefgh', PGPASSWORD: 'pgpass-abcdefgh', ACCESSTOKEN: 'acc-abcdefgh12', DB_PASS: 'dbpass-abcdefgh', SSH_KEY_PASSPHRASE: 'phrase-abcdefgh', AUTHORIZATION: 'Bearer-abcdefgh', API_KEYS: 'k1-abcdefgh9', TOKEN2: 'tok2-abcdefgh', API_KEY_ID: 'keyid-abcdefgh', GITHUB_PAT: 'pat-abcdefgh12', + SESSION_COOKIE: 'cookie-abcdefgh', CLOUD_CREDS: 'creds-abcdefgh', PATH: '/usr/local/bin', GIT_AUTHOR_NAME: 'Somebody Longname', KEYBOARD_LAYOUT: 'dvorak-programmer', TOKENIZERS_PARALLELISM: 'parallel-false', } }); assert.equal( - redactServerText('auth ghp_abcdef123456 k3y-abcdefgh pgpass-abcdefgh acc-abcdefgh12 dbpass-abcdefgh phrase-abcdefgh Bearer-abcdefgh k1-abcdefgh9 tok2-abcdefgh keyid-abcdefgh pat-abcdefgh12 /usr/local/bin Somebody Longname dvorak-programmer parallel-false', env), - 'auth redacted redacted redacted redacted redacted redacted redacted redacted redacted redacted redacted /usr/local/bin Somebody Longname dvorak-programmer parallel-false'); + redactServerText('auth ghp_abcdef123456 k3y-abcdefgh pgpass-abcdefgh acc-abcdefgh12 dbpass-abcdefgh phrase-abcdefgh Bearer-abcdefgh k1-abcdefgh9 tok2-abcdefgh keyid-abcdefgh pat-abcdefgh12 cookie-abcdefgh creds-abcdefgh /usr/local/bin Somebody Longname dvorak-programmer parallel-false', env), + 'auth redacted redacted redacted redacted redacted redacted redacted redacted redacted redacted redacted redacted redacted /usr/local/bin Somebody Longname dvorak-programmer parallel-false'); + // A credential env value as a url would carry it, percent-encoded. + assert.equal(redactServerText('GET /x?pw=p%40ss%2Fw0rd!x', cfg({ command: 'srv', env: { DB_PASSWORD: 'p@ss/w0rd!x' } })), 'GET /x?pw=redacted'); + // A value that isn't a string (a JavaScript host can pass one) is skipped, not thrown on. + assert.equal(redactServerText('fine', cfg({ command: 'srv', env: { API_TOKEN: null as unknown as string, OTHER_KEY: 7 as unknown as string } })), 'fine'); // A short pair only where no letter or digit adjoins it; any other string // wherever it occurs, after a percent escape, an ANSI code or a JSON escape too. const pair = cfg({ url: 'https://h/api/s/pathSecret0123456789/mcp?v=2' }); diff --git a/test/mcpl-config-diagnostics.test.ts b/test/mcpl-config-diagnostics.test.ts index 65e066b89..2ea09491d 100644 --- a/test/mcpl-config-diagnostics.test.ts +++ b/test/mcpl-config-diagnostics.test.ts @@ -386,12 +386,20 @@ test("the initial policy's other records keep the credentials out too", async () return { lines: capture.lines, traces }; }; // An unanswered request whose error quotes the url. - await run(async () => { throw new Error(`timed out talking to wss://h/mcp/${secret}`); }); + const unanswered = await run(async () => { throw new Error(`timed out talking to wss://h/mcp/${secret}`); }); + assert.ok(unanswered.lines.some((l) => l.includes('did not answer the initial policy Request') && l.includes('timed out talking to wss://h/mcp/redacted')), unanswered.lines.join('\n')); // A refusal whose fallback isn't one the host knows: it logs what it does. const odd = await run(async () => ({ accepted: false, fallback: `close-${secret}`, reason: 'no' })); assert.ok(odd.lines.some((l) => l.includes('refused initial policy (no) — fallback: mcp-only')), odd.lines.join('\n')); - // A degraded acceptance whose effect quotes the url. - await run(async () => ({ accepted: true, mode: 'degraded', unavailableFeatures: [{ featureSet: 'fs', effect: `see ${secret}`, missingCapabilities: ['x'] }] })); + // A degraded acceptance whose effect quotes the url, and an entry with no + // capability list, which is logged rather than read as an unanswered request. + const degraded = await run(async () => ({ accepted: true, mode: 'degraded', unavailableFeatures: [ + { featureSet: 'fs', effect: `see ${secret}`, missingCapabilities: ['x'] }, + { featureSet: 'odd', effect: 'none' }, + ] })); + assert.ok(degraded.lines.some((l) => l.includes('srv degraded: fs (see redacted; missing x)')), degraded.lines.join('\n')); + assert.ok(degraded.lines.some((l) => l.includes('srv degraded: odd (none; missing unknown)')), degraded.lines.join('\n')); + assert.ok(!degraded.lines.some((l) => l.includes('did not answer')), degraded.lines.join('\n')); // A refusal's reason, recorded with the derivation the operation began with. await run(async () => ({ accepted: false, fallback: 'mcp-only', reason: `bad ${secret}` })); }); diff --git a/test/mcpl-manifest-changed.test.ts b/test/mcpl-manifest-changed.test.ts index 9196cf44b..34f2b21fd 100644 --- a/test/mcpl-manifest-changed.test.ts +++ b/test/mcpl-manifest-changed.test.ts @@ -99,6 +99,13 @@ function makeHarness() { const settle = () => new Promise((r) => setImmediate(r)); +test('a manifest change from a server with no configuration (removed) fetches nothing', async () => { + const { fw, connection } = makeHarness(); + (fw as unknown as { mcplServerConfigs: Map }).mcplServerConfigs = new Map(); + await fw.handleManifestChanged(connection, { revision: 'sha256:gone', domains: ['capabilities'] }); + assert.deepEqual(connection.events.map((e) => e.kind), [], 'no fetch, grant or policy request'); +}); + test('reduction applies BEFORE the policy Request; receipt names it applied', async () => { const { fw, connection, traces } = makeHarness(); // New manifest drops channels.publish (and the chat set with it, since From a36f8ea1e8798181d79279c91858e0b950c68817 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 03:54:47 -0400 Subject: [PATCH 41/94] fix(mcp): a refresh's tool policy and prefix read the live configuration; only its failure record uses the snapshot 5d27d07d took every modern server's configuration from a snapshot made as the refresh began, for its tools/list failure record. The same variable gave the server's tool prefix and tool policy, and the refresh's loop iterates the live server map, so a server connected while an earlier listing waited was listed with no configuration: its default prefix and every tool allowed. The prefix and policy read the live configuration again, as before 5d27d07d; the failure record alone redacts with the snapshot, falling back to the live configuration. Test: a server connected under its own prefix while a refresh holds another server's listing keeps that prefix in the held refresh's list, which finishes last. Taking the policy from the snapshot fails it. --- src/framework.ts | 10 ++++++---- test/mcp-server-ownership.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 1a672d8c7..ce922210d 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -15253,12 +15253,14 @@ export class AgentFramework { // Modern servers: the same namespacing, tool policy and RFC-008 class // hints. Feature sets are MCPL and don't apply. A server whose listing // fails stays unlisted (unknown), never "listed nothing". - // Each server's configuration as the refresh began, so a server removed - // while an earlier listing waited still has its record redacted. + // Each server's configuration as the refresh began, for its failure + // record alone, so a server removed while an earlier listing waited still + // has its record redacted. Its tool policy and prefix read the live + // configuration, as a server connected meanwhile has one only there. const configsAtStart = new Map(this.mcplServerConfigs); for (const server of this.modernServers().values()) { if (!server.isConnected) continue; - const config = configsAtStart.get(server.id); + const config = this.mcplServerConfigs.get(server.id); const prefix = config?.toolPrefix ?? `mcpl--${server.id}`; try { const listed = await server.listTools(); @@ -15279,7 +15281,7 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:server-error', serverId: server.id, - error: `tools/list failed: ${redactServerText(error instanceof Error ? error.message : String(error), config ?? {})}`, + error: `tools/list failed: ${redactServerText(error instanceof Error ? error.message : String(error), configsAtStart.get(server.id) ?? config ?? {})}`, }); } } diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index b48f11380..44debcc03 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -436,6 +436,33 @@ test("a refresh in flight when a server is disconnected doesn't bring its tools }); }); +test("a server connected while a refresh waits keeps its own tool prefix in that refresh's list", async () => { + await withFramework(async ({ framework, dir }) => { + await framework.connectMcplServer(modern('m', '', join(dir, 'raw.jsonl'))); + const internals = framework as unknown as { + modernServers(): Map }>; + refreshMcplTools(): Promise; + }; + const connection = internals.modernServers().get('m')!; + const realList = connection.listTools.bind(connection); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let listed = false; + connection.listTools = async () => { const tools = await realList(); listed = true; await gate; return tools; }; + const refreshing = internals.refreshMcplTools(); + await until(() => listed, "the refresh holds the first server's list"); + connection.listTools = realList; + // Connected while the first refresh waits, under a prefix of its own. + await framework.connectMcplServer({ ...modern('n', '', join(dir, 'raw-n.jsonl')), toolPrefix: 'custom' }); + // The held refresh finishes last, so its list is the one that stands. + release(); + await refreshing; + const names = framework.getAllTools().map((t) => t.name); + assert.ok(names.some((n) => n.startsWith('custom--')), names.join(', ')); + assert.ok(!names.some((n) => n.startsWith('mcpl--n--')), names.join(', ')); + }); +}); + test('a server lost for good, legacy or modern, leaves the tool list', async () => { await withFramework(async ({ framework, dir }) => { await framework.connectMcplServer(legacy('l', 'ok', join(dir, 'legacy.log'))); From 69ce7ec79a3410892bf737f2726f8a791c241cc1 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 03:55:55 -0400 Subject: [PATCH 42/94] fix(mcp): the modern tools/list refresh reads each server's configuration as before a36f8ea1 kept a snapshot of every configuration, taken as the refresh began, for the tools/list failure record. It isn't needed: each server's configuration is read before that server's own listing waits, and a server removed while an earlier listing waits leaves the live server map too, so the refresh doesn't list it. The snapshot is gone; the record redacts with the configuration the server's tools are listed under, as before 5d27d07d. The test from a36f8ea1 stays: a server connected while a refresh holds another server's listing keeps its own prefix in that refresh's list. --- src/framework.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index ce922210d..2653a1e13 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -15253,11 +15253,6 @@ export class AgentFramework { // Modern servers: the same namespacing, tool policy and RFC-008 class // hints. Feature sets are MCPL and don't apply. A server whose listing // fails stays unlisted (unknown), never "listed nothing". - // Each server's configuration as the refresh began, for its failure - // record alone, so a server removed while an earlier listing waited still - // has its record redacted. Its tool policy and prefix read the live - // configuration, as a server connected meanwhile has one only there. - const configsAtStart = new Map(this.mcplServerConfigs); for (const server of this.modernServers().values()) { if (!server.isConnected) continue; const config = this.mcplServerConfigs.get(server.id); @@ -15281,7 +15276,7 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:server-error', serverId: server.id, - error: `tools/list failed: ${redactServerText(error instanceof Error ? error.message : String(error), configsAtStart.get(server.id) ?? config ?? {})}`, + error: `tools/list failed: ${redactServerText(error instanceof Error ? error.message : String(error), config ?? {})}`, }); } } From 7c76a2e178d87634071731bb035dd69c711a6196 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Tue, 13 Oct 2026 05:10:53 -0400 Subject: [PATCH 43/94] fix(mcp): a server request handler's failure is recorded with the connection's redactor, before it's cut The merge of #263 brought refuseServerItem, which logs and answers a server request whose handler threw (push/event, inference/request, channels/register, channels/changed, channels/incoming, host/command). Its failure text quotes the handler's error, which can quote what the server sent, and it bypassed this branch's redaction: the console line and the -32000/-32603 answer carried a configured credential whole, or its first characters where it straddled the 500-character cut. refuseServerItem takes the connection's redactor, which wireMcplEvents builds once; thrownText redacts the error's text before cutting it, inside the guard that reads it, so an unreadable value still reads as unreadable and a request is still answered once. Test: a push/event handler that throws an error quoting the server's configured token, once whole and once with it across the cut (nine characters kept), is logged and answered without it. Removing the redaction fails it, and so does cutting before redacting. --- src/framework.ts | 40 +++++++++++++++------------ test/stop-with-work-in-flight.test.ts | 28 ++++++++++++++++++- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 3f55bbe52..e9a1306e1 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1044,10 +1044,12 @@ const THROWN_TEXT_LIMIT = 500; * The text is bounded, since a provider or handler error can carry a whole * request body. */ -function thrownText(error: unknown): string { +function thrownText(error: unknown, recorded: (text: unknown) => string = asText): string { let text: string; try { - text = String(error instanceof Error ? error.message : error); + // Redacted before it's cut, so no part of a credential across the cut + // is kept. + text = recorded(String(error instanceof Error ? error.message : error)); } catch { text = '(a value that could not be read)'; } @@ -1059,15 +1061,15 @@ function thrownText(error: unknown): string { * handler failed" and what it threw (nothing, for `undefined`). Telling an * Error apart is itself guarded, since instanceof throws on a revoked Proxy. */ -function handlerFailureText(error: unknown): string { +function handlerFailureText(error: unknown, recorded?: (text: unknown) => string): string { let isError = false; try { isError = error instanceof Error; } catch { // Not an Error that can be read; thrownText says so. } - if (isError) return thrownText(error); - return error === undefined ? 'the handler failed' : `the handler failed: ${thrownText(error)}`; + if (isError) return thrownText(error, recorded); + return error === undefined ? 'the handler failed' : `the handler failed: ${thrownText(error, recorded)}`; } /** A server's responder, and whether the request it belongs to has been answered yet. */ @@ -2626,10 +2628,12 @@ export class AgentFramework { serverId: string, answer: AnswerOnce, outcome: { kind: 'refused' } | { kind: 'failed'; error: unknown }, + /** The connection's redactor: a handler's error can quote what the server sent. */ + recorded: (text: unknown) => string, ): void { // Optional chaining: prototype-built harnesses leave the queue unset. const stopping = outcome.kind === 'refused' || !!this.queue?.isClosed; - const what = outcome.kind === 'refused' ? '' : handlerFailureText(outcome.error); + const what = outcome.kind === 'refused' ? '' : handlerFailureText(outcome.error, recorded); const reason = outcome.kind === 'refused' ? 'the host is stopping' : stopping @@ -15206,12 +15210,12 @@ export class AgentFramework { // Checked here, after the barrier and before anything records the // push as accepted: a stopped host refuses it. if (this.queue?.isClosed) { - this.refuseServerItem('push/event', connection.id, answer, { kind: 'refused' }); + this.refuseServerItem('push/event', connection.id, answer, { kind: 'refused' }, recorded); return; } await this.pushHandler?.handlePushEvent(connection.id, params, answer.responder as never); } catch (error) { - this.refuseServerItem('push/event', connection.id, answer, { kind: 'failed', error }); + this.refuseServerItem('push/event', connection.id, answer, { kind: 'failed', error }, recorded); } }); @@ -15237,7 +15241,7 @@ export class AgentFramework { // A stopping host starts no inference for a server: refused, like a // push, before the router runs. if (this.queue?.isClosed) { - this.refuseServerItem('inference/request', connection.id, answer, { kind: 'refused' }); + this.refuseServerItem('inference/request', connection.id, answer, { kind: 'refused' }, recorded); return; } await this.inferenceRouter.handleInferenceRequest(connection.id, params, { @@ -15246,7 +15250,7 @@ export class AgentFramework { requestId: responder.id, }); } catch (error) { - this.refuseServerItem('inference/request', connection.id, answer, { kind: 'failed', error }); + this.refuseServerItem('inference/request', connection.id, answer, { kind: 'failed', error }, recorded); } } }); @@ -15259,12 +15263,12 @@ export class AgentFramework { const answer = answerOnce(responder, 'channels/register', connection.id); try { if (this.queue?.isClosed) { - this.refuseServerItem('channels/register', connection.id, answer, { kind: 'refused' }); + this.refuseServerItem('channels/register', connection.id, answer, { kind: 'refused' }, recorded); return; } await this.channelRegistry?.handleRegister(connection.id, params, answer.responder as never); } catch (error) { - this.refuseServerItem('channels/register', connection.id, answer, { kind: 'failed', error }); + this.refuseServerItem('channels/register', connection.id, answer, { kind: 'failed', error }, recorded); } }); @@ -15279,12 +15283,12 @@ export class AgentFramework { const answer = answerOnce(responder, 'channels/changed', connection.id); try { if (this.queue?.isClosed) { - this.refuseServerItem('channels/changed', connection.id, answer, { kind: 'refused' }); + this.refuseServerItem('channels/changed', connection.id, answer, { kind: 'refused' }, recorded); return; } await this.channelRegistry?.handleChanged(connection.id, params, answer.responder as never); } catch (error) { - this.refuseServerItem('channels/changed', connection.id, answer, { kind: 'failed', error }); + this.refuseServerItem('channels/changed', connection.id, answer, { kind: 'failed', error }, recorded); } }); @@ -15351,12 +15355,12 @@ export class AgentFramework { const barrier = this.discordAwarenessBarrier; if (barrier) await barrier.promise; if (this.queue?.isClosed) { - this.refuseServerItem('channels/incoming', connection.id, answer, { kind: 'refused' }); + this.refuseServerItem('channels/incoming', connection.id, answer, { kind: 'refused' }, recorded); return; } await this.channelRegistry?.handleIncoming(connection.id, params, answer.responder as never); } catch (error) { - this.refuseServerItem('channels/incoming', connection.id, answer, { kind: 'failed', error }); + this.refuseServerItem('channels/incoming', connection.id, answer, { kind: 'failed', error }, recorded); } }); @@ -15373,13 +15377,13 @@ export class AgentFramework { // A stopping host runs no host command: refused like the requests // above, before its handler runs. if (this.queue?.isClosed) { - this.refuseServerItem('host/command', connection.id, answer, { kind: 'refused' }); + this.refuseServerItem('host/command', connection.id, answer, { kind: 'refused' }, recorded); return; } const result = await this.handleHostCommand(connection.id, params ?? {}); answer.responder!.respond(result); } catch (error) { - this.refuseServerItem('host/command', connection.id, answer, { kind: 'failed', error }); + this.refuseServerItem('host/command', connection.id, answer, { kind: 'failed', error }, recorded); } }); diff --git a/test/stop-with-work-in-flight.test.ts b/test/stop-with-work-in-flight.test.ts index 2674a1f4f..81fc71d60 100644 --- a/test/stop-with-work-in-flight.test.ts +++ b/test/stop-with-work-in-flight.test.ts @@ -375,7 +375,7 @@ describe('stop() with work still in flight', () => { modules: [], }); const connection = Object.assign(new EventEmitter(), { id: 'srv' }); - (framework as unknown as { wireMcplEvents(c: unknown, config: unknown): void }).wireMcplEvents(connection, { id: 'srv', command: 'unused' }); + (framework as unknown as { wireMcplEvents(c: unknown, config: unknown): void }).wireMcplEvents(connection, { id: 'srv', url: 'wss://h/mcp?token=wireToken0123456789' }); return { framework, connection }; } @@ -596,6 +596,32 @@ describe('stop() with work still in flight', () => { } }); + it("a handler's error quoting the server's configured credential is recorded and answered without it, also across the cut", async () => { + const { framework, connection } = await wiredFramework(); + try { + // The credential starts just before the 500-character cut, so a cut + // made before redacting would keep its first characters. + // Once whole, and once with the credential across the 500-character + // cut, where a cut made before redacting keeps its first nine. + const errors = ['refused wireToken0123456789', `${'x'.repeat(490)} wireToken0123456789 and more`]; + for (const [i, error] of errors.entries()) { + (framework as unknown as { pushHandler: unknown }).pushHandler = { + handlePushEvent: async () => { throw new Error(error); }, + }; + const push = responder(); + connection.emit('push-event', { featureSet: 'chat', eventId: `e-cred-${i}`, payload: { content: [{ type: 'text', text: 'x' }] } }, push); + await answered(push); + const message = push.answers[0]!.error!.message; + await until(() => logged.filter((line) => line.includes('push/event from srv failed')).length > i, 'the log line'); + for (const text of [message, ...logged]) { + assert.ok(!text.includes('wireToken'), text.slice(-120)); + } + } + } finally { + await framework.stop(); + } + }); + it('a request the handler already answered is not answered again when the handler then throws', async () => { const { framework, connection } = await wiredFramework(); try { From 4f025627b45161cee1b17a9951eb5458e30fbdfc Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 00:40:31 -0400 Subject: [PATCH 44/94] fix(mcp): a restart holds the id and tool prefix it moves its server to restartMcplServer checked the new id and tool prefix and then awaited its disconnect and connect holding neither, so a connect started during the restart could take them, and the restart's own connect was then refused after the old server was gone. From its checks through its connect, a restart now claims the id and prefix (mcpRestartClaims): another connect or restart is refused them, naming the restart, and the restart's own connect passes its claim through (connectMcplServerClaimed). The claims cover a same-id restart's gap between its disconnect and its connect too, and are released in finally. A restart of an id another restart holds is refused before it tears anything down. Also: - A disconnect releases every prefix the prefix map says its server holds, rather than the one its configuration names now, so a configuration changed after its connect leaves no stale claim. - Recorded server text that may come from any server takes one longest-first pass over every configuration's strings (serverCredentialPatterns + credentialRedactor), so a secret that begins another server's longer one no longer cuts that one short. - The redactor also replaces a token's or credential env value's JSON-escaped slashes and lowercase-hex percent-escapes, a ws:// url's userinfo as one unit before its @ (a short password goes with its username), and the Basic credential a WebSocket client sends for it. - The fixed fragment says the 401/403 body rule is over HTTP, which only the modern engine speaks. --- .../mcp-standard-tool-results.changed.md | 16 ++- .../redact-recorded-mcp-credentials.fixed.md | 2 +- src/framework.ts | 111 ++++++++++++++---- src/mcpl/protocol-family.ts | 84 ++++++++++--- test/mcp-protocol-family.test.ts | 26 ++++ test/mcp-recorded-text.test.ts | 7 ++ test/mcpl-runtime-lifecycle.test.ts | 78 +++++++++++- 7 files changed, 279 insertions(+), 45 deletions(-) diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index c561b1ea7..5d0a6972b 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -108,12 +108,16 @@ server), a new id another server holds, a tool prefix another server or a module holds, a framework whose `stop()` has begun, and a connect or disconnect of the server in flight. Before, a restart disconnected first, - so a refused connect left the server gone; now the server keeps running. A - disconnect releases its tool-prefix claim only while its own server holds - it. Treat a submitted configuration as immutable while the server uses it: - change settings with `restartMcplServer(id, newConfig)`, and rotate a - network server's credentials through `accessProvider` (a stdio server's - changed `env` goes through a restart with the new configuration). + so a refused connect left the server gone; now the server keeps running. + From those checks through its connect, the restart holds the id and the + tool prefix it moves the server to, so a connect or restart started + meanwhile is refused them, naming the restart, rather than taking them + while the old server is torn down. A disconnect releases its tool-prefix + claim only while its own server holds it. Treat a submitted configuration + as immutable while the server uses it: change settings with + `restartMcplServer(id, newConfig)`, and rotate a network server's + credentials through `accessProvider` (a stdio server's changed `env` goes + through a restart with the new configuration). - A server configuration naming no usable transport is now an error before anything is spawned or dialed. That covers a `transport` that doesn't match the url's scheme, `transport: 'http'` without a url, and an diff --git a/changelog.d/redact-recorded-mcp-credentials.fixed.md b/changelog.d/redact-recorded-mcp-credentials.fixed.md index 05e8d920f..e58f63aaa 100644 --- a/changelog.d/redact-recorded-mcp-credentials.fixed.md +++ b/changelog.d/redact-recorded-mcp-credentials.fixed.md @@ -1 +1 @@ -- Server and transport text the framework records has the server's configured credentials replaced, in both engines: connection traces, console lines, stderr traces, the ops alert (and so `logs/failures.log` and the webhook), a tool call's failure as its caller and the model get it, and the `tool:failed` trace's message and stack. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values (each as written and decoded), every non-empty parameter as its whole `name=value`, and a `ws://` url's username and password; the static `token`, raw and as a url carries it, percent- or form-encoded; and a stdio server's `env` values whose names have a part ending in a credential word, singular or plural, with any digits after it (`GITHUB_TOKEN`, `API_KEYS`, `PGPASSWORD`, `DB_PASS`, `AUTHORIZATION`, `SESSION_COOKIE`, `TOKEN2`), raw and percent-encoded. A stdio server's command-line arguments are not derived. A string replaced on its own has at least eight characters, since a shorter one can't be told from ordinary text, and is replaced wherever it occurs. Every query parameter is also replaced as its whole `name=value`, only where no letter or digit adjoins the pair (so `v=2` leaves `dev=2` alone); a short value goes only that way. A connection's event records use a redactor built once for it, and an operation's records one built when the operation begins, so a server removed, or replaced by a restart, while the operation waits doesn't change what they replace. A 401 or 403's response body is left out of the request error's data; where the HTTP client builds the error's message from the body, as it does for most statuses, the message is redacted like any record. A token an access provider fetches, and credentials the configuration doesn't hold, are outside this. JSON-RPC answers, and the error a host's own connect raises, pass as they are. +- Server and transport text the framework records has the server's configured credentials replaced, in both engines: connection traces, console lines, stderr traces, the ops alert (and so `logs/failures.log` and the webhook), a tool call's failure as its caller and the model get it, and the `tool:failed` trace's message and stack. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values (each as written and decoded), every non-empty parameter as its whole `name=value`, and a `ws://` url's username and password, its userinfo as one unit before its `@`, and the Basic credential a WebSocket client sends for it; the static `token`, raw, with its slashes escaped as some JSON encoders write them, and as a url carries it, percent- or form-encoded with either case of hex; and a stdio server's `env` values whose names have a part ending in a credential word, singular or plural, with any digits after it (`GITHUB_TOKEN`, `API_KEYS`, `PGPASSWORD`, `DB_PASS`, `AUTHORIZATION`, `SESSION_COOKIE`, `TOKEN2`), raw, with their slashes escaped as some JSON encoders write them, and percent-encoded with either case of hex. A stdio server's command-line arguments are not derived. A string replaced on its own has at least eight characters, since a shorter one can't be told from ordinary text, and is replaced wherever it occurs. Every query parameter is also replaced as its whole `name=value`, only where no letter or digit adjoins the pair (so `v=2` leaves `dev=2` alone); a short value goes only that way. A connection's event records use a redactor built once for it, and an operation's records one built when the operation begins, so a server removed, or replaced by a restart, while the operation waits doesn't change what they replace. Text that may come from any server takes one pass over every server's strings, so a secret that begins another server's longer one doesn't cut that one short. Over HTTP, which only the modern engine speaks, a 401 or 403's response body is left out of the request error's data; where the HTTP client builds the error's message from the body, as it does for most statuses, the message is redacted like any record. A token an access provider fetches, and credentials the configuration doesn't hold, are outside this. JSON-RPC answers, and the error a host's own connect raises, pass as they are. diff --git a/src/framework.ts b/src/framework.ts index e9a1306e1..d5a67049f 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -394,7 +394,7 @@ import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js'; import type { McplServerConnection } from './mcpl/server-connection.js'; import { McplProtocolVersionError, McplUnreapedLaunchError } from './mcpl/server-connection.js'; import { ModernMcpConnection } from './mcpl/modern-connection.js'; -import { asText, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverTextRedactor } from './mcpl/protocol-family.js'; +import { asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor } from './mcpl/protocol-family.js'; import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js'; import type { McplServerConfig, @@ -1078,6 +1078,10 @@ type AnswerOnce = { answered: () => boolean; }; +/** A restart's hold on the id and tool prefix it is moving its server to; + * `restarting` names the server it restarts. Compared by identity. */ +type McpRestartClaim = { readonly restarting: string }; + /** * Wrap a server request's responder so the request is answered at most once, * and a later failure can tell whether the handler already answered. @@ -1566,6 +1570,14 @@ export class AgentFramework { /** Connects and disconnects in flight, by server id (holdMcpServerId); * created on first use, like modernMcpConnections. */ private mcpServerOps: Map }> | undefined; + /** The ids and tool prefixes restarts hold, each with the claim of the + * restart holding it, from the restart's checks through its connect + * (restartMcplServer): no other connect or restart takes them while the + * old server is torn down. */ + private readonly mcpRestartClaims = { + ids: new Map(), + prefixes: new Map(), + }; /** Host capabilities advertised during the MCP handshake — stored so servers * can be connected at runtime (connectMcplServer) after initialization. */ private mcplHostCapabilities: McplHostCapabilities | null = null; @@ -14336,6 +14348,7 @@ export class AgentFramework { private async connectMcplServerInternal( config: import('./mcpl/types.js').McplServerConfig, deferAwareness = false, + claim?: McpRestartClaim, ): Promise { if (this.mcpServerAdmissionClosed) { throw new Error(`MCP server "${config.id}" was not connected: the framework is stopping or stopped`); @@ -14350,7 +14363,7 @@ export class AgentFramework { // protocol on a URL, a modern deadline of 0, MCPL policy on a modern // server. Every valid legacy shape resolves exactly as before. const family = AgentFramework.usableConfigFamily(config); - const taken = this.mcpServerIdTaken(config.id); + const taken = this.mcpServerIdTaken(config.id, claim); if (taken) throw new Error(taken); const hold = this.holdMcpServerId(config.id, 'connect'); try { @@ -14843,6 +14856,15 @@ export class AgentFramework { */ async connectMcplServer( config: import('./mcpl/types.js').McplServerConfig, + ): Promise { + await this.connectMcplServerClaimed(config); + } + + /** connectMcplServer, for a restart's connect too: the id and tool prefix + * its `claim` holds are free to it, and refused to everyone else. */ + private async connectMcplServerClaimed( + config: import('./mcpl/types.js').McplServerConfig, + claim?: McpRestartClaim, ): Promise { // Refused before anything initializes, spawns or dials. if (this.mcpServerAdmissionClosed) { @@ -14854,11 +14876,11 @@ export class AgentFramework { await this.initializeMcpl([], this.mcplInferenceRoutingConfig ?? undefined); } - const collision = this.mcplPrefixCollision(config); + const collision = this.mcplPrefixCollision(config, undefined, claim); if (collision) throw new Error(collision); const oldToolNames = new Set(this.mcplTools.map(t => t.name)); - await this.connectMcplServerInternal(config); + await this.connectMcplServerInternal(config, false, claim); await this.refreshMcplTools(); this.emitMcplToolDiff(oldToolNames, config.id); } @@ -14897,7 +14919,6 @@ export class AgentFramework { // What held the id before (a connect still running, an earlier // disconnect) settles first, so what it registered is removed whole. await hold.previous; - const config = this.mcplServerConfigs.get(id); const oldToolNames = new Set(this.mcplTools.map(t => t.name)); try { @@ -14922,10 +14943,13 @@ export class AgentFramework { this.featureSetManager?.removeServer(id); this.checkpointManager?.removeServer(id); - const prefix = config?.toolPrefix ?? `mcpl--${id}`; - // Only this server's own claim: a server connected since under the - // prefix it held keeps it. - if (this.mcplPrefixMap.get(prefix) === id) this.mcplPrefixMap.delete(prefix); + // Only this server's own claims, as the prefix map holds them rather + // than as its configuration reads now: a server connected since under + // a prefix it held keeps it, and a configuration changed after its + // connect leaves no claim behind. + for (const [prefix, owner] of this.mcplPrefixMap) { + if (owner === id) this.mcplPrefixMap.delete(prefix); + } this.mcplServerConfigs.delete(id); await this.refreshMcplTools(); @@ -14940,6 +14964,9 @@ export class AgentFramework { * same (or an updated) config. This actually respawns a stdio child — * unlike `reconnect: true`, which only retries after transport-level * failures. Throws if the server has no stored config and none is given. + * What its connect would refuse is refused before the running server is + * torn down, and until the connect, a connect or restart started meanwhile + * is refused the id and tool prefix the restart moves the server to. * * Treat a submitted configuration and its nested values as immutable for * as long as the server uses them. Apply changed settings with @@ -14954,7 +14981,10 @@ export class AgentFramework { // What the connect would refuse is refused here, before the running // server is torn down for it: a framework whose stop() has begun, the // configuration's binding and settings, a new id another server holds, - // and a tool prefix another server or a module already holds. + // and a tool prefix another server or a module already holds. From these + // checks through its connect, the restart holds the id and the tool + // prefix it moves the server to (mcpRestartClaims), so no connect or + // restart started meanwhile takes them while the old server is torn down. if (this.mcpServerAdmissionClosed) { throw new Error(`MCP server "${id}" was not restarted: the framework is stopping or stopped`); } @@ -14971,14 +15001,32 @@ export class AgentFramework { // operation left, after the server was torn down. throw new Error(`MCP server "${id}" was not restarted: it is being ${inFlight.kind === 'disconnect' ? 'disconnected' : 'connected'}; restart it once that has finished`); } + const claimedBy = this.mcpRestartClaims.ids.get(id); + if (claimedBy) { + // Another restart is moving its server to this id, or restarting this + // server already. + throw new Error(claimedBy.restarting === id + ? `MCP server "${id}" was not restarted: it is being restarted; restart it once that has finished` + : `MCP server "${id}" was not restarted: the restart of "${claimedBy.restarting}" holds its id; restart it once that has finished`); + } if (config.id !== id) { const taken = this.mcpServerIdTaken(config.id); if (taken) throw new Error(taken); } const collision = this.mcplPrefixCollision(config, id); if (collision) throw new Error(collision); - await this.disconnectMcplServer(id); - await this.connectMcplServer(config); + + const claim: McpRestartClaim = { restarting: id }; + const prefix = config.toolPrefix ?? `mcpl--${config.id}`; + this.mcpRestartClaims.ids.set(config.id, claim); + this.mcpRestartClaims.prefixes.set(prefix, claim); + try { + await this.disconnectMcplServer(id); + await this.connectMcplServerClaimed(config, claim); + } finally { + if (this.mcpRestartClaims.ids.get(config.id) === claim) this.mcpRestartClaims.ids.delete(config.id); + if (this.mcpRestartClaims.prefixes.get(prefix) === claim) this.mcpRestartClaims.prefixes.delete(prefix); + } } /** @@ -14989,19 +15037,28 @@ export class AgentFramework { * meanwhile, both have their credentials replaced. */ private everyServerRedactor(): (text: unknown) => string { - const snapshot = [...this.mcplServerConfigs.values()].map(config => serverTextRedactor(config)); + const snapshot = [...this.mcplServerConfigs.values()].flatMap(config => serverCredentialPatterns(config)); // The configurations present when the operation began, then those present // now: a server removed meanwhile and one connected meanwhile are both - // covered. - return (text) => [...snapshot, ...[...this.mcplServerConfigs.values()].map(config => serverTextRedactor(config))] - .reduce((recorded, redact) => redact(recorded), asText(text)); + // covered. One pass over all their strings, so a secret that begins + // another server's longer one can't cut that one short. + return (text) => credentialRedactor([ + ...snapshot, + ...[...this.mcplServerConfigs.values()].flatMap(config => serverCredentialPatterns(config)), + ])(text); } /** Why a connect for this id is refused, or null when the id is free. One * owner per id, across both engines (holdMcpServerId): a connect or * disconnect in flight holds it, and so does a registered connection, - * including one kept because its launch couldn't be confirmed exited. */ - private mcpServerIdTaken(id: string): string | null { + * including one kept because its launch couldn't be confirmed exited, and + * a restart moving its server to the id (mcpRestartClaims), except to + * that restart's own connect (`claim`). */ + private mcpServerIdTaken(id: string, claim?: McpRestartClaim): string | null { + const claimedBy = this.mcpRestartClaims.ids.get(id); + if (claimedBy && claimedBy !== claim) { + return `MCP server "${id}" is held by the restart of "${claimedBy.restarting}"; connect it once that has finished`; + } const op = this.mcpServerOps?.get(id); if (op?.kind === 'disconnect') { return `MCP server "${id}" is still being disconnected; connect it once that has finished`; @@ -15016,14 +15073,24 @@ export class AgentFramework { /** The collision rules create() enforces at startup, as a runtime connect * (or a restart, before its disconnect) meets them: the tool prefix must - * not shadow a module name or another server's prefix. The server's own - * prefix is free to keep, and so is that of the server a restart is - * about to disconnect (`leaving`). */ - private mcplPrefixCollision(config: import('./mcpl/types.js').McplServerConfig, leaving?: string): string | null { + * not shadow a module name or another server's prefix, or be one a + * restart is moving its server to (mcpRestartClaims). The server's own + * prefix is free to keep, so is that of the server a restart is about to + * disconnect (`leaving`), and so is the prefix a restart's own connect + * holds (`claim`). */ + private mcplPrefixCollision( + config: import('./mcpl/types.js').McplServerConfig, + leaving?: string, + claim?: McpRestartClaim, + ): string | null { const prefix = config.toolPrefix ?? `mcpl--${config.id}`; if (this.moduleRegistry.getAllModules().some(m => m.name === prefix)) { return `MCPL server "${config.id}" toolPrefix "${prefix}" collides with module "${prefix}"`; } + const claimedBy = this.mcpRestartClaims.prefixes.get(prefix); + if (claimedBy && claimedBy !== claim) { + return `MCPL server "${config.id}" toolPrefix "${prefix}" is held by the restart of "${claimedBy.restarting}"`; + } const prefixOwner = this.mcplPrefixMap.get(prefix); if (prefixOwner && prefixOwner !== config.id && prefixOwner !== leaving) { return `MCPL server "${config.id}" toolPrefix "${prefix}" collides with server "${prefixOwner}"`; diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index 410732751..7ab46747f 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -113,19 +113,25 @@ export function redactUrl(url: string | undefined): string { * - from the url, as written and decoded: credential-like path segments and * query names, credential-shaped or credential-named query values, every * other non-empty parameter as its whole name=value, and a ws:// url's - * username and password; - * - the static token, raw and percent- or form-encoded; - * - a stdio server's environment values whose names say they're credentials. + * username and password, its userinfo as one unit before its @, and the + * Basic credential a WebSocket client sends for that userinfo; + * - the static token, raw, with its slashes escaped as some JSON encoders + * write them, and percent- or form-encoded with either case of hex; + * - a stdio server's environment values whose names say they're + * credentials, raw, with their slashes JSON-escaped, and percent-encoded + * with either case of hex. * A string replaced on its own has at least 8 characters, since a shorter * one can't be told from ordinary text, and it is replaced wherever it * occurs, after an escape or inside a longer token too. A query parameter is * also replaced as its whole name=value, only where no letter or digit * adjoins the pair, so v=2 leaves dev=2 alone; a short value goes only that - * way. One - * longest-first pass never scans its own replacement. A credential the + * way. One longest-first pass never scans its own replacement, and text + * that may come from several servers takes one pass over all their + * configurations' strings (serverCredentialPatterns). A credential the * configuration doesn't hold, such as the token an access provider fetches - * or another origin's, is outside it. JSON-RPC answers, and the error a - * host's own connect raises, pass as they are. + * or another origin's, is outside it, and so are other encodings of the + * ones it holds. JSON-RPC answers, and the error a host's own connect + * raises, pass as they are. */ export function redactServerText(text: unknown, config: Pick): string { return serverTextRedactor(config)(text); @@ -136,28 +142,56 @@ export function redactServerText(text: unknown, config: Pick): (text: unknown) => string { + return credentialRedactor(serverCredentialPatterns(config)); +} + +/** A string {@link redactServerText} replaces, and the pattern that finds it. */ +export interface CredentialPattern { + readonly value: string; + readonly source: string; +} + +/** + * The strings {@link redactServerText} replaces for one configuration, as + * patterns for {@link credentialRedactor}. Text that may come from several + * servers goes through one redactor over all their patterns, so its one + * longest-first pass takes the longest match among them all: a secret that + * begins another configuration's longer one can't cut that one short. + */ +export function serverCredentialPatterns(config: Pick): CredentialPattern[] { // Strings replaced wherever they occur (after an escape or inside a longer // token too), and name=value pairs, replaced only where no letter or digit // adjoins them, so a pair such as v=2 leaves dev=2 alone. A pair's value is // also replaced on its own when it's a credential, wherever it occurs. const secrets = new Set(); const pairs = new Set(); + const userinfo = new Set(); const standalone = (value: string): void => { if (value.length >= 8) secrets.add(value); }; const decode = (value: string, query = false): string => { try { return decodeURIComponent(query ? value.replace(/\+/g, ' ') : value); } catch { return value; } }; - // A string as written and percent-encoded (a string encodeURIComponent - // refuses, such as one with an unpaired surrogate, has no encoded form). + // Percent-escapes in lowercase hex, as some encoders write them. + const lowerEscapes = (value: string): string => value.replace(/%[0-9A-F]{2}/g, (escape) => escape.toLowerCase()); + // A string as written, with its slashes escaped as some JSON encoders + // write them, and percent-encoded in either case of hex (a string + // encodeURIComponent refuses, such as one with an unpaired surrogate, has + // no encoded form). const withEncoded = (value: string): void => { standalone(value); - try { standalone(encodeURIComponent(value)); } catch { /* no percent-encoded form */ } + standalone(value.replace(/\//g, '\\/')); + let encoded: string; + try { encoded = encodeURIComponent(value); } catch { return; /* no percent-encoded form */ } + standalone(encoded); + standalone(lowerEscapes(encoded)); }; // The token as sent: raw in a header, and percent- or form-encoded where a // transport puts it in a url. const token = config.token ?? ''; withEncoded(token); - standalone(new URLSearchParams({ t: token }).toString().slice(2)); + const formEncoded = new URLSearchParams({ t: token }).toString().slice(2); + standalone(formEncoded); + standalone(lowerEscapes(formEncoded)); // A stdio server's environment values whose names say they carry a // credential: a part of the name that ends with a credential word // (GITHUB_TOKEN, API_KEY, PGPASSWORD, ACCESSTOKEN), and not one where the @@ -200,15 +234,35 @@ export function serverTextRedactor(config: Pick= 8) userinfo.add(`${username}:${password}`); + standalone(Buffer.from(`${username}:${password}`).toString('base64')); + } + } } } - if (secrets.size === 0 && pairs.size === 0) return asText; const escape = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const alternatives = [ + return [ ...[...secrets].map(value => ({ value, source: escape(value) })), ...[...pairs].map(value => ({ value, source: `(? b.value.length - a.value.length).map(({ source }) => source); - const pattern = new RegExp(alternatives.join('|'), 'g'); + ...[...userinfo].map(value => ({ value, source: `${escape(value)}(?=@)` })), + ]; +} + +/** + * One longest-first pass over the given patterns that never scans its own + * replacement: at each place, the longest string that matches there becomes + * `redacted`. + */ +export function credentialRedactor(patterns: readonly CredentialPattern[]): (text: unknown) => string { + if (patterns.length === 0) return asText; + const sources = [...patterns].sort((a, b) => b.value.length - a.value.length).map(({ source }) => source); + const pattern = new RegExp([...new Set(sources)].join('|'), 'g'); return (text) => asText(text).replace(pattern, 'redacted'); } diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index fb1e7fe75..64a9564f9 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -14,6 +14,8 @@ import { checkServerConfig, redactUrl, redactServerText, + credentialRedactor, + serverCredentialPatterns, describeCredentialError, MCPL_ONLY_POLICY_FIELDS, MAX_TIMER_MS, @@ -376,6 +378,30 @@ test('the cleartext warning counts a credential-shaped value under a plain name, assert.match(warned[0], /its url's query parameter "session" goes to remote\.example unencrypted/); }); +test('recorded server text: a slash escaped as JSON may write it, and percent-escapes in lowercase hex', () => { + const config = cfg({ token: 'tok/en+x0123', env: { API_TOKEN: 'env/secret+0123' } } as Partial); + for (const form of ['tok/en+x0123', 'tok\\/en+x0123', 'tok%2Fen%2Bx0123', 'tok%2fen%2bx0123', 'env\\/secret+0123', 'env%2fsecret%2b0123']) { + assert.equal(redactServerText(`refused: ${form}`, config), 'refused: redacted', form); + } +}); + +test("recorded server text: a ws:// url's userinfo as one unit before its @, and the Basic credential sent for it", () => { + // Neither part is long enough to replace on its own. + const config = cfg({ url: 'wss://alice:pw@h/mcp' }); + assert.equal(redactServerText('dial wss://alice:pw@h/mcp failed', config), 'dial wss://redacted@h/mcp failed'); + assert.equal(redactServerText('Authorization: Basic YWxpY2U6cHc=', config), 'Authorization: Basic redacted'); + assert.equal(redactServerText('alice:pw is not followed by its @ here', config), 'alice:pw is not followed by its @ here'); +}); + +test("one redactor over several configurations' strings takes the longest secret among them all", () => { + const short = cfg({ id: 'a', token: 'ghp_SHAREDPREFIX123' }); + const long = cfg({ id: 'b', token: 'ghp_SHAREDPREFIX123_but_longer_TAIL9' }); + const redact = credentialRedactor([...serverCredentialPatterns(short), ...serverCredentialPatterns(long)]); + assert.equal(redact('auth failed for ghp_SHAREDPREFIX123_but_longer_TAIL9'), 'auth failed for redacted'); + assert.equal(redact('auth failed for ghp_SHAREDPREFIX123'), 'auth failed for redacted'); + assert.equal(credentialRedactor([])('nothing to replace'), 'nothing to replace'); +}); + test('recorded server text uses longest-first, one-pass replacements and is idempotent', () => { const config = cfg({ url: 'https://h/mcp?secret=token0123456789-tail&redacted=eu', token: 'token0123456789' }); const original = 'secret=token0123456789-tail token0123456789 redacted=eu queue full'; diff --git a/test/mcp-recorded-text.test.ts b/test/mcp-recorded-text.test.ts index ef5e74b58..90e011105 100644 --- a/test/mcp-recorded-text.test.ts +++ b/test/mcp-recorded-text.test.ts @@ -225,6 +225,13 @@ test("a tool call's failure, from either engine, reaches its caller and its trac assert.equal(everyServer('closed lateSecret0123456789'), 'closed redacted'); // A value that can't be printed reads as a placeholder. assert.equal(everyServer(Object.create(null)), '[a value that cannot be printed]'); + // One pass over every server's strings: a secret that begins another + // server's longer one doesn't cut that one short. + framework.mcplServerConfigs = new Map([ + ['a', { id: 'a', token: 'ghp_SHAREDPREFIX123' }], + ['b', { id: 'b', token: 'ghp_SHAREDPREFIX123_but_longer_TAIL9' }], + ]); + assert.equal(framework.everyServerRedactor()('auth failed for ghp_SHAREDPREFIX123_but_longer_TAIL9'), 'auth failed for redacted'); framework.mcplServerConfigs = new Map([[config.id, config]]); // The model's dispatch: the tool:failed trace (message and stack) and the diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index 8bb01254f..d18f45637 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -113,7 +113,7 @@ test('restartMcplServer respawns the server and keeps tools available', async () assert.equal(servers.length, 1); assert.equal(servers[0].connected, true); assert.equal(servers[0].toolCount, 1); - assert.ok(framework.getAllTools().some(t => t.name === 'mcpl--first--echo')); + assert.ok(framework.getAllTools().some(tool => tool.name === 'mcpl--first--echo')); }); }); @@ -207,6 +207,18 @@ test("a disconnect removes its server's prefix claim, and leaves one another ser }); }); +test('a disconnect releases the prefix its server claimed at connect, though its configuration changed since', async () => { + await withFramework(false, async (framework) => { + const config = serverConfig('dyn', { toolPrefix: 'p1' }); + await framework.connectMcplServer(config as never); + // Against the documented rule, the host changes the configuration it submitted. + (config as unknown as { toolPrefix: string }).toolPrefix = 'p2'; + await framework.disconnectMcplServer('dyn'); + await framework.connectMcplServer(serverConfig('other', { toolPrefix: 'p1' }) as never); + assert.deepEqual(framework.listMcplServers().map(s => s.id), ['other']); + }); +}); + test('restartMcplServer of an id whose connect is in flight is refused before it tears anything down', async (t) => { // MCPL is up, so the connect holds its id before its first await. await withFramework(true, async (framework) => { @@ -222,6 +234,70 @@ test('restartMcplServer of an id whose connect is in flight is refused before it }); }); +test('a connect started during a restart is refused the tool prefix the restart moves to, and the restart completes', async () => { + await withFramework(true, async (framework) => { + const restarting = framework.restartMcplServer('first', serverConfig('first', { toolPrefix: 'xx' }) as never); + await assert.rejects( + framework.connectMcplServer(serverConfig('third', { toolPrefix: 'xx' }) as never), + /MCPL server "third" toolPrefix "xx" is held by the restart of "first"/, + ); + await restarting; + assert.deepEqual(framework.listMcplServers().map(s => [s.id, s.connected]), [['first', true]]); + assert.ok(framework.getAllTools().some(t => t.name === 'xx--echo'), 'the restarted server serves the prefix'); + }); +}); + +test('a connect started during a restart is refused the id the restart moves to, and the restart completes', async () => { + await withFramework(true, async (framework) => { + const restarting = framework.restartMcplServer('first', serverConfig('second') as never); + // Another prefix, so only the id is contested. + await assert.rejects( + framework.connectMcplServer(serverConfig('second', { toolPrefix: 'other' }) as never), + /MCP server "second" is held by the restart of "first"/, + ); + await restarting; + assert.deepEqual(framework.listMcplServers().map(s => [s.id, s.connected]), [['second', true]]); + assert.ok(framework.getAllTools().some(t => t.name === 'mcpl--second--echo')); + }); +}); + +test("a connect between a restart's disconnect and its connect is refused the id the restart keeps", async (t) => { + await withFramework(true, async (framework) => { + const disconnect = framework.disconnectMcplServer.bind(framework); + let racing: Promise | undefined; + t.mock.method(framework, 'disconnectMcplServer', async (id: string) => { + await disconnect(id); + // The old server is gone and its disconnect holds nothing now. + racing = framework.connectMcplServer(serverConfig('first', { toolPrefix: 'other' }) as never) + .then(() => 'connected', (error: unknown) => error); + }); + await framework.restartMcplServer('first'); + const outcome = await racing; + assert.ok(outcome instanceof Error, `the racing connect was refused, not ${String(outcome)}`); + assert.match(outcome.message, /MCP server "first" is held by the restart of "first"/); + assert.deepEqual(framework.listMcplServers().map(s => [s.id, s.connected]), [['first', true]]); + assert.ok(framework.getAllTools().some(tool => tool.name === 'mcpl--first--echo')); + }); +}); + +test('a restart that fails releases the id and the tool prefix it held', async (t) => { + await withFramework(true, async (framework) => { + t.mock.method(framework, 'disconnectMcplServer', async () => { + throw new Error('disconnect failed for the test'); + }); + await assert.rejects( + framework.restartMcplServer('first', serverConfig('renamed', { toolPrefix: 'xx' }) as never), + /disconnect failed for the test/, + ); + t.mock.restoreAll(); + await framework.connectMcplServer(serverConfig('renamed', { toolPrefix: 'xx' }) as never); + assert.deepEqual( + framework.listMcplServers().map(s => [s.id, s.connected]).sort(), + [['first', true], ['renamed', true]], + ); + }); +}); + test('connectMcplServer rejects a duplicate id and a colliding prefix', async () => { await withFramework(true, async (framework) => { await assert.rejects( From 379490b66561e94fb1e606876780a9f74f0abccd Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 00:50:05 -0400 Subject: [PATCH 45/94] test(mcp): a restart of an id another restart holds is refused Between a restart's disconnect and its connect, a second restart of the id it keeps, or of the new id it moves its server to, is refused before it tears anything down, naming the restart that holds it. --- test/mcpl-runtime-lifecycle.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index d18f45637..a5c4e468d 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -280,6 +280,35 @@ test("a connect between a restart's disconnect and its connect is refused the id }); }); +test('a restart of an id another restart holds is refused before it tears anything down', async (t) => { + await withFramework(true, async (framework) => { + await framework.connectMcplServer(serverConfig('second') as never); + const disconnect = framework.disconnectMcplServer.bind(framework); + let during: (() => Promise) | undefined; + const refusals: string[] = []; + t.mock.method(framework, 'disconnectMcplServer', async (id: string) => { + await disconnect(id); + // Between the restart's disconnect and its connect, only its claims hold these ids. + const attempt = during; + during = undefined; + if (attempt) { + await attempt().then(() => refusals.push('restarted'), (error: unknown) => refusals.push((error as Error).message)); + } + }); + // A restart under the same id holds it. + during = () => framework.restartMcplServer('first', serverConfig('first') as never); + await framework.restartMcplServer('first'); + // A restart under a new id holds the new one. + during = () => framework.restartMcplServer('renamed', serverConfig('renamed') as never); + await framework.restartMcplServer('second', serverConfig('renamed') as never); + assert.deepEqual(refusals, [ + 'MCP server "first" was not restarted: it is being restarted; restart it once that has finished', + 'MCP server "renamed" was not restarted: the restart of "second" holds its id; restart it once that has finished', + ]); + assert.deepEqual(framework.listMcplServers().map(s => [s.id, s.connected]).sort(), [['first', true], ['renamed', true]]); + }); +}); + test('a restart that fails releases the id and the tool prefix it held', async (t) => { await withFramework(true, async (framework) => { t.mock.method(framework, 'disconnectMcplServer', async () => { From aafc2a8820efce7825f532d5cbb703657ab7de67 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 01:04:55 -0400 Subject: [PATCH 46/94] fix(mcp): what main's merge left: a script's reference names its expiry, and only a legacy connect reads the ledger Two things the merge of main took in cleanly but wrongly: - #303 made an unfetched, expired reference's stub say why in place of advising fetch_reference. referenceBlockForScript, which makes the stub's decision for a script, still gave such a reference savedTo: null and nothing else, and for an expiresAt rejected as a field, no expiresAt at all. It now adds expired, the stub's reason, as the stub judges it: a saved copy is still served. The structured-references fixture's refused reference now has a parseable expiry, and a reference whose expiry doesn't parse joins it, unfetched and told why. - #211's read of the awareness ledger landed before the family split in connectHeldMcplServer, and the restart read it for any family. A modern connection has no awareness barrier and no reaction default, so an unreadable ledger refused a modern connect and restart for no reason of its own. Both reads are now the legacy path's. --- src/framework.ts | 14 +++++++------ src/mcpl/references.ts | 7 ++++++- test/mcpl-reference-fields.test.ts | 24 ++++++++++++++++++++-- test/placed-reaction-baseline.test.ts | 17 +++++++++++++++ test/script-structured-references.test.ts | 25 +++++++++++++++-------- 5 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index b5ae461f8..6e5e381ac 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -15947,13 +15947,14 @@ export class AgentFramework { throw new Error('MCPL subsystem is not initialized'); } - // Every connect needs a readable awareness ledger: a runtime connect's - // awareness barrier reconciles it, and a stdio child taking the + // Every legacy connect needs a readable awareness ledger: a runtime + // connect's awareness barrier reconciles it, and a stdio child taking the // protective default derives that from it. Read it first, so an // unreadable ledger refuses the connect before anything is registered // under its id or spawned (a reconnecting server would otherwise be left // registered as a closed stub). With no outbox configured nothing is read. - this.getPlacedReactionBaseline(); + // A modern connection has no awareness barrier and no reaction default. + if (family === 'legacy') this.getPlacedReactionBaseline(); // Register prefix + config for tool dispatch routing (idempotent with // the pre-registration pass in initializeMcpl). @@ -16674,7 +16675,7 @@ export class AgentFramework { if (!config) { throw new Error(`MCPL server "${id}" is not configured`); } - AgentFramework.usableConfigFamily(config); + const family = AgentFramework.usableConfigFamily(config); const inFlight = this.mcpServerOps?.get(id); if (inFlight) { // A connect or disconnect of the server being restarted, in flight, @@ -16697,12 +16698,13 @@ export class AgentFramework { } const collision = this.mcplPrefixCollision(config, id); if (collision) throw new Error(collision); - // Every connect reads the awareness ledger: its awareness barrier + // A legacy connect reads the awareness ledger: its awareness barrier // reconciles it, and a stdio child taking the protective default derives // that from it. Read it before disconnecting, so an unreadable ledger // refuses the restart and leaves the running child up, whatever the // server's baseline or transport. (No outbox configured: nothing to read.) - this.getPlacedReactionBaseline(); + // A modern connection reads neither. + if (family === 'legacy') this.getPlacedReactionBaseline(); const claim: McpRestartClaim = { restarting: id }; const prefix = config.toolPrefix ?? `mcpl--${config.id}`; diff --git a/src/mcpl/references.ts b/src/mcpl/references.ts index 661bb6345..236bfb4c2 100644 --- a/src/mcpl/references.ts +++ b/src/mcpl/references.ts @@ -356,7 +356,9 @@ function unrecognizedBlockNotice(reason: string): string { * without its URI: the host's reference id, what the server said about it in * this block (its labels stripped and bounded as in the stub), and `savedTo`, * the workspace path its payload was fetched to, or null when it wasn't - * (`fetch_reference` takes the id as `ref_id`). A block the classifier + * (`fetch_reference` takes the id as `ref_id`). An unsaved reference that + * counts as expired also carries `expired`, the reason the stub gives in + * place of advising fetch_reference. A block the classifier * rejects becomes the notice the model's lanes show, and inline data that * also claims a bulk disposition the withheld notice, each as a text block. * A text block, or inline data that claims nothing, is returned as sent. @@ -371,6 +373,8 @@ export function referenceBlockForScript(block: unknown): unknown { case 'reference': { const t = c.testimony; const record = referenceRegistry.register(t); + // As the stub judges it: a saved copy is still served. + const expiry = record.fetchedPath ? null : referenceExpiry(record.testimony); return { type: (block as { type: string }).type, refId: record.refId, @@ -380,6 +384,7 @@ export function referenceBlockForScript(block: unknown): unknown { ...(t.digest !== undefined ? { digest: t.digest } : {}), ...(t.expiresAt !== undefined ? { expiresAt: sanitizeLabel(t.expiresAt, STUB_FIELD_CHARS) } : {}), savedTo: record.fetchedPath ?? null, + ...(expiry ? { expired: EXPIRY_REASON[expiry] } : {}), }; } } diff --git a/test/mcpl-reference-fields.test.ts b/test/mcpl-reference-fields.test.ts index 7732d5062..1a2c8d4a9 100644 --- a/test/mcpl-reference-fields.test.ts +++ b/test/mcpl-reference-fields.test.ts @@ -1,8 +1,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { - buildReferenceStub, classifyBlock, isReferenceExpired, sanitizeLabel, REFERENCE_LIMITS, - referenceRegistry, referenceStubOrNull, + buildReferenceStub, classifyBlock, isReferenceExpired, sanitizeLabel, EXPIRY_REASON, REFERENCE_LIMITS, + referenceBlockForScript, referenceRegistry, referenceStubOrNull, } from '../src/mcpl/references.js'; const fields = ['name', 'mimeType', 'expiresAt'] as const; @@ -116,6 +116,26 @@ test('the stub leaves out a type, or a provenance, that sanitizes to nothing', ( assert.equal(originStub, `[${referenceRegistry.findByUri(originGone.uri)!.refId}] image/png — fetch with fetch_reference`); }); +test("an expired reference reaches a script with the reason its stub gives, and a live or saved one without it", () => { + const cases = [['2001-01-01T00:00:00Z', EXPIRY_REASON.passed], ['not-a-date', EXPIRY_REASON.unparseable], + ['x'.repeat(REFERENCE_LIMITS.expiresAt + 1), EXPIRY_REASON.rejected]] as const; + for (const [i, [expiresAt, why]] of cases.entries()) { + const { block, testimony } = reference('resource', { uri: `https://example.test/script-expired-${i}`, expiresAt }); + const view = referenceBlockForScript(block) as { savedTo: unknown; expired?: string }; + assert.equal(view.savedTo, null); + assert.equal(view.expired, why); + assert.ok(buildReferenceStub(testimony).includes(why), 'the stub gives the same reason'); + } + const live = reference('resource', { uri: 'https://example.test/script-live', expiresAt: '2999-01-01T00:00:00Z' }).block; + assert.equal((referenceBlockForScript(live) as { expired?: string }).expired, undefined); + // A copy saved before its expiry passed is still served, and the script gets its path. + const saved = reference('resource', { uri: 'https://example.test/script-expired-0', expiresAt: '2001-01-01T00:00:00Z' }).block; + referenceRegistry.findByUri(saved.uri)!.fetchedPath = 'scratch/refs/kept.png'; + const view = referenceBlockForScript(saved) as { savedTo: unknown; expired?: string }; + assert.equal(view.savedTo, 'scratch/refs/kept.png'); + assert.equal(view.expired, undefined); +}); + test("an expired reference's stub says why, where a live one's advises fetch_reference", () => { const cases = [['2001-01-01T00:00:00Z', /expired: its expiresAt has passed/], ['not-a-date', /treated as expired: its expiresAt does not parse/], ['x'.repeat(REFERENCE_LIMITS.expiresAt + 1), /treated as expired: its expiresAt was rejected as a field/]] as const; diff --git a/test/placed-reaction-baseline.test.ts b/test/placed-reaction-baseline.test.ts index 77c7dbee3..f9ad03ff3 100644 --- a/test/placed-reaction-baseline.test.ts +++ b/test/placed-reaction-baseline.test.ts @@ -12,6 +12,7 @@ import { MockMembrane } from './helpers/mock-membrane.js'; const ENV_KEY = 'DISCORD_SUPPRESSED_REACTIONS_BASELINE'; const FIXTURE = fileURLToPath(new URL('./fixtures/reaction-baseline-mcpl-server.mjs', import.meta.url)); +const MODERN_FIXTURE = fileURLToPath(new URL('./fixtures/modern-mcp-server.mjs', import.meta.url)); function server(overrides: Partial = {}): McplServerConfig { return { id: 'probe', command: process.execPath, args: [FIXTURE], ...overrides }; @@ -290,6 +291,22 @@ describe('placed-reaction baseline', () => { }); } + it('a modern server connects and restarts on a corrupt ledger, which it never reads', async () => { + // A modern connection has no awareness barrier and no reaction default, + // so the ledger is no reason of its own to refuse it. + const fw = await create(); + const modern = (id: string): McplServerConfig => ({ + id, command: process.execPath, args: [MODERN_FIXTURE, join(dir, `${id}-starts.log`), join(dir, `${id}-events.log`)], protocol: 'modern', + }); + await fw.connectMcplServer(modern('modern')); + writeFileSync(outboxPath, '{broken'); + await fw.restartMcplServer('modern'); + await fw.connectMcplServer(modern('second')); + assert.deepEqual(fw.listMcplServers().map((s) => [s.id, s.connected]).sort(), [['modern', true], ['second', true]]); + // A legacy connect still refuses on it. + await assert.rejects(() => fw.connectMcplServer(server()), accountingRefusal); + }); + it('passes a URL-only WebSocket config through unchanged', async () => { const fw = await create(); // Bring up the MCPL subsystem as a first runtime connect does. diff --git a/test/script-structured-references.test.ts b/test/script-structured-references.test.ts index 8942a3854..77ce75550 100644 --- a/test/script-structured-references.test.ts +++ b/test/script-structured-references.test.ts @@ -18,7 +18,7 @@ import { join } from 'node:path'; import { AgentFramework, WorkspaceModule } from '../src/index.js'; import type { ToolResult } from '../src/index.js'; -import { INLINE_WITHHELD_TEXT } from '../src/mcpl/references.js'; +import { EXPIRY_REASON, INLINE_WITHHELD_TEXT } from '../src/mcpl/references.js'; import type { ReferenceRecord } from '../src/mcpl/references.js'; import { MockMembrane } from './helpers/mock-membrane.js'; @@ -30,6 +30,7 @@ const DIGEST = 'sha256:47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU'; const uris = (test: string) => ({ report: `https://files.example.invalid/${test}/report.pdf?sig=secret-token`, big: `https://files.example.invalid/${test}/big.png?sig=other-token`, + old: `https://files.example.invalid/${test}/old.png?sig=old-token`, }); /** A block of each kind the dispatch decides about. */ @@ -41,7 +42,7 @@ const blocks = (test: string) => [ // The same reference again, saying something else about itself. { type: 'resource', uri: uris(test).report, name: 'report-copy.pdf' }, // A reference the fetch doesn't save. - { type: 'image', uri: uris(test).big, mimeType: 'image/png', disposition: 'ref', expiresAt: '2027-01-01T00:00:00Z\u202E' }, + { type: 'image', uri: uris(test).big, mimeType: 'image/png', disposition: 'ref', expiresAt: '2027-01-01T00:00:00Z' }, // Inline data that claims a bulk disposition, and inline data with a uri // beside it: withheld. { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png', disposition: 'ref' }, @@ -55,6 +56,10 @@ const blocks = (test: string) => [ { type: 'resource', resource: { uri: 'https://files.example.invalid/embedded?sig=embedded-token', mimeType: 'text/plain', text: 'embedded' } }, { type: 'resource_link', uri: 'https://files.example.invalid/link?sig=link-token', name: 'link' }, { type: 'text', text: 'two attachments' }, + // A reference that counts as expired: with a bidi override after it, its + // expiresAt doesn't parse, so it fails closed. It isn't fetched, and the + // script is told why. + { type: 'image', uri: uris(test).old, mimeType: 'image/png', expiresAt: '2027-01-01T00:00:00Z\u202E' }, ]; /** A tools/call result as a server may send it, nonconformant blocks included. */ @@ -137,7 +142,7 @@ async function withPeers(fn: (h: Harness) => Promise, opts: { eager?: bool } /** What a script reads for those blocks from an MCPL peer, given each reference's id. */ -const treated = (report: string, big: string) => [ +const treated = (report: string, big: string, old: string) => [ { type: 'resource', refId: report, name: 'report.pdf', mimeType: 'application/pdf', sizeBytes: 1234, digest: DIGEST, expiresAt: '2026-12-31T00:00:00Z', savedTo: SAVED }, { type: 'resource', refId: report, name: 'report-copy.pdf', savedTo: SAVED }, @@ -149,6 +154,7 @@ const treated = (report: string, big: string) => [ { type: 'text', text: '[unrecognized content block: reference without string uri]' }, { type: 'text', text: '[unrecognized content block: unknown block type: resource_link]' }, { type: 'text', text: 'two attachments' }, + { type: 'image', refId: old, mimeType: 'image/png', expiresAt: '2027-01-01T00:00:00Z', savedTo: null, expired: EXPIRY_REASON.unparseable }, ]; test("an MCPL peer's structured result reaches a script with each block treated as the model's lanes treat it", async () => { @@ -156,13 +162,16 @@ test("an MCPL peer's structured result reaches a script with each block treated answer({ content: blocks('treated'), structuredContent: { attachments: 2 } }); const out = await script('mcpl--files--attach'); assert.ok(!out.includes('example.invalid'), `no URI reaches the script: ${out.slice(0, 2000)}`); - assert.deepEqual(fetched, [uris('treated').report, uris('treated').big], 'every reference was fetched for the script before it read the result'); + assert.deepEqual(fetched, [uris('treated').report, uris('treated').big], + 'every reference was fetched for the script before it read the result, except the one that counts as expired'); const object = JSON.parse(out) as { content: Array<{ refId?: string }>; structuredContent: unknown; isError: boolean }; const [report, , big] = object.content; + const old = object.content[10]; assert.match(String(report!.refId), /^ref_/); assert.match(String(big!.refId), /^ref_/); - assert.notEqual(report!.refId, big!.refId); - assert.deepEqual(object, { content: treated(report!.refId!, big!.refId!), structuredContent: { attachments: 2 }, isError: false }); + assert.match(String(old!.refId), /^ref_/); + assert.equal(new Set([report!.refId, big!.refId, old!.refId]).size, 3); + assert.deepEqual(object, { content: treated(report!.refId!, big!.refId!, old!.refId!), structuredContent: { attachments: 2 }, isError: false }); }); }); @@ -224,7 +233,7 @@ test("an MCPL peer's structured result past the script cap is saved with its ref const saved = read.data.toString('utf8'); assert.ok(!saved.includes('example.invalid'), 'no URI in the saved file'); const object = JSON.parse(saved) as { content: Array<{ refId?: string }> }; - assert.deepEqual(object.content, treated(object.content[0]!.refId!, object.content[2]!.refId!)); + assert.deepEqual(object.content, treated(object.content[0]!.refId!, object.content[2]!.refId!, object.content[10]!.refId!)); }); }); @@ -260,7 +269,7 @@ test('an error with structured content is fetched for the script, and reaches it assert.ok(!out.includes('example.invalid'), out); const object = JSON.parse(out) as { content: Array<{ refId?: string }>; structuredContent: unknown; isError: boolean; error: string }; assert.deepEqual(object, { - content: [treated(object.content[0]!.refId!, '')[0], { type: 'text', text: 'the attach failed' }], + content: [treated(object.content[0]!.refId!, '', '')[0], { type: 'text', text: 'the attach failed' }], structuredContent: { partial: true }, isError: true, error: 'the attach failed', From a02525a2de5a6447b5e71d95c31ff3d0546af1eb Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 01:05:14 -0400 Subject: [PATCH 47/94] docs(changelog): a script's expired reference carries expired --- changelog.d/mcp-standard-tool-results.changed.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index 5d0a6972b..4e07d2530 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -41,7 +41,9 @@ bidi characters stripped from the text fields, as in the model's stub), and `savedTo`, the workspace path the framework fetched its payload to for the script (`null` when it couldn't; `fetch_reference` takes the id as its - `ref_id` argument). A block the classifier rejects arrives as the model's + `ref_id` argument). An unsaved reference that counts as expired also + carries `expired`, the reason the model's stub gives, and isn't fetched. + A block the classifier rejects arrives as the model's `[unrecognized content block: …]` notice, and inline data that also claims a bulk disposition as the withheld notice. #181, by @Meganeuridae, first found scripts losing `structuredContent`, and it kept the saved paths of From c5763d3543b52341b3f636d2f2f916d3e087a589 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 01:08:24 -0400 Subject: [PATCH 48/94] test(baseline): command + URL with no transport is refused, as this branch's binding rule says main's #211 test injected the baseline for a stdio child configured with command and url and no transport, which main launched as stdio. This branch refuses that configuration before anything is spawned or dialed (resolveServerBinding; the legacy boundary's breaking note), so the implicit case now asserts the refusal and that nothing is registered, and the explicit stdio case keeps the injection. --- test/placed-reaction-baseline.test.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/test/placed-reaction-baseline.test.ts b/test/placed-reaction-baseline.test.ts index f9ad03ff3..ef3a91a52 100644 --- a/test/placed-reaction-baseline.test.ts +++ b/test/placed-reaction-baseline.test.ts @@ -141,13 +141,23 @@ describe('placed-reaction baseline', () => { assert.deepEqual(config.env, { BASELINE_TEST_EXTRA: 'preserved' }, 'caller config stays unchanged'); }); - for (const transport of [undefined, 'stdio'] as const) { - it('injects the baseline with command + URL and ' + (transport ?? 'implicit') + ' transport', async () => { - const fw = await create('🔕', [server({ url: 'ws://127.0.0.1:1', transport })]); - const env = await childEnv(fw); - assert.deepEqual(new Set(env.baseline?.split(',')), new Set([...REFUSAL_REACTION_BASELINE, '💤', '🔕'])); - }); - } + it('injects the baseline with command + URL and stdio transport', async () => { + const fw = await create('🔕', [server({ url: 'ws://127.0.0.1:1', transport: 'stdio' })]); + const env = await childEnv(fw); + assert.deepEqual(new Set(env.baseline?.split(',')), new Set([...REFUSAL_REACTION_BASELINE, '💤', '🔕'])); + }); + + it('refuses command + URL with no transport before spawning anything', async () => { + // Which of the two the configuration means is ambiguous, so its binding + // is refused: nothing is launched, and no baseline is derived for it. + const fw = await create('🔕'); + await assert.rejects( + () => fw.connectMcplServer(server({ url: 'ws://127.0.0.1:1' })), + /"command" and "url" are both set, and no "transport" says which one to use/, + ); + const registry = (fw as unknown as { mcplServerRegistry: { getServer(id: string): unknown } | null }).mcplServerRegistry; + assert.equal(registry?.getServer('probe') ?? null, null, 'nothing is registered or spawned'); + }); it('derives the default on runtime connect, including newly retained ledger markers', async () => { const fw = await create('🔕'); From 792ef25a18a72771282da9bde529420977dd65bd Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 01:16:36 -0400 Subject: [PATCH 49/94] fix(mcp): what the full run and the round's readers found From the full run at 379490b6, merge adaptations: - The restart claims are created on first use (restartClaims()), as the modern connections are, so a framework assembled field by field, as the reaction-baseline platform tests build one, needs no setup. - The eager-slot test calls autoFetchReferences with forScript false: the eager pass is unconditional now, and its third argument is the script's demand, which would fetch past the eight slots. - The awareness barrier's inbound-handler tests wire the connection with its configuration, as wireMcplEvents takes it here. - The surgery-marks fixture answers initialize with protocolVersion 2024-11-05, which the legacy engine requires. - A module's result that can't be stored is an error naming the tool by the time the dispatch returns it (main's storableToolResult), so a script reads that error's text, structured or not. The structured results test expects that text, and the docs say so. From the round's read: - addModule refuses a name a server's tool prefix holds, as create() does at startup, and a prefix a restart is moving its server to, which a module added during the restart would otherwise take after the old server was gone. - A ws:// url's userinfo goes as one unit with no length floor, anchored by its @ and by no letter or digit just before it, so a short password goes with its username however short both are. - A test token whose form encoding differs from its percent encoding pins the lowercase form-encoded variant. --- .../mcp-standard-tool-results.changed.md | 11 ++-- .../redact-recorded-mcp-credentials.fixed.md | 2 +- src/framework.ts | 64 ++++++++++++------- src/mcpl/protocol-family.ts | 17 +++-- .../code-execution-structured-results.test.ts | 27 ++++---- test/fixtures/surgery-marks-mcpl-server.mjs | 1 + test/mcp-protocol-family.test.ts | 18 ++++-- test/mcpl-awareness-barrier.test.ts | 6 +- test/mcpl-reference-fetcher.test.ts | 5 +- test/mcpl-runtime-lifecycle.test.ts | 17 +++++ 10 files changed, 109 insertions(+), 59 deletions(-) diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index 4e07d2530..f17265a89 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -112,11 +112,12 @@ disconnect of the server in flight. Before, a restart disconnected first, so a refused connect left the server gone; now the server keeps running. From those checks through its connect, the restart holds the id and the - tool prefix it moves the server to, so a connect or restart started - meanwhile is refused them, naming the restart, rather than taking them - while the old server is torn down. A disconnect releases its tool-prefix - claim only while its own server holds it. Treat a submitted configuration - as immutable while the server uses it: change settings with + tool prefix it moves the server to, so a connect, a restart or a module + added meanwhile is refused them, naming the restart, rather than taking + them while the old server is torn down. `addModule` also refuses a name a + server's tool prefix holds, as `create()` does at startup. A disconnect + releases the tool-prefix claims its own server holds. Treat a submitted + configuration as immutable while the server uses it: change settings with `restartMcplServer(id, newConfig)`, and rotate a network server's credentials through `accessProvider` (a stdio server's changed `env` goes through a restart with the new configuration). diff --git a/changelog.d/redact-recorded-mcp-credentials.fixed.md b/changelog.d/redact-recorded-mcp-credentials.fixed.md index e58f63aaa..62df4f2e8 100644 --- a/changelog.d/redact-recorded-mcp-credentials.fixed.md +++ b/changelog.d/redact-recorded-mcp-credentials.fixed.md @@ -1 +1 @@ -- Server and transport text the framework records has the server's configured credentials replaced, in both engines: connection traces, console lines, stderr traces, the ops alert (and so `logs/failures.log` and the webhook), a tool call's failure as its caller and the model get it, and the `tool:failed` trace's message and stack. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values (each as written and decoded), every non-empty parameter as its whole `name=value`, and a `ws://` url's username and password, its userinfo as one unit before its `@`, and the Basic credential a WebSocket client sends for it; the static `token`, raw, with its slashes escaped as some JSON encoders write them, and as a url carries it, percent- or form-encoded with either case of hex; and a stdio server's `env` values whose names have a part ending in a credential word, singular or plural, with any digits after it (`GITHUB_TOKEN`, `API_KEYS`, `PGPASSWORD`, `DB_PASS`, `AUTHORIZATION`, `SESSION_COOKIE`, `TOKEN2`), raw, with their slashes escaped as some JSON encoders write them, and percent-encoded with either case of hex. A stdio server's command-line arguments are not derived. A string replaced on its own has at least eight characters, since a shorter one can't be told from ordinary text, and is replaced wherever it occurs. Every query parameter is also replaced as its whole `name=value`, only where no letter or digit adjoins the pair (so `v=2` leaves `dev=2` alone); a short value goes only that way. A connection's event records use a redactor built once for it, and an operation's records one built when the operation begins, so a server removed, or replaced by a restart, while the operation waits doesn't change what they replace. Text that may come from any server takes one pass over every server's strings, so a secret that begins another server's longer one doesn't cut that one short. Over HTTP, which only the modern engine speaks, a 401 or 403's response body is left out of the request error's data; where the HTTP client builds the error's message from the body, as it does for most statuses, the message is redacted like any record. A token an access provider fetches, and credentials the configuration doesn't hold, are outside this. JSON-RPC answers, and the error a host's own connect raises, pass as they are. +- Server and transport text the framework records has the server's configured credentials replaced, in both engines: connection traces, console lines, stderr traces, the ops alert (and so `logs/failures.log` and the webhook), a tool call's failure as its caller and the model get it, and the `tool:failed` trace's message and stack. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values (each as written and decoded), every non-empty parameter as its whole `name=value`, and a `ws://` url's username and password, its userinfo as one unit before its `@` (with no length floor, so a short password goes with its username), and the Basic credential a WebSocket client sends for it; the static `token`, raw, with its slashes escaped as some JSON encoders write them, and as a url carries it, percent- or form-encoded with either case of hex; and a stdio server's `env` values whose names have a part ending in a credential word, singular or plural, with any digits after it (`GITHUB_TOKEN`, `API_KEYS`, `PGPASSWORD`, `DB_PASS`, `AUTHORIZATION`, `SESSION_COOKIE`, `TOKEN2`), raw, with their slashes escaped as some JSON encoders write them, and percent-encoded with either case of hex. A stdio server's command-line arguments are not derived. A string replaced on its own has at least eight characters, since a shorter one can't be told from ordinary text, and is replaced wherever it occurs. Every query parameter is also replaced as its whole `name=value`, only where no letter or digit adjoins the pair (so `v=2` leaves `dev=2` alone); a short value goes only that way. A connection's event records use a redactor built once for it, and an operation's records one built when the operation begins, so a server removed, or replaced by a restart, while the operation waits doesn't change what they replace. Text that may come from any server takes one pass over every server's strings, so a secret that begins another server's longer one doesn't cut that one short. Over HTTP, which only the modern engine speaks, a 401 or 403's response body is left out of the request error's data; where the HTTP client builds the error's message from the body, as it does for most statuses, the message is redacted like any record. A token an access provider fetches, and credentials the configuration doesn't hold, are outside this. JSON-RPC answers, and the error a host's own connect raises, pass as they are. diff --git a/src/framework.ts b/src/framework.ts index 6e5e381ac..4e23c3659 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1960,11 +1960,9 @@ export class AgentFramework { /** The ids and tool prefixes restarts hold, each with the claim of the * restart holding it, from the restart's checks through its connect * (restartMcplServer): no other connect or restart takes them while the - * old server is torn down. */ - private readonly mcpRestartClaims = { - ids: new Map(), - prefixes: new Map(), - }; + * old server is torn down. Created on first use (restartClaims), like + * mcpServerOps. */ + private mcpRestartClaims: { ids: Map; prefixes: Map } | undefined; /** Host capabilities advertised during the MCP handshake — stored so servers * can be connected at runtime (connectMcplServer) after initialization. */ private mcplHostCapabilities: McplHostCapabilities | null = null; @@ -3546,6 +3544,18 @@ export class AgentFramework { * Add a module at runtime. */ async addModule(module: Module): Promise { + // A server's tool prefix and a module's name share one namespace, as + // create() enforces at startup (mcplPrefixCollision): a module isn't + // added under a prefix a server holds, or one a restart is moving its + // server to. + const server = this.mcplPrefixMap.get(module.name); + if (server !== undefined) { + throw new Error(`Module "${module.name}" collides with MCPL server "${server}"'s toolPrefix "${module.name}"`); + } + const claimedBy = this.restartClaims().prefixes.get(module.name); + if (claimedBy) { + throw new Error(`Module "${module.name}" collides with the toolPrefix "${module.name}" the restart of "${claimedBy.restarting}" holds`); + } this.resetCompletedStop(); await this.moduleRegistry.addModule(module); this.emitTrace({ type: 'module:added', moduleName: module.name }); @@ -13723,9 +13733,10 @@ export class AgentFramework { * megabytes of base64 helps nobody. A result with structured content is * one JSON object holding both of the server's views * (scriptStructuredResult), so its content blocks reach the script as the - * dispatch gave them, images and payloads included. A result that can't be - * converted to JSON, such as a cyclic object or a BigInt from an in-process - * module, becomes an error that names the tool, in each path's error form. + * dispatch gave them, images and payloads included. A module's result that + * can't be stored or sent, such as a cyclic object or a BigInt, is already + * an error naming the tool when the dispatch returns it (storableToolResult), + * so a script reads that error's text. */ private async handleScriptToolCall( agentName: string, @@ -13785,9 +13796,10 @@ export class AgentFramework { * `{ isError, oversized: { chars, savedTo } }`. If it can't be saved, the * script gets a failure to materialize the result, saying the tool may * already have completed, never a success missing its result. A result - * that can't be converted to JSON (a cyclic object, a BigInt) gets the - * same object empty, `{ content: [], structuredContent: null, isError: - * true, error }`, the error naming the tool. + * that can't be converted to JSON (a cyclic object, a BigInt), which the + * dispatch's own check makes unlikely here, gets the same object empty, + * `{ content: [], structuredContent: null, isError: true, error }`, the + * error naming the tool. */ private async scriptStructuredResult(result: ToolResult, toolName: string): Promise { const object = { @@ -15454,6 +15466,12 @@ export class AgentFramework { return (this.modernMcpConnections ??= new Map()); } + /** The restarts' claims (mcpRestartClaims), created on first use, as the + * modern connections are. */ + private restartClaims(): { ids: Map; prefixes: Map } { + return (this.mcpRestartClaims ??= { ids: new Map(), prefixes: new Map() }); + } + /** * Hold a server id for one connect or disconnect, until `release()`. * Together with the live connections (the MCPL registry and the modern @@ -16648,8 +16666,9 @@ export class AgentFramework { * unlike `reconnect: true`, which only retries after transport-level * failures. Throws if the server has no stored config and none is given. * What its connect would refuse is refused before the running server is - * torn down, and until the connect, a connect or restart started meanwhile - * is refused the id and tool prefix the restart moves the server to. + * torn down, and until the connect, a connect, a restart or a module added + * meanwhile is refused the id and tool prefix the restart moves the server + * to. * * Treat a submitted configuration and its nested values as immutable for * as long as the server uses them. Apply changed settings with @@ -16666,8 +16685,9 @@ export class AgentFramework { // configuration's binding and settings, a new id another server holds, // and a tool prefix another server or a module already holds. From these // checks through its connect, the restart holds the id and the tool - // prefix it moves the server to (mcpRestartClaims), so no connect or - // restart started meanwhile takes them while the old server is torn down. + // prefix it moves the server to (mcpRestartClaims), so no connect, + // restart or module added meanwhile takes them while the old server is + // torn down. if (this.mcpServerAdmissionClosed) { throw new Error(`MCP server "${id}" was not restarted: the framework is stopping or stopped`); } @@ -16684,7 +16704,7 @@ export class AgentFramework { // operation left, after the server was torn down. throw new Error(`MCP server "${id}" was not restarted: it is being ${inFlight.kind === 'disconnect' ? 'disconnected' : 'connected'}; restart it once that has finished`); } - const claimedBy = this.mcpRestartClaims.ids.get(id); + const claimedBy = this.restartClaims().ids.get(id); if (claimedBy) { // Another restart is moving its server to this id, or restarting this // server already. @@ -16708,14 +16728,14 @@ export class AgentFramework { const claim: McpRestartClaim = { restarting: id }; const prefix = config.toolPrefix ?? `mcpl--${config.id}`; - this.mcpRestartClaims.ids.set(config.id, claim); - this.mcpRestartClaims.prefixes.set(prefix, claim); + this.restartClaims().ids.set(config.id, claim); + this.restartClaims().prefixes.set(prefix, claim); try { await this.disconnectMcplServer(id); await this.connectMcplServerClaimed(config, claim); } finally { - if (this.mcpRestartClaims.ids.get(config.id) === claim) this.mcpRestartClaims.ids.delete(config.id); - if (this.mcpRestartClaims.prefixes.get(prefix) === claim) this.mcpRestartClaims.prefixes.delete(prefix); + if (this.restartClaims().ids.get(config.id) === claim) this.restartClaims().ids.delete(config.id); + if (this.restartClaims().prefixes.get(prefix) === claim) this.restartClaims().prefixes.delete(prefix); } } @@ -16745,7 +16765,7 @@ export class AgentFramework { * a restart moving its server to the id (mcpRestartClaims), except to * that restart's own connect (`claim`). */ private mcpServerIdTaken(id: string, claim?: McpRestartClaim): string | null { - const claimedBy = this.mcpRestartClaims.ids.get(id); + const claimedBy = this.restartClaims().ids.get(id); if (claimedBy && claimedBy !== claim) { return `MCP server "${id}" is held by the restart of "${claimedBy.restarting}"; connect it once that has finished`; } @@ -16777,7 +16797,7 @@ export class AgentFramework { if (this.moduleRegistry.getAllModules().some(m => m.name === prefix)) { return `MCPL server "${config.id}" toolPrefix "${prefix}" collides with module "${prefix}"`; } - const claimedBy = this.mcpRestartClaims.prefixes.get(prefix); + const claimedBy = this.restartClaims().prefixes.get(prefix); if (claimedBy && claimedBy !== claim) { return `MCPL server "${config.id}" toolPrefix "${prefix}" is held by the restart of "${claimedBy.restarting}"`; } diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index 7ab46747f..fe230315f 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -113,7 +113,8 @@ export function redactUrl(url: string | undefined): string { * - from the url, as written and decoded: credential-like path segments and * query names, credential-shaped or credential-named query values, every * other non-empty parameter as its whole name=value, and a ws:// url's - * username and password, its userinfo as one unit before its @, and the + * username and password, its userinfo as one unit before its @ (with no + * length floor, so a short password goes with its username), and the * Basic credential a WebSocket client sends for that userinfo; * - the static token, raw, with its slashes escaped as some JSON encoders * write them, and percent- or form-encoded with either case of hex; @@ -235,12 +236,14 @@ export function serverCredentialPatterns(config: Pick= 8) userinfo.add(`${username}:${password}`); + if (password) userinfo.add(`${username}:${password}`); standalone(Buffer.from(`${username}:${password}`).toString('base64')); } } @@ -250,7 +253,7 @@ export function serverCredentialPatterns(config: Pick ({ value, source: escape(value) })), ...[...pairs].map(value => ({ value, source: `(? ({ value, source: `${escape(value)}(?=@)` })), + ...[...userinfo].map(value => ({ value, source: `(? 5000000, large', 'assert "could not be delivered" in large["error"], large', - // Results JSON can't hold: an error naming the tool, in each path's form. - // A structured result's error keeps the documented shape, so a script reading "content" first still works. - 'cyclic = json.loads(await loop__cyclic({}))', - 'assert cyclic["content"] == [] and cyclic["structuredContent"] is None and cyclic["isError"] is True, cyclic', - 'assert "loop--cyclic" in cyclic["error"] and "circular" in cyclic["error"], cyclic', - 'bigint = json.loads(await loop__bigint({}))', - 'assert bigint["content"] == [] and bigint["structuredContent"] is None and bigint["isError"] is True, bigint', - 'assert "loop--bigint" in bigint["error"] and "BigInt" in bigint["error"], bigint', + // Results JSON can't hold: the dispatch makes each an error naming the + // tool (a module's result has to be stored), which a script reads as + // its error text, structured or not. + 'cyclic = await loop__cyclic({})', + 'assert cyclic.startswith("Error: Tool \'loop--cyclic\' ") and "circular" in cyclic, cyclic', + 'bigint = await loop__bigint({})', + 'assert bigint.startswith("Error: Tool \'loop--bigint\' ") and "BigInt" in bigint, bigint', 'cyclic_data = await loop__cyclic_data({})', - 'assert cyclic_data.startswith("Error: tool ") and "loop--cyclic_data" in cyclic_data and "circular" in cyclic_data, cyclic_data', - 'assert all("The call itself completed." in e for e in [cyclic["error"], bigint["error"], cyclic_data]), "completed"', + 'assert cyclic_data.startswith("Error: Tool \'loop--cyclic_data\' ") and "circular" in cyclic_data, cyclic_data', + 'assert all(e.endswith("The call itself completed.") for e in [cyclic, bigint, cyclic_data]), "completed"', // An error result that can't be converted keeps the tool's own error. - 'cyclic_error = json.loads(await loop__cyclic_error({}))', - 'assert cyclic_error["isError"] is True and cyclic_error["error"].endswith("The call itself completed, with this error: db timeout"), cyclic_error', - 'bare_throw = json.loads(await loop__bare_throw({}))', - 'assert bare_throw["isError"] is True and "(a value with no text form)" in bare_throw["error"], bare_throw', + 'cyclic_error = await loop__cyclic_error({})', + 'assert cyclic_error.endswith("The call itself completed, with this error: db timeout"), cyclic_error', + 'bare_throw = await loop__bare_throw({})', + 'assert "(a value with no text form)" in bare_throw, bare_throw', 'print(r["structuredContent"]["runId"], r["structuredContent"]["state"], r["structuredContent"]["output"])', ].join('\n'); membrane.pushResponse(createMockResponse([ diff --git a/test/fixtures/surgery-marks-mcpl-server.mjs b/test/fixtures/surgery-marks-mcpl-server.mjs index 4d2a0a117..1eb76c2ed 100644 --- a/test/fixtures/surgery-marks-mcpl-server.mjs +++ b/test/fixtures/surgery-marks-mcpl-server.mjs @@ -47,6 +47,7 @@ function answer(message) { function handle(message) { if (message.method === 'initialize') { reply(message.id, { + protocolVersion: '2024-11-05', capabilities: { experimental: { mcpl: { diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index 64a9564f9..5c58a79e8 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -383,14 +383,22 @@ test('recorded server text: a slash escaped as JSON may write it, and percent-es for (const form of ['tok/en+x0123', 'tok\\/en+x0123', 'tok%2Fen%2Bx0123', 'tok%2fen%2bx0123', 'env\\/secret+0123', 'env%2fsecret%2b0123']) { assert.equal(redactServerText(`refused: ${form}`, config), 'refused: redacted', form); } + // A token whose form encoding differs from its percent encoding: a space is + there, %20 here. + const spaced = cfg({ token: 'tok en/x0123' }); + for (const form of ['tok+en%2Fx0123', 'tok+en%2fx0123', 'tok%20en%2Fx0123', 'tok%20en%2fx0123']) { + assert.equal(redactServerText(`refused: ${form}`, spaced), 'refused: redacted', form); + } }); test("recorded server text: a ws:// url's userinfo as one unit before its @, and the Basic credential sent for it", () => { - // Neither part is long enough to replace on its own. - const config = cfg({ url: 'wss://alice:pw@h/mcp' }); - assert.equal(redactServerText('dial wss://alice:pw@h/mcp failed', config), 'dial wss://redacted@h/mcp failed'); - assert.equal(redactServerText('Authorization: Basic YWxpY2U6cHc=', config), 'Authorization: Basic redacted'); - assert.equal(redactServerText('alice:pw is not followed by its @ here', config), 'alice:pw is not followed by its @ here'); + // Neither part is long enough to replace on its own, nor is the unit. + const config = cfg({ url: 'wss://bot:pw@h/mcp' }); + assert.equal(redactServerText('dial wss://bot:pw@h/mcp failed', config), 'dial wss://redacted@h/mcp failed'); + assert.equal(redactServerText('Authorization: Basic Ym90OnB3', config), 'Authorization: Basic redacted'); + // The @ and a boundary before it anchor the unit. + assert.equal(redactServerText('bot:pw is not followed by its @ here', config), 'bot:pw is not followed by its @ here'); + assert.equal(redactServerText('robot:pw@h', config), 'robot:pw@h'); + assert.equal(redactServerText('dial wss://alice:pw@h/mcp failed', cfg({ url: 'wss://alice:pw@h/mcp' })), 'dial wss://redacted@h/mcp failed'); }); test("one redactor over several configurations' strings takes the longest secret among them all", () => { diff --git a/test/mcpl-awareness-barrier.test.ts b/test/mcpl-awareness-barrier.test.ts index 6530bee6a..56de8ecac 100644 --- a/test/mcpl-awareness-barrier.test.ts +++ b/test/mcpl-awareness-barrier.test.ts @@ -865,7 +865,7 @@ test('inbound handlers wait out a generation installed while they wait', async ( framework.handleHostCommand = async () => { handled.push('host-command'); return { ok: true }; }; const connection = new EventEmitter() as any; connection.id = 'discord'; - framework.wireMcplEvents(connection); + framework.wireMcplEvents(connection, { id: 'discord' }); const responder = { id: 1, respond: () => {}, respondError: () => {} }; for (const event of ['push-event', 'inference-request', 'channels-incoming', 'host-command']) { connection.emit(event, {}, responder); @@ -897,7 +897,7 @@ test('an inbound handler waiting on a generation that fails refuses its request framework.handleHostCommand = async () => { handled.push('host-command'); return { ok: true }; }; const connection = new EventEmitter() as any; connection.id = 'discord'; - framework.wireMcplEvents(connection); + framework.wireMcplEvents(connection, { id: 'discord' }); const answers: Array<{ event: string; code?: number; result?: unknown }> = []; for (const event of ['push-event', 'inference-request', 'channels-incoming', 'host-command']) { connection.emit(event, {}, { @@ -929,7 +929,7 @@ test('with no generation current, an inbound event is handled in the turn it arr framework.handleHostCommand = async () => { handled.push('host-command'); return { ok: true }; }; const connection = new EventEmitter() as any; connection.id = 'discord'; - framework.wireMcplEvents(connection); + framework.wireMcplEvents(connection, { id: 'discord' }); const responder = { id: 1, respond: () => {}, respondError: () => {} }; for (const event of ['push-event', 'inference-request', 'channels-incoming', 'host-command']) { connection.emit(event, {}, responder); diff --git a/test/mcpl-reference-fetcher.test.ts b/test/mcpl-reference-fetcher.test.ts index 11a55a1f2..9867fc2d3 100644 --- a/test/mcpl-reference-fetcher.test.ts +++ b/test/mcpl-reference-fetcher.test.ts @@ -274,10 +274,11 @@ async function eagerFetch(content: unknown[]): Promise { const fetcher = makeFetcher({ srv: { url: ORIGIN } }); const framework = Object.create(AgentFramework.prototype) as { getReferenceFetcher(): ReferenceFetcher; - autoFetchReferences(content: unknown[], serverId: string, eager: boolean): Promise; + autoFetchReferences(content: unknown[], serverId: string, forScript: boolean): Promise; }; framework.getReferenceFetcher = () => fetcher; - await framework.autoFetchReferences(content, 'srv', true); + // The eager pass alone, as for the model's call: a script's demand would fetch the rest. + await framework.autoFetchReferences(content, 'srv', false); } test('the eager default skips expired references, so the live ones after them get its slots', async (t) => { diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index a5c4e468d..28146a928 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -309,6 +309,23 @@ test('a restart of an id another restart holds is refused before it tears anythi }); }); +test('a module is refused a name a server holds as its tool prefix, or a restart is moving its server to', async () => { + await withFramework(true, async (framework) => { + await assert.rejects( + framework.addModule(new NamedModule('mcpl--first')), + /Module "mcpl--first" collides with MCPL server "first"'s toolPrefix "mcpl--first"/, + ); + const restarting = framework.restartMcplServer('first', serverConfig('first', { toolPrefix: 'xx' }) as never); + await assert.rejects( + framework.addModule(new NamedModule('xx')), + /Module "xx" collides with the toolPrefix "xx" the restart of "first" holds/, + ); + await restarting; + assert.deepEqual(framework.listMcplServers().map(s => [s.id, s.connected]), [['first', true]]); + assert.ok(framework.getAllTools().some(t => t.name === 'xx--echo'), 'the restarted server serves the prefix'); + }); +}); + test('a restart that fails releases the id and the tool prefix it held', async (t) => { await withFramework(true, async (framework) => { t.mock.method(framework, 'disconnectMcplServer', async () => { From e773a451c477c6a9b86361239ce7ab20d93c1768 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 02:30:58 -0400 Subject: [PATCH 50/94] test(baseline): the platform tests' registry stub answers getServer The reaction-baseline platform tests build a framework field by field and stop the connect at addServer. This branch's connect first asks the registry whether the id is already registered (mcpServerIdTaken), so the stub registry answers getServer with nothing registered. Before, every test in the file failed with getServer not a function. --- test/reaction-baseline-platform.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/reaction-baseline-platform.test.ts b/test/reaction-baseline-platform.test.ts index 83b9e1163..7611f6e15 100644 --- a/test/reaction-baseline-platform.test.ts +++ b/test/reaction-baseline-platform.test.ts @@ -29,6 +29,7 @@ async function connectionEnv( discordAwarenessOutbox: null, discordAwarenessEmoji: '🔕', mcplServerRegistry: { + getServer: () => undefined, async addServer(config: McplServerConfig) { captured = config; throw stopBeforeSpawn; From 350c6597a46d761aeb20f78235f22675b70d7790 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 02:59:07 -0400 Subject: [PATCH 51/94] test(mcp): the every-server redactor's one pass spans the configurations it read when made and those present now The every-server redactor reads two sets of configurations: those present when it's made and those present when it's used. Its one longest-first pass over both is what keeps a secret that begins another server's longer one from cutting that one short, but the test put both servers in the first set, so a redactor that ran one set's strings after the other's passed it too. The new case makes the redactor with the shorter secret's server alone, then connects the longer one's, and expects the longer secret redacted whole. --- test/mcp-recorded-text.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/mcp-recorded-text.test.ts b/test/mcp-recorded-text.test.ts index 90e011105..fdaa5042d 100644 --- a/test/mcp-recorded-text.test.ts +++ b/test/mcp-recorded-text.test.ts @@ -232,6 +232,12 @@ test("a tool call's failure, from either engine, reaches its caller and its trac ['b', { id: 'b', token: 'ghp_SHAREDPREFIX123_but_longer_TAIL9' }], ]); assert.equal(framework.everyServerRedactor()('auth failed for ghp_SHAREDPREFIX123_but_longer_TAIL9'), 'auth failed for redacted'); + // The same across the two sets it reads: the secret of a server present + // when it was made begins the longer secret of a server connected since. + framework.mcplServerConfigs = new Map([['a', { id: 'a', token: 'ghp_SHAREDPREFIX123' }]]); + const across = framework.everyServerRedactor(); + framework.mcplServerConfigs = new Map([['b', { id: 'b', token: 'ghp_SHAREDPREFIX123_but_longer_TAIL9' }]]); + assert.equal(across('auth failed for ghp_SHAREDPREFIX123_but_longer_TAIL9'), 'auth failed for redacted'); framework.mcplServerConfigs = new Map([[config.id, config]]); // The model's dispatch: the tool:failed trace (message and stack) and the From 7bffdca9cbaf339ee3502f0e43c311f60e14310a Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 05:32:13 -0400 Subject: [PATCH 52/94] fix(mcp): redact a username alone, JSON's escapes, and overlapping credentials Three gaps in the redaction of recorded server text: - A ws:// url whose userinfo is a username alone (a token written as the username) got no unit, since the unit was added only with a password. A username under 8 characters was then replaced nowhere. The unit is now username:password, or the username alone when there's no password, as written and decoded, anchored as before (no letter or digit just before it, its @ just after). The outer guard that the per-pair check made redundant is gone. - A token's or a credential env value's JSON form covered only an escaped slash, which JSON.stringify never writes. Its escapes for a quote, a backslash or a control character are now covered too, with and without escaped slashes. - One leftmost-first pass let a match consume the start of a string that overlapped it, leaving that string's tail in the text. The redactor now finds every match, a match beginning inside another included, and replaces each run of overlapping matches as one "redacted". Text without overlaps is redacted exactly as before, and the replacement is never scanned. Tests: ws:// beside wss:// for the userinfo unit; a username alone under both schemes, with its Basic credential; a percent-encoded userinfo whose parts are all too short for the standalone floor, as written and decoded, with the Basic credential of each; a credential with a quote and a backslash as JSON.stringify writes it; and two configurations whose tokens overlap, both replaced whole, with adjacent ones still replaced apart. The changelog fragment says each. --- .../redact-recorded-mcp-credentials.fixed.md | 2 +- src/mcpl/protocol-family.ts | 102 ++++++++++++------ test/mcp-protocol-family.test.ts | 42 ++++++++ 3 files changed, 111 insertions(+), 35 deletions(-) diff --git a/changelog.d/redact-recorded-mcp-credentials.fixed.md b/changelog.d/redact-recorded-mcp-credentials.fixed.md index 62df4f2e8..fa3bcfe38 100644 --- a/changelog.d/redact-recorded-mcp-credentials.fixed.md +++ b/changelog.d/redact-recorded-mcp-credentials.fixed.md @@ -1 +1 @@ -- Server and transport text the framework records has the server's configured credentials replaced, in both engines: connection traces, console lines, stderr traces, the ops alert (and so `logs/failures.log` and the webhook), a tool call's failure as its caller and the model get it, and the `tool:failed` trace's message and stack. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values (each as written and decoded), every non-empty parameter as its whole `name=value`, and a `ws://` url's username and password, its userinfo as one unit before its `@` (with no length floor, so a short password goes with its username), and the Basic credential a WebSocket client sends for it; the static `token`, raw, with its slashes escaped as some JSON encoders write them, and as a url carries it, percent- or form-encoded with either case of hex; and a stdio server's `env` values whose names have a part ending in a credential word, singular or plural, with any digits after it (`GITHUB_TOKEN`, `API_KEYS`, `PGPASSWORD`, `DB_PASS`, `AUTHORIZATION`, `SESSION_COOKIE`, `TOKEN2`), raw, with their slashes escaped as some JSON encoders write them, and percent-encoded with either case of hex. A stdio server's command-line arguments are not derived. A string replaced on its own has at least eight characters, since a shorter one can't be told from ordinary text, and is replaced wherever it occurs. Every query parameter is also replaced as its whole `name=value`, only where no letter or digit adjoins the pair (so `v=2` leaves `dev=2` alone); a short value goes only that way. A connection's event records use a redactor built once for it, and an operation's records one built when the operation begins, so a server removed, or replaced by a restart, while the operation waits doesn't change what they replace. Text that may come from any server takes one pass over every server's strings, so a secret that begins another server's longer one doesn't cut that one short. Over HTTP, which only the modern engine speaks, a 401 or 403's response body is left out of the request error's data; where the HTTP client builds the error's message from the body, as it does for most statuses, the message is redacted like any record. A token an access provider fetches, and credentials the configuration doesn't hold, are outside this. JSON-RPC answers, and the error a host's own connect raises, pass as they are. +- Server and transport text the framework records has the server's configured credentials replaced, in both engines: connection traces, console lines, stderr traces, the ops alert (and so `logs/failures.log` and the webhook), an MCP tool call that fails without an answer, as its caller and the model get it and in the `tool:failed` trace's message and stack. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values (each as written and decoded), every non-empty parameter as its whole `name=value`, and a `ws://` url's username and password, its userinfo as one unit before its `@` (the username alone when there's no password), with no length floor, so a short password goes with its username and a short username alone goes too, and the Basic credential a WebSocket client sends for it; the static `token`, raw, as JSON encoders write it (with `JSON.stringify`'s escapes, and with its slashes escaped too, as some encoders write them), and as a url carries it, percent- or form-encoded with either case of hex; and a stdio server's `env` values whose names have a part ending in a credential word, singular or plural, with any digits after it (`GITHUB_TOKEN`, `API_KEYS`, `PGPASSWORD`, `DB_PASS`, `AUTHORIZATION`, `SESSION_COOKIE`, `TOKEN2`), raw, as JSON encoders write them, and percent-encoded with either case of hex. A stdio server's command-line arguments are not derived. A string replaced on its own has at least eight characters, since a shorter one can't be told from ordinary text, and is replaced wherever it occurs. Every non-empty query parameter is also replaced as its whole `name=value`, only where no letter or digit adjoins the pair (so `v=2` leaves `dev=2` alone); a short value goes only that way. A connection's event records use a redactor built once for it. An operation's records replace the credentials of the configurations present when it began and of those present when each record is made, so a server removed, or replaced by a restart, while the operation waits is still covered, and so is one connected meanwhile. Every place a credential matches is replaced, places that overlap included, and text that may come from any server is matched against every server's strings at once, so two servers' secrets that overlap are both replaced whole. Over HTTP, which only the modern engine speaks, a 401 or 403's response body is left out of the request error's data; where the HTTP client builds the error's message from the body, as it does for most statuses, the message is redacted like any record. A token an access provider fetches, and credentials the configuration doesn't hold, are outside this. JSON-RPC answers, and the error a host's own connect raises, pass as they are. diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index fe230315f..5ba548d58 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -113,22 +113,26 @@ export function redactUrl(url: string | undefined): string { * - from the url, as written and decoded: credential-like path segments and * query names, credential-shaped or credential-named query values, every * other non-empty parameter as its whole name=value, and a ws:// url's - * username and password, its userinfo as one unit before its @ (with no - * length floor, so a short password goes with its username), and the - * Basic credential a WebSocket client sends for that userinfo; - * - the static token, raw, with its slashes escaped as some JSON encoders - * write them, and percent- or form-encoded with either case of hex; + * username and password, its userinfo as one unit before its @ (the + * username alone when there's no password), with no length floor, so a + * short password goes with its username and a short username alone goes + * too, and the Basic credential a WebSocket client sends for that userinfo; + * - the static token, raw, as JSON encoders write it (JSON.stringify's + * escapes, and with its slashes escaped too, as some encoders write them), + * and percent- or form-encoded with either case of hex; * - a stdio server's environment values whose names say they're - * credentials, raw, with their slashes JSON-escaped, and percent-encoded - * with either case of hex. + * credentials, raw, as JSON encoders write them, and percent-encoded with + * either case of hex. * A string replaced on its own has at least 8 characters, since a shorter * one can't be told from ordinary text, and it is replaced wherever it * occurs, after an escape or inside a longer token too. A query parameter is * also replaced as its whole name=value, only where no letter or digit * adjoins the pair, so v=2 leaves dev=2 alone; a short value goes only that - * way. One longest-first pass never scans its own replacement, and text - * that may come from several servers takes one pass over all their - * configurations' strings (serverCredentialPatterns). A credential the + * way. Every place one of these matches is replaced, overlapping places + * included, and the replacement is never scanned again; text that may come + * from several servers takes one pass over all their configurations' + * strings (serverCredentialPatterns), so strings of different servers that + * overlap are both replaced whole. A credential the * configuration doesn't hold, such as the token an access provider fetches * or another origin's, is outside it, and so are other encodings of the * ones it holds. JSON-RPC answers, and the error a host's own connect @@ -155,9 +159,9 @@ export interface CredentialPattern { /** * The strings {@link redactServerText} replaces for one configuration, as * patterns for {@link credentialRedactor}. Text that may come from several - * servers goes through one redactor over all their patterns, so its one - * longest-first pass takes the longest match among them all: a secret that - * begins another configuration's longer one can't cut that one short. + * servers goes through one redactor over all their patterns, so a secret + * that begins inside another configuration's, or shares its start, can't + * leave any of that one in place. */ export function serverCredentialPatterns(config: Pick): CredentialPattern[] { // Strings replaced wherever they occur (after an escape or inside a longer @@ -174,13 +178,17 @@ export function serverCredentialPatterns(config: Pick value.replace(/%[0-9A-F]{2}/g, (escape) => escape.toLowerCase()); - // A string as written, with its slashes escaped as some JSON encoders - // write them, and percent-encoded in either case of hex (a string - // encodeURIComponent refuses, such as one with an unpaired surrogate, has - // no encoded form). + // A string as written; as JSON encoders write it, with JSON.stringify's + // escapes (a quote, a backslash, a control character) and with its + // slashes escaped too, as some encoders write them; and percent-encoded in + // either case of hex (a string encodeURIComponent refuses, such as one + // with an unpaired surrogate, has no encoded form). const withEncoded = (value: string): void => { - standalone(value); - standalone(value.replace(/\//g, '\\/')); + const json = JSON.stringify(value).slice(1, -1); + for (const written of [value, json]) { + standalone(written); + standalone(written.replace(/\//g, '\\/')); + } let encoded: string; try { encoded = encodeURIComponent(value); } catch { return; /* no percent-encoded form */ } standalone(encoded); @@ -235,17 +243,18 @@ export function serverCredentialPatterns(config: Pick string { if (patterns.length === 0) return asText; + // Longest first, so at each place the longest string that matches there is + // the match. const sources = [...patterns].sort((a, b) => b.value.length - a.value.length).map(({ source }) => source); const pattern = new RegExp([...new Set(sources)].join('|'), 'g'); - return (text) => asText(text).replace(pattern, 'redacted'); + return (text) => { + const input = asText(text); + const spans: Array<[start: number, end: number]> = []; + pattern.lastIndex = 0; + for (let match = pattern.exec(input); match; match = pattern.exec(input)) { + const start = match.index; + const end = start + match[0].length; + const last = spans[spans.length - 1]; + if (last && start < last[1]) last[1] = Math.max(last[1], end); + else spans.push([start, end]); + // The next search starts one place on, not after this match, so a + // match that begins inside this one is found too. + pattern.lastIndex = start + 1; + } + let redacted = ''; + let from = 0; + for (const [start, end] of spans) { + redacted += `${input.slice(from, start)}redacted`; + from = end; + } + return redacted + input.slice(from); + }; } /** Any value as text: a value that can't be printed reads as a placeholder. */ diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index 5c58a79e8..afab1b238 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -399,6 +399,37 @@ test("recorded server text: a ws:// url's userinfo as one unit before its @, and assert.equal(redactServerText('bot:pw is not followed by its @ here', config), 'bot:pw is not followed by its @ here'); assert.equal(redactServerText('robot:pw@h', config), 'robot:pw@h'); assert.equal(redactServerText('dial wss://alice:pw@h/mcp failed', cfg({ url: 'wss://alice:pw@h/mcp' })), 'dial wss://redacted@h/mcp failed'); + // ws:// as wss://. + const plain = cfg({ url: 'ws://bot:pw@h/mcp' }); + assert.equal(redactServerText('dial ws://bot:pw@h/mcp failed', plain), 'dial ws://redacted@h/mcp failed'); + assert.equal(redactServerText('Authorization: Basic Ym90OnB3', plain), 'Authorization: Basic redacted'); + // A username alone, as a token written as the username is: the unit is the + // username, and the Basic credential is that of "name:". + for (const url of ['ws://k3y@h/mcp', 'wss://k3y@h/mcp']) { + const tokenOnly = cfg({ url }); + assert.equal(redactServerText(`dial ${url} failed`, tokenOnly), `dial ${url.replace('k3y', 'redacted')} failed`, url); + assert.equal(redactServerText(`Authorization: Basic ${Buffer.from('k3y:').toString('base64')}`, tokenOnly), 'Authorization: Basic redacted', url); + assert.equal(redactServerText('monk3y@h', tokenOnly), 'monk3y@h', url); + } + // Percent-encoded, with every part too short to replace on its own: the + // unit as written and as decoded, and the Basic credential sent for each. + const encoded = cfg({ url: 'wss://b%40t:p%40ss@h/mcp' }); + assert.equal(redactServerText('dial wss://b%40t:p%40ss@h/mcp failed', encoded), 'dial wss://redacted@h/mcp failed'); + assert.equal(redactServerText('as user b@t:p@ss@h', encoded), 'as user redacted@h'); + for (const userinfo of ['b%40t:p%40ss', 'b@t:p@ss']) { + assert.equal(redactServerText(`Authorization: Basic ${Buffer.from(userinfo).toString('base64')}`, encoded), 'Authorization: Basic redacted', userinfo); + } +}); + +test('recorded server text: a credential as JSON.stringify writes it, its quotes and backslashes escaped', () => { + const password = 'pa"ss\\w0rd/x1'; + const config = cfg({ command: 'srv', token: 'to"ken\\0123', env: { DB_PASSWORD: password } } as Partial); + for (const secret of [password, config.token!]) { + const json = JSON.stringify(secret).slice(1, -1); + for (const form of [secret, json, json.replace(/\//g, '\\/')]) { + assert.equal(redactServerText(`config {"secret":"${form}"} refused`, config), 'config {"secret":"redacted"} refused', form); + } + } }); test("one redactor over several configurations' strings takes the longest secret among them all", () => { @@ -410,6 +441,17 @@ test("one redactor over several configurations' strings takes the longest secret assert.equal(credentialRedactor([])('nothing to replace'), 'nothing to replace'); }); +test("one redactor over several configurations' strings replaces strings that overlap, both whole", () => { + // The first's end is the second's start. + const head = cfg({ id: 'a', token: 'XXXXXXXXYYYY' }); + const tail = cfg({ id: 'b', token: 'YYYYZZZZZZZZ' }); + const redact = credentialRedactor([...serverCredentialPatterns(head), ...serverCredentialPatterns(tail)]); + assert.equal(redact('auth failed for XXXXXXXXYYYYZZZZZZZZ.'), 'auth failed for redacted.'); + assert.equal(redact('XXXXXXXXYYYY and YYYYZZZZZZZZ'), 'redacted and redacted'); + // Strings next to each other, not overlapping, are each replaced, as before. + assert.equal(redact('XXXXXXXXYYYYYYYYZZZZZZZZ'), 'redactedredacted'); +}); + test('recorded server text uses longest-first, one-pass replacements and is idempotent', () => { const config = cfg({ url: 'https://h/mcp?secret=token0123456789-tail&redacted=eu', token: 'token0123456789' }); const original = 'secret=token0123456789-tail token0123456789 redacted=eu queue full'; From 96c85268a9ea1fb029962e7d52890563dd150030 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 05:32:13 -0400 Subject: [PATCH 53/94] fix(mcp): a disconnect or stop() during a restart ends it, and says the server is down A disconnect of an id a restart holds, its own or the one it moves to, already won over the restart, as a disconnect wins over a connect in flight: the restart's connect was refused or displaced after its teardown. What the restart then said was wrong, "connect it once that has finished", and nothing documented or tested it. Now: - Any failure after the restart's teardown says so first: 'MCP server "a" is down: it was disconnected for its restart, and the restart's connect failed: ' and the connect's own reason. That covers a disconnect or a stop() that began during the restart, and the new server's own failure. - The restart's own connect, meeting a disconnect of the id in flight, names it: that disconnect began during the restart (the restart's own has finished by then), and it ends the restart. - A restart's pre-check on the id it moves to words its refusal for the restart ('MCP server "a" was not restarted: ...; restart "a" once that has finished'), not for a connect. restartMcplServer's doc and the changelog now say what the restart holds and what it doesn't: a module added meanwhile can take only the tool prefix, never an id; the id and prefix it moves the server from are free once its disconnect is done; a disconnect or a stop() that begins during it ends it; and a restart whose connect fails leaves the server down, configured under the new configuration and holding its tool prefix, as any failed connect does (listed as not connected, restartable, removed by a disconnect). addModule's comment no longer names mcplPrefixCollision as create()'s check. Tests: a restart moving to an id, then to a tool prefix, another restart holds is refused before its teardown; a restart whose connect fails leaves the server listed as not connected, its prefix refused to a module, and its holds released (the id and prefix then take a working configuration); and a disconnect that begins in the restart's teardown, its gap or its connect ends it, leaving the server disconnected and the id free. --- .../mcp-standard-tool-results.changed.md | 14 +++- src/framework.ts | 53 ++++++++---- test/mcpl-runtime-lifecycle.test.ts | 80 +++++++++++++++++++ 3 files changed, 130 insertions(+), 17 deletions(-) diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index f17265a89..e8915081c 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -112,9 +112,17 @@ disconnect of the server in flight. Before, a restart disconnected first, so a refused connect left the server gone; now the server keeps running. From those checks through its connect, the restart holds the id and the - tool prefix it moves the server to, so a connect, a restart or a module - added meanwhile is refused them, naming the restart, rather than taking - them while the old server is torn down. `addModule` also refuses a name a + tool prefix it moves the server to: a connect or a restart started + meanwhile is refused them, and a module added meanwhile is refused that + prefix, each naming the restart, rather than taking them while the old + server is torn down. The id and prefix it moves the server from are free + once its disconnect is done. A disconnect of either id, or a `stop()`, + that begins during the restart ends it, as a disconnect ends a connect in + flight, and the restart's error says the server is down. A restart whose + connect fails leaves the server down, configured under the new + configuration and holding its tool prefix, as any failed connect does: + it's listed as not connected, `restartMcplServer(id)` tries it again, and + `disconnectMcplServer(id)` removes it. `addModule` also refuses a name a server's tool prefix holds, as `create()` does at startup. A disconnect releases the tool-prefix claims its own server holds. Treat a submitted configuration as immutable while the server uses it: change settings with diff --git a/src/framework.ts b/src/framework.ts index 4e23c3659..adc25250d 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -3545,9 +3545,8 @@ export class AgentFramework { */ async addModule(module: Module): Promise { // A server's tool prefix and a module's name share one namespace, as - // create() enforces at startup (mcplPrefixCollision): a module isn't - // added under a prefix a server holds, or one a restart is moving its - // server to. + // create() checks at startup: a module isn't added under a prefix a + // server holds, or one a restart is moving its server to. const server = this.mcplPrefixMap.get(module.name); if (server !== undefined) { throw new Error(`Module "${module.name}" collides with MCPL server "${server}"'s toolPrefix "${module.name}"`); @@ -16666,9 +16665,19 @@ export class AgentFramework { * unlike `reconnect: true`, which only retries after transport-level * failures. Throws if the server has no stored config and none is given. * What its connect would refuse is refused before the running server is - * torn down, and until the connect, a connect, a restart or a module added - * meanwhile is refused the id and tool prefix the restart moves the server - * to. + * torn down. Until the connect, a connect or a restart started meanwhile + * is refused the id and the tool prefix the restart moves the server to, + * and a module added meanwhile is refused that tool prefix. The id and + * prefix it moves the server from are free once its disconnect is done, as + * after any disconnect. + * + * A disconnect of either id, or a `stop()`, that begins during the restart + * ends it, as a disconnect ends a connect in flight: the restart rejects, + * saying the server is down, and its holds are released. A restart whose + * connect fails leaves the server down, configured under the new + * configuration and holding its tool prefix, as any failed connect does: + * `listMcplServers()` lists it as not connected, `restartMcplServer(id)` + * tries it again, and `disconnectMcplServer(id)` removes it. * * Treat a submitted configuration and its nested values as immutable for * as long as the server uses them. Apply changed settings with @@ -16687,7 +16696,8 @@ export class AgentFramework { // checks through its connect, the restart holds the id and the tool // prefix it moves the server to (mcpRestartClaims), so no connect, // restart or module added meanwhile takes them while the old server is - // torn down. + // torn down. A stop() or a disconnect that begins after these checks + // isn't held off: it ends the restart, as it would end a connect. if (this.mcpServerAdmissionClosed) { throw new Error(`MCP server "${id}" was not restarted: the framework is stopping or stopped`); } @@ -16713,7 +16723,7 @@ export class AgentFramework { : `MCP server "${id}" was not restarted: the restart of "${claimedBy.restarting}" holds its id; restart it once that has finished`); } if (config.id !== id) { - const taken = this.mcpServerIdTaken(config.id); + const taken = this.mcpServerIdTaken(config.id, undefined, id); if (taken) throw new Error(taken); } const collision = this.mcplPrefixCollision(config, id); @@ -16732,7 +16742,15 @@ export class AgentFramework { this.restartClaims().prefixes.set(prefix, claim); try { await this.disconnectMcplServer(id); - await this.connectMcplServerClaimed(config, claim); + try { + await this.connectMcplServerClaimed(config, claim); + } catch (error) { + // The old server is gone, whatever ended the connect: a disconnect or + // a stop() that began during the restart, or the new server's own + // failure. The caller learns that first, then the connect's reason. + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`MCP server "${id}" is down: it was disconnected for its restart, and the restart's connect failed: ${reason}`, { cause: error }); + } } finally { if (this.restartClaims().ids.get(config.id) === claim) this.restartClaims().ids.delete(config.id); if (this.restartClaims().prefixes.get(prefix) === claim) this.restartClaims().prefixes.delete(prefix); @@ -16763,20 +16781,27 @@ export class AgentFramework { * disconnect in flight holds it, and so does a registered connection, * including one kept because its launch couldn't be confirmed exited, and * a restart moving its server to the id (mcpRestartClaims), except to - * that restart's own connect (`claim`). */ - private mcpServerIdTaken(id: string, claim?: McpRestartClaim): string | null { + * that restart's own connect (`claim`). That connect is still refused + * while a disconnect of the id is in flight: the restart's own disconnect + * has finished by then, so that one began during the restart, and it ends + * the restart. `restarting` words the refusal for a restart of that + * server checking the id it would move to. */ + private mcpServerIdTaken(id: string, claim?: McpRestartClaim, restarting?: string): string | null { + const refused = restarting === undefined ? '' : `MCP server "${restarting}" was not restarted: `; + const retry = restarting === undefined ? 'connect it' : `restart "${restarting}"`; const claimedBy = this.restartClaims().ids.get(id); if (claimedBy && claimedBy !== claim) { - return `MCP server "${id}" is held by the restart of "${claimedBy.restarting}"; connect it once that has finished`; + return `${refused}MCP server "${id}" is held by the restart of "${claimedBy.restarting}"; ${retry} once that has finished`; } const op = this.mcpServerOps?.get(id); if (op?.kind === 'disconnect') { - return `MCP server "${id}" is still being disconnected; connect it once that has finished`; + if (claim) return `MCP server "${id}" is being disconnected, which ends the restart of "${claim.restarting}"`; + return `${refused}MCP server "${id}" is still being disconnected; ${retry} once that has finished`; } const registered = this.modernServers().get(id) ?? this.mcplServerRegistry?.getServer(id); if (op || registered) { const idle = !op && registered && !registered.isConnected && !registered.willReconnect; - return `MCPL server "${id}" is already registered${idle ? ' (not connected and not reconnecting: disconnect it first)' : ''}`; + return `${refused}MCPL server "${id}" is already registered${idle ? ' (not connected and not reconnecting: disconnect it first)' : ''}`; } return null; } diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index 28146a928..2be6ce118 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -344,6 +344,86 @@ test('a restart that fails releases the id and the tool prefix it held', async ( }); }); +test('a restart moving to an id or a tool prefix another restart holds is refused before it tears anything down', async (t) => { + await withFramework(true, async (framework) => { + await framework.connectMcplServer(serverConfig('second') as never); + const disconnect = framework.disconnectMcplServer.bind(framework); + const refusals: string[] = []; + t.mock.method(framework, 'disconnectMcplServer', async (id: string) => { + await disconnect(id); + if (id !== 'first') return; + // Between first's disconnect and its connect, only its claims hold "moved" and "xx". + for (const attempt of [ + () => framework.restartMcplServer('second', serverConfig('moved', { toolPrefix: 'other' }) as never), + () => framework.restartMcplServer('second', serverConfig('second', { toolPrefix: 'xx' }) as never), + ]) { + await attempt().then(() => refusals.push('restarted'), (error: unknown) => refusals.push((error as Error).message)); + } + }); + await framework.restartMcplServer('first', serverConfig('moved', { toolPrefix: 'xx' }) as never); + assert.deepEqual(refusals, [ + 'MCP server "second" was not restarted: MCP server "moved" is held by the restart of "first"; restart "second" once that has finished', + 'MCPL server "second" toolPrefix "xx" is held by the restart of "first"', + ]); + assert.deepEqual(framework.listMcplServers().map(s => [s.id, s.connected]).sort(), [['moved', true], ['second', true]]); + const tools = framework.getAllTools().map(tool => tool.name); + assert.ok(tools.includes('xx--echo') && tools.includes('mcpl--second--echo'), 'each serves its own prefix'); + }); +}); + +test('a restart whose connect fails leaves the server down, configured and not connected, and releases its holds', async () => { + await withFramework(true, async (framework) => { + const exits = { id: 'renamed', command: process.execPath, args: ['-e', 'process.exit(3)'], toolPrefix: 'xx' }; + await assert.rejects( + framework.restartMcplServer('first', exits as never), + /^MCP server "first" is down: it was disconnected for its restart, and the restart's connect failed: /, + ); + // As any failed connect leaves its server: listed as not connected, holding its prefix. + assert.deepEqual(framework.listMcplServers().map(s => [s.id, s.connected]), [['renamed', false]]); + await assert.rejects( + framework.addModule(new NamedModule('xx')), + /Module "xx" collides with MCPL server "renamed"'s toolPrefix "xx"/, + ); + // The restart's holds are released, so the id and the prefix take a working configuration. + await framework.restartMcplServer('renamed', serverConfig('renamed', { toolPrefix: 'xx' }) as never); + assert.deepEqual(framework.listMcplServers().map(s => [s.id, s.connected]), [['renamed', true]]); + assert.ok(framework.getAllTools().some(tool => tool.name === 'xx--echo')); + }); +}); + +for (const when of ['teardown', 'gap', 'connect'] as const) { + test(`a disconnect that begins during a restart's ${when} ends it: the server stays disconnected and the holds are released`, async (t) => { + await withFramework(true, async (framework) => { + const disconnect = framework.disconnectMcplServer.bind(framework); + let host: Promise | undefined; + const hostDisconnect = () => { + host = disconnect('first').then(() => 'disconnected', (error: unknown) => error); + }; + t.mock.method(framework, 'disconnectMcplServer', async (id: string) => { + // The restart's own disconnect. + const own = disconnect(id); + if (when === 'teardown') hostDisconnect(); + await own; + if (when === 'gap') hostDisconnect(); + // The restart's connect takes the id within this turn, before an immediate runs. + if (when === 'connect') setImmediate(hostDisconnect); + }); + const refused = await framework.restartMcplServer('first').then(() => null, (error: unknown) => error as Error); + assert.match(refused?.message ?? 'restarted', /^MCP server "first" is down: it was disconnected for its restart, and the restart's connect failed: /); + if (when !== 'connect') { + // Its connect is refused at once, naming the disconnect. + assert.match(refused!.message, /: MCP server "first" is being disconnected, which ends the restart of "first"$/); + } + assert.equal(await host, 'disconnected', 'the disconnect completed'); + assert.deepEqual(framework.listMcplServers(), [], 'nothing is left of the server'); + t.mock.restoreAll(); + // Nothing holds the id or its prefix now. + await framework.connectMcplServer(serverConfig('first') as never); + assert.deepEqual(framework.listMcplServers().map(s => [s.id, s.connected]), [['first', true]]); + }); + }); +} + test('connectMcplServer rejects a duplicate id and a colliding prefix', async () => { await withFramework(true, async (framework) => { await assert.rejects( From 8a2d4f7b5a828d79c0dee8342c25a0c7129ae148 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 05:36:14 -0400 Subject: [PATCH 54/94] test(mcp): a stop() that begins in a restart's gap ends it, and says the server is down The restart's doc says a stop() that begins during a restart ends it, as a disconnect does. The disconnect is tested in each phase; this pins the stop(): begun between the restart's disconnect and its connect, it refuses the connect, and the restart rejects saying the server is down, with the connect's refusal after it. --- test/mcpl-runtime-lifecycle.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index 2be6ce118..7f5ea5041 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -424,6 +424,22 @@ for (const when of ['teardown', 'gap', 'connect'] as const) { }); } +test("a stop() that begins in a restart's gap ends it, and the restart says the server is down", async (t) => { + await withFramework(true, async (framework) => { + const disconnect = framework.disconnectMcplServer.bind(framework); + let stopping: Promise | undefined; + t.mock.method(framework, 'disconnectMcplServer', async (id: string) => { + await disconnect(id); + stopping = framework.stop(); + }); + await assert.rejects( + framework.restartMcplServer('first'), + /^MCP server "first" is down: it was disconnected for its restart, and the restart's connect failed: MCP server "first" was not connected: the framework is stopping or stopped$/, + ); + await stopping; + }); +}); + test('connectMcplServer rejects a duplicate id and a colliding prefix', async () => { await withFramework(true, async (framework) => { await assert.rejects( From 3168cd47c162f58b65061063fa67024fa692481c Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 06:05:17 -0400 Subject: [PATCH 55/94] test(mcp): match the restart's refusal as assert.rejects reads it, with its Error: prefix assert.rejects tests a regular expression against the error's string form, which starts with "Error: ". The two new tests anchored theirs at the message, so they failed although the restart said what they expect. --- test/mcpl-runtime-lifecycle.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index 7f5ea5041..678dbb021 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -376,7 +376,7 @@ test('a restart whose connect fails leaves the server down, configured and not c const exits = { id: 'renamed', command: process.execPath, args: ['-e', 'process.exit(3)'], toolPrefix: 'xx' }; await assert.rejects( framework.restartMcplServer('first', exits as never), - /^MCP server "first" is down: it was disconnected for its restart, and the restart's connect failed: /, + /^Error: MCP server "first" is down: it was disconnected for its restart, and the restart's connect failed: /, ); // As any failed connect leaves its server: listed as not connected, holding its prefix. assert.deepEqual(framework.listMcplServers().map(s => [s.id, s.connected]), [['renamed', false]]); @@ -434,7 +434,7 @@ test("a stop() that begins in a restart's gap ends it, and the restart says the }); await assert.rejects( framework.restartMcplServer('first'), - /^MCP server "first" is down: it was disconnected for its restart, and the restart's connect failed: MCP server "first" was not connected: the framework is stopping or stopped$/, + /^Error: MCP server "first" is down: it was disconnected for its restart, and the restart's connect failed: MCP server "first" was not connected: the framework is stopping or stopped$/, ); await stopping; }); From 406164eb44350d93bf3c75d8592ac79eb6deb087 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 06:10:19 -0400 Subject: [PATCH 56/94] fix(mcp): say which disconnect ends a restart; the test server answers what it doesn't know The restart's doc and the changelog said a disconnect of either id ends a restart. Only a disconnect of the id the restart moves the server to does, which is its own id for a restart under the same id: a disconnect of the id a move leaves only removes what that id still has, and the restart completes. They also named restartMcplServer(id) and disconnectMcplServer(id) for a server whose restart failed, where id read as the restart's parameter; the server is configured under the id it was moved to, so they now say a restart or a disconnect under that id. The lifecycle tests' stdio server never answered a request it didn't know, so each connect waited out the host's 15 s for the initial feature-set update, and a restart test took 30 s. It now answers such a request with method-not-found. An error answer and no answer reach the same handling, so what the tests check is unchanged, and the file runs in seconds. Found and measured by Beth-2389. --- .../mcp-standard-tool-results.changed.md | 15 +++++++------- src/framework.ts | 20 ++++++++++--------- test/mcpl-runtime-lifecycle.test.ts | 3 +++ 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index e8915081c..b3270f64b 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -116,13 +116,14 @@ meanwhile is refused them, and a module added meanwhile is refused that prefix, each naming the restart, rather than taking them while the old server is torn down. The id and prefix it moves the server from are free - once its disconnect is done. A disconnect of either id, or a `stop()`, - that begins during the restart ends it, as a disconnect ends a connect in - flight, and the restart's error says the server is down. A restart whose - connect fails leaves the server down, configured under the new - configuration and holding its tool prefix, as any failed connect does: - it's listed as not connected, `restartMcplServer(id)` tries it again, and - `disconnectMcplServer(id)` removes it. `addModule` also refuses a name a + once its disconnect is done. A disconnect of the id it moves the server + to (its own, for a restart under the same id), or a `stop()`, that begins + during the restart ends it, as a disconnect ends a connect in flight, and + the restart's error says the server is down. A restart whose connect + fails leaves the server down, configured under the new configuration and + its id, and holding its tool prefix, as any failed connect does: it's + listed as not connected, and a restart or a disconnect under that id + tries it again or removes it. `addModule` also refuses a name a server's tool prefix holds, as `create()` does at startup. A disconnect releases the tool-prefix claims its own server holds. Treat a submitted configuration as immutable while the server uses it: change settings with diff --git a/src/framework.ts b/src/framework.ts index adc25250d..1f95775f9 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -16671,13 +16671,14 @@ export class AgentFramework { * prefix it moves the server from are free once its disconnect is done, as * after any disconnect. * - * A disconnect of either id, or a `stop()`, that begins during the restart - * ends it, as a disconnect ends a connect in flight: the restart rejects, - * saying the server is down, and its holds are released. A restart whose - * connect fails leaves the server down, configured under the new - * configuration and holding its tool prefix, as any failed connect does: - * `listMcplServers()` lists it as not connected, `restartMcplServer(id)` - * tries it again, and `disconnectMcplServer(id)` removes it. + * A disconnect of the id the restart moves the server to (its own id, for + * a restart under the same id), or a `stop()`, that begins during the + * restart ends it, as a disconnect ends a connect in flight: the restart + * rejects, saying the server is down, and its holds are released. A + * restart whose connect fails leaves the server down, configured under the + * new configuration and its id, and holding its tool prefix, as any failed + * connect does: `listMcplServers()` lists it as not connected, and a + * restart or a disconnect under that id tries it again or removes it. * * Treat a submitted configuration and its nested values as immutable for * as long as the server uses them. Apply changed settings with @@ -16696,8 +16697,9 @@ export class AgentFramework { // checks through its connect, the restart holds the id and the tool // prefix it moves the server to (mcpRestartClaims), so no connect, // restart or module added meanwhile takes them while the old server is - // torn down. A stop() or a disconnect that begins after these checks - // isn't held off: it ends the restart, as it would end a connect. + // torn down. A stop(), or a disconnect of the id it moves the server to, + // that begins after these checks isn't held off: it ends the restart, as + // it would end a connect. if (this.mcpServerAdmissionClosed) { throw new Error(`MCP server "${id}" was not restarted: the framework is stopping or stopped`); } diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index 678dbb021..3944e5077 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -42,6 +42,9 @@ process.stdin.on('data', (c) => { if (m.method === 'initialize') reply({ protocolVersion: '2024-11-05', capabilities: { experimental: { mcpl: { version: '0.4', pushEvents: true } } } }); else if (m.method === 'tools/list') reply({ tools: [{ name: 'echo', description: 'e', inputSchema: { type: 'object' } }] }); else if (m.method === 'tools/call') reply({ content: [{ type: 'text', text: String((m.params && m.params.arguments && m.params.arguments.text) || '') }] }); + // Any other request is answered as unknown, so the host's wait for it ends + // at once rather than at its timeout. + else if (m.id !== undefined) process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: m.id, error: { code: -32601, message: 'method not found' } }) + '\\n'); } }); `; From ae1a568dfae0fd61f95d490aae8f0858d73714f7 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 06:42:00 -0400 Subject: [PATCH 57/94] fix(mcp): stop() and a disconnect end a connect in flight at once, in either engine With #325, stop() waits for operator operations before its teardown, and a connect is one. A connect still in its handshake held that wait until its handshake's deadline, and longer when nothing bounds it: a modern one up to its request deadline, a legacy stdio one for the initialize timeout (30 s), a WebSocket one for the open timeout too, and one whose access provider never settles for good, since stop() has no budget by default. - stop() ends each connect in flight first (endConnectsInFlight): a modern connect's connection, registered before it starts, is closed, and a legacy connect's handshake is aborted. Each fails at once, its launch reaped as for any failed handshake, and its operation ends. - Each legacy connect gets its own AbortSignal (legacyConnectAborts), which McplServerRegistry.addServer(), McplServerConnection.connect(), connectChecked() and connectWithReconnect() pass to the handshake, as a reconnect's already was. An aborted connectWithReconnect() makes no retry stub. - A disconnect that takes an id over aborts the legacy connect it displaces too, rather than waiting out its handshake, so a disconnect ends a connect in flight at once in both engines. - A WebSocket dial races its access provider's wait against the signal: an aborted dial stops waiting, doesn't fall back to the static token, and ignores the provider's late answer or failure. The changelog says it, and #325's fragment now says a connect in flight is ended rather than waited for, and that a restart in flight ends too. Tests: stop() and a disconnect each end a legacy stdio connect stuck in its initialize within half a second; stop() ends a WebSocket dial still waiting for an access provider that never settles; and the modern test of a connect stuck on discover now asserts that stop() returns promptly. Without each abort, its test fails: the legacy ones wait 30 s, and the provider one never ends. --- .../mcp-standard-tool-results.changed.md | 7 ++- ...top-waits-for-operator-operations.fixed.md | 10 ++-- src/framework.ts | 47 ++++++++++++------- src/mcpl/server-connection.ts | 19 +++++--- src/mcpl/server-registry.ts | 5 +- src/mcpl/transport.ts | 18 ++++++- test/mcp-modern-framework.test.ts | 3 ++ test/mcpl-runtime-lifecycle.test.ts | 41 ++++++++++++++++ 8 files changed, 120 insertions(+), 30 deletions(-) diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index b3270f64b..1474d469e 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -87,7 +87,12 @@ (`reconnect-failed`, `permanent: true`). Reconnect attempts run one at a time, and never start before the previous child has exited. - `close()` during a reconnect handshake ends that launch before it - resolves, a WebSocket dial still opening included. + resolves, a WebSocket dial still opening included. In the framework, + `stop()` and a disconnect of the id end a first connect still in its + handshake the same way, and a WebSocket dial still waiting for its + access provider: `connect()`, `connectWithReconnect()` and + `McplServerRegistry.addServer()` take an optional `AbortSignal`, and an + aborted `connectWithReconnect()` makes no retry stub. - `McplServerRegistry` holds an id until its child is confirmed exited: `removeServer` frees it only once the close succeeds, a second `addServer` for an id still connecting is refused, and `closeAll()` diff --git a/changelog.d/stop-waits-for-operator-operations.fixed.md b/changelog.d/stop-waits-for-operator-operations.fixed.md index a6e546767..611e54765 100644 --- a/changelog.d/stop-waits-for-operator-operations.fixed.md +++ b/changelog.d/stop-waits-for-operator-operations.fixed.md @@ -2,9 +2,13 @@ refuses new ones.** A quiesce, a resume, a `host/command`, a store surgery, a branch switch, an undo or redo's config materialization, a puppet tool call, an awareness sync, or an MCPL - connect, disconnect or restart admitted before `stop()` began now - finishes before the event gate, the modules, the MCPL connections and the - store go, unless a budget runs out first (see the operators' note below). + disconnect admitted before `stop()` began now finishes before the event + gate, the modules, the MCPL connections and the store go, unless a budget + runs out first (see the operators' note below). An MCPL connect still in + flight is ended instead, in either engine: `stop()` closes it at once, + its launch reaped, rather than waiting out its handshake. A restart in + flight ends too, its connect refused once `stop()` has begun, so the + server it was restarting is down. Before, such an operation kept running through the teardown. A resume waiting on its maintenance pass or previews could go on to leave quiesce while the host stopped, so the host booted unquiesced; a puppet call diff --git a/src/framework.ts b/src/framework.ts index 27f4ecaf5..0bab16c82 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2032,6 +2032,10 @@ export class AgentFramework { /** Connects and disconnects in flight, by server id (holdMcpServerId); * created on first use, like modernMcpConnections. */ private mcpServerOps: Map }> | undefined; + /** Each legacy connect in flight's own abort (connectHeldMcplServer): + * stop() and a disconnect of its id end it at once, its launch reaped, + * rather than waiting out its handshake. */ + private legacyConnectAborts = new Map(); /** The ids and tool prefixes restarts hold, each with the claim of the * restart holding it, from the restart's checks through its connect * (restartMcplServer): no other connect or restart takes them while the @@ -2691,16 +2695,18 @@ export class AgentFramework { return !!this.stopPromise || !!this.queue?.isClosed; } - /** stop()'s first step for a modern connect still in flight, whose - * connection is registered before it starts (connectHeldMcplServer): - * close it, so the connect fails at once and finds the stop begun. A - * legacy connect still in its handshake can't be ended early; it ends with - * its handshake and then closes itself. The close's outcome is the - * teardown's to report (closeModernMcpConnections). */ - private closeModernConnectsInFlight(): void { + /** stop()'s first step for each connect still in flight, in either engine: + * end it, so it fails at once and its operation ends, its launch reaped, + * rather than holding stop()'s wait until its handshake's deadline. A + * modern connect's connection is registered before it starts and is + * closed; a legacy one's handshake, and the access provider a WebSocket + * dial waits for, are aborted (legacyConnectAborts). A modern close's + * outcome is the teardown's to report (closeModernMcpConnections). */ + private endConnectsInFlight(): void { for (const [id, op] of this.mcpServerOps ?? []) { if (op.kind !== 'connect') continue; this.modernServers().get(id)?.close().catch(() => { /* reported by the teardown's close */ }); + this.legacyConnectAborts.get(id)?.abort(); } } @@ -2733,10 +2739,11 @@ export class AgentFramework { private async stopOwned(teardownTimeoutMs: number | undefined): Promise { // No MCP server is admitted from here on (stopHasBegun): none is launched // or dialed while the rest are being closed, so none outlives the - // framework. A modern connect still in flight is closed now, so it fails - // at once and its operator operation ends, rather than holding the wait - // for operator operations below until its handshake's deadline. - this.closeModernConnectsInFlight(); + // framework. A connect still in flight is ended now, in either engine, + // so it fails at once and its operator operation ends, rather than + // holding the wait for operator operations below until its handshake's + // deadline. + this.endConnectsInFlight(); this.pushCoalescer?.suspend(); this.flushCoalescingSnapshot(); this.running = false; @@ -16628,13 +16635,17 @@ export class AgentFramework { }); } let connection: McplServerConnection; + const abort = new AbortController(); + this.legacyConnectAborts.set(config.id, abort); try { - connection = await this.mcplServerRegistry.addServer(connectionConfig, this.mcplHostCapabilities); + connection = await this.mcplServerRegistry.addServer(connectionConfig, this.mcplHostCapabilities, abort.signal); } catch (error) { // The registry keeps a failed launch that may still be running, as the // closed connection that owns it. if (error instanceof McplUnreapedLaunchError) throw AgentFramework.unreapedConnectFailure(config.id, error); throw error; + } finally { + if (this.legacyConnectAborts.get(config.id) === abort) this.legacyConnectAborts.delete(config.id); } // stop() began while the handshake ran: its teardown has already // collected the registry, so this connection would outlive it. Or a @@ -17182,11 +17193,13 @@ export class AgentFramework { const hold = this.holdMcpServerId(id, 'disconnect'); try { // Close what the id has now, so a connect in flight ends rather than - // being waited out: a modern connect is ended by its close, and a - // legacy one already registered (setting up after its handshake) - // fails its requests. A legacy connect still handshaking ends when its - // handshake does, finding itself displaced. The registry's removal is - // the legacy close: a failed one stays registered. + // being waited out: a modern connect is ended by its close, a legacy + // one still in its handshake (or a WebSocket dial waiting for its + // access provider) by its abort, its launch reaped, and a legacy one + // already registered (setting up after its handshake) fails its + // requests and finds itself displaced. The registry's removal is the + // legacy close: a failed one stays registered. + this.legacyConnectAborts.get(id)?.abort(); const modern = this.modernServers().get(id); const closing = [modern?.close(), registry.removeServer(id)]; for (const pending of closing) pending?.catch(() => { /* its verdict is awaited below */ }); diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 80efdfe3b..28b379d0a 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -662,20 +662,24 @@ export class McplServerConnection extends EventEmitter { static async connect( config: McplServerConfig, hostCapabilities: McplHostCapabilities, + signal?: AbortSignal, ): Promise { McplServerConnection.checkConfig(config); - return McplServerConnection.connectChecked(config, hostCapabilities); + return McplServerConnection.connectChecked(config, hostCapabilities, signal); } /** {@link connect}, for a configuration its caller has already checked - * (and warned about) once. */ + * (and warned about) once. `signal` ends the connect at once, its launch + * reaped as for any failed handshake: its caller's stop() or a + * disconnect of its id. */ private static async connectChecked( config: McplServerConfig, hostCapabilities: McplHostCapabilities, + signal?: AbortSignal, ): Promise { let established: Awaited>; try { - established = await McplServerConnection.handshake(config, hostCapabilities); + established = await McplServerConnection.handshake(config, hostCapabilities, signal); } catch (error) { if (!(error instanceof HandshakeCleanupError)) throw error; // The launch may still be running. A connection owns it, closed and @@ -880,15 +884,18 @@ export class McplServerConnection extends EventEmitter { static async connectWithReconnect( config: McplServerConfig, hostCapabilities: McplHostCapabilities, + signal?: AbortSignal, ): Promise { // Outside the retry path below: a configuration error is never retried. McplServerConnection.checkConfig(config); try { - return await McplServerConnection.connectChecked(config, hostCapabilities); + return await McplServerConnection.connectChecked(config, hostCapabilities, signal); } catch (error) { // No common protocol revision is a configuration fact, not an outage: - // a retry stub would restart the server forever for nothing. - if (!config.reconnect || error instanceof McplProtocolVersionError) { + // a retry stub would restart the server forever for nothing. A connect + // its caller ended (stop(), or a disconnect of its id) isn't retried + // either. + if (!config.reconnect || error instanceof McplProtocolVersionError || signal?.aborted) { throw error; } diff --git a/src/mcpl/server-registry.ts b/src/mcpl/server-registry.ts index 95fc6b11f..350ff9759 100644 --- a/src/mcpl/server-registry.ts +++ b/src/mcpl/server-registry.ts @@ -44,6 +44,7 @@ export class McplServerRegistry { async addServer( config: McplServerConfig, hostCapabilities: McplHostCapabilities, + signal?: AbortSignal, ): Promise { if (this.servers.has(config.id) || this.adding.has(config.id)) { throw new Error(`MCPL server "${config.id}" is already registered`); @@ -53,8 +54,8 @@ export class McplServerRegistry { let connection: McplServerConnection; try { connection = config.reconnect - ? await McplServerConnection.connectWithReconnect(config, hostCapabilities) - : await McplServerConnection.connect(config, hostCapabilities); + ? await McplServerConnection.connectWithReconnect(config, hostCapabilities, signal) + : await McplServerConnection.connect(config, hostCapabilities, signal); } catch (error) { if (error instanceof McplUnreapedLaunchError) this.register(config, error.connection); throw error; diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index 18375a2c9..403aa483a 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -317,10 +317,18 @@ export class WebSocketTransport extends McplTransport { // connect AND reconnect, so rotation happens at the transport layer and // nothing above it ever holds a credential. let token: string | null = config.token ?? null; + const abandoned = (): Error => + new Error(`MCPL server "${config.id}" WebSocket connect abandoned: the connection was closed (${redactUrl(config.url)})`); if (config.accessProvider) { + if (signal?.aborted) throw abandoned(); try { - token = (await config.accessProvider()) ?? token; + // A dial whose connection is closed meanwhile stops waiting for the + // provider; whatever the provider does later is ignored. + const provided = Promise.resolve(config.accessProvider()); + provided.catch(() => { /* a late failure after the dial was abandoned */ }); + token = (signal ? await Promise.race([provided, rejectOnAbort(signal)]) : await provided) ?? token; } catch (err) { + if (signal?.aborted) throw abandoned(); // Fall back to the static token (if any) rather than failing the dial // outright — the server will refuse a stale credential loudly anyway. // The provider's own text may quote the credential, so it isn't logged. @@ -454,6 +462,14 @@ export function isWebSocketTransport(config: McplServerConfig): boolean { * The returned transport is connected at the byte level but has NOT * performed the MCPL handshake. */ +/** A promise that rejects once `signal` aborts, and never settles otherwise. */ +function rejectOnAbort(signal: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + if (signal.aborted) reject(signal.reason); + else signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); +} + export async function openTransport(config: McplServerConfig, signal?: AbortSignal): Promise { const binding = resolveServerBinding(config); if (binding.family !== 'legacy') { diff --git a/test/mcp-modern-framework.test.ts b/test/mcp-modern-framework.test.ts index dea9c9310..bcd07f692 100644 --- a/test/mcp-modern-framework.test.ts +++ b/test/mcp-modern-framework.test.ts @@ -487,7 +487,10 @@ test('the framework owns a modern connect in flight: stop() ends it, and a secon await waitFor('the launch waiting on discover', () => lines(log).some((l) => l.includes('server/discover'))); await assert.rejects(framework.connectMcplServer(config), /already registered/); const pid = (JSON.parse(lines(log)[0]!) as { pid: number }).pid; + // stop() ends the connect in flight rather than waiting for its handshake. + const stopping = Date.now(); await framework.stop(); + assert.ok(Date.now() - stopping < 10_000, `stop() took ${Date.now() - stopping} ms, as if it waited out the connect`); const alive = (p: number) => { try { process.kill(p, 0); return true; } catch { return false; } }; assert.equal(alive(pid), false, 'the launch in flight was reaped by stop()'); await pending.catch(() => {}); diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index f6a93a08f..8026d9f51 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -452,6 +452,47 @@ test("a stop() that begins in a restart's gap ends it, and the restart says the }); }); +/** A stdio server that reads its input and never answers, so a connect waits in its handshake. */ +const SILENT = { id: 'silent', command: process.execPath, args: ['-e', 'process.stdin.resume()'] }; + +for (const ending of ['stop()', 'a disconnect'] as const) { + test(`${ending} ends a legacy connect stuck in its handshake at once`, async () => { + await withFramework(true, async (framework) => { + const connecting = framework.connectMcplServer(SILENT as never).then(() => null, (error: unknown) => error as Error); + // The child is up and its initialize sent. + await new Promise((resolve) => setTimeout(resolve, 300)); + const started = Date.now(); + if (ending === 'stop()') await framework.stop(); + else await framework.disconnectMcplServer('silent'); + const took = Date.now() - started; + assert.ok(took < 10_000, `${ending} took ${took} ms, as if it waited out the handshake`); + const refused = await connecting; + assert.match(refused?.message ?? 'connected', /MCPL server "silent" was closed while connecting/); + if (ending === 'a disconnect') assert.ok(!framework.listMcplServers().some(s => s.id === 'silent'), 'nothing is left of it'); + }); + }); +} + +test("stop() ends a WebSocket dial still waiting for its access provider", async () => { + await withFramework(true, async (framework) => { + let asked = false; + const waiting = { + id: 'dial', + url: 'ws://127.0.0.1:9/mcp', + // A provider that never settles. + accessProvider: () => { asked = true; return new Promise(() => {}); }, + }; + const connecting = framework.connectMcplServer(waiting as never).then(() => null, (error: unknown) => error as Error); + for (let i = 0; i < 50 && !asked; i++) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.ok(asked, 'the dial is waiting for its provider'); + const started = Date.now(); + await framework.stop(); + assert.ok(Date.now() - started < 10_000, 'stop() did not wait for the provider'); + const refused = await connecting; + assert.match(refused?.message ?? 'connected', /MCPL server "dial" was closed while connecting/); + }); +}); + test('connectMcplServer rejects a duplicate id and a colliding prefix', async () => { await withFramework(true, async (framework) => { await assert.rejects( From 5a34a2f13fa0065ba0eceb489d97a98a5d7e2019 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 06:51:38 -0400 Subject: [PATCH 58/94] fix(mcp): an aborted legacy connect says why as a modern one does; the abort map is made on first use The full run of ae1a568d failed nine tests in three files: - The legacy connect's abort map was a field initializer, so the reaction baseline platform tests and a reconnect test, whose frameworks are built field by field, met it undefined. It's now made on first use, as the restart claims are. - A legacy connect ended by its abort rejected with the handshake's own "MCPL server "same" was closed while connecting", where a modern connect ended the same way says "MCP server "m" was not connected: it was disconnected while connecting", or that the framework stopped while it was connecting. The legacy connect now says the same, with the handshake's error as its cause, so both engines tell a host the same thing. The ownership test that pinned a disconnect waiting out the handshake now says the disconnect ends it at once, as it does; its expectations stand. --- src/framework.ts | 27 +++++++++++++++++++++------ test/mcp-server-ownership.test.ts | 2 +- test/mcpl-runtime-lifecycle.test.ts | 6 ++++-- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 0bab16c82..aef76b035 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2034,8 +2034,9 @@ export class AgentFramework { private mcpServerOps: Map }> | undefined; /** Each legacy connect in flight's own abort (connectHeldMcplServer): * stop() and a disconnect of its id end it at once, its launch reaped, - * rather than waiting out its handshake. */ - private legacyConnectAborts = new Map(); + * rather than waiting out its handshake. Created on first use, as the + * restart claims are (legacyConnectAborts()). */ + private legacyConnectAbortMap: Map | undefined; /** The ids and tool prefixes restarts hold, each with the claim of the * restart holding it, from the restart's checks through its connect * (restartMcplServer): no other connect or restart takes them while the @@ -2706,7 +2707,7 @@ export class AgentFramework { for (const [id, op] of this.mcpServerOps ?? []) { if (op.kind !== 'connect') continue; this.modernServers().get(id)?.close().catch(() => { /* reported by the teardown's close */ }); - this.legacyConnectAborts.get(id)?.abort(); + this.legacyConnectAborts().get(id)?.abort(); } } @@ -16030,6 +16031,10 @@ export class AgentFramework { return (this.mcpRestartClaims ??= { ids: new Map(), prefixes: new Map() }); } + private legacyConnectAborts(): Map { + return (this.legacyConnectAbortMap ??= new Map()); + } + /** * Hold a server id for one connect or disconnect, until `release()`. * Together with the live connections (the MCPL registry and the modern @@ -16636,16 +16641,26 @@ export class AgentFramework { } let connection: McplServerConnection; const abort = new AbortController(); - this.legacyConnectAborts.set(config.id, abort); + this.legacyConnectAborts().set(config.id, abort); try { connection = await this.mcplServerRegistry.addServer(connectionConfig, this.mcplHostCapabilities, abort.signal); } catch (error) { // The registry keeps a failed launch that may still be running, as the // closed connection that owns it. if (error instanceof McplUnreapedLaunchError) throw AgentFramework.unreapedConnectFailure(config.id, error); + // Ended by stop() or by a disconnect of its id (its abort): said as a + // modern connect ended that way says it, with the handshake's own + // error as the cause. + if (abort.signal.aborted) { + throw new Error( + `MCP server "${config.id}" was not connected: ` + + (this.stopHasBegun() ? 'the framework stopped while it was connecting' : 'it was disconnected while connecting'), + { cause: error }, + ); + } throw error; } finally { - if (this.legacyConnectAborts.get(config.id) === abort) this.legacyConnectAborts.delete(config.id); + if (this.legacyConnectAborts().get(config.id) === abort) this.legacyConnectAborts().delete(config.id); } // stop() began while the handshake ran: its teardown has already // collected the registry, so this connection would outlive it. Or a @@ -17199,7 +17214,7 @@ export class AgentFramework { // already registered (setting up after its handshake) fails its // requests and finds itself displaced. The registry's removal is the // legacy close: a failed one stays registered. - this.legacyConnectAborts.get(id)?.abort(); + this.legacyConnectAborts().get(id)?.abort(); const modern = this.modernServers().get(id); const closing = [modern?.close(), registry.removeServer(id)]; for (const pending of closing) pending?.catch(() => { /* its verdict is awaited below */ }); diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index 44debcc03..4ee06691c 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -171,7 +171,7 @@ test('a modern connect is refused while a legacy connect for the same id is stil }); }); -test('a disconnect during a legacy handshake ends that connect when the handshake finishes, and removes it whole', async () => { +test('a disconnect during a legacy handshake ends that connect at once, and removes it whole', async () => { await withFramework(async ({ framework, dir }) => { const log = join(dir, 'legacy.log'); const pending = framework.connectMcplServer(legacy('same', 'slow', log, { SLOW_INIT_MS: '600' })); diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index 8026d9f51..f81d750f0 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -467,7 +467,9 @@ for (const ending of ['stop()', 'a disconnect'] as const) { const took = Date.now() - started; assert.ok(took < 10_000, `${ending} took ${took} ms, as if it waited out the handshake`); const refused = await connecting; - assert.match(refused?.message ?? 'connected', /MCPL server "silent" was closed while connecting/); + assert.match(refused?.message ?? 'connected', ending === 'stop()' + ? /^MCP server "silent" was not connected: the framework stopped while it was connecting$/ + : /^MCP server "silent" was not connected: it was disconnected while connecting$/); if (ending === 'a disconnect') assert.ok(!framework.listMcplServers().some(s => s.id === 'silent'), 'nothing is left of it'); }); }); @@ -489,7 +491,7 @@ test("stop() ends a WebSocket dial still waiting for its access provider", async await framework.stop(); assert.ok(Date.now() - started < 10_000, 'stop() did not wait for the provider'); const refused = await connecting; - assert.match(refused?.message ?? 'connected', /MCPL server "dial" was closed while connecting/); + assert.match(refused?.message ?? 'connected', /^MCP server "dial" was not connected: the framework stopped while it was connecting$/); }); }); From 3f8304ed9b7646bb4dbbbb34bc8176993fa3bf51 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 07:00:57 -0400 Subject: [PATCH 59/94] fix(mcp): stop() ends a connect past its handshake too: its setup and its tool listing A connect's handshake was the only phase stop() ended. After it, a legacy connect waits on its server for its setup (the feature-set request, 15 s), and every connect ends by listing every server's tools, each under that server's request timeout, which a configuration can set to none. stop() waited for both, since a connect is an operator operation: a server that never answers held stop() for good. - stop()'s first step closes a legacy connect's registered connection when the connect is past its handshake, as a disconnect does, so the setup's requests fail at once. - The connect's tool listing is raced against stop() beginning (untilStopBegins): the connect stops waiting, and the teardown's close ends what the listing still waits for. - A connect that stop() overtook in its setup or its listing rejects with "was not connected: the framework stopped while it was connecting", as one ended in its handshake does, rather than resolving after the teardown began. #325's fragment says a connect in flight is ended in any of those phases. Tests, with a request timeout of none: stop() ends a connect whose server never answers its feature-set request, one whose server never answers its tool listing, and one whose listing waits on another server that answered once and then went quiet, each within a second. Removed on its own, the setup's close fails the first (15 s), the race fails the other two (they never end), and the final check fails the last two (they resolve as connected). #325's tests that stop() waits for an MCPL connect or restart already running now expect that connect, or the restart's connect, to reject as overtaken; a disconnect still finishes. Each still checks that the operation settles before the modules stop. A listing let go that way can fail once the teardown closes its server; a listing that fails after stop() has begun emits no mcpl:server-error, since the failure is the stop's, not the server's (Phoebe-2419's note). --- ...top-waits-for-operator-operations.fixed.md | 6 +- src/framework.ts | 56 ++++++++++++++++--- test/mcpl-runtime-lifecycle.test.ts | 42 ++++++++++++++ test/stop-with-host-operations.test.ts | 10 +++- 4 files changed, 104 insertions(+), 10 deletions(-) diff --git a/changelog.d/stop-waits-for-operator-operations.fixed.md b/changelog.d/stop-waits-for-operator-operations.fixed.md index 611e54765..9cfebe3fd 100644 --- a/changelog.d/stop-waits-for-operator-operations.fixed.md +++ b/changelog.d/stop-waits-for-operator-operations.fixed.md @@ -5,8 +5,10 @@ disconnect admitted before `stop()` began now finishes before the event gate, the modules, the MCPL connections and the store go, unless a budget runs out first (see the operators' note below). An MCPL connect still in - flight is ended instead, in either engine: `stop()` closes it at once, - its launch reaped, rather than waiting out its handshake. A restart in + flight is ended instead, in either engine, rejecting with "was not + connected: the framework stopped while it was connecting": `stop()` + doesn't wait on a server for it, in its handshake (its launch reaped), + its setup or its tool listing, which asks every server. A restart in flight ends too, its connect refused once `stop()` has begun, so the server it was restarting is down. Before, such an operation kept running through the teardown. A resume diff --git a/src/framework.ts b/src/framework.ts index aef76b035..f67cceba5 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2707,7 +2707,39 @@ export class AgentFramework { for (const [id, op] of this.mcpServerOps ?? []) { if (op.kind !== 'connect') continue; this.modernServers().get(id)?.close().catch(() => { /* reported by the teardown's close */ }); - this.legacyConnectAborts().get(id)?.abort(); + const abort = this.legacyConnectAborts().get(id); + if (abort) { + abort.abort(); + } else if (this.mcplServerRegistry?.getServer(id)) { + // Past its handshake, setting up: the registered connection's close, + // as a disconnect's, fails what the setup waits for. + this.mcplServerRegistry.removeServer(id).catch(() => { /* reported by the teardown's close */ }); + } + } + // A connect waiting on other servers, listing every server's tools, stops + // waiting (untilStopBegins). + for (const wake of [...(this.stopWaiters ?? [])]) wake(); + } + + /** Wakers for the waits a connect makes that a stop() ends: see + * untilStopBegins. Made on first use. */ + private stopWaiters: Set<() => void> | undefined; + + /** `work`, or undefined once stop() has begun, whichever comes first: a + * connect's last step (its tool listing, which asks every server) needn't + * hold stop()'s wait for operator operations on a server that doesn't + * answer. The work goes on; the teardown's close ends what it waits for. */ + private async untilStopBegins(work: Promise): Promise { + work.catch(() => { /* its caller has stopped waiting, or reads it below */ }); + if (this.stopHasBegun()) return undefined; + const waiters = (this.stopWaiters ??= new Set()); + let wake!: () => void; + const stopped = new Promise((resolve) => { wake = () => resolve(undefined); }); + waiters.add(wake); + try { + return await Promise.race([work, stopped]); + } finally { + waiters.delete(wake); } } @@ -17176,7 +17208,13 @@ export class AgentFramework { const oldToolNames = new Set(this.mcplTools.map(t => t.name)); await this.connectMcplServerInternal(config, false, claim); - await this.refreshMcplTools(); + // A stop() that began during the connect's setup or its tool listing + // ends the connect: it doesn't wait on a server for a framework that is + // stopping, and the teardown closes what the connect set up. + const stopped = () => new Error(`MCP server "${config.id}" was not connected: the framework stopped while it was connecting`); + if (this.stopHasBegun()) throw stopped(); + await this.untilStopBegins(this.refreshMcplTools()); + if (this.stopHasBegun()) throw stopped(); this.emitMcplToolDiff(oldToolNames, config.id); } @@ -18128,11 +18166,15 @@ export class AgentFramework { }); } } catch (error) { - this.emitTrace({ - type: 'mcpl:server-error', - serverId: server.id, - error: `tools/list failed: ${redactServerText(error instanceof Error ? error.message : String(error), config ?? {})}`, - }); + // A listing the teardown closed under a stop() isn't the server's + // error (untilStopBegins lets such a listing go on after stop()). + if (!this.stopHasBegun()) { + this.emitTrace({ + type: 'mcpl:server-error', + serverId: server.id, + error: `tools/list failed: ${redactServerText(error instanceof Error ? error.message : String(error), config ?? {})}`, + }); + } } } diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index f81d750f0..78ac74ac7 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -31,6 +31,7 @@ class NamedModule implements Module { // NDJSON on stdin, answers initialize / tools/list / tools/call on stdout. const STDIO_SERVER = ` let buf = ''; +const answered = {}; process.stdin.on('data', (c) => { buf += c.toString('utf8'); let i; @@ -38,6 +39,14 @@ process.stdin.on('data', (c) => { const line = buf.slice(0, i); buf = buf.slice(i + 1); if (!line.trim()) continue; let m; try { m = JSON.parse(line); } catch { continue; } + // Requests a test's server never answers, named in SILENT_ON, and one it + // answers a number of times and then never again (SILENT_AFTER=method:n). + if ((process.env.SILENT_ON || '').split(',').includes(m.method)) continue; + const [afterMethod, afterCount] = (process.env.SILENT_AFTER || ':').split(':'); + if (m.method === afterMethod) { + answered[m.method] = (answered[m.method] || 0) + 1; + if (answered[m.method] > Number(afterCount)) continue; + } const reply = (result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: m.id, result }) + '\\n'); if (m.method === 'initialize') reply({ protocolVersion: '2024-11-05', capabilities: { experimental: { mcpl: { version: '0.4', pushEvents: true } } } }); else if (m.method === 'tools/list') reply({ tools: [{ name: 'echo', description: 'e', inputSchema: { type: 'object' } }] }); @@ -475,6 +484,39 @@ for (const ending of ['stop()', 'a disconnect'] as const) { }); } +for (const silentOn of ['featureSets/update', 'tools/list'] as const) { + test(`stop() ends a legacy connect past its handshake whose server never answers ${silentOn}, with no request timeout`, async () => { + await withFramework(true, async (framework) => { + const config = serverConfig('late', { env: { SILENT_ON: silentOn }, requestTimeoutMs: 0 }); + const connecting = framework.connectMcplServer(config as never).then(() => null, (error: unknown) => error as Error); + // Past the handshake, waiting for the answer that never comes. + await new Promise((resolve) => setTimeout(resolve, 600)); + const started = Date.now(); + await framework.stop(); + const took = Date.now() - started; + assert.ok(took < 10_000, `stop() took ${took} ms, as if it waited on the server`); + const refused = await connecting; + assert.match(refused?.message ?? 'connected', /^MCP server "late" was not connected: the framework stopped while it was connecting$/); + }); + }); +} + +test("stop() ends a connect whose tool listing waits on another server that stopped answering", async () => { + await withFramework(false, async (framework) => { + // Lists its tools once, at its own connect, and never again. + await framework.connectMcplServer(serverConfig('quiet', { env: { SILENT_AFTER: 'tools/list:1' }, requestTimeoutMs: 0 }) as never); + const connecting = framework.connectMcplServer(serverConfig('late') as never).then(() => null, (error: unknown) => error as Error); + // The new connect is listing every server's tools, and waits on "quiet". + await new Promise((resolve) => setTimeout(resolve, 600)); + const started = Date.now(); + await framework.stop(); + const took = Date.now() - started; + assert.ok(took < 10_000, `stop() took ${took} ms, as if it waited on the other server`); + const refused = await connecting; + assert.match(refused?.message ?? 'connected', /^MCP server "late" was not connected: the framework stopped while it was connecting$/); + }); +}); + test("stop() ends a WebSocket dial still waiting for its access provider", async () => { await withFramework(true, async (framework) => { let asked = false; diff --git a/test/stop-with-host-operations.test.ts b/test/stop-with-host-operations.test.ts index 3a01a4f70..20ff8ff27 100644 --- a/test/stop-with-host-operations.test.ts +++ b/test/stop-with-host-operations.test.ts @@ -929,7 +929,15 @@ describe('stop() with an operator operation in flight', () => { await until(() => entered, 'MCPL operation'); const stopping = framework.stop(); await releaseWhenStopWaits(release); - await running; + // A disconnect finishes. A connect, or a restart's connect, that stop() + // overtook says so rather than reporting a connection the teardown is + // about to close. + if (action === 'disconnect') await running; + else { + await assert.rejects(running, action === 'connect' + ? /^Error: MCP server "srv" was not connected: the framework stopped while it was connecting$/ + : /^Error: MCP server "srv" is down: it was disconnected for its restart, and the restart's connect failed: MCP server "srv" was not connected: the framework stopped while it was connecting$/); + } await stopping; assert.equal(connected, false); const settled = action === 'disconnect' ? 'connection removed' : 'connection opened'; From 636ddd70aaa49d4e9b070efcb4b2e25d8d4c1fef Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 11:25:10 -0400 Subject: [PATCH 60/94] fix(mcp): a disconnect of the id a restart moves to ends the restart, even one that finishes first Greptile on #337 (in this PR's code): restartMcplServer('old', { id: 'new' }) holds 'new'. A disconnectMcplServer('new') made while the old server is torn down finds nothing to remove and finishes, and when the restart's connect began, mcpServerIdTaken saw no disconnect in flight and accepted the restart's own claim, so 'new' launched, against the contract that a disconnect of that id begun during the restart ends it. The public disconnect now marks a restart claim on its id as ended (McpRestartClaim.endedBy), and mcpServerIdTaken refuses the claim's own connect with that reason once the disconnect has finished (one still running keeps its own wording, "is being disconnected"), so the restart rejects saying the server is down. The restart's own disconnect goes through the inner path and marks nothing. Test: in the restart's gap, a disconnect of the new id that completes before the connect; the restart rejects with the reason, nothing is connected under the new id, and both the id and the prefix are free again. --- src/framework.ts | 13 ++++++++++--- test/mcpl-runtime-lifecycle.test.ts | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index f67cceba5..a96d11bc5 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1539,9 +1539,10 @@ type AnswerOnce = { answered: () => boolean; }; -/** A restart's hold on the id and tool prefix it is moving its server to; - * `restarting` names the server it restarts. Compared by identity. */ -type McpRestartClaim = { readonly restarting: string }; +/** A restart's hold on the id and tool prefix it moves its server to; + * `restarting` names the server it restarts. Compared by identity. + * `endedBy` says what ended it before its connect: a disconnect of that id. */ +type McpRestartClaim = { readonly restarting: string; endedBy?: string }; /** * Wrap a server request's responder so the request is answered at most once, @@ -17235,6 +17236,10 @@ export class AgentFramework { */ async disconnectMcplServer(id: string): Promise { this.refuseOperatorAfterStop('disconnect MCPL server'); + // A restart moving its server to this id is ended by this disconnect, + // even one that finishes before the restart's connect begins. + const claim = this.restartClaims().ids.get(id); + if (claim) claim.endedBy = `MCP server "${id}" was disconnected, which ends the restart of "${claim.restarting}"`; return this.trackOperatorOperation('mcpl:disconnect', () => this.runDisconnectMcplServer(id)); } @@ -17446,6 +17451,8 @@ export class AgentFramework { if (claim) return `MCP server "${id}" is being disconnected, which ends the restart of "${claim.restarting}"`; return `${refused}MCP server "${id}" is still being disconnected; ${retry} once that has finished`; } + // A disconnect of the id began during the restart, and has finished. + if (claim?.endedBy) return claim.endedBy; const registered = this.modernServers().get(id) ?? this.mcplServerRegistry?.getServer(id); if (op || registered) { const idle = !op && registered && !registered.isConnected && !registered.willReconnect; diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index 78ac74ac7..eaf1cb49c 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -461,6 +461,27 @@ test("a stop() that begins in a restart's gap ends it, and the restart says the }); }); +test("a disconnect of the id a restart moves to ends the restart, even one that finishes before the restart's connect", async (t) => { + await withFramework(true, async (framework) => { + const disconnect = (framework as unknown as Inner).runDisconnectMcplServer.bind(framework); + t.mock.method(framework as unknown as Inner, 'runDisconnectMcplServer', async (id: string) => { + await disconnect(id); + // In the restart's gap, the id it moves to is disconnected, and that + // disconnect is over before the restart's connect begins. + if (id === 'first') await framework.disconnectMcplServer('second'); + }); + await assert.rejects( + framework.restartMcplServer('first', serverConfig('second') as never), + /^Error: MCP server "first" is down: it was disconnected for its restart, and the restart's connect failed: MCP server "second" was disconnected, which ends the restart of "first"$/, + ); + assert.deepEqual(framework.listMcplServers(), [], 'nothing was connected under the new id'); + assert.ok(!framework.getAllTools().some(tool => tool.name.startsWith('mcpl--second--'))); + // The id and the prefix are free again. + await framework.connectMcplServer(serverConfig('second') as never); + assert.deepEqual(framework.listMcplServers().map(s => [s.id, s.connected]), [['second', true]]); + }); +}); + /** A stdio server that reads its input and never answers, so a connect waits in its handshake. */ const SILENT = { id: 'silent', command: process.execPath, args: ['-e', 'process.stdin.resume()'] }; From 1d47151c15ae3c1daea8b3abf7f1a9e39bc3481b Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 11:27:43 -0400 Subject: [PATCH 61/94] fix(mcp): a refresh overtaken by a later one publishes nothing, so a restart's old listing can't come back Greptile on #337 (in this PR's code): refreshMcplTools dropped the listings of servers whose id was gone at its end, so a modern server restarted under the same id while a refresh awaited another server kept the old connection's tools, schemas and names in what the last refresh to finish published; and the live map iterator could visit the replacement in the same refresh, listing both. - A refresh overtaken by one started after it, which has published already, publishes nothing (mcplToolsRefreshStarted/Published). The later one listed every server after this one did, a replacement under the same id included, and a restart's disconnect and connect each refresh. - The refresh iterates a snapshot of each engine's server list: the legacy one as it begins, and the modern one as its loop begins, after the legacy listings. A server connected after its snapshot is listed by its own connect's refresh. - Test: a refresh holding a modern server's list while it restarts under the same id with another tool prefix publishes none of the old connection's tools, and the new prefix's tools stand. The title and comment of the test for a server connected while a refresh waits now say the held refresh publishes nothing. --- src/framework.ts | 17 ++++++++++++++-- test/mcp-server-ownership.test.ts | 32 +++++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index a96d11bc5..58139ea65 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2013,6 +2013,11 @@ export class AgentFramework { private mcplToolServers: Map = new Map(); /** Servers whose tools/list answered in the latest refresh. */ private mcplListedServers: Set = new Set(); + /** refreshMcplTools' starts, and the start whose result was published + * last: a refresh that a later-started one has overtaken publishes + * nothing, since that one listed every server after it. */ + private mcplToolsRefreshStarted = 0; + private mcplToolsRefreshPublished = 0; /** Zero-match tool-pattern diagnostics (checkToolPatterns): set at the end * of create(), when the tool universe is first complete. */ private toolPatternChecksArmed = false; @@ -18099,6 +18104,7 @@ export class AgentFramework { */ private async refreshMcplTools(): Promise { if (!this.mcplServerRegistry) return; + const generation = ++this.mcplToolsRefreshStarted; const tools: import('./types/index.js').ToolDefinition[] = []; const toolFeatureSets = new Map(); @@ -18106,7 +18112,7 @@ export class AgentFramework { const toolServers = new Map(); const listedServers = new Set(); - for (const server of this.mcplServerRegistry.getAllServers()) { + for (const server of [...this.mcplServerRegistry.getAllServers()]) { const config = this.mcplServerConfigs.get(server.id); const prefix = config?.toolPrefix ?? `mcpl--${server.id}`; try { @@ -18153,7 +18159,9 @@ export class AgentFramework { // Modern servers: the same namespacing, tool policy and RFC-008 class // hints. Feature sets are MCPL and don't apply. A server whose listing // fails stays unlisted (unknown), never "listed nothing". - for (const server of this.modernServers().values()) { + // The servers there now: one connected while this refresh runs is + // listed by its own connect's refresh. + for (const server of [...this.modernServers().values()]) { if (!server.isConnected) continue; const config = this.mcplServerConfigs.get(server.id); const prefix = config?.toolPrefix ?? `mcpl--${server.id}`; @@ -18185,6 +18193,10 @@ export class AgentFramework { } } + // A refresh started after this one has published already: it listed + // every server after this one did, a server replaced by a restart under + // the same id included, so this one's listings are older. + if (generation < this.mcplToolsRefreshPublished) return; // A server removed while this refresh awaited its lists (a disconnect, // or a server lost for good) publishes nothing: whichever refresh // finishes last must not bring back a removed server's tools. @@ -18203,6 +18215,7 @@ export class AgentFramework { } for (const serverId of gone) listedServers.delete(serverId); } + this.mcplToolsRefreshPublished = generation; this.mcplTools = gone.size > 0 ? tools.filter((tool) => toolServers.has(tool.name)) : tools; this.mcplToolFeatureSets = toolFeatureSets; this.mcplToolClasses = toolClasses; diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index 4ee06691c..115ac3f06 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -436,7 +436,7 @@ test("a refresh in flight when a server is disconnected doesn't bring its tools }); }); -test("a server connected while a refresh waits keeps its own tool prefix in that refresh's list", async () => { +test("a server connected while a refresh waits keeps its own tool prefix, and the held refresh publishes nothing", async () => { await withFramework(async ({ framework, dir }) => { await framework.connectMcplServer(modern('m', '', join(dir, 'raw.jsonl'))); const internals = framework as unknown as { @@ -454,7 +454,8 @@ test("a server connected while a refresh waits keeps its own tool prefix in that connection.listTools = realList; // Connected while the first refresh waits, under a prefix of its own. await framework.connectMcplServer({ ...modern('n', '', join(dir, 'raw-n.jsonl')), toolPrefix: 'custom' }); - // The held refresh finishes last, so its list is the one that stands. + // The held refresh finishes last, and publishes nothing: the connect's + // own refresh, started after it, listed both servers. release(); await refreshing; const names = framework.getAllTools().map((t) => t.name); @@ -463,6 +464,33 @@ test("a server connected while a refresh waits keeps its own tool prefix in that }); }); +test("a refresh in flight when a server restarts under the same id publishes none of the old connection's listing", async () => { + await withFramework(async ({ framework, dir }) => { + await framework.connectMcplServer(modern('m', '', join(dir, 'raw.jsonl'))); + assert.ok(toolsOf(framework, 'm').length > 0); + const internals = framework as unknown as { + modernServers(): Map }>; + refreshMcplTools(): Promise; + }; + const connection = internals.modernServers().get('m')!; + const realList = connection.listTools.bind(connection); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let listed = false; + connection.listTools = async () => { const tools = await realList(); listed = true; await gate; return tools; }; + const refreshing = internals.refreshMcplTools(); + await until(() => listed, "the refresh holds the old connection's list"); + // Restarted under the same id, its tools now under another prefix. + await framework.restartMcplServer('m', { ...modern('m', '', join(dir, 'raw-2.jsonl')), toolPrefix: 'second' }); + assert.notEqual(internals.modernServers().get('m'), connection, 'a new connection'); + release(); + await refreshing; + const names = framework.getAllTools().map((t) => t.name); + assert.deepEqual(toolsOf(framework, 'm'), [], `the old connection's listing isn't published: ${names.join(', ')}`); + assert.ok(names.some((n) => n.startsWith('second--')), names.join(', ')); + }); +}); + test('a server lost for good, legacy or modern, leaves the tool list', async () => { await withFramework(async ({ framework, dir }) => { await framework.connectMcplServer(legacy('l', 'ok', join(dir, 'legacy.log'))); From 4fa550d2c5c8ba5d2985b09559536030dc7c1ea7 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 11:29:02 -0400 Subject: [PATCH 62/94] fix(mcp): ModernMcpConnection.connect hands back the owner of a launch its cleanup couldn't reap Greptile on #337 (in this PR's code): when a modern stdio connect's first attempt fails and its cleanup can't confirm the child exited, the connection keeps that child as unreaped. ModernMcpConnection.connect, the exported create-and-start helper, rejected with the bare failure and dropped the connection, so a direct caller couldn't retry the reap with close() and might start another child beside the first. The framework itself uses create() and keeps the connection registered, so this reached only direct callers. connect() now throws McplUnreapedLaunchError, as the legacy engine's connect does, carrying the connection that owns the launch. The error class is generic in its connection (default McplServerConnection, so the legacy uses read as before). Test: a modern connect whose server refuses server/discover and whose cleanup can't reap rejects with McplUnreapedLaunchError carrying a ModernMcpConnection; once the child is killed, its close() resolves. --- changelog.d/modern-mcp-client.added.md | 5 ++++- src/mcpl/modern-connection.ts | 17 ++++++++++++++--- src/mcpl/server-connection.ts | 10 +++++----- test/mcp-server-ownership.test.ts | 21 ++++++++++++++++++++- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/changelog.d/modern-mcp-client.added.md b/changelog.d/modern-mcp-client.added.md index 8f9af2a81..8130ab377 100644 --- a/changelog.d/modern-mcp-client.added.md +++ b/changelog.d/modern-mcp-client.added.md @@ -29,7 +29,10 @@ changes go through ordinary admission, so they park under quiesce like any wake. `close()` and `stop()` end any connect in flight. A `ModernMcpConnection` starts once: a second `start()` returns the first - call's promise. + call's promise. When a stdio launch's first attempt fails and its cleanup + can't confirm the child exited, `ModernMcpConnection.connect()` throws + `McplUnreapedLaunchError`, whose `connection` owns the launch; its + `close()` retries the reap. - **Status:** `listMcplServers()` entries gain `family`, `protocolVersion` and `transport`. - A server lost for good (it closed and won't reconnect), MCPL or modern, diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index e97ed30b8..5c6c6dc07 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -60,7 +60,7 @@ import { } from '@modelcontextprotocol/client'; import { StdioTransport, type McplTransport } from './transport.js'; -import { McplProtocolVersionError, McplRequestError } from './server-connection.js'; +import { McplProtocolVersionError, McplRequestError, McplUnreapedLaunchError } from './server-connection.js'; import { LEGACY_MCP_PROTOCOL_VERSION, MAX_TIMER_MS, MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, describeCredentialError, redactServerText, serverTextRedactor } from './protocol-family.js'; import type { McplServerConfig, McpToolDefinition } from './types.js'; @@ -490,10 +490,21 @@ export class ModernMcpConnection extends EventEmitter { } } - /** {@link create} and {@link start} in one step. */ + /** + * {@link create} and {@link start} in one step. A first connect that fails + * and whose cleanup couldn't confirm its child exited throws + * {@link McplUnreapedLaunchError}, whose connection owns that launch, as + * the legacy engine's connect does: its close() retries the reap. + */ static async connect(config: McplServerConfig): Promise { const connection = ModernMcpConnection.create(config); - await connection.start(); + try { + await connection.start(); + } catch (error) { + const held = connection.unreaped[0]; + if (held) throw new McplUnreapedLaunchError(connection, error instanceof Error ? error : new Error(String(error)), held.failure); + throw error; + } return connection; } diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 28b379d0a..4f3512dcf 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -206,13 +206,13 @@ class HandshakeCleanupError extends Error { * exited: it may still be running. `connection` owns that launch, closed and * not reconnecting. Its close() retries the reap and resolves once the exit * is confirmed, so the caller can still clean up, and should start nothing - * else for this server until then. connect() throws this; - * connectWithReconnect() returns the connection instead, halted. `cause` is - * the connect's own failure. + * else for this server until then. connect() throws this, the modern + * engine's ModernMcpConnection.connect() too; connectWithReconnect() returns + * the connection instead, halted. `cause` is the connect's own failure. */ -export class McplUnreapedLaunchError extends Error { +export class McplUnreapedLaunchError } = McplServerConnection> extends Error { constructor( - readonly connection: McplServerConnection, + readonly connection: Connection, failure: Error, cleanup: Error, ) { diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index 115ac3f06..a8ae10bb3 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -16,7 +16,7 @@ import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { AgentFramework, ShutdownTimeoutError } from '../src/index.js'; +import { AgentFramework, ModernMcpConnection, McplUnreapedLaunchError, ShutdownTimeoutError } from '../src/index.js'; import type { McplServerConfig } from '../src/mcpl/types.js'; import { StdioTransport } from '../src/mcpl/transport.js'; import { MockMembrane } from './helpers/mock-membrane.js'; @@ -268,6 +268,25 @@ test('modern: a failed connect whose cleanup cannot reap stays registered until }); }); +test("modern: ModernMcpConnection.connect hands back the connection that owns a launch its cleanup couldn't reap", async () => { + const dir = mkdtempSync(join(tmpdir(), 'mcp-ownership-')); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + const log = join(dir, 'raw.jsonl'); + const reaping = unreapable(); + const failed = await ModernMcpConnection.connect(modern('m', 'reject-discover', log)).then( + () => null, + (error: unknown) => error, + ); + assert.ok(failed instanceof McplUnreapedLaunchError, String(failed)); + assert.ok(failed.connection instanceof ModernMcpConnection, 'the owner of the launch'); + assert.match(failed.message, /its cleanup also failed: .*could not be reaped/); + assert.equal(launches(log).length, 1); + await killAndWait(reaping.stuck[0]!); + reaping.active = false; + // Its close() retries the reap, and resolves once the exit is confirmed. + await failed.connection.close(); +}); + test('legacy: a failed connect whose cleanup cannot reap stays registered until a disconnect confirms the exit', async () => { await withFramework(async ({ framework, dir }) => { const log = join(dir, 'legacy.log'); From 05768682c9634e48d7532525dd14c72387a78cc3 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Thu, 15 Oct 2026 09:17:28 -0400 Subject: [PATCH 63/94] fix(mcp): MCPL policy on a modern server is refused only where its value grants something (R1-3) Anarchid's review of #262 at 4fa550d2 (R1-3, room-364 #105270): connectome- host's overlay route stamps disabledCapabilities: ['contextHooks', 'inferenceLifecycle', 'inferenceRequest', 'toolLifecycle'] on every server it deploys, and serverConfigProblems refused any effective MCPL-only field on a modern server, so every overlay-deployed https:// server was refused at connect. He asked the maintainers to choose between narrowing the refusal and a companion host change. Ada-1017's call (room-203 #105304, #105387), with each field's side checked in the code (#105372): a value that asks for behaviour only an MCPL server can have is refused, since an operator who chose it would otherwise never learn it can't apply; a value that only takes something away, or repeats a default, is accepted as already satisfied, since a modern server has no MCPL surface to deny. No warning for those, since the host stamps its deny-list on every entry. MCPL_ONLY_POLICY_FIELDS (#262's own, unreleased) becomes mcplPolicyGrants(), the granting parts by path, and the refusal names each one ("" asks for MCPL behaviour, which a modern MCP server cannot negotiate). By field: - refused: enabledFeatureSets and enabledCapabilities when non-empty (enabledCapabilities is also how deny-by-default paths are granted); allowHostCommands when set; channelSubscription 'auto' or a non-empty list; a scope's non-empty whitelist (scopes..whitelist); toolLifecycle observe or inputs given as a policy object, which configGrants (capability-grant.ts) reads as the grant; an autofetch maxBytes or maxTotalBytes above its default (DEFAULT_EAGER_FETCH_MAX_BYTES, DEFAULT_MAX_TOTAL_BYTES), or one that isn't a number; - accepted: disabledFeatureSets, disabledCapabilities, a scope's blacklist, channelSubscription 'manual' (the framework's default, framework.ts's `?? 'manual'`), toolLifecycle with only maxInputBytes or with a non-object observe/inputs (which configGrants says grants nothing), an autofetch bound at or below its default, and shouldTriggerInference, whose doc says every incoming message triggers inference without it, so it only narrows. Empty values grant nothing, as before. The README's modern-server section and both changelog fragments state the rule, and the framework's comment on connect-time checks says it too. Tests: - mcp-protocol-family: each granting value is refused, naming its path (scopes.files.whitelist, toolLifecycle.inputs, autofetch.maxBytes...), each narrowing value is accepted, connectome-host's stamped deny-list included, and empty values pass on a modern stdio server. - mcp-modern-framework: a modern stdio server connected with the host's stamped deny-list and disabledFeatureSets: ['*'] connects and lists its tools; the refusal test reads the new message. Red-first with src at 4fa550d2: the deny-list connect fails (refused), the message test fails, and mcp-protocol-family can't load (no mcplPolicyGrants). With the change, on the VPS (Node 24.21.0): tsc clean; mcp-protocol-family 27/27, mcp-modern-framework 18/18, mcpl-list-status 3/3, mcp-modern-connection 9/9, mcp-modern-http 9/9. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +++- .../mcp-standard-tool-results.changed.md | 4 +- changelog.d/modern-mcp-client.added.md | 12 ++-- src/framework.ts | 5 +- src/mcpl/index.ts | 2 +- src/mcpl/protocol-family.ts | 71 +++++++++++++------ test/mcp-modern-framework.test.ts | 20 +++++- test/mcp-protocol-family.test.ts | 58 ++++++++++----- 8 files changed, 133 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index f12c803f9..b579c40f2 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,17 @@ class overrides and dispatch as an MCPL server's, and `listMcplServers()` shows its `family`, `protocolVersion` and `transport`. Its config differs in three ways: - It has no MCPL surface (grant, push, channels, inference requests, feature - sets), so setting MCPL-only fields on one is a configuration error. + sets). A setting that asks for that surface can't apply, so it is a + configuration error: `enabledFeatureSets`, `enabledCapabilities`, + `allowHostCommands: true`, `channelSubscription` other than `'manual'`, a + scope's `whitelist`, a `toolLifecycle` `observe` or `inputs` block, and an + `autofetch` bound above its default. A setting that only takes something + away, or repeats a default, is already satisfied, so it is accepted: + `disabledFeatureSets`, `disabledCapabilities`, a scope's `blacklist`, + `channelSubscription: 'manual'`, `toolLifecycle.maxInputBytes`, an + `autofetch` bound at or below its default, and `shouldTriggerInference`. + So a host that puts a deny-list on every server it deploys can deploy a + modern one. - `requestTimeoutMs` is one deadline per tool call and must be 1 to 2^31−1; 0 is refused, where MCPL reads it as no watchdog. - Over stdio it takes no credential: `token` or `accessProvider` on one is diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index 1474d469e..fbe38f7f1 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -111,8 +111,8 @@ registered. A teardown never removes what a later connection registered. `restartMcplServer` refuses, before it tears the running server down, what its connect would refuse: whatever the configuration check refuses (such as - an unusable binding, userinfo on an http(s) url, or MCPL policy on a modern - server), a new id another server holds, a tool prefix another server or a + an unusable binding, userinfo on an http(s) url, or MCPL policy that asks + for what a modern server lacks), a new id another server holds, a tool prefix another server or a module holds, a framework whose `stop()` has begun, and a connect or disconnect of the server in flight. Before, a restart disconnected first, so a refused connect left the server gone; now the server keeps running. diff --git a/changelog.d/modern-mcp-client.added.md b/changelog.d/modern-mcp-client.added.md index 8130ab377..7f708e936 100644 --- a/changelog.d/modern-mcp-client.added.md +++ b/changelog.d/modern-mcp-client.added.md @@ -5,10 +5,14 @@ stays legacy unless its config sets `protocol: 'modern'`. A modern server's tools use the same prefix, tool policy, RFC-008 class hints and dispatch paths as any MCPL server's. It has no MCPL surface: no grant, push, - channels, inference requests, quiesce plane or awareness gate. MCPL-only - policy on one is a configuration error, and so is a modern - `requestTimeoutMs` outside 1..2^31−1, since 0 means "time out now" to the - SDK. Details: + channels, inference requests, quiesce plane or awareness gate. MCPL policy + that asks for that surface on one is a configuration error, such as + `enabledFeatureSets` or `allowHostCommands: true`. Policy that only takes + something away or repeats a default, such as `disabledCapabilities`, is + accepted as already satisfied, so a host that puts a deny-list on every + server it deploys can deploy a modern one. A modern `requestTimeoutMs` + outside 1..2^31−1 is a configuration error too, since 0 means "time out + now" to the SDK. Details: - **stdio:** runs through the framework's own spawner (env allowlist, `inheritEnv`, stderr lines including startup), launched once per connect. - **HTTP credentials:** come from `token`/`accessProvider`, as a cached diff --git a/src/framework.ts b/src/framework.ts index 58139ea65..39e12eb38 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -16561,8 +16561,9 @@ export class AgentFramework { // Configuration mistakes fail here, before any routing is registered: a // combination naming no usable transport (an http(s) url with transport // 'websocket', transport 'http' with a ws url or with no url at all), - // protocol on a URL, a modern deadline of 0, MCPL policy on a modern - // server. Every valid legacy shape resolves exactly as before. + // protocol on a URL, a modern deadline of 0, MCPL policy that asks for + // what a modern server lacks. Every valid legacy shape resolves exactly + // as before. const family = AgentFramework.usableConfigFamily(config); const taken = this.mcpServerIdTaken(config.id, claim); if (taken) throw new Error(taken); diff --git a/src/mcpl/index.ts b/src/mcpl/index.ts index af99ad0e4..ef27b1d85 100644 --- a/src/mcpl/index.ts +++ b/src/mcpl/index.ts @@ -115,7 +115,7 @@ export { LEGACY_MCP_PROTOCOL_VERSION, MODERN_MCP_PROTOCOL_VERSION, MAX_TIMER_MS, - MCPL_ONLY_POLICY_FIELDS, + mcplPolicyGrants, resolveServerBinding, serverConfigProblems, serverConfigWarnings, diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index 5ba548d58..8aaaa2b20 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -22,7 +22,8 @@ * isn't `initialize`), the SDK's stdio probe launches the server twice, and a * server's era is a stable fact its configuration can state. */ -import type { McplServerConfig } from './types.js'; +import { DEFAULT_EAGER_FETCH_MAX_BYTES, DEFAULT_MAX_TOTAL_BYTES } from './reference-fetcher.js'; +import type { McplServerConfig, ScopeConfig } from './types.js'; /** The one revision the legacy engine offers and accepts. */ export const LEGACY_MCP_PROTOCOL_VERSION = '2024-11-05'; @@ -40,23 +41,6 @@ export interface ServerBinding { /** The largest delay a Node/Bun timer honors (2³¹−1 ms, about 24.8 days). */ export const MAX_TIMER_MS = 2_147_483_647; -/** - * Configuration fields that are MCPL policy: they have no meaning for a modern - * server, which cannot negotiate MCPL. Setting one there is rejected rather - * than ignored, so an operator who chose a policy learns that it cannot apply. - */ -export const MCPL_ONLY_POLICY_FIELDS = [ - 'enabledFeatureSets', - 'disabledFeatureSets', - 'enabledCapabilities', - 'disabledCapabilities', - 'scopes', - 'channelSubscription', - 'allowHostCommands', - 'toolLifecycle', - 'autofetch', - 'shouldTriggerInference', -] as const satisfies readonly (keyof McplServerConfig)[]; type BindingFields = Pick; @@ -389,6 +373,51 @@ function isEffective(value: unknown): boolean { return true; } +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * The parts of a configuration's MCPL policy whose values grant something, + * by path, such as `allowHostCommands`, `scopes..whitelist` or + * `autofetch.maxBytes`: each asks for behaviour only an MCPL server can have. + * A modern server has none, so such a value can't apply there, and is + * refused rather than ignored, so that an operator who chose it learns so. + * + * A value that only takes something away, or repeats a default, is already + * satisfied on a modern server and isn't listed: `disabledFeatureSets`, + * `disabledCapabilities`, a scope's `blacklist`, `channelSubscription: + * 'manual'` (the default), `toolLifecycle` with only `maxInputBytes` or + * with an `observe` or `inputs` that isn't a policy object (which grants + * nothing), an `autofetch` bound at or below its default, and + * `shouldTriggerInference`, which only narrows which channel messages + * trigger inference. So a host that stamps a deny-list on every server it + * deploys can deploy a modern one. Empty values grant nothing either. + */ +export function mcplPolicyGrants(config: McplServerConfig): string[] { + const grants: string[] = []; + for (const field of ['enabledFeatureSets', 'enabledCapabilities', 'allowHostCommands'] as const) { + if (isEffective(config[field])) grants.push(field); + } + if (config.channelSubscription !== 'manual' && isEffective(config.channelSubscription)) grants.push('channelSubscription'); + if (isPlainRecord(config.scopes)) { + for (const [set, scope] of Object.entries(config.scopes)) { + if (isEffective((scope as ScopeConfig | null | undefined)?.whitelist)) grants.push(`scopes.${set}.whitelist`); + } + } else if (isEffective(config.scopes)) { + grants.push('scopes'); + } + for (const key of ['observe', 'inputs'] as const) { + if (isPlainRecord((config.toolLifecycle as Record | undefined)?.[key])) grants.push(`toolLifecycle.${key}`); + } + const bounds = [['maxBytes', DEFAULT_EAGER_FETCH_MAX_BYTES], ['maxTotalBytes', DEFAULT_MAX_TOTAL_BYTES]] as const; + for (const [key, ceiling] of bounds) { + const value = (config.autofetch as Record | undefined)?.[key]; + if (value !== undefined && value !== null && !(typeof value === 'number' && value <= ceiling)) grants.push(`autofetch.${key}`); + } + return grants; +} + /** * Problems with a server configuration's protocol settings, as messages; empty * when it is usable. Legacy configurations are checked only for what this @@ -429,10 +458,8 @@ export function serverConfigProblems(config: McplServerConfig): string[] { `MCP server "${config.id}": requestTimeoutMs must be an integer from 1 to ${MAX_TIMER_MS} for a modern server (got ${String(timeout)}); every modern call has a deadline`, ); } - for (const field of MCPL_ONLY_POLICY_FIELDS) { - if (isEffective(config[field])) { - problems.push(`MCP server "${config.id}": "${field}" is MCPL policy, which a modern MCP server cannot negotiate`); - } + for (const path of mcplPolicyGrants(config)) { + problems.push(`MCP server "${config.id}": "${path}" asks for MCPL behaviour, which a modern MCP server cannot negotiate`); } // A modern stdio server takes no credential: nothing would carry it. if (binding.transport === 'stdio') { diff --git a/test/mcp-modern-framework.test.ts b/test/mcp-modern-framework.test.ts index bcd07f692..d222330de 100644 --- a/test/mcp-modern-framework.test.ts +++ b/test/mcp-modern-framework.test.ts @@ -339,11 +339,29 @@ test("a payload whose workspace write rejects is lost from a completed call's re }); }); +test('a modern server deployed with a host\'s MCPL deny-list connects, since what only narrows is already satisfied (R1-3)', async () => { + await withModern(async ({ framework, dir }) => { + await framework.connectMcplServer({ + id: 'overlay', + command: process.execPath, + args: [MODERN, join(dir, 'overlay-starts.log'), join(dir, 'overlay-events.log')], + protocol: 'modern', + // connectome-host's overlay route stamps this on every server it deploys. + disabledCapabilities: ['contextHooks', 'inferenceLifecycle', 'inferenceRequest', 'toolLifecycle'], + disabledFeatureSets: ['*'], + }); + const status = framework.listMcplServers().find((s) => s.id === 'overlay'); + assert.equal(status?.connected, true); + assert.equal(status?.family, 'modern'); + assert.ok(framework.getAllTools().some((t) => t.name === 'mcpl--overlay--echo')); + }); +}); + test('MCPL policy on a modern server is a configuration error at connect', async () => { await withModern(async ({ framework }) => { await assert.rejects( framework.connectMcplServer({ id: 'bad', url: 'https://example.invalid/mcp', enabledFeatureSets: ['x'] }), - /"enabledFeatureSets" is MCPL policy/, + /"enabledFeatureSets" asks for MCPL behaviour/, ); await assert.rejects( framework.connectMcplServer({ id: 'bad2', url: 'https://example.invalid/mcp', protocol: 'modern' }), diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index afab1b238..dd9abd2e4 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -17,9 +17,10 @@ import { credentialRedactor, serverCredentialPatterns, describeCredentialError, - MCPL_ONLY_POLICY_FIELDS, + mcplPolicyGrants, MAX_TIMER_MS, } from '../src/mcpl/protocol-family.js'; +import { DEFAULT_EAGER_FETCH_MAX_BYTES, DEFAULT_MAX_TOTAL_BYTES } from '../src/mcpl/reference-fetcher.js'; import { ModernMcpConnection } from '../src/mcpl/modern-connection.js'; import { WebSocketTransport } from '../src/mcpl/transport.js'; import type { McplServerConfig } from '../src/mcpl/types.js'; @@ -141,24 +142,44 @@ test('a legacy deadline above the timer limit is refused; every value that worke } }); -test('MCPL policy on a modern server is rejected, empty values are not', () => { - const effective: Partial = { - enabledFeatureSets: ['a'], - disabledFeatureSets: ['b'], - enabledCapabilities: ['pushEvents'], - disabledCapabilities: ['channels'], - scopes: { a: {} as never }, - channelSubscription: 'manual', - allowHostCommands: true, - toolLifecycle: { observe: {} } as never, - autofetch: { maxBytes: 10 }, - shouldTriggerInference: () => true, - }; - for (const field of MCPL_ONLY_POLICY_FIELDS) { - const problems = serverConfigProblems(cfg({ url: 'https://x/mcp', [field]: effective[field] })); - assert.equal(problems.length, 1, field); - assert.match(problems[0]!, new RegExp(`"${field}" is MCPL policy`)); +test('MCPL policy on a modern server is refused where its value grants, and accepted where it only narrows or repeats a default (R1-3)', () => { + const modern = (c: Partial) => cfg({ url: 'https://x/mcp', ...c }); + // Each value that asks for MCPL behaviour is refused, naming the part that grants. + const granting: Array<[Partial, string]> = [ + [{ enabledFeatureSets: ['a'] }, 'enabledFeatureSets'], + [{ enabledCapabilities: ['pushEvents'] }, 'enabledCapabilities'], + [{ allowHostCommands: true }, 'allowHostCommands'], + [{ channelSubscription: 'auto' }, 'channelSubscription'], + [{ channelSubscription: ['general'] }, 'channelSubscription'], + [{ scopes: { files: { whitelist: ['read:*'], blacklist: ['write:*'] } } }, 'scopes.files.whitelist'], + [{ toolLifecycle: { observe: {} } }, 'toolLifecycle.observe'], + [{ toolLifecycle: { inputs: { tools: ['x--*'] } } }, 'toolLifecycle.inputs'], + [{ autofetch: { maxBytes: DEFAULT_EAGER_FETCH_MAX_BYTES + 1 } }, 'autofetch.maxBytes'], + [{ autofetch: { maxTotalBytes: DEFAULT_MAX_TOTAL_BYTES + 1 } }, 'autofetch.maxTotalBytes'], + ]; + for (const [config, path] of granting) { + assert.deepEqual(mcplPolicyGrants(modern(config)), [path], path); + const problems = serverConfigProblems(modern(config)); + assert.equal(problems.length, 1, path); + assert.ok(problems[0]!.includes(`"${path}" asks for MCPL behaviour, which a modern MCP server cannot negotiate`), problems[0]); + } + // What only takes something away, or repeats a default, is already + // satisfied: a modern server has no MCPL surface. + const narrowing: Partial[] = [ + // connectome-host's overlay route stamps this on every server it deploys. + { disabledCapabilities: ['contextHooks', 'inferenceLifecycle', 'inferenceRequest', 'toolLifecycle'] }, + { disabledFeatureSets: ['b'] }, + { scopes: { files: { blacklist: ['write:*'] } } }, + { channelSubscription: 'manual' }, + { toolLifecycle: { maxInputBytes: 1024 } }, + { toolLifecycle: { observe: false } as never }, + { autofetch: { maxBytes: DEFAULT_EAGER_FETCH_MAX_BYTES, maxTotalBytes: 1 } }, + { shouldTriggerInference: () => false }, + ]; + for (const config of narrowing) { + assert.deepEqual(serverConfigProblems(modern(config)), [], JSON.stringify(config)); } + // Empty values grant nothing, on a modern stdio server too. const empty = cfg({ command: 'srv', protocol: 'modern', @@ -167,6 +188,7 @@ test('MCPL policy on a modern server is rejected, empty values are not', () => { scopes: {}, allowHostCommands: false, toolLifecycle: {} as never, + channelSubscription: [], }); assert.deepEqual(serverConfigProblems(empty), []); }); From 42eb65cb7e61a67f482e4b96a1e0ddacd30b0dea Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Thu, 15 Oct 2026 09:23:48 -0400 Subject: [PATCH 64/94] fix(mcp): a modern child that exits during its connect's list-change subscribe fails the connect (R1-2) Anarchid's review of #262 at 4fa550d2 (R1-2, room-364 #105270): the SDK opens the auto-subscription inside connect() and, when that listen fails for any reason but the connect's own abort, reports it through onerror and lets connect() resolve (SDK 2.3.1 client/dist/index.mjs:3449-3457). A child that exits at that subscribe closes the transport during the connect: onclose reached handleLost, which returned at once since this.session wasn't assigned yet; establish then adopted the dead session. start() resolved connected, keepListening retried a listen that could never open, and attemptReconnect returned early because a session was set. Only close() ended it. Ada-1017's call (room-203 #105304), as he proposed: a close during the opening generation makes the connect a failed connect. - Each Session carries `lost`, set by client.onclose whether or not the session has been adopted. - establish, after a connect that resolved for the current generation, closes a lost session (holding a launch it can't reap, as a failed connect does) and throws "MCP server "" closed its connection while connecting". So firstConnect and attemptReconnect take their normal failure paths: start() rejects without reconnect; with reconnect, it's a failed connect that backs off and tries again. Fixture: modern-raw-server.mjs gains `exit-on-listen-first`, whose first launch exits when its subscriptions/listen arrives. Tests (mcp-modern-outcomes, 27 -> 29): - without reconnect, the connect rejects "closed its connection while connecting", after the subscribe arrived, with one launch; - with reconnect, the next launch serves: connected, two launches, and a call answered on launch 2. Red-first with src at 4fa550d2: the first gets no rejection, and the second sees one launch only, since the dead session read as connected. With the change, on the VPS (Node 24.21.0): tsc clean; mcp-modern-outcomes 29/29, mcp-modern-connection 9/9, mcp-modern-framework 18/18, mcp-modern-http 9/9, mcp-server-ownership 21/21. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/modern-connection.ts | 18 +++++++++++++++++- test/fixtures/modern-raw-server.mjs | 2 ++ test/mcp-modern-outcomes.test.ts | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 5c6c6dc07..47820d4e6 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -338,6 +338,8 @@ interface Session { readonly generation: number; readonly client: Client; readonly wire: ObservedTransport; + /** Its transport has closed, whether or not the session was adopted yet. */ + lost: boolean; } export class ModernMcpConnection extends EventEmitter { @@ -543,6 +545,7 @@ export class ModernMcpConnection extends EventEmitter { }, }, ); + const session: Session = { generation, client, wire, lost: false }; client.onerror = (error: Error) => { // The SDK reports late responses to deadline-abandoned calls here // ("unknown message ID"), and a list-change subscription it could not @@ -553,10 +556,12 @@ export class ModernMcpConnection extends EventEmitter { if (this.isCurrent(generation)) this.emit('error', error); }; client.onclose = () => { + // Marked before the session is adopted too: a close during the connect + // makes it a failed connect (see establish). + session.lost = true; if (this.isCurrent(generation)) this.handleLost(generation, 'transport closed'); }; - const session: Session = { generation, client, wire }; // `done` settles only once this connect's own cleanup has finished, on // every path: close() waits on it, and must not resolve while that // cleanup could still find a child it can't reap. @@ -585,6 +590,17 @@ export class ModernMcpConnection extends EventEmitter { if (failure) this.holdUnreaped(session, failure); return; } + if (session.lost) { + // The transport closed while the client connected, such as a child + // that exited at the connect-time list-change subscribe: the SDK + // reports that subscribe's failure only as an error, and resolves the + // connect anyway (R1-2). Adopted, the session would read as connected + // with nothing behind it, and no later close would come to reconnect + // it. So the connect failed. + const failure = await ModernMcpConnection.closeSession(session); + if (failure) this.holdUnreaped(session, failure); + throw new Error(`MCP server "${this.id}" closed its connection while connecting`); + } this.session = session; this.protocolVersion = MODERN_MCP_PROTOCOL_VERSION; this.reconnectAttempts = 0; diff --git a/test/fixtures/modern-raw-server.mjs b/test/fixtures/modern-raw-server.mjs index 2044d8889..a7b18b81c 100644 --- a/test/fixtures/modern-raw-server.mjs +++ b/test/fixtures/modern-raw-server.mjs @@ -6,6 +6,7 @@ // Flags, comma-separated: // invalid-schema tools/list advertises an outputSchema that doesn't compile // listen-fail-first the first subscriptions/listen of each launch is refused +// exit-on-listen-first the first launch exits when its subscriptions/listen arrives // hang-discover-later launches after the first never answer server/discover // hang-discover no launch ever answers server/discover // reject-discover-later launches after the first answer server/discover with an error @@ -69,6 +70,7 @@ createInterface({ input: process.stdin }).on('line', (line) => { _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'raw-fixture', version: '1' } }, }); case 'subscriptions/listen': + if (flags.has('exit-on-listen-first') && launch === 1) process.exit(0); listens++; if (flags.has('listen-fail-first') && listens === 1) return error(m.id, -32603, 'first listen unavailable'); subscription = m.id; diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts index 338e00ee0..822ecf347 100644 --- a/test/mcp-modern-outcomes.test.ts +++ b/test/mcp-modern-outcomes.test.ts @@ -142,6 +142,24 @@ test('awareness survives a reconnect whose first listen is refused again', async await until(() => changes > before, 'the announced change on launch 2'); }); +test('a child that exits during the connect-time subscribe fails the connect, rather than being adopted as connected (R1-2)', async () => { + const log = scratchLog(); + await assert.rejects(connect('exit-on-listen-first', log), /closed its connection while connecting/); + assert.ok(wire(log).some((e) => e.method === 'subscriptions/listen'), 'it exited at the subscribe'); + assert.equal(wire(log).filter((e) => e.event === 'start').length, 1, 'one launch, and no reconnect'); +}); + +test('with reconnect, a child that exits during the connect-time subscribe is a failed connect, and the next launch serves (R1-2)', async () => { + const log = scratchLog(); + const connection = await connect('exit-on-listen-first', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }); + await until(() => connection.isConnected, 'connected on the next launch'); + assert.equal(wire(log).filter((e) => e.event === 'start').length, 2, 'launch 1 failed its connect, and launch 2 serves'); + const plain = await connection.callTool('plain', {}); + assert.ok(plain.content.length > 0); + assert.equal(calls(log, 'plain'), 1); + assert.equal(wire(log).find((e) => e.method === 'tools/call')!.launch, 2); +}); + test('close() during a reconnect handshake ends the launch it was waiting on, and stops', async () => { const log = scratchLog(); const connection = await connect('hang-discover-later', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }); From 74574fef8bedd313929799609beab8680e5ed0f2 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Thu, 15 Oct 2026 09:25:58 -0400 Subject: [PATCH 65/94] docs(mcp): listMcplServers' family and transport are null only when no binding resolves (R1-7) Anarchid's review of #262 at 4fa550d2 (R1-7, room-364 #105270): the changelog said a configuration that connecting would refuse lists with `family` and `transport` null. They're null only when the configuration names no usable transport. An https url with requestTimeoutMs: 0 lists `refused` beside `family: 'modern'` and `transport: 'http'`. The note now names `refused` as the verdict to read and says when the other two are null, as the listing's own doc comments in framework.ts already do. Ada-1017's call: fix the note (room-203 #105304). Changelog only. Co-Authored-By: Claude Opus 5.5 --- changelog.d/mcp-standard-tool-results.changed.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index fbe38f7f1..72357416d 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -153,6 +153,9 @@ its error's class and its message's length, not its text, which may quote the credential. - `listMcplServers()` lists a configuration that connecting would refuse - with `refused` (why), and `family` and `transport` null, where it guessed - a legacy binding before. Its `policyEstablished` is null for a modern - server, which has no MCPL policy. + with `refused`, which says why and is the verdict to read. Its `family` + and `transport` are null only when the configuration names no usable + transport, where the listing guessed a legacy binding before. A usable + binding refused for something else, such as an https url with + `requestTimeoutMs: 0`, keeps them. Its `policyEstablished` is null for a + modern server, which has no MCPL policy. From 0fbb71ece6a82f59433cfa6a70a55300b4aed7f2 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Thu, 15 Oct 2026 17:55:38 -0400 Subject: [PATCH 66/94] fix(mcp): a lost modern session is reaped before the reconnect, and close() waits for that reap (R1-1) Anarchid's review of #262 at 4fa550d2 (R1-1, room-364 #105270): a stdio child that stops servicing its stdin is never detected as lost, and, of any fix that maps a pipe failure to a loss: handleLost (modern-connection.ts) drops the session without closing its transport, which is fine once the child has exited, but such a fix "must reap before it reconnects, or a second child runs beside the first". Ada-1017's call (room-203 #105304): the transport side, a stdin or stdout failure on a live child, is Kestrel-2409's #344 follow-up on main (now anima-research/agent-framework#374), which #262 takes when it merges main. #262's own part is that handleLost reaps before it reconnects, and that the modern engine's doc says the per-call deadline is the only bound for a child that silently stops reading. The loss path for both engines, agreed with Kestrel (#105558, #105560): on main, kill() and then, at the child's exit, markClosed with the pipe error in the reason and pending work failed; on #262, its SIGTERM-then-SIGKILL reap where main has kill(). Here: - handleLost closes the lost session (closeSession, which reaps through the transport's close, shared with any close already running) before it schedules a reconnect. A launch that can't be reaped is held, which halts reconnecting, as after a failed connect. - The reap in flight is kept as `losing`. close() counts its session among the generations it closes and waits for that reap, as it does for a connect in flight, so close() still resolves only once the child has exited. - The module doc's Lifetime bullet says the restart comes once the lost launch is reaped, and that a stdio child that stays alive but silently stops reading its stdin is no loss the pipes show: each call's deadline is the only bound on it. Tests (mcp-modern-outcomes, 29 -> 32). Each stands in for a loss read from a failed pipe by closing the session's stdio line (markClosed) while its child still runs, since on this head the line closes only at the child's exit: - the reconnect's launch starts only once the first child is gone; - close() made during that reap, with a child that ignores SIGTERM, resolves only once the child is dead, and nothing launches after it; - a child whose signals never arrive (its kill stubbed, as the unreapable tests here do) is held as unreaped: the 'error' says no other launch starts, willReconnect is false, and no second launch comes. Red-first with src at 42eb65cb's: all three fail (a second launch beside a live first child; close() resolving at once with the child alive; no hold within 8 s). With the change, on the VPS (Node 24.21.0), all three pass; tsc clean; mcp-modern-outcomes 32/32, mcp-modern-connection 9/9, mcp-modern-framework 18/18, mcp-modern-http 9/9, mcp-server-ownership 21/21, mcpl-runtime-lifecycle 33/33, stop-with-host-operations 57/57. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/modern-connection.ts | 40 ++++++++++++++++++------ test/mcp-modern-outcomes.test.ts | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 47820d4e6..61706783f 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -26,7 +26,11 @@ * `input_required` answer asks for another round, so a call that ends * after one has no answer. * - **Lifetime.** On a lost transport the connection restarts with the same - * backoff settings as legacy, when `reconnect` is set. A server that shares + * backoff settings as legacy, when `reconnect` is set, once the lost + * launch is reaped, so a second child never runs beside the first. A + * stdio child that stays alive but silently stops reading its stdin is + * no loss the pipes show: each call's deadline is the only bound on it. + * A server that shares * no revision with this client is a configuration verdict, as for legacy * (`McplProtocolVersionError`): it is never retried. List-change * awareness is kept while the connection lives: a subscription the server @@ -368,6 +372,9 @@ export class ModernMcpConnection extends EventEmitter { * first. close() retries them and reports what still fails. */ private unreaped: Array<{ session: Session; failure: Error }> = []; + /** A lost session's reap in flight: the reconnect follows it, and + * close() waits for it. */ + private losing: { session: Session; done: Promise } | null = null; /** Child stderr from before anyone listens (the caller attaches only once * connect() returns), delivered to the first listener. Bounded: the * newest lines are kept. */ @@ -700,11 +707,22 @@ export class ModernMcpConnection extends EventEmitter { /** The transport went away (child exit, or a closed HTTP client). */ private handleLost(generation: number, reason: string): void { - if (this.session?.generation !== generation) return; + const session = this.session; + if (session?.generation !== generation) return; this.session = null; this.cancelRelisten(); this.emit('close', { reason }); - if (this.willReconnect) this.scheduleReconnect(); + // Reap before any reconnect, so a second child never runs beside the + // first. A transport usually closes once its child has exited, but a + // loss read from a failed pipe can come while the child still runs + // (R1-1). A launch that can't be reaped halts reconnecting, as a failed + // connect's does. + const done = ModernMcpConnection.closeSession(session).then((failure) => { + if (this.losing?.session === session) this.losing = null; + if (failure) this.holdUnreaped(session, failure); + else if (this.willReconnect) this.scheduleReconnect(); + }); + this.losing = { session, done }; } /** The legacy engine's backoff: doubling from the base interval, capped, @@ -860,14 +878,17 @@ export class ModernMcpConnection extends EventEmitter { this.reconnectTimer = null; this.cancelRelisten(); const opening = this.opening; + const losing = this.losing; const session = this.session; this.session = null; if (session) this.closeIncomplete = true; // Every generation this connection still owns: a connect in flight, the - // live one, and any a failed cleanup couldn't reap. + // live one, a lost one still being reaped, and any a failed cleanup + // couldn't reap. const targets: Session[] = []; if (opening) targets.push(opening.session); if (session) targets.push(session); + if (losing) targets.push(losing.session); for (const held of this.unreaped) if (!targets.includes(held.session)) targets.push(held.session); this.unreaped = []; const failures: Error[] = []; @@ -880,11 +901,12 @@ export class ModernMcpConnection extends EventEmitter { if (!this.unreaped.some((held) => held.session === target)) this.unreaped.push({ session: target, failure }); } } - if (opening) { - // The connect's own cleanup closes its generation again, after the - // close above; `done` settles once it has. A child that cleanup - // couldn't reap is this close's failure too. - await opening.done; + // The connect's own cleanup, and a lost session's reap, close their + // generation again, after the close above; each `done` settles once it + // has. A child either couldn't reap is this close's failure too. + for (const pending of [opening, losing]) { + if (!pending) continue; + await pending.done; for (const held of this.unreaped) if (!failures.includes(held.failure)) failures.push(held.failure); } if (failures.length === 1) throw failures[0]; diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts index 822ecf347..70938b789 100644 --- a/test/mcp-modern-outcomes.test.ts +++ b/test/mcp-modern-outcomes.test.ts @@ -160,6 +160,58 @@ test('with reconnect, a child that exits during the connect-time subscribe is a assert.equal(wire(log).find((e) => e.method === 'tools/call')!.launch, 2); }); +/** + * The stdio line under a connection's live session, reached from inside, to + * stand for a loss read from a failed pipe while the child still runs: the + * transport closes before the child has exited (R1-1). + */ +function liveLine(connection: ModernMcpConnection): { markClosed: (info: { reason: string }) => void; child: { pid: number; kill: (signal?: string) => boolean } } { + const session = (connection as unknown as { session: { wire: { inner: { line: unknown } } } | null }).session; + assert.ok(session, 'a live session'); + return session.wire.inner.line as never; +} +const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } }; + +test('a transport lost while its child still runs is reaped before the reconnect, so no second child runs beside the first (R1-1)', async () => { + const log = scratchLog(); + const connection = await connect('', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }); + const first = wire(log).find((e) => e.event === 'start')!.pid!; + liveLine(connection).markClosed({ reason: 'stdio pipe failed' }); + await until(() => wire(log).filter((e) => e.event === 'start').length === 2, 'the next launch'); + assert.equal(alive(first), false, 'the first child was gone when the second started'); + await until(() => connection.isConnected, 'connected on the next launch'); +}); + +test('close() during a lost transport\'s reap waits for its child to exit (R1-1)', async () => { + const log = scratchLog(); + const connection = await connect('ignore-sigterm', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }); + const first = wire(log).find((e) => e.event === 'start')!.pid!; + liveLine(connection).markClosed({ reason: 'stdio pipe failed' }); + await connection.close(); + assert.equal(alive(first), false, 'dead when close() resolved'); + assert.equal(wire(log).filter((e) => e.event === 'start').length, 1, 'no launch after close'); +}); + +test('a lost transport whose child can\'t be reaped halts reconnecting (R1-1)', async () => { + const log = scratchLog(); + const connection = await connect('', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }); + const errors: string[] = []; + connection.on('error', (error: Error) => errors.push(error.message)); + const line = liveLine(connection); + const realKill = line.child.kill.bind(line.child); + line.child.kill = () => true; // signals never arrive: the child outlives both bounds + // Before the connection's close(), which then reaps an exited child. + cleanups.push(async () => { + realKill('SIGKILL'); + await until(() => !alive(line.child.pid), 'the held child killed'); + }); + line.markClosed({ reason: 'stdio pipe failed' }); + await until(() => errors.some((m) => /Not starting another launch while it may still be running/.test(m)), 'the launch held as unreaped', 8000); + assert.equal(connection.willReconnect, false); + await new Promise((r) => setTimeout(r, 200)); + assert.equal(wire(log).filter((e) => e.event === 'start').length, 1, 'no second launch'); +}); + test('close() during a reconnect handshake ends the launch it was waiting on, and stops', async () => { const log = scratchLog(); const connection = await connect('hang-discover-later', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }); From 59f1986073f049a1e3acb48d15eb3341bc1ee077 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Thu, 15 Oct 2026 21:11:55 -0400 Subject: [PATCH 67/94] test(mcp): a modern stdio command that can't be spawned fails its connect at once, with its spawn error (R1-4) Anarchid's review of #262 at 4fa550d2 (R1-4, room-364 #105270): with the synchronous spawn, a modern stdio server whose command couldn't be spawned failed only after the discover probe's 30 s timeout ("Version negotiation probe timed out"), since the child's ENOENT was only logged and no exit followed. Ada-1017's call (room-203 #105304): #344 makes spawn() wait for 'spawn' or 'error', and SpawnerStdioTransport.start() waits on it too. That await is in the merge of main, c59efc48, which needed it to compile. This is its test. Test (mcp-modern-connection, 9 -> 10): ModernMcpConnection.connect with command 'no-such-command-r1-4' rejects with exactly 'MCPL server "typo" could not be started: spawn no-such-command-r1-4 ENOENT' within 5 s. Red-first at 0fbb71ec, the round before the merge: it fails after 30.4 s with "Version negotiation probe timed out after 30000ms". At the merge it passes in 280 ms. Both runs were on the VPS (J11453). Co-Authored-By: Claude Opus 5.5 --- test/mcp-modern-connection.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/mcp-modern-connection.test.ts b/test/mcp-modern-connection.test.ts index 148adca4c..615d807fe 100644 --- a/test/mcp-modern-connection.test.ts +++ b/test/mcp-modern-connection.test.ts @@ -183,6 +183,16 @@ test('a first connect that fails keeps retrying with reconnect, and close stops void dir; }); +test('a modern stdio server whose command can\'t be spawned fails its connect at once, with its spawn error (R1-4)', async () => { + const began = Date.now(); + await assert.rejects( + ModernMcpConnection.connect({ id: 'typo', command: 'no-such-command-r1-4', protocol: 'modern' }), + /^Error: MCPL server "typo" could not be started: spawn no-such-command-r1-4 ENOENT$/, + ); + const tookMs = Date.now() - began; + assert.ok(tookMs < 5000, `failed after ${tookMs} ms, not at the discover probe's 30 s timeout`); +}); + test('configuration mistakes throw before anything is launched', async () => { // x.invalid never resolves, so a dial would fail with a connect error instead. await assert.rejects(ModernMcpConnection.connect({ id: 'x', url: 'https://x.invalid/mcp', protocol: 'modern' }), /applies only to stdio/); From 023728dbf68210c872058e152c28b4a079a1552b Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Thu, 15 Oct 2026 21:15:02 -0400 Subject: [PATCH 68/94] test(mcp): narrowing MCPL policy on a modern server raises no warning either (R1-3) Ada-1017's R1-3 call (room-203 #105304) was that deny-side policy on a modern server is accepted with no warning. connectome-host stamps its deny-list on every overlay entry, so a warning would come at every start with nothing for the operator to do. 05768682's test pinned that serverConfigProblems accepts each narrowing value. It now also pins that serverConfigWarnings returns nothing for each, since #262's body says so. On the VPS (Node 24.21.0), at this head: mcp-protocol-family 27/27. Co-Authored-By: Claude Opus 5.5 --- test/mcp-protocol-family.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index dd9abd2e4..fd8a63b11 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -178,6 +178,9 @@ test('MCPL policy on a modern server is refused where its value grants, and acce ]; for (const config of narrowing) { assert.deepEqual(serverConfigProblems(modern(config)), [], JSON.stringify(config)); + // Nor a warning: a host that stamps its deny-list on every server would + // hear it at every start, with nothing to do about it. + assert.deepEqual(serverConfigWarnings(modern(config)), [], JSON.stringify(config)); } // Empty values grant nothing, on a modern stdio server too. const empty = cfg({ From 2789a9ff80e636bfcaf41b9b8c9a754070e153ff Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Thu, 15 Oct 2026 21:20:53 -0400 Subject: [PATCH 69/94] fix(mcp): a modern server accepts MCPL allow-lists, refusing only enabledCapabilities that name a capability denied by default (R1-3) Ezra-2423's read of the round (room-203 #110138): the R1-3 rule is to refuse MCPL policy on a modern server only where its value grants something, but 05768682 refused both enabled* lists whenever they were non-empty, and the code reads them as allow-lists: - enabledFeatureSets: absent, every declared set is a candidate; present, an allowlist (feature-set-manager.ts). So any list only narrows, as disabledFeatureSets does. `[]` was accepted while `['a']`, wider, was refused. - enabledCapabilities: a mask over what the server advertises, except that a pattern matching a path denied by default grants it (computeGrant's explicitlyEnabled in capability-grant.ts): contextHooks.beforeInference .inject.system, toolLifecycle.observe, toolLifecycle.inputs. So ['pushEvents'] only takes away. My sort in #105372 had gone by what an operator who writes them wants, not by what the code does with them. Ada-1017's call (#110155, #110156) was to change the code, not the rule. - mcplPolicyGrants no longer lists enabledFeatureSets. - enabledCapabilities is listed only where a pattern matches a DENY_BY_DEFAULT path, through capabilityPatternMatches, so the refusal can't drift from what the grant computes. Each such path is named ("enabledCapabilities: toolLifecycle.observe"). A non-array value that would take effect is still refused whole, as before. capability-grant.ts exports DENY_BY_DEFAULT for that. - Since `*` matches exactly one segment, with counts agreeing, ['*'] reaches no path denied by default and is accepted. 'toolLifecycle.*' reaches both tool-lifecycle paths, and '*.*.*.*' the injection path. - The README's modern-server bullet, the changelog fragment and mcplPolicyGrants' doc list the two allow-lists among what's accepted. Tests (mcp-protocol-family): the granting cases name 'enabledCapabilities: toolLifecycle.observe' and the injection path (from 'contextHooks.beforeInference.inject.*'), and ['*.*', '*.*.*.*'] names all three in DENY_BY_DEFAULT's order. The accepted cases add enabledFeatureSets: ['a'], enabledCapabilities: ['pushEvents', 'contextHooks.afterInference'], and ['*']. mcp-modern-framework's refusal at connect now uses allowHostCommands: true. Red-first with src at 023728db: the R1-3 test fails. With the change, on the VPS (Node 24.21.0): mcp-protocol-family 27/27, mcp-modern-framework 18/18 (J11488). Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +++++++++------- changelog.d/modern-mcp-client.added.md | 5 +++-- src/mcpl/capability-grant.ts | 2 +- src/mcpl/protocol-family.ts | 29 +++++++++++++++++++------- test/mcp-modern-framework.test.ts | 4 ++-- test/mcp-protocol-family.test.ts | 18 ++++++++++++++-- 6 files changed, 54 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index c9e8298f2..d177a0495 100644 --- a/README.md +++ b/README.md @@ -301,14 +301,16 @@ shows its `family`, `protocolVersion` and `transport`. Its config differs in three ways: - It has no MCPL surface (grant, push, channels, inference requests, feature sets). A setting that asks for that surface can't apply, so it is a - configuration error: `enabledFeatureSets`, `enabledCapabilities`, - `allowHostCommands: true`, `channelSubscription` other than `'manual'`, a - scope's `whitelist`, a `toolLifecycle` `observe` or `inputs` block, and an - `autofetch` bound above its default. A setting that only takes something - away, or repeats a default, is already satisfied, so it is accepted: - `disabledFeatureSets`, `disabledCapabilities`, a scope's `blacklist`, - `channelSubscription: 'manual'`, `toolLifecycle.maxInputBytes`, an - `autofetch` bound at or below its default, and `shouldTriggerInference`. + configuration error: `allowHostCommands: true`, an `enabledCapabilities` + pattern that names a path denied by default, `channelSubscription` other + than `'manual'`, a scope's `whitelist`, a `toolLifecycle` `observe` or + `inputs` block, and an `autofetch` bound above its default. A setting + that only takes something away, or repeats a default, is already + satisfied, so it is accepted: `enabledFeatureSets` and the rest of + `enabledCapabilities`, which are allow-lists, `disabledFeatureSets`, + `disabledCapabilities`, a scope's `blacklist`, `channelSubscription: + 'manual'`, `toolLifecycle.maxInputBytes`, an `autofetch` bound at or + below its default, and `shouldTriggerInference`. So a host that puts a deny-list on every server it deploys can deploy a modern one. - `requestTimeoutMs` is one deadline per tool call and must be 1 to 2^31−1; diff --git a/changelog.d/modern-mcp-client.added.md b/changelog.d/modern-mcp-client.added.md index 7f708e936..ce56f15ba 100644 --- a/changelog.d/modern-mcp-client.added.md +++ b/changelog.d/modern-mcp-client.added.md @@ -7,8 +7,9 @@ paths as any MCPL server's. It has no MCPL surface: no grant, push, channels, inference requests, quiesce plane or awareness gate. MCPL policy that asks for that surface on one is a configuration error, such as - `enabledFeatureSets` or `allowHostCommands: true`. Policy that only takes - something away or repeats a default, such as `disabledCapabilities`, is + `allowHostCommands: true` or a scope's `whitelist`. Policy that only takes + something away or repeats a default, such as `disabledCapabilities` or an + allow-list like `enabledFeatureSets`, is accepted as already satisfied, so a host that puts a deny-list on every server it deploys can deploy a modern one. A modern `requestTimeoutMs` outside 1..2^31−1 is a configuration error too, since 0 means "time out diff --git a/src/mcpl/capability-grant.ts b/src/mcpl/capability-grant.ts index 728757d1a..ae5f9c292 100644 --- a/src/mcpl/capability-grant.ts +++ b/src/mcpl/capability-grant.ts @@ -223,7 +223,7 @@ export function capabilityPatternMatches(pattern: string, path: string): boolean /** §13.4: denied unless explicitly enabled by host config. RFC-007 adds both * tool-lifecycle leaves: observing other servers' tool use (and, above all, * their arguments) is an operator decision, never a default. */ -const DENY_BY_DEFAULT: readonly string[] = [ +export const DENY_BY_DEFAULT: readonly string[] = [ 'contextHooks.beforeInference.inject.system', 'toolLifecycle.observe', 'toolLifecycle.inputs', diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index 8aaaa2b20..a38b08b42 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -22,6 +22,7 @@ * isn't `initialize`), the SDK's stdio probe launches the server twice, and a * server's era is a stable fact its configuration can state. */ +import { DENY_BY_DEFAULT, capabilityPatternMatches } from './capability-grant.js'; import { DEFAULT_EAGER_FETCH_MAX_BYTES, DEFAULT_MAX_TOTAL_BYTES } from './reference-fetcher.js'; import type { McplServerConfig, ScopeConfig } from './types.js'; @@ -379,13 +380,17 @@ function isPlainRecord(value: unknown): value is Record { /** * The parts of a configuration's MCPL policy whose values grant something, - * by path, such as `allowHostCommands`, `scopes..whitelist` or - * `autofetch.maxBytes`: each asks for behaviour only an MCPL server can have. - * A modern server has none, so such a value can't apply there, and is - * refused rather than ignored, so that an operator who chose it learns so. + * by path, such as `allowHostCommands`, `scopes..whitelist`, + * `enabledCapabilities: toolLifecycle.observe` or `autofetch.maxBytes`: each + * asks for behaviour only an MCPL server can have. A modern server has none, + * so such a value can't apply there, and is refused rather than ignored, so + * that an operator who chose it learns so. * * A value that only takes something away, or repeats a default, is already - * satisfied on a modern server and isn't listed: `disabledFeatureSets`, + * satisfied on a modern server and isn't listed: `enabledFeatureSets`, an + * allow-list over every declared set (`feature-set-manager.ts`); + * `enabledCapabilities` patterns that match no path denied by default, since + * elsewhere it is an allow-list too; `disabledFeatureSets`, * `disabledCapabilities`, a scope's `blacklist`, `channelSubscription: * 'manual'` (the default), `toolLifecycle` with only `maxInputBytes` or * with an `observe` or `inputs` that isn't a policy object (which grants @@ -396,8 +401,18 @@ function isPlainRecord(value: unknown): value is Record { */ export function mcplPolicyGrants(config: McplServerConfig): string[] { const grants: string[] = []; - for (const field of ['enabledFeatureSets', 'enabledCapabilities', 'allowHostCommands'] as const) { - if (isEffective(config[field])) grants.push(field); + if (isEffective(config.allowHostCommands)) grants.push('allowHostCommands'); + // An allow-list, except that a pattern matching a path denied by default + // grants it (computeGrant's explicitlyEnabled), named by the path. + const enabled = config.enabledCapabilities as unknown; + if (Array.isArray(enabled)) { + for (const path of DENY_BY_DEFAULT) { + if (enabled.some((pattern) => typeof pattern !== 'string' || capabilityPatternMatches(pattern, path))) { + grants.push(`enabledCapabilities: ${path}`); + } + } + } else if (isEffective(enabled)) { + grants.push('enabledCapabilities'); } if (config.channelSubscription !== 'manual' && isEffective(config.channelSubscription)) grants.push('channelSubscription'); if (isPlainRecord(config.scopes)) { diff --git a/test/mcp-modern-framework.test.ts b/test/mcp-modern-framework.test.ts index d222330de..8f0378e7f 100644 --- a/test/mcp-modern-framework.test.ts +++ b/test/mcp-modern-framework.test.ts @@ -360,8 +360,8 @@ test('a modern server deployed with a host\'s MCPL deny-list connects, since wha test('MCPL policy on a modern server is a configuration error at connect', async () => { await withModern(async ({ framework }) => { await assert.rejects( - framework.connectMcplServer({ id: 'bad', url: 'https://example.invalid/mcp', enabledFeatureSets: ['x'] }), - /"enabledFeatureSets" asks for MCPL behaviour/, + framework.connectMcplServer({ id: 'bad', url: 'https://example.invalid/mcp', allowHostCommands: true }), + /"allowHostCommands" asks for MCPL behaviour/, ); await assert.rejects( framework.connectMcplServer({ id: 'bad2', url: 'https://example.invalid/mcp', protocol: 'modern' }), diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index fd8a63b11..c1446b462 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -146,8 +146,8 @@ test('MCPL policy on a modern server is refused where its value grants, and acce const modern = (c: Partial) => cfg({ url: 'https://x/mcp', ...c }); // Each value that asks for MCPL behaviour is refused, naming the part that grants. const granting: Array<[Partial, string]> = [ - [{ enabledFeatureSets: ['a'] }, 'enabledFeatureSets'], - [{ enabledCapabilities: ['pushEvents'] }, 'enabledCapabilities'], + [{ enabledCapabilities: ['toolLifecycle.observe'] }, 'enabledCapabilities: toolLifecycle.observe'], + [{ enabledCapabilities: ['contextHooks.beforeInference.inject.*'] }, 'enabledCapabilities: contextHooks.beforeInference.inject.system'], [{ allowHostCommands: true }, 'allowHostCommands'], [{ channelSubscription: 'auto' }, 'channelSubscription'], [{ channelSubscription: ['general'] }, 'channelSubscription'], @@ -157,6 +157,13 @@ test('MCPL policy on a modern server is refused where its value grants, and acce [{ autofetch: { maxBytes: DEFAULT_EAGER_FETCH_MAX_BYTES + 1 } }, 'autofetch.maxBytes'], [{ autofetch: { maxTotalBytes: DEFAULT_MAX_TOTAL_BYTES + 1 } }, 'autofetch.maxTotalBytes'], ]; + // Patterns reaching every path denied by default name each. A `*` matches + // one segment, and counts must agree (capabilityPatternMatches). + assert.deepEqual(mcplPolicyGrants(modern({ enabledCapabilities: ['*.*', '*.*.*.*'] })), [ + 'enabledCapabilities: contextHooks.beforeInference.inject.system', + 'enabledCapabilities: toolLifecycle.observe', + 'enabledCapabilities: toolLifecycle.inputs', + ]); for (const [config, path] of granting) { assert.deepEqual(mcplPolicyGrants(modern(config)), [path], path); const problems = serverConfigProblems(modern(config)); @@ -169,6 +176,13 @@ test('MCPL policy on a modern server is refused where its value grants, and acce // connectome-host's overlay route stamps this on every server it deploys. { disabledCapabilities: ['contextHooks', 'inferenceLifecycle', 'inferenceRequest', 'toolLifecycle'] }, { disabledFeatureSets: ['b'] }, + // Allow-lists: absent, every declared set or advertised capability is a + // candidate, so a list can only narrow, except where it names a path + // denied by default. + { enabledFeatureSets: ['a'] }, + { enabledCapabilities: ['pushEvents', 'contextHooks.afterInference'] }, + // A one-segment `*` reaches no path denied by default. + { enabledCapabilities: ['*'] }, { scopes: { files: { blacklist: ['write:*'] } } }, { channelSubscription: 'manual' }, { toolLifecycle: { maxInputBytes: 1024 } }, From 0ab25e5f4d740d23f45533862f5ec7710308403e Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Thu, 15 Oct 2026 21:23:58 -0400 Subject: [PATCH 70/94] fix(mcp): close() during a modern stdio connect's spawn closes the child it yields Myrtle-2415's read of the merge (room-203 #110184, probe J11484): with c59efc48's await, SpawnerStdioTransport.start() waits for main's async StdioTransport.spawn (#344), but close() closed only `this.line`, which stays null until the spawn settles. A close() in that window, which create()'s doc invites ("a connect in flight is already the owner's to close"), closed nothing. The child then spawned, the SDK's discover probe ran against it, and establish's cleanup reaped it only when the probe timed out. close() took 30,016 ms at c59efc48 against 7 ms at 0fbb71ec. Both reaped the child, so the cost was latency, not a leak. SpawnerStdioTransport keeps the spawn's promise. A close() made before the line is set awaits it, a failed spawn included, and closes the child it yields. start() awaited that promise first, so its continuation has wired the line's close to onclose by then. The close ends what the SDK has in flight, as it does after the spawn. This is Myrtle's suggested fix. Test (mcp-modern-outcomes, 32 -> 33): create(), start(), then close() at once against the raw fixture's hang-discover server. close() must resolve within 5 s, the connection isn't connected, and any child that logged its start is gone. Red-first with src at 2789a9ff: close() took 30,027 ms. With the fix it passes in 494 ms, and mcp-modern-outcomes passes 33/33 (J11497, VPS, Node 24.21.0). Co-Authored-By: Claude Opus 5.5 --- src/mcpl/modern-connection.ts | 9 ++++++++- test/mcp-modern-outcomes.test.ts | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 6f1073a8b..b602389cf 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -258,6 +258,8 @@ class SpawnerStdioTransport implements Transport { onerror?: (error: Error) => void; onmessage?: (message: T, extra?: MessageExtraInfo) => void; private line: McplTransport | null = null; + /** The spawn start() awaits, so a close() made meanwhile closes its child. */ + private spawning: Promise | null = null; /** Built once for the transport: every malformed line uses it. */ private readonly recorded: (text: unknown) => string; @@ -272,7 +274,8 @@ class SpawnerStdioTransport implements Transport { async start(): Promise { // A command that can't be spawned rejects here, with its spawn error, so // the connect fails with it rather than waiting out its discover probe. - const line = await StdioTransport.spawn(this.config); + this.spawning = StdioTransport.spawn(this.config); + const line = await this.spawning; this.line = line; line.on('line', (text: string) => { // A line that isn't JSON is ignored, as the legacy engine ignores it. @@ -305,6 +308,10 @@ class SpawnerStdioTransport implements Transport { } async close(): Promise { + // A close() during the spawn closes the child it yields. start() + // awaited the spawn first, so the line is wired by then, and its close + // ends what the SDK has in flight, as it does after the spawn. + if (!this.line && this.spawning) await this.spawning.catch(() => null); await this.line?.close(); } } diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts index 5d42d37cb..bdc6c803d 100644 --- a/test/mcp-modern-outcomes.test.ts +++ b/test/mcp-modern-outcomes.test.ts @@ -212,6 +212,22 @@ test('a lost transport whose child can\'t be reaped halts reconnecting (R1-1)', assert.equal(wire(log).filter((e) => e.event === 'start').length, 1, 'no second launch'); }); +test('close() right after start() ends a modern stdio connect still spawning, without waiting out its discover probe', async () => { + const log = scratchLog(); + const connection = ModernMcpConnection.create({ id: 'raw', command: process.execPath, args: [SERVER, 'hang-discover', log], protocol: 'modern' }); + const started = connection.start().then(() => null, (error: unknown) => error); + const began = Date.now(); + await connection.close(); + const tookMs = Date.now() - began; + assert.ok(tookMs < 5000, `close() took ${tookMs} ms, not the discover probe's 30 s`); + await started; + assert.equal(connection.isConnected, false); + // The child may have been ended before it could log its start. + for (const launch of wire(log).filter((e) => e.event === 'start')) { + assert.equal(alive(launch.pid!), false, 'the child is gone once close() resolved'); + } +}); + test('close() during a reconnect handshake ends the launch it was waiting on, and stops', async () => { const log = scratchLog(); const connection = await connect('hang-discover-later', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }); From c7eda6ce179c0bea4f4647524872abbc37a1e737 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Thu, 15 Oct 2026 22:13:22 -0400 Subject: [PATCH 71/94] docs(mcp): the modern-server refusal list says exactly which values are refused (R1-3) Ezra-2423's read of #262's body (room-203 #110802) pointed out wordings that said more than the code does: - "`channelSubscription` other than 'manual'" also took in an empty list, which grants nothing and is accepted. The README now says "`channelSubscription: 'auto'` or a non-empty list". - "a scope's `whitelist`" is refused only when the list is non-empty. The README and the changelog fragment now say "a scope's non-empty `whitelist`". - "an `enabledCapabilities` pattern that names a path denied by default" should say what the grant does, which is match one: `*.*` names nothing and reaches two such paths. The README now says "matches a capability denied by default". mcplPolicyGrants already behaves this way. The R1-3 test pins it: its empty case passes channelSubscription: [], and its accepted cases now include a scope whose whitelist is empty. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++++--- changelog.d/modern-mcp-client.added.md | 6 +++--- test/mcp-protocol-family.test.ts | 1 + 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d177a0495..6f374a54d 100644 --- a/README.md +++ b/README.md @@ -302,9 +302,10 @@ three ways: - It has no MCPL surface (grant, push, channels, inference requests, feature sets). A setting that asks for that surface can't apply, so it is a configuration error: `allowHostCommands: true`, an `enabledCapabilities` - pattern that names a path denied by default, `channelSubscription` other - than `'manual'`, a scope's `whitelist`, a `toolLifecycle` `observe` or - `inputs` block, and an `autofetch` bound above its default. A setting + pattern that matches a capability denied by default, `channelSubscription: + 'auto'` or a non-empty list, a scope's non-empty `whitelist`, a + `toolLifecycle` `observe` or `inputs` block, and an `autofetch` bound + above its default. A setting that only takes something away, or repeats a default, is already satisfied, so it is accepted: `enabledFeatureSets` and the rest of `enabledCapabilities`, which are allow-lists, `disabledFeatureSets`, diff --git a/changelog.d/modern-mcp-client.added.md b/changelog.d/modern-mcp-client.added.md index ce56f15ba..0cbc4df94 100644 --- a/changelog.d/modern-mcp-client.added.md +++ b/changelog.d/modern-mcp-client.added.md @@ -7,9 +7,9 @@ paths as any MCPL server's. It has no MCPL surface: no grant, push, channels, inference requests, quiesce plane or awareness gate. MCPL policy that asks for that surface on one is a configuration error, such as - `allowHostCommands: true` or a scope's `whitelist`. Policy that only takes - something away or repeats a default, such as `disabledCapabilities` or an - allow-list like `enabledFeatureSets`, is + `allowHostCommands: true` or a scope's non-empty `whitelist`. Policy that + only takes something away or repeats a default, such as + `disabledCapabilities` or an allow-list like `enabledFeatureSets`, is accepted as already satisfied, so a host that puts a deny-list on every server it deploys can deploy a modern one. A modern `requestTimeoutMs` outside 1..2^31−1 is a configuration error too, since 0 means "time out diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index c1446b462..61efbdd60 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -184,6 +184,7 @@ test('MCPL policy on a modern server is refused where its value grants, and acce // A one-segment `*` reaches no path denied by default. { enabledCapabilities: ['*'] }, { scopes: { files: { blacklist: ['write:*'] } } }, + { scopes: { files: { whitelist: [] } } }, { channelSubscription: 'manual' }, { toolLifecycle: { maxInputBytes: 1024 } }, { toolLifecycle: { observe: false } as never }, From 87d1ae1cd3586f921603ad0ba611174c666cd017 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 13:19:11 -0400 Subject: [PATCH 72/94] fix(mcp): a refresh dropped as older starts one more when it held a listing the published one lacks Greptile's review of #337 at f28dde4f (discussion_r4303614274) found this in #262's code. 1d47151c's guard drops a refresh that a later one has overtaken, so an older listing can't replace a newer one. But the later refresh can lack a listing the earlier one has. Say a connect or disconnect refresh, R1, waits on server A's tools/list while a list-change refresh, R2, starts, and A refuses R2's request quickly. R2 publishes without A's tools. R1's listing of A then succeeds and is dropped. Nothing starts another refresh, so A's tools stay missing until some other event refreshes. Before 1d47151c, the last refresh to finish published, so this case came with the guard. This takes the smaller of Greptile's two answers, one more refresh. The other was keeping successful results from unchanged connections. The guard keeps its job, and the dropped refresh's news isn't lost. - A dropped refresh that listed a server the published listing has none for starts one more through handleToolsListChanged. A refresh already under way collapses with it, and the tools that come back are announced as any list change's are. - A test pins the condition: a dropped refresh whose servers the published one all listed starts nothing. A refresh starts listing as soon as it begins, so the test counts listTools calls right after the dropped refresh returns. Tests (mcp-server-ownership): - New, Greptile's case on a modern server. The first refresh holds its listing of m, a later one meets m's refusal and publishes no listing of m, then the first is dropped, and one more refresh brings m's tools back. - "a server connected while a refresh waits keeps its own tool prefix, and the held refresh publishes nothing" now also checks that the dropped refresh started no other. Red-first, Linux, Node 24.22.0: - With src at c7eda6ce, the new test fails ("in time: one more refresh brings m's tools back"). - With the condition replaced by one that always holds, the connect-while-held test fails ("the dropped refresh started no other"). - With the fix: mcp-server-ownership 22/22, mcp-modern-framework 18/18, mcpl-config-diagnostics 14/14, mcpl-manifest-changed 7/7, checkpoint-attribution 10/10, stop-with-host-operations 58/58. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 11 ++++++-- test/mcp-server-ownership.test.ts | 45 +++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 4fbd02f72..372a544a0 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -18759,8 +18759,15 @@ export class AgentFramework { // A refresh started after this one has published already: it listed // every server after this one did, a server replaced by a restart under - // the same id included, so this one's listings are older. - if (generation < this.mcplToolsRefreshPublished) return; + // the same id included, so this one's listings are older. A server this + // one listed can still have no listing there, such as one that refused + // the later refresh's request. Its tools would stay missing until + // something else refreshed, so one more refresh lists it again. + if (generation < this.mcplToolsRefreshPublished) { + const unlisted = [...listedServers].find((id) => !this.mcplListedServers.has(id)); + if (unlisted !== undefined) this.handleToolsListChanged(unlisted); + return; + } // A server removed while this refresh awaited its lists (a disconnect, // or a server lost for good) publishes nothing: whichever refresh // finishes last must not bring back a removed server's tools. diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index 2c4e1cf08..0e67c399b 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -470,13 +470,18 @@ test("a server connected while a refresh waits keeps its own tool prefix, and th connection.listTools = async () => { const tools = await realList(); listed = true; await gate; return tools; }; const refreshing = internals.refreshMcplTools(); await until(() => listed, "the refresh holds the first server's list"); - connection.listTools = realList; + let lists = 0; + connection.listTools = async () => { lists += 1; return realList(); }; // Connected while the first refresh waits, under a prefix of its own. await framework.connectMcplServer({ ...modern('n', '', join(dir, 'raw-n.jsonl')), toolPrefix: 'custom' }); // The held refresh finishes last, and publishes nothing: the connect's - // own refresh, started after it, listed both servers. + // own refresh, started after it, listed both servers. So it starts no + // other refresh either, since that one has a listing of every server it + // listed (a refresh begins listing at once). + const listsBefore = lists; release(); await refreshing; + assert.equal(lists, listsBefore, 'the dropped refresh started no other'); const names = framework.getAllTools().map((t) => t.name); assert.ok(names.some((n) => n.startsWith('custom--')), names.join(', ')); assert.ok(!names.some((n) => n.startsWith('mcpl--n--')), names.join(', ')); @@ -510,6 +515,42 @@ test("a refresh in flight when a server restarts under the same id publishes non }); }); +test('a server whose listing a later refresh failed is listed again when the earlier listing is dropped', async () => { + await withFramework(async ({ framework, dir }) => { + await framework.connectMcplServer(modern('m', '', join(dir, 'raw.jsonl'))); + assert.ok(toolsOf(framework, 'm').length > 0); + const internals = framework as unknown as { + modernServers(): Map }>; + refreshMcplTools(): Promise; + }; + const connection = internals.modernServers().get('m')!; + const realList = connection.listTools.bind(connection); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let mode: 'hold' | 'refuse' | 'answer' = 'hold'; + let held = false; + connection.listTools = async () => { + if (mode === 'refuse') throw new Error('refused while another listing is open'); + const tools = await realList(); + if (mode === 'hold') { held = true; await gate; } + return tools; + }; + // The first refresh lists m and holds the answer. + const first = internals.refreshMcplTools(); + await until(() => held, 'the first refresh holds its listing of m'); + // A later refresh, whose request m refuses, publishes first, without m. + mode = 'refuse'; + await internals.refreshMcplTools(); + assert.deepEqual(toolsOf(framework, 'm'), [], "the later refresh published no listing of m"); + // The first refresh's listing is older, so it's dropped, and one more + // refresh lists m again. + mode = 'answer'; + release(); + await first; + await until(() => toolsOf(framework, 'm').length > 0, "one more refresh brings m's tools back"); + }); +}); + test('a server lost for good, legacy or modern, leaves the tool list', async () => { await withFramework(async ({ framework, dir }) => { await framework.connectMcplServer(legacy('l', 'ok', join(dir, 'legacy.log'))); From d1e6e16df5bf376daa7e58026b0fb7b2ed973279 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 13:31:38 -0400 Subject: [PATCH 73/94] fix(mcp): a listing begun before its server's session closed publishes nothing, so its class hints stay dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile's review of #262 at c7eda6ce (discussion_r4303661234). Say a refresh lists server A and waits on B, and A's session closes with reconnect set. The close handler drops A's RFC-008 class hints as stale, which leaves its tools unclassed until the next tools/list, the restrictive answer (RFC-007 §4.3). But the refresh's final check asked only whether A's id was still registered, so it published the closed session's hints again. Until another listing, observers got arguments under an old class such as `files` that the unclassed state would withhold. Greptile's own probe showed it through openingFor. The rule: a refresh publishes a server's listing only while the session it listed is still that server's live one. - The framework counts each server's closes (mcplServerCloses) in both engines' close handlers, where the hints are dropped. A refresh notes the count as each listing begins. A listing whose server has closed since counts no more than one whose server is gone. The refresh leaves its tools, feature sets, class hints and its place among the listed servers out of what it publishes. - That replaces the check for removed ids alone. Taken from the listed servers rather than from the servers whose tools were listed, it also leaves out a removed server that listed no tools, which the old check kept among the listed. It now runs before the guard for an overtaken refresh, so the guard's extra refresh asks only about listings that count. - The server's own reconnect lists its new session, since both engines' reconnect handlers refresh (handleToolsListChanged). So no other refresh starts for a listing left out this way. - A server whose listing is left out this way has no tools in that publication until its reconnect lists it. A failed listing has always meant that. Tests (mcp-server-ownership), for each engine, on a server with reconnect set and a reconnect too slow to come during the test. The refresh holds a listing that declares the tools `files`, the server's session ends (its die tool), and once the refresh publishes, no tool of that server is classed `files`. Red-first, Linux, Node 24.22.0: - With src at the commit before, both new tests fail ("no tool keeps the closed session's class"). - Without the legacy close handler's count, the legacy test fails. Without the modern one's, the modern test fails. Without the closes condition in the check, both fail. - The extra refresh's two pieces still fail their own tests. Without its trigger, the dropped-listing test fails, and with a condition that always holds, the connect-while-held test fails. - With the change: mcp-server-ownership 24/24, mcp-modern-framework 18/18, mcpl-tool-class-listing 4/4, mcpl-tool-classes 9/9, mcpl-tool-lifecycle 31/31, mcpl-config-diagnostics 14/14, mcpl-manifest-changed 7/7, checkpoint-attribution 10/10, stop-with-host-operations 58/58, mcpl-awareness-barrier 29/29. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 66 +++++++++++++++++++++---------- test/mcp-server-ownership.test.ts | 40 +++++++++++++++++++ 2 files changed, 85 insertions(+), 21 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 372a544a0..3aa973a5b 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2041,6 +2041,10 @@ export class AgentFramework { * nothing, since that one listed every server after it. */ private mcplToolsRefreshStarted = 0; private mcplToolsRefreshPublished = 0; + /** How many times each server's connection has closed. A listing that + * began before its server's latest close is of a session that's over, + * and refreshMcplTools publishes none of it. */ + private mcplServerCloses: Map = new Map(); /** Zero-match tool-pattern diagnostics (checkToolPatterns): set at the end * of create(), when the tool universe is first complete. */ private toolPatternChecksArmed = false; @@ -15292,6 +15296,13 @@ export class AgentFramework { this.store.appendToStateJson(PROCESS_LOG_ID, entryToStore); } + /** Count one server's close (see mcplServerCloses). Partially-constructed + * frameworks (tests build them with Object.create) have no map yet. */ + private noteMcplServerClosed(serverId: string): void { + this.mcplServerCloses ??= new Map(); + this.mcplServerCloses.set(serverId, (this.mcplServerCloses.get(serverId) ?? 0) + 1); + } + /** Forget one server's RFC-008 class declarations (see the close handler). */ private dropMcplToolClasses(serverId: string): void { // Tolerate partially-constructed frameworks (tests build them with @@ -18569,7 +18580,10 @@ export class AgentFramework { this.featureSetManager?.removeServer(connection.id); // RFC-008: a closed provider's class hints are stale — on reconnect it // may class its tools differently. Until the next tools/list its tools - // are unclassed, the restrictive answer (RFC-007 §4.3). + // are unclassed, the restrictive answer (RFC-007 §4.3). A listing + // already under way is of the closed session, so it doesn't count as + // that next one (see refreshMcplTools). + this.noteMcplServerClosed(connection.id); this.dropMcplToolClasses(connection.id); this.emitTrace({ type: 'mcpl:server-closed', @@ -18629,7 +18643,9 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:server-error', serverId: connection.id, error: recorded(err.message) }); }); connection.on('close', () => { - // A closed provider's class hints are stale (RFC-008), as for MCPL. + // A closed provider's class hints are stale (RFC-008), as for MCPL, + // and so is a listing already under way. + this.noteMcplServerClosed(connection.id); this.dropMcplToolClasses(connection.id); this.emitTrace({ type: 'mcpl:server-closed', @@ -18675,10 +18691,14 @@ export class AgentFramework { const toolClasses = new Map(); const toolServers = new Map(); const listedServers = new Set(); + const closesOf = (serverId: string): number => this.mcplServerCloses?.get(serverId) ?? 0; + // Each server's close count as its listing began. + const closesAtListing = new Map(); for (const server of [...this.mcplServerRegistry.getAllServers()]) { const config = this.mcplServerConfigs.get(server.id); const prefix = config?.toolPrefix ?? `mcpl--${server.id}`; + closesAtListing.set(server.id, closesOf(server.id)); try { const result = await server.sendToolsList(); listedServers.add(server.id); @@ -18729,6 +18749,7 @@ export class AgentFramework { if (!server.isConnected) continue; const config = this.mcplServerConfigs.get(server.id); const prefix = config?.toolPrefix ?? `mcpl--${server.id}`; + closesAtListing.set(server.id, closesOf(server.id)); try { const listed = await server.listTools(); listedServers.add(server.id); @@ -18757,6 +18778,27 @@ export class AgentFramework { } } + // A listing counts only while the session it came from is still its + // server's live one. A server removed while this refresh awaited its + // lists (a disconnect, or a server lost for good) contributes nothing, + // so whichever refresh finishes last can't bring back its tools. Nor + // does one whose connection closed after its listing began: the close + // forgot its class hints as stale (RFC-008), and its reconnect lists the + // new session. Asked of the same listing the refresh began from, now. + const live = new Set([ + ...(this.mcplServerRegistry?.getAllServers() ?? []).map((server) => server.id), + ...this.modernServers().keys(), + ]); + const over = new Set([...listedServers].filter((serverId) => !live.has(serverId) || closesOf(serverId) !== closesAtListing.get(serverId))); + if (over.size > 0) { + for (const [name, serverId] of [...toolServers]) { + if (!over.has(serverId)) continue; + toolServers.delete(name); + toolFeatureSets.delete(name); + toolClasses.delete(name); + } + for (const serverId of over) listedServers.delete(serverId); + } // A refresh started after this one has published already: it listed // every server after this one did, a server replaced by a restart under // the same id included, so this one's listings are older. A server this @@ -18768,26 +18810,8 @@ export class AgentFramework { if (unlisted !== undefined) this.handleToolsListChanged(unlisted); return; } - // A server removed while this refresh awaited its lists (a disconnect, - // or a server lost for good) publishes nothing: whichever refresh - // finishes last must not bring back a removed server's tools. - // Asked of the same listing the refresh began from, now. - const live = new Set([ - ...(this.mcplServerRegistry?.getAllServers() ?? []).map((server) => server.id), - ...this.modernServers().keys(), - ]); - const gone = new Set([...new Set(toolServers.values())].filter((serverId) => !live.has(serverId))); - if (gone.size > 0) { - for (const [name, serverId] of [...toolServers]) { - if (!gone.has(serverId)) continue; - toolServers.delete(name); - toolFeatureSets.delete(name); - toolClasses.delete(name); - } - for (const serverId of gone) listedServers.delete(serverId); - } this.mcplToolsRefreshPublished = generation; - this.mcplTools = gone.size > 0 ? tools.filter((tool) => toolServers.has(tool.name)) : tools; + this.mcplTools = over.size > 0 ? tools.filter((tool) => toolServers.has(tool.name)) : tools; this.mcplToolFeatureSets = toolFeatureSets; this.mcplToolClasses = toolClasses; this.mcplToolServers = toolServers; diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index 0e67c399b..231a49bda 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -551,6 +551,46 @@ test('a server whose listing a later refresh failed is listed again when the ear }); }); +for (const engine of ['legacy', 'modern'] as const) { + test(`${engine}: a listing begun before its server's session closed doesn't bring back the class hints the close dropped`, async () => { + await withFramework(async ({ framework, dir }) => { + const base = engine === 'legacy' ? legacy('s', 'ok', join(dir, 'legacy.log')) : modern('s', '', join(dir, 'raw.jsonl')); + // It reconnects, but not before the test is over. + await framework.connectMcplServer({ ...base, reconnect: true, reconnectIntervalMs: 60_000, reconnectMaxIntervalMs: 60_000 }); + type Tool = { name: string; _meta?: Record }; + type Server = { on(event: 'close', listener: () => void): void; sendToolsList(): Promise<{ tools: Tool[] }>; listTools(): Promise }; + const internals = framework as unknown as { + mcplServerRegistry: { getServer(id: string): Server }; + modernServers(): Map; + refreshMcplTools(): Promise; + }; + const server = engine === 'legacy' ? internals.mcplServerRegistry.getServer('s') : internals.modernServers().get('s')!; + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let held = false; + // The session about to close declares its tools `files`. + const classed = (tools: Tool[]): Tool[] => tools.map((tool) => ({ ...tool, _meta: { ...tool._meta, 'mcpl/class': ['files'] } })); + if (engine === 'legacy') { + const real = server.sendToolsList.bind(server); + server.sendToolsList = async () => { const result = await real(); held = true; await gate; return { ...result, tools: classed(result.tools) }; }; + } else { + const real = server.listTools.bind(server); + server.listTools = async () => { const tools = classed(await real()); held = true; await gate; return tools; }; + } + const refreshing = internals.refreshMcplTools(); + await until(() => held, 'the refresh holds its listing'); + let closed = false; + server.on('close', () => { closed = true; }); + await framework.executeToolCall(call('mcpl--s--die')).catch(() => {}); + await until(() => closed, 'the session closes, and the server waits to reconnect'); + release(); + await refreshing; + const kept = framework.listToolClasses().filter((tool) => tool.serverId === 's' && tool.class.includes('files')); + assert.deepEqual(kept, [], "no tool keeps the closed session's class"); + }); + }); +} + test('a server lost for good, legacy or modern, leaves the tool list', async () => { await withFramework(async ({ framework, dir }) => { await framework.connectMcplServer(legacy('l', 'ok', join(dir, 'legacy.log'))); From 6d5962f1d932c4824ad18ea3684866d1badc377b Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 13:37:57 -0400 Subject: [PATCH 74/94] fix(mcp): a connect whose server is disconnected while its tools are listed fails, as a restart's does Greptile's review of #262 at c7eda6ce (discussion_r4303661227). connectMcplServerInternal releases its hold on the id once the connect's setup is done, and the connect then lists tools, checking only whether stop() began. A disconnect during that listing isn't held off. It can remove the new connection whole while the listing waits on another server, and the connect then resolved as if it had connected. A restart did the same after the disconnect had ended it (claim.endedBy), rather than saying the server is down. After the listing, the connect now stands only while the connection it set up is still the one its id has, and no disconnect of the id has begun. - connectHeldMcplServer hands back the connection it registered, at each of its three successes, and connectMcplServerInternal passes it on. - Otherwise the connect fails with 'MCP server "m" was not connected: it was disconnected or lost while its tools were being listed'. A restart's catch reports it as its connect's failure ("is down: ..."). A disconnect sets claim.endedBy and takes its hold synchronously, so a restart needs nothing more. The same check catches a server lost for good during the listing, since the registry has dropped it. - The check comes at the end, rather than the hold lasting through the listing. A hold would keep a disconnect waiting on every server's listing, and the listing publishes nothing of a removed server anyway. Tests (mcp-server-ownership, with a helper that holds a server's listings): - A disconnect removes m while m's connect lists a's tools. The connect rejects with that message. - A disconnect is still under way when the listing ends (m ignores SIGTERM, so its teardown holds the id for about 2 s with m registered). The connect rejects, the disconnect hasn't finished, and m is still registered. Red-first, Linux, Node 24.22.0: - With src at the commit before, both tests fail ("Missing expected rejection."). - Without the check of the id's connection, the first test fails. Without the check of a disconnect under way, the second does. - With the change: mcp-server-ownership 26/26, mcp-modern-framework 18/18, mcp-modern-connection 10/10, mcpl-config-diagnostics 14/14, stop-with-host-operations 58/58, mcpl-awareness-barrier 29/29, mcpl-tool-lifecycle 31/31, checkpoint-attribution 10/10, mcpl-manifest-changed 7/7. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 25 +++++++++++----- test/mcp-server-ownership.test.ts | 48 +++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 3aa973a5b..0a1bdd531 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -17124,7 +17124,7 @@ export class AgentFramework { config: import('./mcpl/types.js').McplServerConfig, deferAwareness = false, claim?: McpRestartClaim, - ): Promise { + ): Promise { if (this.stopHasBegun()) { throw new Error(`MCP server "${config.id}" was not connected: the framework is stopping or stopped`); } @@ -17143,20 +17143,21 @@ export class AgentFramework { if (taken) throw new Error(taken); const hold = this.holdMcpServerId(config.id, 'connect'); try { - await this.connectHeldMcplServer(config, family, deferAwareness, hold.displaced); + return await this.connectHeldMcplServer(config, family, deferAwareness, hold.displaced); } finally { hold.release(); } } /** The part of a connect that runs holding its id. `displaced`: a - * disconnect has taken the id over and is waiting for this connect. */ + * disconnect has taken the id over and is waiting for this connect. It + * hands back the connection it registered. */ private async connectHeldMcplServer( config: import('./mcpl/types.js').McplServerConfig, family: 'legacy' | 'modern', deferAwareness: boolean, displaced: () => boolean, - ): Promise { + ): Promise { if (!this.mcplServerRegistry || !this.mcplHostCapabilities) { throw new Error('MCPL subsystem is not initialized'); } @@ -17191,7 +17192,7 @@ export class AgentFramework { } if (!failure && !modern.isClosed) { this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` }); - return; + return modern; } // It failed, or stop() or a disconnect closed it while connecting: it // never serves. The connect error is the one to report. @@ -17303,7 +17304,7 @@ export class AgentFramework { // grant now, before staging ever releases. await this.registerMcplServerFeatures(config, connection); this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` }); - return; + return connection; } const awarenessBarrier = this.installMcplDataPlaneGate(); @@ -17336,6 +17337,7 @@ export class AgentFramework { this.completeMcplDataPlaneGate(awarenessBarrier); this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` }); + return connection; } /** Per-connection §17.8 fetch limiter: at most one in-flight fetch, at @@ -17788,7 +17790,7 @@ export class AgentFramework { if (collision) throw new Error(collision); const oldToolNames = new Set(this.mcplTools.map(t => t.name)); - await this.connectMcplServerInternal(config, false, claim); + const connection = await this.connectMcplServerInternal(config, false, claim); // A stop() that began during the connect's setup or its tool listing // ends the connect: it doesn't wait on a server for a framework that is // stopping, and the teardown closes what the connect set up. @@ -17796,6 +17798,15 @@ export class AgentFramework { if (this.stopHasBegun()) throw stopped(); await this.untilStopBegins(this.refreshMcplTools()); if (this.stopHasBegun()) throw stopped(); + // The connect let go of its id once its setup was done, so a disconnect + // during the listing wasn't held off. One that has begun, or has removed + // what the connect set up, ends the connect, as one during its setup + // does: what it set up is going or gone. So does a server lost for good + // meanwhile. A restart reports it as its connect's failure. + const current = this.modernServers().get(config.id) ?? this.mcplServerRegistry?.getServer(config.id); + if (current !== connection || this.mcpServerOps?.get(config.id)?.kind === 'disconnect') { + throw new Error(`MCP server "${config.id}" was not connected: it was disconnected or lost while its tools were being listed`); + } this.emitMcplToolDiff(oldToolNames, config.id); } diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index 231a49bda..f3a526e2e 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -591,6 +591,54 @@ for (const engine of ['legacy', 'modern'] as const) { }); } +/** Hold modern server `id`'s listings once they've answered, until release(). */ +function holdListings(framework: AgentFramework, id: string): { held: () => number; release: () => void } { + const connection = (framework as unknown as { modernServers(): Map }> }).modernServers().get(id)!; + const realList = connection.listTools.bind(connection); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let held = 0; + connection.listTools = async () => { const tools = await realList(); held += 1; await gate; return tools; }; + return { held: () => held, release }; +} + +test("a disconnect during a connect's tool listing ends the connect", async () => { + await withFramework(async ({ framework, dir }) => { + await framework.connectMcplServer(modern('a', '', join(dir, 'raw-a.jsonl'))); + const a = holdListings(framework, 'a'); + const servers = (framework as unknown as { modernServers(): Map }).modernServers(); + const connecting = framework.connectMcplServer(modern('m', '', join(dir, 'raw-m.jsonl'))); + await until(() => a.held() > 0, "m's connect, listing tools, waits on a"); + // The disconnect removes m whole while that listing waits. Its own + // refresh then waits on a too, so it's awaited once a answers. + const disconnecting = framework.disconnectMcplServer('m'); + await until(() => !servers.has('m'), 'm is removed'); + a.release(); + await assert.rejects(connecting, /MCP server "m" was not connected: it was disconnected or lost while its tools were being listed/); + await disconnecting; + }); +}); + +test("a disconnect still under way when a connect's tool listing ends fails the connect", async () => { + await withFramework(async ({ framework, dir }) => { + await framework.connectMcplServer(modern('a', '', join(dir, 'raw-a.jsonl'))); + const a = holdListings(framework, 'a'); + const servers = (framework as unknown as { modernServers(): Map }).modernServers(); + // m ignores SIGTERM, so its disconnect stays in flight for ~2 s, holding + // the id, with m still registered. + const connecting = framework.connectMcplServer(modern('m', 'ignore-sigterm', join(dir, 'raw-m.jsonl'))); + await until(() => a.held() > 0, "m's connect, listing tools, waits on a"); + const m = servers.get('m'); + let disconnected = false; + const disconnecting = framework.disconnectMcplServer('m').then(() => { disconnected = true; }); + a.release(); + await assert.rejects(connecting, /MCP server "m" was not connected: it was disconnected or lost while its tools were being listed/); + assert.equal(disconnected, false, 'the disconnect was still under way'); + assert.equal(servers.get('m'), m, 'with m still registered'); + await disconnecting; + }); +}); + test('a server lost for good, legacy or modern, leaves the tool list', async () => { await withFramework(async ({ framework, dir }) => { await framework.connectMcplServer(legacy('l', 'ok', join(dir, 'legacy.log'))); From efa07806bf32ad470e5606500627683c382f6a31 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 13:37:57 -0400 Subject: [PATCH 75/94] fix(mcp): a connection's credential patterns come from what each dial sends: the query as re-encoded, and an access provider's token Greptile's review of #262 at c7eda6ce (discussion_r4303661216). buildWebSocketUrl sets the token with searchParams.set, so URLSearchParams writes the whole query again, form-encoded. A configured ?password=secret%20key123 goes out as password=secret+key123. serverCredentialPatterns derived patterns from the query as configured and as decoded, and neither matches the + form. So a server that echoes the URL it was dialed with, in a close reason say, left the password in tool failures and traces. A sibling, the same redactor's gap: an access provider's token goes into the WebSocket url, or the HTTP Authorization header, at each dial, while the patterns came from the configuration alone. That was documented as outside the redaction. The rule: a connection's patterns come from what each dial actually sends. The configured form, URLSearchParams' re-encoding and an access provider's token are then one case. Patterns from the configured query stay only if something quotes the configured url before any dial. Nothing does: the framework shows a url only through redactUrl, and a url that doesn't parse isn't echoed. - urlAsDialed (protocol-family) is the one definition of the url a dial sends. A WebSocket dial sets the token as the `token` parameter, and an http(s) dial sends the url as configured. buildWebSocketUrl uses it. - The configuration's url patterns come from its url as the dial sends it, with the configuration's own token. - Each dial notes what it sent beyond that (noteDialSent), keyed by the configuration object the dial read, which is the one the framework records the server's text with. The WebSocket dial notes the url it dialed, and the HTTP bearer notes a token an access provider handed it. The patterns cover each url noted, as a url, and each token noted, as a token. Only the newest 16 of each are kept, since a provider can hand each dial a new one. - A connection's redactor, built once, is built again once a dial has noted something new. - redactServerText's doc says what's covered now. A credential neither the configuration holds nor a dial sent, such as another origin's, is still outside it. Tests (mcp-protocol-family): - With ?password=secret%20key123 and a token, the dial's url carries password=secret+key123, and the text quoting it loses the password and the token. - An access provider's token is removed once a dial has sent it, from a WebSocket url's query and from an HTTP Authorization header. The WebSocket server's redactor is made before the dial, as the framework makes a connection's, and it removes the token only after the dial. - After 17 tokens noted, the oldest is let go and the rest are removed. Red-first, Linux, Node 24.22.0: - With src at the commit before, the re-encoded query test fails. - Each piece removed on its own fails a test: the dial's form of the configured url, the WebSocket dial's note, the HTTP bearer's note, the redactor rebuilt after a dial, and the cap. - With the change: mcp-protocol-family 30/30, mcp-modern-connection 10/10, mcp-modern-framework 18/18, mcp-modern-outcomes 33/33, mcpl-transport-close 9/9, mcp-server-ownership 26/26, mcpl-config-diagnostics 14/14. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/modern-connection.ts | 9 ++- src/mcpl/protocol-family.ts | 109 +++++++++++++++++++++++++------ src/mcpl/transport.ts | 10 +-- test/mcp-protocol-family.test.ts | 36 +++++++++- 4 files changed, 135 insertions(+), 29 deletions(-) diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index b602389cf..f7a5a64c4 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -65,7 +65,7 @@ import { import { StdioTransport, type McplTransport } from './transport.js'; import { McplProtocolVersionError, McplRequestError, McplUnreapedLaunchError } from './server-connection.js'; -import { LEGACY_MCP_PROTOCOL_VERSION, MAX_TIMER_MS, MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, describeCredentialError, redactServerText, serverTextRedactor } from './protocol-family.js'; +import { LEGACY_MCP_PROTOCOL_VERSION, MAX_TIMER_MS, MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, describeCredentialError, noteDialSent, redactServerText, serverTextRedactor } from './protocol-family.js'; import type { McplServerConfig, McpToolDefinition } from './types.js'; const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; @@ -326,7 +326,12 @@ function bearerAuth(config: McplServerConfig): AuthProvider | undefined { if (provider) { try { const fresh = await provider(); - if (fresh) return fresh; + if (fresh) { + // Sent in the Authorization header: the server's text is recorded + // with it replaced. + noteDialSent(config, { token: fresh }); + return fresh; + } } catch (error) { // Same fallback as the WebSocket dial: use the static token, and let // the server refuse a stale credential loudly. The provider's own diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index a38b08b42..eb7e9f532 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -94,17 +94,21 @@ export function redactUrl(url: string | undefined): string { /** * Server and transport text as the framework records it, with the strings a - * server's configuration knows to be credentials replaced: - * - from the url, as written and decoded: credential-like path segments and + * server's configuration knows to be credentials replaced, and those its + * dials sent: + * - from the url as each dial sends it ({@link urlAsDialed}), and decoded: + * the configured one, and each one dialed with an access provider's token + * ({@link noteDialSent}): credential-like path segments and * query names, credential-shaped or credential-named query values, every * other non-empty parameter as its whole name=value, and a ws:// url's * username and password, its userinfo as one unit before its @ (the * username alone when there's no password), with no length floor, so a * short password goes with its username and a short username alone goes * too, and the Basic credential a WebSocket client sends for that userinfo; - * - the static token, raw, as JSON encoders write it (JSON.stringify's - * escapes, and with its slashes escaped too, as some encoders write them), - * and percent- or form-encoded with either case of hex; + * - the static token, and each token an access provider handed a dial for + * a header, raw, as JSON encoders write it (JSON.stringify's escapes, and + * with its slashes escaped too, as some encoders write them), and percent- + * or form-encoded with either case of hex; * - a stdio server's environment values whose names say they're * credentials, raw, as JSON encoders write them, and percent-encoded with * either case of hex. @@ -117,11 +121,10 @@ export function redactUrl(url: string | undefined): string { * included, and the replacement is never scanned again; text that may come * from several servers takes one pass over all their configurations' * strings (serverCredentialPatterns), so strings of different servers that - * overlap are both replaced whole. A credential the - * configuration doesn't hold, such as the token an access provider fetches - * or another origin's, is outside it, and so are other encodings of the - * ones it holds. JSON-RPC answers, and the error a host's own connect - * raises, pass as they are. + * overlap are both replaced whole. A credential neither the configuration + * holds nor a dial of it sent, such as another origin's, is outside it, and + * so are other encodings of the ones it holds. JSON-RPC answers, and the + * error a host's own connect raises, pass as they are. */ export function redactServerText(text: unknown, config: Pick): string { return serverTextRedactor(config)(text); @@ -129,10 +132,62 @@ export function redactServerText(text: unknown, config: Pick): (text: unknown) => string { - return credentialRedactor(serverCredentialPatterns(config)); + let built = -1; + let redact: (text: unknown) => string = asText; + return (text) => { + const version = dialsSent.get(config)?.version ?? 0; + if (version !== built) { + redact = credentialRedactor(serverCredentialPatterns(config)); + built = version; + } + return redact(text); + }; +} + +/** + * What a configuration's dials sent that the configuration doesn't hold: the + * urls dialed with an access provider's token, and the provider's tokens sent + * in a header. Kept by the configuration object the dial read, which is the + * one the framework records the server's text with. A provider can hand each + * dial a new token, so only the newest are kept. + */ +const dialsSent = new WeakMap(); +const DIAL_SENT_KEPT = 16; + +/** Note what one dial sent: the url it dialed, or a token it sent in a + * header. That configuration's patterns cover it from then on. */ +export function noteDialSent(config: object, sent: { readonly url?: string; readonly token?: string }): void { + let entry = dialsSent.get(config); + if (!entry) { + entry = { urls: [], tokens: [], version: 0 }; + dialsSent.set(config, entry); + } + const kept = entry; + const keep = (list: string[], value: string | undefined): void => { + if (!value || list.includes(value)) return; + list.push(value); + if (list.length > DIAL_SENT_KEPT) list.shift(); + kept.version += 1; + }; + keep(kept.urls, sent.url); + keep(kept.tokens, sent.token); +} + +/** + * The url a dial sends. A WebSocket dial sets the token as the `token` + * parameter, and setting it makes URLSearchParams write the whole query + * again, form-encoded (a space as `+`), so the url as sent can differ from + * the url as configured. An http(s) dial sends the url as configured. + */ +export function urlAsDialed(url: URL, token: string | null | undefined): URL { + const dialed = new URL(url); + if (token && (dialed.protocol === 'ws:' || dialed.protocol === 'wss:')) dialed.searchParams.set('token', token); + return dialed; } /** A string {@link redactServerText} replaces, and the pattern that finds it. */ @@ -179,13 +234,15 @@ export function serverCredentialPatterns(config: Pick { + withEncoded(token); + const formEncoded = new URLSearchParams({ t: token }).toString().slice(2); + standalone(formEncoded); + standalone(lowerEscapes(formEncoded)); + }; + addToken(config.token ?? ''); // A stdio server's environment values whose names say they carry a // credential: a part of the name that ends with a credential word // (GITHUB_TOKEN, API_KEY, PGPASSWORD, ACCESSTOKEN), and not one where the @@ -195,9 +252,9 @@ export function serverCredentialPatterns(config: Pick CREDENTIAL_NAME_PART.test(part))) withEncoded(value); } - let url: URL | undefined; - try { url = new URL(config.url ?? ''); } catch { /* Still remove the static token. */ } - if (url) { + // A url as a dial sends it. Nothing quotes the configured url before a + // dial, so its form as configured needs no strings of its own. + const addUrl = (url: URL): void => { for (const segment of url.pathname.split('/')) { if (credentialLikeSegment(segment)) { standalone(segment); @@ -242,7 +299,17 @@ export function serverCredentialPatterns(config: Pick value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return [ ...[...secrets].map(value => ({ value, source: escape(value) })), diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index c3f112530..475b14db3 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -22,7 +22,7 @@ import { EventEmitter } from 'node:events'; import WebSocket from 'ws'; import type { McplServerConfig } from './types.js'; -import { describeCredentialError, redactUrl, resolveServerBinding } from './protocol-family.js'; +import { describeCredentialError, noteDialSent, redactUrl, resolveServerBinding, urlAsDialed } from './protocol-family.js'; /** Why/how a transport closed. `code`/`signal` are populated for stdio child * exits; `reason` carries a WebSocket close reason or an error message. */ @@ -367,6 +367,9 @@ export class WebSocketTransport extends McplTransport { } } const url = buildWebSocketUrl(config, token); + // A provider's token is in this url, and the query may be re-encoded: + // the server's text is recorded with what the dial sent. + noteDialSent(config, { url }); // Error strings travel: traces, logs, tool results the model reads. They // get the endpoint, never the credential. const redacted = redactUrl(config.url); @@ -462,10 +465,7 @@ export function buildWebSocketUrl(config: McplServerConfig, tokenOverride?: stri ); } const token = tokenOverride !== undefined ? tokenOverride : config.token; - if (token) { - parsed.searchParams.set('token', token); - } - return parsed.toString(); + return urlAsDialed(parsed, token).toString(); } // --------------------------------------------------------------------------- diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index 61efbdd60..caec2cd5e 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -16,13 +16,15 @@ import { redactServerText, credentialRedactor, serverCredentialPatterns, + serverTextRedactor, + noteDialSent, describeCredentialError, mcplPolicyGrants, MAX_TIMER_MS, } from '../src/mcpl/protocol-family.js'; import { DEFAULT_EAGER_FETCH_MAX_BYTES, DEFAULT_MAX_TOTAL_BYTES } from '../src/mcpl/reference-fetcher.js'; import { ModernMcpConnection } from '../src/mcpl/modern-connection.js'; -import { WebSocketTransport } from '../src/mcpl/transport.js'; +import { WebSocketTransport, buildWebSocketUrl } from '../src/mcpl/transport.js'; import type { McplServerConfig } from '../src/mcpl/types.js'; const cfg = (c: Partial): McplServerConfig => ({ id: 's', ...c }); @@ -338,6 +340,38 @@ test('both engines admit a configuration through checkServerConfig, which logs i } }); +test("a query credential is removed as the WebSocket dial sends it, the query re-encoded once the token is set", () => { + const config = cfg({ url: 'wss://host.example/mcp?password=secret%20key123', token: 'tok-abcdefgh' }); + const sent = buildWebSocketUrl(config); + // The form the server receives, and can echo, as URLSearchParams writes it. + assert.ok(sent.includes('password=secret+key123'), sent); + const shown = redactServerText(`closed by the server: ${sent}`, config); + assert.ok(!shown.includes('key123'), shown); + assert.ok(!shown.includes('tok-abcdefgh'), shown); +}); + +test("an access provider's token is removed once a dial has sent it, in a WebSocket url or an HTTP header", async () => { + const ws = cfg({ id: 'ws', url: 'ws://127.0.0.1:1/mcpl?password=secret%20key123', accessProvider: async () => 'prov-ws-0123456789' }); + const http = cfg({ id: 'http', url: 'http://127.0.0.1:1/mcp', accessProvider: async () => 'prov-http-0123456789' }); + // Made before the dial, as the framework makes a connection's. + const recorded = serverTextRedactor(ws); + assert.ok(recorded('token=prov-ws-0123456789').includes('prov-ws'), 'no dial has sent it yet'); + // Nothing listens on port 1: each dial fails after sending its credential. + await assert.rejects(WebSocketTransport.open(ws)); + await assert.rejects(ModernMcpConnection.connect(http)); + const shown = recorded('closed: ws://127.0.0.1:1/mcpl?password=secret+key123&token=prov-ws-0123456789'); + assert.ok(!shown.includes('prov-ws') && !shown.includes('key123'), shown); + const header = redactServerText('refused: Authorization: Bearer prov-http-0123456789', http); + assert.ok(!header.includes('prov-http'), header); +}); + +test("a configuration keeps only its dials' newest provider tokens", () => { + const config = cfg({ id: 'p', url: 'http://127.0.0.1:1/mcp' }); + for (let n = 0; n <= 16; n++) noteDialSent(config, { token: `prov-token-${String(n).padStart(4, '0')}` }); + assert.ok(redactServerText('prov-token-0000', config).includes('prov-token-0000'), 'the oldest of 17 is let go'); + assert.equal(redactServerText('prov-token-0001 prov-token-0016', config), 'redacted redacted'); +}); + test('recorded server text removes encoded and decoded URL credentials as exact strings', () => { const config = cfg({ url: 'https://h/api/s/secret%2B0123456789/mcp?password=p%2Bassw0rd%21&bare%2Bsecret0123456789&plain=value0123456789abcdef®ion=eu&label=a%20b' }); const secrets = ['secret%2B0123456789', 'secret+0123456789', 'p%2Bassw0rd%21', 'p+assw0rd!', 'bare%2Bsecret0123456789', 'bare+secret0123456789', 'value0123456789abcdef', 'region=eu', 'label=a%20b', 'label=a b']; From 6c476173da89fe2df7b5823be4bcfd55c83e4d36 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 13:38:09 -0400 Subject: [PATCH 76/94] docs(mcp): a call paused at input_required has no cancel, so the server keeps its state until its own timeout #262's Oct 9 thread comment (issuecomment-6086060670), answering slimepriestess's carried note, said: "We'll check whether the 2026-07-28 protocol defines a cancel for a call paused at input-required. If it does, we'll send it at the deadline so the server can release the continuation. If it doesn't, one doc line will say the server keeps that state until its own timeout." The check was in SDK 2.3.1's spec.types.2026-07-28.ts (packages/core-internal, tag @modelcontextprotocol/client@2.3.1). notifications/cancelled names "a request the client previously issued", which "SHOULD still be in-flight". The schema has no other cancel, and no tasks/cancel. An input_required answer ends its request, and the next round is a retry of the original request carrying inputResponses and the opaque requestState. So nothing cancels a call paused between rounds. The line goes in both places that say nothing is in flight between rounds: the README's paragraph on deadlines, reflowed, and the header of src/mcpl/modern-connection.ts ("One deadline per tool call"). Docs only. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 ++++++++++-------- src/mcpl/modern-connection.ts | 5 ++++- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6f374a54d..f80a9afcd 100644 --- a/README.md +++ b/README.md @@ -322,14 +322,16 @@ three ways: At the deadline, cancellation is requested for a leg still in flight; between the rounds of an `input_required` continuation nothing is in flight to cancel. Either way the outcome is reported as unknown (`no-response`), -and the call is never retried. A call that ends for any other reason after -the server asked for another round, before its final answer, is reported -the same way, since the server may already have acted. An MCPL server that -refuses `2024-11-05` with `-32022` fails with `McplProtocolVersionError`, -and is not retried. The error names the revisions the server says it -supports, or its error text when it lists none, and when the list includes -`2026-07-28` it says to configure the server with `protocol: 'modern'` (stdio) -or reach it over http(s). +and the call is never retried. The protocol has no cancel for a paused call, +so whatever the server holds for it, beyond the `requestState` it gave this +client, it keeps until its own timeout. A call that ends for any other +reason after the server asked for another round, before its final answer, is +reported the same way, since the server may already have acted. An MCPL +server that refuses `2024-11-05` with `-32022` fails with +`McplProtocolVersionError`, and is not retried. The error names the +revisions the server says it supports, or its error text when it lists none, +and when the list includes `2026-07-28` it says to configure the server with +`protocol: 'modern'` (stdio) or reach it over http(s). Server and transport text the framework records has the server's configured credentials replaced, in both engines. That covers connection traces, console lines, stderr traces, ops alerts, and a tool call's failure as its caller, the model and the `tool:failed` trace get it. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values, and a `ws://` url's userinfo, in their encoded and decoded forms, replaced wherever they occur; every non-empty query parameter as its whole `name=value`, where no letter or digit adjoins it; the static `token`, raw and url-encoded; and a stdio server's `env` values whose names have a part ending in a credential word, such as `GITHUB_TOKEN`, `PGPASSWORD` or `SESSION_COOKIE`, raw and url-encoded. A stdio server's command-line arguments aren't derived. Standalone strings shorter than eight characters stay unchanged because they may be ordinary words; short query values are still removed with their parameter names. Treat a submitted configuration as immutable while the server uses it: apply changes with `restartMcplServer(id, newConfig)`. The token an `accessProvider` fetches, and secrets the configuration doesn't hold, remain outside this filtering. JSON-RPC tool answers, and the error a host's own connect raises, pass through as sent. diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index f7a5a64c4..cde0853a5 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -17,7 +17,10 @@ * bounds every leg of the call and the pauses between them. At the * deadline the SDK requests cancellation of a leg in flight; between * continuation rounds nothing is in flight. Either way the outcome is - * unknown. Calls are never replayed. + * unknown. Calls are never replayed. The protocol has no cancel for a + * call paused at `input_required`, so whatever the server holds for it, + * beyond the `requestState` it gave this client, it keeps until its own + * timeout. * - **Request outcomes from the wire.** A failed call is classified by what * crossed Connectome's transport boundary, never by the error's class. The * SDK raises the same error classes locally (an invalid outputSchema before From f98a657100a5edb9782f6d2ddc4184fb1a333f60 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 15:22:50 -0400 Subject: [PATCH 77/94] fix(mcp): a connect and a listing count only while their connection serves, and the redactor covers what each dial sent An internal review of this round's first five commits found these. The connect's check after its listing, and the refresh's publication: - A connect stood after its listing while its connection was still the id's and no disconnect held the id. A disconnect that begins during the listing and can't reap the child leaves the connection registered and closed, with its hold released, so the connect stood. The check now asks whether the connection still serves its server: connected or reconnecting, and not closed by the host. It no longer asks whether the id still has that connection, since whatever removes or replaces a connection closes it first, and a closed connection doesn't serve. It still asks about a disconnect under way, which takes the id before its close marks the connection. - A refresh published a listing while its server's id was registered and its close count unchanged. A host close emits 'close' only once its teardown succeeds, so a listing answered before a disconnect was published while the teardown ran, or after it failed, class hints included. Publication now also leaves out the listing of a server whose connection doesn't serve, by the same test. The close count stays, for a session that closed and reconnected before the refresh published. Server text's credentials: - An operation's redactor (everyServerRedactor) took the patterns of the configurations present when it began. A dial noted after that, by a server removed since, was in neither those patterns nor the current ones. The redactor now keeps the configurations, and derives their patterns, with their dials' notes, when it records each text. - A WebSocket dial noted only the url it dialed, so an access provider's token in it got a url's forms and not a token's, such as its JSON form with an escaped slash. The dial now notes the token too. - A token a dial sends again kept its first place among the newest sixteen, so it could be let go while still in use. It now moves to the newest place. Docs and comments: - README.md and the redaction changelog fragment still said an access provider's token is outside the redaction. They now say what's covered, and that the userinfo rule is a WebSocket url's, ws:// or wss://. - The line on a paused call said the server keeps its state "until its own timeout". Whether a server times it out is the server's own policy, so the line now says the state stays until the server lets it go. - The overtaken-refresh guard's comment said the later refresh "listed every server after this one did". It now says the later one began listing after this one did. noteDialSent's comment says which dials note anything, and a test's comment says each dial notes its credential before it fails. - serverTextRedactor's redact has no unused initializer. Tests: - mcp-server-ownership: - A disconnect that fails to reap during a connect's listing fails the connect. - The listing of a server whose disconnect failed to reap isn't published. - A listing begun before its server closed and reconnected is left out, and the new session's listing is published. - The disconnect-under-way test holds the disconnect's close behind a gate it opens once the connect has rejected. It no longer rests on a child that ignores SIGTERM for about 2 s. - The closed-session class tests also check that the closed session's tools aren't published. - These tests release what they hold in a finally, so a failure fails rather than hangs. - mcp-protocol-family: - The re-encoded query test's url also carries q=a~b, whose re-encoded pair q=a%7Eb is removed. - A provider token with a slash, sent in a WebSocket url, is removed in its JSON form too. - A token a dial sends again is the newest, so the oldest of the others goes first. - mcp-recorded-text: an operation's redactor covers what a server's dial sent after the redactor was made, though the server has gone. - checkpoint-attribution: its stub server says it's connected, since publication now asks whether a listed server's connection serves. Considered and left as they are: - The extra refresh announces its tools under whichever server's change started it. That's handleToolsListChanged's coalescing of list changes, and the tools themselves are right. - The close counts are kept per id for the framework's life. Forgetting one at a disconnect would let a new connection under that id reach a count a stale listing had noted. - A superseded connection's 'close' always comes before its replacement is registered, so a late close can't move a count a replacement's listing noted. - A server that refuses its listing has no tools until a later listing succeeds, as before this round. Red-first, Linux, Node 24.22.0: - With src at the commit before, five of the new tests fail: the connect whose disconnect fails to reap, the listing of a server whose disconnect failed to reap, the provider token in a WebSocket url, a token sent again, and an operation's redactor after a dial. The other new and changed tests pass there. - Each piece removed on its own fails a test: the connect's check of a disconnect under way, and of whether the connection serves; publication's check of whether the connection serves, and of the close count; each engine's close count; the WebSocket dial's notes of its url and of its token; the HTTP bearer's note; the dial's form of the configured url; the newest place; the cap; the redactor's kept configurations; and the redactor rebuilt after a dial. - With the connect's check of whether the id still has its connection removed, every test passed, which is why that condition is gone. - With the change, the whole suite passes (node --test over every file: 2,593 tests, none failing). Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- .../redact-recorded-mcp-credentials.fixed.md | 2 +- src/framework.ts | 72 ++++++---- src/mcpl/modern-connection.ts | 4 +- src/mcpl/protocol-family.ts | 23 ++- src/mcpl/transport.ts | 5 +- test/checkpoint-attribution.test.ts | 1 + test/mcp-protocol-family.test.ts | 28 +++- test/mcp-recorded-text.test.ts | 9 ++ test/mcp-server-ownership.test.ts | 134 ++++++++++++++++-- 10 files changed, 224 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index f80a9afcd..9ffd3a8ef 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ between the rounds of an `input_required` continuation nothing is in flight to cancel. Either way the outcome is reported as unknown (`no-response`), and the call is never retried. The protocol has no cancel for a paused call, so whatever the server holds for it, beyond the `requestState` it gave this -client, it keeps until its own timeout. A call that ends for any other +client, stays until the server lets it go. A call that ends for any other reason after the server asked for another round, before its final answer, is reported the same way, since the server may already have acted. An MCPL server that refuses `2024-11-05` with `-32022` fails with @@ -333,7 +333,7 @@ revisions the server says it supports, or its error text when it lists none, and when the list includes `2026-07-28` it says to configure the server with `protocol: 'modern'` (stdio) or reach it over http(s). -Server and transport text the framework records has the server's configured credentials replaced, in both engines. That covers connection traces, console lines, stderr traces, ops alerts, and a tool call's failure as its caller, the model and the `tool:failed` trace get it. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values, and a `ws://` url's userinfo, in their encoded and decoded forms, replaced wherever they occur; every non-empty query parameter as its whole `name=value`, where no letter or digit adjoins it; the static `token`, raw and url-encoded; and a stdio server's `env` values whose names have a part ending in a credential word, such as `GITHUB_TOKEN`, `PGPASSWORD` or `SESSION_COOKIE`, raw and url-encoded. A stdio server's command-line arguments aren't derived. Standalone strings shorter than eight characters stay unchanged because they may be ordinary words; short query values are still removed with their parameter names. Treat a submitted configuration as immutable while the server uses it: apply changes with `restartMcplServer(id, newConfig)`. The token an `accessProvider` fetches, and secrets the configuration doesn't hold, remain outside this filtering. JSON-RPC tool answers, and the error a host's own connect raises, pass through as sent. +Server and transport text the framework records has the server's configured credentials replaced, in both engines. That covers connection traces, console lines, stderr traces, ops alerts, and a tool call's failure as its caller, the model and the `tool:failed` trace get it. The credentials are those the configuration holds and those its dials sent: from the url as each dial sends it (a WebSocket dial sets the token in the query, which writes the whole query again, form-encoded, and an access provider's token can be in it), credential-like path segments and query names, credential-shaped or credential-named query values, and a WebSocket url's userinfo, in their encoded and decoded forms, replaced wherever they occur; every non-empty query parameter as its whole `name=value`, where no letter or digit adjoins it; the static `token`, and each token an access provider handed a dial, the newest sixteen, raw and url-encoded; and a stdio server's `env` values whose names have a part ending in a credential word, such as `GITHUB_TOKEN`, `PGPASSWORD` or `SESSION_COOKIE`, raw and url-encoded. A stdio server's command-line arguments aren't derived. Standalone strings shorter than eight characters stay unchanged because they may be ordinary words; short query values are still removed with their parameter names. Treat a submitted configuration as immutable while the server uses it: apply changes with `restartMcplServer(id, newConfig)`. Secrets neither the configuration holds nor a dial sent remain outside this filtering. JSON-RPC tool answers, and the error a host's own connect raises, pass through as sent. #### Feature sets diff --git a/changelog.d/redact-recorded-mcp-credentials.fixed.md b/changelog.d/redact-recorded-mcp-credentials.fixed.md index fa3bcfe38..1df441cea 100644 --- a/changelog.d/redact-recorded-mcp-credentials.fixed.md +++ b/changelog.d/redact-recorded-mcp-credentials.fixed.md @@ -1 +1 @@ -- Server and transport text the framework records has the server's configured credentials replaced, in both engines: connection traces, console lines, stderr traces, the ops alert (and so `logs/failures.log` and the webhook), an MCP tool call that fails without an answer, as its caller and the model get it and in the `tool:failed` trace's message and stack. The credentials are those the configuration holds: from the url, credential-like path segments and query names, credential-shaped or credential-named query values (each as written and decoded), every non-empty parameter as its whole `name=value`, and a `ws://` url's username and password, its userinfo as one unit before its `@` (the username alone when there's no password), with no length floor, so a short password goes with its username and a short username alone goes too, and the Basic credential a WebSocket client sends for it; the static `token`, raw, as JSON encoders write it (with `JSON.stringify`'s escapes, and with its slashes escaped too, as some encoders write them), and as a url carries it, percent- or form-encoded with either case of hex; and a stdio server's `env` values whose names have a part ending in a credential word, singular or plural, with any digits after it (`GITHUB_TOKEN`, `API_KEYS`, `PGPASSWORD`, `DB_PASS`, `AUTHORIZATION`, `SESSION_COOKIE`, `TOKEN2`), raw, as JSON encoders write them, and percent-encoded with either case of hex. A stdio server's command-line arguments are not derived. A string replaced on its own has at least eight characters, since a shorter one can't be told from ordinary text, and is replaced wherever it occurs. Every non-empty query parameter is also replaced as its whole `name=value`, only where no letter or digit adjoins the pair (so `v=2` leaves `dev=2` alone); a short value goes only that way. A connection's event records use a redactor built once for it. An operation's records replace the credentials of the configurations present when it began and of those present when each record is made, so a server removed, or replaced by a restart, while the operation waits is still covered, and so is one connected meanwhile. Every place a credential matches is replaced, places that overlap included, and text that may come from any server is matched against every server's strings at once, so two servers' secrets that overlap are both replaced whole. Over HTTP, which only the modern engine speaks, a 401 or 403's response body is left out of the request error's data; where the HTTP client builds the error's message from the body, as it does for most statuses, the message is redacted like any record. A token an access provider fetches, and credentials the configuration doesn't hold, are outside this. JSON-RPC answers, and the error a host's own connect raises, pass as they are. +- Server and transport text the framework records has the server's configured credentials replaced, in both engines: connection traces, console lines, stderr traces, the ops alert (and so `logs/failures.log` and the webhook), an MCP tool call that fails without an answer, as its caller and the model get it and in the `tool:failed` trace's message and stack. The credentials are those the configuration holds and those its dials sent: from the url as each dial sends it (a WebSocket dial sets the token in the query, which writes the whole query again, form-encoded, and an access provider's token can be in it), credential-like path segments and query names, credential-shaped or credential-named query values (each as written and decoded), every non-empty parameter as its whole `name=value`, and a WebSocket url's username and password, its userinfo as one unit before its `@` (the username alone when there's no password), with no length floor, so a short password goes with its username and a short username alone goes too, and the Basic credential a WebSocket client sends for it; the static `token`, and each token an access provider handed a dial (the newest sixteen), raw, as JSON encoders write it (with `JSON.stringify`'s escapes, and with its slashes escaped too, as some encoders write them), and as a url carries it, percent- or form-encoded with either case of hex; and a stdio server's `env` values whose names have a part ending in a credential word, singular or plural, with any digits after it (`GITHUB_TOKEN`, `API_KEYS`, `PGPASSWORD`, `DB_PASS`, `AUTHORIZATION`, `SESSION_COOKIE`, `TOKEN2`), raw, as JSON encoders write them, and percent-encoded with either case of hex. A stdio server's command-line arguments are not derived. A string replaced on its own has at least eight characters, since a shorter one can't be told from ordinary text, and is replaced wherever it occurs. Every non-empty query parameter is also replaced as its whole `name=value`, only where no letter or digit adjoins the pair (so `v=2` leaves `dev=2` alone); a short value goes only that way. A connection's event records use a redactor built once for it, and built again once a dial has sent something new. An operation's records replace the credentials of the configurations present when it began and of those present when each record is made, so a server removed, or replaced by a restart, while the operation waits is still covered, and so is one connected meanwhile. Every place a credential matches is replaced, places that overlap included, and text that may come from any server is matched against every server's strings at once, so two servers' secrets that overlap are both replaced whole. Over HTTP, which only the modern engine speaks, a 401 or 403's response body is left out of the request error's data; where the HTTP client builds the error's message from the body, as it does for most statuses, the message is redacted like any record. Credentials neither the configuration holds nor a dial sent are outside this. JSON-RPC answers, and the error a host's own connect raises, pass as they are. diff --git a/src/framework.ts b/src/framework.ts index 0a1bdd531..dd81d3a28 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -16679,6 +16679,14 @@ export class AgentFramework { }; } + /** Whether a server's connection still serves it, or is reconnecting to + * it. One the host is closing or has closed doesn't, and neither does one + * lost for good, whose reconnecting has stopped. */ + private static mcpConnectionServes(connection: McplServerConnection | ModernMcpConnection): boolean { + if (connection instanceof ModernMcpConnection && connection.isClosed) return false; + return connection.isConnected || connection.willReconnect; + } + /** * A connect whose failed launch couldn't be confirmed exited. Its * connection stays registered, closed, and holds the id, so nothing @@ -17799,12 +17807,16 @@ export class AgentFramework { await this.untilStopBegins(this.refreshMcplTools()); if (this.stopHasBegun()) throw stopped(); // The connect let go of its id once its setup was done, so a disconnect - // during the listing wasn't held off. One that has begun, or has removed - // what the connect set up, ends the connect, as one during its setup - // does: what it set up is going or gone. So does a server lost for good - // meanwhile. A restart reports it as its connect's failure. - const current = this.modernServers().get(config.id) ?? this.mcplServerRegistry?.getServer(config.id); - if (current !== connection || this.mcpServerOps?.get(config.id)?.kind === 'disconnect') { + // during the listing wasn't held off. A disconnect under way ends the + // connect, as one during its setup does, and so does a connection that + // no longer serves: closed by a disconnect, its child reaped or not, or + // lost for good. Whatever removed or replaced the connection closed it + // first, since an id has one owner at a time. A connection the server + // closed that is reconnecting still serves. A disconnect takes the id + // before its close marks the connection, a turn later for a legacy one, + // so a disconnect under way is asked of as well. A restart reports the + // connect's failure as its own. + if (this.mcpServerOps?.get(config.id)?.kind === 'disconnect' || !AgentFramework.mcpConnectionServes(connection)) { throw new Error(`MCP server "${config.id}" was not connected: it was disconnected or lost while its tools were being listed`); } this.emitMcplToolDiff(oldToolNames, config.id); @@ -18009,15 +18021,15 @@ export class AgentFramework { * meanwhile, both have their credentials replaced. */ private everyServerRedactor(): (text: unknown) => string { - const snapshot = [...this.mcplServerConfigs.values()].flatMap(config => serverCredentialPatterns(config)); + const snapshot = [...this.mcplServerConfigs.values()]; // The configurations present when the operation began, then those present // now: a server removed meanwhile and one connected meanwhile are both - // covered. One pass over all their strings, so a secret that begins + // covered, each with what its dials have sent by the time the text is + // recorded. One pass over all their strings, so a secret that begins // another server's longer one can't cut that one short. - return (text) => credentialRedactor([ - ...snapshot, - ...[...this.mcplServerConfigs.values()].flatMap(config => serverCredentialPatterns(config)), - ])(text); + return (text) => credentialRedactor( + [...new Set([...snapshot, ...this.mcplServerConfigs.values()])].flatMap(config => serverCredentialPatterns(config)), + )(text); } /** Why a connect for this id is refused, or null when the id is free. One @@ -18790,17 +18802,23 @@ export class AgentFramework { } // A listing counts only while the session it came from is still its - // server's live one. A server removed while this refresh awaited its - // lists (a disconnect, or a server lost for good) contributes nothing, - // so whichever refresh finishes last can't bring back its tools. Nor - // does one whose connection closed after its listing began: the close - // forgot its class hints as stale (RFC-008), and its reconnect lists the - // new session. Asked of the same listing the refresh began from, now. - const live = new Set([ - ...(this.mcplServerRegistry?.getAllServers() ?? []).map((server) => server.id), - ...this.modernServers().keys(), + // server's live one, asked of the servers there now. A server removed + // while this refresh awaited its lists (a disconnect, or a server lost + // for good) contributes nothing, so whichever refresh finishes last + // can't bring back its tools. Nor does one whose connection no longer + // serves: the host is closing it or failed to reap it, or it was lost + // for good. Nor does one whose connection closed after its listing + // began, even if it has reconnected since: the close forgot its class + // hints as stale (RFC-008), and its reconnect lists the new session. + const connections = new Map([ + ...(this.mcplServerRegistry?.getAllServers() ?? []).map((server) => [server.id, server] as const), + ...this.modernServers(), ]); - const over = new Set([...listedServers].filter((serverId) => !live.has(serverId) || closesOf(serverId) !== closesAtListing.get(serverId))); + const serving = (serverId: string): boolean => { + const connection = connections.get(serverId); + return !!connection && AgentFramework.mcpConnectionServes(connection); + }; + const over = new Set([...listedServers].filter((serverId) => !serving(serverId) || closesOf(serverId) !== closesAtListing.get(serverId))); if (over.size > 0) { for (const [name, serverId] of [...toolServers]) { if (!over.has(serverId)) continue; @@ -18810,11 +18828,11 @@ export class AgentFramework { } for (const serverId of over) listedServers.delete(serverId); } - // A refresh started after this one has published already: it listed - // every server after this one did, a server replaced by a restart under - // the same id included, so this one's listings are older. A server this - // one listed can still have no listing there, such as one that refused - // the later refresh's request. Its tools would stay missing until + // A refresh started after this one has published already: it began + // listing after this one did, a server replaced by a restart under the + // same id included, so this one's listings are older. A server this one + // listed can still have no listing there, such as one that refused the + // later refresh's request. Its tools would stay missing until // something else refreshed, so one more refresh lists it again. if (generation < this.mcplToolsRefreshPublished) { const unlisted = [...listedServers].find((id) => !this.mcplListedServers.has(id)); diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index cde0853a5..0ea1f2592 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -19,8 +19,8 @@ * continuation rounds nothing is in flight. Either way the outcome is * unknown. Calls are never replayed. The protocol has no cancel for a * call paused at `input_required`, so whatever the server holds for it, - * beyond the `requestState` it gave this client, it keeps until its own - * timeout. + * beyond the `requestState` it gave this client, stays until the server + * lets it go. * - **Request outcomes from the wire.** A failed call is classified by what * crossed Connectome's transport boundary, never by the error's class. The * SDK raises the same error classes locally (an invalid outputSchema before diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index eb7e9f532..31f2e74e0 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -100,7 +100,7 @@ export function redactUrl(url: string | undefined): string { * the configured one, and each one dialed with an access provider's token * ({@link noteDialSent}): credential-like path segments and * query names, credential-shaped or credential-named query values, every - * other non-empty parameter as its whole name=value, and a ws:// url's + * other non-empty parameter as its whole name=value, and a WebSocket url's * username and password, its userinfo as one unit before its @ (the * username alone when there's no password), with no length floor, so a * short password goes with its username and a short username alone goes @@ -138,7 +138,7 @@ export function redactServerText(text: unknown, config: Pick): (text: unknown) => string { let built = -1; - let redact: (text: unknown) => string = asText; + let redact!: (text: unknown) => string; return (text) => { const version = dialsSent.get(config)?.version ?? 0; if (version !== built) { @@ -151,10 +151,12 @@ export function serverTextRedactor(config: Pick(); const DIAL_SENT_KEPT = 16; @@ -169,7 +171,14 @@ export function noteDialSent(config: object, sent: { readonly url?: string; read } const kept = entry; const keep = (list: string[], value: string | undefined): void => { - if (!value || list.includes(value)) return; + if (!value) return; + const at = list.indexOf(value); + if (at >= 0) { + // Sent again: it moves to the newest place, and the set is unchanged. + list.splice(at, 1); + list.push(value); + return; + } list.push(value); if (list.length > DIAL_SENT_KEPT) list.shift(); kept.version += 1; diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index 475b14db3..3d8ccec9d 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -368,8 +368,9 @@ export class WebSocketTransport extends McplTransport { } const url = buildWebSocketUrl(config, token); // A provider's token is in this url, and the query may be re-encoded: - // the server's text is recorded with what the dial sent. - noteDialSent(config, { url }); + // the server's text is recorded with what the dial sent, the token in + // each form a token is replaced in. + noteDialSent(config, { url, token: token ?? undefined }); // Error strings travel: traces, logs, tool results the model reads. They // get the endpoint, never the credential. const redacted = redactUrl(config.url); diff --git a/test/checkpoint-attribution.test.ts b/test/checkpoint-attribution.test.ts index 3f9581105..db30b80b7 100644 --- a/test/checkpoint-attribution.test.ts +++ b/test/checkpoint-attribution.test.ts @@ -44,6 +44,7 @@ function makeFrameworkHarness( }> = []; const server = { id: 'srv', + isConnected: true, async sendToolsList() { return { tools }; }, async sendToolsCall( name: string, diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index caec2cd5e..a9ce26267 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -341,30 +341,44 @@ test('both engines admit a configuration through checkServerConfig, which logs i }); test("a query credential is removed as the WebSocket dial sends it, the query re-encoded once the token is set", () => { - const config = cfg({ url: 'wss://host.example/mcp?password=secret%20key123', token: 'tok-abcdefgh' }); + const config = cfg({ url: 'wss://host.example/mcp?password=secret%20key123&q=a~b', token: 'tok-abcdefgh' }); const sent = buildWebSocketUrl(config); // The form the server receives, and can echo, as URLSearchParams writes it. - assert.ok(sent.includes('password=secret+key123'), sent); + assert.ok(sent.includes('password=secret+key123') && sent.includes('q=a%7Eb'), sent); const shown = redactServerText(`closed by the server: ${sent}`, config); assert.ok(!shown.includes('key123'), shown); assert.ok(!shown.includes('tok-abcdefgh'), shown); + assert.ok(!shown.includes('q=a%7Eb'), shown); }); test("an access provider's token is removed once a dial has sent it, in a WebSocket url or an HTTP header", async () => { - const ws = cfg({ id: 'ws', url: 'ws://127.0.0.1:1/mcpl?password=secret%20key123', accessProvider: async () => 'prov-ws-0123456789' }); + const ws = cfg({ id: 'ws', url: 'ws://127.0.0.1:1/mcpl?password=secret%20key123', accessProvider: async () => 'prov/ws-0123456789' }); const http = cfg({ id: 'http', url: 'http://127.0.0.1:1/mcp', accessProvider: async () => 'prov-http-0123456789' }); // Made before the dial, as the framework makes a connection's. const recorded = serverTextRedactor(ws); - assert.ok(recorded('token=prov-ws-0123456789').includes('prov-ws'), 'no dial has sent it yet'); - // Nothing listens on port 1: each dial fails after sending its credential. + assert.ok(recorded('token=prov%2Fws-0123456789').includes('ws-0123456789'), 'no dial has sent it yet'); + // Nothing listens on port 1: each dial notes its credential before it fails. await assert.rejects(WebSocketTransport.open(ws)); await assert.rejects(ModernMcpConnection.connect(http)); - const shown = recorded('closed: ws://127.0.0.1:1/mcpl?password=secret+key123&token=prov-ws-0123456789'); - assert.ok(!shown.includes('prov-ws') && !shown.includes('key123'), shown); + const shown = recorded('closed: ws://127.0.0.1:1/mcpl?password=secret+key123&token=prov%2Fws-0123456789'); + assert.ok(!shown.includes('ws-0123456789') && !shown.includes('key123'), shown); + // The token in the forms a token takes, as JSON with its slash escaped. + const json = recorded('{"token":"prov\\/ws-0123456789"}'); + assert.ok(!json.includes('ws-0123456789'), json); const header = redactServerText('refused: Authorization: Bearer prov-http-0123456789', http); assert.ok(!header.includes('prov-http'), header); }); +test("a token a dial sends again is the newest, so the oldest of the others goes first", () => { + const config = cfg({ id: 'r', url: 'http://127.0.0.1:1/mcp' }); + noteDialSent(config, { token: 'prov-again-0000' }); + for (let n = 1; n <= 15; n++) noteDialSent(config, { token: `prov-other-${String(n).padStart(4, '0')}` }); + noteDialSent(config, { token: 'prov-again-0000' }); + noteDialSent(config, { token: 'prov-other-0016' }); + assert.equal(redactServerText('prov-again-0000', config), 'redacted'); + assert.equal(redactServerText('prov-other-0001', config), 'prov-other-0001', 'the oldest of the others is let go'); +}); + test("a configuration keeps only its dials' newest provider tokens", () => { const config = cfg({ id: 'p', url: 'http://127.0.0.1:1/mcp' }); for (let n = 0; n <= 16; n++) noteDialSent(config, { token: `prov-token-${String(n).padStart(4, '0')}` }); diff --git a/test/mcp-recorded-text.test.ts b/test/mcp-recorded-text.test.ts index fdaa5042d..7a32e4743 100644 --- a/test/mcp-recorded-text.test.ts +++ b/test/mcp-recorded-text.test.ts @@ -11,6 +11,7 @@ import type { ModuleContext } from '../src/index.js'; import { AgentFramework } from '../src/framework.js'; import { McplProtocolVersionError, McplServerConnection, McplUnreapedLaunchError } from '../src/mcpl/server-connection.js'; import { ModernMcpConnection, malformedLineDiagnostic } from '../src/mcpl/modern-connection.js'; +import { noteDialSent } from '../src/mcpl/protocol-family.js'; import { MockMembrane } from './helpers/mock-membrane.js'; /** Discovery succeeds; the inventory request gets a normal echoed-path 404. */ @@ -223,6 +224,14 @@ test("a tool call's failure, from either engine, reaches its caller and its trac // A server connected after it was made is covered too. framework.mcplServerConfigs = new Map([['late', { id: 'late', url: 'https://k/api/s/lateSecret0123456789/mcp' }]]); assert.equal(everyServer('closed lateSecret0123456789'), 'closed redacted'); + // What a server's dial sent after the redactor was made is covered, though + // the server has gone by the time the text is recorded. + const dialing = { id: 'dialing', url: 'https://d/mcp' }; + framework.mcplServerConfigs = new Map([[dialing.id, dialing]]); + const beforeDial = framework.everyServerRedactor(); + noteDialSent(dialing, { token: 'prov-late-0123456789' }); + framework.mcplServerConfigs = new Map(); + assert.equal(beforeDial('refused prov-late-0123456789'), 'refused redacted'); // A value that can't be printed reads as a placeholder. assert.equal(everyServer(Object.create(null)), '[a value that cannot be printed]'); // One pass over every server's strings: a secret that begins another diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index f3a526e2e..ac16a99fb 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -587,6 +587,7 @@ for (const engine of ['legacy', 'modern'] as const) { await refreshing; const kept = framework.listToolClasses().filter((tool) => tool.serverId === 's' && tool.class.includes('files')); assert.deepEqual(kept, [], "no tool keeps the closed session's class"); + assert.deepEqual(framework.getAllTools().filter((tool) => tool.name.startsWith('mcpl--s--')), [], "nor are the closed session's tools published"); }); }); } @@ -623,19 +624,132 @@ test("a disconnect still under way when a connect's tool listing ends fails the await withFramework(async ({ framework, dir }) => { await framework.connectMcplServer(modern('a', '', join(dir, 'raw-a.jsonl'))); const a = holdListings(framework, 'a'); - const servers = (framework as unknown as { modernServers(): Map }).modernServers(); - // m ignores SIGTERM, so its disconnect stays in flight for ~2 s, holding - // the id, with m still registered. - const connecting = framework.connectMcplServer(modern('m', 'ignore-sigterm', join(dir, 'raw-m.jsonl'))); + const servers = (framework as unknown as { modernServers(): Map; isConnected: boolean }> }).modernServers(); + const connecting = framework.connectMcplServer(modern('m', '', join(dir, 'raw-m.jsonl'))); await until(() => a.held() > 0, "m's connect, listing tools, waits on a"); - const m = servers.get('m'); + // m's close waits for the test, so its disconnect holds the id while m + // is still registered and still connected. + const m = servers.get('m')!; + const realClose = m.close.bind(m); + let openClose!: () => void; + const closeGate = new Promise((resolve) => { openClose = resolve; }); + m.close = async () => { await closeGate; return realClose(); }; let disconnected = false; const disconnecting = framework.disconnectMcplServer('m').then(() => { disconnected = true; }); - a.release(); - await assert.rejects(connecting, /MCP server "m" was not connected: it was disconnected or lost while its tools were being listed/); - assert.equal(disconnected, false, 'the disconnect was still under way'); - assert.equal(servers.get('m'), m, 'with m still registered'); - await disconnecting; + try { + a.release(); + await assert.rejects(connecting, /MCP server "m" was not connected: it was disconnected or lost while its tools were being listed/); + assert.equal(disconnected, false, 'the disconnect was still under way'); + assert.equal(servers.get('m'), m, 'with m still registered'); + assert.equal(m.isConnected, true, 'and still connected'); + } finally { + openClose(); + await disconnecting; + } + }); +}); + +test("a disconnect that fails to reap during a connect's tool listing fails the connect", async () => { + await withFramework(async ({ framework, dir }) => { + await framework.connectMcplServer(modern('a', '', join(dir, 'raw-a.jsonl'))); + const a = holdListings(framework, 'a'); + const reaping = unreapable(); + const connecting = framework.connectMcplServer(modern('m', '', join(dir, 'raw-m.jsonl'))); + await until(() => a.held() > 0, "m's connect, listing tools, waits on a"); + reaping.active = false; + try { + // The disconnect closes m and can't reap its child: m stays + // registered, closed, and the disconnect has let go of the id. + await assert.rejects(framework.disconnectMcplServer('m'), /could not be reaped/); + assert.equal(status(framework, 'm')?.connected, false, 'still listed, closed'); + a.release(); + await assert.rejects(connecting, /MCP server "m" was not connected: it was disconnected or lost while its tools were being listed/); + } finally { + a.release(); + for (const stuck of reaping.stuck) await killAndWait(stuck); + } + await framework.disconnectMcplServer('m'); + }); +}); + +test("a listing of a server whose disconnect failed to reap isn't published", async () => { + await withFramework(async ({ framework, dir }) => { + const reaping = unreapable(); + await framework.connectMcplServer(modern('m', '', join(dir, 'raw-m.jsonl'))); + reaping.active = false; + assert.ok(toolsOf(framework, 'm').length > 0); + type Tool = { name: string; _meta?: Record }; + const internals = framework as unknown as { + modernServers(): Map }>; + refreshMcplTools(): Promise; + }; + const connection = internals.modernServers().get('m')!; + const realList = connection.listTools.bind(connection); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let held = false; + connection.listTools = async () => { + const tools = (await realList()).map((tool) => ({ ...tool, _meta: { ...tool._meta, 'mcpl/class': ['files'] } })); + held = true; + await gate; + return tools; + }; + const refreshing = internals.refreshMcplTools(); + await until(() => held, 'the refresh holds its listing of m'); + try { + // A failed teardown emits no 'close', so only m's state says it's over. + await assert.rejects(framework.disconnectMcplServer('m'), /could not be reaped/); + release(); + await refreshing; + assert.deepEqual(toolsOf(framework, 'm'), [], "the closed connection's listing isn't published"); + assert.deepEqual(framework.listToolClasses().filter((tool) => tool.serverId === 'm' && tool.class.includes('files')), []); + } finally { + release(); + for (const stuck of reaping.stuck) await killAndWait(stuck); + } + await framework.disconnectMcplServer('m'); + }); +}); + +test('a listing begun before its server closed and reconnected is left out, and the new session is listed', async () => { + await withFramework(async ({ framework, dir }) => { + await framework.connectMcplServer({ ...modern('m', '', join(dir, 'raw.jsonl')), reconnect: true, reconnectIntervalMs: 50, reconnectMaxIntervalMs: 100 }); + type Tool = { name: string; _meta?: Record }; + const internals = framework as unknown as { + modernServers(): Map }>; + refreshMcplTools(): Promise; + }; + const connection = internals.modernServers().get('m')!; + const realList = connection.listTools.bind(connection); + // The first listing is the old session's, which declares `files`. The + // second is the reconnect's own refresh, of the new session. + const gates = [0, 1].map(() => { let open!: () => void; const shut = new Promise((resolve) => { open = resolve; }); return { open, shut }; }); + let calls = 0; + const answered: boolean[] = []; + connection.listTools = async () => { + const call = calls++; + const tools = await realList(); + answered[call] = true; + if (call > 1) return tools; + await gates[call]!.shut; + return call === 0 ? tools.map((tool) => ({ ...tool, _meta: { ...tool._meta, 'mcpl/class': ['files'] } })) : tools; + }; + const refreshing = internals.refreshMcplTools(); + try { + await until(() => answered[0] === true, "the refresh holds the old session's listing of m"); + await framework.executeToolCall(call('mcpl--m--die')).catch(() => {}); + // m's session closes and a new one starts: its reconnect lists it. + await until(() => calls > 1, "m's reconnect lists its new session"); + gates[0]!.open(); + await refreshing; + assert.deepEqual(toolsOf(framework, 'm'), [], "the old session's listing isn't published, though m is connected again"); + assert.deepEqual(framework.listToolClasses().filter((tool) => tool.serverId === 'm' && tool.class.includes('files')), []); + gates[1]!.open(); + await until(() => toolsOf(framework, 'm').length > 0, "the new session's listing is published"); + assert.deepEqual(framework.listToolClasses().filter((tool) => tool.serverId === 'm' && tool.class.includes('files')), []); + } finally { + for (const gate of gates) gate.open(); + } }); }); From f8a5bf6d2dd549a15c58660fd41ad738d88164cd Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 15:30:09 -0400 Subject: [PATCH 78/94] docs(mcp): the stdio-only configuration copy says why a dialing configuration isn't copied Each dial notes what it sent against the configuration object it reads (noteDialSent), and the framework records a server's text with its own object. They're the same object because the framework copies a legacy configuration only for a stdio server, whose launch notes nothing. No test goes through the framework's own connect with an access provider, so a copy made on the WebSocket path one day would pass the suite and lose the notes. The comment at the copy says so. Comment only. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/framework.ts b/src/framework.ts index dd81d3a28..e4317cdf8 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -17251,6 +17251,10 @@ export class AgentFramework { `replaces the framework's protective default`, ); } + // Only a stdio server's configuration is copied, and its launch notes + // nothing. A dialing configuration stays the object the framework + // records the server's text with, since each dial notes what it sent + // against the object it read (noteDialSent). connectionConfig = { ...config }; Object.defineProperty(connectionConfig, 'env', { enumerable: true, From 6bad04c83f7c35f77e82b68fb39b5caf90cd74a3 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 16:53:55 -0400 Subject: [PATCH 79/94] fix(mcp): a lost modern session's id is held until its reap is confirmed, and the review's other findings since 322c67a3 An internal review of this pull request's code since 322c67a3 found these. No review run had read that range before. A modern session lost on its own (R1-1's reap): - handleLost emitted 'close' before it started the lost child's reap. The framework's close handler dropped a connection that won't reconnect at that emit, so the id was free while the child might still run, and a reap that failed left a child nothing owned. The framework now drops such a connection once its reap settles (lossReaped). One whose reap failed (holdsUnreapedChild) stays registered, holding the id, for a disconnect to retry. Its tools leave the agents' list either way. - The reap now starts, and is recorded, before 'close' is emitted, so a 'close' listener that throws can't skip it. - A reap that fails halts reconnecting, and is now a permanent failure: reconnect-failed with permanent set, as a reconnect attempt's unreaped launch is, which the framework raises as an mcpl-down alert. A close() the host made meanwhile reports the failure itself, so none goes out beside it. The modern engine's first connect and close: - With reconnect on, a first connect whose launch couldn't be reaped emitted a permanent connect-failed and resolved, so the framework reported the server connected. No retry can come beside that launch, so the first connect now fails, as one without reconnect does, and the framework's failed-connect path keeps the connection registered. - At startup, the failed connect's trace said willRetry from the configuration's reconnect setting. It now says whether a connection the connect left registered will reconnect. - close() set closedByHost a step after its call, so a connect checked in that step read the connection as open, and a start() in the same turn launched. close() now sets it at once. The legacy engine and transports: - reconnectAfterFailure set hostCloseRequested before transport.close(), and a close that rejected left it set, so the next close the host didn't ask for read as the host's. The flag is now cleared when that close rejects. - Two console lines printed a transport error's raw message. They're now redacted with the server's configuration, as their siblings are. - An access provider's race with the dial's abort signal left its listener on the signal, one per dial, so a reconnect loop with a provider gathered them. The listener now comes off once the provider settles (raceAbort), and the helper moved above openTransport's doc block, which it had split from openTransport. Configuration: a toolLifecycle.maxInputBytes above its default is refused on a modern server, as an autofetch bound above its default is. A bound above its default is on the allow side. Text: the README's version error for an empty supported list, the redaction changelog fragment (a stdio server's command-line arguments aren't derived), the restart's doc for a restart a disconnect ended, the export block's header, connectHeldMcplServer's doc, and two broken comments in modern-connection.ts. Tests: - mcp-server-ownership: - A modern server lost without reconnect holds its id while its child's reap runs, then leaves. - One whose child can't be reaped stays registered, alerts, and its tools leave the list. - With reconnect, a first connect whose launch can't be reaped fails and stays registered. At startup, it's traced as not retrying. - The legacy handshake test's handshake never finishes, so only the disconnect can end the connect. - mcp-modern-outcomes: - A lost transport whose reap fails emits a permanent reconnect-failed. - A host close() during that reap reports the failure, with no reconnect-failed beside it. - A 'close' listener that throws doesn't skip the reap. - close() marks the connection closed at once, so a start() in the same turn launches nothing. - A read error on a stdio child's stdout or stderr is reported through 'error', never thrown with nothing listening. - The close-during-spawn test takes its child's pid from the spawn, and bounds the connect's own end. - mcp-protocol-family: a maxInputBytes above its default is refused, at its default accepted. A non-string enabledCapabilities entry is read as asking for every path denied by default. A dial with an access provider leaves no abort listener on the signal. - mcpl-ws-transport: a reconnectAfterFailure() whose close fails leaves a later remote drop reported as the server's. - mcpl-transport-close: a failed handshake's transport error is logged without the server's credentials. - mcpl-runtime-lifecycle: three stop() tests wait for the state they name, rather than sleeping 300 or 600 ms. The connect variant of the disconnect-during-restart test also checks that the disconnect ended it. Considered and left as they are: - A legacy connect displaced after its handshake fails at its final check, since its connection doesn't serve. - shouldTriggerInference's first match over every configured server is main's selection. - The newest sixteen dial notes, the env names and userinfo the patterns derive, a host label, and a static token under eight characters are the redaction's documented scope. - A second connect for an id with a connect in flight is refused before it reaches the abort map. - stop() closes its queue in its synchronous first step, so a failed attempt leaves admission closed. Red-first, Linux, Node 24.22.0: - With src at f8a5bf6d, ten of the new tests fail. In mcp-server-ownership, they're the failed first connect with reconnect, the lost server holding its id, and the lost server whose reap fails. In mcp-modern-outcomes, the permanent failure, the throwing 'close' listener, and close() marking at once. In mcp-protocol-family, the policy test's maxInputBytes case and the abort listener. In mcpl-ws-transport, the host flag, and in mcpl-transport-close, the redacted frame error. - The other new and changed tests pass there. The startup trace passes for another reason: the old first connect traced its permanent failure as not retrying. - Each of 13 pieces, removed on its own, fails a test: the immediate closedByHost; the reap's order; the loss's permanent failure, and its guard for a close the host made; the first connect's halt; the drop after the reap; keeping an unreaped connection; the tool refresh either way; the startup trace's willRetry; the legacy flag's reset; the first console line's redaction; the abort listener's removal; and the maxInputBytes bound. - The second console line's redaction has no test. Reaching it needs a reconnect handshake that finishes after its close began. - With the change, the whole suite passes (node --test over every file: 2,606 tests, none failing). Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- .../redact-recorded-mcp-credentials.fixed.md | 2 +- src/framework.ts | 54 +++++---- src/index.ts | 8 +- src/mcpl/modern-connection.ts | 56 ++++++--- src/mcpl/protocol-family.ts | 17 ++- src/mcpl/server-connection.ts | 13 +- src/mcpl/transport.ts | 22 ++-- test/mcp-modern-outcomes.test.ts | 90 +++++++++++++- test/mcp-protocol-family.test.ts | 20 ++++ test/mcp-server-ownership.test.ts | 112 +++++++++++++++++- test/mcpl-runtime-lifecycle.test.ts | 28 ++++- test/mcpl-transport-close.test.ts | 23 ++++ test/mcpl-ws-transport.test.ts | 26 ++++ 14 files changed, 407 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 9ffd3a8ef..0106a55ae 100644 --- a/README.md +++ b/README.md @@ -304,13 +304,14 @@ three ways: configuration error: `allowHostCommands: true`, an `enabledCapabilities` pattern that matches a capability denied by default, `channelSubscription: 'auto'` or a non-empty list, a scope's non-empty `whitelist`, a - `toolLifecycle` `observe` or `inputs` block, and an `autofetch` bound - above its default. A setting + `toolLifecycle` `observe` or `inputs` block, and a + `toolLifecycle.maxInputBytes` or an `autofetch` bound above its default. + A setting that only takes something away, or repeats a default, is already satisfied, so it is accepted: `enabledFeatureSets` and the rest of `enabledCapabilities`, which are allow-lists, `disabledFeatureSets`, `disabledCapabilities`, a scope's `blacklist`, `channelSubscription: - 'manual'`, `toolLifecycle.maxInputBytes`, an `autofetch` bound at or + 'manual'`, a `toolLifecycle.maxInputBytes` or an `autofetch` bound at or below its default, and `shouldTriggerInference`. So a host that puts a deny-list on every server it deploys can deploy a modern one. @@ -329,7 +330,7 @@ reason after the server asked for another round, before its final answer, is reported the same way, since the server may already have acted. An MCPL server that refuses `2024-11-05` with `-32022` fails with `McplProtocolVersionError`, and is not retried. The error names the -revisions the server says it supports, or its error text when it lists none, +revisions the server says it supports ("none listed" for an empty list), or its error text when it sends no list, and when the list includes `2026-07-28` it says to configure the server with `protocol: 'modern'` (stdio) or reach it over http(s). diff --git a/changelog.d/redact-recorded-mcp-credentials.fixed.md b/changelog.d/redact-recorded-mcp-credentials.fixed.md index 1df441cea..c3a8867e8 100644 --- a/changelog.d/redact-recorded-mcp-credentials.fixed.md +++ b/changelog.d/redact-recorded-mcp-credentials.fixed.md @@ -1 +1 @@ -- Server and transport text the framework records has the server's configured credentials replaced, in both engines: connection traces, console lines, stderr traces, the ops alert (and so `logs/failures.log` and the webhook), an MCP tool call that fails without an answer, as its caller and the model get it and in the `tool:failed` trace's message and stack. The credentials are those the configuration holds and those its dials sent: from the url as each dial sends it (a WebSocket dial sets the token in the query, which writes the whole query again, form-encoded, and an access provider's token can be in it), credential-like path segments and query names, credential-shaped or credential-named query values (each as written and decoded), every non-empty parameter as its whole `name=value`, and a WebSocket url's username and password, its userinfo as one unit before its `@` (the username alone when there's no password), with no length floor, so a short password goes with its username and a short username alone goes too, and the Basic credential a WebSocket client sends for it; the static `token`, and each token an access provider handed a dial (the newest sixteen), raw, as JSON encoders write it (with `JSON.stringify`'s escapes, and with its slashes escaped too, as some encoders write them), and as a url carries it, percent- or form-encoded with either case of hex; and a stdio server's `env` values whose names have a part ending in a credential word, singular or plural, with any digits after it (`GITHUB_TOKEN`, `API_KEYS`, `PGPASSWORD`, `DB_PASS`, `AUTHORIZATION`, `SESSION_COOKIE`, `TOKEN2`), raw, as JSON encoders write them, and percent-encoded with either case of hex. A stdio server's command-line arguments are not derived. A string replaced on its own has at least eight characters, since a shorter one can't be told from ordinary text, and is replaced wherever it occurs. Every non-empty query parameter is also replaced as its whole `name=value`, only where no letter or digit adjoins the pair (so `v=2` leaves `dev=2` alone); a short value goes only that way. A connection's event records use a redactor built once for it, and built again once a dial has sent something new. An operation's records replace the credentials of the configurations present when it began and of those present when each record is made, so a server removed, or replaced by a restart, while the operation waits is still covered, and so is one connected meanwhile. Every place a credential matches is replaced, places that overlap included, and text that may come from any server is matched against every server's strings at once, so two servers' secrets that overlap are both replaced whole. Over HTTP, which only the modern engine speaks, a 401 or 403's response body is left out of the request error's data; where the HTTP client builds the error's message from the body, as it does for most statuses, the message is redacted like any record. Credentials neither the configuration holds nor a dial sent are outside this. JSON-RPC answers, and the error a host's own connect raises, pass as they are. +- Server and transport text the framework records has the server's configured credentials replaced, in both engines: connection traces, console lines, stderr traces, the ops alert (and so `logs/failures.log` and the webhook), an MCP tool call that fails without an answer, as its caller and the model get it and in the `tool:failed` trace's message and stack. The credentials are those the configuration holds and those its dials sent: from the url as each dial sends it (a WebSocket dial sets the token in the query, which writes the whole query again, form-encoded, and an access provider's token can be in it), credential-like path segments and query names, credential-shaped or credential-named query values (each as written and decoded), every non-empty parameter as its whole `name=value`, and a WebSocket url's username and password, its userinfo as one unit before its `@` (the username alone when there's no password), with no length floor, so a short password goes with its username and a short username alone goes too, and the Basic credential a WebSocket client sends for it; the static `token`, and each token an access provider handed a dial (the newest sixteen), raw, as JSON encoders write it (with `JSON.stringify`'s escapes, and with its slashes escaped too, as some encoders write them), and as a url carries it, percent- or form-encoded with either case of hex; and a stdio server's `env` values whose names have a part ending in a credential word, singular or plural, with any digits after it (`GITHUB_TOKEN`, `API_KEYS`, `PGPASSWORD`, `DB_PASS`, `AUTHORIZATION`, `SESSION_COOKIE`, `TOKEN2`), raw, as JSON encoders write them, and percent-encoded with either case of hex. A stdio server's command-line arguments are not derived. A string replaced on its own has at least eight characters, since a shorter one can't be told from ordinary text, and is replaced wherever it occurs. Every non-empty query parameter is also replaced as its whole `name=value`, only where no letter or digit adjoins the pair (so `v=2` leaves `dev=2` alone); a short value goes only that way. A connection's event records use a redactor built once for it, and built again once a dial has sent something new. An operation's records replace the credentials of the configurations present when it began and of those present when each record is made, so a server removed, or replaced by a restart, while the operation waits is still covered, and so is one connected meanwhile. Every place a credential matches is replaced, places that overlap included, and text that may come from any server is matched against every server's strings at once, so two servers' secrets that overlap are both replaced whole. Over HTTP, which only the modern engine speaks, a 401 or 403's response body is left out of the request error's data; where the HTTP client builds the error's message from the body, as it does for most statuses, the message is redacted like any record. Credentials neither the configuration holds nor a dial sent are outside this, and so are a stdio server's command-line arguments. JSON-RPC answers, and the error a host's own connect raises, pass as they are. diff --git a/src/framework.ts b/src/framework.ts index e4317cdf8..f5560b653 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -16592,20 +16592,19 @@ export class AgentFramework { const err = error instanceof Error ? error : new Error(String(error)); const message = redactServerText(err.message, config); console.error(`Failed to connect MCPL server "${config.id}":`, message); - // Reaching this catch means no stub was created, so nothing will - // retry — except in the edge case where addServer succeeded (with a - // reconnecting stub) and a later setup step threw; config.reconnect - // is the best cheap approximation of that. + // Whether anything retries is the registered connection's to say: + // usually nothing is left registered, but a legacy connect whose + // setup threw after its handshake leaves a reconnecting stub, and a + // launch that couldn't be reaped leaves a connection that won't + // reconnect. A protocol-version verdict and a refused configuration + // never retry. + const left = this.modernServers().get(config.id) ?? this.mcplServerRegistry?.getServer(config.id); this.emitTrace({ type: 'mcpl:server-connect-failed', serverId: config.id, error: message, attempt: 0, - // A protocol-version verdict is never retried (no common revision; - // only configuration can fix it), and neither is a configuration - // refused before anything was launched. - willRetry: config.reconnect === true && !(error instanceof McplProtocolVersionError) - && !(error instanceof McpConfigurationRefusal), + willRetry: !!left?.willReconnect, }); // So the server doesn't vanish quietly: alert at once, as the // reconnect path does for the same verdict, since no attempt follows. @@ -17157,9 +17156,10 @@ export class AgentFramework { } } - /** The part of a connect that runs holding its id. `displaced`: a - * disconnect has taken the id over and is waiting for this connect. It - * hands back the connection it registered. */ + /** The part of a connect that runs holding its id, handing back the + * connection it registered. `displaced`: whether a disconnect has taken + * the id over and is waiting for this connect, which a legacy connect asks + * once its handshake is done. */ private async connectHeldMcplServer( config: import('./mcpl/types.js').McplServerConfig, family: 'legacy' | 'modern', @@ -17926,8 +17926,9 @@ export class AgentFramework { * A disconnect of the id the restart moves the server to (its own id, for * a restart under the same id), or a `stop()`, that begins during the * restart ends it, as a disconnect ends a connect in flight: the restart - * rejects, saying the server is down, and its holds are released. A - * restart whose connect fails leaves the server down, configured under the + * rejects, saying the server is down, and its holds are released, and the + * server isn't left configured, as after any disconnect. A restart whose + * connect fails on its own leaves the server down, configured under the * new configuration and its id, and holding its tool prefix, as any failed * connect does: `listMcplServers()` lists it as not connected, and a * restart or a disconnect under that id tries it again or removes it. @@ -18658,8 +18659,9 @@ export class AgentFramework { attempt: params.attempt, willRetry: connection.willReconnect, }); - // `permanent`: reconnecting halted on a launch that couldn't be reaped. - // No later attempt will come, so alert now. + // `permanent`: reconnecting halted, on a launch that couldn't be + // reaped or a protocol-version verdict. No later attempt will come, so + // alert now. if (params.permanent || params.attempt >= 5) { this.opsAlert('mcpl-down', connection.id, `MCP server unreachable (attempt ${params.attempt}): ${recorded(params.error)}`); } @@ -18683,13 +18685,21 @@ export class AgentFramework { }); this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` }); // Without reconnect a server lost on its own is gone, as the MCPL - // registry drops one; its configuration stays listed as disconnected. + // registry drops one, once its child's reap is confirmed: until then it + // holds the id, so nothing launches beside a child that may still run, + // and stop() and a disconnect still reach it. A reap that fails keeps + // it registered, holding the id, as a failed disconnect does, for a + // disconnect to retry. Its configuration stays listed as disconnected. // One the host is closing stays until that teardown is done: whoever - // closes it removes it, so the id stays held meanwhile. - if (!connection.isClosed && !connection.willReconnect && this.modernServers().get(connection.id) === connection) { - this.modernServers().delete(connection.id); - // Gone for good: its tools leave the agents' list, as a disconnect's do. - this.handleToolsListChanged(connection.id); + // closes it removes it. + if (!connection.isClosed && !connection.willReconnect) { + void connection.lossReaped().then(() => { + if (connection.isClosed || this.modernServers().get(connection.id) !== connection) return; + if (!connection.holdsUnreapedChild) this.modernServers().delete(connection.id); + // Gone for good either way: its tools leave the agents' list, as a + // disconnect's do. + this.handleToolsListChanged(connection.id); + }); } }); } diff --git a/src/index.ts b/src/index.ts index bf32ceb17..cd1e7ae85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,9 +88,11 @@ export { ChannelRegistry } from './mcpl/index.js'; // connectMcplServer / disconnectMcplServer / restartMcplServer) export type { McplServerConfig } from './mcpl/index.js'; -// MCPL client connection (exposed so external tooling — server playtests, -// health probes, protocol harnesses — can dial an MCPL server exactly the -// way the framework host does, over stdio or WebSocket) +// Both engines' client connections, and the configuration helpers and +// protocol revisions they share (exposed so external tooling, such as server +// playtests, health probes and protocol harnesses, can reach a server +// exactly the way the framework host does: MCPL over stdio or WebSocket, and +// modern MCP over stdio or Streamable HTTP) export { McplServerConnection, McplRequestError, McplProtocolVersionError, McplUnreapedLaunchError } from './mcpl/index.js'; export { ModernMcpConnection } from './mcpl/index.js'; export type { ModernToolCallResult } from './mcpl/index.js'; diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 0ea1f2592..8614f01fc 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -33,9 +33,9 @@ * launch is reaped, so a second child never runs beside the first. A * stdio child that stays alive but silently stops reading its stdin is * no loss the pipes show: each call's deadline is the only bound on it. - * A server that shares - * no revision with this client is a configuration verdict, as for legacy - * (`McplProtocolVersionError`): it is never retried. List-change + * A server that shares no revision with this client is a configuration + * verdict, as for legacy (`McplProtocolVersionError`): it is never + * retried. List-change * awareness is kept while the connection lives: a subscription the server * advertises but that didn't open, or that ended, is reopened. Each connect * is a generation, so a superseded client never touches a newer one. @@ -451,6 +451,20 @@ export class ModernMcpConnection extends EventEmitter { return this.closedByHost; } + /** Whether a child this connection launched couldn't be confirmed exited. + * While it's true the connection owns that child, so whatever registered + * it keeps its id held; close() retries the reap. */ + get holdsUnreapedChild(): boolean { + return this.unreaped.length > 0; + } + + /** The reap of a session lost on its own, settled once it's done, whether + * or not it confirmed the child exited (holdsUnreapedChild says which); + * settled at once when no such reap is running. */ + lossReaped(): Promise { + return this.losing?.done ?? Promise.resolve(); + } + /** A failed generation's cleanup couldn't reap its child: keep owning it, * and halt reconnecting until close() settles it. */ private holdUnreaped(session: Session, failure: Error): void { @@ -483,8 +497,10 @@ export class ModernMcpConnection extends EventEmitter { /** * The first connect. Without `reconnect` a failure throws. With it, the * connection keeps retrying in the background, as the legacy engine's - * stub does, and reports `connect-failed`. A connection closed before or - * during its first connect resolves unconnected. A connection starts once: + * stub does, and reports `connect-failed`, unless the failed launch + * couldn't be reaped: no retry can come then, so it throws too. A + * connection closed before or during its first connect resolves + * unconnected. A connection starts once: * later calls return the first call's promise, so a second session never * opens beside the first. */ @@ -503,12 +519,9 @@ export class ModernMcpConnection extends EventEmitter { this.reconnectEnabled = false; throw error; } - if (!this.reconnectEnabled) throw error; - const halted = this.unreapedFailure(); - if (halted) { - this.emit('connect-failed', { error: `${(error as Error).message}; ${halted}`, attempt: 0, permanent: true }); - return; - } + // A launch that couldn't be reaped halts reconnecting, so no retry + // will come: the first connect fails, as one without reconnect does. + if (!this.reconnectEnabled || this.holdsUnreapedChild) throw error; console.error(`MCP server "${this.id}" initial connect failed, will retry:`, redactServerText((error as Error).message, this.config)); this.reconnectAttempts = 1; this.emit('connect-failed', { error: (error as Error).message, attempt: 0 }); @@ -728,18 +741,25 @@ export class ModernMcpConnection extends EventEmitter { if (session?.generation !== generation) return; this.session = null; this.cancelRelisten(); - this.emit('close', { reason }); // Reap before any reconnect, so a second child never runs beside the // first. A transport usually closes once its child has exited, but a // loss read from a failed pipe can come while the child still runs // (R1-1). A launch that can't be reaped halts reconnecting, as a failed - // connect's does. + // reconnect's does, and is a permanent failure, as that one is: no + // later attempt will come. The reap starts, and is recorded, before + // 'close' is emitted, so a listener that throws can't skip it. const done = ModernMcpConnection.closeSession(session).then((failure) => { if (this.losing?.session === session) this.losing = null; - if (failure) this.holdUnreaped(session, failure); - else if (this.willReconnect) this.scheduleReconnect(); + if (failure) { + this.holdUnreaped(session, failure); + // A close() the host made meanwhile reports the failure itself. + if (!this.closedByHost) { + this.emit('reconnect-failed', { error: `${reason}; ${this.unreapedFailure()}`, attempt: Math.max(1, this.reconnectAttempts), permanent: true }); + } + } else if (this.willReconnect) this.scheduleReconnect(); }); this.losing = { session, done }; + this.emit('close', { reason }); } /** The legacy engine's backoff: doubling from the base interval, capped, @@ -883,6 +903,9 @@ export class ModernMcpConnection extends EventEmitter { * session the host closed while live completes, not on a failed close. */ close(): Promise { + // Closed from this call on: a connect or start() in the same turn sees + // it, and launches nothing. + this.closedByHost = true; const previous = this.closing; const next = (previous ? previous.catch(() => {}) : Promise.resolve()).then(() => this.closeStep()); this.closing = next; @@ -920,7 +943,8 @@ export class ModernMcpConnection extends EventEmitter { } // The connect's own cleanup, and a lost session's reap, close their // generation again, after the close above; each `done` settles once it - // has. A child either couldn't reap is this close's failure too. + // has. A child that either cleanup couldn't reap is this close's failure + // too. for (const pending of [opening, losing]) { if (!pending) continue; await pending.done; diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index 31f2e74e0..30c84290c 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -24,6 +24,7 @@ */ import { DENY_BY_DEFAULT, capabilityPatternMatches } from './capability-grant.js'; import { DEFAULT_EAGER_FETCH_MAX_BYTES, DEFAULT_MAX_TOTAL_BYTES } from './reference-fetcher.js'; +import { DEFAULT_MAX_INPUT_BYTES } from './tool-lifecycle.js'; import type { McplServerConfig, ScopeConfig } from './types.js'; /** The one revision the legacy engine offers and accepts. */ @@ -457,7 +458,8 @@ function isPlainRecord(value: unknown): value is Record { /** * The parts of a configuration's MCPL policy whose values grant something, * by path, such as `allowHostCommands`, `scopes..whitelist`, - * `enabledCapabilities: toolLifecycle.observe` or `autofetch.maxBytes`: each + * `enabledCapabilities: toolLifecycle.observe`, `toolLifecycle.maxInputBytes` + * or `autofetch.maxBytes`: each * asks for behaviour only an MCPL server can have. A modern server has none, * so such a value can't apply there, and is refused rather than ignored, so * that an operator who chose it learns so. @@ -468,9 +470,10 @@ function isPlainRecord(value: unknown): value is Record { * `enabledCapabilities` patterns that match no path denied by default, since * elsewhere it is an allow-list too; `disabledFeatureSets`, * `disabledCapabilities`, a scope's `blacklist`, `channelSubscription: - * 'manual'` (the default), `toolLifecycle` with only `maxInputBytes` or - * with an `observe` or `inputs` that isn't a policy object (which grants - * nothing), an `autofetch` bound at or below its default, and + * 'manual'` (the default), `toolLifecycle` with only a `maxInputBytes` at + * or below its default or with an `observe` or `inputs` that isn't a policy + * object (which grants nothing), an `autofetch` bound at or below its + * default, and * `shouldTriggerInference`, which only narrows which channel messages * trigger inference. So a host that stamps a deny-list on every server it * deploys can deploy a modern one. Empty values grant nothing either. @@ -501,6 +504,12 @@ export function mcplPolicyGrants(config: McplServerConfig): string[] { for (const key of ['observe', 'inputs'] as const) { if (isPlainRecord((config.toolLifecycle as Record | undefined)?.[key])) grants.push(`toolLifecycle.${key}`); } + // A larger input bound than the default asks for more than MCPL gives + // by default, as an autofetch bound above its default does. + const maxInputBytes = (config.toolLifecycle as Record | undefined)?.maxInputBytes; + if (maxInputBytes !== undefined && maxInputBytes !== null && !(typeof maxInputBytes === 'number' && maxInputBytes <= DEFAULT_MAX_INPUT_BYTES)) { + grants.push('toolLifecycle.maxInputBytes'); + } const bounds = [['maxBytes', DEFAULT_EAGER_FETCH_MAX_BYTES], ['maxTotalBytes', DEFAULT_MAX_TOTAL_BYTES]] as const; for (const [key, ceiling] of bounds) { const value = (config.autofetch as Record | undefined)?.[key]; diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 7921eecf0..5fc6597db 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -758,7 +758,7 @@ export class McplServerConnection extends EventEmitter { // WebSocket frame that arrives with the initialize response, or while a // failed handshake's close() runs, is such an error. const logAfterSettled = (err: Error) => { - console.error(`[mcpl] ${config.id}: transport error after the handshake settled: ${err.message}`); + console.error(`[mcpl] ${config.id}: transport error after the handshake settled: ${redactServerText(err.message, config)}`); }; try { @@ -1290,7 +1290,14 @@ export class McplServerConnection extends EventEmitter { } if (this.transport) { this.hostCloseRequested = true; - await this.transport.close(); + try { + await this.transport.close(); + } catch (error) { + // The close couldn't confirm the child exited, so its close event + // never came: the next close is not one the host asked for. + this.hostCloseRequested = false; + throw error; + } return; } this.closed = true; @@ -1416,7 +1423,7 @@ export class McplServerConnection extends EventEmitter { // malformed frame from a WebSocket server, is logged here rather than // thrown where nothing catches it. transport.on('error', (err: Error) => { - console.error(`[mcpl] ${this.id}: a transport closed before it served reported an error: ${err.message}`); + console.error(`[mcpl] ${this.id}: a transport closed before it served reported an error: ${redactServerText(err.message, config)}`); }); await transport.close().catch((error: unknown) => this.holdUnreaped(transport, errorOf(error))); return; diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index 3d8ccec9d..d159934ec 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -312,6 +312,18 @@ const STDIO_EXIT_GRACE_MS = 2_000; /** Timeout for the WebSocket to reach OPEN before we give up (ms). */ const WS_OPEN_TIMEOUT_MS = 15_000; +/** `work`, or `signal`'s reason once it aborts, whichever comes first. The + * listener comes off the signal when `work` settles, so a long-lived signal, + * such as a reconnect loop's, doesn't gather one per dial. */ +function raceAbort(work: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)); + }); +} + export class WebSocketTransport extends McplTransport { readonly kind = 'websocket' as const; @@ -355,7 +367,7 @@ export class WebSocketTransport extends McplTransport { // provider; whatever the provider does later is ignored. const provided = Promise.resolve(config.accessProvider()); provided.catch(() => { /* a late failure after the dial was abandoned */ }); - token = (signal ? await Promise.race([provided, rejectOnAbort(signal)]) : await provided) ?? token; + token = (signal ? await raceAbort(provided, signal) : await provided) ?? token; } catch (err) { if (signal?.aborted) throw abandoned(); // Fall back to the static token (if any) rather than failing the dial @@ -492,14 +504,6 @@ export function isWebSocketTransport(config: McplServerConfig): boolean { * The returned transport is connected at the byte level but has NOT * performed the MCPL handshake. */ -/** A promise that rejects once `signal` aborts, and never settles otherwise. */ -function rejectOnAbort(signal: AbortSignal): Promise { - return new Promise((_resolve, reject) => { - if (signal.aborted) reject(signal.reason); - else signal.addEventListener('abort', () => reject(signal.reason), { once: true }); - }); -} - export async function openTransport(config: McplServerConfig, signal?: AbortSignal): Promise { const binding = resolveServerBinding(config); if (binding.family !== 'legacy') { diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts index bdc6c803d..64bcf0e4b 100644 --- a/test/mcp-modern-outcomes.test.ts +++ b/test/mcp-modern-outcomes.test.ts @@ -197,6 +197,8 @@ test('a lost transport whose child can\'t be reaped halts reconnecting (R1-1)', const connection = await connect('', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }); const errors: string[] = []; connection.on('error', (error: Error) => errors.push(error.message)); + const failures: Array<{ error: string; permanent?: boolean }> = []; + connection.on('reconnect-failed', (failure: { error: string; permanent?: boolean }) => failures.push(failure)); const line = liveLine(connection); const realKill = line.child.kill.bind(line.child); line.child.kill = () => true; // signals never arrive: the child outlives both bounds @@ -208,24 +210,102 @@ test('a lost transport whose child can\'t be reaped halts reconnecting (R1-1)', line.markClosed({ reason: 'stdio pipe failed' }); await until(() => errors.some((m) => /Not starting another launch while it may still be running/.test(m)), 'the launch held as unreaped', 8000); assert.equal(connection.willReconnect, false); + // No later attempt will come, so the halt is a permanent failure, which the + // framework raises as an alert. + assert.equal(failures.length, 1); + assert.equal(failures[0]!.permanent, true); + assert.match(failures[0]!.error, /reconnecting is halted/); await new Promise((r) => setTimeout(r, 200)); assert.equal(wire(log).filter((e) => e.event === 'start').length, 1, 'no second launch'); }); +test("a close() during a lost transport's failing reap reports the failure itself, with no reconnect failure beside it (R1-1)", async () => { + const log = scratchLog(); + const connection = await connect('', log); + const failures: unknown[] = []; + connection.on('reconnect-failed', (failure: unknown) => failures.push(failure)); + connection.on('error', () => { /* the held launch, reported */ }); + const line = liveLine(connection); + const realKill = line.child.kill.bind(line.child); + line.child.kill = () => true; // signals never arrive: the child outlives both bounds + cleanups.push(async () => { + realKill('SIGKILL'); + await until(() => !alive(line.child.pid), 'the held child killed'); + }); + line.markClosed({ reason: 'stdio pipe failed' }); + // The host closes it while the lost child's reap runs: the close's + // rejection is the report, so no permanent failure goes out beside it. + await assert.rejects(connection.close()); + assert.deepEqual(failures, []); +}); + +test("a 'close' listener that throws doesn't skip a lost transport's reap (R1-1)", async () => { + const log = scratchLog(); + const connection = await connect('ignore-sigterm', log); + const first = wire(log).find((e) => e.event === 'start')!.pid!; + cleanups.push(() => { try { process.kill(first, 'SIGKILL'); } catch { /* reaped already */ } }); + connection.on('close', () => { throw new Error('a listener failed'); }); + try { + liveLine(connection).markClosed({ reason: 'stdio pipe failed' }); + } catch { /* the listener's own error, wherever it surfaces */ } + // The child ignores SIGTERM, so its reap ends with SIGKILL after 2 s. + await until(() => !alive(first), "the lost child's reap", 8000); +}); + +test('close() marks a connection closed at once, so a start() in the same turn launches nothing', async () => { + const log = scratchLog(); + const connection = ModernMcpConnection.create({ id: 'raw', command: process.execPath, args: [SERVER, '', log], protocol: 'modern' }); + const closing = connection.close(); + assert.equal(connection.isClosed, true, 'closed from the call on'); + await connection.start(); + await closing; + assert.equal(connection.isConnected, false); + assert.equal((connection as unknown as { generation: number }).generation, 0, 'no launch began'); +}); + +/** The pid of each child StdioTransport.spawn starts from here on, from the spawn itself. */ +function spawnedPids(): number[] { + const realSpawn = StdioTransport.spawn; + const pids: number[] = []; + (StdioTransport as unknown as { spawn: typeof StdioTransport.spawn }).spawn = async (config) => { + const transport = await realSpawn.call(StdioTransport, config); + pids.push((transport as unknown as { child: { pid: number } }).child.pid); + return transport; + }; + cleanups.push(() => { (StdioTransport as unknown as { spawn: typeof StdioTransport.spawn }).spawn = realSpawn; }); + return pids; +} + +test("a read error on a stdio child's stdout or stderr is reported through the transport's 'error', never thrown with nothing listening", async () => { + const transport = await StdioTransport.spawn({ id: 'pipes', command: process.execPath, args: ['-e', 'setInterval(() => {}, 1 << 30)'] }); + cleanups.push(() => transport.close()); + const child = (transport as unknown as { child: import('node:child_process').ChildProcess }).child; + assert.doesNotThrow(() => child.stdout!.emit('error', new Error('stdout read failed')), 'stdout, nothing listening'); + assert.doesNotThrow(() => child.stderr!.emit('error', new Error('stderr read failed')), 'stderr, nothing listening'); + const errors: string[] = []; + transport.on('error', (error: Error) => errors.push(error.message)); + child.stdout!.emit('error', new Error('stdout read failed')); + child.stderr!.emit('error', new Error('stderr read failed')); + assert.deepEqual(errors, ['stdout read failed', 'stderr read failed']); +}); + test('close() right after start() ends a modern stdio connect still spawning, without waiting out its discover probe', async () => { const log = scratchLog(); + const pids = spawnedPids(); const connection = ModernMcpConnection.create({ id: 'raw', command: process.execPath, args: [SERVER, 'hang-discover', log], protocol: 'modern' }); - const started = connection.start().then(() => null, (error: unknown) => error); const began = Date.now(); + const started = connection.start().then(() => null, (error: unknown) => error); await connection.close(); const tookMs = Date.now() - began; assert.ok(tookMs < 5000, `close() took ${tookMs} ms, not the discover probe's 30 s`); + // The child may be ended before it can log its start, so its pid comes + // from the spawn. + assert.equal(pids.length, 1, 'one launch'); + assert.equal(alive(pids[0]!), false, 'the child is gone once close() resolved'); await started; + const endedMs = Date.now() - began; + assert.ok(endedMs < 5000, `the connect ended after ${endedMs} ms, not the discover probe's 30 s`); assert.equal(connection.isConnected, false); - // The child may have been ended before it could log its start. - for (const launch of wire(log).filter((e) => e.event === 'start')) { - assert.equal(alive(launch.pid!), false, 'the child is gone once close() resolved'); - } }); test('close() during a reconnect handshake ends the launch it was waiting on, and stops', async () => { diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index a9ce26267..5ad3a6c79 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -22,7 +22,9 @@ import { mcplPolicyGrants, MAX_TIMER_MS, } from '../src/mcpl/protocol-family.js'; +import { getEventListeners } from 'node:events'; import { DEFAULT_EAGER_FETCH_MAX_BYTES, DEFAULT_MAX_TOTAL_BYTES } from '../src/mcpl/reference-fetcher.js'; +import { DEFAULT_MAX_INPUT_BYTES } from '../src/mcpl/tool-lifecycle.js'; import { ModernMcpConnection } from '../src/mcpl/modern-connection.js'; import { WebSocketTransport, buildWebSocketUrl } from '../src/mcpl/transport.js'; import type { McplServerConfig } from '../src/mcpl/types.js'; @@ -158,6 +160,7 @@ test('MCPL policy on a modern server is refused where its value grants, and acce [{ toolLifecycle: { inputs: { tools: ['x--*'] } } }, 'toolLifecycle.inputs'], [{ autofetch: { maxBytes: DEFAULT_EAGER_FETCH_MAX_BYTES + 1 } }, 'autofetch.maxBytes'], [{ autofetch: { maxTotalBytes: DEFAULT_MAX_TOTAL_BYTES + 1 } }, 'autofetch.maxTotalBytes'], + [{ toolLifecycle: { maxInputBytes: DEFAULT_MAX_INPUT_BYTES + 1 } }, 'toolLifecycle.maxInputBytes'], ]; // Patterns reaching every path denied by default name each. A `*` matches // one segment, and counts must agree (capabilityPatternMatches). @@ -166,6 +169,13 @@ test('MCPL policy on a modern server is refused where its value grants, and acce 'enabledCapabilities: toolLifecycle.observe', 'enabledCapabilities: toolLifecycle.inputs', ]); + // An entry that isn't a string can't be matched, so it's read as asking + // for every path denied by default. + assert.deepEqual(mcplPolicyGrants(modern({ enabledCapabilities: [42 as never] })), [ + 'enabledCapabilities: contextHooks.beforeInference.inject.system', + 'enabledCapabilities: toolLifecycle.observe', + 'enabledCapabilities: toolLifecycle.inputs', + ]); for (const [config, path] of granting) { assert.deepEqual(mcplPolicyGrants(modern(config)), [path], path); const problems = serverConfigProblems(modern(config)); @@ -189,6 +199,7 @@ test('MCPL policy on a modern server is refused where its value grants, and acce { scopes: { files: { whitelist: [] } } }, { channelSubscription: 'manual' }, { toolLifecycle: { maxInputBytes: 1024 } }, + { toolLifecycle: { maxInputBytes: DEFAULT_MAX_INPUT_BYTES } }, { toolLifecycle: { observe: false } as never }, { autofetch: { maxBytes: DEFAULT_EAGER_FETCH_MAX_BYTES, maxTotalBytes: 1 } }, { shouldTriggerInference: () => false }, @@ -369,6 +380,15 @@ test("an access provider's token is removed once a dial has sent it, in a WebSoc assert.ok(!header.includes('prov-http'), header); }); +test("a dial with an access provider takes its abort listener off the signal once the provider has answered", async () => { + // A reconnect loop's signal lives across many dials. + const controller = new AbortController(); + for (let n = 1; n <= 3; n++) { + await assert.rejects(WebSocketTransport.open(cfg({ id: 'ws', url: 'ws://127.0.0.1:1/mcpl', accessProvider: async () => `prov-loop-${n}-0123456789` }), controller.signal)); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0, `after dial ${n}`); + } +}); + test("a token a dial sends again is the newest, so the oldest of the others goes first", () => { const config = cfg({ id: 'r', url: 'http://127.0.0.1:1/mcp' }); noteDialSent(config, { token: 'prov-again-0000' }); diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index ac16a99fb..6d0bd79ca 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -174,7 +174,9 @@ test('a modern connect is refused while a legacy connect for the same id is stil test('a disconnect during a legacy handshake ends that connect at once, and removes it whole', async () => { await withFramework(async ({ framework, dir }) => { const log = join(dir, 'legacy.log'); - const pending = framework.connectMcplServer(legacy('same', 'slow', log, { SLOW_INIT_MS: '600' })); + // The handshake never finishes on its own, so only the disconnect can + // end the connect. + const pending = framework.connectMcplServer(legacy('same', 'slow', log, { SLOW_INIT_MS: '3600000' })); await until(() => launches(log).length === 1, 'the legacy launch, handshaking'); const disconnecting = framework.disconnectMcplServer('same'); await assert.rejects(framework.connectMcplServer(legacy('same', 'ok', log)), /"same" is still being disconnected/); @@ -268,6 +270,114 @@ test('modern: a failed connect whose cleanup cannot reap stays registered until }); }); +test('modern, with reconnect: a failed first connect whose launch cannot be reaped fails, and stays registered until a disconnect confirms the exit', async () => { + await withFramework(async ({ framework, dir }) => { + const log = join(dir, 'raw.jsonl'); + const reaping = unreapable(); + // No retry can come beside a launch that may still run, so the connect + // fails rather than reporting a server that will never serve. + await assert.rejects( + framework.connectMcplServer({ ...modern('m', 'reject-discover', log), reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }), + /server\/discover .*; its cleanup also failed: .*could not be reaped\. MCP server "m" stays registered, closed, until its exit is confirmed: disconnect it to retry the reap/, + ); + await assert.rejects(framework.connectMcplServer(modern('m', '', log)), /"m" is already registered \(not connected and not reconnecting/); + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.equal(launches(log).length, 1, 'nothing launched beside it'); + + await killAndWait(reaping.stuck[0]!); + reaping.active = false; + await framework.disconnectMcplServer('m'); + await framework.connectMcplServer(modern('m', '', log)); + assert.equal(status(framework, 'm')?.connected, true); + }); +}); + +test("at startup, a modern server with reconnect whose first launch can't be reaped is traced as not retrying", async () => { + const dir = mkdtempSync(join(tmpdir(), 'mcp-ownership-')); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + const log = join(dir, 'raw.jsonl'); + const reaping = unreapable(); + const traces: Array<{ type: string; serverId?: string; willRetry?: boolean }> = []; + const watcher = { + name: 'trace-watcher', + async start(ctx: { onTrace(listener: (event: unknown) => void): void }) { ctx.onTrace((event) => traces.push(event as never)); }, + async stop() {}, + getTools: () => [], + handleToolCall: async () => ({ success: false, error: 'no tools', isError: true }), + onProcess: async () => ({}), + }; + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], + modules: [watcher as never], + mcplServers: [{ ...modern('m', 'reject-discover', log), reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }], + }); + cleanups.push(async () => { + for (const stuck of reaping.stuck) await killAndWait(stuck); + await framework.stop().catch(() => {}); + }); + const failed = traces.find((t) => t.type === 'mcpl:server-connect-failed' && t.serverId === 'm'); + // No retry can come beside a launch that may still run. + assert.equal(failed?.willRetry, false); +}); + +/** The stdio line under a modern server's live session, reached from inside, + * to stand for a loss read from a failed pipe while the child still runs. */ +function liveLineOf(framework: AgentFramework, id: string): { markClosed: (info: { reason: string }) => void; child: { pid: number; kill: (signal?: string) => boolean } } { + const connection = (framework as unknown as { modernServers(): Map }).modernServers().get(id); + assert.ok(connection?.session, 'a live session'); + return connection.session.wire.inner.line as never; +} + +test('modern, without reconnect: a server lost on its own holds its id until its child is reaped, then leaves', async () => { + await withFramework(async ({ framework, dir }) => { + const log = join(dir, 'raw.jsonl'); + await framework.connectMcplServer(modern('m', 'ignore-sigterm', log)); + assert.ok(toolsOf(framework, 'm').length > 0); + const servers = (framework as unknown as { modernServers(): Map }).modernServers(); + const connection = servers.get('m'); + const line = liveLineOf(framework, 'm'); + line.markClosed({ reason: 'stdio pipe failed' }); + // The child ignores SIGTERM, so its reap takes about 2 s: meanwhile the + // id is held, and nothing launches beside the child. + assert.equal(servers.get('m'), connection, 'still registered while its reap runs'); + await assert.rejects(framework.connectMcplServer(modern('m', '', log)), /"m" is already registered/); + await until(() => !servers.has('m'), 'gone once its reap is confirmed', 10_000); + assert.equal(alive(line.child.pid), false, 'its child had exited'); + await until(() => toolsOf(framework, 'm').length === 0, 'its tools left the list'); + assert.equal(launches(log).length, 1, 'nothing launched beside it'); + }); +}); + +test("modern, without reconnect: a server lost on its own whose child can't be reaped stays registered, alerts, and its tools leave", async () => { + await withFramework(async ({ framework, dir }) => { + const log = join(dir, 'raw.jsonl'); + const alerts: string[] = []; + framework.onTrace((event) => { + const traced = event as { type: string; kind?: string; agentName?: string }; + if (traced.type === 'ops:alert') alerts.push(`${traced.kind} ${traced.agentName}`); + }); + await framework.connectMcplServer(modern('m', '', log)); + assert.ok(toolsOf(framework, 'm').length > 0); + const line = liveLineOf(framework, 'm'); + const realKill = line.child.kill.bind(line.child); + line.child.kill = () => true; // signals never arrive: the child outlives both bounds + cleanups.push(() => { realKill('SIGKILL'); }); + line.markClosed({ reason: 'stdio pipe failed' }); + await until(() => alerts.includes('mcpl-down m'), 'the halt raised as an alert', 10_000); + await until(() => toolsOf(framework, 'm').length === 0, 'its tools left the list'); + assert.equal(status(framework, 'm')?.connected, false, 'still registered, not connected'); + await assert.rejects(framework.connectMcplServer(modern('m', '', log)), /"m" is already registered \(not connected and not reconnecting/); + assert.equal(launches(log).length, 1, 'nothing launched beside it'); + + realKill('SIGKILL'); + await until(() => !alive(line.child.pid), 'the held child killed'); + await framework.disconnectMcplServer('m'); + assert.equal(status(framework, 'm'), undefined); + }); +}); + test("modern: ModernMcpConnection.connect hands back the connection that owns a launch its cleanup couldn't reap", async () => { const dir = mkdtempSync(join(tmpdir(), 'mcp-ownership-')); cleanups.push(() => rmSync(dir, { recursive: true, force: true })); diff --git a/test/mcpl-runtime-lifecycle.test.ts b/test/mcpl-runtime-lifecycle.test.ts index eaf1cb49c..6d770b14d 100644 --- a/test/mcpl-runtime-lifecycle.test.ts +++ b/test/mcpl-runtime-lifecycle.test.ts @@ -61,6 +61,16 @@ process.stdin.on('data', (c) => { /** The framework's inner disconnect, which a restart's own teardown calls * (the public disconnectMcplServer is an operator operation around it). */ type Inner = { runDisconnectMcplServer(id: string): Promise }; +type Internals = { + legacyConnectAborts(): Map; + mcplServerRegistry: { getServer(id: string): { sendToolsList(): Promise } | null } | null; +}; + +async function until(cond: () => boolean, what: string, ms = 10_000): Promise { + const deadline = Date.now() + ms; + while (!cond() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 20)); + assert.ok(cond(), `in time: ${what}`); +} function serverConfig(id: string, extra?: Record) { return { id, command: process.execPath, args: ['-e', STDIO_SERVER], ...extra }; @@ -434,6 +444,10 @@ for (const when of ['teardown', 'gap', 'connect'] as const) { if (when !== 'connect') { // Its connect is refused at once, naming the disconnect. assert.match(refused!.message, /: MCP server "first" is being disconnected, which ends the restart of "first"$/); + } else { + // Its connect, under way, is ended by the disconnect, in whichever + // phase the disconnect finds it. + assert.match(refused!.message, /: MCP server "first" (was not connected: it was disconnected (while connecting|or lost while its tools were being listed)|is being disconnected, which ends the restart of "first")$/); } assert.equal(await host, 'disconnected', 'the disconnect completed'); assert.deepEqual(framework.listMcplServers(), [], 'nothing is left of the server'); @@ -489,8 +503,8 @@ for (const ending of ['stop()', 'a disconnect'] as const) { test(`${ending} ends a legacy connect stuck in its handshake at once`, async () => { await withFramework(true, async (framework) => { const connecting = framework.connectMcplServer(SILENT as never).then(() => null, (error: unknown) => error as Error); - // The child is up and its initialize sent. - await new Promise((resolve) => setTimeout(resolve, 300)); + // The connect is in its handshake, which never finishes. + await until(() => (framework as unknown as Internals).legacyConnectAborts().has('silent'), 'the connect in its handshake'); const started = Date.now(); if (ending === 'stop()') await framework.stop(); else await framework.disconnectMcplServer('silent'); @@ -510,8 +524,8 @@ for (const silentOn of ['featureSets/update', 'tools/list'] as const) { await withFramework(true, async (framework) => { const config = serverConfig('late', { env: { SILENT_ON: silentOn }, requestTimeoutMs: 0 }); const connecting = framework.connectMcplServer(config as never).then(() => null, (error: unknown) => error as Error); - // Past the handshake, waiting for the answer that never comes. - await new Promise((resolve) => setTimeout(resolve, 600)); + // Past the handshake, registered, waiting for the answer that never comes. + await until(() => !!(framework as unknown as Internals).mcplServerRegistry?.getServer('late'), 'the connect past its handshake'); const started = Date.now(); await framework.stop(); const took = Date.now() - started; @@ -526,9 +540,13 @@ test("stop() ends a connect whose tool listing waits on another server that stop await withFramework(false, async (framework) => { // Lists its tools once, at its own connect, and never again. await framework.connectMcplServer(serverConfig('quiet', { env: { SILENT_AFTER: 'tools/list:1' }, requestTimeoutMs: 0 }) as never); + const quiet = (framework as unknown as Internals).mcplServerRegistry!.getServer('quiet')!; + const realList = quiet.sendToolsList.bind(quiet); + let asked = false; + quiet.sendToolsList = () => { asked = true; return realList(); }; const connecting = framework.connectMcplServer(serverConfig('late') as never).then(() => null, (error: unknown) => error as Error); // The new connect is listing every server's tools, and waits on "quiet". - await new Promise((resolve) => setTimeout(resolve, 600)); + await until(() => asked, "the new connect's listing asked quiet"); const started = Date.now(); await framework.stop(); const took = Date.now() - started; diff --git a/test/mcpl-transport-close.test.ts b/test/mcpl-transport-close.test.ts index dbbb02f76..2d3a63860 100644 --- a/test/mcpl-transport-close.test.ts +++ b/test/mcpl-transport-close.test.ts @@ -178,6 +178,29 @@ test("a failed handshake's transport logs an error it reports while it closes, r ); }); +test("a failed handshake's transport error is logged without the server's credentials", { timeout: 10_000 }, async (t) => { + const logged = captureConsoleError(t); + const server = new WebSocketServer({ port: 0, host: '127.0.0.1' }); + t.after(() => new Promise((resolve) => { server.close(() => resolve()); })); + await new Promise((resolve) => { server.once('listening', () => resolve()); }); + server.on('connection', (socket) => { + socket.on('message', (data) => { + const request = JSON.parse(String(data)) as { id?: number; method?: string }; + if (request.method !== 'initialize') return; + socket.send(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32000, message: 'refused by the test server' } })); + (socket as unknown as { _socket: Socket })._socket.write(Buffer.from([0x83, 0x00])); + }); + }); + const { port } = server.address() as AddressInfo; + // The frame's error text, "invalid opcode 3", quotes this token. + await assert.rejects(McplServerConnection.connect({ id: 'malformed', url: `ws://127.0.0.1:${port}`, token: 'invalid opcode' }, { version: '0.5' })); + await waitFor(() => logged.some((line) => line.includes('after the handshake settled')), 'the frame\'s error to be logged'); + assert.ok( + logged.some((line) => line === '[mcpl] malformed: transport error after the handshake settled: Invalid WebSocket frame: redacted 3'), + logged.join('\n'), + ); +}); + /** Settles with the promise's value, or with `fallback` after 5 s, so a test that * waits for something that never comes still reaches its cleanup. */ async function within(promise: Promise, fallback: F): Promise { diff --git a/test/mcpl-ws-transport.test.ts b/test/mcpl-ws-transport.test.ts index dcc85aa7b..9cf4bd433 100644 --- a/test/mcpl-ws-transport.test.ts +++ b/test/mcpl-ws-transport.test.ts @@ -300,6 +300,32 @@ test("a server-side close with code 4001 surfaces on the connection 'close' even } }); +test("a reconnectAfterFailure() whose close fails leaves no host flag behind: a later remote drop is the server's", async () => { + const sockets: WebSocket[] = []; + const wss = startMockMcplWsServer((ws) => sockets.push(ws)); + await waitListening(wss); + let conn: McplServerConnection | undefined; + try { + conn = await McplServerConnection.connect({ id: 'editor', url: wsUrl(wss) }, HOST_CAPS); + conn.ready(); + const transport = (conn as unknown as { transport: { close(): Promise } }).transport; + const realClose = transport.close.bind(transport); + // A teardown that can't confirm its end, as an unreaped child's can't. + transport.close = async () => { throw new Error('could not be confirmed'); }; + await assert.rejects(conn.reconnectAfterFailure(), /could not be confirmed/); + transport.close = realClose; + const remote = new Promise((resolve) => + conn!.once('close', (_code?: number | null, _signal?: string | null, info?: McplConnectionCloseInfo) => resolve(info))); + sockets[0]!.close(4002, 'server going away'); + const info = await withDeadline(remote, 5_000, "'close' after the server's drop"); + assert.equal(info?.byHost, false); + assert.equal(info?.code, 4002); + } finally { + await conn?.close().catch(() => { /* already closed */ }); + wss.close(); + } +}); + test("reconnectAfterFailure() on a live transport reports byHost: true and still reconnects", async () => { const sockets: WebSocket[] = []; const wss = startMockMcplWsServer((ws) => sockets.push(ws)); From b965af049b70b5018989f2e7f4bca8224861cf85 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 18:44:25 -0400 Subject: [PATCH 80/94] fix(mcp): a server the host has given up on leaves the tool list, and its child stays owned until its exit is confirmed An internal review of the previous commit found these. One rule covers the four behaviour changes: a server the host has given up on leaves the agents' tool list, and its child stays owned until its exit is confirmed. - A permanent failure takes the server's tools out of the agents' list, in both engines. With reconnect on, a lost session whose reap failed halted for good, but only the close handler's reconnect-off branch refreshed the tools, decided when 'close' was emitted, so the halted server's tools stayed listed until some unrelated refresh. The same held for a reconnect attempt's protocol-version verdict and for a reconnect launch that couldn't be reaped. Without reconnect, the loss continuation now drops, and refreshes, only a connection whose exit was confirmed; a reap that fails is a permanent failure, which refreshes. - A disconnect whose close can't confirm the exit refreshes the tool list before it rejects. The server was closed either way, but its tools stayed listed. It still keeps its id until a disconnect confirms the exit. - The legacy connectWithReconnect() throws McplUnreapedLaunchError for a first launch it can't reap, as connect() does, instead of resolving with a halted connection. A runtime connect with reconnect then failed at its final check with the wrong reason. The registry already keeps the failed launch's connection on that error. With the previous commit's change to the modern first connect, one rule covers both engines, and the framework's legacy connect-failed alert for a permanent failure goes, since nothing emits one now. - At startup, a failed connect that leaves a child it couldn't reap raises the mcpl-down alert, as a protocol-version verdict does. The previous commit's modern first connect throws there, which had dropped the alert its permanent connect-failed used to raise. - The legacy reconnectAfterFailure() on a connection without reconnect ends it with close(). It closed the transport, which emits 'close' before its reap, so the registry dropped the connection, freeing the id while the child might still run, and a reap that failed left the child unowned. close() emits 'close' only once the exit is confirmed, and keeps a child it can't reap, holding the id. Also: - The previous commit's reset of hostCloseRequested when the recycle's close rejects was a no-op, and is removed with its test. Both real transports emit 'close' at the start of close(), so the legacy close handler has read and cleared the flag before any close can reject. Only a stub whose close rejects without closing reached the reset. - Without reconnect, a lost session's reap failure no longer says "reconnecting is halted". - holdsUnreapedChild's doc says it's settled only once a reap has finished. - The description says a host close emits 'close' only for a live session, and names the startup trace's test as passing before the previous commit. Tests: - mcp-server-ownership: - A modern server lost with reconnect whose child can't be reaped alerts, and its tools leave, as without reconnect. - A legacy reconnect that meets a protocol-version verdict alerts, and its tools leave. - A failed disconnect's tools leave, on both engines. - A legacy failed connect with reconnect whose cleanup can't reap fails with that reason and stays registered, as without reconnect. - At startup, a server with reconnect whose first launch can't be reaped is traced as not retrying, and alerts, on both engines. - A legacy recycle without reconnect whose child can't be reaped keeps the server registered, holding its id. - mcpl-legacy-lifetime: connectWithReconnect() throws for a first launch it can't reap, handing over its owner. - mcp-modern-outcomes: without reconnect, a lost session's reap failure says nothing of reconnecting. The close-during-spawn test's bounds are 15 s, still well under the discover probe's 30 s. - mcp-protocol-family: a dial whose signal aborts while its access provider is called ends then, not when the provider answers. - mcp-recorded-text: the stub framework's records test emits a permanent reconnect failure instead of the permanent connect failure no engine emits now. The legacy console test loses its halted-connection half, whose log line is gone. Considered and left as they are: - A module's close traces report the connection as it is at the loss: it has stopped serving and means to reconnect. A reap that fails later is reported by its own permanent failure. - A bound that isn't a number is refused, as the autofetch bounds are. - A modern server has no feature-set or checkpoint state to clear. - emitTrace isolates its listeners, so the close handler's statements before the drop don't throw. Red-first, Linux, Node 24.22.0: - With src at the previous commit, nine of the new and changed tests fail: in mcp-server-ownership, both failed disconnects' tools, the startup alert, the modern loss with reconnect, the legacy reconnect's verdict, the legacy recycle and the legacy failed connect with reconnect; in mcp-modern-outcomes, the halt wording without reconnect; in mcpl-legacy-lifetime, connectWithReconnect() throwing. - The others pass there: the dial whose provider aborts the signal (its early return was already there), the stub framework's records, the close-during-spawn bounds, the two held-listing tests whose setup changed, and the modern loss without reconnect. - Each of 17 pieces, removed on its own, fails a test. Nine are this commit's: the permanent-failure refresh in each engine, the failed disconnect's refresh, the startup alert, the legacy first connect's throw, the recycle's close(), the halt wording, raceAbort's early return (its test is cancelled at its timeout) and the loss continuation's condition. Eight are earlier pieces whose tests this commit changed or whose catch moved: the connect check's serving and disconnect conditions, publication's serving and close-count conditions, the loss's permanent failure and drop, the startup trace's willRetry and the modern first connect's halt. - With the change, the whole suite passes (node --test over every file: 2,623 tests, none failing). Co-Authored-By: Claude Opus 5.5 --- .../mcp-standard-tool-results.changed.md | 18 +-- changelog.d/modern-mcp-client.added.md | 4 +- src/framework.ts | 39 ++++--- src/mcpl/modern-connection.ts | 8 +- src/mcpl/server-connection.ts | 34 +++--- test/mcp-modern-outcomes.test.ts | 24 +++- test/mcp-protocol-family.test.ts | 14 +++ test/mcp-recorded-text.test.ts | 16 +-- test/mcp-server-ownership.test.ts | 105 +++++++++++++++--- test/mcpl-legacy-lifetime.test.ts | 25 ++--- test/mcpl-ws-transport.test.ts | 26 ----- 11 files changed, 198 insertions(+), 115 deletions(-) diff --git a/changelog.d/mcp-standard-tool-results.changed.md b/changelog.d/mcp-standard-tool-results.changed.md index 72357416d..a86ded399 100644 --- a/changelog.d/mcp-standard-tool-results.changed.md +++ b/changelog.d/mcp-standard-tool-results.changed.md @@ -79,13 +79,13 @@ calls share one teardown, and a later call re-checks a child that couldn't be reaped. A launch whose exit can't be confirmed is never orphaned, and nothing launches beside it: - - `McplServerConnection.connect()` throws the new `McplUnreapedLaunchError`, - whose `connection` owns the launch; its `close()` retries the reap. - `connectWithReconnect()` returns that connection, halted - (`connect-failed` with `permanent: true`, which raises the ops alert), - and a reconnect attempt halts the loop the same way + - `McplServerConnection.connect()` and `connectWithReconnect()` throw the + new `McplUnreapedLaunchError`, whose `connection` owns the launch; its + `close()` retries the reap. A reconnect attempt halts the loop instead (`reconnect-failed`, `permanent: true`). Reconnect attempts run one at a time, and never start before the previous child has exited. + - `reconnectAfterFailure()` on a connection without reconnect ends it + with `close()`, so a child it can't reap stays owned. - `close()` during a reconnect handshake ends that launch before it resolves, a WebSocket dial still opening included. In the framework, `stop()` and a disconnect of the id end a first connect still in its @@ -99,9 +99,11 @@ keeps each connection it couldn't reap, reporting every failure. - In the framework, a disconnect or failed connect whose cleanup can't reap the child keeps the server registered, closed and listed as - disconnected, and its error says so. A connect for that id is refused - until a disconnect, retried once the child is gone, confirms the exit - and completes the removal. + disconnected, and its error says so; its tools leave the agents' list. + A connect for that id is refused until a disconnect, retried once the + child is gone, confirms the exit and completes the removal. A failed + startup connect that leaves such a child raises the `mcpl-down` ops + alert. - One owner per server id, across both families: a connect is refused while another connect for that id is in flight (a legacy handshake included) or a disconnect is still tearing it down. A disconnect closes a registered diff --git a/changelog.d/modern-mcp-client.added.md b/changelog.d/modern-mcp-client.added.md index 0cbc4df94..f8a30a27d 100644 --- a/changelog.d/modern-mcp-client.added.md +++ b/changelog.d/modern-mcp-client.added.md @@ -41,5 +41,7 @@ - **Status:** `listMcplServers()` entries gain `family`, `protocolVersion` and `transport`. - A server lost for good (it closed and won't reconnect), MCPL or modern, - now leaves the agents' tool list, as a disconnected one does. An MCP tool + now leaves the agents' tool list, as a disconnected one does. So does one + whose reconnecting halts for good (`reconnect-failed` with + `permanent: true`, which also raises the ops alert). An MCP tool result marked as an error is traced as `tool:failed`, on both engines. diff --git a/src/framework.ts b/src/framework.ts index f5560b653..c6f64592e 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -16606,9 +16606,11 @@ export class AgentFramework { attempt: 0, willRetry: !!left?.willReconnect, }); - // So the server doesn't vanish quietly: alert at once, as the - // reconnect path does for the same verdict, since no attempt follows. - if (error instanceof McplProtocolVersionError) { + // So the server doesn't vanish quietly: alert at once, since no + // attempt follows, for what needs the operator. That's a + // protocol-version verdict, as the reconnect path alerts for one, or + // a launch that couldn't be reaped, which only a disconnect retries. + if (error instanceof McplProtocolVersionError || left?.holdsUnreapedChild) { this.opsAlert('mcpl-down', config.id, `MCPL server unreachable at startup: ${message}`); } } @@ -17879,6 +17881,10 @@ export class AgentFramework { // handshaking, whose own removal couldn't reap, is re-checked here. await registry.removeServer(id); } catch (error) { + // Closed either way, so its tools leave the agents' list. The server + // keeps its id until a disconnect confirms the exit. + await this.refreshMcplTools(); + this.emitMcplToolDiff(oldToolNames, id); const message = error instanceof Error ? error.message : String(error); throw new Error( `${message}. MCP server "${id}" stays registered, closed, until its exit is confirmed: disconnect it again to retry the reap`, @@ -18526,7 +18532,7 @@ export class AgentFramework { // Surface connect/reconnect failures. Before these traces existed the // only receipt was a console.error on the host's own stderr — invisible // unless someone ssh'd in and read the process log. - connection.on('connect-failed', (params: { error: string; attempt: number; permanent?: boolean }) => { + connection.on('connect-failed', (params: { error: string; attempt: number }) => { this.emitTrace({ type: 'mcpl:server-connect-failed', serverId: connection.id, @@ -18534,11 +18540,6 @@ export class AgentFramework { attempt: params.attempt, willRetry: connection.willReconnect, }); - // `permanent`: the failed launch couldn't be reaped, so no retry will - // start beside it. Alert now, as for a permanent reconnect failure. - if (params.permanent) { - this.opsAlert('mcpl-down', connection.id, `MCPL server unreachable (attempt ${params.attempt}): ${recorded(params.error)}`); - } }); connection.on('reconnect-failed', (params: { error: string; attempt: number; permanent?: boolean }) => { this.emitTrace({ @@ -18562,6 +18563,9 @@ export class AgentFramework { `MCPL server unreachable (attempt ${params.attempt}): ${recorded(params.error)}`, ); } + // Halted for good: its tools leave the agents' list, since it won't + // serve again on its own. + if (params.permanent) this.handleToolsListChanged(connection.id); }); // A late response to a tools/call that already timed out, carrying @@ -18661,10 +18665,12 @@ export class AgentFramework { }); // `permanent`: reconnecting halted, on a launch that couldn't be // reaped or a protocol-version verdict. No later attempt will come, so - // alert now. + // alert now, and the server's tools leave the agents' list: it won't + // serve again on its own. if (params.permanent || params.attempt >= 5) { this.opsAlert('mcpl-down', connection.id, `MCP server unreachable (attempt ${params.attempt}): ${recorded(params.error)}`); } + if (params.permanent) this.handleToolsListChanged(connection.id); }; connection.on('connect-failed', failed); connection.on('reconnect-failed', failed); @@ -18689,14 +18695,15 @@ export class AgentFramework { // holds the id, so nothing launches beside a child that may still run, // and stop() and a disconnect still reach it. A reap that fails keeps // it registered, holding the id, as a failed disconnect does, for a - // disconnect to retry. Its configuration stays listed as disconnected. - // One the host is closing stays until that teardown is done: whoever - // closes it removes it. + // disconnect to retry; that failure is a permanent one, which takes + // its tools out of the list. Its configuration stays listed as + // disconnected. One the host is closing stays until that teardown is + // done: whoever closes it removes it. if (!connection.isClosed && !connection.willReconnect) { void connection.lossReaped().then(() => { - if (connection.isClosed || this.modernServers().get(connection.id) !== connection) return; - if (!connection.holdsUnreapedChild) this.modernServers().delete(connection.id); - // Gone for good either way: its tools leave the agents' list, as a + if (connection.isClosed || connection.holdsUnreapedChild || this.modernServers().get(connection.id) !== connection) return; + this.modernServers().delete(connection.id); + // Gone for good: its tools leave the agents' list, as a // disconnect's do. this.handleToolsListChanged(connection.id); }); diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 8614f01fc..9a0765e97 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -453,7 +453,9 @@ export class ModernMcpConnection extends EventEmitter { /** Whether a child this connection launched couldn't be confirmed exited. * While it's true the connection owns that child, so whatever registered - * it keeps its id held; close() retries the reap. */ + * it keeps its id held; close() retries the reap. It's settled only once + * a reap has finished: while a lost session's reap or a close runs, it + * reads false, so read it once lossReaped() or close() has settled. */ get holdsUnreapedChild(): boolean { return this.unreaped.length > 0; } @@ -478,7 +480,9 @@ export class ModernMcpConnection extends EventEmitter { /** The halt, as a permanent failure for the reconnect events. */ private unreapedFailure(): string | null { const held = this.unreaped[0]; - return held ? `the previous launch could not be reaped (${held.failure.message}); reconnecting is halted` : null; + if (!held) return null; + const halted = this.reconnectEnabled ? '; reconnecting is halted' : ''; + return `the previous launch could not be reaped (${held.failure.message})${halted}`; } /** diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 5fc6597db..1bba76864 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -890,9 +890,10 @@ export class McplServerConnection extends EventEmitter { * When `config.reconnect` is true and the initial connection fails, * resolves immediately with null capabilities and retries in the background. * A configuration this engine can't use, or a protocol-version verdict, - * still throws: retrying can't change either. A failed launch that can't be - * reaped resolves with a halted stub that owns it: no retry starts beside - * a child that may still be running, and close() retries the reap. + * still throws: retrying can't change either. So does a failed launch that + * can't be reaped, as in {@link connect}: no retry starts beside a child + * that may still be running, and the {@link McplUnreapedLaunchError} + * carries the connection that owns it, whose close() retries the reap. * Adapted from Anarchid/agent-framework@mcpl-module-proto. */ static async connectWithReconnect( @@ -908,20 +909,14 @@ export class McplServerConnection extends EventEmitter { // No common protocol revision is a configuration fact, not an outage: // a retry stub would restart the server forever for nothing. A connect // its caller ended (stop(), or a disconnect of its id) isn't retried - // either. - if (!config.reconnect || error instanceof McplProtocolVersionError || signal?.aborted) { + // either, nor is a launch that couldn't be reaped. + if (!config.reconnect || error instanceof McplProtocolVersionError || error instanceof McplUnreapedLaunchError || signal?.aborted) { throw error; } // Surface the initial failure. The event is buffered by the emit() // override until the host wires listeners and calls ready(), so it is // not lost in the window before wireMcplEvents runs. - if (error instanceof McplUnreapedLaunchError) { - const halted = error.connection; - console.error(`MCPL server "${config.id}" initial connect failed, not retrying:`, redactServerText(error.message, config)); - halted.emit('connect-failed', { error: `${(error.cause as Error).message}; ${halted.unreapedFailure()}`, attempt: 0, permanent: true }); - return halted; - } // Non-blocking start: create a disconnected stub that will reconnect in background console.error(`MCPL server "${config.id}" initial connect failed, will retry:`, redactServerText((error as Error).message, config)); const stub = McplServerConnection.createDisconnectedStub(config, hostCapabilities); @@ -1280,24 +1275,23 @@ export class McplServerConnection extends EventEmitter { * Tear down a live transport after an awareness-accounting failure without * disabling configured auto-reconnect. The normal transport close handler * rejects requests, emits lifecycle state, and schedules the next bounded - * reconnect attempt. With reconnect disabled this leaves the server closed. + * reconnect attempt. With reconnect disabled the recycle ends the + * connection for good, as close() does: 'close' comes once the child's + * exit is confirmed, and a child it can't reap stays owned, holding the id. */ async reconnectAfterFailure(): Promise { this.pauseDataPlane(); + if (!this.reconnectEnabled) { + await this.close(); + return; + } if (this.closed) { this.scheduleReconnect(); return; } if (this.transport) { this.hostCloseRequested = true; - try { - await this.transport.close(); - } catch (error) { - // The close couldn't confirm the child exited, so its close event - // never came: the next close is not one the host asked for. - this.hostCloseRequested = false; - throw error; - } + await this.transport.close(); return; } this.closed = true; diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts index 64bcf0e4b..b2bec6d79 100644 --- a/test/mcp-modern-outcomes.test.ts +++ b/test/mcp-modern-outcomes.test.ts @@ -219,6 +219,26 @@ test('a lost transport whose child can\'t be reaped halts reconnecting (R1-1)', assert.equal(wire(log).filter((e) => e.event === 'start').length, 1, 'no second launch'); }); +test("without reconnect, a lost transport whose child can't be reaped is a permanent failure that says nothing of reconnecting (R1-1)", async () => { + const log = scratchLog(); + const connection = await connect('', log); + connection.on('error', () => { /* the held launch, reported */ }); + const failures: Array<{ error: string; permanent?: boolean }> = []; + connection.on('reconnect-failed', (failure: { error: string; permanent?: boolean }) => failures.push(failure)); + const line = liveLine(connection); + const realKill = line.child.kill.bind(line.child); + line.child.kill = () => true; // signals never arrive: the child outlives both bounds + cleanups.push(async () => { + realKill('SIGKILL'); + await until(() => !alive(line.child.pid), 'the held child killed'); + }); + line.markClosed({ reason: 'stdio pipe failed' }); + await until(() => failures.length > 0, 'the permanent failure', 8000); + assert.equal(failures[0]!.permanent, true); + assert.match(failures[0]!.error, /the previous launch could not be reaped/); + assert.doesNotMatch(failures[0]!.error, /reconnecting/); +}); + test("a close() during a lost transport's failing reap reports the failure itself, with no reconnect failure beside it (R1-1)", async () => { const log = scratchLog(); const connection = await connect('', log); @@ -297,14 +317,14 @@ test('close() right after start() ends a modern stdio connect still spawning, wi const started = connection.start().then(() => null, (error: unknown) => error); await connection.close(); const tookMs = Date.now() - began; - assert.ok(tookMs < 5000, `close() took ${tookMs} ms, not the discover probe's 30 s`); + assert.ok(tookMs < 15_000, `close() took ${tookMs} ms, not the discover probe's 30 s`); // The child may be ended before it can log its start, so its pid comes // from the spawn. assert.equal(pids.length, 1, 'one launch'); assert.equal(alive(pids[0]!), false, 'the child is gone once close() resolved'); await started; const endedMs = Date.now() - began; - assert.ok(endedMs < 5000, `the connect ended after ${endedMs} ms, not the discover probe's 30 s`); + assert.ok(endedMs < 15_000, `the connect ended after ${endedMs} ms, not the discover probe's 30 s`); assert.equal(connection.isConnected, false); }); diff --git a/test/mcp-protocol-family.test.ts b/test/mcp-protocol-family.test.ts index 5ad3a6c79..6f23e44f9 100644 --- a/test/mcp-protocol-family.test.ts +++ b/test/mcp-protocol-family.test.ts @@ -389,6 +389,20 @@ test("a dial with an access provider takes its abort listener off the signal onc } }); +test("a dial whose signal aborts while its access provider is called ends then, not when the provider answers", { timeout: 10_000 }, async () => { + const controller = new AbortController(); + await assert.rejects( + WebSocketTransport.open(cfg({ + id: 'ws', + url: 'ws://127.0.0.1:1/mcpl', + // The connection is closed while the provider runs, and the provider + // never answers. + accessProvider: () => { controller.abort(); return new Promise(() => {}); }, + }), controller.signal), + /WebSocket connect abandoned: the connection was closed/, + ); +}); + test("a token a dial sends again is the newest, so the oldest of the others goes first", () => { const config = cfg({ id: 'r', url: 'http://127.0.0.1:1/mcp' }); noteDialSent(config, { token: 'prov-again-0000' }); diff --git a/test/mcp-recorded-text.test.ts b/test/mcp-recorded-text.test.ts index 7a32e4743..570493023 100644 --- a/test/mcp-recorded-text.test.ts +++ b/test/mcp-recorded-text.test.ts @@ -9,7 +9,7 @@ import { Client, type Transport } from '@modelcontextprotocol/client'; import { EventEmitter } from 'node:events'; import type { ModuleContext } from '../src/index.js'; import { AgentFramework } from '../src/framework.js'; -import { McplProtocolVersionError, McplServerConnection, McplUnreapedLaunchError } from '../src/mcpl/server-connection.js'; +import { McplProtocolVersionError, McplServerConnection } from '../src/mcpl/server-connection.js'; import { ModernMcpConnection, malformedLineDiagnostic } from '../src/mcpl/modern-connection.js'; import { noteDialSent } from '../src/mcpl/protocol-family.js'; import { MockMembrane } from './helpers/mock-membrane.js'; @@ -52,13 +52,16 @@ test('both framework engines scrub failure, error and stderr records before trac framework.mcplServerConfigs = new Map([[config.id, config]]); framework.emitTrace = (event: unknown) => traces.push(event); framework.opsAlert = (_kind: string, _id: string, text: string) => alerts.push(text); + // A permanent failure also refreshes the tool list. + framework.handleToolsListChanged = () => {}; const connection = Object.assign(new EventEmitter(), { id: config.id, willReconnect: true }); framework[method](connection, config); - connection.emit('connect-failed', { error: `Cannot POST ${secret}`, attempt: 0, permanent: true }); + connection.emit('connect-failed', { error: `Cannot POST ${secret}`, attempt: 0 }); + connection.emit('reconnect-failed', { error: `Cannot POST ${secret}`, attempt: 1, permanent: true }); connection.emit('reconnect-failed', { error: `Cannot POST ${secret}`, attempt: 5 }); connection.emit('error', new Error(`transport ${secret}`)); connection.emit('stderr', { line: `server ${config.token}` }); - assert.equal(traces.length, 4); + assert.equal(traces.length, 5); assert.equal(alerts.length, 2); assert.ok(!JSON.stringify(traces).includes(secret), method); assert.ok(!JSON.stringify(traces).includes(config.token), method); @@ -168,13 +171,6 @@ test('legacy initial and reconnect console records redact known credentials', as assert.ok(lines.some(line => line.includes('reconnect failed: transport redacted'))); assert.ok(!lines.join('\n').includes(secret)); } finally { await connection.close(); } - const halted = Object.assign(new EventEmitter(), { unreapedFailure: () => `cleanup ${secret}` }); - t.mock.method(McplServerConnection as any, 'connectChecked', async () => { - throw new McplUnreapedLaunchError(halted as unknown as McplServerConnection, new Error(`transport ${secret}`), new Error(`cleanup ${secret}`)); - }); - await McplServerConnection.connectWithReconnect(legacyConfig, capabilities); - assert.ok(lines.some(line => line.includes('initial connect failed, not retrying: transport redacted; its cleanup also failed: cleanup redacted'))); - assert.ok(!lines.join('\n').includes(secret)); }); test('a real discover-then-tools/list 404 is redacted in the framework error trace', async (t) => { diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index 6d0bd79ca..2a776a12a 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -19,6 +19,7 @@ import { fileURLToPath } from 'node:url'; import { AgentFramework, ModernMcpConnection, McplUnreapedLaunchError, ShutdownTimeoutError } from '../src/index.js'; import type { McplServerConfig } from '../src/mcpl/types.js'; import { StdioTransport } from '../src/mcpl/transport.js'; +import type { McplServerConnection } from '../src/mcpl/server-connection.js'; import { MockMembrane } from './helpers/mock-membrane.js'; const here = dirname(fileURLToPath(import.meta.url)); @@ -68,6 +69,10 @@ const legacy = (id: string, mode: string, log: string, env: Record framework.listMcplServers().find((s) => s.id === id); const toolsOf = (framework: AgentFramework, id: string) => framework.getAllTools().map((t) => t.name).filter((n) => n.startsWith(`mcpl--${id}--`)); const call = (name: string) => ({ id: `t-${Math.random()}`, name, input: {} }); +const RECONNECT = { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 } as const; +/** A legacy server's connection, from the framework's registry. */ +const legacyServer = (framework: AgentFramework, id: string) => + (framework as unknown as { mcplServerRegistry: { getServer(id: string): McplServerConnection | null } }).mcplServerRegistry.getServer(id)!; /** * Stdio launches can't be signalled while `stuck.active` is set, so their @@ -210,11 +215,13 @@ test('modern: a disconnect that cannot reap keeps the server registered; a conne const config = modern('m', '', log); const reaping = unreapable(); await framework.connectMcplServer(config); + assert.ok(toolsOf(framework, 'm').length > 0); await assert.rejects( framework.disconnectMcplServer('m'), /could not be reaped\. MCP server "m" stays registered, closed, until its exit is confirmed: disconnect it again to retry the reap/, ); assert.equal(status(framework, 'm')?.connected, false, 'still listed, closed'); + assert.equal(toolsOf(framework, 'm').length, 0, 'closed either way, so its tools left the list'); await assert.rejects(framework.connectMcplServer(config), /"m" is already registered \(not connected and not reconnecting: disconnect it first\)/); assert.equal(launches(log).length, 1, 'no launch beside the unreaped child'); @@ -233,11 +240,13 @@ test('legacy: a disconnect that cannot reap keeps the server registered; a conne const config = legacy('l', 'ok', log); const reaping = unreapable(); await framework.connectMcplServer(config); + assert.ok(toolsOf(framework, 'l').length > 0); await assert.rejects( framework.disconnectMcplServer('l'), /could not be reaped\. MCP server "l" stays registered, closed, until its exit is confirmed: disconnect it again to retry the reap/, ); assert.equal(status(framework, 'l')?.connected, false, 'still listed, closed'); + assert.equal(toolsOf(framework, 'l').length, 0, 'closed either way, so its tools left the list'); await assert.rejects(framework.connectMcplServer(config), /"l" is already registered \(not connected and not reconnecting: disconnect it first\)/); assert.equal(launches(log).length, 1, 'no launch beside the unreaped child'); @@ -292,12 +301,12 @@ test('modern, with reconnect: a failed first connect whose launch cannot be reap }); }); -test("at startup, a modern server with reconnect whose first launch can't be reaped is traced as not retrying", async () => { +test("at startup, a server with reconnect whose first launch can't be reaped is traced as not retrying, and alerts, on both engines", async () => { const dir = mkdtempSync(join(tmpdir(), 'mcp-ownership-')); cleanups.push(() => rmSync(dir, { recursive: true, force: true })); const log = join(dir, 'raw.jsonl'); const reaping = unreapable(); - const traces: Array<{ type: string; serverId?: string; willRetry?: boolean }> = []; + const traces: Array<{ type: string; serverId?: string; willRetry?: boolean; kind?: string; agentName?: string }> = []; const watcher = { name: 'trace-watcher', async start(ctx: { onTrace(listener: (event: unknown) => void): void }) { ctx.onTrace((event) => traces.push(event as never)); }, @@ -311,15 +320,23 @@ test("at startup, a modern server with reconnect whose first launch can't be rea membrane: new MockMembrane().asMembrane(), agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], modules: [watcher as never], - mcplServers: [{ ...modern('m', 'reject-discover', log), reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }], + mcplServers: [ + { ...modern('m', 'reject-discover', log), reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }, + { ...legacy('l', 'error', join(dir, 'legacy.log')), reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60 }, + ], }); cleanups.push(async () => { for (const stuck of reaping.stuck) await killAndWait(stuck); await framework.stop().catch(() => {}); }); - const failed = traces.find((t) => t.type === 'mcpl:server-connect-failed' && t.serverId === 'm'); - // No retry can come beside a launch that may still run. - assert.equal(failed?.willRetry, false); + for (const id of ['m', 'l']) { + const failed = traces.filter((t) => t.type === 'mcpl:server-connect-failed' && t.serverId === id); + // No retry can come beside a launch that may still run, and only a + // disconnect retries its reap: the operator is told. + assert.equal(failed.length, 1, id); + assert.equal(failed[0]!.willRetry, false, id); + assert.ok(traces.some((t) => t.type === 'ops:alert' && t.kind === 'mcpl-down' && t.agentName === id), id); + } }); /** The stdio line under a modern server's live session, reached from inside, @@ -350,7 +367,8 @@ test('modern, without reconnect: a server lost on its own holds its id until its }); }); -test("modern, without reconnect: a server lost on its own whose child can't be reaped stays registered, alerts, and its tools leave", async () => { +for (const reconnect of [false, true]) { +test(`modern, ${reconnect ? 'with' : 'without'} reconnect: a server lost on its own whose child can't be reaped stays registered, alerts, and its tools leave`, async () => { await withFramework(async ({ framework, dir }) => { const log = join(dir, 'raw.jsonl'); const alerts: string[] = []; @@ -358,7 +376,7 @@ test("modern, without reconnect: a server lost on its own whose child can't be r const traced = event as { type: string; kind?: string; agentName?: string }; if (traced.type === 'ops:alert') alerts.push(`${traced.kind} ${traced.agentName}`); }); - await framework.connectMcplServer(modern('m', '', log)); + await framework.connectMcplServer({ ...modern('m', '', log), ...(reconnect ? RECONNECT : {}) }); assert.ok(toolsOf(framework, 'm').length > 0); const line = liveLineOf(framework, 'm'); const realKill = line.child.kill.bind(line.child); @@ -377,6 +395,43 @@ test("modern, without reconnect: a server lost on its own whose child can't be r assert.equal(status(framework, 'm'), undefined); }); }); +} + +test('legacy, with reconnect: a reconnect that meets a protocol-version verdict halts for good, alerts, and its tools leave', async () => { + await withFramework(async ({ framework, dir }) => { + const log = join(dir, 'legacy.log'); + const alerts: string[] = []; + framework.onTrace((event) => { + const traced = event as { type: string; kind?: string; agentName?: string }; + if (traced.type === 'ops:alert') alerts.push(`${traced.kind} ${traced.agentName}`); + }); + // The first launch serves; every later one refuses the revision. + await framework.connectMcplServer({ ...legacy('l', 'ok-then-reject', log), ...RECONNECT }); + assert.ok(toolsOf(framework, 'l').length > 0); + await legacyServer(framework, 'l').sendToolsCall('die', {}).catch(() => { /* the session ends */ }); + await until(() => alerts.includes('mcpl-down l'), 'the halt raised as an alert', 10_000); + await until(() => toolsOf(framework, 'l').length === 0, 'its tools left the list'); + assert.equal(launches(log).length, 2, 'no attempt after the verdict'); + }); +}); + +test("legacy, without reconnect: a recycle whose child can't be reaped keeps the server registered, holding its id, until a disconnect confirms the exit", async () => { + await withFramework(async ({ framework, dir }) => { + const log = join(dir, 'legacy.log'); + const config = legacy('l', 'ok', log); + const reaping = unreapable(); + await framework.connectMcplServer(config); + await assert.rejects(legacyServer(framework, 'l').reconnectAfterFailure(), /could not be reaped/); + assert.equal(status(framework, 'l')?.connected, false, 'still listed, closed'); + await assert.rejects(framework.connectMcplServer(config), /"l" is already registered \(not connected and not reconnecting/); + assert.equal(launches(log).length, 1, 'no launch beside the unreaped child'); + + await killAndWait(reaping.stuck[0]!); + reaping.active = false; + await framework.disconnectMcplServer('l'); + assert.equal(status(framework, 'l'), undefined, 'the disconnect completed'); + }); +}); test("modern: ModernMcpConnection.connect hands back the connection that owns a launch its cleanup couldn't reap", async () => { const dir = mkdtempSync(join(tmpdir(), 'mcp-ownership-')); @@ -397,12 +452,13 @@ test("modern: ModernMcpConnection.connect hands back the connection that owns a await failed.connection.close(); }); -test('legacy: a failed connect whose cleanup cannot reap stays registered until a disconnect confirms the exit', async () => { +for (const reconnect of [false, true]) { +test(`legacy${reconnect ? ', with reconnect' : ''}: a failed connect whose cleanup cannot reap stays registered until a disconnect confirms the exit`, async () => { await withFramework(async ({ framework, dir }) => { const log = join(dir, 'legacy.log'); const reaping = unreapable(); await assert.rejects( - framework.connectMcplServer(legacy('l', 'error', log)), + framework.connectMcplServer({ ...legacy('l', 'error', log), ...(reconnect ? RECONNECT : {}) }), /initialize failed on purpose; its cleanup also failed: .*could not be reaped\. MCP server "l" stays registered, closed, until its exit is confirmed: disconnect it to retry the reap/, ); await assert.rejects(framework.connectMcplServer(legacy('l', 'ok', log)), /"l" is already registered \(not connected and not reconnecting/); @@ -415,6 +471,7 @@ test('legacy: a failed connect whose cleanup cannot reap stays registered until assert.equal(status(framework, 'l')?.connected, true); }); }); +} test('stop() closes every connection, keeps the ones it cannot reap registered, and rejects with each failure; once they have exited, a retry completes', async () => { const dir = mkdtempSync(join(tmpdir(), 'mcp-ownership-')); @@ -702,14 +759,21 @@ for (const engine of ['legacy', 'modern'] as const) { }); } -/** Hold modern server `id`'s listings once they've answered, until release(). */ -function holdListings(framework: AgentFramework, id: string): { held: () => number; release: () => void } { +/** Hold modern server `id`'s listings once they've answered, until + * release(): every one, or only the first `first`. */ +function holdListings(framework: AgentFramework, id: string, first = Infinity): { held: () => number; release: () => void } { const connection = (framework as unknown as { modernServers(): Map }> }).modernServers().get(id)!; const realList = connection.listTools.bind(connection); let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); let held = 0; - connection.listTools = async () => { const tools = await realList(); held += 1; await gate; return tools; }; + connection.listTools = async () => { + const tools = await realList(); + if (held >= first) return tools; + held += 1; + await gate; + return tools; + }; return { held: () => held, release }; } @@ -762,7 +826,9 @@ test("a disconnect still under way when a connect's tool listing ends fails the test("a disconnect that fails to reap during a connect's tool listing fails the connect", async () => { await withFramework(async ({ framework, dir }) => { await framework.connectMcplServer(modern('a', '', join(dir, 'raw-a.jsonl'))); - const a = holdListings(framework, 'a'); + // Only the connect's listing: the failed disconnect's own refresh lists + // a too. + const a = holdListings(framework, 'a', 1); const reaping = unreapable(); const connecting = framework.connectMcplServer(modern('m', '', join(dir, 'raw-m.jsonl'))); await until(() => a.held() > 0, "m's connect, listing tools, waits on a"); @@ -806,15 +872,22 @@ test("a listing of a server whose disconnect failed to reap isn't published", as }; const refreshing = internals.refreshMcplTools(); await until(() => held, 'the refresh holds its listing of m'); + let disconnecting: Promise | undefined; try { - // A failed teardown emits no 'close', so only m's state says it's over. - await assert.rejects(framework.disconnectMcplServer('m'), /could not be reaped/); + // The disconnect closes m, and its reap then fails, which takes + // seconds; the refresh publishes meanwhile. A teardown that hasn't + // confirmed the exit has emitted no 'close', so only m's state says + // it's over. + disconnecting = framework.disconnectMcplServer('m'); + await until(() => (connection as unknown as { isClosed: boolean }).isClosed, 'm closed by the disconnect'); release(); await refreshing; assert.deepEqual(toolsOf(framework, 'm'), [], "the closed connection's listing isn't published"); assert.deepEqual(framework.listToolClasses().filter((tool) => tool.serverId === 'm' && tool.class.includes('files')), []); + await assert.rejects(disconnecting, /could not be reaped/); } finally { release(); + await disconnecting?.catch(() => { /* its verdict is asserted above */ }); for (const stuck of reaping.stuck) await killAndWait(stuck); } await framework.disconnectMcplServer('m'); diff --git a/test/mcpl-legacy-lifetime.test.ts b/test/mcpl-legacy-lifetime.test.ts index e6ee3d2ac..746fd186c 100644 --- a/test/mcpl-legacy-lifetime.test.ts +++ b/test/mcpl-legacy-lifetime.test.ts @@ -160,23 +160,20 @@ test('a failed connect that cannot be reaped hands its owner to the caller, who await owner.close(); }); -test('with reconnect, a failed first launch that cannot be reaped halts instead of retrying', async () => { +test('with reconnect too, a failed first launch that cannot be reaped throws, handing over its owner, as without', async () => { const log = scratchLog(); const stuck = unreapableFrom(1); - const connection = await McplServerConnection.connectWithReconnect(config('error', log, RECONNECT), HOST_CAPS); - cleanups.push(() => connection.close().catch(() => {})); - const failures: Array<{ error: string; attempt: number; permanent?: boolean }> = []; - connection.on('connect-failed', (f) => failures.push(f)); - connection.ready(); - await until(() => failures.length > 0, 'the connect-failed event'); - assert.equal(failures[0]!.permanent, true); - assert.match(failures[0]!.error, /initialize failed on purpose; the previous launch could not be reaped .*reconnecting is halted/); - assert.equal(connection.willReconnect, false); - // 300 ms is several reconnect intervals (20–40 ms): a retry would have launched. - await new Promise((r) => setTimeout(r, 300)); - assert.equal(starts(log).length, 1, 'no launch beside the unreaped one'); + const error = await McplServerConnection.connectWithReconnect(config('error', log, RECONNECT), HOST_CAPS).then(() => null, (e: unknown) => e); + // No stub: no retry starts beside a child that may still be running. + assert.ok(error instanceof McplUnreapedLaunchError, String(error)); + assert.match(error.message, /initialize failed on purpose; its cleanup also failed: .*could not be reaped/); + const owner = error.connection; + cleanups.push(() => owner.close().catch(() => {})); + assert.equal(owner.willReconnect, false); + await assert.rejects(owner.close(), /could not be reaped/, 'still running: the reap fails again'); await killAndWait(stuck[0]!); - await connection.close(); + await owner.close(); + assert.equal(starts(log).length, 1, 'no launch beside the unreaped one'); }); test('a reconnect launch that cannot be reaped halts reconnecting; close() settles it once it has exited', async () => { diff --git a/test/mcpl-ws-transport.test.ts b/test/mcpl-ws-transport.test.ts index 9cf4bd433..dcc85aa7b 100644 --- a/test/mcpl-ws-transport.test.ts +++ b/test/mcpl-ws-transport.test.ts @@ -300,32 +300,6 @@ test("a server-side close with code 4001 surfaces on the connection 'close' even } }); -test("a reconnectAfterFailure() whose close fails leaves no host flag behind: a later remote drop is the server's", async () => { - const sockets: WebSocket[] = []; - const wss = startMockMcplWsServer((ws) => sockets.push(ws)); - await waitListening(wss); - let conn: McplServerConnection | undefined; - try { - conn = await McplServerConnection.connect({ id: 'editor', url: wsUrl(wss) }, HOST_CAPS); - conn.ready(); - const transport = (conn as unknown as { transport: { close(): Promise } }).transport; - const realClose = transport.close.bind(transport); - // A teardown that can't confirm its end, as an unreaped child's can't. - transport.close = async () => { throw new Error('could not be confirmed'); }; - await assert.rejects(conn.reconnectAfterFailure(), /could not be confirmed/); - transport.close = realClose; - const remote = new Promise((resolve) => - conn!.once('close', (_code?: number | null, _signal?: string | null, info?: McplConnectionCloseInfo) => resolve(info))); - sockets[0]!.close(4002, 'server going away'); - const info = await withDeadline(remote, 5_000, "'close' after the server's drop"); - assert.equal(info?.byHost, false); - assert.equal(info?.code, 4002); - } finally { - await conn?.close().catch(() => { /* already closed */ }); - wss.close(); - } -}); - test("reconnectAfterFailure() on a live transport reports byHost: true and still reconnects", async () => { const sockets: WebSocket[] = []; const wss = startMockMcplWsServer((ws) => sockets.push(ws)); From 179f66fa6feb80dfd68e04cacb19ce4adfc2f28e Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 19:19:51 -0400 Subject: [PATCH 81/94] fix(mcp): a failed recycle of a server without reconnect takes its tools out of the list An internal review of the previous commit found these. - The legacy recycle after an awareness-accounting failure now ends a connection without reconnect with close(). A close that can't confirm the child's exit keeps the child, holding the id, but emits nothing, and the recycle's caller only logged it, so the closed server's tools stayed listed. The caller now refreshes the tool list when the recycle rejects and the connection won't reconnect, as a failed disconnect does. A connection with reconnect is left to its reconnect loop, which halts, and refreshes, or serves again once it has confirmed the exit. - McplUnreapedLaunchError's doc said connectWithReconnect() returns the connection, halted. Both connects throw it now. - The startup catch's comment, and the description, said only a disconnect retries a launch's reap; stop() does too. They now say nothing retries it on its own. The modern failure handler's comment no longer calls every permanent failure a halted reconnect. - The previous commit's message counted raceAbort's early return among its own pieces. The early return was already there: that commit added its test, which fails without it. Tests: - mcp-server-ownership: the legacy recycle test now goes through the failed awareness gate that recycles every server, and checks that the server's tools leave the list. Red-first, Linux, Node 24.22.0: - With src at the previous commit, the recycle test fails: the tools stay listed. Removing the new refresh fails it too. - The condition that leaves a connection with reconnect to its reconnect loop has no test of its own. Telling the two apart needs a child that exits between the recycle's failed close and the reconnect's re-check. - With the change, the whole suite passes (node --test over every file, no failures). Its count varies between runs of the same tree: 2,596 tests in one run, and 2,625 and 2,606 in two more. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 16 +++++++++++----- src/mcpl/server-connection.ts | 6 +++--- test/mcp-server-ownership.test.ts | 11 +++++++++-- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index c6f64592e..3ba2efa5c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -16609,7 +16609,8 @@ export class AgentFramework { // So the server doesn't vanish quietly: alert at once, since no // attempt follows, for what needs the operator. That's a // protocol-version verdict, as the reconnect path alerts for one, or - // a launch that couldn't be reaped, which only a disconnect retries. + // a launch that couldn't be reaped, whose reap nothing retries on its + // own. if (error instanceof McplProtocolVersionError || left?.holdsUnreapedChild) { this.opsAlert('mcpl-down', config.id, `MCPL server unreachable at startup: ${message}`); } @@ -17119,6 +17120,11 @@ export class AgentFramework { `[discord-awareness] could not recycle unhealthy connection ${connection.id}:`, closeError instanceof Error ? closeError.message : closeError, ); + // One without reconnect is closed, keeping a child it couldn't + // reap, so it won't serve again: its tools leave the agents' list, + // as a failed disconnect's do. One with reconnect is its reconnect + // loop's to settle, which halts (and refreshes) or serves again. + if (!connection.willReconnect) this.handleToolsListChanged(connection.id); }); })); } @@ -18663,10 +18669,10 @@ export class AgentFramework { attempt: params.attempt, willRetry: connection.willReconnect, }); - // `permanent`: reconnecting halted, on a launch that couldn't be - // reaped or a protocol-version verdict. No later attempt will come, so - // alert now, and the server's tools leave the agents' list: it won't - // serve again on its own. + // `permanent`: no later attempt will come, after a launch that + // couldn't be reaped or a protocol-version verdict. Alert now, and the + // server's tools leave the agents' list: it won't serve again on its + // own. if (params.permanent || params.attempt >= 5) { this.opsAlert('mcpl-down', connection.id, `MCP server unreachable (attempt ${params.attempt}): ${recorded(params.error)}`); } diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 1bba76864..e2f18b93b 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -206,9 +206,9 @@ class HandshakeCleanupError extends Error { * exited: it may still be running. `connection` owns that launch, closed and * not reconnecting. Its close() retries the reap and resolves once the exit * is confirmed, so the caller can still clean up, and should start nothing - * else for this server until then. connect() throws this, the modern - * engine's ModernMcpConnection.connect() too; connectWithReconnect() returns - * the connection instead, halted. `cause` is the connect's own failure. + * else for this server until then. connect() and connectWithReconnect() + * throw this, and the modern engine's ModernMcpConnection.connect() too. + * `cause` is the connect's own failure. */ export class McplUnreapedLaunchError } = McplServerConnection> extends Error { constructor( diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index 2a776a12a..6b3a3593a 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -415,14 +415,21 @@ test('legacy, with reconnect: a reconnect that meets a protocol-version verdict }); }); -test("legacy, without reconnect: a recycle whose child can't be reaped keeps the server registered, holding its id, until a disconnect confirms the exit", async () => { +test("legacy, without reconnect: a recycle whose child can't be reaped keeps the server registered, holding its id, until a disconnect confirms the exit, and its tools leave", async () => { await withFramework(async ({ framework, dir }) => { const log = join(dir, 'legacy.log'); const config = legacy('l', 'ok', log); const reaping = unreapable(); await framework.connectMcplServer(config); - await assert.rejects(legacyServer(framework, 'l').reconnectAfterFailure(), /could not be reaped/); + assert.ok(toolsOf(framework, 'l').length > 0); + // The awareness accounting fails, and the gate recycles every server. + const gate = framework as unknown as { + installMcplDataPlaneGate(): unknown; + failMcplDataPlaneGate(barrier: unknown, context: string, error: unknown): Promise; + }; + await gate.failMcplDataPlaneGate(gate.installMcplDataPlaneGate(), 'a test', new Error('accounting failed on purpose')); assert.equal(status(framework, 'l')?.connected, false, 'still listed, closed'); + await until(() => toolsOf(framework, 'l').length === 0, 'its tools left the list'); await assert.rejects(framework.connectMcplServer(config), /"l" is already registered \(not connected and not reconnecting/); assert.equal(launches(log).length, 1, 'no launch beside the unreaped child'); From 3c043d5d2bf2de4b4deb7be33f6acf49cb74dc86 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 20:18:16 -0400 Subject: [PATCH 82/94] docs(mcp): name the one configuration that worked before and is now refused serverConfigProblems' doc, and the legacy engine's checkConfig doc after it, said every configuration that worked before still passes. One doesn't: `command` and `url` with no `transport`, which was launched as stdio with its url ignored, and is now refused as naming no transport to use, as the breaking changelog fragment says. Both docs now name it. Comments only. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/protocol-family.ts | 4 +++- src/mcpl/server-connection.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/mcpl/protocol-family.ts b/src/mcpl/protocol-family.ts index 30c84290c..c93454b7d 100644 --- a/src/mcpl/protocol-family.ts +++ b/src/mcpl/protocol-family.ts @@ -521,7 +521,9 @@ export function mcplPolicyGrants(config: McplServerConfig): string[] { /** * Problems with a server configuration's protocol settings, as messages; empty * when it is usable. Legacy configurations are checked only for what this - * design added, so every configuration that worked before still passes. + * design added, so every configuration that worked before still passes but + * one: `command` and `url` with no `transport`, which was launched as stdio + * with its url ignored, and is now refused as naming no transport to use. */ export function serverConfigProblems(config: McplServerConfig): string[] { let binding: ServerBinding; diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 8124542fd..58567b8a0 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -759,7 +759,8 @@ export class McplServerConnection extends EventEmitter { * dialed: every problem {@link checkServerConfig} finds, the same check the * framework applies, and the modern family, which belongs to * ModernMcpConnection (an http(s) url is never dialed as a WebSocket). - * Every configuration that worked before still passes. + * Every configuration that worked before still passes but the one + * serverConfigProblems names. */ private static checkConfig(config: McplServerConfig): void { const { family } = checkServerConfig(config); From cddfc19e76a3e0af7484ce495e992865a47855d8 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 20:22:11 -0400 Subject: [PATCH 83/94] test(mcp): a failed create() closes a modern server it had connected The merge of main replaced this branch's failed-startup cleanup with create()'s release through stop(), and that cleanup's test went with it. main's test of the release connected a legacy server only. It now connects a modern server first, so both engines hold a server when the awareness barrier fails, and it waits for both children to exit. Checks, on Linux: the file passes on Node 24.22.0 and Node 20.20.2. With stop()'s close of the modern connections removed, this test fails, its modern server still running after create() rejected. Co-Authored-By: Claude Opus 5.5 --- test/failed-create-releases.test.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/test/failed-create-releases.test.ts b/test/failed-create-releases.test.ts index 7d063defc..9d82eeb62 100644 --- a/test/failed-create-releases.test.ts +++ b/test/failed-create-releases.test.ts @@ -83,7 +83,7 @@ test('a create() whose module fails to start stops every module it started, that await retried.stop(); }); -test('a create() that fails after its MCPL server connected closes the server and frees its store', async t => { +test('a create() that fails after its MCP servers connected closes them, in both engines, and frees its store', async t => { const dir = tempDir(t); const outboxPath = join(dir, 'awareness.json'); // A pending Discord awareness batch makes startup run the awareness barrier @@ -99,7 +99,12 @@ test('a create() that fails after its MCPL server connected closes the server an agents: [{ name: 'assistant', model: 'test', systemPrompt: 'test' }], discordAwarenessOutboxPath: outboxPath, discordAwarenessDeadlineMs: 1_000, + // The modern server connects first, so both engines hold a server when + // the failure comes. mcplServers: [{ + id: 'modern', command: process.execPath, protocol: 'modern' as const, + args: [fileURLToPath(new URL('./fixtures/modern-raw-server.mjs', import.meta.url)), '', join(dir, 'modern.jsonl')], + }, { id: 'discord', command: process.execPath, args: [fileURLToPath(new URL('./fixtures/awareness-barrier-mcpl-server.mjs', import.meta.url))], requestTimeoutMs: 1_000, enabledFeatureSets: ['chat'], @@ -120,14 +125,21 @@ test('a create() that fails after its MCPL server connected closes the server an .map(line => JSON.parse(line) as { event: string; pid?: number }) .find(record => record.event === 'initialized'); assert.ok(initialized?.pid, 'the MCPL server had finished its handshake before the failure'); - // Wait on that child itself, with room for a loaded machine: only a child - // still running after 10 s fails this. + const modern = readFileSync(join(dir, 'modern.jsonl'), 'utf8').split('\n').filter(Boolean) + .map(line => JSON.parse(line) as { event: string; method?: string; pid?: number }); + const modernPid = modern.find(record => record.event === 'start')?.pid; + assert.ok(modernPid, 'the modern server was launched'); const running = (pid: number) => { try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM'; } }; - for (let waits = 0; running(initialized.pid); waits++) { - assert.ok(waits < 1000, 'the MCPL server is still running after create() rejected'); + // Whatever the assertions find, no child outlives the test. + t.after(() => { if (running(modernPid)) process.kill(modernPid, 'SIGKILL'); }); + assert.ok(modern.some(record => record.method === 'server/discover'), 'the modern server had its handshake before the failure'); + // Wait on those children themselves, with room for a loaded machine: only + // a child still running after 10 s fails this. + for (let waits = 0; running(initialized.pid) || running(modernPid); waits++) { + assert.ok(waits < 1000, `still running after create() rejected: ${running(initialized.pid) ? 'the MCPL server' : 'the modern server'}`); await new Promise(done => setTimeout(done, 10)); } const retried = await AgentFramework.create(config); From 5237b6d3e8cce70b1e2074035888b90eca3313bf Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 20:32:30 -0400 Subject: [PATCH 84/94] test(mcp): a disconnect during a restart's tool listing ends the restart A restart's connect goes through the same final check as a connect: after its tool listing, it stands only while its connection still serves and no disconnect of its id is under way. The connect's three tests pinned that check; nothing tested it through a restart. This test holds the restart's listing, disconnects the new server during it, and checks that the restart says the server is down, with the connect's reason. Checks, on Linux: the file passes on Node 24.22.0 and Node 20.20.2. With the final check's condition removed, this test fails ("Missing expected rejection."), and so do the connect's three. Co-Authored-By: Claude Opus 5.5 --- test/mcp-server-ownership.test.ts | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index dddf84ff0..0e7580cdc 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -763,6 +763,44 @@ test("a disconnect during a connect's tool listing ends the connect", async () = }); }); +test("a disconnect during a restart's tool listing ends the restart, which says the server is down", async () => { + await withFramework(async ({ framework, dir }) => { + await framework.connectMcplServer(modern('a', '', join(dir, 'raw-a.jsonl'))); + await framework.connectMcplServer(modern('m', '', join(dir, 'raw-m.jsonl'))); + const servers = (framework as unknown as { modernServers(): Map }).modernServers(); + const before = servers.get('m'); + // Hold a's listings made once the restart has registered the new m: its + // connect's. The teardown's refresh, and the disconnect's below, come + // while no m is registered, and pass. + const a = servers.get('a') as { listTools(): Promise }; + const realList = a.listTools.bind(a); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let held = 0; + a.listTools = async () => { + const tools = await realList(); + if (!servers.has('m') || servers.get('m') === before) return tools; + held += 1; + await gate; + return tools; + }; + const restarting = framework.restartMcplServer('m'); + // Handled here too, so an assertion that fails first leaves no + // unhandled rejection behind. + void restarting.catch(() => {}); + let disconnecting: Promise | undefined; + try { + await until(() => held > 0, "the restart's connect, listing tools, waits on a"); + disconnecting = framework.disconnectMcplServer('m'); + await until(() => !servers.has('m'), 'the new m is removed'); + } finally { + release(); + } + await assert.rejects(restarting, /^Error: MCP server "m" is down: it was disconnected for its restart, and the restart's connect failed: MCP server "m" was not connected: it was disconnected or lost while its tools were being listed$/); + await disconnecting; + }); +}); + test("a disconnect still under way when a connect's tool listing ends fails the connect", async () => { await withFramework(async ({ framework, dir }) => { await framework.connectMcplServer(modern('a', '', join(dir, 'raw-a.jsonl'))); From 323a32af0c50827e85fe2fb5ba10b374b7fab888 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 23:35:27 -0400 Subject: [PATCH 85/94] fix(mcp): a stdio child whose pipe failed is killed if it ignores SIGTERM, and no request is written after the failure Anarchid's review at 5237b6d3 (R2-1). Since main's #384, an error on a stdio child's stdin or stdout sends the child SIGTERM once, and its exit closes the transport. A child that traps SIGTERM kept running, so the connection stayed connected, and every later request was written into the failed pipe and reported at its deadline as possibly executed. Both engines share the transport, so both are fixed here. The transport (src/mcpl/transport.ts): - The SIGTERM, 2 s, SIGKILL escalation moves out of reap() into terminate(), one attempt per transport, which close() and the pipe failure share: a close() made while the pipe failure ends the child sends no second SIGTERM and arms no second timer. A failed attempt is re-checked, not repeated, so a child that has exited since resolves it, as close() already did for its own later calls. - The pipe handler runs terminate(). The close is still left to the child's exit, so its reason still names the pipe's error. - pipeFailure, a getter on every transport (undefined but for a stdio one whose pipe failed), says the pipe has failed, for the engines to refuse a request. - A child that outlives SIGKILL too leaves the transport open, owning it, and the transport emits 'unreaped' with the failure. It doesn't when the host has begun closing it, whose close() reports the failure itself. The engines: - Both refuse a request as not sent once pipeFailure is set: the modern engine's stdio send throws NotWrittenError, and the legacy sendRequest rejects with outcome 'not-sent'. A request written before the failure is still reported as possibly executed, since the transport can't tell which of its writes reached the child. - On 'unreaped', each engine takes the case it already has for a launch it can't reap. The modern engine handles it as a loss: handleLost's reap finds the child still running, holds the launch, and emits the permanent reconnect-failed. The legacy engine holds the launch first, which halts reconnecting, then handles it as a close, so its registry keeps the connection and its id, and emits the permanent reconnect-failed. In both, the framework's handlers raise the mcpl-down alert and take the server's tools off the list, and a later close() retries the reap. The changelog fragment names both engines. Tests: in mcpl-transport-close, a child that closes its stdin and ignores SIGTERM gets SIGKILL 2 s after the write that failed, with the pipe's error in its close; close() during that escalation sends no second SIGTERM; a child that outlives SIGKILL too leaves the transport open with 'unreaped', and the first close() once it has exited succeeds; a host close() during such an escalation rejects with no 'unreaped'; a legacy request after the failure is refused as not-sent, and the server is killed and reconnected; a legacy server whose child outlives SIGKILL closes holding the launch, then emits the permanent reconnect-failed, and close() retries. In mcp-modern-outcomes, the same two cases for the modern engine, through the raw fixture's new deafen tool, which answers and then closes its stdin. Red-first, Linux, Node 24.22.0: - With src at the commit before, the 8 new tests fail. - Each of 9 pieces, removed on its own, fails a test: the pipe handler's escalation, the shared attempt, its re-check, the host-close guard on 'unreaped', both engines' send refusal, both engines' 'unreaped' handling, and the legacy engine's holding the launch before its close. - With the change, these files pass, each run on its own and unforced: mcpl-transport-close 19, mcp-modern-outcomes 40 and mcpl-legacy-lifetime 16 on Node 24.22.0 and 20.20.2, and mcp-server-ownership 37, mcpl-runtime-lifecycle 37, mcp-modern-framework 18, mcp-modern-connection 10, mcp-recorded-text 10 and mcpl-stderr-tail 16 on Node 24.22.0. Co-Authored-By: Claude Opus 5.5 --- .../stdio-pipe-failure-sigkill.fixed.md | 16 ++ src/mcpl/modern-connection.ts | 17 +- src/mcpl/server-connection.ts | 22 +- src/mcpl/transport.ts | 75 +++++-- test/fixtures/modern-raw-server.mjs | 23 ++- test/mcp-modern-outcomes.test.ts | 61 ++++++ test/mcpl-transport-close.test.ts | 191 ++++++++++++++++++ 7 files changed, 386 insertions(+), 19 deletions(-) create mode 100644 changelog.d/stdio-pipe-failure-sigkill.fixed.md diff --git a/changelog.d/stdio-pipe-failure-sigkill.fixed.md b/changelog.d/stdio-pipe-failure-sigkill.fixed.md new file mode 100644 index 000000000..40ed40c54 --- /dev/null +++ b/changelog.d/stdio-pipe-failure-sigkill.fixed.md @@ -0,0 +1,16 @@ +- **A stdio MCP server whose stdin or stdout fails is now ended even when + it ignores SIGTERM, in both engines.** Since 0.22.2 such a failure sends + the child SIGTERM, and its exit closes the connection. A child that + ignored SIGTERM kept running, the connection stayed connected, and each + request waited out its timeout. The transport now sends SIGKILL after 2 s, + as its `close()` does, in the one attempt `close()` shares, so the + connection closes and reconnects as after any exit, its close still naming + the pipe's error. + - A request made after the failure is refused as not sent (`not-sent`), + where it was written to the failed pipe and reported at its deadline as + possibly executed. One written before the failure is still reported as + possibly executed. + - A child that outlives SIGKILL too is held as a launch that couldn't be + reaped, as a reconnect holds one: reconnecting halts, the `mcpl-down` + alert is raised, its tools leave the agents' list, and its id stays held + until a disconnect or `stop()` retries the reap and confirms the exit. diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 9a0765e97..9dc569bf7 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -270,6 +270,10 @@ class SpawnerStdioTransport implements Transport { constructor( private readonly config: McplServerConfig, private readonly onStderr: (line: string) => void, + /** The child's pipe failed and it outlived SIGKILL too, so the line + * stays open, owning it (the line's 'unreaped'): `reason` names the + * pipe's error. */ + private readonly onUnreaped: (reason: string) => void, ) { this.recorded = serverTextRedactor(config); } @@ -303,10 +307,15 @@ class SpawnerStdioTransport implements Transport { line.on('stderr', (text: string) => this.onStderr(text)); line.on('error', (error: Error) => this.onerror?.(error)); line.on('close', () => this.onclose?.()); + line.on('unreaped', () => this.onUnreaped(`its stdio pipe failed with ${line.pipeFailure?.message ?? 'an error'}`)); } async send(message: JSONRPCMessage): Promise { if (!this.line || this.line.closed) throw new NotWrittenError(`MCP server "${this.config.id}" stdio transport is closed`); + // The child can't read what's written once its stdin or stdout has + // failed, while the line waits for it to exit. + const failed = this.line.pipeFailure; + if (failed) throw new NotWrittenError(`MCP server "${this.config.id}" stdio pipe failed with ${failed.message}`); this.line.writeLine(JSON.stringify(message)); } @@ -566,7 +575,13 @@ export class ModernMcpConnection extends EventEmitter { // generation's failures. this.relistenAttempts = 0; const inner: Transport = this.transportKind === 'stdio' - ? new SpawnerStdioTransport(this.config, (line) => { if (this.isCurrent(generation)) this.deliverStderr(line); }) + ? new SpawnerStdioTransport( + this.config, + (line) => { if (this.isCurrent(generation)) this.deliverStderr(line); }, + // A loss whose reap fails: handleLost's reap finds the child still + // running and holds the launch, as for any launch it can't reap. + (reason) => { if (this.isCurrent(generation)) this.handleLost(generation, reason); }, + ) : new StreamableHTTPClientTransport(new URL(this.config.url!), { authProvider: bearerAuth(this.config) }); const wire = new ObservedTransport(inner); diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 95922e1b1..178cd0670 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -1545,6 +1545,12 @@ export class McplServerConnection extends EventEmitter { if (this.closed) { return Promise.reject(new McplRequestError(`Cannot send request: connection to "${this.id}" is closed`, 'not-sent')); } + // The child can't read what's written once its stdin or stdout has + // failed, while the transport waits for it to exit. + const pipeFailure = this.transport?.pipeFailure; + if (pipeFailure) { + return Promise.reject(new McplRequestError(`Cannot send request: the stdio pipe of "${this.id}" failed with ${pipeFailure.message}`, 'not-sent')); + } const id = this.nextRequestId++; const request: JsonRpcRequest = { @@ -1835,7 +1841,7 @@ export class McplServerConnection extends EventEmitter { this.emit('error', new Error(`MCPL server "${this.id}" transport error: ${err.message}`)); }); - transport.on('close', (info: TransportCloseInfo) => { + const lost = (info: TransportCloseInfo) => { if (!this.closed) { this.closed = true; this.resetPolicyForTransportBoundary(); @@ -1874,6 +1880,20 @@ export class McplServerConnection extends EventEmitter { this.scheduleReconnect(); } } + }; + transport.on('close', lost); + // A stdio child whose stdin or stdout failed, and that outlived SIGKILL + // too: the transport stays open, owning the child. The connection holds + // it as a launch it can't reap, as a reconnect does, which halts + // reconnecting, and is lost as on a close, with the permanent failure + // that raises the alert and takes its tools off the list. Its id stays + // held until close() confirms the exit. A close the host has begun + // reports the failure itself. + transport.on('unreaped', (failure: Error) => { + if (this.closed || this.transport !== transport) return; + this.holdUnreaped(transport, failure); + lost({ reason: `its stdio pipe failed with ${transport.pipeFailure?.message ?? 'an error'}, and its child outlived SIGKILL` }); + this.emit('reconnect-failed', { error: this.unreapedFailure(), attempt: Math.max(1, this.reconnectAttempts), permanent: true }); }); } } diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index b17822c52..2531a3393 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -44,6 +44,9 @@ export interface TransportCloseInfo { * - `'stderr'` `(line: string)` diagnostic line (stdio child stderr; never WS) * - `'error'` `(err: Error)` transport-level error * - `'close'` `(info: TransportCloseInfo)` transport closed (once) + * - `'unreaped'` `(failure: Error)` a stdio child whose pipe failed + * outlived SIGKILL too: the transport stays open, owning the child, and + * close() retries the reap (never WS) * * Inbound `'line'`/`'stderr'` events are buffered until the first matching * listener attaches (and re-buffered whenever there are none), so no message is @@ -86,6 +89,16 @@ export abstract class McplTransport extends EventEmitter { return ''; } + /** + * The error a stdio child's stdin or stdout failed with, once one has: no + * write reaches the child after it, though the transport stays open until + * the child exits. Undefined while writes can go out, and always for a + * transport that has no pipes. + */ + get pipeFailure(): Error | undefined { + return undefined; + } + /** Deliver an inbound line, buffering it if nobody is listening yet. */ protected pushLine(line: string): void { if (this.listenerCount('line') > 0) this.emit('line', line); @@ -198,6 +211,10 @@ export class StdioTransport extends McplTransport { return (this.stderrCut ? '…' : '') + this.stderrEnd; } + override get pipeFailure(): Error | undefined { + return this.pipeError; + } + private constructor( /** The server this child serves, named in what its reaping reports. */ private readonly serverId: string, @@ -234,16 +251,21 @@ export class StdioTransport extends McplTransport { this.child.on('error', (err) => this.emit('error', err)); // The child's stdin and stdout carry the protocol, so after an error on // either, a write's EPIPE or a read error, this transport can't reach the - // child even if it's still running. The handler sends a running child - // SIGTERM, and the exit's close names the error, so the connection handles - // it as any other exit. The close is the report: no 'error' is emitted, - // nothing is thrown. Nothing here escalates to SIGKILL, so a child that - // ignores SIGTERM keeps running, unreported. A child that stops reading - // without closing its stdin raises no error: the writes are buffered, and - // only request timeouts bound it (McplServerConfig.requestTimeoutMs). + // child even if it's still running. The handler ends the child as close() + // does, SIGTERM and then SIGKILL, in the one attempt close() shares, and + // the exit's close names the error, so the connection handles it as any + // other exit. The close is the report: no 'error' is emitted, nothing is + // thrown, and pipeFailure refuses writes meanwhile. A child that outlives + // SIGKILL too stays this transport's: 'unreaped' says so, unless the host + // is closing it, whose close() reports the failure itself. A child that + // stops reading without closing its stdin raises no error: the writes are + // buffered, and only request timeouts bound it + // (McplServerConfig.requestTimeoutMs). const endOnPipeError = (err: Error) => { this.pipeError ??= err; - if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill(); + void this.terminate().catch((failure: unknown) => { + if (!this.closed) this.emit('unreaped', failure instanceof Error ? failure : new Error(String(failure))); + }); }; this.child.stdin?.on('error', endOnPipeError); // On the stream itself, since close() closes readline, which takes its own @@ -314,8 +336,9 @@ export class StdioTransport extends McplTransport { * child that outlives SIGTERM by {@link STDIO_EXIT_GRACE_MS} gets SIGKILL. * If it still hasn't exited after that bound again, close rejects: an * explicit cleanup failure, rather than waiting forever or claiming an - * exit that wasn't seen. Calls share one attempt; a later call succeeds if - * the child has exited since. + * exit that wasn't seen. Calls share one attempt, and so does a pipe + * failure that is already ending the child; a later call succeeds if the + * child has exited since. */ close(): Promise { if (!this.closing) { @@ -325,7 +348,7 @@ export class StdioTransport extends McplTransport { // A later call re-checks a failed verdict: a child that has exited since // is reaped now. No new signals are sent. return this.closing.catch((error: unknown) => { - if (this.child.exitCode !== null || this.child.signalCode !== null) return; + if (this.exited()) return; throw error; }); } @@ -333,10 +356,34 @@ export class StdioTransport extends McplTransport { private async reap(): Promise { this.markClosed({ reason: 'closed by host' }); this.rl.close(); + await this.terminate(); + } + + /** Whether the child has exited, or never started (a spawn error). */ + private exited(): boolean { + return this.child.pid === undefined || this.child.exitCode !== null || this.child.signalCode !== null; + } + + private terminating: Promise | null = null; + + /** + * End the child: SIGTERM, then SIGKILL once it has outlived SIGTERM by + * {@link STDIO_EXIT_GRACE_MS}. Resolves once it has exited; rejects if it + * still hasn't that long after SIGKILL. close() and a pipe failure share + * one attempt, so no second signal or timer goes out. A failed attempt is + * re-checked, not repeated: a child that has exited since resolves it. + */ + private terminate(): Promise { + this.terminating ??= this.escalate(); + return this.terminating.catch((error: unknown) => { + if (this.exited()) return; + throw error; + }); + } + + private async escalate(): Promise { const child = this.child; - // Never started (a spawn error): there is no process to reap. - if (child.pid === undefined) return; - if (child.exitCode !== null || child.signalCode !== null) return; + if (this.exited()) return; const exited = new Promise((resolve) => child.once('exit', () => resolve())); const exitWithin = (ms: number) => new Promise((resolve) => { const timer = setTimeout(() => resolve(false), ms); diff --git a/test/fixtures/modern-raw-server.mjs b/test/fixtures/modern-raw-server.mjs index a7b18b81c..9724a4fbf 100644 --- a/test/fixtures/modern-raw-server.mjs +++ b/test/fixtures/modern-raw-server.mjs @@ -23,9 +23,10 @@ // arguments.leg1 'exit' exits just after the first leg's answer, and // 'elicit' asks for elicitation input instead), task (a result type this // revision doesn't define), bare (a result with no resultType), die (exits), -// vanish (exits without answering). +// vanish (exits without answering), deafen (answers, then closes its stdin and +// stays up, so the next write to it fails with EPIPE). import { createInterface } from 'node:readline'; -import { appendFileSync, existsSync, readFileSync } from 'node:fs'; +import { appendFileSync, closeSync, existsSync, readFileSync } from 'node:fs'; const [flagList = '', log] = process.argv.slice(2); const flags = new Set(flagList.split(',').filter(Boolean)); @@ -43,7 +44,8 @@ const SUB = 'io.modelcontextprotocol/subscriptionId'; let listens = 0; let subscription = null; -createInterface({ input: process.stdin }).on('line', (line) => { +const lines = createInterface({ input: process.stdin }); +lines.on('line', (line) => { let m; try { m = JSON.parse(line); } catch { return; } note({ event: m.id === undefined ? 'notification' : 'request', method: m.method, id: m.id ?? null, ...(m.method === 'tools/call' ? { tool: m.params?.name } : {}) }); @@ -103,6 +105,7 @@ createInterface({ input: process.stdin }).on('line', (line) => { { name: 'bare', inputSchema: { type: 'object' } }, { name: 'die', inputSchema: { type: 'object' } }, { name: 'vanish', inputSchema: { type: 'object' } }, + { name: 'deafen', inputSchema: { type: 'object' } }, ], }); case 'tools/call': { @@ -117,6 +120,20 @@ createInterface({ input: process.stdin }).on('line', (line) => { } if (name === 'die') { setTimeout(() => process.exit(5), 10); return result(m.id, { content: [{ type: 'text', text: 'bye' }] }); } if (name === 'vanish') process.exit(6); + if (name === 'deafen') { + result(m.id, { content: [{ type: 'text', text: 'deaf now' }] }); + // Node keeps a stdio descriptor open when its stream is destroyed, so + // the read end is closed by hand once the stream is done with it. + process.stdin.once('close', () => { + try { closeSync(0); } catch { /* already closed */ } + note({ event: 'deaf' }); + }); + lines.close(); + process.stdin.destroy(); + // Nothing else keeps the process up once its stdin is gone. + setInterval(() => {}, 1 << 30); + return; + } if (name === 'task') return send({ jsonrpc: '2.0', id: m.id, result: { resultType: 'task', taskId: 'task-1' } }); if (name === 'bare') return send({ jsonrpc: '2.0', id: m.id, result: { content: [{ type: 'text', text: 'no resultType' }] } }); if (name === 'nullframe') { diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts index 5bd108ebe..8305bf417 100644 --- a/test/mcp-modern-outcomes.test.ts +++ b/test/mcp-modern-outcomes.test.ts @@ -778,3 +778,64 @@ test('close() during a connect waits for that connect\'s own cleanup, and report statics.closeSession = real; await connection.close(); }); + +test('a server that closes its stdin and ignores SIGTERM is killed 2 s later and reconnected, and a call made after the failure is refused as not sent', { timeout: 40_000 }, async () => { + const log = scratchLog(); + const connection = await connect('ignore-sigterm', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60, requestTimeoutMs: 30_000 }); + connection.on('error', () => { /* out-of-band SDK errors */ }); + const closes: string[] = []; + connection.on('close', (info: { reason?: string }) => closes.push(info.reason ?? '')); + await connection.callTool('deafen', {}); + await until(() => wire(log).some((e) => e.event === 'deaf'), 'the server to close its stdin'); + // The first write after that fails with EPIPE. It went out, so its call + // isn't answered and isn't refused: it fails with the close. + const first = connection.callTool('plain', {}).then(() => 'answered', (error: unknown) => error); + await until(() => wire(log).some((e) => e.event === 'sigterm-ignored'), 'the transport to send SIGTERM'); + // From here on nothing is written: a call is refused at once, as not sent. + await assert.rejects(connection.callTool('plain', {}), (error: unknown) => { + assert.ok(error instanceof McplRequestError, String(error)); + assert.equal(error.outcome, 'not-sent'); + assert.match(error.message, /stdio pipe failed with write EPIPE/); + return true; + }); + // SIGKILL ends the child, and the connection reconnects as after any loss. + await until(() => wire(log).filter((e) => e.event === 'start').length === 2 && connection.isConnected, 'the next launch, connected', 15_000); + assert.equal(closes.length, 1, closes.join('; ')); + const outcome = await first; + assert.ok(outcome instanceof McplRequestError && outcome.outcome !== 'not-sent', `the written call failed with the close: ${String(outcome)}`); + assert.deepEqual((await connection.callTool('plain', {})).content, [{ type: 'text', text: 'plain' }], 'the next launch answers'); +}); + +test("a server that closes its stdin and outlives SIGKILL too is held as a launch that can't be reaped: a permanent failure, no second launch, and close() retries", { timeout: 40_000 }, async () => { + const log = scratchLog(); + const connection = await connect('', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60, requestTimeoutMs: 30_000 }); + connection.on('error', () => { /* the held launch, reported */ }); + const failures: Array<{ error: string; permanent?: boolean }> = []; + connection.on('reconnect-failed', (failure: { error: string; permanent?: boolean }) => failures.push(failure)); + const line = liveLine(connection); + const realKill = line.child.kill.bind(line.child); + line.child.kill = () => true; // signals never arrive: the child outlives both bounds + // Before the connection's close(), which then reaps an exited child. + cleanups.push(async () => { + realKill('SIGKILL'); + await until(() => !alive(line.child.pid), 'the held child killed'); + }); + await connection.callTool('deafen', {}); + await until(() => wire(log).some((e) => e.event === 'deaf'), 'the server to close its stdin'); + void connection.callTool('plain', {}).catch(() => { /* its write meets EPIPE */ }); + await until(() => failures.length > 0, 'the permanent failure', 12_000); + assert.equal(failures.length, 1); + assert.equal(failures[0]!.permanent, true); + assert.match(failures[0]!.error, /^its stdio pipe failed with write EPIPE; the previous launch could not be reaped \(stdio child \d+ of MCP server "raw" did not exit within 2000ms of SIGKILL; it could not be reaped\); reconnecting is halted$/); + assert.equal(connection.isConnected, false); + assert.equal(connection.willReconnect, false); + assert.equal(connection.holdsUnreapedChild, true); + await new Promise((r) => setTimeout(r, 200)); + assert.equal(wire(log).filter((e) => e.event === 'start').length, 1, 'no second launch'); + // close() retries the reap: refused while the child runs, then done. + await assert.rejects(connection.close(), /could not be reaped/); + realKill('SIGKILL'); + await until(() => !alive(line.child.pid), 'the child to be killed'); + await connection.close(); + assert.equal(connection.holdsUnreapedChild, false); +}); diff --git a/test/mcpl-transport-close.test.ts b/test/mcpl-transport-close.test.ts index 6f0719f3f..2ef5cdb7c 100644 --- a/test/mcpl-transport-close.test.ts +++ b/test/mcpl-transport-close.test.ts @@ -16,7 +16,10 @@ import { test, type TestContext } from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import type { ChildProcess } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import type { AddressInfo, Socket } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { WebSocketServer } from 'ws'; import { McplTransport, StdioTransport, type TransportCloseInfo } from '../src/mcpl/transport.js'; @@ -436,3 +439,191 @@ test("a server that stops reading its stdin without closing it stays connected, assert.equal(closed, false, 'nothing closed the connection'); assert.equal(connection.isConnected, true, 'the connection is still up'); }); + +/** A child that closes its end of stdin and ignores SIGTERM, appending a line + * to `signals` for each SIGTERM it gets, so only SIGKILL ends it. */ +async function spawnDeafStubbornChild(t: TestContext, id: string, signals: string): Promise { + const transport = await StdioTransport.spawn({ + id, + command: process.execPath, + args: ['-e', [ + "const fs = require('fs');", + `process.on('SIGTERM', () => fs.appendFileSync(${JSON.stringify(signals)}, 'SIGTERM\\n'));`, + "fs.closeSync(0); console.log('stdin closed'); setInterval(() => {}, 1 << 30);", + ].join(' ')], + }); + t.after(() => transport.close().catch(() => {})); + const ready = await within(new Promise((resolve) => { transport.once('line', () => resolve(true)); }), false); + assert.ok(ready, 'the child said it had closed its stdin within 5 s'); + return transport; +} + +function signalsFile(t: TestContext): string { + const dir = mkdtempSync(join(tmpdir(), 'mcpl-pipe-signals-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return join(dir, 'signals.txt'); +} +const received = (file: string) => (existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).length : 0); + +test("a failed write to a child that ignores SIGTERM ends it with SIGKILL 2 s later, and the close still names the failure", { timeout: 15_000 }, async (t) => { + const signals = signalsFile(t); + const transport = await spawnDeafStubbornChild(t, 'deaf-stubborn', signals); + const logged = captureConsoleError(t); + const closed = new Promise((resolve) => { transport.once('close', resolve); }); + transport.writeLine('{}'); + await waitFor(() => transport.pipeFailure !== undefined, 'the write to fail'); + assert.equal(transport.pipeFailure?.message, 'write EPIPE', 'writes are refused from here on'); + const info = await within(closed, null, 8000); + assert.ok(info, 'the transport closed within 8 s'); + assert.equal(info.signal, 'SIGKILL', 'SIGKILL ended the child'); + assert.equal(info.reason, 'child process exited, and its pipe failed with write EPIPE'); + assert.equal(received(signals), 1, 'one SIGTERM, then SIGKILL'); + assert.equal(logged.filter((line) => line.includes('ignored SIGTERM for 2000ms')).length, 1, logged.join('\n')); +}); + +test("close() during a pipe failure's escalation shares its attempt: one SIGTERM, one SIGKILL", { timeout: 15_000 }, async (t) => { + const signals = signalsFile(t); + const transport = await spawnDeafStubbornChild(t, 'deaf-shared', signals); + const logged = captureConsoleError(t); + transport.writeLine('{}'); + await waitFor(() => received(signals) === 1, 'the pipe failure to send SIGTERM'); + // close() comes inside the 2 s the child has before SIGKILL, and resolves + // once that SIGKILL has ended it, with no SIGTERM of its own. + await transport.close(); + const child = (transport as unknown as { child: ChildProcess }).child; + assert.equal(child.signalCode, 'SIGKILL'); + assert.equal(received(signals), 1, 'close() sent no second SIGTERM'); + assert.equal(logged.filter((line) => line.includes('sending SIGKILL')).length, 1, logged.join('\n')); +}); + +test("a pipe failure whose child outlives SIGKILL too leaves the transport open and says 'unreaped'; a close() once the child has exited reaps it", { timeout: 20_000 }, async (t) => { + const signals = signalsFile(t); + const transport = await spawnDeafStubbornChild(t, 'deaf-unreaped', signals); + captureConsoleError(t); + const child = (transport as unknown as { child: ChildProcess & { kill: (signal?: string) => boolean } }).child; + const realKill = child.kill.bind(child); + child.kill = () => true; // signals never arrive: the child outlives both bounds + t.after(() => { realKill('SIGKILL'); }); + const unreaped = new Promise((resolve) => { transport.once('unreaped', resolve); }); + transport.writeLine('{}'); + const failure = await within(unreaped, null, 10_000); + assert.ok(failure, "'unreaped' within 10 s"); + assert.match(failure.message, /^stdio child \d+ of MCP server "deaf-unreaped" did not exit within 2000ms of SIGKILL; it could not be reaped$/); + assert.equal(transport.closed, false, 'the transport stays open, owning the child'); + // The child exits at last: its exit closes the transport, naming the pipe + // failure, and the first close() after it re-checks the failed attempt + // rather than repeating its verdict. + const closed = new Promise((resolve) => { transport.once('close', resolve); }); + realKill('SIGKILL'); + assert.equal((await within(closed, null))?.reason, 'child process exited, and its pipe failed with write EPIPE'); + await transport.close(); +}); + +test("a host close() during an unreapable child's pipe escalation reports the failure itself, with no 'unreaped'", { timeout: 20_000 }, async (t) => { + const signals = signalsFile(t); + const transport = await spawnDeafStubbornChild(t, 'deaf-host-close', signals); + captureConsoleError(t); + const child = (transport as unknown as { child: ChildProcess & { kill: (signal?: string) => boolean } }).child; + const realKill = child.kill.bind(child); + child.kill = () => true; + t.after(() => { realKill('SIGKILL'); }); + let saidUnreaped = false; + transport.on('unreaped', () => { saidUnreaped = true; }); + transport.writeLine('{}'); + await waitFor(() => transport.pipeFailure !== undefined, 'the write to fail'); + await assert.rejects(transport.close(), /could not be reaped/); + await new Promise((done) => { setTimeout(done, 50); }); + assert.equal(saidUnreaped, false); +}); + +/** A legacy MCPL server that answers initialize, then, once the initialized + * notification is in, closes its stdin, ignoring SIGTERM, and says so on + * stderr. */ +const DEAF_AND_STUBBORN = [ + "process.on('SIGTERM', () => console.error('ignored SIGTERM'));", + "const rl = require('readline').createInterface({ input: process.stdin });", + "rl.on('line', (line) => {", + ' const message = JSON.parse(line);', + " if (message.method === 'initialize') {", + " process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { protocolVersion: '2024-11-05', capabilities: { experimental: { mcpl: { version: '0.5' } } }, serverInfo: { name: 'deaf-and-stubborn', version: '0.0.0' } } }) + '\\n');", + " } else if (message.method === 'notifications/initialized') {", + " process.stdin.once('close', () => { try { require('fs').closeSync(0); } catch {} console.error('closed stdin'); });", + ' rl.close(); process.stdin.destroy();', + ' }', + '});', + 'setInterval(() => {}, 1 << 30);', +].join('\n'); + +test('a legacy request made after a stdin failure is refused as not sent, and the server, which ignores SIGTERM, is killed and reconnected', { timeout: 60_000 }, async (t) => { + const connection = await McplServerConnection.connectWithReconnect( + { id: 'deaf-and-stubborn', command: process.execPath, args: ['-e', DEAF_AND_STUBBORN], reconnect: true, reconnectIntervalMs: 10, reconnectMaxIntervalMs: 10 }, + { version: '0.5' }, + ); + t.after(() => connection.close()); + captureConsoleError(t); + const stderr: string[] = []; + connection.on('stderr', ({ line }: { line: string }) => { stderr.push(line); }); + const closed = new Promise<{ reason?: string; signal?: string | null }>((resolve) => { + connection.once('close', (_code: unknown, _signal: unknown, info: { reason?: string; signal?: string | null }) => resolve(info)); + }); + const reconnected = new Promise((resolve) => { connection.once('reconnect', () => resolve()); }); + connection.ready(); + await waitFor(() => stderr.includes('closed stdin'), 'the server to close its stdin'); + // The first write after that fails with EPIPE. It went out, so its request + // waits for the close, as possibly executed. + const first = connection.sendManifestRequest(30_000).then(() => 'answered', (error: Error) => error.message); + await waitFor(() => stderr.includes('ignored SIGTERM'), 'the transport to send SIGTERM'); + // From here on nothing is written: a request is refused at once. + await assert.rejects(connection.sendManifestRequest(30_000), (error: unknown) => { + assert.ok(error instanceof Error && (error as { outcome?: string }).outcome === 'not-sent', String(error)); + assert.match((error as Error).message, /^Cannot send request: the stdio pipe of "deaf-and-stubborn" failed with write EPIPE$/); + return true; + }); + const info = await within(closed, null, 10_000); + assert.equal(info?.signal, 'SIGKILL', 'SIGKILL ended it'); + assert.equal(info?.reason, 'child process exited, and its pipe failed with write EPIPE'); + assert.match(await within(first, 'still waiting', 10_000), /disconnected unexpectedly .*while awaiting mcpl\/manifest/); + assert.equal(await within(reconnected.then(() => 'reconnected'), 'no reconnect within 15 s', 15_000), 'reconnected'); +}); + +test("a legacy server whose stdin fails and whose child outlives SIGKILL is held as a launch it can't reap: a permanent failure, and close() retries", { timeout: 60_000 }, async (t) => { + const connection = await McplServerConnection.connectWithReconnect( + { id: 'deaf-unreapable', command: process.execPath, args: ['-e', DEAF_AND_STUBBORN], reconnect: true, reconnectIntervalMs: 10, reconnectMaxIntervalMs: 10 }, + { version: '0.5' }, + ); + captureConsoleError(t); + const child = (connection as unknown as { transport: { child: ChildProcess & { kill: (signal?: string) => boolean } } }).transport.child; + const realKill = child.kill.bind(child); + child.kill = () => true; // signals never arrive: the child outlives both bounds + t.after(async () => { + realKill('SIGKILL'); + await waitFor(() => child.signalCode !== null, 'the held child killed'); + await connection.close(); + }); + const stderr: string[] = []; + connection.on('stderr', ({ line }: { line: string }) => { stderr.push(line); }); + const order: string[] = []; + const failures: Array<{ error: string; permanent?: boolean }> = []; + connection.on('close', (_code: unknown, _signal: unknown, info: { reason?: string }) => { + order.push(`close: ${info.reason} (holds its child: ${connection.holdsUnreapedChild})`); + }); + connection.on('reconnect-failed', (failure: { error: string; permanent?: boolean }) => { order.push('reconnect-failed'); failures.push(failure); }); + connection.ready(); + await waitFor(() => stderr.includes('closed stdin'), 'the server to close its stdin'); + void connection.sendManifestRequest(30_000).catch(() => {}); // its write meets EPIPE + await within(new Promise((resolve) => { connection.once('reconnect-failed', () => resolve()); }), null, 10_000); + assert.deepEqual(order, [ + 'close: its stdio pipe failed with write EPIPE, and its child outlived SIGKILL (holds its child: true)', + 'reconnect-failed', + ]); + assert.equal(failures[0]!.permanent, true); + assert.match(failures[0]!.error, /^the previous launch could not be reaped \(stdio child \d+ of MCP server "deaf-unreapable" did not exit within 2000ms of SIGKILL; it could not be reaped\); reconnecting is halted$/); + assert.equal(connection.isConnected, false); + assert.equal(connection.willReconnect, false); + // close() retries the reap: refused while the child runs, then done. + await assert.rejects(connection.close(), /could not be reaped/); + realKill('SIGKILL'); + await waitFor(() => child.signalCode !== null, 'the child to be killed'); + await connection.close(); + assert.equal(connection.holdsUnreapedChild, false); +}); From b75b6fa3a041fe2e80104722eb9ec32569bcd84d Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 23:35:27 -0400 Subject: [PATCH 86/94] docs(mcp): closeSession never rejects, which the loss path relies on closeSession returns the transport's close failure as its value and catches the client's close, so it never rejects. lossReaped() returns the promise built on it, and the framework's wait on lossReaped() has no catch. The doc now says so, so a change that gave it a throwing path would meet the reason first. Comments only. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/modern-connection.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 9dc569bf7..7e34c9252 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -891,7 +891,8 @@ export class ModernMcpConnection extends EventEmitter { * Close one generation: the transport first (during discovery the SDK * hasn't attached it yet, so only closing it ends the probe), then the * client. The transport's close is the reaping verdict, and its failure is - * returned, never swallowed. + * returned, never swallowed. It never rejects, which lossReaped() and the + * framework's unguarded wait on it rely on. */ private static async closeSession(session: Session): Promise { let failure: Error | null = null; From bd24be7d76cb4e1c5f1a361c86209a41d0fcd9b7 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 23:35:27 -0400 Subject: [PATCH 87/94] fix(mcp): a failed close for good takes the tools off, a held launch's failure reaches its listeners, and nothing is written after a pipe fails A review of the round that answers R2-1 and merges main e6703634. Nothing more goes to a child whose pipe failed: - StdioTransport.writeLine writes nothing once a pipe has failed. Only the request paths checked pipeFailure, so after a stdout-only failure a legacy notification or response still reached the child, though the getter's doc said no write does. Every write passes writeLine, so the rule sits there; the engines' request checks stay, so a request is refused as not sent. The getter's doc and both refusal comments now give that reason: the transport is ending the child. - The first pipe error starts the one attempt to end the child, and a later one, from either pipe, adds nothing. Each used to attach its own catch, so one failed attempt could say 'unreaped' twice. A close for good that fails: - A legacy close() that can't reap its child emits no 'close', so no close listener takes the server's tools off the list. A disconnect and a recycle refresh the list on such a failure themselves. A failed runtime connect's close (main's #459) only logged it, and the initial and post-manifest policy refusals' 'close' fallbacks closed inside the policy request's try, so a failed close was logged as an unanswered policy, and on a manifest change the server's grant and listing stood. - closeMcplServerForGood closes a legacy connection for good through the registry, and when the close fails, refreshes the tool list and rethrows. The failed runtime connect's close and both fallbacks use it, the fallbacks after the policy try, so a failed close is reported as itself. A held launch's permanent failure: - reconnect-failed is control-plane, so it's held until the first ready(). main's #459 has ready() and readyControlPlane() do nothing for a connection closed for good, which a launch held as unreaped is, so a loss held before the first gate released it never reported its permanent failure: no mcpl-down alert at startup, and no refresh. Both now deliver a closed-for-good connection's own buffered connect-failed and reconnect-failed, which need no grant, and nothing else, so #459's rule still holds for what the server sent. A connect that stop() ends, whose cleanup can't reap: - Its error offered "disconnect it to retry the reap", which stop() refuses once it has begun, and the legacy one left out that the framework had stopped. In both engines it now says the framework stopped while it was connecting and its cleanup failed, and offers no disconnect. McplUnreapedLaunchError keeps its cleanup's failure (cleanup) for that. Text: - During a connect, a child whose pipe failed and that outlives SIGKILL is held when the connect fails, by the connect's own cleanup, not at once: the modern engine has no session to lose yet, and the legacy handshake doesn't listen for 'unreaped'. The comments at both wirings and the changelog fragment say so. Tests: the recycle guard's reconnect side (a recycle whose close can't reap leaves the tools listed for the reconnect loop), a close() on a failed attempt while the child runs (rejects again, no signal), the legacy written request's outcome (no-response), close() arriving while the child runs, and a test name that promised a 2 s it didn't measure. Red-first, Linux, Node 24.22.0: - With src at the commit before, 7 new tests fail: in mcpl-transport-close, the write after a pipe failure and both early holds (ready() and readyControlPlane()); in mcp-server-ownership, both engines' stop-ended connects; in mcpl-config-diagnostics, the failed fallback close; in mcpl-awareness-barrier, the failed runtime connect's close. The recycle guard's new test passes there, since the guard was already in the code; its removal below fails it. The tightened assertions in older tests pin behaviour that was already right. - Each of 12 pieces, removed on its own, fails a test: the write gate, the first-error rule, the helper's refresh, the runtime connect's close through the helper, each fallback's close after the policy try, delivering the connection's own reports from ready() and from readyControlPlane(), delivering only those, the stop-aware advice, the legacy stop text (whose removal also fails the typecheck), and the recycle guard's condition. - With the change, these files pass, each run on its own and unforced: mcpl-transport-close 22, mcp-modern-outcomes 40, mcp-server-ownership 40, mcpl-config-diagnostics 15 and mcpl-awareness-barrier 34 on Node 24.22.0 and 20.20.2, and mcpl-legacy-lifetime 16, mcpl-runtime-lifecycle 37, stop-with-host-operations 58, mcpl-manifest-changed 9, failed-create-releases 11, mcp-modern-framework 18 and mcp-recorded-text 10 on Node 24.22.0. Co-Authored-By: Claude Opus 5.5 --- .../stdio-pipe-failure-sigkill.fixed.md | 10 +- src/framework.ts | 95 ++++++++++++------ src/mcpl/modern-connection.ts | 10 +- src/mcpl/server-connection.ts | 53 +++++++--- src/mcpl/transport.ts | 28 +++--- test/mcp-modern-outcomes.test.ts | 2 +- test/mcp-server-ownership.test.ts | 63 ++++++++++++ test/mcpl-awareness-barrier.test.ts | 8 +- test/mcpl-config-diagnostics.test.ts | 45 +++++++++ test/mcpl-transport-close.test.ts | 98 +++++++++++++++++-- 10 files changed, 340 insertions(+), 72 deletions(-) diff --git a/changelog.d/stdio-pipe-failure-sigkill.fixed.md b/changelog.d/stdio-pipe-failure-sigkill.fixed.md index 40ed40c54..4783c6979 100644 --- a/changelog.d/stdio-pipe-failure-sigkill.fixed.md +++ b/changelog.d/stdio-pipe-failure-sigkill.fixed.md @@ -6,11 +6,13 @@ as its `close()` does, in the one attempt `close()` shares, so the connection closes and reconnects as after any exit, its close still naming the pipe's error. - - A request made after the failure is refused as not sent (`not-sent`), - where it was written to the failed pipe and reported at its deadline as - possibly executed. One written before the failure is still reported as - possibly executed. + - Nothing more is written to the child after the failure. A request made + then is refused as not sent (`not-sent`), where it was written to the + failed pipe and reported at its deadline as possibly executed. One + written before the failure is still reported as possibly executed. - A child that outlives SIGKILL too is held as a launch that couldn't be reaped, as a reconnect holds one: reconnecting halts, the `mcpl-down` alert is raised, its tools leave the agents' list, and its id stays held until a disconnect or `stop()` retries the reap and confirms the exit. + During a connect, that happens when the connect times out, and the + connect fails with it. diff --git a/src/framework.ts b/src/framework.ts index 6a4dc1f02..fc1aa3d80 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -17977,11 +17977,14 @@ export class AgentFramework { * A connect whose failed launch couldn't be confirmed exited. Its * connection stays registered, closed, and holds the id, so nothing * launches beside a child that may still be running; a disconnect retries - * the reap, and completes once the exit is confirmed. + * the reap, and completes once the exit is confirmed. Once stop() has + * begun it refuses a disconnect, and retries the reap itself, so no + * disconnect is offered then. */ - private static unreapedConnectFailure(id: string, failure: Error): Error { + private unreapedConnectFailure(id: string, failure: Error): Error { return new Error( - `${failure.message}. MCP server "${id}" stays registered, closed, until its exit is confirmed: disconnect it to retry the reap`, + `${failure.message}. MCP server "${id}" stays registered, closed, until its exit is confirmed` + + (this.stopHasBegun() ? '' : ': disconnect it to retry the reap'), { cause: failure }, ); } @@ -18396,6 +18399,22 @@ export class AgentFramework { })); } + /** + * Close a legacy connection for good through the registry. A close that + * can't confirm its child exited keeps the connection registered, holding + * its id, and emits no 'close', so no close listener takes its tools off + * the agents' list: this does, then rethrows the failure for the caller to + * report. A server the host has given up on leaves the list either way. + */ + private async closeMcplServerForGood(connection: McplServerConnection): Promise { + try { + await this.mcplServerRegistry!.closeServer(connection.id, connection); + } catch (error) { + this.handleToolsListChanged(connection.id); + throw error; + } + } + /** * Close a runtime connect's new connection for good, through the registry, * after a step of that connect failed. A close that fails keeps the entry, @@ -18406,7 +18425,7 @@ export class AgentFramework { connection: McplServerConnection, failedStep: string, ): Promise { - await this.mcplServerRegistry!.closeServer(connection.id, connection).catch((closeError) => { + await this.closeMcplServerForGood(connection).catch((closeError) => { console.error( `[mcpl] could not close MCPL server "${connection.id}" after its ${failedStep} failed; ` + 'it stays registered until a disconnect, restart or shutdown closes it:', @@ -18512,7 +18531,7 @@ export class AgentFramework { const message = cleanup instanceof Error ? cleanup.message : String(cleanup); this.emitTrace({ type: 'mcpl:server-error', serverId: config.id, error: redactServerText(message, config) }); if (this.modernServers().get(config.id) === modern) { - throw AgentFramework.unreapedConnectFailure(config.id, new Error(`${reason.message}; its cleanup also failed: ${message}`, { cause: reason })); + throw this.unreapedConnectFailure(config.id, new Error(`${reason.message}; its cleanup also failed: ${message}`, { cause: reason })); } throw reason; } @@ -18565,19 +18584,21 @@ export class AgentFramework { try { connection = await this.mcplServerRegistry.addServer(connectionConfig, this.mcplHostCapabilities, abort.signal); } catch (error) { - // The registry keeps a failed launch that may still be running, as the - // closed connection that owns it. - if (error instanceof McplUnreapedLaunchError) throw AgentFramework.unreapedConnectFailure(config.id, error); // Ended by stop() or by a disconnect of its id (its abort): said as a // modern connect ended that way says it, with the handshake's own // error as the cause. - if (abort.signal.aborted) { - throw new Error( - `MCP server "${config.id}" was not connected: ` + - (this.stopHasBegun() ? 'the framework stopped while it was connecting' : 'it was disconnected while connecting'), - { cause: error }, - ); + const ended = abort.signal.aborted + ? `MCP server "${config.id}" was not connected: ` + + (this.stopHasBegun() ? 'the framework stopped while it was connecting' : 'it was disconnected while connecting') + : null; + // The registry keeps a failed launch that may still be running, as the + // closed connection that owns it. + if (error instanceof McplUnreapedLaunchError) { + throw this.unreapedConnectFailure(config.id, ended === null + ? error + : new Error(`${ended}; its cleanup also failed: ${error.cleanup.message}`, { cause: error })); } + if (ended !== null) throw new Error(ended, { cause: error }); throw error; } finally { if (this.legacyConnectAborts().get(config.id) === abort) this.legacyConnectAborts().delete(config.id); @@ -18823,6 +18844,9 @@ export class AgentFramework { // feature state, mirroring the §5.3 initial path; // unanswered → interim grant and interim-derived state stand. let negotiated = false; + // A 'close' fallback closes after this try, so a close that fails is + // reported as itself, not as an unanswered policy. + let closeForGood = false; try { const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams); if (receipt && receipt.accepted === false) { @@ -18836,11 +18860,11 @@ export class AgentFramework { ...refusal, }); if (fallback === 'close') { - await this.mcplServerRegistry!.closeServer(connection.id, connection); - return; + closeForGood = true; + } else { + connection.establishGrant(CapabilityGrant.empty()); + this.featureSetManager!.initializeServer(connection.id, maskedOrEmpty, cfg, CapabilityGrant.empty()); } - connection.establishGrant(CapabilityGrant.empty()); - this.featureSetManager!.initializeServer(connection.id, maskedOrEmpty, cfg, CapabilityGrant.empty()); } else { connection.establishGrant(newGrant); this.featureSetManager!.initializeServer(connection.id, maskedOrEmpty, cfg, newGrant); @@ -18850,6 +18874,10 @@ export class AgentFramework { } catch { console.error(`[mcpl] ${connection.id} did not answer post-manifest policy — expansion does not activate (§6.7); interim state stands`); } + if (closeForGood) { + await this.closeMcplServerForGood(connection); + return; + } // Adopt the validated manifest + tracking. droppedCapabilities is // REPLACED unconditionally — an empty new mask must clear stale paths @@ -18944,6 +18972,9 @@ export class AgentFramework { updateParams.deniedCapabilities = [...grant.deniedPaths]; } + // A 'close' fallback closes after this try, so a close that fails is + // reported as itself, not as an unanswered policy. + let closeForGood = false; try { const receipt = await connection.sendFeatureSetsUpdateRequest(updateParams); if (receipt && receipt.accepted === false) { @@ -18960,22 +18991,20 @@ export class AgentFramework { phase: 'initial', ...refusal, }); - if (fallback === 'close') { - await this.mcplServerRegistry!.closeServer(connection.id, connection); - return; - } // mcp-only (§3.2): grant stays empty, MCPL privileged surface stays // dark, plain MCP tools keep working. MUST NOT widen in response to // the refusal — a human can change config; the policy engine cannot. - return; - } - // Expansion activates on receipt (§6.7). - connection.establishGrant(grant); - if (receipt?.accepted === true && receipt.mode === 'degraded') { - for (const uf of receipt.unavailableFeatures ?? []) { - console.error(recorded( - `[mcpl] ${config.id} degraded: ${uf.featureSet} (${uf.effect}; missing ${Array.isArray(uf.missingCapabilities) ? uf.missingCapabilities.join(', ') : 'unknown'})`, - )); + if (fallback !== 'close') return; + closeForGood = true; + } else { + // Expansion activates on receipt (§6.7). + connection.establishGrant(grant); + if (receipt?.accepted === true && receipt.mode === 'degraded') { + for (const uf of receipt.unavailableFeatures ?? []) { + console.error(recorded( + `[mcpl] ${config.id} degraded: ${uf.featureSet} (${uf.effect}; missing ${Array.isArray(uf.missingCapabilities) ? uf.missingCapabilities.join(', ') : 'unknown'})`, + )); + } } } } catch (err) { @@ -18991,6 +19020,10 @@ export class AgentFramework { ); return; } + if (closeForGood) { + await this.closeMcplServerForGood(connection); + return; + } if (config.scopes) { console.error(`[mcpl] ${config.id}: config \`scopes\` is ignored — §7 Scoped Access removed in 0.5.0`); diff --git a/src/mcpl/modern-connection.ts b/src/mcpl/modern-connection.ts index 7e34c9252..a8e2d75be 100644 --- a/src/mcpl/modern-connection.ts +++ b/src/mcpl/modern-connection.ts @@ -312,8 +312,8 @@ class SpawnerStdioTransport implements Transport { async send(message: JSONRPCMessage): Promise { if (!this.line || this.line.closed) throw new NotWrittenError(`MCP server "${this.config.id}" stdio transport is closed`); - // The child can't read what's written once its stdin or stdout has - // failed, while the line waits for it to exit. + // Once the child's stdin or stdout has failed, the line is ending it and + // writes nothing more to it, so a message is refused here as not sent. const failed = this.line.pipeFailure; if (failed) throw new NotWrittenError(`MCP server "${this.config.id}" stdio pipe failed with ${failed.message}`); this.line.writeLine(JSON.stringify(message)); @@ -578,8 +578,10 @@ export class ModernMcpConnection extends EventEmitter { ? new SpawnerStdioTransport( this.config, (line) => { if (this.isCurrent(generation)) this.deliverStderr(line); }, - // A loss whose reap fails: handleLost's reap finds the child still - // running and holds the launch, as for any launch it can't reap. + // A child whose pipe failed and that outlived SIGKILL: a loss, whose + // reap finds the child still running and holds the launch, as for any + // launch it can't reap. During a connect there is no session to lose + // yet: the connect's own cleanup holds it, once the connect fails. (reason) => { if (this.isCurrent(generation)) this.handleLost(generation, reason); }, ) : new StreamableHTTPClientTransport(new URL(this.config.url!), { authProvider: bearerAuth(this.config) }); diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 178cd0670..a58f858f0 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -249,7 +249,8 @@ export class McplUnreapedLaunchError constructor( readonly connection: Connection, failure: Error, - cleanup: Error, + /** The cleanup's own failure: the child it couldn't confirm exited. */ + readonly cleanup: Error, ) { super(`${failure.message}; its cleanup also failed: ${cleanup.message}`, { cause: failure }); this.name = 'McplUnreapedLaunchError'; @@ -511,16 +512,19 @@ export class McplServerConnection extends EventEmitter { * construction (when setupMessageRouting starts emitting) and now (when * the caller has attached listeners via wireMcplEvents). * - * Does nothing once the connection is closed for good. Its policy was reset - * when it closed, so what it held that needs a grant would be refused, and a - * closed connection sends no response, so nothing it held could be answered. - * Only what needs no grant would pass, to listeners of a server that won't - * be served again. A connection between retries isn't closed for good: what - * it held, such as a reconnect stub's connect-failed, still reaches its - * listeners. + * Once the connection is closed for good it readies nothing. Its policy was + * reset when it closed, so what the server sent that needs a grant would be + * refused, and a closed connection sends no response, so nothing it held + * could be answered. It delivers only its own reports of how it ended (see + * {@link deliverOwnReports}). A connection between retries isn't closed for + * good: what it held, such as a reconnect stub's connect-failed, reaches + * its listeners as it does any connection's. */ ready(): void { - if (this.closedForGood) return; + if (this.closedForGood) { + this.deliverOwnReports(); + return; + } this.controlPlaneReady = true; this.dataPlaneReady = true; const pending = this.bufferedEvents; @@ -553,10 +557,14 @@ export class McplServerConnection extends EventEmitter { * to host requests are never event-buffered, so tools/list and tools/call * responses remain live as well. Inference-bearing requests stay queued in * their original relative order until {@link ready} is called. Like - * {@link ready}, it does nothing once the connection is closed for good. + * {@link ready}, once the connection is closed for good it delivers only + * its own reports of how it ended. */ readyControlPlane(): void { - if (this.closedForGood) return; + if (this.closedForGood) { + this.deliverOwnReports(); + return; + } this.controlPlaneReady = true; const pending = this.bufferedEvents; this.bufferedEvents = []; @@ -575,6 +583,21 @@ export class McplServerConnection extends EventEmitter { } } + /** + * A connection closed for good still says how it ended. Its own buffered + * connect-failed and reconnect-failed, such as a launch held as unreaped + * before the first gate released it, are the framework's records of it: + * they need no grant, and its listeners act on them (an alert, and its + * tools leaving the list). What the server sent stays held. + */ + private deliverOwnReports(): void { + const own = (item: { event: string }) => item.event === 'connect-failed' || item.event === 'reconnect-failed'; + const reports = this.bufferedEvents.filter(own); + if (reports.length === 0) return; + this.bufferedEvents = this.bufferedEvents.filter((item) => !own(item)); + for (const { event, args } of reports) super.emit(event, ...args); + } + /** * Close the data plane synchronously while leaving an already-released * control plane live. Used before reconnect adoption and list-change @@ -1545,8 +1568,9 @@ export class McplServerConnection extends EventEmitter { if (this.closed) { return Promise.reject(new McplRequestError(`Cannot send request: connection to "${this.id}" is closed`, 'not-sent')); } - // The child can't read what's written once its stdin or stdout has - // failed, while the transport waits for it to exit. + // Once the child's stdin or stdout has failed, the transport is ending + // it and writes nothing more to it, so a request is refused here as not + // sent rather than left to wait out its timeout. const pipeFailure = this.transport?.pipeFailure; if (pipeFailure) { return Promise.reject(new McplRequestError(`Cannot send request: the stdio pipe of "${this.id}" failed with ${pipeFailure.message}`, 'not-sent')); @@ -1888,7 +1912,8 @@ export class McplServerConnection extends EventEmitter { // reconnecting, and is lost as on a close, with the permanent failure // that raises the alert and takes its tools off the list. Its id stays // held until close() confirms the exit. A close the host has begun - // reports the failure itself. + // reports the failure itself. During a handshake nothing listens yet: + // the handshake's own cleanup holds the launch, once the handshake fails. transport.on('unreaped', (failure: Error) => { if (this.closed || this.transport !== transport) return; this.holdUnreaped(transport, failure); diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index 2531a3393..adbde8d0c 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -90,10 +90,11 @@ export abstract class McplTransport extends EventEmitter { } /** - * The error a stdio child's stdin or stdout failed with, once one has: no - * write reaches the child after it, though the transport stays open until - * the child exits. Undefined while writes can go out, and always for a - * transport that has no pipes. + * The error a stdio child's stdin or stdout failed with, once one has. The + * transport is ending the child then, and stays open until it exits, but + * writes nothing more to it: writeLine drops each line, and both engines + * refuse a request as not sent. Undefined while writes go out, and always + * for a transport that has no pipes. */ get pipeFailure(): Error | undefined { return undefined; @@ -255,14 +256,16 @@ export class StdioTransport extends McplTransport { // does, SIGTERM and then SIGKILL, in the one attempt close() shares, and // the exit's close names the error, so the connection handles it as any // other exit. The close is the report: no 'error' is emitted, nothing is - // thrown, and pipeFailure refuses writes meanwhile. A child that outlives - // SIGKILL too stays this transport's: 'unreaped' says so, unless the host - // is closing it, whose close() reports the failure itself. A child that - // stops reading without closing its stdin raises no error: the writes are - // buffered, and only request timeouts bound it - // (McplServerConfig.requestTimeoutMs). + // thrown, and nothing more is written meanwhile (pipeFailure). The first + // error starts that one attempt, and a later one, from either pipe, adds + // nothing to it. A child that outlives SIGKILL too stays this + // transport's: 'unreaped' says so, unless the host is closing it, whose + // close() reports the failure itself. A child that stops reading without + // closing its stdin raises no error: the writes are buffered, and only + // request timeouts bound it (McplServerConfig.requestTimeoutMs). const endOnPipeError = (err: Error) => { - this.pipeError ??= err; + if (this.pipeError) return; + this.pipeError = err; void this.terminate().catch((failure: unknown) => { if (!this.closed) this.emit('unreaped', failure instanceof Error ? failure : new Error(String(failure))); }); @@ -324,6 +327,9 @@ export class StdioTransport extends McplTransport { } writeLine(json: string): void { + // Nothing more goes to a child whose stdin or stdout has failed: this + // transport is ending it. + if (this.pipeError) return; // stdin may be gone if the child died between the closed-check and here. this.child.stdin?.write(json + '\n'); } diff --git a/test/mcp-modern-outcomes.test.ts b/test/mcp-modern-outcomes.test.ts index 8305bf417..2287d05be 100644 --- a/test/mcp-modern-outcomes.test.ts +++ b/test/mcp-modern-outcomes.test.ts @@ -779,7 +779,7 @@ test('close() during a connect waits for that connect\'s own cleanup, and report await connection.close(); }); -test('a server that closes its stdin and ignores SIGTERM is killed 2 s later and reconnected, and a call made after the failure is refused as not sent', { timeout: 40_000 }, async () => { +test('a server that closes its stdin and ignores SIGTERM is killed and reconnected, and a call made after the failure is refused as not sent', { timeout: 40_000 }, async () => { const log = scratchLog(); const connection = await connect('ignore-sigterm', log, { reconnect: true, reconnectIntervalMs: 30, reconnectMaxIntervalMs: 60, requestTimeoutMs: 30_000 }); connection.on('error', () => { /* out-of-band SDK errors */ }); diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index 0e7580cdc..7b5a8788e 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -440,6 +440,69 @@ test("legacy, without reconnect: a recycle whose child can't be reaped keeps the }); }); +test("legacy, with reconnect: a recycle whose close can't reap leaves the server's tools listed for its reconnect loop to settle", async () => { + await withFramework(async ({ framework, dir }) => { + const log = join(dir, 'legacy.log'); + // Its reconnect is a minute away, so what the recycle itself did is seen + // before the reconnect loop settles anything. + const config = { ...legacy('l', 'ok', log), reconnect: true, reconnectIntervalMs: 60_000, reconnectMaxIntervalMs: 60_000 }; + const reaping = unreapable(); + await framework.connectMcplServer(config); + assert.ok(toolsOf(framework, 'l').length > 0); + const internals = framework as unknown as { handleToolsListChanged(id: string): void }; + const refresh = internals.handleToolsListChanged.bind(framework); + const refreshes: string[] = []; + internals.handleToolsListChanged = (id: string) => { refreshes.push(id); refresh(id); }; + // The awareness accounting fails, and the gate recycles every server. + const gate = framework as unknown as { + installMcplDataPlaneGate(): unknown; + failMcplDataPlaneGate(barrier: unknown, context: string, error: unknown): Promise; + }; + await gate.failMcplDataPlaneGate(gate.installMcplDataPlaneGate(), 'a test', new Error('accounting failed on purpose')); + assert.equal(legacyServer(framework, 'l').willReconnect, true, 'its reconnect is still to come'); + assert.deepEqual(refreshes, [], 'the failed recycle refreshed nothing'); + assert.ok(toolsOf(framework, 'l').length > 0, 'its tools are still listed'); + + await killAndWait(reaping.stuck[0]!); + reaping.active = false; + await framework.disconnectMcplServer('l'); + assert.equal(status(framework, 'l'), undefined, 'the disconnect completed'); + }); +}); + +for (const engine of ['legacy', 'modern'] as const) { + test(`${engine}: a connect that stop() ends, whose cleanup can't reap its child, says the framework stopped and offers no disconnect`, async () => { + const dir = mkdtempSync(join(tmpdir(), 'mcp-ownership-')); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], + modules: [], + }); + const reaping = unreapable(); + const log = join(dir, engine === 'legacy' ? 'legacy.log' : 'raw.jsonl'); + // Neither connect finishes on its own: the legacy handshake waits an + // hour, and the modern server never answers discover. + const pending = framework.connectMcplServer(engine === 'legacy' + ? legacy('s', 'slow', log, { SLOW_INIT_MS: '3600000' }) + : modern('s', 'hang-discover', log)).then(() => null, (error: unknown) => error as Error); + await until( + () => (engine === 'legacy' ? launches(log).length === 1 : lines(log).some((l) => l.includes('server/discover'))), + 'the connect in flight', + ); + await assert.rejects(framework.stop(), /could not be reaped/); + const refused = await pending; + assert.match( + refused?.message ?? 'connected', + /^MCP server "s" was not connected: the framework stopped while it was connecting; its cleanup also failed: .*could not be reaped\. MCP server "s" stays registered, closed, until its exit is confirmed$/, + ); + for (const stuck of reaping.stuck) await killAndWait(stuck); + reaping.active = false; + await framework.stop(); + }); +} + test("modern: ModernMcpConnection.connect hands back the connection that owns a launch its cleanup couldn't reap", async () => { const dir = mkdtempSync(join(tmpdir(), 'mcp-ownership-')); cleanups.push(() => rmSync(dir, { recursive: true, force: true })); diff --git a/test/mcpl-awareness-barrier.test.ts b/test/mcpl-awareness-barrier.test.ts index b0d9140d6..1707cbc42 100644 --- a/test/mcpl-awareness-barrier.test.ts +++ b/test/mcpl-awareness-barrier.test.ts @@ -858,6 +858,11 @@ async function failPolicyRegistrationWhileServing(dir: string, closeFails: boole try { const traces: Array<{ type: string; serverId?: string; error?: string }> = []; framework.onTrace((event) => traces.push(event as never)); + // Which servers' tools the framework asks to relist. + const lists = framework as unknown as { handleToolsListChanged(id: string): void }; + const relist = lists.handleToolsListChanged.bind(framework); + const refreshed: string[] = []; + lists.handleToolsListChanged = (id: string) => { refreshed.push(id); relist(id); }; const features = FeatureSetManager.prototype as any; const initializeServer = features.initializeServer; features.initializeServer = function (this: unknown, serverId: string, ...rest: unknown[]) { @@ -900,7 +905,7 @@ async function failPolicyRegistrationWhileServing(dir: string, closeFails: boole false, 'no gate failed', ); - return { framework, discord, registry: internals.mcplServerRegistry }; + return { framework, discord, registry: internals.mcplServerRegistry, refreshed }; } catch (error) { await framework.stop(); throw error; @@ -943,6 +948,7 @@ test('a runtime connect whose policy registration throws and whose close then fa assert.ok(refused, 'the connection whose close failed is still registered'); assert.equal(refused.willReconnect, false, 'its close had disarmed it before the transport close failed'); assert.equal(refused.dataPlaneReady, false, 'the gate\'s completion left the refused connection paused'); + assert.ok(failed.refreshed.includes(failed.discord.id), 'its tools leave the list though its close failed'); await failed.framework.syncDiscordAwarenessMarkers(); assert.equal(refused.dataPlaneReady, false, 'a later gate\'s completion leaves it paused too'); assert.ok( diff --git a/test/mcpl-config-diagnostics.test.ts b/test/mcpl-config-diagnostics.test.ts index db3568c4b..721848ce3 100644 --- a/test/mcpl-config-diagnostics.test.ts +++ b/test/mcpl-config-diagnostics.test.ts @@ -536,6 +536,51 @@ test('manifest change: a refused policy is traced with phase manifest-change, fo ]); }); +test("a policy refusal's 'close' fallback whose close fails reports that failure, not an unanswered policy, and the server's tools leave the list", async () => { + const unreaped = () => new Error('stdio child 7 of MCP server "srv" did not exit within 2000ms of SIGKILL; it could not be reaped'); + // At the initial policy. + const fw = Object.create(AgentFramework.prototype) as any; + fw.traceListeners = []; + fw.featureSetManager = new FeatureSetManager(); + fw.checkpointManager = null; + const refreshed: string[] = []; + fw.handleToolsListChanged = (id: string) => { refreshed.push(id); }; + const connection = { + id: 'srv', + capabilities: { version: '0.5', pushEvents: true } as unknown as McplCapabilities, + mcpToolsAdvertised: false, + establishGrant: () => {}, + sendFeatureSetsUpdateRequest: async () => ({ accepted: false, fallback: 'close', reason: 'policy too narrow' }), + close: async () => { throw unreaped(); }, + }; + const registry = new McplServerRegistry(); + (registry as any).servers.set('srv', connection); + fw.mcplServerRegistry = registry; + const initial = captureConsoleError(); + try { + await assert.rejects(fw.registerMcplServerFeatures({ id: 'srv', command: 'unused' } as McplServerConfig, connection), /it could not be reaped$/); + } finally { + initial.stop(); + } + assert.deepEqual(refreshed, ['srv'], 'its tools leave the list'); + assert.equal(registry.getServer('srv'), connection, 'it stays registered, holding its id'); + assert.ok(!initial.lines.some((l) => l.includes('did not answer')), initial.lines.join('\n')); + + // At a manifest change. + const h = manifestHarness({ accepted: false, fallback: 'close', reason: 'too narrow' }); + h.connection.close = () => Promise.reject(unreaped()); + const changed: string[] = []; + h.fw.handleToolsListChanged = (id: string) => { changed.push(id); }; + const later = captureConsoleError(); + try { + await assert.rejects(h.fw.handleManifestChanged(h.connection, { revision: 'r2', domains: ['capabilities'] }), /it could not be reaped$/); + } finally { + later.stop(); + } + assert.deepEqual(changed, ['srv'], 'its tools leave the list'); + assert.ok(!later.lines.some((l) => l.includes('did not answer')), later.lines.join('\n')); +}); + test('policy-refused traces report the branch the host took, not raw receipt fields', async () => { // Anything but `close` takes the mcp-only branch; a non-string reason is dropped. const odd = await runManifestChange({ accepted: false, fallback: 'other', reason: 42 }); diff --git a/test/mcpl-transport-close.test.ts b/test/mcpl-transport-close.test.ts index 2ef5cdb7c..cab80da2b 100644 --- a/test/mcpl-transport-close.test.ts +++ b/test/mcpl-transport-close.test.ts @@ -23,7 +23,7 @@ import { join } from 'node:path'; import { WebSocketServer } from 'ws'; import { McplTransport, StdioTransport, type TransportCloseInfo } from '../src/mcpl/transport.js'; -import { McplServerConnection } from '../src/mcpl/server-connection.js'; +import { McplRequestError, McplServerConnection } from '../src/mcpl/server-connection.js'; import { AgentFramework } from '../src/framework.js'; function deferred() { @@ -50,8 +50,8 @@ function captureConsoleError(t: TestContext): string[] { /** Polls until the condition holds, failing after 5 s, so a regression fails * the test rather than keeping the process alive. */ -async function waitFor(condition: () => boolean, what: string): Promise { - const deadline = Date.now() + 5000; +async function waitFor(condition: () => boolean, what: string, ms = 5000): Promise { + const deadline = Date.now() + ms; while (!condition()) { if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); await new Promise((done) => { setTimeout(done, 5); }); @@ -489,8 +489,9 @@ test("close() during a pipe failure's escalation shares its attempt: one SIGTERM await waitFor(() => received(signals) === 1, 'the pipe failure to send SIGTERM'); // close() comes inside the 2 s the child has before SIGKILL, and resolves // once that SIGKILL has ended it, with no SIGTERM of its own. - await transport.close(); const child = (transport as unknown as { child: ChildProcess }).child; + assert.ok(child.exitCode === null && child.signalCode === null, 'close() comes while the child still runs'); + await transport.close(); assert.equal(child.signalCode, 'SIGKILL'); assert.equal(received(signals), 1, 'close() sent no second SIGTERM'); assert.equal(logged.filter((line) => line.includes('sending SIGKILL')).length, 1, logged.join('\n')); @@ -536,6 +537,48 @@ test("a host close() during an unreapable child's pipe escalation reports the fa assert.equal(saidUnreaped, false); }); +test("after a pipe fails nothing more is written, a second pipe error starts nothing, and a close() while the child runs rejects again without a signal", { timeout: 20_000 }, async (t) => { + // A child that ignores SIGTERM and keeps its stdin open: only its stdout + // fails here, so a write would still reach it. + const transport = await StdioTransport.spawn({ + id: 'read-failed', + command: process.execPath, + args: ['-e', "process.on('SIGTERM', () => {}); console.log('ready'); setInterval(() => {}, 1 << 30);"], + }); + t.after(() => transport.close().catch(() => {})); + captureConsoleError(t); + assert.ok(await within(new Promise((resolve) => { transport.once('line', () => resolve(true)); }), false), 'the child is up'); + const child = (transport as unknown as { child: ChildProcess & { kill: (signal?: string) => boolean } }).child; + const realKill = child.kill.bind(child); + let signalled = 0; + child.kill = () => { signalled++; return true; }; // signals never arrive: the child outlives both bounds + t.after(() => { realKill('SIGKILL'); }); + const stdin = child.stdin!; + const realWrite = stdin.write.bind(stdin); + let written = 0; + (stdin as unknown as { write: (...args: unknown[]) => boolean }).write = (...args: unknown[]) => { written++; return (realWrite as (...a: unknown[]) => boolean)(...args); }; + let unreaped = 0; + const first = new Promise((resolve) => { transport.on('unreaped', () => { unreaped++; resolve(); }); }); + + transport.writeLine('{"before":true}'); + assert.equal(written, 1, 'a write goes out while the pipes hold'); + child.stdout!.emit('error', new Error('read failed')); + assert.equal(transport.pipeFailure?.message, 'read failed'); + transport.writeLine('{"after":true}'); + assert.equal(written, 1, 'nothing more is written once a pipe has failed'); + child.stdin!.emit('error', new Error('write EPIPE')); + assert.equal(transport.pipeFailure?.message, 'read failed', 'the first failure is the one kept'); + + assert.ok(await within(first.then(() => true), false, 10_000), "'unreaped' within 10 s"); + await new Promise((resolve) => { setImmediate(resolve); }); + assert.equal(unreaped, 1, "one 'unreaped' for the one failed attempt"); + assert.equal(signalled, 2, 'SIGTERM, then SIGKILL'); + // While the child runs, close() fails with the same verdict and sends no + // signal of its own. + await assert.rejects(transport.close(), /could not be reaped/); + assert.equal(signalled, 2, 'close() sent no signal'); +}); + /** A legacy MCPL server that answers initialize, then, once the initialized * notification is in, closes its stdin, ignoring SIGTERM, and says so on * stderr. */ @@ -571,7 +614,7 @@ test('a legacy request made after a stdin failure is refused as not sent, and th await waitFor(() => stderr.includes('closed stdin'), 'the server to close its stdin'); // The first write after that fails with EPIPE. It went out, so its request // waits for the close, as possibly executed. - const first = connection.sendManifestRequest(30_000).then(() => 'answered', (error: Error) => error.message); + const first = connection.sendManifestRequest(30_000).then(() => null, (error: unknown) => error); await waitFor(() => stderr.includes('ignored SIGTERM'), 'the transport to send SIGTERM'); // From here on nothing is written: a request is refused at once. await assert.rejects(connection.sendManifestRequest(30_000), (error: unknown) => { @@ -582,7 +625,10 @@ test('a legacy request made after a stdin failure is refused as not sent, and th const info = await within(closed, null, 10_000); assert.equal(info?.signal, 'SIGKILL', 'SIGKILL ended it'); assert.equal(info?.reason, 'child process exited, and its pipe failed with write EPIPE'); - assert.match(await within(first, 'still waiting', 10_000), /disconnected unexpectedly .*while awaiting mcpl\/manifest/); + const outcome = await within(first, 'still waiting', 10_000); + assert.ok(outcome instanceof McplRequestError, `the written request failed with the close: ${String(outcome)}`); + assert.equal(outcome.outcome, 'no-response', 'it went out, so it may have run'); + assert.match(outcome.message, /disconnected unexpectedly .*while awaiting mcpl\/manifest/); assert.equal(await within(reconnected.then(() => 'reconnected'), 'no reconnect within 15 s', 15_000), 'reconnected'); }); @@ -627,3 +673,43 @@ test("a legacy server whose stdin fails and whose child outlives SIGKILL is held await connection.close(); assert.equal(connection.holdsUnreapedChild, false); }); + +for (const release of ['ready', 'readyControlPlane'] as const) { + test(`a legacy connection held as unreaped before its first ${release}() still delivers its permanent failure there, and nothing else`, { timeout: 60_000 }, async (t) => { + const connection = await McplServerConnection.connectWithReconnect( + { id: 'held-early', command: process.execPath, args: ['-e', DEAF_AND_STUBBORN], reconnect: true, reconnectIntervalMs: 10, reconnectMaxIntervalMs: 10 }, + { version: '0.5' }, + ); + captureConsoleError(t); + const transport = (connection as unknown as { transport: { pipeFailure: Error | undefined; child: ChildProcess & { kill: (signal?: string) => boolean } } }).transport; + const child = transport.child; + const realKill = child.kill.bind(child); + child.kill = () => true; // signals never arrive: the child outlives both bounds + t.after(async () => { + realKill('SIGKILL'); + await waitFor(() => child.signalCode !== null, 'the held child killed'); + await connection.close(); + }); + const failures: Array<{ error: string; permanent?: boolean }> = []; + connection.on('reconnect-failed', (failure: { error: string; permanent?: boolean }) => { failures.push(failure); }); + const delivered: string[] = []; + connection.on('stderr', ({ line }: { line: string }) => { delivered.push(line); }); + // The server closes its stdin once the handshake is done, so a write after + // that meets EPIPE. (Before ready(), the connection holds its own events, + // the server's stderr among them, so the writes say when.) + await waitFor(() => { + if (transport.pipeFailure === undefined) void connection.sendManifestRequest(30_000).catch(() => {}); + return transport.pipeFailure !== undefined; + }, 'a write to meet EPIPE'); + await waitFor(() => connection.holdsUnreapedChild, 'the launch held', 10_000); + assert.equal(connection.willReconnect, false); + assert.equal(failures.length, 0, 'held until the first release'); + connection[release](); + assert.equal(failures.length, 1, `${release}() delivered the permanent failure`); + assert.equal(failures[0]!.permanent, true); + assert.match(failures[0]!.error, /could not be reaped/); + // The server's own traffic, its stderr among it, stays held. + assert.deepEqual(delivered, [], 'nothing the server sent was delivered'); + assert.equal((connection as unknown as { dataPlaneReady: boolean }).dataPlaneReady, false, 'and no plane was readied'); + }); +} From 991eb1a288ac34e1231bb4cadbd0e44771dba8d6 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sat, 17 Oct 2026 23:37:18 -0400 Subject: [PATCH 88/94] docs(mcp): under Bun a child that closes its stdin isn't reported, so request timeouts bound it Under Bun a write to a child that has closed its stdin calls back with no error, and child.stdin emits neither 'error' nor 'close' until it ends. Checked on Bun 1.4.3, connectome-host's floor, and 1.4.4's canary: 100 small writes and a 1 MB write each called back ok. On 1.3.10 only the 1 MB write's callback got EPIPE, and still no 'error' fired. So the transport's pipe-error handler, which listens for 'error', never runs for such a child: like one that stops reading, request timeouts are its only bound. The pipe-error handler's comment, requestTimeoutMs's doc and the stdio pipe failure's changelog fragment say so. Comments and changelog only. Co-Authored-By: Claude Opus 5.5 --- changelog.d/stdio-pipe-failure-sigkill.fixed.md | 3 +++ src/mcpl/transport.ts | 5 ++++- src/mcpl/types.ts | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/changelog.d/stdio-pipe-failure-sigkill.fixed.md b/changelog.d/stdio-pipe-failure-sigkill.fixed.md index 4783c6979..26df106ab 100644 --- a/changelog.d/stdio-pipe-failure-sigkill.fixed.md +++ b/changelog.d/stdio-pipe-failure-sigkill.fixed.md @@ -16,3 +16,6 @@ until a disconnect or `stop()` retries the reap and confirms the exit. During a connect, that happens when the connect times out, and the connect fails with it. + - Under Bun, a child that closes its stdin isn't reported at all, since + Bun emits no error for the writes to it, so request timeouts still + bound it, as they do a child that stops reading. diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index adbde8d0c..c7014c2f7 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -262,7 +262,10 @@ export class StdioTransport extends McplTransport { // transport's: 'unreaped' says so, unless the host is closing it, whose // close() reports the failure itself. A child that stops reading without // closing its stdin raises no error: the writes are buffered, and only - // request timeouts bound it (McplServerConfig.requestTimeoutMs). + // request timeouts bound it (McplServerConfig.requestTimeoutMs). Under + // Bun a child that closes its stdin isn't reported either: Bun emits no + // 'error' for the writes to it (checked on 1.3.10, 1.4.3 and 1.4.4's + // canary), so request timeouts are its only bound too. const endOnPipeError = (err: Error) => { if (this.pipeError) return; this.pipeError = err; diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 9840462dd..92fe02027 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -410,7 +410,9 @@ export interface McplServerConfig { * descriptive error, which the framework surfaces as a normal isError * tool_result. Request timeouts are the only bound for a stdio server that * stops reading its stdin without closing it: the host's writes are - * buffered, no error is raised, and the connection stays up. This is the + * buffered, no error is raised, and the connection stays up. Under Bun they + * are also the only bound for one that closes its stdin, since Bun reports + * no error for the writes to it. This is the * timeout of every request that names none; the handshake's initialize has * its own. Set 0 to disable. Default: 60000 (60 seconds). A value above * 2^31−1 is refused: Node runs a longer timer after about 1 ms, so every From 6dfee0eb4a2f3ff6f26545b58abda1c06fd4de44 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sun, 18 Oct 2026 00:05:17 -0400 Subject: [PATCH 89/94] fix(mcp): a queued manifest refresh logs a failed close, and the texts on a held launch's reports and a connect-time hold say what the code does A review of the round before this one. - A failed close from a policy refusal's 'close' fallback now leaves handleManifestChanged as itself, and its two queued runs, behind the fetch floor and behind a run in flight, caught every rejection with an empty catch, so such a failure on a queued run was reported nowhere. Both now log it as the announcement's own run does: "manifest refresh failed", redacted. - deliverOwnReports' doc says what the listeners do: they trace each report, and only for a permanent failure raise the alert and take the tools off the list. - During a connect, the connect's own cleanup holds a child that outlived SIGKILL once the connect fails, at the latest at its timeout; the changelog fragment said "when the connect times out", and the modern engine's connect can fail sooner. - requestTimeoutMs's doc names itself after the Bun sentence, which read as the subject of "This is the timeout". Tests: - Both queued runs: a run queued behind the fetch floor, and one queued behind a run in flight, each with a 'close' fallback whose close fails, log "manifest refresh failed" with that failure. - A reconnect stub whose second launch can't be reaped delivers its buffered connect-failed (attempt 0) and its permanent reconnect-failed at its first ready(). - The early-hold tests check that the server's stderr line was held before the release and is still held after it, so their "nothing delivered" can't pass vacuously. - The failed-close fallback tests and the failed runtime connect's assertion say what they check: a refresh of the tool list was asked for. Red-first, Linux, Node 24.22.0: - With src at the commit before, both queued-run tests fail. The stub's delivery test and the held stderr checks pass there, since they pin the commit before's code; the removal of connect-failed from the filter, below, fails the stub's test. - Each of 4 pieces, removed on its own, fails a test: the queued runs' logging catch, its use behind the fetch floor, its use behind a run in flight, and connect-failed in the filter of a closed-for-good connection's own reports. - With the change, these files pass, each run on its own and unforced: mcpl-config-diagnostics 17, mcpl-legacy-lifetime 17, mcpl-transport-close 22 and mcpl-awareness-barrier 34 on Node 24.22.0 and 20.20.2, and mcpl-manifest-changed 9 and mcp-server-ownership 40 on Node 24.22.0. Co-Authored-By: Claude Opus 5.5 --- .../stdio-pipe-failure-sigkill.fixed.md | 5 ++- src/framework.ts | 11 +++++- src/mcpl/server-connection.ts | 9 +++-- src/mcpl/types.ts | 6 +-- test/mcpl-awareness-barrier.test.ts | 2 +- test/mcpl-config-diagnostics.test.ts | 38 +++++++++++++++++-- test/mcpl-legacy-lifetime.test.ts | 23 +++++++++++ test/mcpl-transport-close.test.ts | 6 +++ 8 files changed, 85 insertions(+), 15 deletions(-) diff --git a/changelog.d/stdio-pipe-failure-sigkill.fixed.md b/changelog.d/stdio-pipe-failure-sigkill.fixed.md index 26df106ab..eee122dbc 100644 --- a/changelog.d/stdio-pipe-failure-sigkill.fixed.md +++ b/changelog.d/stdio-pipe-failure-sigkill.fixed.md @@ -14,8 +14,9 @@ reaped, as a reconnect holds one: reconnecting halts, the `mcpl-down` alert is raised, its tools leave the agents' list, and its id stays held until a disconnect or `stop()` retries the reap and confirms the exit. - During a connect, that happens when the connect times out, and the - connect fails with it. + During a connect, the connect's own cleanup holds it once the connect + fails, at the latest when the connect times out, and the connect fails + with it. - Under Bun, a child that closes its stdin isn't reported at all, since Bun emits no error for the writes to it, so request timeouts still bound it, as they do a child that stops reading. diff --git a/src/framework.ts b/src/framework.ts index fc1aa3d80..6465853ce 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -18743,6 +18743,13 @@ export class AgentFramework { const config = this.mcplServerConfigs.get(connection.id); if (!config) return; const recorded = serverTextRedactor(config); + // A queued run's failure is logged as the announcement's own run's is + // (wireMcplEvents), such as a 'close' fallback whose close failed. + const runQueued = () => { + void this.handleManifestChanged(connection, announce).catch((err) => { + console.error(`[mcpl] ${connection.id} manifest refresh failed:`, recorded(err instanceof Error ? err.message : err)); + }); + }; // The announcement's params are the server's hints, which a notification // may leave out: read the one the receipt reports here, before anything // is applied, so no later step can throw on them. @@ -18765,7 +18772,7 @@ export class AgentFramework { const cur = this.manifestRefreshState.get(connection.id); if (cur?.queued && !cur.inFlight) { cur.queued = false; - void this.handleManifestChanged(connection, announce).catch(() => {}); + runQueued(); } }, AgentFramework.MANIFEST_FETCH_FLOOR_MS - sinceLast).unref?.(); } @@ -18915,7 +18922,7 @@ export class AgentFramework { st.lastCompletedAt = Date.now(); if (st.queued) { st.queued = false; - setTimeout(() => { void this.handleManifestChanged(connection, announce).catch(() => {}); }, + setTimeout(runQueued, AgentFramework.MANIFEST_FETCH_FLOOR_MS).unref?.(); } } diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index a58f858f0..fdf2f820b 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -585,10 +585,11 @@ export class McplServerConnection extends EventEmitter { /** * A connection closed for good still says how it ended. Its own buffered - * connect-failed and reconnect-failed, such as a launch held as unreaped - * before the first gate released it, are the framework's records of it: - * they need no grant, and its listeners act on them (an alert, and its - * tools leaving the list). What the server sent stays held. + * connect-failed and reconnect-failed, such as the permanent failure of a + * launch held as unreaped before the first gate released it, are the + * framework's records of it: they need no grant, and its listeners act on + * them (a trace, and for a permanent failure an alert and its tools leaving + * the list). What the server sent stays held. */ private deliverOwnReports(): void { const own = (item: { event: string }) => item.event === 'connect-failed' || item.event === 'reconnect-failed'; diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 92fe02027..a886601bc 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -412,9 +412,9 @@ export interface McplServerConfig { * stops reading its stdin without closing it: the host's writes are * buffered, no error is raised, and the connection stays up. Under Bun they * are also the only bound for one that closes its stdin, since Bun reports - * no error for the writes to it. This is the - * timeout of every request that names none; the handshake's initialize has - * its own. Set 0 to disable. Default: 60000 (60 seconds). A value above + * no error for the writes to it. requestTimeoutMs is the timeout of every + * request that names none; the handshake's initialize has its own. Set 0 + * to disable. Default: 60000 (60 seconds). A value above * 2^31−1 is refused: Node runs a longer timer after about 1 ms, so every * request would time out at once. * diff --git a/test/mcpl-awareness-barrier.test.ts b/test/mcpl-awareness-barrier.test.ts index 1707cbc42..25d55d808 100644 --- a/test/mcpl-awareness-barrier.test.ts +++ b/test/mcpl-awareness-barrier.test.ts @@ -948,7 +948,7 @@ test('a runtime connect whose policy registration throws and whose close then fa assert.ok(refused, 'the connection whose close failed is still registered'); assert.equal(refused.willReconnect, false, 'its close had disarmed it before the transport close failed'); assert.equal(refused.dataPlaneReady, false, 'the gate\'s completion left the refused connection paused'); - assert.ok(failed.refreshed.includes(failed.discord.id), 'its tools leave the list though its close failed'); + assert.ok(failed.refreshed.includes(failed.discord.id), 'a refresh of the tool list was asked for though its close failed'); await failed.framework.syncDiscordAwarenessMarkers(); assert.equal(refused.dataPlaneReady, false, 'a later gate\'s completion leaves it paused too'); assert.ok( diff --git a/test/mcpl-config-diagnostics.test.ts b/test/mcpl-config-diagnostics.test.ts index 721848ce3..36d0a0876 100644 --- a/test/mcpl-config-diagnostics.test.ts +++ b/test/mcpl-config-diagnostics.test.ts @@ -536,7 +536,7 @@ test('manifest change: a refused policy is traced with phase manifest-change, fo ]); }); -test("a policy refusal's 'close' fallback whose close fails reports that failure, not an unanswered policy, and the server's tools leave the list", async () => { +test("a policy refusal's 'close' fallback whose close fails reports that failure, not an unanswered policy, and asks for the tool list to be refreshed", async () => { const unreaped = () => new Error('stdio child 7 of MCP server "srv" did not exit within 2000ms of SIGKILL; it could not be reaped'); // At the initial policy. const fw = Object.create(AgentFramework.prototype) as any; @@ -562,7 +562,7 @@ test("a policy refusal's 'close' fallback whose close fails reports that failure } finally { initial.stop(); } - assert.deepEqual(refreshed, ['srv'], 'its tools leave the list'); + assert.deepEqual(refreshed, ['srv'], 'a refresh of the tool list was asked for'); assert.equal(registry.getServer('srv'), connection, 'it stays registered, holding its id'); assert.ok(!initial.lines.some((l) => l.includes('did not answer')), initial.lines.join('\n')); @@ -577,10 +577,42 @@ test("a policy refusal's 'close' fallback whose close fails reports that failure } finally { later.stop(); } - assert.deepEqual(changed, ['srv'], 'its tools leave the list'); + assert.deepEqual(changed, ['srv'], 'a refresh of the tool list was asked for'); assert.ok(!later.lines.some((l) => l.includes('did not answer')), later.lines.join('\n')); }); +for (const queuedBehind of ['the fetch floor', 'a run in flight'] as const) { + test(`a manifest refresh queued behind ${queuedBehind}, whose 'close' fallback fails to close, logs that failure`, async () => { + const h = manifestHarness({ accepted: false, fallback: 'close', reason: 'too narrow' }); + h.connection.close = () => Promise.reject(new Error('stdio child 7 of MCP server "srv" did not exit within 2000ms of SIGKILL; it could not be reaped')); + const announce = { revision: 'r2', domains: ['capabilities'] }; + const capture = captureConsoleError(); + try { + if (queuedBehind === 'the fetch floor') { + // The last refresh finished just inside the floor, so this one is + // queued, and runs on its own a few milliseconds later. + const floor = (AgentFramework as unknown as { MANIFEST_FETCH_FLOOR_MS: number }).MANIFEST_FETCH_FLOOR_MS; + h.fw.manifestRefreshState.set('srv', { inFlight: false, queued: false, lastCompletedAt: Date.now() - floor + 20 }); + await h.fw.handleManifestChanged(h.connection, announce); + assert.equal(h.fw.manifestRefreshState.get('srv').queued, true, 'queued, not run yet'); + } else { + // The second announcement comes while the first run is in flight, so + // it's queued, and runs once the fetch floor has passed after it. + const first = h.fw.handleManifestChanged(h.connection, announce).then(() => null, (error: unknown) => error); + await h.fw.handleManifestChanged(h.connection, announce); + assert.equal(h.fw.manifestRefreshState.get('srv').queued, true, 'queued behind the first'); + assert.match(String(await first), /it could not be reaped$/, 'the first run rejects with its failed close'); + } + await waitFor( + () => capture.lines.some((l) => l.includes('srv manifest refresh failed') && l.includes('it could not be reaped')), + 'the queued run to log its failed close', + ); + } finally { + capture.stop(); + } + }); +} + test('policy-refused traces report the branch the host took, not raw receipt fields', async () => { // Anything but `close` takes the mcp-only branch; a non-string reason is dropped. const odd = await runManifestChange({ accepted: false, fallback: 'other', reason: 42 }); diff --git a/test/mcpl-legacy-lifetime.test.ts b/test/mcpl-legacy-lifetime.test.ts index 49393b0df..11cf94217 100644 --- a/test/mcpl-legacy-lifetime.test.ts +++ b/test/mcpl-legacy-lifetime.test.ts @@ -196,6 +196,29 @@ test('a reconnect launch that cannot be reaped halts reconnecting; close() settl await connection.close(); }); +test("a reconnect stub held as unreaped before its first ready() still delivers its connect-failed and its permanent failure there", async () => { + const log = scratchLog(); + // The first launch fails its handshake and is reaped, so a stub reconnects; + // the second fails too, and its cleanup can't reap it. + const stuck = unreapableFrom(2); + const connection = await McplServerConnection.connectWithReconnect(config('error', log, RECONNECT), HOST_CAPS); + cleanups.push(() => connection.close().catch(() => {})); + const reports: Array<{ event: string; attempt: number; permanent?: boolean }> = []; + connection.on('connect-failed', (f: { attempt: number }) => reports.push({ event: 'connect-failed', ...f })); + connection.on('reconnect-failed', (f: { attempt: number; permanent?: boolean }) => reports.push({ event: 'reconnect-failed', ...f })); + await until(() => connection.holdsUnreapedChild, 'the reconnect launch held'); + assert.equal(connection.willReconnect, false); + assert.equal(reports.length, 0, 'held until the first release'); + connection.ready(); + assert.deepEqual( + reports.map((r) => [r.event, r.event === 'connect-failed' ? r.attempt : r.permanent === true]), + [['connect-failed', 0], ['reconnect-failed', true]], + ); + await assert.rejects(connection.close(), /could not be reaped/); + await killAndWait(stuck[0]!); + await connection.close(); +}); + test('close() during a reconnect handshake ends that launch before resolving, and nothing is adopted', async () => { const log = scratchLog(); const connection = await McplServerConnection.connectWithReconnect(config('ok-then-hang', log, RECONNECT), HOST_CAPS); diff --git a/test/mcpl-transport-close.test.ts b/test/mcpl-transport-close.test.ts index cab80da2b..a7241a57f 100644 --- a/test/mcpl-transport-close.test.ts +++ b/test/mcpl-transport-close.test.ts @@ -704,12 +704,18 @@ for (const release of ['ready', 'readyControlPlane'] as const) { await waitFor(() => connection.holdsUnreapedChild, 'the launch held', 10_000); assert.equal(connection.willReconnect, false); assert.equal(failures.length, 0, 'held until the first release'); + // The server's stderr line came before its pipe failed, and is held too. + const heldStderr = () => (connection as unknown as { bufferedEvents: Array<{ event: string; args: unknown[] }> }).bufferedEvents + .filter((item) => item.event === 'stderr') + .map((item) => (item.args[0] as { line: string }).line); + assert.ok(heldStderr().includes('closed stdin'), heldStderr().join('\n')); connection[release](); assert.equal(failures.length, 1, `${release}() delivered the permanent failure`); assert.equal(failures[0]!.permanent, true); assert.match(failures[0]!.error, /could not be reaped/); // The server's own traffic, its stderr among it, stays held. assert.deepEqual(delivered, [], 'nothing the server sent was delivered'); + assert.ok(heldStderr().includes('closed stdin'), 'its stderr line is still held'); assert.equal((connection as unknown as { dataPlaneReady: boolean }).dataPlaneReady, false, 'and no plane was readied'); }); } From 0faeab1a3b71fb5ff587a9e29667861c084983f2 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sun, 18 Oct 2026 00:14:37 -0400 Subject: [PATCH 90/94] docs(changelog): a connection closed by a pipe failure reconnects only if it would after any exit The stdio pipe failure's fragment said the connection "closes and reconnects as after any exit", which reads as if every server reconnects; one without reconnect doesn't. It now says the connection closes, and reconnects if it would after any exit. Changelog only. Co-Authored-By: Claude Opus 5.5 --- changelog.d/stdio-pipe-failure-sigkill.fixed.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/stdio-pipe-failure-sigkill.fixed.md b/changelog.d/stdio-pipe-failure-sigkill.fixed.md index eee122dbc..c447da4e8 100644 --- a/changelog.d/stdio-pipe-failure-sigkill.fixed.md +++ b/changelog.d/stdio-pipe-failure-sigkill.fixed.md @@ -4,8 +4,8 @@ ignored SIGTERM kept running, the connection stayed connected, and each request waited out its timeout. The transport now sends SIGKILL after 2 s, as its `close()` does, in the one attempt `close()` shares, so the - connection closes and reconnects as after any exit, its close still naming - the pipe's error. + connection closes, and reconnects if it would after any exit, its close + still naming the pipe's error. - Nothing more is written to the child after the failure. A request made then is refused as not sent (`not-sent`), where it was written to the failed pipe and reported at its deadline as possibly executed. One From 988fca99b027fac137b336ea53d74b85dca703d5 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sun, 18 Oct 2026 01:02:53 -0400 Subject: [PATCH 91/94] docs(mcp): the stdio pipe failure's texts claim what the tests show, and say Bun emits no 'error' event - The changelog fragment's held-child line now speaks of a connected server's child, which the tests cover in both engines. Its sentence on a connect, that the connect's own cleanup holds the child once the connect fails, goes: no test drives a pipe failure through a connect, and the description gives that case as what the code reads as. - Under Bun a write's callback can get EPIPE (a 1 MB write on Bun 1.3.10 did), but no 'error' event is emitted for the writes to a child whose stdin is closed, on 1.3.10, 1.4.3 or 1.4.4's canary, and the transport listens for the event. The fragment and requestTimeoutMs's doc now say "emits no 'error' event", as the transport's comment does. Changelog and comments only. Co-Authored-By: Claude Opus 5.5 --- changelog.d/stdio-pipe-failure-sigkill.fixed.md | 16 +++++++--------- src/mcpl/types.ts | 8 ++++---- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/changelog.d/stdio-pipe-failure-sigkill.fixed.md b/changelog.d/stdio-pipe-failure-sigkill.fixed.md index c447da4e8..250cc00fd 100644 --- a/changelog.d/stdio-pipe-failure-sigkill.fixed.md +++ b/changelog.d/stdio-pipe-failure-sigkill.fixed.md @@ -10,13 +10,11 @@ then is refused as not sent (`not-sent`), where it was written to the failed pipe and reported at its deadline as possibly executed. One written before the failure is still reported as possibly executed. - - A child that outlives SIGKILL too is held as a launch that couldn't be - reaped, as a reconnect holds one: reconnecting halts, the `mcpl-down` - alert is raised, its tools leave the agents' list, and its id stays held - until a disconnect or `stop()` retries the reap and confirms the exit. - During a connect, the connect's own cleanup holds it once the connect - fails, at the latest when the connect times out, and the connect fails - with it. + - A connected server's child that outlives SIGKILL too is held as a + launch that couldn't be reaped, as a reconnect holds one: reconnecting + halts, the `mcpl-down` alert is raised, its tools leave the agents' + list, and its id stays held until a disconnect or `stop()` retries the + reap and confirms the exit. - Under Bun, a child that closes its stdin isn't reported at all, since - Bun emits no error for the writes to it, so request timeouts still - bound it, as they do a child that stops reading. + Bun emits no `'error'` event for the writes to it, so request timeouts + still bound it, as they do a child that stops reading. diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index a886601bc..d23991502 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -411,10 +411,10 @@ export interface McplServerConfig { * tool_result. Request timeouts are the only bound for a stdio server that * stops reading its stdin without closing it: the host's writes are * buffered, no error is raised, and the connection stays up. Under Bun they - * are also the only bound for one that closes its stdin, since Bun reports - * no error for the writes to it. requestTimeoutMs is the timeout of every - * request that names none; the handshake's initialize has its own. Set 0 - * to disable. Default: 60000 (60 seconds). A value above + * are also the only bound for one that closes its stdin, since Bun emits no + * 'error' event for the writes to it. requestTimeoutMs is the timeout of + * every request that names none; the handshake's initialize has its own. + * Set 0 to disable. Default: 60000 (60 seconds). A value above * 2^31−1 is refused: Node runs a longer timer after about 1 ms, so every * request would time out at once. * From 3f343d6f282c213c56bee7f0baf4654ed7901e69 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sun, 18 Oct 2026 15:22:45 -0400 Subject: [PATCH 92/94] test(mcpl): the merge's loose ends: a startup test waits for initialize, two stubs drop mcplConnecting, and #506's fragment names the restart it covers - mcpl-stderr-tail: the startup test's server ends in EXIT_ON_INITIALIZE, as the file's other servers do, so its failure takes the same path on every run. - mcpl-reconnect-reregistration and reaction-baseline-platform no longer seed mcplConnecting, which the merge removed and nothing reads. - mcpl-connect-in-flight.fixed.md: the refusal in its words is for a runtime connect, or a restart of another server into the id. A restart of the connecting server itself is refused first by the restart's own check, which says it is being connected. The three test files pass at this commit on Node 24.22.0: 21, 4 and 8 tests. Co-Authored-By: Claude Opus 5.5 --- changelog.d/mcpl-connect-in-flight.fixed.md | 2 +- test/mcpl-reconnect-reregistration.test.ts | 2 -- test/mcpl-stderr-tail.test.ts | 2 +- test/reaction-baseline-platform.test.ts | 1 - 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/changelog.d/mcpl-connect-in-flight.fixed.md b/changelog.d/mcpl-connect-in-flight.fixed.md index 409e54230..953d931bc 100644 --- a/changelog.d/mcpl-connect-in-flight.fixed.md +++ b/changelog.d/mcpl-connect-in-flight.fixed.md @@ -1 +1 @@ -- A runtime connect or a restart into an id whose connect is still in flight, in either engine, is now refused before it changes anything, with `MCPL server "" has a connect in flight; try again once it settles`. Before, a second connect went ahead beside it, and whichever settled last replaced the other in the registry without closing it. A disconnect of that id now ends the connect in flight and removes whatever it registered. Before, the disconnect removed the server's config and tool prefix, and the connect then registered the server without them, where no call reached it and only another disconnect cleared it. +- A runtime connect, or a restart of another server into an id whose connect is still in flight, in either engine, is now refused before it changes anything, with `MCPL server "" has a connect in flight; try again once it settles`. Before, a second connect went ahead beside it, and whichever settled last replaced the other in the registry without closing it. A disconnect of that id now ends the connect in flight and removes whatever it registered. Before, the disconnect removed the server's config and tool prefix, and the connect then registered the server without them, where no call reached it and only another disconnect cleared it. diff --git a/test/mcpl-reconnect-reregistration.test.ts b/test/mcpl-reconnect-reregistration.test.ts index 7ee1a1cc3..912479b28 100644 --- a/test/mcpl-reconnect-reregistration.test.ts +++ b/test/mcpl-reconnect-reregistration.test.ts @@ -107,8 +107,6 @@ async function makeHarness() { fw.mcplTools = []; fw.mcplToolRefreshInFlight = false; fw.mcplToolRefreshPending = false; - // A disconnect reads the servers whose connect is in flight. - fw.mcplConnecting = new Set(); fw.channelRegistry = null; fw.inferenceRouter = null; fw.eventGate = null; diff --git a/test/mcpl-stderr-tail.test.ts b/test/mcpl-stderr-tail.test.ts index 6f9e602b4..94d6d56b9 100644 --- a/test/mcpl-stderr-tail.test.ts +++ b/test/mcpl-stderr-tail.test.ts @@ -278,7 +278,7 @@ test("at startup, a failed connect's trace and log line carry the server's stder mcplServers: [{ id: 'needs-token', command: process.execPath, - args: ['-e', "console.error('fatal: DISCORD_TOKEN ' + process.env.DISCORD_TOKEN + ' was refused'); process.exit(1)"], + args: ['-e', `console.error('fatal: DISCORD_TOKEN ' + process.env.DISCORD_TOKEN + ' was refused'); ${EXIT_ON_INITIALIZE}`], env: { DISCORD_TOKEN: 'tok-0123456789abcdef' }, }], }); diff --git a/test/reaction-baseline-platform.test.ts b/test/reaction-baseline-platform.test.ts index d004073c5..4f6f9e5d3 100644 --- a/test/reaction-baseline-platform.test.ts +++ b/test/reaction-baseline-platform.test.ts @@ -25,7 +25,6 @@ async function connectionEnv( mcplHostCapabilities: {}, mcplPrefixMap: new Map(), mcplServerConfigs: new Map(), - mcplConnecting: new Set(), channelRegistry: null, discordAwarenessOutbox: null, discordAwarenessEmoji: '🔕', From fffa85735441c62b5d66dd6ad7289ac0e24c10a9 Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sun, 18 Oct 2026 15:40:10 -0400 Subject: [PATCH 93/94] test(mcp): a modern server's tool prefix meets another server's under #469's rule At a runtime connect, a server whose prefix meets a modern server's is refused, and so is a modern server whose prefix meets a legacy one's, whichever family connected first. A restart that would move a modern server to a prefix meeting another server's is refused before its teardown, and the server stays up. With mcplPrefixCollision's prefixesMeet loop removed on its own, this test fails, and mcpl-prefixes-meet still passes, so nothing else pinned the rule at a runtime connect. At this commit, on Node 24.22.0, mcp-server-ownership passes 41 of 41. Co-Authored-By: Claude Opus 5.5 --- test/mcp-server-ownership.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/mcp-server-ownership.test.ts b/test/mcp-server-ownership.test.ts index 4f438ae59..b8da064c7 100644 --- a/test/mcp-server-ownership.test.ts +++ b/test/mcp-server-ownership.test.ts @@ -617,6 +617,27 @@ test("stop()'s teardown budget bounds a modern server's close too, and the serve assert.equal(modernServers.has('m'), false, 'removed once its close completed'); }); +test("a modern server's tool prefix meets another server's under #469's rule, at a runtime connect and a restart, whichever family is first", async () => { + await withFramework(async ({ framework, dir }) => { + const modernLog = join(dir, 'raw.jsonl'); + // A modern server first, under `foo`. + await framework.connectMcplServer({ ...modern('m', '', modernLog), toolPrefix: 'foo' }); + await assert.rejects(framework.connectMcplServer({ ...legacy('l', 'ok', join(dir, 'legacy.log')), toolPrefix: 'foo--bar' }), + /toolPrefix "foo--bar" meets server "m"'s toolPrefix "foo"/, 'a legacy server whose prefix meets it'); + await assert.rejects(framework.connectMcplServer({ ...modern('m2', '', join(dir, 'raw2.jsonl')), toolPrefix: 'foo-' }), + /toolPrefix "foo-" meets server "m"'s toolPrefix "foo"/, 'a modern server whose prefix meets it'); + // A legacy server first, under `bar`. + await framework.connectMcplServer({ ...legacy('l2', 'ok', join(dir, 'legacy2.log')), toolPrefix: 'bar' }); + await assert.rejects(framework.connectMcplServer({ ...modern('m3', '', join(dir, 'raw3.jsonl')), toolPrefix: 'bar--baz' }), + /toolPrefix "bar--baz" meets server "l2"'s toolPrefix "bar"/, 'a modern server whose prefix meets a legacy one'); + // A restart that would move the modern server to a prefix meeting the legacy one's is refused before its teardown. + await assert.rejects(framework.restartMcplServer('m', { ...modern('m', '', modernLog), toolPrefix: 'bar-' }), + /toolPrefix "bar-" meets server "l2"'s toolPrefix "bar"/, 'a restart into a prefix that meets another'); + assert.equal(status(framework, 'm')?.connected, true, 'the refused restart left the server up'); + for (const id of ['l', 'm2', 'm3']) assert.equal(status(framework, id), undefined, `${id} was never connected`); + }); +}); + test('a restart whose new configuration is refused leaves the running server as it was', async () => { await withFramework(async ({ framework, dir }) => { const log = join(dir, 'raw.jsonl'); From b1ce54c203d0b3d2b257e2cc2c9d91643451881d Mon Sep 17 00:00:00 2001 From: Crispin-2406 Date: Sun, 18 Oct 2026 15:50:31 -0400 Subject: [PATCH 94/94] docs(mcp): #506's in-flight words hold during a legacy handshake only The in-flight refusal's doc and its changelog fragment said the words hold in either engine. They hold where mcpServerIdTaken finds a connect in flight and no server registered: a legacy connect's handshake, since the legacy registry takes a server once its handshake is done. A modern connect registers its connection first, so a second connect of its id reads as already registered, and a tool call during it reads that connection's own refusal. Both texts now say so, and mcplServerAbsentText's doc says when its "connecting" text applies. Comments and changelog only. Co-Authored-By: Claude Opus 5.5 --- changelog.d/mcpl-connect-in-flight.fixed.md | 2 +- src/framework.ts | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/changelog.d/mcpl-connect-in-flight.fixed.md b/changelog.d/mcpl-connect-in-flight.fixed.md index 953d931bc..f3f6d9961 100644 --- a/changelog.d/mcpl-connect-in-flight.fixed.md +++ b/changelog.d/mcpl-connect-in-flight.fixed.md @@ -1 +1 @@ -- A runtime connect, or a restart of another server into an id whose connect is still in flight, in either engine, is now refused before it changes anything, with `MCPL server "" has a connect in flight; try again once it settles`. Before, a second connect went ahead beside it, and whichever settled last replaced the other in the registry without closing it. A disconnect of that id now ends the connect in flight and removes whatever it registered. Before, the disconnect removed the server's config and tool prefix, and the connect then registered the server without them, where no call reached it and only another disconnect cleared it. +- A runtime connect, or a restart of another server into an id whose legacy connect is still in its handshake, is now refused before it changes anything, with `MCPL server "" has a connect in flight; try again once it settles`. While a modern connect is in flight, the id reads as already registered. Before, a second connect went ahead beside a legacy one, and whichever settled last replaced the other in the registry without closing it. A disconnect of the id, in either engine, now ends the connect in flight and removes whatever it registered. Before, the disconnect removed the server's config and tool prefix, and the connect then registered the server without them, where no call reached it and only another disconnect cleared it. diff --git a/src/framework.ts b/src/framework.ts index d717e3de3..b06394192 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -171,10 +171,12 @@ function mcplToolDeniedText(serverId: string, toolName: string): string { "and calling it again won't change that. If you need it, ask whoever runs this host."; } -/** A runtime connect or restart refused because a connect of the server's id - * is in flight, in either engine (mcpServerIdTaken). A disconnect isn't - * refused: it ends that connect. Whoever asked reads it: an operator, or an - * agent through a host's tool that passes it on. */ +/** A runtime connect or restart refused because a legacy connect of the + * server's id is still in its handshake, before its server is registered + * (mcpServerIdTaken). A modern connect registers its connection first, so an + * id it holds reads as already registered. A disconnect isn't refused: it + * ends the connect in flight, in either engine. Whoever asked reads it: an + * operator, or an agent through a host's tool that passes it on. */ function mcplConnectInFlightText(serverId: string): string { return `MCPL server "${serverId}" has a connect in flight; try again once it settles`; } @@ -24124,10 +24126,12 @@ export class AgentFramework { } } - /** The refusal of a tool call whose server, of either engine, isn't - * registered, for the model's call and a script's alike: "connecting" - * while a connect of its id is in flight (mcpServerOps) or a restart holds - * the id, and "gone" otherwise. */ + /** The refusal of a tool call whose server isn't registered, for the + * model's call and a script's alike: "connecting" while a connect of its + * id is in flight (mcpServerOps) or a restart holds the id, and "gone" + * otherwise. A legacy connect registers its server once its handshake is + * done; a modern connect registers its connection first, so a call during + * it reads that connection's own refusal. */ private mcplServerAbsentText(serverId: string, toolName: string): string { return this.mcpServerOps?.get(serverId)?.kind === 'connect' || this.restartClaims().ids.has(serverId) ? mcplServerConnectingText(serverId, toolName)