diff --git a/.changeset/fn-7267-planning-mission-mcp.md b/.changeset/fn-7267-planning-mission-mcp.md new file mode 100644 index 0000000000..da03a977af --- /dev/null +++ b/.changeset/fn-7267-planning-mission-mcp.md @@ -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. diff --git a/docs/mcp.md b/docs/mcp.md index 50c0b334df..b16add29b7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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____` 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. diff --git a/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts b/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts index a5c377eae1..eda0707742 100644 --- a/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts +++ b/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts @@ -76,6 +76,8 @@ describe("dashboard MCP lane forwarding", () => { expect(resolveMcpServersForStoreMock).toHaveBeenCalledWith(store); expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/tmp/fusion-dashboard-test", + tools: "readonly", + allowMcpToolsInReadonly: true, mcpServers: [expect.objectContaining({ name: "docs", env: { TOKEN: "materialized-secret" } })], })); }); @@ -105,6 +107,7 @@ describe("dashboard MCP lane forwarding", () => { expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/tmp/fusion-dashboard-test", tools: "readonly", + allowMcpToolsInReadonly: true, mcpServers: [expect.objectContaining({ name: "docs", env: { TOKEN: "materialized-secret" } })], })); }); @@ -127,6 +130,7 @@ describe("dashboard MCP lane forwarding", () => { expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/tmp/fusion-dashboard-test", tools: "readonly", + allowMcpToolsInReadonly: true, mcpServers: [expect.objectContaining({ name: "docs", env: { TOKEN: "materialized-secret" } })], })); }); diff --git a/packages/dashboard/src/milestone-slice-interview.ts b/packages/dashboard/src/milestone-slice-interview.ts index 3e4e7a3142..da47991842 100644 --- a/packages/dashboard/src/milestone-slice-interview.ts +++ b/packages/dashboard/src/milestone-slice-interview.ts @@ -755,6 +755,9 @@ export async function createTargetInterviewAgent( /* 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. + + 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; @@ -763,6 +766,7 @@ export async function createTargetInterviewAgent( systemPrompt: getSystemPrompt(session.targetType), tools: "readonly", mcpServers, + allowMcpToolsInReadonly: true, customTools: [...createPlanningBoardTools(store)], /* FNXC:InterviewSkills 2026-06-17-21:42: diff --git a/packages/dashboard/src/mission-interview.ts b/packages/dashboard/src/mission-interview.ts index c5cfe7ee6e..19490e6217 100644 --- a/packages/dashboard/src/mission-interview.ts +++ b/packages/dashboard/src/mission-interview.ts @@ -874,6 +874,9 @@ export async function createMissionInterviewAgent( 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. + + 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; return createFnAgent({ @@ -881,6 +884,7 @@ export async function createMissionInterviewAgent( systemPrompt: effectivePrompt, tools: "readonly", mcpServers, + allowMcpToolsInReadonly: true, ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), builtinToolsAllowlist: [...MISSION_INTERVIEW_BUILTIN_WEB_TOOLS], customTools: [...createPlanningBoardTools(store)], diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index ea512a7f35..67f03b58ae 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -941,7 +941,9 @@ export async function createSession( ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), 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-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, + allowMcpToolsInReadonly: true, customTools: [ ...createPlanningBoardTools(store), ...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }), @@ -1541,7 +1543,9 @@ async function createPlanningAgent( ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), 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-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, + allowMcpToolsInReadonly: true, customTools: [ ...createPlanningBoardTools(store), ...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }), diff --git a/packages/engine/src/__tests__/mcp-surface-coverage.test.ts b/packages/engine/src/__tests__/mcp-surface-coverage.test.ts index 07147d2359..0fc9b6aa9d 100644 --- a/packages/engine/src/__tests__/mcp-surface-coverage.test.ts +++ b/packages/engine/src/__tests__/mcp-surface-coverage.test.ts @@ -15,15 +15,23 @@ function secrets(values: Record): 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 resolveIndex = source.indexOf(resolveNeedle); const createIndex = source.indexOf(createNeedle, resolveIndex); const forwardIndex = source.indexOf(forwardNeedle, createIndex); + const afterForwardIndex = afterForwardNeedle ? source.indexOf(afterForwardNeedle, forwardIndex) : -1; expect(resolveIndex).toBeGreaterThan(-1); expect(createIndex).toBeGreaterThan(resolveIndex); expect(forwardIndex).toBeGreaterThan(createIndex); + if (afterForwardNeedle) expect(afterForwardIndex).toBeGreaterThan(forwardIndex); } 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( "../dashboard/src/mission-interview.ts", "const mcpServers = (await resolveMcpServersForStore(store)).servers;", "return createFnAgent({", "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( "../dashboard/src/milestone-slice-interview.ts", "const mcpServers = (await resolveMcpServersForStore(store)).servers;", "return createFnAgent({", "mcpServers,", + "allowMcpToolsInReadonly: true,", ); }); }); diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index 934a32c024..bbd170caeb 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -1774,6 +1774,73 @@ describe("createFnAgent", () => { 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 () => { const { piLog } = await import("../logger.js"); const logSpy = vi.spyOn(piLog, "log").mockImplementation(() => {}); diff --git a/packages/engine/src/__tests__/pi-mcp-session-tools.test.ts b/packages/engine/src/__tests__/pi-mcp-session-tools.test.ts index d57e9e4d33..28cb6425f3 100644 --- a/packages/engine/src/__tests__/pi-mcp-session-tools.test.ts +++ b/packages/engine/src/__tests__/pi-mcp-session-tools.test.ts @@ -14,10 +14,12 @@ describe("pi MCP session tool integration", () => { 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(); - 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("allowReadonlyMcpTools ? { allowTool: (tool) => mcpReadonlyTools.has(tool) } : {}"); expect(source).toContain("await mcpToolset.dispose()"); expect(source).toContain("await mcpToolset?.dispose()"); }); diff --git a/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts b/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts index b304749d95..c81a45008f 100644 --- a/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts +++ b/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts @@ -66,7 +66,22 @@ describe("workflow-step readonly allowlist policy", () => { ]); 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", () => { diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 570d583f56..8661462594 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -1006,6 +1006,11 @@ export interface AgentOptions { * below decides whether to forward or skip them without logging contents. */ 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. */ mcpClientFactory?: McpClientFactory; /** Optional task-scoped env injected into this session's subprocess tools only. */ @@ -2215,10 +2220,14 @@ export async function createFnAgent(options: AgentOptions): Promise // tools through `customTools` instead. The wrapped tools preserve the same // names (`read`, `bash`, ...) as the built-ins they replace. 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: * 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, { cwd: options.cwd, @@ -2229,12 +2238,16 @@ export async function createFnAgent(options: AgentOptions): Promise piLog.log(`readonly session — MCP servers (${forwardedMcpServers.length}) skipped`); } + const mcpReadonlyTools = new Set(mcpToolset?.tools ?? []); const candidateCustomTools = [ ...(options.customTools ?? []), ...(mcpToolset?.tools ?? []), ]; const readonlyFilteredCustomTools = isReadonly - ? filterCustomToolsForReadonly(candidateCustomTools) + ? filterCustomToolsForReadonly( + candidateCustomTools, + allowReadonlyMcpTools ? { allowTool: (tool) => mcpReadonlyTools.has(tool) } : {}, + ) : { allowed: candidateCustomTools, denied: [] }; const allowlistFilteredCustomTools = { ...readonlyFilteredCustomTools, diff --git a/packages/engine/src/workflow-step-tool-policy.ts b/packages/engine/src/workflow-step-tool-policy.ts index 9cada17879..3b9b3296c9 100644 --- a/packages/engine/src/workflow-step-tool-policy.ts +++ b/packages/engine/src/workflow-step-tool-policy.ts @@ -47,14 +47,25 @@ export function isReadonlyAllowed(toolName: string): boolean { 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 denied: string[] = []; for (const tool of tools) { const name = tool.name?.trim() ?? ""; if (!name) continue; - if (isReadonlyAllowed(name)) { + if (isReadonlyAllowed(name) || options.allowTool?.(tool) === true) { allowed.push(tool); continue; }