diff options
| author | Adam Malczewski <[email protected]> | 2026-07-01 03:30:42 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-07-01 03:30:42 +0900 |
| commit | 665672eccff531f7a785295a8f16e57e98106deb (patch) | |
| tree | 4a9fd97fd332bbc153987b617e184570c6558706 | |
| parent | 6bca0a8b65506239b0ce72d7f86ba96f825152b1 (diff) | |
| parent | 918b8cddf9013c20943310e0c02a724aecb3fe94 (diff) | |
| download | dispatch-665672eccff531f7a785295a8f16e57e98106deb.tar.gz dispatch-665672eccff531f7a785295a8f16e57e98106deb.zip | |
Merge branch 'feature/mcp-transport-fixes' into predev
| -rw-r--r-- | packages/mcp/src/client.test.ts | 66 | ||||
| -rw-r--r-- | packages/mcp/src/client.ts | 48 | ||||
| -rw-r--r-- | packages/mcp/src/extension.test.ts | 107 | ||||
| -rw-r--r-- | packages/mcp/src/extension.ts | 49 | ||||
| -rw-r--r-- | packages/mcp/src/framing.test.ts | 157 | ||||
| -rw-r--r-- | packages/mcp/src/framing.ts | 176 | ||||
| -rw-r--r-- | packages/mcp/src/index.ts | 6 | ||||
| -rw-r--r-- | packages/mcp/src/manager.test.ts | 25 | ||||
| -rw-r--r-- | packages/mcp/src/manager.ts | 37 | ||||
| -rw-r--r-- | packages/mcp/src/rpc.test.ts | 5 | ||||
| -rw-r--r-- | packages/mcp/src/timeout.test.ts | 105 | ||||
| -rw-r--r-- | packages/mcp/src/timeout.ts | 114 | ||||
| -rw-r--r-- | packages/mcp/src/transport.test.ts | 29 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 5 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/tools-filter.ts | 10 |
15 files changed, 823 insertions, 116 deletions
diff --git a/packages/mcp/src/client.test.ts b/packages/mcp/src/client.test.ts index ca8a11b..4542a36 100644 --- a/packages/mcp/src/client.test.ts +++ b/packages/mcp/src/client.test.ts @@ -203,4 +203,70 @@ describe("McpClient", () => { content: [{ type: "text", text: "too late" }], }); }); + + /** A connection whose initialize never responds (simulates a framing- + * incompatible server like chrome-devtools-mcp under Content-Length framing): + * the pending JSON-RPC request would hang forever without a timeout/abort. */ + function makeHangingConnection(): Connection { + const never = new Promise<unknown>(() => {}); + return { + send: () => never, + notify: () => {}, + onNotification: () => {}, + close: () => {}, + pid: 1, + }; + } + + it("initialize raises McpTimeoutError when the server never responds", async () => { + const { McpTimeoutError } = await import("./timeout.js"); + const client = new McpClient({ connection: makeHangingConnection() }); + await expect(client.initialize(undefined, 20)).rejects.toBeInstanceOf(McpTimeoutError); + expect(client.getState()).toBe("error"); + }); + + it("initialize is abortable: an aborting signal rejects immediately", async () => { + const client = new McpClient({ connection: makeHangingConnection() }); + const controller = new AbortController(); + const p = client.initialize(controller.signal, 50_000); + controller.abort(); + await expect(p).rejects.toThrow("Aborted"); + expect(client.getState()).toBe("error"); + }); + + it("initialize rejects immediately when the signal is already aborted", async () => { + const client = new McpClient({ connection: makeHangingConnection() }); + const controller = new AbortController(); + controller.abort(); + await expect(client.initialize(controller.signal, 50_000)).rejects.toThrow("Aborted"); + }); + + it("listTools raises McpTimeoutError when the server never responds", async () => { + const { McpTimeoutError } = await import("./timeout.js"); + // Reach "connected" with a fast (auto-responding) connection, then swap in + // a hanging connection for the tools/list call. + const fast = makeMockConnection(); + const client = new McpClient({ connection: fast }); + await client.initialize(); + + const hanging = makeHangingConnection(); + // Swap the connection so tools/list hangs. + (client as unknown as { connection: Connection }).connection = hanging; + + await expect(client.listTools(undefined, 20)).rejects.toBeInstanceOf(McpTimeoutError); + }); + + it("listTools is abortable", async () => { + const fast = makeMockConnection(); + const client = new McpClient({ connection: fast }); + await client.initialize(); + + const hanging = makeHangingConnection(); + (client as unknown as { connection: Connection }).connection = hanging; + + const controller = new AbortController(); + const p = client.listTools(controller.signal, 50_000); + controller.abort(); + await expect(p).rejects.toThrow("Aborted"); + }); }); diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index d5d06fc..6a69c00 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -3,8 +3,15 @@ * * Manages a single MCP server connection: initialize handshake, * tool discovery, tool invocation, and list_changed notifications. + * + * Every awaited handshake/list operation is bounded by `withTimeout` (a default + * timeout) and an optional `AbortSignal`, so a misbehaving or framing- + * incompatible server can never hang the caller (the per-turn tools filter) + * indefinitely. `callTool` was already abort-aware; `initialize`/`listTools` + * now are too. */ +import { MCP_DEFAULT_TIMEOUT_MS, withTimeout } from "./timeout.js"; import type { Connection } from "./transport.js"; import type { McpCallResult, @@ -47,14 +54,27 @@ export class McpClient { this.toolsChangedHandler = handler; } - async initialize(): Promise<McpInitializeResult> { + /** + * Perform the MCP `initialize` handshake. Bounded by `timeoutMs` (default + * {@link MCP_DEFAULT_TIMEOUT_MS}) and the optional `signal` (the turn's abort + * signal) so a server that never responds cannot hang the caller forever. + */ + async initialize( + signal?: AbortSignal, + timeoutMs: number = MCP_DEFAULT_TIMEOUT_MS, + ): Promise<McpInitializeResult> { this.state = "connecting"; try { - const result = (await this.connection.send("initialize", { - protocolVersion: "2025-11-25", - capabilities: {}, - clientInfo: { name: "dispatch", version: "0.0.0" }, - })) as McpInitializeResult; + const result = (await withTimeout( + this.connection.send("initialize", { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "dispatch", version: "0.0.0" }, + }), + "initialize", + timeoutMs, + signal, + )) as McpInitializeResult; this.capabilities = result.capabilities; this.connection.notify("notifications/initialized", {}); @@ -73,11 +93,23 @@ export class McpClient { } } - async listTools(): Promise<readonly McpToolInfo[]> { + /** + * List the server's tools. Bounded by `timeoutMs` (default + * {@link MCP_DEFAULT_TIMEOUT_MS}) and the optional `signal`. + */ + async listTools( + signal?: AbortSignal, + timeoutMs: number = MCP_DEFAULT_TIMEOUT_MS, + ): Promise<readonly McpToolInfo[]> { if (this.state !== "connected") { throw new Error("Client not connected"); } - const result = (await this.connection.send("tools/list")) as McpListToolsResult; + const result = (await withTimeout( + this.connection.send("tools/list"), + "tools/list", + timeoutMs, + signal, + )) as McpListToolsResult; this.tools = result.tools; return this.tools; } diff --git a/packages/mcp/src/extension.test.ts b/packages/mcp/src/extension.test.ts index b937c2b..9e029d2 100644 --- a/packages/mcp/src/extension.test.ts +++ b/packages/mcp/src/extension.test.ts @@ -81,6 +81,9 @@ describe("filterMcpTools (pure)", () => { interface FakeServer { tools: McpToolInfo[]; failInitialize: boolean; + /** When true, the spawn never responds to `initialize` (a hanging / + * framing-incompatible server) — used to exercise timeout/abort paths. */ + hangInitialize: boolean; emitListChanged: () => void; } @@ -113,7 +116,11 @@ function makeFakeSpawn(server: FakeServer): SpawnProcess { const id = parsed.id ?? 0; const method = parsed.method; if (method === "initialize") { - if (server.failInitialize) { + if (server.hangInitialize) { + // Never respond — simulates a framing-incompatible server + // (e.g. chrome-devtools-mcp under the old Content-Length + // framing). The connect must be bounded by timeout/abort. + } else if (server.failInitialize) { emit( encode( JSON.stringify({ @@ -246,7 +253,12 @@ const assembly = (tools: ToolContract[], cwd = "/proj"): ToolAssembly => ({ const flush = () => new Promise((r) => setTimeout(r, 0)); function makeServer(initialTools: McpToolInfo[]): FakeServer { - return { tools: [...initialTools], failInitialize: false, emitListChanged: () => {} }; + return { + tools: [...initialTools], + failInitialize: false, + hangInitialize: false, + emitListChanged: () => {}, + }; } function makeExt(server: FakeServer, configJson: string): Extension { @@ -360,4 +372,95 @@ describe("mcp extension lifecycle", () => { const after = await service.status("/proj"); expect(after[0].state).toBe("disconnected"); }); + + // ------------------------------------------------------------------------- + // Bug 2 + Bug 3: a misbehaving/hanging server must not hang a turn, and the + // turn's AbortSignal (assembly.signal) must interrupt a stuck connect. + // ------------------------------------------------------------------------- + + it("degrades gracefully (no MCP tools) when the turn's signal is already aborted", async () => { + const server = makeServer([tool("create_object")]); + const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } })); + const { host, getFilter } = makeFakeHost(); + ext.activate(host); + const filter = requireFilter(getFilter); + + const controller = new AbortController(); + controller.abort(); + + const base = assembly([]); + // The filter must NOT hang on the (never-needed) connect: an aborted turn + // signal propagates to initialize, which rejects immediately. + const result = await filter({ + tools: base.tools, + cwd: base.cwd, + conversationId: base.conversationId, + signal: controller.signal, + }); + + expect(result.tools).toEqual([]); + ext.deactivate?.(); + }); + + it("the turn's signal aborts a hanging server connect (POST /stop interrupts)", async () => { + // hangInitialize: the spawn never responds to initialize (a framing- + // incompatible / misbehaving server). Without abort propagation this + // would hang the filter until MCP_CONNECT_TIMEOUT_MS; with propagation + // the abort breaks it immediately. + const server = makeServer([tool("create_object")]); + server.hangInitialize = true; + const ext = makeExt(server, dispatchConfig({ chrome: { command: "fake" } })); + const { host, getFilter } = makeFakeHost(); + ext.activate(host); + const filter = requireFilter(getFilter); + + const controller = new AbortController(); + const base = assembly([]); + const resultPromise = filter({ + tools: base.tools, + cwd: base.cwd, + conversationId: base.conversationId, + signal: controller.signal, + }); + + // Let the filter progress into the hanging initialize (withTimeout has + // its abort listener armed), THEN abort — a true mid-flight cancel + // simulating POST /conversations/:id/stop. Without signal propagation + // this would hang ~30s (the connect backstop) and time out the test. + await flush(); + controller.abort(); + + const result = await resultPromise; + // Degraded: no MCP tools surfaced, and the filter resolved (did not hang). + expect(result.tools).toEqual([]); + ext.deactivate?.(); + }); + + it("non-MCP tools pass through unchanged when an MCP connect fails", async () => { + // failInitialize: the server rejects initialize (a fast failure, not a + // hang) so the connect degrades promptly without waiting on a backstop. + const server = makeServer([tool("create_object")]); + server.failInitialize = true; + const ext = makeExt(server, dispatchConfig({ chrome: { command: "fake" } })); + const { host, getFilter } = makeFakeHost(); + ext.activate(host); + const filter = requireFilter(getFilter); + + const stubNonMcp: ToolContract = { + name: "run_shell", + description: "kept", + parameters: { type: "object" }, + execute: async () => ({ content: "" }), + }; + + const result = await filter({ + tools: [stubNonMcp], + cwd: "/proj", + conversationId: "c", + }); + + // Non-MCP tool survives; the failed MCP server contributed no tools. + expect(result.tools.map((t) => t.name)).toEqual(["run_shell"]); + ext.deactivate?.(); + }); }); diff --git a/packages/mcp/src/extension.ts b/packages/mcp/src/extension.ts index d12d420..bb951ee 100644 --- a/packages/mcp/src/extension.ts +++ b/packages/mcp/src/extension.ts @@ -18,6 +18,7 @@ import { resolveServers } from "./config.js"; import type { Logger } from "./manager.js"; import { McpManager } from "./manager.js"; import { adaptTool, namespace } from "./registry.js"; +import { MCP_CONNECT_TIMEOUT_MS } from "./timeout.js"; import type { SpawnedProcess, SpawnProcess } from "./transport.js"; import { createStdioTransport } from "./transport.js"; import type { McpServerStatus, McpService, ResolvedMcpServer } from "./types.js"; @@ -111,13 +112,19 @@ export function makeMcpExtension(deps: McpExtensionDeps): Extension { } } - async function connectAndRegister(server: ResolvedMcpServer, cwd: string): Promise<void> { - const client = await manager.ensureConnected(server, cwd); + async function connectAndRegister( + server: ResolvedMcpServer, + cwd: string, + signal?: AbortSignal, + ): Promise<void> { + const client = await manager.ensureConnected(server, cwd, signal); registerToolsFromClient(server.id, client); // Wire list_changed → re-list → re-register. onToolsChanged replaces // the handler; ensureConnected returns the same cached client so this - // is idempotent across turns. + // is idempotent across turns. The async re-list runs LATER (not during + // this filter), so it is NOT bound to the filter's signal (already + // done) — it relies on listTools()'s own default timeout instead. client.onToolsChanged(async () => { try { await client.listTools(); @@ -133,18 +140,44 @@ export function makeMcpExtension(deps: McpExtensionDeps): Extension { // Resolve config + ensure servers connected, then drop tools whose // server is not connected. Lazy-spawn happens here (first turn). + // + // The whole connect phase is wrapped in a per-filter AbortController + // that fires on EITHER (a) the turn's signal (`assembly.signal`, so + // POST /conversations/:id/stop interrupts a stuck connect immediately) + // OR (b) a timeout (`MCP_CONNECT_TIMEOUT_MS`, so a misbehaving / + // framing-incompatible server cannot hang the turn forever). On abort + // we degrade gracefully: skip MCP tools for this turn rather than block. host.addFilter(toolsFilter, async (assembly: ToolAssembly): Promise<ToolAssembly> => { const cwd = assembly.cwd ?? deps.getCwd(); const dispatchMcpJson = await deps.readFile(joinPath(cwd, ".dispatch", "mcp.json")); const opencodeJson = await deps.readFile(joinPath(cwd, "opencode.json")); const { servers } = resolveServers({ dispatchMcpJson, opencodeJson }); - for (const server of servers) { - try { - await connectAndRegister(server, cwd); - } catch { - // Connection failure — the manager tracks broken state. + const controller = new AbortController(); + const parentSignal = assembly.signal; + const onParentAbort = (): void => controller.abort(); + if (parentSignal !== undefined) { + if (parentSignal.aborted) { + controller.abort(); + } else { + parentSignal.addEventListener("abort", onParentAbort, { once: true }); + } + } + const timer = setTimeout(() => controller.abort(), MCP_CONNECT_TIMEOUT_MS); + + try { + for (const server of servers) { + try { + await connectAndRegister(server, cwd, controller.signal); + } catch { + // Connection failure / timeout / aborted — the manager tracks + // broken state; we keep going (or abort cascades) below. + } + if (controller.signal.aborted) break; } + } finally { + clearTimeout(timer); + parentSignal?.removeEventListener("abort", onParentAbort); } const statuses = manager.status(servers); diff --git a/packages/mcp/src/framing.test.ts b/packages/mcp/src/framing.test.ts index 3b207a3..9832c74 100644 --- a/packages/mcp/src/framing.test.ts +++ b/packages/mcp/src/framing.test.ts @@ -1,84 +1,74 @@ import { describe, expect, it } from "vitest"; import { encode, FrameDecoder } from "./framing.js"; +/** Build a legacy Content-Length frame (for exercising the decoder's CL path). */ +function contentLengthFrame(body: string): Uint8Array { + const bodyBytes = new TextEncoder().encode(body); + return new TextEncoder().encode(`Content-Length: ${bodyBytes.length}\r\n\r\n${body}`); +} + describe("encode", () => { - it("produces correct Content-Length header", () => { + it("produces newline-delimited JSON (current MCP spec framing)", () => { const msg = '{"jsonrpc":"2.0","id":1,"method":"initialize"}'; const encoded = encode(msg); const text = new TextDecoder().decode(encoded); - expect(text).toBe(`Content-Length: ${new TextEncoder().encode(msg).length}\r\n\r\n${msg}`); + expect(text).toBe(`${msg}\n`); }); - it("handles empty message", () => { + it("appends a trailing newline to an empty message", () => { const encoded = encode(""); const text = new TextDecoder().decode(encoded); - expect(text).toBe("Content-Length: 0\r\n\r\n"); + expect(text).toBe("\n"); }); }); -describe("FrameDecoder", () => { - it("reassembles a complete message from one chunk", () => { +describe("FrameDecoder — newline-delimited JSON", () => { + it("decodes a single newline-delimited message", () => { + const msg = '{"jsonrpc":"2.0","id":1}'; + const decoder = new FrameDecoder(); + expect(decoder.decode(encode(msg))).toEqual([msg]); + }); + + it("decodes a CRLF-terminated message (trailing \\r tolerated)", () => { const msg = '{"jsonrpc":"2.0","id":1}'; - const encoded = encode(msg); const decoder = new FrameDecoder(); - const messages = decoder.decode(encoded); - expect(messages).toEqual([msg]); + const framed = new TextEncoder().encode(`${msg}\r\n`); + expect(decoder.decode(framed)).toEqual([msg]); }); - it("handles split across chunks", () => { + it("handles a split across chunks", () => { const msg = '{"jsonrpc":"2.0","id":1,"method":"initialize"}'; const encoded = encode(msg); const mid = Math.floor(encoded.length / 2); - const chunk1 = encoded.slice(0, mid); - const chunk2 = encoded.slice(mid); const decoder = new FrameDecoder(); - const result1 = decoder.decode(chunk1); - expect(result1).toEqual([]); - - const result2 = decoder.decode(chunk2); - expect(result2).toEqual([msg]); + expect(decoder.decode(encoded.slice(0, mid))).toEqual([]); + expect(decoder.decode(encoded.slice(mid))).toEqual([msg]); }); it("handles two messages in one chunk", () => { const msg1 = '{"jsonrpc":"2.0","id":1}'; const msg2 = '{"jsonrpc":"2.0","id":2}'; - const encoded1 = encode(msg1); - const encoded2 = encode(msg2); - const combined = new Uint8Array(encoded1.length + encoded2.length); - combined.set(encoded1); - combined.set(encoded2, encoded1.length); + const combined = new Uint8Array(encode(msg1).length + encode(msg2).length); + combined.set(encode(msg1)); + combined.set(encode(msg2), encode(msg1).length); const decoder = new FrameDecoder(); - const messages = decoder.decode(combined); - expect(messages).toEqual([msg1, msg2]); + expect(decoder.decode(combined)).toEqual([msg1, msg2]); }); - it("rejects negative Content-Length by skipping header", () => { - const header = "Content-Length: -5\r\n\r\n"; - const encoded = new TextEncoder().encode(`${header}extra`); - const decoder = new FrameDecoder(); - const messages = decoder.decode(encoded); - // Negative length does not match the digit capture, so the header is skipped. - expect(messages).toEqual([]); - }); - - it("rejects zero Content-Length", () => { - const encoded = encode(""); + it("skips blank lines between messages", () => { + const msg = '{"jsonrpc":"2.0","id":1}'; + const framed = new TextEncoder().encode(`\n\n${msg}\n\n`); const decoder = new FrameDecoder(); - const messages = decoder.decode(encoded); - expect(messages).toEqual([""]); + expect(decoder.decode(framed)).toEqual([msg]); }); - it("reassembles multi-byte UTF-8 content (byte-length, not char-length)", () => { - // "héllo" — é is two UTF-8 bytes; Content-Length counts bytes. + it("reassembles multi-byte UTF-8 content (byte-aware, not char-aware)", () => { const msg = '{"text":"héllo 🚀"}'; - const encoded = encode(msg); expect(new TextEncoder().encode(msg).length).toBeGreaterThan(msg.length); - const decoder = new FrameDecoder(); - const messages = decoder.decode(encoded); - expect(messages).toEqual([msg]); + expect(decoder.decode(encode(msg))).toEqual([msg]); }); it("reassembles multi-byte content split across a chunk boundary", () => { @@ -89,4 +79,85 @@ describe("FrameDecoder", () => { expect(decoder.decode(encoded.slice(0, mid))).toEqual([]); expect(decoder.decode(encoded.slice(mid))).toEqual([msg]); }); + + it("does not split a JSON string containing an escaped \\n (no raw newline)", () => { + // JSON escapes newlines inside strings as the two chars `\` + `n`; a raw + // 0x0a only ever appears as a message separator. So a JSON body carrying + // an embedded newline literal survives intact. + const msg = '{"text":"line1\\nline2"}'; + const decoder = new FrameDecoder(); + expect(decoder.decode(encode(msg))).toEqual([msg]); + }); +}); + +describe("FrameDecoder — legacy Content-Length framing (auto-detected)", () => { + it("decodes a Content-Length-framed message", () => { + const msg = '{"jsonrpc":"2.0","id":1}'; + const decoder = new FrameDecoder(); + expect(decoder.decode(contentLengthFrame(msg))).toEqual([msg]); + }); + + it("reassembles a Content-Length frame split across chunks", () => { + const msg = '{"jsonrpc":"2.0","id":1,"method":"initialize"}'; + const encoded = contentLengthFrame(msg); + const mid = Math.floor(encoded.length / 2); + const decoder = new FrameDecoder(); + expect(decoder.decode(encoded.slice(0, mid))).toEqual([]); + expect(decoder.decode(encoded.slice(mid))).toEqual([msg]); + }); + + it("decodes two Content-Length frames in one chunk", () => { + const msg1 = '{"jsonrpc":"2.0","id":1}'; + const msg2 = '{"jsonrpc":"2.0","id":2}'; + const combined = new Uint8Array( + contentLengthFrame(msg1).length + contentLengthFrame(msg2).length, + ); + combined.set(contentLengthFrame(msg1)); + combined.set(contentLengthFrame(msg2), contentLengthFrame(msg1).length); + + const decoder = new FrameDecoder(); + expect(decoder.decode(combined)).toEqual([msg1, msg2]); + }); + + it("rejects negative Content-Length by skipping header", () => { + const encoded = new TextEncoder().encode("Content-Length: -5\r\n\r\nextra"); + const decoder = new FrameDecoder(); + expect(decoder.decode(encoded)).toEqual([]); + }); + + it("accepts zero Content-Length as an empty message", () => { + const decoder = new FrameDecoder(); + expect(decoder.decode(contentLengthFrame(""))).toEqual([""]); + }); + + it("reassembles multi-byte UTF-8 via Content-Length (byte count)", () => { + const msg = '{"text":"héllo 🚀"}'; + const decoder = new FrameDecoder(); + expect(decoder.decode(contentLengthFrame(msg))).toEqual([msg]); + }); + + it("does not mis-read a partial 'Content-Length' prefix as newline-delimited", () => { + // A buffer that is a partial prefix of "Content-Length:" must WAIT for more + // bytes rather than being split on a (nonexistent) newline. + const decoder = new FrameDecoder(); + const partial = new TextEncoder().encode("Content-Len"); + expect(decoder.decode(partial)).toEqual([]); + const rest = new TextEncoder().encode("gth: 3\r\n\r\nabc"); + expect(decoder.decode(rest)).toEqual(["abc"]); + }); +}); + +describe("FrameDecoder — mixed framings", () => { + it("decodes a Content-Length frame followed by a newline-delimited message", () => { + const clMsg = '{"jsonrpc":"2.0","id":1}'; + const nlMsg = '{"jsonrpc":"2.0","id":2}'; + const cl = contentLengthFrame(clMsg); + const nl = encode(nlMsg); + const combined = new Uint8Array(cl.length + nl.length); + combined.set(cl); + combined.set(nl, cl.length); + + const decoder = new FrameDecoder(); + expect(decoder.decode(combined)).toEqual([clMsg, nlMsg]); + }); }); diff --git a/packages/mcp/src/framing.ts b/packages/mcp/src/framing.ts index 5f818e2..d30b871 100644 --- a/packages/mcp/src/framing.ts +++ b/packages/mcp/src/framing.ts @@ -1,32 +1,40 @@ /** - * Content-Length framing for MCP stdio transport. + * MCP stdio framing. * - * Each JSON-RPC message is framed as: - * Content-Length: <byte-length>\r\n\r\n<JSON bytes> + * The MCP spec (revision 2025-03-26 and later, including 2025-11-25) frames + * JSON-RPC messages over stdio as **newline-delimited JSON**: each message is + * a single JSON document followed by a `\n` (or `\r\n`). This replaced the + * older LSP-style `Content-Length: N\r\n\r\n<JSON>` framing that the protocol + * inherited originally. Modern servers (e.g. chrome-devtools-mcp) speak ONLY + * newline-delimited JSON — they emit `<JSON>\n` on stdout and do not respond to + * `Content-Length`-framed input at all. * - * Same framing as LSP — the MCP spec inherited this from the LSP base protocol. + * Outgoing messages are therefore encoded as newline-delimited JSON (the + * current spec default), so we can talk to modern servers. The decoder + * **auto-detects** the incoming framing per message — it accepts BOTH + * newline-delimited JSON and legacy `Content-Length` frames — so we still + * interoperate with servers that respond with the old framing. * * PURE: no I/O. Operates on bytes (Uint8Array) so multi-byte UTF-8 content is * handled correctly — `Content-Length` is a *byte* count, not a character count. */ +const HEADER_PREFIX = "Content-Length:"; const HEADER_SEP = "\r\n\r\n"; const CONTENT_LENGTH_RE = /Content-Length:\s*(\d+)/i; -const SEP_BYTES = new TextEncoder().encode(HEADER_SEP); +const encoder = new TextEncoder(); +const SEP_BYTES = encoder.encode(HEADER_SEP); + +const CR = 0x0d; +const LF = 0x0a; /** - * Encode a JSON string into a single Content-Length-framed message. - * Returns the full frame (header + blank line + body) as bytes. + * Encode a JSON string as a single newline-delimited frame (the current MCP + * spec stdio framing): `<JSON>\n`. This is what we send to MCP servers. */ export function encode(msg: string): Uint8Array { - const body = new TextEncoder().encode(msg); - const header = `Content-Length: ${body.length}\r\n\r\n`; - const frame = new TextEncoder().encode(header); - const result = new Uint8Array(frame.length + body.length); - result.set(frame); - result.set(body, frame.length); - return result; + return encoder.encode(`${msg}\n`); } /** Find the first occurrence of `needle` in `haystack` at or after `from`. -1 if absent. */ @@ -46,9 +54,48 @@ function indexOfBytes(haystack: Uint8Array, needle: Uint8Array, from: number): n return -1; } +/** Find the first occurrence of a single byte at or after `from`. -1 if absent. */ +function indexOfByte(haystack: Uint8Array, needle: number, from: number): number { + for (let i = from; i < haystack.length; i++) { + if (haystack[i] === needle) return i; + } + return -1; +} + +/** Lowercase an ASCII byte (A-Z → a-z); leave everything else unchanged. */ +function toLowerByte(b: number): number { + return b >= 0x41 && b <= 0x5a ? b + 0x20 : b; +} + +function isLineTerminator(b: number | undefined): boolean { + return b === CR || b === LF; +} + +/** + * How does `buf` relate to the `Content-Length:` header prefix (case-insensitive)? + * - `HEADER_PREFIX.length` → `buf` starts with the full `Content-Length:` prefix. + * - a positive number `< HEADER_PREFIX.length` → `buf` is a (possibly partial) + * prefix of `Content-Length:` — ambiguous, the caller must wait for more bytes. + * - `-1` → `buf` definitively does NOT start with `Content-Length:` (not CL framing). + */ +function contentLengthPrefixLength(buf: Uint8Array): number { + const n = Math.min(buf.length, HEADER_PREFIX.length); + for (let i = 0; i < n; i++) { + const a = buf[i]; + if (a === undefined) return -1; // unreachable: i < n <= buf.length + if (toLowerByte(a) !== toLowerByte(HEADER_PREFIX.charCodeAt(i))) return -1; + } + return n; +} + /** * Feed raw bytes into the decoder. Returns all complete JSON messages that can * be extracted from the accumulated buffer. Buffers partial frames across calls. + * + * Auto-detects framing per message: a `Content-Length:`-prefixed buffer is parsed + * as a Content-Length frame (legacy/LSP-style); anything else is parsed as + * newline-delimited JSON (current MCP spec). Both framings may be mixed in a + * single stream. */ export class FrameDecoder { private buf: Uint8Array = new Uint8Array(0); @@ -56,45 +103,90 @@ export class FrameDecoder { decode(chunk: Uint8Array): string[] { // Append the incoming chunk to the internal byte buffer. - const next = new Uint8Array(this.buf.length + chunk.length); - next.set(this.buf); - next.set(chunk, this.buf.length); - this.buf = next; + if (chunk.length > 0) { + const next = new Uint8Array(this.buf.length + chunk.length); + next.set(this.buf); + next.set(chunk, this.buf.length); + this.buf = next; + } const messages: string[] = []; while (true) { - const sepIdx = indexOfBytes(this.buf, SEP_BYTES, 0); - if (sepIdx === -1) break; - - // The header block is everything before the separator; parse - // Content-Length from it (ASCII, so decoding the slice is safe). - const headerText = this.decoder.decode(this.buf.subarray(0, sepIdx)); - const match = CONTENT_LENGTH_RE.exec(headerText); - const bodyStart = sepIdx + SEP_BYTES.length; - - if (!match?.[1]) { - // No usable Content-Length — drop this header and continue scanning. - this.buf = this.buf.subarray(bodyStart); - continue; - } + // 1. Skip leading CR/LF whitespace between frames. + let i = 0; + while (i < this.buf.length && isLineTerminator(this.buf[i])) i++; + if (i > 0) this.buf = this.buf.subarray(i); + if (this.buf.length === 0) break; - const length = Number.parseInt(match[1], 10); - if (length < 0) { - this.buf = this.buf.subarray(bodyStart); + // 2. Detect framing. + const prefix = contentLengthPrefixLength(this.buf); + if (prefix >= 0 && prefix < HEADER_PREFIX.length) { + // Buffer is a (possibly partial) prefix of "Content-Length:" — ambiguous; + // wait for more bytes before deciding this is (or isn't) a CL frame. + break; + } + if (prefix === HEADER_PREFIX.length) { + // Content-Length framing. + const result = this.tryParseContentLength(); + if (result === "incomplete") break; + if (result !== "skip") messages.push(result); continue; } - if (this.buf.length - bodyStart < length) { - // Body not fully received yet; wait for more bytes. - break; - } + // 3. Newline-delimited JSON: one message per line, terminated by `\n` + // (a trailing `\r` before the `\n` is tolerated). A raw newline byte + // can only ever appear as a message separator — JSON escapes newlines + // inside strings as the two characters `\n`, never a literal 0x0a — so + // splitting on `\n` bytes is safe for valid JSON. + const nl = indexOfByte(this.buf, LF, 0); + if (nl === -1) break; // incomplete line — wait for more bytes - // Decode exactly `length` body bytes (preserves multi-byte UTF-8). - messages.push(this.decoder.decode(this.buf.subarray(bodyStart, bodyStart + length))); - this.buf = this.buf.subarray(bodyStart + length); + let end = nl; + if (end > 0 && this.buf[end - 1] === CR) end--; + const text = this.decoder.decode(this.buf.subarray(0, end)); + this.buf = this.buf.subarray(nl + 1); + if (text.length > 0) messages.push(text); } return messages; } + + /** + * Parse one Content-Length frame from the front of `this.buf`. Returns: + * - the decoded body string (possibly `""` for a zero-length body), + * - `"incomplete"` if the header or body hasn't fully arrived (caller waits), + * - `"skip"` if the header was consumed but carried no usable Content-Length + * (caller continues scanning without emitting a message). + */ + private tryParseContentLength(): string | "incomplete" | "skip" { + const sepIdx = indexOfBytes(this.buf, SEP_BYTES, 0); + if (sepIdx === -1) return "incomplete"; // header not fully received yet + + const headerText = this.decoder.decode(this.buf.subarray(0, sepIdx)); + const match = CONTENT_LENGTH_RE.exec(headerText); + const bodyStart = sepIdx + SEP_BYTES.length; + + if (!match?.[1]) { + // No usable Content-Length — drop this header and continue scanning. + this.buf = this.buf.subarray(bodyStart); + return "skip"; + } + + const length = Number.parseInt(match[1], 10); + if (length < 0) { + this.buf = this.buf.subarray(bodyStart); + return "skip"; + } + + if (this.buf.length - bodyStart < length) { + // Body not fully received yet; wait for more bytes. + return "incomplete"; + } + + // Decode exactly `length` body bytes (preserves multi-byte UTF-8). + const body = this.decoder.decode(this.buf.subarray(bodyStart, bodyStart + length)); + this.buf = this.buf.subarray(bodyStart + length); + return body; + } } diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 6902244..6d186d7 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -11,6 +11,12 @@ export { encode, FrameDecoder } from "./framing.js"; export { type Logger, McpManager, type McpManagerDeps } from "./manager.js"; export { adaptTool, flattenContent, namespace } from "./registry.js"; export { + MCP_CONNECT_TIMEOUT_MS, + MCP_DEFAULT_TIMEOUT_MS, + McpTimeoutError, + withTimeout, +} from "./timeout.js"; +export { type Connection, createStdioTransport, type SpawnedProcess, diff --git a/packages/mcp/src/manager.test.ts b/packages/mcp/src/manager.test.ts index 7cc53b8..d6179b7 100644 --- a/packages/mcp/src/manager.test.ts +++ b/packages/mcp/src/manager.test.ts @@ -213,4 +213,29 @@ describe("McpManager", () => { expect(manager.getClient("nonexistent")).toBeUndefined(); }); + + it("forwards the abort signal: a hanging initialize is interrupted", async () => { + // A connection whose initialize never resolves (a misbehaving / + // framing-incompatible server). Without a signal this hangs forever. + const hangingConn: Connection = { + send: () => new Promise(() => {}), + notify: () => {}, + onNotification: () => {}, + close: () => {}, + pid: 300, + }; + const manager = makeManager(() => { + return { connection: hangingConn, promise: Promise.resolve() }; + }); + + const controller = new AbortController(); + const connectPromise = manager.ensureConnected(testServer, "/tmp", controller.signal); + + // Abort mid-connect — the signal must reach initialize() and break it. + controller.abort(); + + await expect(connectPromise).rejects.toThrow(); + // The server is recorded as broken (not silently wedged). + expect(manager.status([testServer])[0].state).toBe("error"); + }); }); diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index 08ad389..1d719c6 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -104,7 +104,19 @@ export class McpManager { return results; } - async ensureConnected(server: ResolvedMcpServer, cwd: string): Promise<McpClient> { + /** + * Ensure a client for `server` is connected, lazily spawning + handshaking on + * first access. The optional `signal` (the turn's abort signal) is forwarded + * into the `initialize`/`listTools` handshake so `POST /conversations/:id/stop` + * can interrupt a stuck connect; the operations are independently bounded by + * their own default timeout, so a misbehaving server cannot hang a turn even + * when no signal is supplied. + */ + async ensureConnected( + server: ResolvedMcpServer, + cwd: string, + signal?: AbortSignal, + ): Promise<McpClient> { const existing = this.clients.get(server.id); if (existing && existing.client.getState() === "connected") { return existing.client; @@ -119,7 +131,7 @@ export class McpManager { this.broken.delete(server.id); } - await this.spawnClient(server, cwd); + await this.spawnClient(server, cwd, signal); const entry = this.clients.get(server.id); if (!entry) { throw new Error(`Failed to spawn MCP client for ${server.id}`); @@ -131,11 +143,15 @@ export class McpManager { return entry.client; } - private async spawnClient(server: ResolvedMcpServer, cwd: string): Promise<void> { + private async spawnClient( + server: ResolvedMcpServer, + cwd: string, + signal?: AbortSignal, + ): Promise<void> { const existingSpawn = this.spawning.get(server.id); if (existingSpawn) return existingSpawn; - const spawnPromise = this.doSpawn(server, cwd); + const spawnPromise = this.doSpawn(server, cwd, signal); this.spawning.set(server.id, spawnPromise); try { @@ -145,7 +161,11 @@ export class McpManager { } } - private async doSpawn(server: ResolvedMcpServer, cwd: string): Promise<void> { + private async doSpawn( + server: ResolvedMcpServer, + cwd: string, + signal?: AbortSignal, + ): Promise<void> { const { connection, promise } = this.connectionFactory(server, cwd); const client = new McpClient({ connection }); @@ -153,7 +173,7 @@ export class McpManager { const entry: ClientEntry = { client, server, - promise: this.initClient(client, server, promise), + promise: this.initClient(client, server, promise, signal), }; this.clients.set(server.id, entry); @@ -172,10 +192,11 @@ export class McpManager { client: McpClient, server: ResolvedMcpServer, _transportPromise: Promise<void>, + signal?: AbortSignal, ): Promise<void> { try { - await client.initialize(); - await client.listTools(); + await client.initialize(signal); + await client.listTools(signal); this.deps.logger?.info("MCP server connected", { serverId: server.id, toolCount: String(client.getTools().length), diff --git a/packages/mcp/src/rpc.test.ts b/packages/mcp/src/rpc.test.ts index dc295af..34b6af4 100644 --- a/packages/mcp/src/rpc.test.ts +++ b/packages/mcp/src/rpc.test.ts @@ -87,11 +87,10 @@ describe("JsonRpcClient", () => { client.request("c"); expect(written.length).toBe(3); - // Extract JSON body from Content-Length framed messages + // Outgoing messages are newline-delimited JSON (current MCP spec framing). const parse = (bytes: Uint8Array): { id: number } => { const text = new TextDecoder().decode(bytes); - const bodyStart = text.indexOf("\r\n\r\n") + 4; - return JSON.parse(text.slice(bodyStart)) as { id: number }; + return JSON.parse(text.replace(/\r?\n$/, "")) as { id: number }; }; const msg1 = parse(written[0]); const msg2 = parse(written[1]); diff --git a/packages/mcp/src/timeout.test.ts b/packages/mcp/src/timeout.test.ts new file mode 100644 index 0000000..38961d0 --- /dev/null +++ b/packages/mcp/src/timeout.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { McpTimeoutError, withTimeout } from "./timeout.js"; + +/** + * `withTimeout` uses a single `setTimeout` (the only edge). Tests drive it + * deterministically via `AbortController` for the abort path, and use real + * (tiny) timers for the timeout path — vitest's async scheduler is fast enough + * at single-digit ms that this is stable. + */ + +describe("withTimeout", () => { + it("resolves with the underlying value when the promise settles first", async () => { + const result = await withTimeout(Promise.resolve("ok"), "initialize", 1000); + expect(result).toBe("ok"); + }); + + it("rejects with the underlying error when the promise rejects first", async () => { + await expect( + withTimeout(Promise.reject(new Error("boom")), "initialize", 1000), + ).rejects.toThrow("boom"); + }); + + it("rejects with McpTimeoutError when the timeout fires first", async () => { + const never = new Promise<string>(() => {}); // never settles + const p = withTimeout(never, "initialize", 20); + await expect(p).rejects.toBeInstanceOf(McpTimeoutError); + await expect(p).rejects.toMatchObject({ method: "initialize", timeoutMs: 20 }); + }); + + it("cleans up the timer after the promise settles (no leak)", async () => { + // If the timer were not cleared, vitest would report an unhandled timer; + // resolving quickly should leave nothing pending. + const result = await withTimeout(Promise.resolve(1), "tools/list", 50_000); + expect(result).toBe(1); + }); + + it("rejects with Error('Aborted') when the signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + withTimeout(new Promise(() => {}), "initialize", 50_000, controller.signal), + ).rejects.toThrow("Aborted"); + }); + + it("rejects with Error('Aborted') when the signal aborts mid-flight", async () => { + const controller = new AbortController(); + const never = new Promise<string>(() => {}); + const p = withTimeout(never, "initialize", 50_000, controller.signal); + controller.abort(); + await expect(p).rejects.toThrow("Aborted"); + }); + + it("abort beats timeout (immediate cancellation)", async () => { + const controller = new AbortController(); + const never = new Promise<string>(() => {}); + const p = withTimeout(never, "initialize", 50_000, controller.signal); + controller.abort(); + await expect(p).rejects.toThrow("Aborted"); + }); + + it("passes through when timeout is disabled (0) and no signal", async () => { + const result = await withTimeout(Promise.resolve("passthrough"), "initialize", 0); + expect(result).toBe("passthrough"); + }); + + it("passes through when timeout is Infinity and no signal", async () => { + const result = await withTimeout(Promise.resolve(42), "tools/list", Number.POSITIVE_INFINITY); + expect(result).toBe(42); + }); + + it("still honors the signal when timeout is disabled", async () => { + const controller = new AbortController(); + const p = withTimeout(new Promise<string>(() => {}), "initialize", 0, controller.signal); + controller.abort(); + await expect(p).rejects.toThrow("Aborted"); + }); + + it("removes the abort listener after the promise settles", async () => { + const controller = new AbortController(); + const addSpy = controller.signal.addEventListener.bind(controller.signal); + let added = 0; + const spiedAdd = (...args: unknown[]) => { + added++; + return (addSpy as (...a: unknown[]) => void)(...args); + }; + controller.signal.addEventListener = spiedAdd as typeof controller.signal.addEventListener; + + await withTimeout(Promise.resolve("ok"), "initialize", 50_000, controller.signal); + // The resolve path cleans up: the listener was added then removed. The + // important assertion is that resolution works and no abort fires after. + expect(added).toBe(1); + // Aborting after settlement must NOT reject the already-settled promise. + controller.abort(); + }); +}); + +describe("McpTimeoutError", () => { + it("carries method + timeoutMs and a descriptive message", () => { + const err = new McpTimeoutError("initialize", 5000); + expect(err.method).toBe("initialize"); + expect(err.timeoutMs).toBe(5000); + expect(err.message).toBe("MCP initialize timed out after 5000ms"); + expect(err.name).toBe("McpTimeoutError"); + }); +}); diff --git a/packages/mcp/src/timeout.ts b/packages/mcp/src/timeout.ts new file mode 100644 index 0000000..294bfc5 --- /dev/null +++ b/packages/mcp/src/timeout.ts @@ -0,0 +1,114 @@ +/** + * Timeout + abort helper for MCP operations. + * + * A single misbehaving or framing-incompatible MCP server must never be able to + * hang an agent turn indefinitely: the JSON-RPC `initialize` / `tools/list` + * requests are awaited in-band during the per-turn tools filter, so a server + * that never responds would block the whole turn forever. `withTimeout` bounds + * any such awaited operation by BOTH a timeout (always) and an optional + * `AbortSignal` (so the turn's stop can interrupt an in-flight connect). + * + * Edge effect: uses `setTimeout` (the clock is the only I/O). The timer + the + * signal listener are always cleaned up on settlement, so a resolved operation + * never leaks a pending timer. The underlying promise ALWAYS has a handler + * attached (even when abort/timeout wins first), so it can never surface as an + * unhandled rejection. Mocking the OUTERMOST edge (real clock) is fine; tests + * drive this via `AbortController` (deterministic) rather than the timer. + */ + +/** Default per-operation timeout for MCP handshake/tool-list requests (ms). */ +export const MCP_DEFAULT_TIMEOUT_MS = 30_000; + +/** + * Backstop timeout bounding the ENTIRE per-turn MCP connect phase (spawn + + * initialize + listTools across all configured servers), applied by the tools + * filter. A misbehaving or framing-incompatible server cannot hang a turn + * longer than this; on expiry the filter degrades gracefully (skips MCP tools + * for that turn) instead of blocking the turn. Generous enough to absorb a + * legitimate slow server startup (e.g. a browser-launching MCP server). + */ +export const MCP_CONNECT_TIMEOUT_MS = 30_000; + +/** + * Raised when an MCP operation does not settle within its timeout. Distinct + * from a plain `Error` so callers (the manager's broken-state tracking, tests) + * can tell a timeout/incompatibility apart from a server-reported RPC error. + */ +export class McpTimeoutError extends Error { + readonly method: string; + readonly timeoutMs: number; + constructor(method: string, timeoutMs: number) { + super(`MCP ${method} timed out after ${timeoutMs}ms`); + this.name = "McpTimeoutError"; + this.method = method; + this.timeoutMs = timeoutMs; + } +} + +/** + * Race `promise` against a timeout (always) and an optional `AbortSignal`. + * Resolves/rejects with `promise`'s outcome if it settles first; rejects with + * `McpTimeoutError` on timeout, or `Error("Aborted")` if `signal` aborts first. + * + * @param method JSON-RPC method name (for the timeout message). + * @param timeoutMs Milliseconds before a timeout is raised. Pass `0` or + * `Infinity` to disable the timeout (only the `signal` then bounds the call). + * @param signal Optional abort signal — typically the turn's signal, so + * `POST /conversations/:id/stop` can interrupt a stuck connect immediately. + */ +export function withTimeout<T>( + promise: Promise<T>, + method: string, + timeoutMs: number, + signal?: AbortSignal, +): Promise<T> { + // No timeout and no signal → pass straight through (nothing to race). + const hasTimeout = timeoutMs > 0 && Number.isFinite(timeoutMs); + if (!hasTimeout && signal === undefined) { + return promise; + } + + return new Promise<T>((resolve, reject) => { + let settled = false; + + const finish = (action: () => void): void => { + if (settled) return; + settled = true; + cleanup(); + action(); + }; + + let timer: ReturnType<typeof setTimeout> | undefined; + const onAbort = (): void => { + finish(() => reject(new Error("Aborted"))); + }; + + const cleanup = (): void => { + if (timer !== undefined) clearTimeout(timer); + if (signal !== undefined) signal.removeEventListener("abort", onAbort); + }; + + if (hasTimeout) { + timer = setTimeout( + () => finish(() => reject(new McpTimeoutError(method, timeoutMs))), + timeoutMs, + ); + } + if (signal !== undefined) { + if (signal.aborted) { + // Already aborted: abort wins immediately. The `.then` below still + // attaches a handler so the underlying promise never rejects unhandled. + finish(() => reject(new Error("Aborted"))); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + + // Always attach handlers so the underlying promise is never unhandled — + // even when abort/timeout already won (settled), this is a no-op. + promise.then( + (value) => finish(() => resolve(value)), + (err: unknown) => finish(() => reject(err)), + ); + }); +} diff --git a/packages/mcp/src/transport.test.ts b/packages/mcp/src/transport.test.ts index e828340..8c69ad2 100644 --- a/packages/mcp/src/transport.test.ts +++ b/packages/mcp/src/transport.test.ts @@ -62,7 +62,7 @@ describe("createStdioTransport", () => { connection.close(); }); - it("connection sends framed messages via stdin", () => { + it("connection sends newline-delimited messages via stdin (current MCP spec)", () => { const pair = makePipe(); const spawn: SpawnProcess = () => pair.process; @@ -73,8 +73,33 @@ describe("createStdioTransport", () => { const writes = pair.writtenToStdin(); expect(writes.length).toBe(1); const text = new TextDecoder().decode(writes[0]); - expect(text).toContain("Content-Length:"); + // Outgoing framing is newline-delimited JSON (not Content-Length). + expect(text).not.toContain("Content-Length:"); expect(text).toContain('"method":"test/method"'); + expect(text.endsWith("\n")).toBe(true); + connection.close(); + }); + + it("decodes a newline-delimited server response (auto-detect)", async () => { + const pair = makePipe(); + const spawn: SpawnProcess = () => pair.process; + + const { connection } = createStdioTransport({ spawn, command: ["test"] }, "/tmp"); + + const resultPromise = connection.send("tools/list"); + + // Server responds with newline-delimited JSON (e.g. chrome-devtools-mcp). + const response = `${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { tools: [{ name: "t", description: "d", inputSchema: { type: "object" } }] }, + })}\n`; + pair.emitStdout(new TextEncoder().encode(response)); + + const result = await resultPromise; + expect(result).toEqual({ + tools: [{ name: "t", description: "d", inputSchema: { type: "object" } }], + }); connection.close(); }); diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index badb8dd..a2e141a 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -819,6 +819,11 @@ export function createSessionOrchestrator( conversationId, ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), + // Thread the turn's abort signal into the filter chain so a filter + // awaiting slow I/O (the MCP tools filter connecting to MCP servers) + // can be interrupted by POST /conversations/:id/stop instead of + // blocking the turn until its own timeout fires. + signal: controller.signal, }); const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy(); const turnLogger = deps.logger?.child({ conversationId, turnId }); diff --git a/packages/session-orchestrator/src/tools-filter.ts b/packages/session-orchestrator/src/tools-filter.ts index 28e82bf..22bc5cd 100644 --- a/packages/session-orchestrator/src/tools-filter.ts +++ b/packages/session-orchestrator/src/tools-filter.ts @@ -15,6 +15,15 @@ export interface ToolAssembly { readonly computerId?: string; /** The conversation this turn belongs to. */ readonly conversationId: string; + /** + * The turn's abort signal, threaded through the filter chain so a filter that + * awaits slow I/O (e.g. the MCP tools filter connecting to MCP servers) can be + * interrupted by `POST /conversations/:id/stop` instead of blocking the turn + * until its own timeout fires. Optional: omitted by paths that have no turn + * controller (e.g. the cache-warm probe), in which case filters fall back to + * their own timeouts. Filters that return a fresh assembly MUST preserve it. + */ + readonly signal?: AbortSignal; } /** Filter chain run once per turn to transform the tool set before it reaches runTurn. */ @@ -55,5 +64,6 @@ export function filterRemoteIncompatibleTools(assembly: ToolAssembly): ToolAssem ...(assembly.cwd !== undefined ? { cwd: assembly.cwd } : {}), ...(assembly.computerId !== undefined ? { computerId: assembly.computerId } : {}), conversationId: assembly.conversationId, + ...(assembly.signal !== undefined ? { signal: assembly.signal } : {}), }; } |
