Skip to content

Commit 1ba2599

Browse files
fix(subagent): pass inherited MCP tools through to workers (#1296)
* fix(subagent): pass inherited MCP tools through to workers requires_tools entries for live inherited-MCP tools (mcp__<server>__<tool>) now validate against the parent's mounted set at dispatch instead of rejecting as unknown_tool, and the worker mount exempts inherited MCP tools from the built-ins-only allowlist strip. Unmounted mcp__ names still reject fail-closed, and explicit excludes still withhold live MCP tools. * fix(subagent): retain inherited MCP tools without a requires_tools stamp On-demand narrowing stripped inherited MCP tools unless stamped in requiresTools or named in an allowlist, breaking the inherit-by-default contract: workers inheriting via inheritMcpTools with a parent grant could no longer execute the inherited tool. Inheritance itself now counts as the on-demand request - run.ts passes the live inherited MCP set into applyCapabilityFilter, which retains those names when no narrower constraint (capabilities/requiresTools) applies. Stamped dispatches still narrow to the stamped subset, unmounted mcp__ names still drop fail-closed, and explicit excludes still withhold. The e2e probes also share worker id "worker" across fresh tmpdirs while denied-call grant envelopes live in a process-shared store, so one probe's pending envelope vetoed the next via the retry-from-another- directory blocker. withWorker now clears the process grant store per probe using the provided test-only seam. Also ignore scratch __repro/ dirs (port from main).
1 parent 3a1d5f8 commit 1ba2599

9 files changed

Lines changed: 784 additions & 13 deletions

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,7 @@ subagents/
3030

3131
# Working notes and design spikes — local only, never committed
3232
docs/plans/
33+
34+
# Scratch repro tests — never committed
35+
__repro/
36+
**/__repro/

‎e2e/subagent-permission.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type { MCPClient } from "../src/mcp/client.js";
2121
import { getSubAgentIdentity } from "../src/subagent/identity-context.js";
2222
import { createSubAgentSessionStore } from "../src/subagent/session-store.js";
2323
import { workerPermissionGate } from "../src/permission/reactor-authorize.js";
24+
import { getProcessWorkerGrantStore } from "../src/permission/worker-grant.js";
2425
import { gateAgentTools } from "../src/plugins/permission-plugin.js";
2526

2627
const report =
@@ -69,6 +70,12 @@ async function withWorker(
6970
}),
7071
};
7172
try {
73+
// Every probe reuses worker id "worker" in a fresh tmpdir, but denied-call
74+
// grant envelopes live in a process-shared store keyed by that session id.
75+
// A prior probe's pending envelope (same tool + empty args, other cwd)
76+
// would veto this probe's call via the retry-from-another-directory
77+
// blocker, so each probe starts from a clean store.
78+
getProcessWorkerGrantStore().clear();
7279
await withMockedModuleDuring(
7380
import.meta.resolve("../src/session/assemble-runtime.js"),
7481
(real: typeof import("../src/session/assemble-runtime.js")) => ({
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* CL-9704: Linear MCP discovery-to-invocation on the primary session.
3+
*
4+
* Regression lock for "discoverable but not callable": tool_search finds
5+
* `mcp__linear__*`, and promote-on-execute must then commit a callable
6+
* schema for exactly the called name and dispatch it — list_teams first,
7+
* then save_issue. Search alone never promotes (the wire stays
8+
* built-ins-only until a call), and promoting one name never implies its
9+
* siblings: the primary session mounts MCP tools on demand, mirroring the
10+
* worker requires_tools gate.
11+
*/
12+
13+
import { afterEach, describe, expect, test } from "bun:test";
14+
import { mkdtempSync, rmSync } from "node:fs";
15+
import { tmpdir } from "node:os";
16+
import { join } from "node:path";
17+
18+
import {
19+
installMcpConnectMock,
20+
linearHttpMcpServer,
21+
mcpTestPermissionGate,
22+
} from "../../testkit/mcp-connect-mock.js";
23+
24+
const linearTools = [
25+
{
26+
name: "list_teams",
27+
description: "List Linear teams",
28+
inputSchema: { type: "object", properties: {}, required: [] },
29+
},
30+
{
31+
name: "save_issue",
32+
description: "Save a Linear issue",
33+
inputSchema: {
34+
type: "object",
35+
properties: {
36+
title: { type: "string" },
37+
teamId: { type: "string" },
38+
},
39+
required: ["title"],
40+
},
41+
},
42+
];
43+
44+
const mock = await installMcpConnectMock(
45+
import.meta.resolve("../mcp/client.js"),
46+
{ initialTools: linearTools, toolCallResult: "linear-ok" },
47+
);
48+
49+
const { createAgentToolset } = await import("./tools.js");
50+
const { createToolIndex, createToolSearchTool } =
51+
await import("./tool-search.js");
52+
const { createAdvertisedToolset } =
53+
await import("../session/assemble-runtime.js");
54+
55+
const dirs: string[] = [];
56+
57+
function tempDir(prefix: string): string {
58+
const dir = mkdtempSync(join(tmpdir(), prefix));
59+
dirs.push(dir);
60+
return dir;
61+
}
62+
63+
afterEach(() => {
64+
mock.reset();
65+
for (const dir of dirs.splice(0)) {
66+
rmSync(dir, { recursive: true, force: true });
67+
}
68+
});
69+
70+
describe("CL-9704 linear MCP discovery-to-invocation", () => {
71+
test("tool_search finds list_teams, promote-on-execute makes list_teams then save_issue callable", async () => {
72+
const toolset = await createAgentToolset({
73+
cwd: tempDir("corbits-cl9704-"),
74+
permissionGate: mcpTestPermissionGate(),
75+
onOperatorGate: async () => ({ kind: "cancel" }),
76+
mcpServers: [linearHttpMcpServer],
77+
});
78+
try {
79+
await toolset.connectMCP({
80+
interactiveAuth: false,
81+
onStatus: () => undefined,
82+
onToolsChanged: () => undefined,
83+
});
84+
85+
const registered = toolset.dynamicRunner
86+
.currentDefinitions()
87+
.map((d) => d.name);
88+
expect(registered).toContain("mcp__linear__list_teams");
89+
expect(registered).toContain("mcp__linear__save_issue");
90+
91+
// Primary-session promote-on-execute wiring (mirrors
92+
// tui/runner/session.ts + tui/runner/exit.ts): the call gate keys off
93+
// the advertised set, and the promoter declares exactly the called
94+
// name, committing its schema onto the next infer's wire.
95+
const advertised = createAdvertisedToolset({
96+
sessionMode: "orchestrator",
97+
toolAvailability: { languageServerAvailable: false },
98+
getProvider: () => ({ providerName: "test", model: "test" }),
99+
});
100+
toolset.dynamicRunner.setCallGate(
101+
(name) => advertised.isAdvertised(name),
102+
{ isActivated: (name) => advertised.activated.has(name) },
103+
);
104+
let wire: string[] = [];
105+
toolset.setToolPromoter((names) => {
106+
advertised.activated.activate(names);
107+
if (advertised.flushPromotions()) {
108+
wire = advertised
109+
.computeAdvertised(toolset.dynamicRunner.currentDefinitions())
110+
.map((d) => d.name);
111+
}
112+
});
113+
const wireSchemas = (): Map<string, unknown> =>
114+
new Map(
115+
advertised
116+
.computeAdvertised(toolset.dynamicRunner.currentDefinitions())
117+
.map((d) => [d.name, d.inputSchema]),
118+
);
119+
120+
const index = createToolIndex(() =>
121+
toolset.dynamicRunner.currentDefinitions(),
122+
);
123+
const search = createToolSearchTool({
124+
search: (query, limit) => index.search(query, limit),
125+
lookup: (name) =>
126+
toolset.dynamicRunner
127+
.currentDefinitions()
128+
.find((d) => d.name === name),
129+
});
130+
if (search.kind !== "string") throw new Error("expected string tool");
131+
132+
// Discovery: the card names list_teams with its description.
133+
const card = await search.handler(
134+
{ query: "linear teams" },
135+
new AbortController().signal,
136+
);
137+
expect(card).toContain("mcp__linear__list_teams");
138+
expect(card).toContain("List Linear teams");
139+
140+
// Search alone promotes nothing: no Linear schema on the wire.
141+
expect(wire).not.toContain("mcp__linear__list_teams");
142+
expect(wire).not.toContain("mcp__linear__save_issue");
143+
144+
const run = (name: string, args: Record<string, unknown> = {}) =>
145+
toolset.dynamicRunner.run(
146+
{ id: `call-${name}`, name, arguments: args },
147+
new AbortController().signal,
148+
);
149+
150+
// Invocation 1: list_teams dispatches and promotes only itself.
151+
const listed = await run("mcp__linear__list_teams");
152+
expect(listed.isError).toBeUndefined();
153+
expect(listed.content).toContain("linear-ok");
154+
expect(wire).toContain("mcp__linear__list_teams");
155+
expect(wire).not.toContain("mcp__linear__save_issue");
156+
expect(wireSchemas().get("mcp__linear__list_teams")).toEqual(
157+
linearTools[0]?.inputSchema,
158+
);
159+
160+
// Invocation 2: save_issue dispatches with its args after discovery.
161+
const saved = await run("mcp__linear__save_issue", {
162+
title: "hello",
163+
teamId: "t1",
164+
});
165+
expect(saved.isError).toBeUndefined();
166+
expect(saved.content).toContain("linear-ok");
167+
expect(wire).toEqual(
168+
expect.arrayContaining([
169+
"mcp__linear__list_teams",
170+
"mcp__linear__save_issue",
171+
]),
172+
);
173+
expect(mock.calls.map((c) => c.toolName)).toEqual([
174+
"list_teams",
175+
"save_issue",
176+
]);
177+
expect(mock.calls[1]?.args).toEqual({ title: "hello", teamId: "t1" });
178+
} finally {
179+
await toolset.dispose();
180+
}
181+
});
182+
});

‎src/subagent/agent-fleet-requires-tools.test.ts‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, test } from "bun:test";
22

3+
import { stringTool, type AgentTool } from "@intx/agent";
34
import {
45
createFleetMailbox,
56
createSpawnAgentTool,
@@ -38,6 +39,13 @@ const FULL_MOUNT_PROFILE: AgentProfile = {
3839
systemPromptRole: "You do anything.",
3940
};
4041

42+
// BUILD_TOOLS-like envelope: built-ins only, no MCP names.
43+
const BUILD_LIKE_PROFILE: AgentProfile = {
44+
id: "build-worker",
45+
systemPromptRole: "You build.",
46+
capabilities: { mode: "allow", tools: ["read_file", "run_shell"] },
47+
};
48+
4149
function makeDeps(
4250
run: (params: RunSubAgentParams) => Promise<RunSubAgentResult>,
4351
capturedTelemetry: TelemetryEvent[],
@@ -300,6 +308,156 @@ describe("spawn_agent requires_tools preflight", () => {
300308
expect(seenTier).toBe("leaf");
301309
});
302310

311+
test("mounted mcp__linear__ tool passes dispatch preflight under a built-ins-only allowlist", async () => {
312+
const telemetry: TelemetryEvent[] = [];
313+
let runCalled = false;
314+
let seenRequires: readonly string[] | undefined;
315+
const linearTool: AgentTool = stringTool({
316+
definition: {
317+
name: "mcp__linear__list_teams",
318+
description: "Inherited Linear tool",
319+
inputSchema: {},
320+
},
321+
handler: async () => "teams",
322+
});
323+
const base = makeDeps(async (params) => {
324+
runCalled = true;
325+
seenRequires = params.requiresTools;
326+
return { report: "done" };
327+
}, telemetry);
328+
const deps: AgentFleetDeps = {
329+
...base,
330+
profiles: [BUILD_LIKE_PROFILE],
331+
inheritMcpTools: () => [linearTool],
332+
};
333+
const spawn = createSpawnAgentTool(deps);
334+
335+
const result = await callSpawn(spawn, {
336+
description: "linear job",
337+
prompt: "list teams",
338+
agent: "build-worker",
339+
requires_tools: ["mcp__linear__list_teams"],
340+
});
341+
342+
expect(result.isError).not.toBe(true);
343+
expect(runCalled).toBe(true);
344+
const body = JSON.parse(result.content) as { agent_id: string };
345+
expect(deps.sessions.get(body.agent_id)?.requiresTools).toEqual([
346+
"mcp__linear__list_teams",
347+
]);
348+
expect(seenRequires).toEqual(["mcp__linear__list_teams"]);
349+
});
350+
351+
test("unmounted mcp__linear__ tool rejects dispatch preflight as unknown_tool with no run", async () => {
352+
const telemetry: TelemetryEvent[] = [];
353+
let runCalled = false;
354+
const base = makeDeps(async () => {
355+
runCalled = true;
356+
return { report: "done" };
357+
}, telemetry);
358+
const deps: AgentFleetDeps = {
359+
...base,
360+
profiles: [BUILD_LIKE_PROFILE],
361+
};
362+
const spawn = createSpawnAgentTool(deps);
363+
364+
const result = await callSpawn(spawn, {
365+
description: "linear job",
366+
prompt: "list teams",
367+
agent: "build-worker",
368+
requires_tools: ["mcp__linear__list_teams"],
369+
});
370+
371+
expect(result.isError).toBe(true);
372+
expect(result.content).toContain('unknown tool "mcp__linear__list_teams"');
373+
expect(result.content).not.toContain("Did you mean");
374+
expect(runCalled).toBe(false);
375+
expect(deps.sessions.list()).toEqual([]);
376+
expect(telemetry).toEqual([]);
377+
});
378+
379+
test("requires_tools stamps only the requested live tool, never the inherited set", async () => {
380+
const telemetry: TelemetryEvent[] = [];
381+
let runCalled = false;
382+
let seenRequires: readonly string[] | undefined;
383+
const mcpTool = (name: string): AgentTool =>
384+
stringTool({
385+
definition: {
386+
name,
387+
description: `Inherited ${name}`,
388+
inputSchema: {},
389+
},
390+
handler: async () => name,
391+
});
392+
const base = makeDeps(async (params) => {
393+
runCalled = true;
394+
seenRequires = params.requiresTools;
395+
return { report: "done" };
396+
}, telemetry);
397+
const deps: AgentFleetDeps = {
398+
...base,
399+
profiles: [BUILD_LIKE_PROFILE],
400+
inheritMcpTools: () => [
401+
mcpTool("mcp__linear__list_teams"),
402+
mcpTool("mcp__linear__create_issue"),
403+
],
404+
};
405+
const spawn = createSpawnAgentTool(deps);
406+
407+
const result = await callSpawn(spawn, {
408+
description: "linear job",
409+
prompt: "list teams",
410+
agent: "build-worker",
411+
requires_tools: ["mcp__linear__list_teams"],
412+
});
413+
414+
// On-demand: dispatch stamps exactly the requested live tool — the
415+
// inherited sibling mounts only under its own stamp (run.ts drops it).
416+
expect(result.isError).not.toBe(true);
417+
expect(runCalled).toBe(true);
418+
expect(seenRequires).toEqual(["mcp__linear__list_teams"]);
419+
});
420+
421+
test("requires_tools naming a live and an unmounted mcp__ tool rejects the unmounted one with no run", async () => {
422+
const telemetry: TelemetryEvent[] = [];
423+
let runCalled = false;
424+
const base = makeDeps(async () => {
425+
runCalled = true;
426+
return { report: "done" };
427+
}, telemetry);
428+
const deps: AgentFleetDeps = {
429+
...base,
430+
profiles: [BUILD_LIKE_PROFILE],
431+
inheritMcpTools: () => [
432+
stringTool({
433+
definition: {
434+
name: "mcp__linear__list_teams",
435+
description: "Inherited Linear tool",
436+
inputSchema: {},
437+
},
438+
handler: async () => "teams",
439+
}),
440+
],
441+
};
442+
const spawn = createSpawnAgentTool(deps);
443+
444+
const result = await callSpawn(spawn, {
445+
description: "linear job",
446+
prompt: "list teams and file",
447+
agent: "build-worker",
448+
requires_tools: ["mcp__linear__list_teams", "mcp__linear__create_issue"],
449+
});
450+
451+
expect(result.isError).toBe(true);
452+
expect(result.content).toContain(
453+
'unknown tool "mcp__linear__create_issue"',
454+
);
455+
expect(result.content).not.toContain("Did you mean");
456+
expect(runCalled).toBe(false);
457+
expect(deps.sessions.list()).toEqual([]);
458+
expect(telemetry).toEqual([]);
459+
});
460+
303461
test("whitespace-only requires_tools rejects fail-closed with no session, telemetry, or run", async () => {
304462
for (const requiresTools of [[" "], ["read_file", " "]]) {
305463
const telemetry: TelemetryEvent[] = [];

0 commit comments

Comments
 (0)