summaryrefslogtreecommitdiffhomepage
path: root/packages/mcp/src/extension.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/mcp/src/extension.test.ts')
-rw-r--r--packages/mcp/src/extension.test.ts697
1 files changed, 400 insertions, 297 deletions
diff --git a/packages/mcp/src/extension.test.ts b/packages/mcp/src/extension.test.ts
index 75515fb..9e029d2 100644
--- a/packages/mcp/src/extension.test.ts
+++ b/packages/mcp/src/extension.test.ts
@@ -11,67 +11,67 @@ import type { McpToolInfo } from "./types.js";
// ---------------------------------------------------------------------------
const stubTool = (name: string): ToolContract => ({
- name,
- description: "",
- parameters: { type: "object" },
- execute: async () => ({ content: "" }),
+ name,
+ description: "",
+ parameters: { type: "object" },
+ execute: async () => ({ content: "" }),
});
describe("filterMcpTools (pure)", () => {
- it("keeps non-MCP tools and connected-server tools, removes disconnected-server tools", () => {
- const toolToServer = new Map<string, string>([
- ["a__x", "a"],
- ["b__y", "b"],
- ]);
- const connected = new Set<string>(["a"]);
-
- const result = filterMcpTools(
- {
- tools: [stubTool("a__x"), stubTool("b__y"), stubTool("other")],
- cwd: "/p",
- conversationId: "c",
- },
- toolToServer,
- connected,
- );
-
- expect(result.tools.map((t) => t.name).sort()).toEqual(["a__x", "other"]);
- expect(result.cwd).toBe("/p");
- expect(result.conversationId).toBe("c");
- });
-
- it("removes all MCP tools when no server is connected", () => {
- const result = filterMcpTools(
- { tools: [stubTool("a__x")], conversationId: "c" },
- new Map<string, string>([["a__x", "a"]]),
- new Set<string>(),
- );
- expect(result.tools).toHaveLength(0);
- expect(result.conversationId).toBe("c");
- expect(result.cwd).toBeUndefined();
- expect(result.computerId).toBeUndefined();
- });
-
- it("preserves computerId when set (mirrors cwd/conversationId preservation)", () => {
- const toolToServer = new Map<string, string>([["a__x", "a"]]);
- const connected = new Set<string>(["a"]);
-
- const result = filterMcpTools(
- {
- tools: [stubTool("a__x"), stubTool("other")],
- cwd: "/p",
- computerId: "ssh-host",
- conversationId: "c",
- },
- toolToServer,
- connected,
- );
-
- expect(result.tools.map((t) => t.name).sort()).toEqual(["a__x", "other"]);
- expect(result.computerId).toBe("ssh-host");
- expect(result.cwd).toBe("/p");
- expect(result.conversationId).toBe("c");
- });
+ it("keeps non-MCP tools and connected-server tools, removes disconnected-server tools", () => {
+ const toolToServer = new Map<string, string>([
+ ["a__x", "a"],
+ ["b__y", "b"],
+ ]);
+ const connected = new Set<string>(["a"]);
+
+ const result = filterMcpTools(
+ {
+ tools: [stubTool("a__x"), stubTool("b__y"), stubTool("other")],
+ cwd: "/p",
+ conversationId: "c",
+ },
+ toolToServer,
+ connected,
+ );
+
+ expect(result.tools.map((t) => t.name).sort()).toEqual(["a__x", "other"]);
+ expect(result.cwd).toBe("/p");
+ expect(result.conversationId).toBe("c");
+ });
+
+ it("removes all MCP tools when no server is connected", () => {
+ const result = filterMcpTools(
+ { tools: [stubTool("a__x")], conversationId: "c" },
+ new Map<string, string>([["a__x", "a"]]),
+ new Set<string>(),
+ );
+ expect(result.tools).toHaveLength(0);
+ expect(result.conversationId).toBe("c");
+ expect(result.cwd).toBeUndefined();
+ expect(result.computerId).toBeUndefined();
+ });
+
+ it("preserves computerId when set (mirrors cwd/conversationId preservation)", () => {
+ const toolToServer = new Map<string, string>([["a__x", "a"]]);
+ const connected = new Set<string>(["a"]);
+
+ const result = filterMcpTools(
+ {
+ tools: [stubTool("a__x"), stubTool("other")],
+ cwd: "/p",
+ computerId: "ssh-host",
+ conversationId: "c",
+ },
+ toolToServer,
+ connected,
+ );
+
+ expect(result.tools.map((t) => t.name).sort()).toEqual(["a__x", "other"]);
+ expect(result.computerId).toBe("ssh-host");
+ expect(result.cwd).toBe("/p");
+ expect(result.conversationId).toBe("c");
+ });
});
// ---------------------------------------------------------------------------
@@ -79,9 +79,12 @@ describe("filterMcpTools (pure)", () => {
// ---------------------------------------------------------------------------
interface FakeServer {
- tools: McpToolInfo[];
- failInitialize: boolean;
- emitListChanged: () => void;
+ 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;
}
/**
@@ -90,87 +93,91 @@ interface FakeServer {
* transport → framing → rpc → client → manager end to end.
*/
function makeFakeSpawn(server: FakeServer): SpawnProcess {
- const decoder = new FrameDecoder();
- let dataListeners: Array<(data: Uint8Array) => void> = [];
-
- const emit = (frame: Uint8Array) => {
- for (const cb of dataListeners) cb(frame);
- };
-
- const spawn: SpawnProcess = (_command, _opts) => {
- // Each spawn is a fresh process; reset listeners so a reconnect (after
- // shutdown) doesn't feed closed rpc instances.
- dataListeners = [];
- const process: SpawnedProcess = {
- stdin: {
- write: (bytes: Uint8Array) => {
- for (const msg of decoder.decode(bytes)) {
- const parsed = JSON.parse(msg) as {
- id?: number;
- method?: string;
- params?: unknown;
- };
- const id = parsed.id ?? 0;
- const method = parsed.method;
- if (method === "initialize") {
- if (server.failInitialize) {
- emit(
- encode(
- JSON.stringify({
- jsonrpc: "2.0",
- id,
- error: { code: -32603, message: "initialize failed" },
- }),
- ),
- );
- } else {
- emit(
- encode(
- JSON.stringify({
- jsonrpc: "2.0",
- id,
- result: {
- protocolVersion: "2025-11-25",
- capabilities: { tools: { listChanged: true } },
- serverInfo: { name: "fake", version: "0.0.0" },
- },
- }),
- ),
- );
- }
- } else if (method === "tools/list") {
- emit(encode(JSON.stringify({ jsonrpc: "2.0", id, result: { tools: server.tools } })));
- } else if (method === "tools/call") {
- emit(
- encode(
- JSON.stringify({
- jsonrpc: "2.0",
- id,
- result: { content: [{ type: "text", text: "ok" }], isError: false },
- }),
- ),
- );
- }
- // notifications (notifications/initialized): no response.
- }
- },
- },
- stdout: {
- on: (event: string, cb: (data: Uint8Array) => void) => {
- if (event === "data") dataListeners.push(cb);
- },
- },
- pid: 7000,
- kill: () => {},
- };
- return process;
- };
-
- server.emitListChanged = () => {
- emit(encode(JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })));
- };
-
- return spawn;
+ const decoder = new FrameDecoder();
+ let dataListeners: Array<(data: Uint8Array) => void> = [];
+
+ const emit = (frame: Uint8Array) => {
+ for (const cb of dataListeners) cb(frame);
+ };
+
+ const spawn: SpawnProcess = (_command, _opts) => {
+ // Each spawn is a fresh process; reset listeners so a reconnect (after
+ // shutdown) doesn't feed closed rpc instances.
+ dataListeners = [];
+ const process: SpawnedProcess = {
+ stdin: {
+ write: (bytes: Uint8Array) => {
+ for (const msg of decoder.decode(bytes)) {
+ const parsed = JSON.parse(msg) as {
+ id?: number;
+ method?: string;
+ params?: unknown;
+ };
+ const id = parsed.id ?? 0;
+ const method = parsed.method;
+ if (method === "initialize") {
+ 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({
+ jsonrpc: "2.0",
+ id,
+ error: { code: -32603, message: "initialize failed" },
+ }),
+ ),
+ );
+ } else {
+ emit(
+ encode(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id,
+ result: {
+ protocolVersion: "2025-11-25",
+ capabilities: { tools: { listChanged: true } },
+ serverInfo: { name: "fake", version: "0.0.0" },
+ },
+ }),
+ ),
+ );
+ }
+ } else if (method === "tools/list") {
+ emit(encode(JSON.stringify({ jsonrpc: "2.0", id, result: { tools: server.tools } })));
+ } else if (method === "tools/call") {
+ emit(
+ encode(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id,
+ result: { content: [{ type: "text", text: "ok" }], isError: false },
+ }),
+ ),
+ );
+ }
+ // notifications (notifications/initialized): no response.
+ }
+ },
+ },
+ stdout: {
+ on: (event: string, cb: (data: Uint8Array) => void) => {
+ if (event === "data") dataListeners.push(cb);
+ },
+ },
+ pid: 7000,
+ kill: () => {},
+ };
+ return process;
+ };
+
+ server.emitListChanged = () => {
+ emit(encode(JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })));
+ };
+
+ return spawn;
}
// ---------------------------------------------------------------------------
@@ -178,51 +185,51 @@ function makeFakeSpawn(server: FakeServer): SpawnProcess {
// ---------------------------------------------------------------------------
function makeFakeHost(): {
- host: HostAPI;
- tools: Map<string, ToolContract>;
- getFilter: () => ((a: ToolAssembly) => Promise<ToolAssembly>) | null;
- getService: () => unknown;
+ host: HostAPI;
+ tools: Map<string, ToolContract>;
+ getFilter: () => ((a: ToolAssembly) => Promise<ToolAssembly>) | null;
+ getService: () => unknown;
} {
- const tools = new Map<string, ToolContract>();
- let filterFn: ((a: ToolAssembly) => Promise<ToolAssembly>) | null = null;
- let service: unknown = null;
-
- const noopSpan = {
- id: "s",
- log: {} as Logger,
- setAttributes: () => {},
- addLink: () => {},
- child: () => noopSpan,
- end: () => {},
- };
- const noopLogger: Logger = {
- info: () => {},
- warn: () => {},
- error: () => {},
- debug: () => {},
- child: () => noopLogger,
- span: () => noopSpan,
- };
-
- const host = {
- defineTool: (t: ToolContract) => {
- tools.set(t.name, t);
- },
- addFilter: (_hook: typeof toolsFilter, fn: (a: ToolAssembly) => Promise<ToolAssembly>) => {
- filterFn = fn;
- return () => {
- filterFn = null;
- };
- },
- provideService: (_handle: unknown, impl: unknown) => {
- service = impl;
- },
- getService: () => service,
- getTools: () => tools,
- logger: noopLogger,
- } as unknown as HostAPI;
-
- return { host, tools, getFilter: () => filterFn, getService: () => service };
+ const tools = new Map<string, ToolContract>();
+ let filterFn: ((a: ToolAssembly) => Promise<ToolAssembly>) | null = null;
+ let service: unknown = null;
+
+ const noopSpan = {
+ id: "s",
+ log: {} as Logger,
+ setAttributes: () => {},
+ addLink: () => {},
+ child: () => noopSpan,
+ end: () => {},
+ };
+ const noopLogger: Logger = {
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ debug: () => {},
+ child: () => noopLogger,
+ span: () => noopSpan,
+ };
+
+ const host = {
+ defineTool: (t: ToolContract) => {
+ tools.set(t.name, t);
+ },
+ addFilter: (_hook: typeof toolsFilter, fn: (a: ToolAssembly) => Promise<ToolAssembly>) => {
+ filterFn = fn;
+ return () => {
+ filterFn = null;
+ };
+ },
+ provideService: (_handle: unknown, impl: unknown) => {
+ service = impl;
+ },
+ getService: () => service,
+ getTools: () => tools,
+ logger: noopLogger,
+ } as unknown as HostAPI;
+
+ return { host, tools, getFilter: () => filterFn, getService: () => service };
}
// ---------------------------------------------------------------------------
@@ -232,132 +239,228 @@ function makeFakeHost(): {
const dispatchConfig = (servers: Record<string, unknown>): string => JSON.stringify({ servers });
const tool = (name: string, description = name): McpToolInfo => ({
- name,
- description,
- inputSchema: { type: "object" },
+ name,
+ description,
+ inputSchema: { type: "object" },
});
const assembly = (tools: ToolContract[], cwd = "/proj"): ToolAssembly => ({
- tools,
- cwd,
- conversationId: "conv-1",
+ tools,
+ cwd,
+ conversationId: "conv-1",
});
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 {
- return makeMcpExtension({
- spawn: makeFakeSpawn(server),
- readFile: async (path) => (path.endsWith(".dispatch/mcp.json") ? configJson : null),
- getCwd: () => "/proj",
- });
+ return makeMcpExtension({
+ spawn: makeFakeSpawn(server),
+ readFile: async (path) => (path.endsWith(".dispatch/mcp.json") ? configJson : null),
+ getCwd: () => "/proj",
+ });
}
// ---------------------------------------------------------------------------
// Lifecycle tests
// ---------------------------------------------------------------------------
describe("mcp extension lifecycle", () => {
- /** Get the registered filter, throwing if activation did not register one. */
- function requireFilter(getFilter: () => ((a: ToolAssembly) => Promise<ToolAssembly>) | null) {
- const filter = getFilter();
- if (!filter) throw new Error("toolsFilter was not registered");
- return filter;
- }
-
- /** Look up a registered tool, throwing if absent. */
- function requireTool(tools: Map<string, ToolContract>, name: string) {
- const t = tools.get(name);
- if (!t) throw new Error(`tool ${name} not registered`);
- return t;
- }
-
- it("registers tools on connect", async () => {
- const server = makeServer([tool("create_object", "Create an object")]);
- const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } }));
- const { host, tools, getFilter } = makeFakeHost();
-
- ext.activate(host);
- const filter = requireFilter(getFilter);
-
- // Running the filter triggers lazy connect + register.
- await filter(assembly([]));
-
- expect(tools.has("freecad__create_object")).toBe(true);
- const t = requireTool(tools, "freecad__create_object");
- expect(t.description).toBe("[freecad] Create an object");
- expect(t.concurrencySafe).toBe(false);
- ext.deactivate?.();
- });
-
- it("toolsFilter keeps connected-server tools and removes disconnected-server tools", async () => {
- const server = makeServer([tool("create_object")]);
- const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } }));
- const { host, tools, getFilter } = makeFakeHost();
- ext.activate(host);
- const filter = requireFilter(getFilter);
-
- // Connect + register the tool.
- await filter(assembly([]));
- const registered = requireTool(tools, "freecad__create_object");
-
- // Connected server → tool passes through the filter.
- const kept = await filter(assembly([registered]));
- expect(kept.tools.map((t) => t.name)).toContain("freecad__create_object");
-
- // Disconnect: deactivate shuts down the client (clearing it), then make
- // the server fail to reconnect. toolToServer still maps the tool, so the
- // filter drops it because the server is no longer connected.
- ext.deactivate?.();
- server.failInitialize = true;
-
- const removed = await filter(assembly([registered]));
- expect(removed.tools.map((t) => t.name)).not.toContain("freecad__create_object");
- });
-
- it("re-registers tools on list_changed", async () => {
- const server = makeServer([tool("first_tool")]);
- const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } }));
- const { host, tools, getFilter } = makeFakeHost();
- ext.activate(host);
- const filter = requireFilter(getFilter);
-
- await filter(assembly([]));
- expect(tools.has("freecad__first_tool")).toBe(true);
-
- // Server changes its tool set, then announces list_changed.
- server.tools = [tool("first_tool"), tool("second_tool", "The second")];
- server.emitListChanged();
-
- // Let the async onToolsChanged handler (re-list + re-register) flush.
- await flush();
-
- expect(tools.has("freecad__second_tool")).toBe(true);
- expect(requireTool(tools, "freecad__second_tool").description).toBe("[freecad] The second");
- ext.deactivate?.();
- });
-
- it("deactivate shuts down all clients", async () => {
- const server = makeServer([tool("create_object")]);
- const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } }));
- const { host, getFilter, getService } = makeFakeHost();
- ext.activate(host);
- const filter = requireFilter(getFilter);
-
- await filter(assembly([]));
-
- const service = getService() as {
- status: (cwd: string) => Promise<readonly { state: string }[]>;
- };
- const before = await service.status("/proj");
- expect(before[0].state).toBe("connected");
-
- ext.deactivate?.();
-
- const after = await service.status("/proj");
- expect(after[0].state).toBe("disconnected");
- });
+ /** Get the registered filter, throwing if activation did not register one. */
+ function requireFilter(getFilter: () => ((a: ToolAssembly) => Promise<ToolAssembly>) | null) {
+ const filter = getFilter();
+ if (!filter) throw new Error("toolsFilter was not registered");
+ return filter;
+ }
+
+ /** Look up a registered tool, throwing if absent. */
+ function requireTool(tools: Map<string, ToolContract>, name: string) {
+ const t = tools.get(name);
+ if (!t) throw new Error(`tool ${name} not registered`);
+ return t;
+ }
+
+ it("registers tools on connect", async () => {
+ const server = makeServer([tool("create_object", "Create an object")]);
+ const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } }));
+ const { host, tools, getFilter } = makeFakeHost();
+
+ ext.activate(host);
+ const filter = requireFilter(getFilter);
+
+ // Running the filter triggers lazy connect + register.
+ await filter(assembly([]));
+
+ expect(tools.has("freecad__create_object")).toBe(true);
+ const t = requireTool(tools, "freecad__create_object");
+ expect(t.description).toBe("[freecad] Create an object");
+ expect(t.concurrencySafe).toBe(false);
+ ext.deactivate?.();
+ });
+
+ it("toolsFilter keeps connected-server tools and removes disconnected-server tools", async () => {
+ const server = makeServer([tool("create_object")]);
+ const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } }));
+ const { host, tools, getFilter } = makeFakeHost();
+ ext.activate(host);
+ const filter = requireFilter(getFilter);
+
+ // Connect + register the tool.
+ await filter(assembly([]));
+ const registered = requireTool(tools, "freecad__create_object");
+
+ // Connected server → tool passes through the filter.
+ const kept = await filter(assembly([registered]));
+ expect(kept.tools.map((t) => t.name)).toContain("freecad__create_object");
+
+ // Disconnect: deactivate shuts down the client (clearing it), then make
+ // the server fail to reconnect. toolToServer still maps the tool, so the
+ // filter drops it because the server is no longer connected.
+ ext.deactivate?.();
+ server.failInitialize = true;
+
+ const removed = await filter(assembly([registered]));
+ expect(removed.tools.map((t) => t.name)).not.toContain("freecad__create_object");
+ });
+
+ it("re-registers tools on list_changed", async () => {
+ const server = makeServer([tool("first_tool")]);
+ const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } }));
+ const { host, tools, getFilter } = makeFakeHost();
+ ext.activate(host);
+ const filter = requireFilter(getFilter);
+
+ await filter(assembly([]));
+ expect(tools.has("freecad__first_tool")).toBe(true);
+
+ // Server changes its tool set, then announces list_changed.
+ server.tools = [tool("first_tool"), tool("second_tool", "The second")];
+ server.emitListChanged();
+
+ // Let the async onToolsChanged handler (re-list + re-register) flush.
+ await flush();
+
+ expect(tools.has("freecad__second_tool")).toBe(true);
+ expect(requireTool(tools, "freecad__second_tool").description).toBe("[freecad] The second");
+ ext.deactivate?.();
+ });
+
+ it("deactivate shuts down all clients", async () => {
+ const server = makeServer([tool("create_object")]);
+ const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } }));
+ const { host, getFilter, getService } = makeFakeHost();
+ ext.activate(host);
+ const filter = requireFilter(getFilter);
+
+ await filter(assembly([]));
+
+ const service = getService() as {
+ status: (cwd: string) => Promise<readonly { state: string }[]>;
+ };
+ const before = await service.status("/proj");
+ expect(before[0].state).toBe("connected");
+
+ ext.deactivate?.();
+
+ 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?.();
+ });
});