diff --git a/.changeset/fn-7078-mcp-planning-helpers.md b/.changeset/fn-7078-mcp-planning-helpers.md new file mode 100644 index 0000000000..8101b120c5 --- /dev/null +++ b/.changeset/fn-7078-mcp-planning-helpers.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Configured MCP servers now reach dashboard planning helpers like subtask breakdown, text refine, and insights. +category: feature +dev: Thread TaskStore/secrets into dashboard readonly createFnAgent helpers and forward resolveMcpServersForStore; see FN-7078. diff --git a/docs/mcp.md b/docs/mcp.md index 52ffba3cc9..1df661e467 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -58,8 +58,8 @@ Fusion materializes MCP secret references only at the use seam: - when running a bounded validation/reachability probe; - when importing plaintext Claude Desktop env/header values and immediately creating Fusion secrets. - -MCP-capable AI sessions include Chat, planning, executor/Tasks, heartbeat runs, reviewer/validator/merger lanes, PR-response and PR-conflict merger helpers, manual AI-prompt workflow steps, workflow model nodes, evaluator, cron/automation, mission execution, mission and milestone/slice interviews, and agent reflection. Non-agent runtime surfaces such as terminal sessions and `chatStore.createSession` database row creation do not receive MCP servers. + +MCP-capable AI sessions include Chat, planning, executor/Tasks, heartbeat runs, reviewer/validator/merger lanes, PR-response and PR-conflict merger helpers, manual AI-prompt workflow steps, workflow model nodes, evaluator, cron/automation, mission execution, mission and milestone/slice interviews, agent reflection, and dashboard readonly planning helpers such as subtask breakdown, text refinement/goal drafting, agent onboarding generation, PR metadata generation, and insight extraction. Non-agent runtime surfaces such as terminal sessions and `chatStore.createSession` database row creation do not receive MCP servers. Expected outcome: API responses, CLI output, settings JSON, exports, and structured logs show secret references or counts/status metadata only; they do not include decrypted env/header values. @@ -262,7 +262,7 @@ Expected outcome: API clients can display candidates, source labels, configured ## How MCP servers reach AI lanes -When an AI lane starts a session, Fusion resolves the effective `mcpServers` settings, materializes secret references through the scoped secrets store, and passes the resulting in-memory server declarations to runtimes that support MCP. The forwarding path covers chat/planning, executor, reviewer, validator, merger, workflow model nodes, summarization, evaluator, research, cron/automation, mission, and reflection paths. +When an AI lane or readonly dashboard helper starts a session, Fusion resolves the effective `mcpServers` settings, materializes secret references through the scoped secrets store, and passes the resulting in-memory server declarations to runtimes that support MCP. The forwarding path covers chat/planning, executor, reviewer, validator, merger, workflow model nodes, summarization, evaluator, research, cron/automation, mission, reflection, subtask breakdown, text refinement/goal drafting, agent onboarding generation, PR metadata generation, and insight extraction paths. Runtime support is guarded. Claude/pi/ACP-compatible runtimes receive MCP servers; mock or unsupported runtimes skip forwarding and emit only structured count/provider/runtime metadata. Skipped forwarding is not a settings error: it means the selected runtime does not accept MCP server declarations. diff --git a/packages/dashboard/src/__tests__/agent-generation.test.ts b/packages/dashboard/src/__tests__/agent-generation.test.ts index 611426e63c..1e6aba5249 100644 --- a/packages/dashboard/src/__tests__/agent-generation.test.ts +++ b/packages/dashboard/src/__tests__/agent-generation.test.ts @@ -128,6 +128,7 @@ describe("agent-generation module", () => { }); vi.doMock("@fusion/engine", () => ({ + resolveMcpServersForStore: vi.fn().mockResolvedValue({ servers: [], errors: [] }), createFnAgent: vi.fn(async () => { const messages: Array<{ role: string; content: string }> = []; return { @@ -187,6 +188,7 @@ describe("agent-generation module", () => { const generationFailure = new Error("engine failed to generate spec"); vi.doMock("@fusion/engine", () => ({ + resolveMcpServersForStore: vi.fn().mockResolvedValue({ servers: [], errors: [] }), createFnAgent: vi.fn(async () => { throw generationFailure; }), @@ -495,6 +497,7 @@ describe("agent-generation module", () => { capturedSystemPrompt = undefined; // Mock createFnAgent before tests run vi.doMock("@fusion/engine", () => ({ + resolveMcpServersForStore: vi.fn().mockResolvedValue({ servers: [], errors: [] }), createFnAgent: vi.fn(async (options: { cwd: string; systemPrompt: string; tools: string }) => { capturedSystemPrompt = options.systemPrompt; const messages: Array<{ role: string; content: string }> = []; diff --git a/packages/dashboard/src/__tests__/agent-onboarding.test.ts b/packages/dashboard/src/__tests__/agent-onboarding.test.ts index 8c9e92522e..e858952dad 100644 --- a/packages/dashboard/src/__tests__/agent-onboarding.test.ts +++ b/packages/dashboard/src/__tests__/agent-onboarding.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { mockCreateFnAgent } = vi.hoisted(() => ({ +const { mockCreateFnAgent, mockResolveMcpServersForStore } = vi.hoisted(() => ({ mockCreateFnAgent: vi.fn(), + mockResolveMcpServersForStore: vi.fn(async () => ({ servers: [], errors: [] })), })); vi.mock("@fusion/engine", () => ({ @@ -22,6 +23,7 @@ vi.mock("@fusion/engine", () => ({ }; }, createFnAgent: mockCreateFnAgent, + resolveMcpServersForStore: mockResolveMcpServersForStore, })); import { @@ -33,6 +35,7 @@ import { InvalidSessionStateError, parseAgentOnboardingResponse, respondToAgentOnboarding, + retryAgentOnboardingSession, SessionNotFoundError, startAgentOnboardingSession, } from "../agent-onboarding.js"; @@ -71,6 +74,7 @@ function createSkillPluginRunner(skills: Array<{ name: string; enabled?: boolean describe("agent-onboarding", () => { beforeEach(() => { vi.clearAllMocks(); + mockResolveMcpServersForStore.mockResolvedValue({ servers: [], errors: [] }); __resetAgentOnboardingState(); }); @@ -294,6 +298,72 @@ describe("agent-onboarding", () => { expect(options.skillSelection?.requestedSkillNames).toEqual(["fusion", "ce-debug"]); }); + it("forwards store-resolved MCP servers to onboarding helper agents", async () => { + const mcpServers = [{ name: "planning-helper", command: "mcp-helper", env: { TOKEN: "materialized-secret" } }]; + const store = { getSettingsByScope: vi.fn() }; + mockResolveMcpServersForStore.mockResolvedValueOnce({ servers: mcpServers, errors: [] }); + mockCreateFnAgent.mockResolvedValueOnce( + createMockAgent([ + JSON.stringify({ + type: "question", + data: { id: "goal", type: "text", question: "What is the primary goal?" }, + }), + ]), + ); + + await startAgentOnboardingSession( + "127.0.0.1", + { intent: "mcp", existingAgents: [], templates: [] }, + process.cwd(), + undefined, + undefined, + undefined, + undefined, + store as never, + ); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store); + const options = mockCreateFnAgent.mock.calls.at(-1)?.[0] as { mcpServers?: unknown[] }; + expect(options.mcpServers).toBe(mcpServers); + }); + + it("uses the retry scoped store when recreating an uninitialized onboarding helper agent", async () => { + const startStore = { getSettingsByScope: vi.fn() }; + const retryStore = { getSettingsByScope: vi.fn() }; + const retryMcpServers = [{ name: "planning-helper", command: "mcp-helper", env: { TOKEN: "retry-secret" } }]; + mockCreateFnAgent.mockResolvedValueOnce( + createMockAgent([ + JSON.stringify({ + type: "question", + data: { id: "goal", type: "text", question: "What is the primary goal?" }, + }), + ]), + ); + + const sessionId = await startAgentOnboardingSession( + "127.0.0.1", + { intent: "mcp retry", existingAgents: [], templates: [] }, + process.cwd(), + undefined, + undefined, + undefined, + undefined, + startStore as never, + ); + const session = getAgentOnboardingSession(sessionId) as { agent?: unknown; error?: string }; + session.agent = undefined; + session.error = "AI generation timed out. You can retry."; + mockResolveMcpServersForStore.mockClear(); + mockResolveMcpServersForStore.mockResolvedValueOnce({ servers: retryMcpServers, errors: [] }); + mockCreateFnAgent.mockResolvedValueOnce(createMockAgent([JSON.stringify({ type: "question", data: { id: "retry", type: "text", question: "Retry?" } })])); + + await retryAgentOnboardingSession(sessionId, retryStore as never); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(retryStore); + const options = mockCreateFnAgent.mock.calls.at(-1)?.[0] as { mcpServers?: unknown[] }; + expect(options.mcpServers).toBe(retryMcpServers); + }); + it("requests role-fallback skills when onboarding plugin runner is unavailable", async () => { mockCreateFnAgent.mockResolvedValueOnce( createMockAgent([ diff --git a/packages/dashboard/src/__tests__/ai-refine.test.ts b/packages/dashboard/src/__tests__/ai-refine.test.ts index 3b9e21b9f1..cae922c42f 100644 --- a/packages/dashboard/src/__tests__/ai-refine.test.ts +++ b/packages/dashboard/src/__tests__/ai-refine.test.ts @@ -19,22 +19,58 @@ import { } from "../ai-refine.js"; // Hoisted mock factory -const { mockCreateFnAgent } = vi.hoisted(() => ({ +const { mockCreateFnAgent, mockResolveMcpServersForStore } = vi.hoisted(() => ({ mockCreateFnAgent: vi.fn(), + mockResolveMcpServersForStore: vi.fn().mockResolvedValue({ servers: [], errors: [] }), })); // Mock the engine module to avoid dynamic import issues in tests vi.mock("@fusion/engine", () => ({ listCliAdapterDescriptors: () => [], createFnAgent: mockCreateFnAgent, + resolveMcpServersForStore: mockResolveMcpServersForStore, })); +const resolvedMcpServers = [ + { name: "docs", transport: "stdio", command: "node", args: ["server.js"], env: { MCP_TOKEN: "materialized-secret-value" } }, +]; + +function createMcpEnabledStore(): object { + return { + mcpEnabledForTest: true, + getSettingsByScope: vi.fn().mockResolvedValue({ global: { mcpServers: { enabled: true, servers: [{ name: "docs" }] } }, project: {} }), + getSecretsStore: vi.fn().mockResolvedValue({ revealSecret: vi.fn().mockResolvedValue("materialized-secret-value") }), + }; +} + +function createMcpDisabledStore(): object { + return { + getSettingsByScope: vi.fn().mockResolvedValue({ global: { mcpServers: { enabled: false, servers: [] } }, project: {} }), + getSecretsStore: vi.fn().mockResolvedValue({ revealSecret: vi.fn() }), + }; +} + +function mockAgentWithAssistantText(text: string): void { + mockCreateFnAgent.mockResolvedValue({ + session: { + state: { messages: [{ role: "assistant", content: text }] }, + prompt: vi.fn(), + dispose: vi.fn(), + }, + }); +} + describe("ai-refine module", () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); __resetRefineState(); vi.clearAllMocks(); mockCreateFnAgent.mockResolvedValue(null); + mockResolveMcpServersForStore.mockImplementation(async (store: { mcpEnabledForTest?: boolean }) => ( + store?.mcpEnabledForTest + ? { servers: resolvedMcpServers, errors: [] } + : { servers: [], errors: [] } + )); }); afterEach(() => { @@ -306,6 +342,35 @@ describe("ai-refine module", () => { "Failed to initialize AI agent" ); }); + + it("forwards resolved MCP servers when a store with enabled MCP is provided", async () => { + const store = createMcpEnabledStore(); + mockAgentWithAssistantText("Refined text"); + + await expect(refineText("some text", "clarify", "/tmp/project", undefined, store as never)).resolves.toBe("Refined text"); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: resolvedMcpServers })); + }); + + it("uses an empty MCP set when MCP is disabled for text refinement", async () => { + const store = createMcpDisabledStore(); + mockAgentWithAssistantText("Refined text"); + + await expect(refineText("some text", "clarify", "/tmp/project", undefined, store as never)).resolves.toBe("Refined text"); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); + }); + + it("uses an empty MCP set when text refinement has no store", async () => { + mockAgentWithAssistantText("Refined text"); + + await expect(refineText("some text", "clarify", "/tmp/project")).resolves.toBe("Refined text"); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith({}); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); + }); }); describe("draftGoalDescription", () => { @@ -350,6 +415,35 @@ describe("ai-refine module", () => { ); expect(mockAgent.session.dispose).toHaveBeenCalledTimes(1); }); + + it("forwards resolved MCP servers when a store with enabled MCP is provided", async () => { + const store = createMcpEnabledStore(); + mockCreateFnAgent.mockResolvedValueOnce(createDraftMockAgent("Drafted goal description")); + + await expect(draftGoalDescription("Grow plugin ecosystem", "/tmp/project", undefined, store as never)).resolves.toBe("Drafted goal description"); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: resolvedMcpServers })); + }); + + it("uses an empty MCP set when MCP is disabled for goal drafting", async () => { + const store = createMcpDisabledStore(); + mockCreateFnAgent.mockResolvedValueOnce(createDraftMockAgent("Drafted goal description")); + + await expect(draftGoalDescription("Grow plugin ecosystem", "/tmp/project", undefined, store as never)).resolves.toBe("Drafted goal description"); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); + }); + + it("uses an empty MCP set when goal drafting has no store", async () => { + mockCreateFnAgent.mockResolvedValueOnce(createDraftMockAgent("Drafted goal description")); + + await expect(draftGoalDescription("Grow plugin ecosystem", "/tmp/project")).resolves.toBe("Drafted goal description"); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith({}); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); + }); }); describe("__resetRefineState", () => { diff --git a/packages/dashboard/src/__tests__/mcp-helper-forwarding.test.ts b/packages/dashboard/src/__tests__/mcp-helper-forwarding.test.ts new file mode 100644 index 0000000000..e2c0bad036 --- /dev/null +++ b/packages/dashboard/src/__tests__/mcp-helper-forwarding.test.ts @@ -0,0 +1,258 @@ +import express from "express"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { InsightStore, TaskStore as TaskStoreClass, type Task, type TaskStore } from "@fusion/core"; +import * as coreModule from "@fusion/core"; +import { request } from "../test-request.js"; + +const { mockCreateFnAgent, mockPromptWithFallback, mockResolveMcpServersForStore, execMock } = vi.hoisted(() => ({ + mockCreateFnAgent: vi.fn(), + mockPromptWithFallback: vi.fn(), + mockResolveMcpServersForStore: vi.fn(), + execMock: vi.fn(), +})); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, exec: execMock }; +}); + +vi.mock("@fusion/engine", () => ({ + listCliAdapterDescriptors: () => [], + buildSessionSkillContextSync: () => ({ + skillSelectionContext: { projectRootDir: "/tmp/project", requestedSkillNames: ["fusion"], sessionPurpose: "executor" }, + resolvedSkillNames: ["fusion"], + skillSource: "role-fallback" as const, + }), + createFnAgent: mockCreateFnAgent, + promptWithFallback: mockPromptWithFallback, + resolveMcpServersForStore: mockResolveMcpServersForStore, +})); + +const resolvedMcpServers = [ + { name: "docs", transport: "stdio", command: "node", args: ["server.js"], env: { MCP_TOKEN: "materialized-secret-value" } }, +]; + +function createMcpEnabledStore(): object { + return { + mcpEnabledForTest: true, + getSettingsByScope: vi.fn().mockResolvedValue({ global: { mcpServers: { enabled: true, servers: [{ name: "docs" }] } }, project: {} }), + getSecretsStore: vi.fn().mockResolvedValue({ revealSecret: vi.fn().mockResolvedValue("materialized-secret-value") }), + }; +} + +function createMcpDisabledStore(): object { + return { + getSettingsByScope: vi.fn().mockResolvedValue({ global: { mcpServers: { enabled: false, servers: [] } }, project: {} }), + getSecretsStore: vi.fn().mockResolvedValue({ revealSecret: vi.fn() }), + }; +} + +function createTask(): Task { + return { + id: "FN-7078", + title: "Forward helper MCP", + description: "Wire MCP into helper sessions", + status: "todo", + column: "todo", + priority: "normal", + dependencies: [], + size: "M", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as Task; +} + +async function createInsightTaskStore(mode: boolean | "no-settings-scope"): Promise<{ root: string; store: TaskStore }> { + const root = mkdtempSync(join(tmpdir(), "kb-mcp-helper-forwarding-")); + const store = new TaskStoreClass(root, join(root, ".fusion-global-settings"), { inMemoryDb: true }) as TaskStore & { mcpEnabledForTest?: boolean; getSettingsByScope?: unknown }; + await store.init(); + if (mode === true) store.mcpEnabledForTest = true; + if (mode === "no-settings-scope") { + Object.defineProperty(store, "getSettingsByScope", { value: undefined, configurable: true }); + } + return { root, store }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockResolveMcpServersForStore.mockImplementation(async (store: { mcpEnabledForTest?: boolean }) => ( + store?.mcpEnabledForTest ? { servers: resolvedMcpServers, errors: [] } : { servers: [], errors: [] } + )); + mockPromptWithFallback.mockResolvedValue(undefined); + mockCreateFnAgent.mockImplementation(async (options?: { onText?: (delta: string) => void; systemPrompt?: string }) => { + const messages: Array<{ role: string; content: string }> = []; + return { + session: { + state: { messages }, + prompt: vi.fn(async () => { + if (options?.onText) { + options.onText(JSON.stringify({ + title: "feat: forward MCP", + summary: "Summary", + changes: "Changes", + testing: "Tests", + linkedTask: "FN-7078", + })); + } + messages.push({ + role: "assistant", + content: JSON.stringify({ + title: "Generated Agent", + icon: "🤖", + role: "custom", + description: "Generated description", + systemPrompt: "Generated prompt", + thinkingLevel: "low", + maxTurns: 12, + }), + }); + }), + dispose: vi.fn(), + }, + }; + }); + execMock.mockImplementation((command: string, options: unknown, callback?: (error: Error | null, stdout: string, stderr: string) => void) => { + const cb = typeof options === "function" ? options : callback; + cb?.(null, command.includes("rev-parse") ? "main\n" : "mock output\n", ""); + return { kill: vi.fn() }; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("MCP forwarding for readonly dashboard helper seams", () => { + it.each([ + ["enabled", createMcpEnabledStore, resolvedMcpServers], + ["disabled", createMcpDisabledStore, []], + ["absent", () => undefined, []], + ])("agent onboarding generation forwards MCP set for %s store", async (_label, storeFactory, expectedServers) => { + const { startAgentGeneration, generateAgentSpec } = await import("../agent-generation.js"); + const session = await startAgentGeneration(`127.0.0.${Math.floor(Math.random() * 200) + 1}`, "Create a docs helper"); + const store = storeFactory(); + + await generateAgentSpec(session.id, "/tmp/project", undefined, store as never); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store ?? {}); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ tools: "none", mcpServers: expectedServers })); + }); + + it.each([ + ["enabled", createMcpEnabledStore, resolvedMcpServers], + ["disabled", createMcpDisabledStore, []], + ["absent", () => undefined, []], + ])("agent onboarding interview start forwards MCP set for %s store", async (_label, storeFactory, expectedServers) => { + const { __resetAgentOnboardingState, startAgentOnboardingSession } = await import("../agent-onboarding.js"); + const store = storeFactory(); + + try { + await startAgentOnboardingSession( + `127.0.0.${Math.floor(Math.random() * 200) + 1}`, + { intent: "Create a docs helper", existingAgents: [], templates: [] }, + "/tmp/project", + undefined, + undefined, + undefined, + undefined, + store as never, + ); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store ?? {}); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ tools: "readonly", mcpServers: expectedServers })); + } finally { + __resetAgentOnboardingState(); + } + }); + + it("agent onboarding interview retry forwards scoped MCP set when recreating an uninitialized helper agent", async () => { + const { __resetAgentOnboardingState, getAgentOnboardingSession, retryAgentOnboardingSession, startAgentOnboardingSession } = await import("../agent-onboarding.js"); + const startStore = createMcpDisabledStore(); + const retryStore = createMcpEnabledStore(); + + try { + const sessionId = await startAgentOnboardingSession( + "127.0.0.77", + { intent: "Create a docs helper", existingAgents: [], templates: [] }, + "/tmp/project", + undefined, + undefined, + undefined, + undefined, + startStore as never, + ); + const session = getAgentOnboardingSession(sessionId) as { agent?: unknown; error?: string }; + session.agent = undefined; + session.error = "AI generation timed out. You can retry."; + mockResolveMcpServersForStore.mockClear(); + mockCreateFnAgent.mockClear(); + + await retryAgentOnboardingSession(sessionId, retryStore as never); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(retryStore); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ tools: "readonly", mcpServers: resolvedMcpServers })); + } finally { + __resetAgentOnboardingState(); + } + }); + + it.each([ + ["enabled", createMcpEnabledStore, resolvedMcpServers], + ["disabled", createMcpDisabledStore, []], + ["absent", () => undefined, []], + ])("PR metadata generation forwards MCP set for %s store", async (_label, storeFactory, expectedServers) => { + const { generatePrMetadata } = await import("../pr-metadata-generator.js"); + const store = storeFactory(); + + await generatePrMetadata({ + task: createTask(), + repoRoot: "/tmp/project", + settings: {}, + store: store as never, + timeoutMs: 5_000, + }); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store ?? {}); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ tools: "readonly", mcpServers: expectedServers })); + }); + + it.each([ + ["enabled", true, resolvedMcpServers], + ["disabled", false, []], + ["no settings-scope", "no-settings-scope", []], + ] as const)("insight extraction forwards MCP set for %s scoped store", async (_label, mode, expectedServers) => { + const { createInsightsRouter } = await import("../insights-routes.js"); + const readWorkingMemorySpy = vi.spyOn(coreModule, "readWorkingMemory").mockResolvedValue("working memory notes"); + vi.spyOn(coreModule, "readInsightsMemory").mockResolvedValue(null); + vi.spyOn(coreModule, "writeInsightsMemory").mockResolvedValue(undefined); + vi.spyOn(coreModule, "buildInsightExtractionPrompt").mockReturnValue("prompt"); + vi.spyOn(coreModule, "parseInsightExtractionResponse").mockReturnValue({ + summary: "Extraction summary", + insights: [], + extractedAt: "2026-06-26T00:00:00.000Z", + }); + vi.spyOn(coreModule, "mergeInsights").mockReturnValue("# merged insights"); + const { root, store } = await createInsightTaskStore(mode); + const app = express(); + app.use(express.json()); + const router = createInsightsRouter(store) as ReturnType & { __disposeSweeper?: () => void }; + app.use("/api/insights", router); + + try { + const res = await request(app, "POST", "/api/insights/run", JSON.stringify({ trigger: "manual" }), { "content-type": "application/json" }); + + expect(res.status).toBe(201); + expect(readWorkingMemorySpy).toHaveBeenCalledWith(root); + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ tools: "readonly", mcpServers: expectedServers })); + } finally { + router.__disposeSweeper?.(); + await store.close(); + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }); + } + }); +}); diff --git a/packages/dashboard/src/__tests__/pr-metadata-generator.test.ts b/packages/dashboard/src/__tests__/pr-metadata-generator.test.ts index b5b1d33e2f..04850297d7 100644 --- a/packages/dashboard/src/__tests__/pr-metadata-generator.test.ts +++ b/packages/dashboard/src/__tests__/pr-metadata-generator.test.ts @@ -20,6 +20,7 @@ vi.mock("node:child_process", async (importOriginal) => { vi.mock("@fusion/engine", () => ({ listCliAdapterDescriptors: () => [], + resolveMcpServersForStore: vi.fn().mockResolvedValue({ servers: [], errors: [] }), createFnAgent: vi.fn(async () => ({ session: { prompt: promptMock, diff --git a/packages/dashboard/src/__tests__/subtask-breakdown.test.ts b/packages/dashboard/src/__tests__/subtask-breakdown.test.ts index 181e50d3ce..750a1c3340 100644 --- a/packages/dashboard/src/__tests__/subtask-breakdown.test.ts +++ b/packages/dashboard/src/__tests__/subtask-breakdown.test.ts @@ -4,13 +4,15 @@ import { EventEmitter } from "node:events"; import ts from "typescript"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const { mockCreateFnAgent } = vi.hoisted(() => ({ +const { mockCreateFnAgent, mockResolveMcpServersForStore } = vi.hoisted(() => ({ mockCreateFnAgent: vi.fn(), + mockResolveMcpServersForStore: vi.fn().mockResolvedValue({ servers: [], errors: [] }), })); vi.mock("@fusion/engine", () => ({ listCliAdapterDescriptors: () => [], createFnAgent: mockCreateFnAgent, + resolveMcpServersForStore: mockResolveMcpServersForStore, })); import type { AiSessionRow } from "../ai-session-store.js"; @@ -26,6 +28,7 @@ import { cancelSubtaskSession, cleanupSubtaskSession, createSubtaskSession, + decomposeForTriage, retrySubtaskSession, getSubtaskSession, rehydrateFromStore, @@ -240,9 +243,40 @@ beforeAll(async () => { internalFns = await loadInternalSubtaskFunctions(); }); +const resolvedMcpServers = [ + { name: "docs", transport: "stdio", command: "node", args: ["server.js"], env: { MCP_TOKEN: "materialized-secret-value" } }, +]; + +function createMcpEnabledStore(): object { + return { + mcpEnabledForTest: true, + getSettingsByScope: vi.fn().mockResolvedValue({ + global: { mcpServers: { enabled: true, servers: [{ name: "docs" }] } }, + project: {}, + }), + getSecretsStore: vi.fn().mockResolvedValue({ revealSecret: vi.fn().mockResolvedValue("materialized-secret-value") }), + }; +} + +function createMcpDisabledStore(): object { + return { + getSettingsByScope: vi.fn().mockResolvedValue({ + global: { mcpServers: { enabled: false, servers: [] } }, + project: {}, + }), + getSecretsStore: vi.fn().mockResolvedValue({ revealSecret: vi.fn() }), + }; +} + beforeEach(() => { mockCreateFnAgent.mockReset(); mockCreateFnAgent.mockImplementation(async () => createMockSubtaskAgent()); + mockResolveMcpServersForStore.mockReset(); + mockResolveMcpServersForStore.mockImplementation(async (store: { mcpEnabledForTest?: boolean }) => ( + store?.mcpEnabledForTest + ? { servers: resolvedMcpServers, errors: [] } + : { servers: [], errors: [] } + )); }); afterEach(() => { @@ -625,6 +659,69 @@ describe("subtask session lifecycle", () => { expect(callArg?.systemPrompt).toContain("task decomposition assistant"); }); + it("forwards resolved MCP servers for streaming subtask generation", async () => { + const store = createMcpEnabledStore(); + const created = await createSubtaskSession("Break down MCP-aware work", store as never, "/tmp/project"); + + await vi.waitFor(() => { + const session = getSubtaskSession(created.sessionId); + return session?.status === "complete"; + }, { timeout: 5000 }); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: resolvedMcpServers })); + }); + + it("keeps streaming subtask generation MCP-empty when MCP is disabled", async () => { + const store = createMcpDisabledStore(); + const created = await createSubtaskSession("Break down disabled MCP work", store as never, "/tmp/project"); + + await vi.waitFor(() => { + const session = getSubtaskSession(created.sessionId); + return session?.status === "complete"; + }, { timeout: 5000 }); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); + }); + + it("keeps streaming subtask generation MCP-empty for no-store callers", async () => { + const created = await createSubtaskSession("Break down no-store work", undefined, "/tmp/project"); + + await vi.waitFor(() => { + const session = getSubtaskSession(created.sessionId); + return session?.status === "complete"; + }, { timeout: 5000 }); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith({}); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); + }); + + it("forwards resolved MCP servers for triage one-shot subtask decomposition", async () => { + const store = createMcpEnabledStore(); + + await decomposeForTriage("Break down triage work", "/tmp/project", undefined, store as never); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: resolvedMcpServers })); + }); + + it("keeps triage one-shot subtask decomposition MCP-empty when MCP is disabled", async () => { + const store = createMcpDisabledStore(); + + await decomposeForTriage("Break down triage work", "/tmp/project", undefined, store as never); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); + }); + + it("keeps triage one-shot subtask decomposition MCP-empty without a store", async () => { + await decomposeForTriage("Break down triage work", "/tmp/project"); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith({}); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); + }); + it("retrySubtaskSession retries errored sessions restored from SQLite", async () => { const store = new MockAiSessionStore(); const row = buildSubtaskRow({ @@ -646,6 +743,44 @@ describe("subtask session lifecycle", () => { expect(store.get(row.id)?.error).toBeNull(); }); + it("forwards resolved MCP servers for retry subtask generation", async () => { + const sessionStore = new MockAiSessionStore(); + const row = buildSubtaskRow({ id: "subtask-retry-mcp", status: "error", error: "Transient failure", result: null }); + sessionStore.rows.set(row.id, row); + setAiSessionStore(sessionStore as any); + const store = createMcpEnabledStore(); + + await retrySubtaskSession(row.id, "/tmp/project", undefined, store as never); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: resolvedMcpServers })); + }); + + it("keeps retry subtask generation MCP-empty when MCP is disabled", async () => { + const sessionStore = new MockAiSessionStore(); + const row = buildSubtaskRow({ id: "subtask-retry-disabled-mcp", status: "error", error: "Transient failure", result: null }); + sessionStore.rows.set(row.id, row); + setAiSessionStore(sessionStore as any); + const store = createMcpDisabledStore(); + + await retrySubtaskSession(row.id, "/tmp/project", undefined, store as never); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith(store); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); + }); + + it("keeps retry subtask generation MCP-empty without a store", async () => { + const sessionStore = new MockAiSessionStore(); + const row = buildSubtaskRow({ id: "subtask-retry-no-store-mcp", status: "error", error: "Transient failure", result: null }); + sessionStore.rows.set(row.id, row); + setAiSessionStore(sessionStore as any); + + await retrySubtaskSession(row.id, "/tmp/project"); + + expect(mockResolveMcpServersForStore).toHaveBeenCalledWith({}); + expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); + }); + it("retrySubtaskSession rejects non-error sessions", async () => { const store = new MockAiSessionStore(); const row = buildSubtaskRow({ id: "subtask-retry-2", status: "generating" }); diff --git a/packages/dashboard/src/agent-generation.ts b/packages/dashboard/src/agent-generation.ts index 59c9987e27..68ee31a4c9 100644 --- a/packages/dashboard/src/agent-generation.ts +++ b/packages/dashboard/src/agent-generation.ts @@ -13,6 +13,7 @@ */ import { randomUUID } from "node:crypto"; +import type { TaskStore } from "@fusion/core"; import { createSessionDiagnostics, nonfatal } from "./ai-session-diagnostics.js"; import { registerBeforeExitCleanup } from "./process-lifecycle.js"; @@ -41,7 +42,7 @@ async function initPromptCatalog() { // Initialize prompt catalog (will be awaited in actual usage) const promptCatalogReadyPromise = initPromptCatalog(); -import { createFnAgent as engineCreateFnAgent } from "@fusion/engine"; +import { createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const createFnAgent: any = engineCreateFnAgent; @@ -459,7 +460,8 @@ export async function startAgentGeneration( export async function generateAgentSpec( sessionId: string, rootDir: string, - promptOverrides?: PromptOverrideMap + promptOverrides?: PromptOverrideMap, + store?: TaskStore, ): Promise { const session = sessions.get(sessionId); if (!session) { @@ -469,7 +471,7 @@ export async function generateAgentSpec( try { await ensureEngineReady(); await promptCatalogReadyPromise; - const spec = await generateSpecWithAI(session, rootDir, promptOverrides); + const spec = await generateSpecWithAI(session, rootDir, promptOverrides, store); session.spec = spec; session.updatedAt = new Date(); return spec; @@ -488,7 +490,8 @@ export async function generateAgentSpec( async function generateSpecWithAI( session: Session, rootDir: string, - promptOverrides?: PromptOverrideMap + promptOverrides?: PromptOverrideMap, + store?: TaskStore, ): Promise { if (!createFnAgent) { throw new Error("AI agent not available. Ensure the engine is properly configured."); @@ -497,14 +500,16 @@ async function generateSpecWithAI( // Resolve the system prompt using prompt overrides (with fallback to default) const effectiveSystemPrompt = resolvePrompt("agent-generation-system", promptOverrides) || AGENT_GENERATION_SYSTEM_PROMPT; + const mcpServers = (await resolveMcpServersForStore(store ?? {})).servers; /* - * FNXC:McpConfig 2026-06-26-00:00: - * Agent generation is a tools:none dashboard helper built from an in-memory session and rootDir only. No TaskStore/secrets reader is available here, so configured MCP servers are intentionally not injected. + * FNXC:McpConfig 2026-06-26-16:58: + * Agent onboarding generation is a tools:none readonly helper, but routes can provide a dashboard-scoped TaskStore. Forward the resolved in-memory MCP server set consistently without changing tool semantics; no-store callers remain empty and secrets are never logged. */ const agent = await createFnAgent({ cwd: rootDir, systemPrompt: effectiveSystemPrompt, tools: "none", + mcpServers, }); try { diff --git a/packages/dashboard/src/agent-onboarding.ts b/packages/dashboard/src/agent-onboarding.ts index c110b9364c..74f77f9f4b 100644 --- a/packages/dashboard/src/agent-onboarding.ts +++ b/packages/dashboard/src/agent-onboarding.ts @@ -1,8 +1,8 @@ import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; -import type { AgentCapability, PlanningQuestion } from "@fusion/core"; +import type { AgentCapability, PlanningQuestion, TaskStore } from "@fusion/core"; import { resolvePrompt, type PromptOverrideMap } from "@fusion/core"; -import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent } from "@fusion/engine"; +import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine"; import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js"; export interface AgentOnboardingSummary { @@ -98,6 +98,11 @@ interface Session { history: Array<{ question: PlanningQuestion; response: Record }>; thinkingOutput: string; agent?: OnboardingAgent; + rootDir: string; + modelProvider?: string; + modelId?: string; + promptOverrides?: PromptOverrideMap; + pluginRunner?: SkillSelectionPluginRunner; createdAt: Date; updatedAt: Date; } @@ -283,6 +288,7 @@ export async function startAgentOnboardingSession( modelId?: string, promptOverrides?: PromptOverrideMap, pluginRunner?: SkillSelectionPluginRunner, + store?: TaskStore, ): Promise { const id = randomUUID(); const mode: OnboardingMode = initialContext.mode ?? "create"; @@ -299,18 +305,39 @@ export async function startAgentOnboardingSession( }), history: [], thinkingOutput: "", + rootDir, + modelProvider, + modelId, + promptOverrides, + pluginRunner, createdAt: new Date(), updatedAt: new Date(), }; sessions.set(id, session); - const systemPrompt = resolvePrompt("agent-onboarding-system", promptOverrides) || AGENT_ONBOARDING_SYSTEM_PROMPT; - const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, pluginRunner); - session.agent = await createFnAgent({ - cwd: rootDir, + session.agent = await createAgentOnboardingAgent(session, store); + + void continueConversation(session, session.contextPrompt); + return id; +} + +async function createAgentOnboardingAgent(session: Session, store?: TaskStore): Promise { + const systemPrompt = resolvePrompt("agent-onboarding-system", session.promptOverrides) || AGENT_ONBOARDING_SYSTEM_PROMPT; + const skillContext = buildSessionSkillContextSync(null, "executor", session.rootDir, session.pluginRunner); + const mcpServers = (await resolveMcpServersForStore(store ?? {})).servers; + /* + * FNXC:McpConfig 2026-06-26-17:26: + * Agent onboarding interviews are dashboard readonly planning helpers. Resolve MCP from the request-scoped TaskStore when routes can provide it; no-store callers stay empty and this seam must not log secret material. + * + * FNXC:McpConfig 2026-06-26-18:10: + * Retry can recover a session whose agent was not initialized, so the retry route must pass its scoped TaskStore into this same createFnAgent seam instead of continuing with secret-less defaults. + */ + return createFnAgent({ + cwd: session.rootDir, systemPrompt, tools: "readonly", - ...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}), + mcpServers, + ...(session.modelProvider && session.modelId ? { defaultProvider: session.modelProvider, defaultModelId: session.modelId } : {}), /* FNXC:InterviewSkills 2026-06-17-21:53: Agent onboarding is a model-only dashboard interview lane, so it must request executor role-fallback skills plus enabled plugin skills such as ce-debug like other agent-acting sessions. @@ -325,9 +352,6 @@ export async function startAgentOnboardingSession( agentOnboardingStreamManager.broadcast(session.id, { type: "thinking", data: delta }); }, }); - - void continueConversation(session, session.contextPrompt); - return id; } async function runGenerationWithTimeout(session: Session, operation: () => Promise): Promise { @@ -392,11 +416,14 @@ export async function respondToAgentOnboarding(sessionId: string, responses: Rec await continueConversation(session, formatted); } -export async function retryAgentOnboardingSession(sessionId: string): Promise { +export async function retryAgentOnboardingSession(sessionId: string, store?: TaskStore): Promise { const session = sessions.get(sessionId); if (!session) throw new SessionNotFoundError(`Agent onboarding session ${sessionId} not found or expired`); if (!session.error) throw new InvalidSessionStateError("Session is not in an error state"); session.error = undefined; + if (!session.agent) { + session.agent = await createAgentOnboardingAgent(session, store); + } const retryPrompt = session.currentQuestion ? `Please continue from the last question: ${session.currentQuestion.question}` : "Please continue and ask the next best onboarding question."; diff --git a/packages/dashboard/src/ai-refine.ts b/packages/dashboard/src/ai-refine.ts index 4cc0175b1e..071bd9c211 100644 --- a/packages/dashboard/src/ai-refine.ts +++ b/packages/dashboard/src/ai-refine.ts @@ -11,10 +11,10 @@ * - Prompt override support for project-level customization */ -import type { PromptOverrideMap } from "@fusion/core"; +import type { PromptOverrideMap, TaskStore } from "@fusion/core"; import { resolvePrompt } from "@fusion/core"; -import { createFnAgent as engineCreateFnAgent } from "@fusion/engine"; +import { createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine"; import { registerBeforeExitCleanup } from "./process-lifecycle.js"; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -322,6 +322,7 @@ export async function refineText( type: RefinementType, rootDir: string, promptOverrides?: PromptOverrideMap, + store?: TaskStore, ): Promise { // Ensure engine is loaded before using createFnAgent await ensureEngineReady(); @@ -332,14 +333,16 @@ export async function refineText( const effectivePrompt = resolvePrompt("ai-refine-system", promptOverrides); + const mcpServers = (await resolveMcpServersForStore(store ?? {})).servers; /* - * FNXC:McpConfig 2026-06-26-00:00: - * Text refinement receives only rootDir and prompt overrides, not a TaskStore/secrets reader. This readonly helper intentionally skips configured MCP resolution until a store is threaded through the API. + * FNXC:McpConfig 2026-06-26-16:55: + * Text refinement is a readonly dashboard helper that receives the request-scoped TaskStore from routes. Resolve configured MCP servers at session creation and forward only the in-memory server set; keep no-store callers on an empty set and never log materialized secrets. */ const agentResult = await createFnAgent({ cwd: rootDir, systemPrompt: effectivePrompt, tools: "readonly", + mcpServers, }); if (!agentResult?.session) { @@ -395,6 +398,7 @@ export async function draftGoalDescription( title: string, rootDir: string, _promptOverrides?: PromptOverrideMap, + store?: TaskStore, ): Promise { await ensureEngineReady(); @@ -402,14 +406,16 @@ export async function draftGoalDescription( throw new AiServiceError("AI engine not available"); } + const mcpServers = (await resolveMcpServersForStore(store ?? {})).servers; /* - * FNXC:McpConfig 2026-06-26-00:00: - * Goal description drafting shares the text-refine no-store seam; without TaskStore/secrets access it intentionally creates a readonly non-MCP session. + * FNXC:McpConfig 2026-06-26-16:55: + * Goal description drafting shares the text-refine readonly helper seam and now resolves MCP from the dashboard-scoped TaskStore when routes can provide it. No-store callers intentionally receive an empty server set; do not log env/header secret values. */ const agentResult = await createFnAgent({ cwd: rootDir, systemPrompt: GOAL_DRAFT_SYSTEM_PROMPT, tools: "readonly", + mcpServers, }); if (!agentResult?.session) { diff --git a/packages/dashboard/src/insights-routes.ts b/packages/dashboard/src/insights-routes.ts index 6462ed7b3a..0050d3316e 100644 --- a/packages/dashboard/src/insights-routes.ts +++ b/packages/dashboard/src/insights-routes.ts @@ -41,7 +41,7 @@ import { startInsightRunSweeper, sweepStaleInsightRuns, } from "./insight-run-sweeper.js"; -import { createFnAgent, promptWithFallback } from "@fusion/engine"; +import { createFnAgent, promptWithFallback, resolveMcpServersForStore } from "@fusion/engine"; /** * Re-throws an error as an ApiError, converting unknown errors to internal errors. @@ -129,6 +129,7 @@ async function executeInsightAttempt(params: { runId: string; signal: AbortSignal; insightStore: InsightStore; + taskStore?: TaskStore; settings: Settings; modelProvider?: string; modelId?: string; @@ -158,9 +159,15 @@ async function executeInsightAttempt(params: { const fallbackModelId = hasCustomModel ? settingsModelId : undefined; const existingInsights = await readInsightsMemory(params.rootDir); + const mcpServers = (await resolveMcpServersForStore(params.taskStore ?? {})).servers; let responseText = ""; const { session } = await createFnAgent({ cwd: params.rootDir, + /* + * FNXC:McpConfig 2026-06-26-16:58: + * Insight extraction runs as a readonly dashboard helper under AsyncLocalStorage request scope. Resolve configured MCP servers from that scoped TaskStore at session creation; lightweight/no-store attempts get an empty set and diagnostics must never include materialized secret values. + */ + mcpServers, defaultProvider: finalProvider, defaultModelId: finalModelId, fallbackProvider, @@ -279,23 +286,22 @@ export function createInsightsRouter(store: TaskStore): Router { * Uses projectId from query/body to get the scoped store if provided, * otherwise falls back to the default store. */ - router.use((req: Request, res: Response, next: NextFunction) => { - const projectId = getProjectId(req); - if (projectId) { - // Import here to avoid circular dependency issues - import("./project-store-resolver.js").then(({ getOrCreateProjectStore }) => { - getOrCreateProjectStore(projectId).then((scopedStore) => { - requestContext.run(scopedStore, () => { - next(); - }); - }).catch((err) => { - rethrowAsApiError(err, "Failed to get project store"); - }); - }); - } else { - requestContext.run(store, () => { + router.use(async (req: Request, res: Response, next: NextFunction) => { + try { + const projectId = getProjectId(req); + const scopedStore = projectId + // Import here to avoid circular dependency issues + ? await import("./project-store-resolver.js").then(({ getOrCreateProjectStore }) => getOrCreateProjectStore(projectId)) + : store; + requestContext.run(scopedStore, () => { next(); }); + } catch (err) { + try { + rethrowAsApiError(err, "Failed to get project store"); + } catch (apiError) { + next(apiError); + } } }); @@ -424,6 +430,7 @@ export function createInsightsRouter(store: TaskStore): Router { runId: run.id, signal, insightStore, + taskStore, settings, modelProvider, modelId, @@ -629,6 +636,7 @@ export function createInsightsRouter(store: TaskStore): Router { runId: run.id, signal, insightStore: store, + taskStore, settings, modelProvider: retryModelProvider, modelId: retryModelId, diff --git a/packages/dashboard/src/pr-metadata-generator.ts b/packages/dashboard/src/pr-metadata-generator.ts index 978872b52d..97e2fa62dc 100644 --- a/packages/dashboard/src/pr-metadata-generator.ts +++ b/packages/dashboard/src/pr-metadata-generator.ts @@ -2,9 +2,9 @@ import { promisify } from "node:util"; import { exec as execCb } from "node:child_process"; import { access, readFile } from "node:fs/promises"; import { join } from "node:path"; -import type { GlobalSettings, ProjectSettings, Settings, Task } from "@fusion/core"; +import type { GlobalSettings, ProjectSettings, Settings, Task, TaskStore } from "@fusion/core"; import { resolveTitleSummarizerSettingsModel } from "@fusion/core"; -import { createFnAgent } from "@fusion/engine"; +import { createFnAgent, resolveMcpServersForStore } from "@fusion/engine"; const execAsync = promisify(execCb); export const PR_METADATA_TIMEOUT_MS = 60_000; @@ -224,10 +224,11 @@ export async function generatePrMetadata(input: { task: Task; repoRoot: string; settings: ProjectSettings & GlobalSettings; + store?: TaskStore; signal?: AbortSignal; timeoutMs?: number; }): Promise { - const { task, repoRoot, settings, signal, timeoutMs = PR_METADATA_TIMEOUT_MS } = input; + const { task, repoRoot, settings, store, signal, timeoutMs = PR_METADATA_TIMEOUT_MS } = input; const fallback = buildFallbackPrMetadata(task); const controller = new AbortController(); const abortFromCaller = () => controller.abort(signal?.reason instanceof Error ? signal.reason : createAbortError()); @@ -269,10 +270,16 @@ export async function generatePrMetadata(input: { const template = templateExists ? await raceWithAbort(readFile(templatePath, "utf8"), combinedSignal) : ""; const model = resolveTitleSummarizerSettingsModel(settings as Partial); + const mcpServers = (await raceWithAbort(resolveMcpServersForStore(store ?? {}), combinedSignal)).servers; let aiText = ""; const { session } = await raceWithAbort(createFnAgent({ cwd: repoRoot, tools: "readonly", + /* + * FNXC:McpConfig 2026-06-26-16:58: + * PR metadata generation is a readonly dashboard helper with a strict modal timeout. Resolve MCP through the request-scoped TaskStore inside the abort budget and forward only the materialized in-memory server set; no-store fallbacks stay empty and secret values must not be logged. + */ + mcpServers, defaultProvider: model.provider, defaultModelId: model.modelId, systemPrompt: [ diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 68eb8e875a..3f800c4152 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -1946,6 +1946,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout validated.type, rootDir, settings.promptOverrides, + scopedStore, ); res.json({ refined }); } catch (err: unknown) { @@ -2005,7 +2006,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout throw err; } - const description = await draftGoalDescription(validatedTitle, rootDir, settings.promptOverrides); + const description = await draftGoalDescription(validatedTitle, rootDir, settings.promptOverrides, scopedStore); res.json({ description }); } catch (err: unknown) { if (err instanceof ApiError) { diff --git a/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts b/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts index c8cbed0b2f..94feb6e6de 100644 --- a/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts +++ b/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts @@ -923,6 +923,7 @@ export function registerAgentGenerationRoutes(ctx: ApiRoutesContext): void { planningModelId, settings.promptOverrides, options?.pluginRunner as Parameters[3], + scopedStore, ); res.status(201).json({ sessionId }); @@ -1014,8 +1015,9 @@ export function registerAgentGenerationRoutes(ctx: ApiRoutesContext): void { router.post("/agents/onboarding/:sessionId/retry", async (req, res) => { try { const { sessionId } = req.params; + const { store: scopedStore } = await getProjectContext(req); const { retryAgentOnboardingSession } = await import("../agent-onboarding.js"); - await retryAgentOnboardingSession(sessionId); + await retryAgentOnboardingSession(sessionId, scopedStore); res.json({ success: true, sessionId }); } catch (err: unknown) { if (err instanceof ApiError) throw err; @@ -1111,7 +1113,7 @@ export function registerAgentGenerationRoutes(ctx: ApiRoutesContext): void { const rootDir = scopedStore.getRootDir(); const settings = await scopedStore.getSettings(); - const spec = await generateAgentSpec(sessionId, rootDir, settings.promptOverrides); + const spec = await generateAgentSpec(sessionId, rootDir, settings.promptOverrides, scopedStore); res.json({ spec }); } catch (err: unknown) { if (err instanceof ApiError) { diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 479826ae9d..bb332f7e76 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -5132,6 +5132,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { task, repoRoot: scopedStore.getRootDir(), settings, + store: scopedStore, signal: controller.signal, timeoutMs: PR_METADATA_ROUTE_TIMEOUT_MS, }); diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index 23144cb7a0..fba7d7fe1f 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -459,7 +459,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann const { store: scopedStore } = await getProjectContext(req); const settings = await scopedStore.getSettings(); const { retrySubtaskSession } = await import("../subtask-breakdown.js"); - await retrySubtaskSession(sessionId, scopedStore.getRootDir(), settings.promptOverrides); + await retrySubtaskSession(sessionId, scopedStore.getRootDir(), settings.promptOverrides, scopedStore); res.json({ success: true, sessionId }); } catch (err: unknown) { if (err instanceof ApiError) { diff --git a/packages/dashboard/src/subtask-breakdown.ts b/packages/dashboard/src/subtask-breakdown.ts index 7d948ac178..60172c87f0 100644 --- a/packages/dashboard/src/subtask-breakdown.ts +++ b/packages/dashboard/src/subtask-breakdown.ts @@ -11,7 +11,7 @@ import { } from "./ai-session-diagnostics.js"; import { GenerationGuard, createAbortError, isAbortError } from "./ai-session-timeout.js"; -import { createFnAgent as engineCreateFnAgent } from "@fusion/engine"; +import { createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const createFnAgent: any = engineCreateFnAgent; @@ -398,6 +398,7 @@ export async function decomposeForTriage( description: string, rootDir?: string, promptOverrides?: PromptOverrideMap, + store?: TaskStore, ): Promise { await ensureEngineReady(); const cwd = rootDir ?? process.cwd(); @@ -407,11 +408,12 @@ export async function decomposeForTriage( return generateFallbackSubtasks(description); } + const mcpServers = (await resolveMcpServersForStore(store ?? {})).servers; /* - * FNXC:McpConfig 2026-06-26-00:00: - * Triage subtask decomposition receives only description/rootDir/prompt overrides. With no TaskStore or secrets reader at this one-shot seam, configured MCP servers are intentionally skipped. + * FNXC:McpConfig 2026-06-26-16:45: + * Triage subtask decomposition is a readonly planning helper; when the dashboard triage hook provides a scoped store, resolve MCP at session creation and forward only the in-memory server set. Keep no-store callers on an empty MCP set and never log materialized secrets. */ - const agent: SubtaskAgent = await createFnAgent({ cwd, systemPrompt, tools: "readonly" }); + const agent: SubtaskAgent = await createFnAgent({ cwd, systemPrompt, tools: "readonly", mcpServers }); try { await agent.session.prompt(description); const messages = agent.session.state.messages as Array<{ @@ -440,7 +442,7 @@ export async function decomposeForTriage( export async function createSubtaskSession( initialDescription: string, - _store?: TaskStore, + store?: TaskStore, rootDir?: string, promptOverrides?: PromptOverrideMap, projectId?: string, @@ -460,7 +462,7 @@ export async function createSubtaskSession( persistSubtaskSession(session, "generating"); const cwd = rootDir ?? process.cwd(); - void startSubtaskGeneration(sessionId, cwd, promptOverrides); + void startSubtaskGeneration(sessionId, cwd, promptOverrides, store); return { sessionId, @@ -475,9 +477,10 @@ async function startSubtaskGeneration( sessionId: string, cwd: string, promptOverrides?: PromptOverrideMap, + store?: TaskStore, ): Promise { try { - await generateSubtasks(sessionId, cwd, promptOverrides); + await generateSubtasks(sessionId, cwd, promptOverrides, store); } catch (err) { // Timeout / user-stop already published an error state via the guard // handlers. Don't overwrite it with a generic AbortError message. @@ -498,6 +501,7 @@ async function generateSubtasks( sessionId: string, cwd: string, promptOverrides?: PromptOverrideMap, + store?: TaskStore, ): Promise { const session = sessions.get(sessionId); if (!session) throw new SessionNotFoundError(`Subtask session ${sessionId} not found`); @@ -532,14 +536,16 @@ async function generateSubtasks( FNXC:SubtaskBreakdown 2026-06-16-20:15: FN-6511 requires the full subtask generation lifecycle to be timeout-bounded, including createFnAgent construction before prompt() starts. Keep construction and prompt inside one GenerationGuard entry so a model-registry or extension-discovery stall cannot pin the SSE session in generating forever. */ + const mcpServers = (await resolveMcpServersForStore(store ?? {})).servers; /* - FNXC:McpConfig 2026-06-26-00:00: - Streaming subtask generation currently does not thread the optional TaskStore into the async generation worker, so this readonly planning surface intentionally skips MCP until that larger session-state refactor is performed. + FNXC:McpConfig 2026-06-26-16:45: + Streaming subtask generation is a readonly planning helper that now carries the dashboard-scoped TaskStore into the timeout-bounded worker. Resolve MCP inside the GenerationGuard window and forward only counts/errors if diagnostics are added; never expose plaintext env/header secrets. */ const agentPromise = createFnAgent({ cwd, systemPrompt, tools: "readonly", + mcpServers, onThinking: (delta: string) => { const current = sessions.get(sessionId); if (!current) return; @@ -700,6 +706,7 @@ export async function retrySubtaskSession( sessionId: string, rootDir: string, promptOverrides?: PromptOverrideMap, + store?: TaskStore, ): Promise { const visibleSession = getSubtaskSession(sessionId); if (!visibleSession) { @@ -730,7 +737,7 @@ export async function retrySubtaskSession( session.updatedAt = new Date(); persistSubtaskSession(session, "generating"); - await startSubtaskGeneration(sessionId, rootDir, promptOverrides); + await startSubtaskGeneration(sessionId, rootDir, promptOverrides, store); } export function getSubtaskSession(sessionId: string): SubtaskSession | undefined { diff --git a/packages/dashboard/src/triage-trait.ts b/packages/dashboard/src/triage-trait.ts index c8d1bf8735..87a2aa961b 100644 --- a/packages/dashboard/src/triage-trait.ts +++ b/packages/dashboard/src/triage-trait.ts @@ -349,7 +349,7 @@ export async function runTriageOnEnter(task: Task, deps: TriageDeps): Promise decomposeForTriage(d, deps.rootDir, deps.promptOverrides)); + const decompose = deps.decompose ?? ((d: string) => decomposeForTriage(d, deps.rootDir, deps.promptOverrides, deps.store)); subtasks = await decompose(task.description); } catch (err) { return parkInTriage(store, task, err, "decompose");