Skip to content

Commit 027ea02

Browse files
committed
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.
1 parent 3a1d5f8 commit 027ea02

6 files changed

Lines changed: 541 additions & 13 deletions

File tree

‎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[] = [];

‎src/subagent/agent-fleet.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ import {
127127
type InterventionSink,
128128
} from "./intervention-log.js";
129129
import { takeAndProjectMailboxRecord } from "./fleet-dry-drive.js";
130+
import { canonicalToolName } from "../agent/canonical-tool-name.js";
131+
import { isMcpToolName } from "../mcp/tool-name.js";
130132

131133
const log = getLogger([LOG_NAMESPACE_ROOT, "subagent", "agent-fleet"]);
132134

@@ -1115,12 +1117,23 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
11151117
}
11161118
const requiresToolsRaw = rawEntries.map((t) => t.trim());
11171119
if (requiresToolsRaw.length > 0) {
1120+
// Live inherited-MCP set: the worker mount carries a requested live
1121+
// tool on demand (run.ts retains only requested inherited MCP
1122+
// tools), so preflight validates `mcp__*` requirements against this
1123+
// set instead of the built-in catalog. Gating through the worker
1124+
// gate keeps the names to what this dispatch may actually mount.
1125+
const availableMcpTools = (
1126+
deps.inheritMcpTools?.(deps.permissionGate) ?? []
1127+
)
1128+
.map((tool) => canonicalToolName(tool.definition.name))
1129+
.filter((name) => isMcpToolName(name));
11181130
const preflight = preflightCapabilities({
11191131
required: requiresToolsRaw,
11201132
...(resolved.capabilities !== undefined
11211133
? { resolvedFilter: resolved.capabilities }
11221134
: {}),
11231135
knownEngines: DEFAULT_KNOWN_ENGINES,
1136+
availableMcpTools,
11241137
agentLabel: resolved.agentLabel,
11251138
});
11261139
if (!preflight.ok) {

‎src/subagent/capability-preflight.test.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,24 @@ function preflight(
2525
required: readonly string[],
2626
resolvedFilter?: CapabilityFilter,
2727
knownEngines: readonly string[] = DEFAULT_KNOWN_ENGINES,
28+
availableMcpTools: readonly string[] = [],
2829
) {
2930
return preflightCapabilities({
3031
required,
3132
...(resolvedFilter !== undefined ? { resolvedFilter } : {}),
3233
knownEngines,
34+
availableMcpTools,
3335
agentLabel: "test-worker",
3436
});
3537
}
3638

39+
// A BUILD_TOOLS-like allowlist: built-ins only, no MCP names — the shape
40+
// that used to strip inherited Linear tools at both layers.
41+
const ALLOW_BUILD_NO_MCP: CapabilityFilter = {
42+
mode: "allow",
43+
tools: ["read_file", "run_shell"],
44+
};
45+
3746
describe("preflightCapabilities", () => {
3847
test("allow filter mounting the tool passes and returns canonical names", () => {
3948
const result = preflight(["run_shell"], ALLOW_SHELL);
@@ -125,6 +134,72 @@ describe("preflightCapabilities", () => {
125134
expect(alternatives).toEqual([...alternatives].sort());
126135
expect(alternatives).not.toContain("dispatch");
127136
});
137+
138+
test("mounted mcp__linear__ tool passes preflight under a built-ins-only allowlist", () => {
139+
const result = preflight(
140+
["mcp__linear__list_teams"],
141+
ALLOW_BUILD_NO_MCP,
142+
DEFAULT_KNOWN_ENGINES,
143+
["mcp__linear__list_teams"],
144+
);
145+
expect(result).toEqual({
146+
ok: true,
147+
canonical: ["mcp__linear__list_teams"],
148+
});
149+
});
150+
151+
test("unmounted mcp__linear__ tool rejects unknown_tool when no server is mounted", () => {
152+
const result = preflight(["mcp__linear__list_teams"], ALLOW_BUILD_NO_MCP);
153+
expect(result.ok).toBe(false);
154+
if (result.ok) throw new Error("expected rejection");
155+
expect(result.unavailable.code).toBe("unknown_tool");
156+
expect(result.unavailable.tool).toBe("mcp__linear__list_teams");
157+
expect(result.unavailable.suggestion).toBeUndefined();
158+
});
159+
160+
test("mcp__ tool from an unmounted server rejects even when other servers are mounted", () => {
161+
const result = preflight(
162+
["mcp__nope__frobnicate"],
163+
ALLOW_BUILD_NO_MCP,
164+
DEFAULT_KNOWN_ENGINES,
165+
["mcp__linear__list_teams"],
166+
);
167+
expect(result.ok).toBe(false);
168+
if (result.ok) throw new Error("expected rejection");
169+
expect(result.unavailable.code).toBe("unknown_tool");
170+
expect(result.unavailable.suggestion).toBeUndefined();
171+
});
172+
173+
test("preflight grants only the named requirement, never sibling live tools", () => {
174+
const result = preflight(
175+
["mcp__linear__list_teams"],
176+
ALLOW_BUILD_NO_MCP,
177+
DEFAULT_KNOWN_ENGINES,
178+
["mcp__linear__list_teams", "mcp__linear__create_issue"],
179+
);
180+
// On-demand: the stamp covers exactly the requested tool — the live
181+
// sibling is not implied and mounts only under its own stamp.
182+
expect(result).toEqual({
183+
ok: true,
184+
canonical: ["mcp__linear__list_teams"],
185+
});
186+
});
187+
188+
test("explicit exclude naming a live MCP tool rejects permission_static", () => {
189+
const result = preflightCapabilities({
190+
required: ["mcp__linear__list_teams"],
191+
resolvedFilter: {
192+
mode: "exclude",
193+
tools: ["mcp__linear__list_teams"],
194+
},
195+
knownEngines: DEFAULT_KNOWN_ENGINES,
196+
availableMcpTools: ["mcp__linear__list_teams"],
197+
agentLabel: "test-worker",
198+
});
199+
expect(result.ok).toBe(false);
200+
if (result.ok) throw new Error("expected rejection");
201+
expect(result.unavailable.code).toBe("permission_static");
202+
});
128203
});
129204

130205
describe("formatCapabilityUnavailable", () => {

0 commit comments

Comments
 (0)