summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-07-01 03:30:42 +0900
committerAdam Malczewski <[email protected]>2026-07-01 03:30:42 +0900
commit665672eccff531f7a785295a8f16e57e98106deb (patch)
tree4a9fd97fd332bbc153987b617e184570c6558706
parent6bca0a8b65506239b0ce72d7f86ba96f825152b1 (diff)
parent918b8cddf9013c20943310e0c02a724aecb3fe94 (diff)
downloaddispatch-665672eccff531f7a785295a8f16e57e98106deb.tar.gz
dispatch-665672eccff531f7a785295a8f16e57e98106deb.zip
Merge branch 'feature/mcp-transport-fixes' into predev
-rw-r--r--packages/mcp/src/client.test.ts66
-rw-r--r--packages/mcp/src/client.ts48
-rw-r--r--packages/mcp/src/extension.test.ts107
-rw-r--r--packages/mcp/src/extension.ts49
-rw-r--r--packages/mcp/src/framing.test.ts157
-rw-r--r--packages/mcp/src/framing.ts176
-rw-r--r--packages/mcp/src/index.ts6
-rw-r--r--packages/mcp/src/manager.test.ts25
-rw-r--r--packages/mcp/src/manager.ts37
-rw-r--r--packages/mcp/src/rpc.test.ts5
-rw-r--r--packages/mcp/src/timeout.test.ts105
-rw-r--r--packages/mcp/src/timeout.ts114
-rw-r--r--packages/mcp/src/transport.test.ts29
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts5
-rw-r--r--packages/session-orchestrator/src/tools-filter.ts10
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 } : {}),
};
}