diff --git a/.changeset/fn-7446-planning-mcp-resolver.md b/.changeset/fn-7446-planning-mcp-resolver.md new file mode 100644 index 0000000000..99e1e7598d --- /dev/null +++ b/.changeset/fn-7446-planning-mcp-resolver.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent Planning Mode sessions from failing when MCP resolution returns no shaped result. +category: fix +dev: Dashboard planning lanes now default malformed MCP resolver output to an empty server set while preserving MCP forwarding. diff --git a/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts b/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts index eda0707742..2ea5ece800 100644 --- a/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts +++ b/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts @@ -56,7 +56,7 @@ vi.mock("../planning-board-tools.js", () => ({ createPlanningBoardTools: vi.fn(() => []), })); -import { __resetPlanningState, createSession } from "../planning.js"; +import { __resetPlanningState, createSession, createSessionWithAgent, planningStreamManager } from "../planning.js"; import { resolveManualAiPromptMcpServers } from "../routes.js"; import { createMissionInterviewAgent } from "../mission-interview.js"; import { createTargetInterviewAgent } from "../milestone-slice-interview.js"; @@ -82,6 +82,39 @@ describe("dashboard MCP lane forwarding", () => { })); }); + it("defaults an undefined MCP resolver result to empty servers for non-streaming planning", async () => { + resolveMcpServersForStoreMock.mockResolvedValueOnce(undefined as never); + + await createSession("127.0.0.1", "Build without MCP", {} as never, "/tmp/fusion-dashboard-test"); + + expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ + tools: "readonly", + allowMcpToolsInReadonly: true, + mcpServers: [], + })); + }); + + it("defaults an undefined MCP resolver result to empty servers for streaming planning", async () => { + resolveMcpServersForStoreMock.mockResolvedValueOnce(undefined as never); + + const sessionId = await createSessionWithAgent( + "127.0.0.1", + "Stream without MCP", + "/tmp/fusion-dashboard-test", + {} as never, + ); + + const startInitialTurn = planningStreamManager.consumeInitialTurn(sessionId); + expect(startInitialTurn).toBeTypeOf("function"); + startInitialTurn?.(); + + await vi.waitFor(() => expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ + tools: "readonly", + allowMcpToolsInReadonly: true, + mcpServers: [], + }))); + }); + it("resolves materialized MCP servers for manual AI-prompt workflow steps", async () => { const store = {} as never; diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index f2c05b8d11..9e6044f390 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -61,9 +61,23 @@ type AgentResult = any; type SkillPluginRunner = Parameters[3]; const PLANNING_BUILTIN_WEB_TOOLS = ["WebSearch", "WebFetch"] as const; +type PlanningMcpServers = Awaited>["servers"]; // eslint-disable-next-line @typescript-eslint/no-explicit-any let createFnAgent: any = engineCreateFnAgent; +async function resolvePlanningMcpServers(store: TaskStore): Promise { + const resolved = await resolveMcpServersForStore(store); + /* + FNXC:McpConfig 2026-07-02-13:45: + Planning lanes must forward shaped MCP server arrays, while dashboard route-test mocks may omit the resolver result entirely. + Default only malformed test-seam output to an empty in-memory set so configured servers and secret-bearing materialized fields are never logged or persisted here. + */ + if (!resolved || !Array.isArray(resolved.servers)) { + return []; + } + return resolved.servers; +} + // ── Notification Integration ──────────────────────────────────────────── // // The planning module sends "planning-awaiting-input" notifications when an @@ -1076,7 +1090,7 @@ export async function createSession( 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, + mcpServers: await resolvePlanningMcpServers(store), allowMcpToolsInReadonly: true, customTools: [ ...createPlanningBoardTools(store), @@ -1670,7 +1684,7 @@ async function createPlanningAgent( 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, + mcpServers: await resolvePlanningMcpServers(store), allowMcpToolsInReadonly: true, customTools: [ ...createPlanningBoardTools(store), diff --git a/packages/dashboard/src/test/mockCoreEngine.ts b/packages/dashboard/src/test/mockCoreEngine.ts index 8bad8239ef..f45ecc5d4c 100644 --- a/packages/dashboard/src/test/mockCoreEngine.ts +++ b/packages/dashboard/src/test/mockCoreEngine.ts @@ -66,8 +66,8 @@ export function createEngineMock(overrides: AnyModule = {}): AnyModule { */ createChatTaskDocumentTools: vi.fn(() => []), createChatArtifactTools: vi.fn(() => []), - // FNXC:McpConfig 2026-07-02-00:00: Planning/mission route tests share this engine mock; MCP resolution must return a shaped empty server set so readonly session creation can proceed without importing real engine stores. - resolveMcpServersForStore: vi.fn(async () => ({ servers: [] })), + // FNXC:McpConfig 2026-07-02-13:45: Planning/mission route tests share this engine mock; MCP resolution must return the full shaped empty result so readonly session creation can proceed without importing real engine stores. + resolveMcpServersForStore: vi.fn(async () => ({ servers: [], errors: [] })), ...overrides, }); }