FN-7267: expose MCP tools to planning sessions
Expose configured MCP session tools to read-only planning and mission lanes while keeping other read-only sessions locked down. - Add an explicit allowMcpToolsInReadonly option to createFnAgent and apply it only to connected MCP tools. - Opt planning, streaming planning, mission, milestone, and slice interview sessions into read-only MCP tools. - Expand regression coverage for MCP forwarding, read-only filtering, and session tool disposal behavior. - Document the read-only MCP opt-in model and add a published package changeset. Files changed: .changeset/fn-7267-planning-mission-mcp.md | 7 +++ docs/mcp.md | 4 +- .../src/__tests__/mcp-lane-forwarding.test.ts | 4 ++ .../dashboard/src/milestone-slice-interview.ts | 4 ++ packages/dashboard/src/mission-interview.ts | 4 ++ packages/dashboard/src/planning.ts | 4 ++ .../src/__tests__/mcp-surface-coverage.test.ts | 25 +++++++- .../src/__tests__/pi-create-fn-agent.test.ts | 67 ++++++++++++++++++++++ .../src/__tests__/pi-mcp-session-tools.test.ts | 6 +- .../workflow-step-readonly-allowlist.test.ts | 17 +++++- packages/engine/src/pi.ts | 17 +++++- packages/engine/src/workflow-step-tool-policy.ts | 15 ++++- 12 files changed, 163 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-7267 Fusion-Task-Lineage: 8d9cac1a-edcc-453d-bda1-9abb58de7f86 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7267-planning-mission-mcp.md
Normal file
7
.changeset/fn-7267-planning-mission-mcp.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Make configured MCP tools available in planning and mission interviews.
|
||||||
|
category: fix
|
||||||
|
dev: Adds an explicit read-only MCP opt-in for planning and mission session factories with regression coverage.
|
||||||
@@ -268,6 +268,8 @@ Runtime support is guarded. Claude/pi/ACP-compatible runtimes receive MCP server
|
|||||||
|
|
||||||
The default pi runtime connects resolved MCP servers inside the engine because pi does not consume raw `mcpServers` declarations itself. For each reachable server, Fusion performs the MCP handshake, lists tools, and registers each tool as a pi custom tool named `mcp__<server>__<tool>` with sanitized, deterministic suffixes for collisions. Unreachable or disabled servers fail soft with content-free logs, and all MCP clients/transports are closed when the agent session is disposed so stdio subprocesses are reaped.
|
The default pi runtime connects resolved MCP servers inside the engine because pi does not consume raw `mcpServers` declarations itself. For each reachable server, Fusion performs the MCP handshake, lists tools, and registers each tool as a pi custom tool named `mcp__<server>__<tool>` with sanitized, deterministic suffixes for collisions. Unreachable or disabled servers fail soft with content-free logs, and all MCP clients/transports are closed when the agent session is disposed so stdio subprocesses are reaped.
|
||||||
|
|
||||||
Expected outcome: enabling a server makes it available to subsequent supported AI sessions, while unsupported sessions continue without MCP tools and without logging secret-bearing server definitions.
|
Read-only sessions do not receive MCP tools automatically. Interactive planning and mission interview lanes (planning, streaming planning, mission interview, milestone interview, and slice interview) explicitly opt in because their job is to gather context and create plans, so configured MCP documentation/context tools are available there while still passing through the same custom-tool read-only filter, allowlists, permanent-agent gating, action-gate wrapping, worktree-boundary wrapping, schema preservation, redacted logging, and teardown path. Other read-only validator/helper lanes must make their own reviewed opt-in before external MCP tools appear.
|
||||||
|
|
||||||
|
Expected outcome: enabling a server makes it available to subsequent supported AI sessions and explicitly opted-in planning/mission read-only sessions, while unsupported sessions and read-only sessions without the opt-in continue without MCP tools and without logging secret-bearing server definitions.
|
||||||
|
|
||||||
See [Settings Reference](./settings-reference.md) for the `mcpServers` settings contract and [Agents](./agents.md) for runtime/model lane behavior.
|
See [Settings Reference](./settings-reference.md) for the `mcpServers` settings contract and [Agents](./agents.md) for runtime/model lane behavior.
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ describe("dashboard MCP lane forwarding", () => {
|
|||||||
expect(resolveMcpServersForStoreMock).toHaveBeenCalledWith(store);
|
expect(resolveMcpServersForStoreMock).toHaveBeenCalledWith(store);
|
||||||
expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({
|
expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
cwd: "/tmp/fusion-dashboard-test",
|
cwd: "/tmp/fusion-dashboard-test",
|
||||||
|
tools: "readonly",
|
||||||
|
allowMcpToolsInReadonly: true,
|
||||||
mcpServers: [expect.objectContaining({ name: "docs", env: { TOKEN: "materialized-secret" } })],
|
mcpServers: [expect.objectContaining({ name: "docs", env: { TOKEN: "materialized-secret" } })],
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -105,6 +107,7 @@ describe("dashboard MCP lane forwarding", () => {
|
|||||||
expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({
|
expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
cwd: "/tmp/fusion-dashboard-test",
|
cwd: "/tmp/fusion-dashboard-test",
|
||||||
tools: "readonly",
|
tools: "readonly",
|
||||||
|
allowMcpToolsInReadonly: true,
|
||||||
mcpServers: [expect.objectContaining({ name: "docs", env: { TOKEN: "materialized-secret" } })],
|
mcpServers: [expect.objectContaining({ name: "docs", env: { TOKEN: "materialized-secret" } })],
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -127,6 +130,7 @@ describe("dashboard MCP lane forwarding", () => {
|
|||||||
expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({
|
expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
cwd: "/tmp/fusion-dashboard-test",
|
cwd: "/tmp/fusion-dashboard-test",
|
||||||
tools: "readonly",
|
tools: "readonly",
|
||||||
|
allowMcpToolsInReadonly: true,
|
||||||
mcpServers: [expect.objectContaining({ name: "docs", env: { TOKEN: "materialized-secret" } })],
|
mcpServers: [expect.objectContaining({ name: "docs", env: { TOKEN: "materialized-secret" } })],
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -755,6 +755,9 @@ export async function createTargetInterviewAgent(
|
|||||||
/*
|
/*
|
||||||
FNXC:McpConfig 2026-06-26-00:00:
|
FNXC:McpConfig 2026-06-26-00:00:
|
||||||
Milestone and slice interviews already receive a TaskStore for planning-board tools; forward configured MCP servers so this readonly agent-work surface matches mission/planning coverage without logging resolved secrets.
|
Milestone and slice interviews already receive a TaskStore for planning-board tools; forward configured MCP servers so this readonly agent-work surface matches mission/planning coverage without logging resolved secrets.
|
||||||
|
|
||||||
|
FNXC:McpConfig 2026-06-29-00:00:
|
||||||
|
Milestone and slice interviews opt into MCP tools explicitly because they are planning-context lanes; other read-only sessions still skip MCP unless they make the same reviewed policy choice.
|
||||||
*/
|
*/
|
||||||
const mcpServers = (await resolveMcpServersForStore(store)).servers;
|
const mcpServers = (await resolveMcpServersForStore(store)).servers;
|
||||||
|
|
||||||
@@ -763,6 +766,7 @@ export async function createTargetInterviewAgent(
|
|||||||
systemPrompt: getSystemPrompt(session.targetType),
|
systemPrompt: getSystemPrompt(session.targetType),
|
||||||
tools: "readonly",
|
tools: "readonly",
|
||||||
mcpServers,
|
mcpServers,
|
||||||
|
allowMcpToolsInReadonly: true,
|
||||||
customTools: [...createPlanningBoardTools(store)],
|
customTools: [...createPlanningBoardTools(store)],
|
||||||
/*
|
/*
|
||||||
FNXC:InterviewSkills 2026-06-17-21:42:
|
FNXC:InterviewSkills 2026-06-17-21:42:
|
||||||
|
|||||||
@@ -874,6 +874,9 @@ export async function createMissionInterviewAgent(
|
|||||||
|
|
||||||
FNXC:McpConfig 2026-06-26-00:00:
|
FNXC:McpConfig 2026-06-26-00:00:
|
||||||
Mission interviews have a TaskStore for planning-board tools and are agent-work sessions, so forward the store-resolved MCP set while keeping resolved secret values out of logs and persisted session state.
|
Mission interviews have a TaskStore for planning-board tools and are agent-work sessions, so forward the store-resolved MCP set while keeping resolved secret values out of logs and persisted session state.
|
||||||
|
|
||||||
|
FNXC:McpConfig 2026-06-29-00:00:
|
||||||
|
Mission interviews are read-only planning lanes that intentionally expose configured MCP context tools; the engine opt-in preserves the read-only default for unrelated validator sessions.
|
||||||
*/
|
*/
|
||||||
const mcpServers = (await resolveMcpServersForStore(store)).servers;
|
const mcpServers = (await resolveMcpServersForStore(store)).servers;
|
||||||
return createFnAgent({
|
return createFnAgent({
|
||||||
@@ -881,6 +884,7 @@ export async function createMissionInterviewAgent(
|
|||||||
systemPrompt: effectivePrompt,
|
systemPrompt: effectivePrompt,
|
||||||
tools: "readonly",
|
tools: "readonly",
|
||||||
mcpServers,
|
mcpServers,
|
||||||
|
allowMcpToolsInReadonly: true,
|
||||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||||
builtinToolsAllowlist: [...MISSION_INTERVIEW_BUILTIN_WEB_TOOLS],
|
builtinToolsAllowlist: [...MISSION_INTERVIEW_BUILTIN_WEB_TOOLS],
|
||||||
customTools: [...createPlanningBoardTools(store)],
|
customTools: [...createPlanningBoardTools(store)],
|
||||||
|
|||||||
@@ -941,7 +941,9 @@ export async function createSession(
|
|||||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||||
builtinToolsAllowlist: [...PLANNING_BUILTIN_WEB_TOOLS],
|
builtinToolsAllowlist: [...PLANNING_BUILTIN_WEB_TOOLS],
|
||||||
// FNXC:McpConfig 2026-06-25-22:31: Planning/chat session creation resolves trusted MCP servers through the dashboard-scoped store and forwards only the materialized in-memory set to the engine runtime guard.
|
// FNXC:McpConfig 2026-06-25-22:31: Planning/chat session creation resolves trusted MCP servers through the dashboard-scoped store and forwards only the materialized in-memory set to the engine runtime guard.
|
||||||
|
// FNXC:McpConfig 2026-06-29-00:00: Planning sessions are intentionally read-only but still need configured MCP documentation/context tools; opt in at the session boundary while preserving engine-side namespacing, filtering, wrappers, and disposal.
|
||||||
mcpServers: (await resolveMcpServersForStore(store)).servers,
|
mcpServers: (await resolveMcpServersForStore(store)).servers,
|
||||||
|
allowMcpToolsInReadonly: true,
|
||||||
customTools: [
|
customTools: [
|
||||||
...createPlanningBoardTools(store),
|
...createPlanningBoardTools(store),
|
||||||
...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }),
|
...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }),
|
||||||
@@ -1541,7 +1543,9 @@ async function createPlanningAgent(
|
|||||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||||
builtinToolsAllowlist: [...PLANNING_BUILTIN_WEB_TOOLS],
|
builtinToolsAllowlist: [...PLANNING_BUILTIN_WEB_TOOLS],
|
||||||
// FNXC:McpConfig 2026-06-25-22:31: Streaming planning uses the same dashboard-scoped MCP resolution seam as non-streaming planning so no planning lane silently drops enabled servers.
|
// FNXC:McpConfig 2026-06-25-22:31: Streaming planning uses the same dashboard-scoped MCP resolution seam as non-streaming planning so no planning lane silently drops enabled servers.
|
||||||
|
// FNXC:McpConfig 2026-06-29-00:00: Streaming planning uses the explicit read-only MCP opt-in; non-planning read-only lanes remain denied unless they set the same reviewed policy flag.
|
||||||
mcpServers: (await resolveMcpServersForStore(store)).servers,
|
mcpServers: (await resolveMcpServersForStore(store)).servers,
|
||||||
|
allowMcpToolsInReadonly: true,
|
||||||
customTools: [
|
customTools: [
|
||||||
...createPlanningBoardTools(store),
|
...createPlanningBoardTools(store),
|
||||||
...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }),
|
...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }),
|
||||||
|
|||||||
@@ -15,15 +15,23 @@ function secrets(values: Record<string, string>): McpSecretReader {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectResolvedMcpForwarded(sourcePath: string, resolveNeedle: string, createNeedle: string, forwardNeedle: string) {
|
function expectResolvedMcpForwarded(
|
||||||
|
sourcePath: string,
|
||||||
|
resolveNeedle: string,
|
||||||
|
createNeedle: string,
|
||||||
|
forwardNeedle: string,
|
||||||
|
afterForwardNeedle?: string,
|
||||||
|
) {
|
||||||
const source = readFileSync(join(process.cwd(), sourcePath), "utf8");
|
const source = readFileSync(join(process.cwd(), sourcePath), "utf8");
|
||||||
const resolveIndex = source.indexOf(resolveNeedle);
|
const resolveIndex = source.indexOf(resolveNeedle);
|
||||||
const createIndex = source.indexOf(createNeedle, resolveIndex);
|
const createIndex = source.indexOf(createNeedle, resolveIndex);
|
||||||
const forwardIndex = source.indexOf(forwardNeedle, createIndex);
|
const forwardIndex = source.indexOf(forwardNeedle, createIndex);
|
||||||
|
const afterForwardIndex = afterForwardNeedle ? source.indexOf(afterForwardNeedle, forwardIndex) : -1;
|
||||||
|
|
||||||
expect(resolveIndex).toBeGreaterThan(-1);
|
expect(resolveIndex).toBeGreaterThan(-1);
|
||||||
expect(createIndex).toBeGreaterThan(resolveIndex);
|
expect(createIndex).toBeGreaterThan(resolveIndex);
|
||||||
expect(forwardIndex).toBeGreaterThan(createIndex);
|
expect(forwardIndex).toBeGreaterThan(createIndex);
|
||||||
|
if (afterForwardNeedle) expect(afterForwardIndex).toBeGreaterThan(forwardIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("MCP surface coverage", () => {
|
describe("MCP surface coverage", () => {
|
||||||
@@ -121,21 +129,32 @@ describe("MCP surface coverage", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps mission interview forwarding the store-resolved MCP result", () => {
|
it("keeps dashboard planning forwarding resolved MCP with the readonly opt-in", () => {
|
||||||
|
const source = readFileSync(join(process.cwd(), "../dashboard/src/planning.ts"), "utf8");
|
||||||
|
const forwardingNeedle = "mcpServers: (await resolveMcpServersForStore(store)).servers,";
|
||||||
|
expect(source.match(new RegExp(forwardingNeedle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"))?.length).toBe(2);
|
||||||
|
expect(source.match(/allowMcpToolsInReadonly: true,/g)?.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(source).toContain("const agentResult = await createFnAgent({");
|
||||||
|
expect(source).toContain("return createFnAgent({");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps mission interview forwarding the store-resolved MCP result with the readonly opt-in", () => {
|
||||||
expectResolvedMcpForwarded(
|
expectResolvedMcpForwarded(
|
||||||
"../dashboard/src/mission-interview.ts",
|
"../dashboard/src/mission-interview.ts",
|
||||||
"const mcpServers = (await resolveMcpServersForStore(store)).servers;",
|
"const mcpServers = (await resolveMcpServersForStore(store)).servers;",
|
||||||
"return createFnAgent({",
|
"return createFnAgent({",
|
||||||
"mcpServers,",
|
"mcpServers,",
|
||||||
|
"allowMcpToolsInReadonly: true,",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps milestone and slice interview forwarding the store-resolved MCP result", () => {
|
it("keeps milestone and slice interview forwarding the store-resolved MCP result with the readonly opt-in", () => {
|
||||||
expectResolvedMcpForwarded(
|
expectResolvedMcpForwarded(
|
||||||
"../dashboard/src/milestone-slice-interview.ts",
|
"../dashboard/src/milestone-slice-interview.ts",
|
||||||
"const mcpServers = (await resolveMcpServersForStore(store)).servers;",
|
"const mcpServers = (await resolveMcpServersForStore(store)).servers;",
|
||||||
"return createFnAgent({",
|
"return createFnAgent({",
|
||||||
"mcpServers,",
|
"mcpServers,",
|
||||||
|
"allowMcpToolsInReadonly: true,",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1774,6 +1774,73 @@ describe("createFnAgent", () => {
|
|||||||
expect(createSessionArgs.customTools.map((tool) => tool.name)).toContain("fn_heartbeat_done");
|
expect(createSessionArgs.customTools.map((tool) => tool.name)).toContain("fn_heartbeat_done");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes connected MCP tools in readonly sessions only with the explicit opt-in", async () => {
|
||||||
|
const { createFnAgent } = await import("../pi.js");
|
||||||
|
const close = vi.fn(async () => undefined);
|
||||||
|
const mcpClient = {
|
||||||
|
connect: vi.fn(async () => undefined),
|
||||||
|
listTools: vi.fn(async () => ({
|
||||||
|
tools: [{
|
||||||
|
name: "lookup",
|
||||||
|
description: "Lookup docs",
|
||||||
|
inputSchema: { type: "object", properties: { topic: { type: "string" } }, required: ["topic"] },
|
||||||
|
}],
|
||||||
|
})),
|
||||||
|
callTool: vi.fn(async () => ({ content: [{ type: "text", text: "ok" }] })),
|
||||||
|
close,
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await createFnAgent({
|
||||||
|
cwd: "/test/project",
|
||||||
|
systemPrompt: "test",
|
||||||
|
tools: "readonly",
|
||||||
|
defaultProvider: "anthropic",
|
||||||
|
defaultModelId: "claude-sonnet-4-5",
|
||||||
|
mcpServers: [{ name: "docs", transport: "stdio", command: "node", enabled: true }],
|
||||||
|
allowMcpToolsInReadonly: true,
|
||||||
|
mcpClientFactory: () => mcpClient as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSessionArgs = createAgentSessionMock.mock.calls[0]?.[0] as { customTools: Array<{ name: string; parameters?: unknown }> };
|
||||||
|
expect(createSessionArgs.customTools).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "mcp__docs__lookup",
|
||||||
|
parameters: expect.objectContaining({
|
||||||
|
type: "object",
|
||||||
|
properties: { topic: { type: "string" } },
|
||||||
|
required: ["topic"],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
|
||||||
|
await created.session.dispose?.();
|
||||||
|
expect(close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps MCP tools out of readonly sessions without the explicit opt-in", async () => {
|
||||||
|
const { createFnAgent } = await import("../pi.js");
|
||||||
|
const mcpClient = {
|
||||||
|
connect: vi.fn(async () => undefined),
|
||||||
|
listTools: vi.fn(async () => ({ tools: [{ name: "lookup" }] })),
|
||||||
|
callTool: vi.fn(async () => ({ content: [{ type: "text", text: "ok" }] })),
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
await createFnAgent({
|
||||||
|
cwd: "/test/project",
|
||||||
|
systemPrompt: "test",
|
||||||
|
tools: "readonly",
|
||||||
|
defaultProvider: "anthropic",
|
||||||
|
defaultModelId: "claude-sonnet-4-5",
|
||||||
|
mcpServers: [{ name: "docs", transport: "stdio", command: "node", enabled: true }],
|
||||||
|
mcpClientFactory: () => mcpClient as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSessionArgs = createAgentSessionMock.mock.calls[0]?.[0] as { customTools: Array<{ name: string }> };
|
||||||
|
expect(mcpClient.connect).not.toHaveBeenCalled();
|
||||||
|
expect(createSessionArgs.customTools.map((tool) => tool.name)).not.toContain("mcp__docs__lookup");
|
||||||
|
});
|
||||||
|
|
||||||
it("logs createFnAgent startup diagnostics without leaking cwd", async () => {
|
it("logs createFnAgent startup diagnostics without leaking cwd", async () => {
|
||||||
const { piLog } = await import("../logger.js");
|
const { piLog } = await import("../logger.js");
|
||||||
const logSpy = vi.spyOn(piLog, "log").mockImplementation(() => {});
|
const logSpy = vi.spyOn(piLog, "log").mockImplementation(() => {});
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ describe("pi MCP session tool integration", () => {
|
|||||||
expect(source).not.toContain("mcpServers: forwardedMcpServers");
|
expect(source).not.toContain("mcpServers: forwardedMcpServers");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips MCP servers in readonly sessions and chains disposal into session dispose", () => {
|
it("keeps readonly MCP exposure behind an explicit opt-in while preserving disposal", () => {
|
||||||
const source = piSource();
|
const source = piSource();
|
||||||
expect(source).toContain("forwardedMcpServers.length > 0 && !isReadonly");
|
expect(source).toContain("allowMcpToolsInReadonly");
|
||||||
|
expect(source).toContain("forwardedMcpServers.length > 0 && (!isReadonly || allowReadonlyMcpTools)");
|
||||||
expect(source).toContain("readonly session — MCP servers");
|
expect(source).toContain("readonly session — MCP servers");
|
||||||
|
expect(source).toContain("allowReadonlyMcpTools ? { allowTool: (tool) => mcpReadonlyTools.has(tool) } : {}");
|
||||||
expect(source).toContain("await mcpToolset.dispose()");
|
expect(source).toContain("await mcpToolset.dispose()");
|
||||||
expect(source).toContain("await mcpToolset?.dispose()");
|
expect(source).toContain("await mcpToolset?.dispose()");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,7 +66,22 @@ describe("workflow-step readonly allowlist policy", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(filtered.allowed.map((tool) => tool.name)).toEqual(["read", "fn_task_list"]);
|
expect(filtered.allowed.map((tool) => tool.name)).toEqual(["read", "fn_task_list"]);
|
||||||
expect(filtered.denied).toEqual(["edit"]);
|
expect(filtered.denied).toEqual(["edit", "fn_task_update"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows explicitly approved tool objects without blanket-allowing mcp names", () => {
|
||||||
|
const connectedMcpTool = { name: "mcp__docs__lookup" } as any;
|
||||||
|
const callerSuppliedMcpNamedTool = { name: "mcp__docs__write" } as any;
|
||||||
|
|
||||||
|
const filtered = filterCustomToolsForReadonly([
|
||||||
|
connectedMcpTool,
|
||||||
|
callerSuppliedMcpNamedTool,
|
||||||
|
], {
|
||||||
|
allowTool: (tool) => tool === connectedMcpTool,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filtered.allowed).toEqual([connectedMcpTool]);
|
||||||
|
expect(filtered.allowed).not.toContain(callerSuppliedMcpNamedTool);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("captures readonly violation error shape", () => {
|
it("captures readonly violation error shape", () => {
|
||||||
|
|||||||
@@ -1006,6 +1006,11 @@ export interface AgentOptions {
|
|||||||
* below decides whether to forward or skip them without logging contents.
|
* below decides whether to forward or skip them without logging contents.
|
||||||
*/
|
*/
|
||||||
mcpServers?: ResolvedMcpServerDefinition[];
|
mcpServers?: ResolvedMcpServerDefinition[];
|
||||||
|
/**
|
||||||
|
* Allow connected MCP session tools through the read-only custom-tool filter.
|
||||||
|
* Defaults to false so validators/read-only helpers do not inherit arbitrary external tools.
|
||||||
|
*/
|
||||||
|
allowMcpToolsInReadonly?: boolean;
|
||||||
/** Test seam for MCP session tools; production uses the SDK client/transport factories. */
|
/** Test seam for MCP session tools; production uses the SDK client/transport factories. */
|
||||||
mcpClientFactory?: McpClientFactory;
|
mcpClientFactory?: McpClientFactory;
|
||||||
/** Optional task-scoped env injected into this session's subprocess tools only. */
|
/** Optional task-scoped env injected into this session's subprocess tools only. */
|
||||||
@@ -2215,10 +2220,14 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
|||||||
// tools through `customTools` instead. The wrapped tools preserve the same
|
// tools through `customTools` instead. The wrapped tools preserve the same
|
||||||
// names (`read`, `bash`, ...) as the built-ins they replace.
|
// names (`read`, `bash`, ...) as the built-ins they replace.
|
||||||
let mcpToolset: McpSessionToolset | undefined;
|
let mcpToolset: McpSessionToolset | undefined;
|
||||||
if (forwardedMcpServers.length > 0 && !isReadonly) {
|
const allowReadonlyMcpTools = options.allowMcpToolsInReadonly === true;
|
||||||
|
if (forwardedMcpServers.length > 0 && (!isReadonly || allowReadonlyMcpTools)) {
|
||||||
/*
|
/*
|
||||||
* FNXC:McpConfig 2026-06-27-14:06:
|
* FNXC:McpConfig 2026-06-27-14:06:
|
||||||
* pi-coding-agent does not have a createAgentSession `mcpServers` option, so passing resolved servers is silently ignored. Connect MCP servers here and merge namespaced tools into the same customTools filtering/gating/boundary pipeline as engine tools before the session sees them.
|
* pi-coding-agent does not have a createAgentSession `mcpServers` option, so passing resolved servers is silently ignored. Connect MCP servers here and merge namespaced tools into the same customTools filtering/gating/boundary pipeline as engine tools before the session sees them.
|
||||||
|
*
|
||||||
|
* FNXC:McpConfig 2026-06-29-00:00:
|
||||||
|
* Planning and mission interviews are read-only lanes that still need operator-configured documentation/context MCP tools. They must opt in explicitly; other read-only sessions continue to skip MCP connection so unknown external tools do not bypass the read-only allowlist by default.
|
||||||
*/
|
*/
|
||||||
mcpToolset = await connectMcpSessionTools(forwardedMcpServers, {
|
mcpToolset = await connectMcpSessionTools(forwardedMcpServers, {
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
@@ -2229,12 +2238,16 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
|||||||
piLog.log(`readonly session — MCP servers (${forwardedMcpServers.length}) skipped`);
|
piLog.log(`readonly session — MCP servers (${forwardedMcpServers.length}) skipped`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mcpReadonlyTools = new Set(mcpToolset?.tools ?? []);
|
||||||
const candidateCustomTools = [
|
const candidateCustomTools = [
|
||||||
...(options.customTools ?? []),
|
...(options.customTools ?? []),
|
||||||
...(mcpToolset?.tools ?? []),
|
...(mcpToolset?.tools ?? []),
|
||||||
];
|
];
|
||||||
const readonlyFilteredCustomTools = isReadonly
|
const readonlyFilteredCustomTools = isReadonly
|
||||||
? filterCustomToolsForReadonly(candidateCustomTools)
|
? filterCustomToolsForReadonly(
|
||||||
|
candidateCustomTools,
|
||||||
|
allowReadonlyMcpTools ? { allowTool: (tool) => mcpReadonlyTools.has(tool) } : {},
|
||||||
|
)
|
||||||
: { allowed: candidateCustomTools, denied: [] };
|
: { allowed: candidateCustomTools, denied: [] };
|
||||||
const allowlistFilteredCustomTools = {
|
const allowlistFilteredCustomTools = {
|
||||||
...readonlyFilteredCustomTools,
|
...readonlyFilteredCustomTools,
|
||||||
|
|||||||
@@ -47,14 +47,25 @@ export function isReadonlyAllowed(toolName: string): boolean {
|
|||||||
return READONLY_ALLOWLIST_SET.has(toolName.trim());
|
return READONLY_ALLOWLIST_SET.has(toolName.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterCustomToolsForReadonly(tools: ToolDefinition[]): { allowed: ToolDefinition[]; denied: string[] } {
|
export interface ReadonlyCustomToolFilterOptions {
|
||||||
|
/**
|
||||||
|
* FNXC:McpConfig 2026-06-29-00:00:
|
||||||
|
* Planning and mission interviews use read-only sessions but intentionally opt into MCP session tools after Fusion has connected, namespaced, and materialized those tools. Keep this as a per-tool predicate instead of a blanket `mcp__` name allowlist so other read-only lanes and caller-supplied custom tools remain protected by default.
|
||||||
|
*/
|
||||||
|
allowTool?: (tool: ToolDefinition) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterCustomToolsForReadonly(
|
||||||
|
tools: ToolDefinition[],
|
||||||
|
options: ReadonlyCustomToolFilterOptions = {},
|
||||||
|
): { allowed: ToolDefinition[]; denied: string[] } {
|
||||||
const allowed: ToolDefinition[] = [];
|
const allowed: ToolDefinition[] = [];
|
||||||
const denied: string[] = [];
|
const denied: string[] = [];
|
||||||
|
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
const name = tool.name?.trim() ?? "";
|
const name = tool.name?.trim() ?? "";
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
if (isReadonlyAllowed(name)) {
|
if (isReadonlyAllowed(name) || options.allowTool?.(tool) === true) {
|
||||||
allowed.push(tool);
|
allowed.push(tool);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user