diff --git a/.changeset/fn-7077-mcp-all-surfaces.md b/.changeset/fn-7077-mcp-all-surfaces.md new file mode 100644 index 0000000000..d40337b82f --- /dev/null +++ b/.changeset/fn-7077-mcp-all-surfaces.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Configured MCP servers now reach every agent surface, including heartbeat runs. +category: feature +dev: Heartbeat and other un-wired session seams now resolve MCP via resolveMcpServersForStore; see FN-7077 audit. diff --git a/docs/mcp.md b/docs/mcp.md index 19f4a6bb41..52ffba3cc9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -58,6 +58,9 @@ 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. + 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. ## Validation and reachability diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 28fa77313d..c6e4c89e17 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -160,7 +160,7 @@ Each server is named and uses one transport: Resolution uses project-over-global precedence by server name. The project-level `enabled` flag overrides the global flag when set; if the effective flag is false, no MCP servers are active. When enabled, global servers are loaded first, project servers with the same `name` replace them, and a project server with `enabled:false` removes the inherited server. -Enabled MCP servers are trusted once configured. Fusion materializes the effective server set at AI session creation and forwards it to every MCP-capable AI lane, including chat/planning, executor, reviewer, validator, merger, workflow model nodes, summarization, evaluator, research, cron/automation, mission, and reflection paths. Runtime support is guarded: Claude/pi/ACP-compatible runtimes receive MCP servers, while mock or unsupported runtimes skip forwarding and emit only a structured count/provider/runtime log entry, never server definitions or secret values. +Enabled MCP servers are trusted once configured. Fusion materializes the effective server set at AI session creation and forwards it to every MCP-capable AI lane, including chat/planning, executor/tasks, heartbeat, reviewer, validator, merger, PR-response/PR-conflict merger helpers, workflow model nodes, summarization, evaluator, manual AI-prompt workflow steps, cron/automation, mission execution/interviews, milestone and slice interviews, and reflection paths. Readonly helper seams without a TaskStore/secrets reader (for example current research provider helpers, text refinement, and core memory compaction) carry FNXC skip comments until a store seam is threaded. Runtime support is guarded: Claude/pi/ACP-compatible runtimes receive MCP servers, while mock or unsupported runtimes skip forwarding and emit only a structured count/provider/runtime log entry, never server definitions or secret values. Secret rule: `env` and `headers` maps are sensitive. Values must be Fusion secret references such as `{ "secretRef": "sec_...", "scope": "project" }` or `{ "secretRef": "sec_...", "scope": "global" }`. Write-boundary sanitizers and validators reject plaintext strings in these fields. Claude Desktop-style imports return `secretsToCreate` descriptors for plaintext env/header values and replace those values with secret refs in the imported definitions. At runtime, secret references are revealed through the scoped secrets store immediately before forwarding or validation, kept only in memory, and never echoed in API responses. diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index d8c7d8390b..7de0d678eb 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -1461,10 +1461,9 @@ describe("runServe — Semaphore boundary (task lanes only)", () => { await runServe(4040, {}); expect(createAiPromptExecutor).toHaveBeenCalledTimes(1); - // createAiPromptExecutor takes only cwd parameter + // In-process runtime intentionally calls createAiPromptExecutor with cwd only; no TaskStore is available at this seam. expect(createAiPromptExecutor).toHaveBeenCalledWith(expect.any(String)); const calledWith = createAiPromptExecutor.mock.calls[0]; - // Should be called with exactly one argument (cwd) expect(calledWith.length).toBe(1); await triggerSignal("SIGINT"); diff --git a/packages/core/src/memory-compaction.ts b/packages/core/src/memory-compaction.ts index 3e31858837..cbd1711616 100644 --- a/packages/core/src/memory-compaction.ts +++ b/packages/core/src/memory-compaction.ts @@ -104,6 +104,10 @@ export async function compactMemoryWithAi( } if (DEBUG) console.log("[memory-compaction] Creating agent session..."); + /* + * FNXC:McpConfig 2026-06-26-00:00: + * Memory compaction lives in @fusion/core and cannot import engine MCP resolution without introducing a core→engine cycle. This readonly session intentionally runs without configured MCP servers until a neutral store/secrets seam exists in core. + */ const agentResult = await createFnAgent(agentOptions); if (!agentResult?.session) { diff --git a/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts b/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts index f97fcd49e0..a5c377eae1 100644 --- a/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts +++ b/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts @@ -57,6 +57,9 @@ vi.mock("../planning-board-tools.js", () => ({ })); import { __resetPlanningState, createSession } from "../planning.js"; +import { resolveManualAiPromptMcpServers } from "../routes.js"; +import { createMissionInterviewAgent } from "../mission-interview.js"; +import { createTargetInterviewAgent } from "../milestone-slice-interview.js"; describe("dashboard MCP lane forwarding", () => { beforeEach(() => { @@ -76,4 +79,63 @@ describe("dashboard MCP lane forwarding", () => { mcpServers: [expect.objectContaining({ name: "docs", env: { TOKEN: "materialized-secret" } })], })); }); + + it("resolves materialized MCP servers for manual AI-prompt workflow steps", async () => { + const store = {} as never; + + await expect(resolveManualAiPromptMcpServers(store)).resolves.toEqual([ + expect.objectContaining({ name: "docs", env: { TOKEN: "materialized-secret" } }), + ]); + expect(resolveMcpServersForStoreMock).toHaveBeenCalledWith(store); + }); + + it("preserves empty MCP results for manual AI-prompt workflow steps", async () => { + resolveMcpServersForStoreMock.mockResolvedValueOnce({ servers: [], errors: [] }); + + await expect(resolveManualAiPromptMcpServers({} as never)).resolves.toEqual([]); + }); + + it("forwards materialized MCP servers to mission interview agents", async () => { + const store = {} as never; + const session = { id: "mission-session", thinkingOutput: "" } as never; + + await createMissionInterviewAgent(session, "/tmp/fusion-dashboard-test", store); + + expect(resolveMcpServersForStoreMock).toHaveBeenCalledWith(store); + expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ + cwd: "/tmp/fusion-dashboard-test", + tools: "readonly", + mcpServers: [expect.objectContaining({ name: "docs", env: { TOKEN: "materialized-secret" } })], + })); + }); + + it("forwards empty MCP results to mission interview agents", async () => { + resolveMcpServersForStoreMock.mockResolvedValueOnce({ servers: [], errors: [] }); + + await createMissionInterviewAgent({ id: "mission-empty", thinkingOutput: "" } as never, "/tmp/fusion-dashboard-test", {} as never); + + expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); + }); + + it("forwards materialized MCP servers to milestone and slice interview agents", async () => { + const store = {} as never; + const session = { id: "target-session", targetType: "milestone", thinkingOutput: "" } as never; + + await createTargetInterviewAgent(session, "/tmp/fusion-dashboard-test", store); + + expect(resolveMcpServersForStoreMock).toHaveBeenCalledWith(store); + expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ + cwd: "/tmp/fusion-dashboard-test", + tools: "readonly", + mcpServers: [expect.objectContaining({ name: "docs", env: { TOKEN: "materialized-secret" } })], + })); + }); + + it("forwards empty MCP results to milestone and slice interview agents", async () => { + resolveMcpServersForStoreMock.mockResolvedValueOnce({ servers: [], errors: [] }); + + await createTargetInterviewAgent({ id: "target-empty", targetType: "slice", thinkingOutput: "" } as never, "/tmp/fusion-dashboard-test", {} as never); + + expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ mcpServers: [] })); + }); }); diff --git a/packages/dashboard/src/agent-generation.ts b/packages/dashboard/src/agent-generation.ts index 514a2b5ac0..59c9987e27 100644 --- a/packages/dashboard/src/agent-generation.ts +++ b/packages/dashboard/src/agent-generation.ts @@ -497,6 +497,10 @@ 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; + /* + * 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. + */ const agent = await createFnAgent({ cwd: rootDir, systemPrompt: effectiveSystemPrompt, diff --git a/packages/dashboard/src/ai-refine.ts b/packages/dashboard/src/ai-refine.ts index f39e9a6c4c..4cc0175b1e 100644 --- a/packages/dashboard/src/ai-refine.ts +++ b/packages/dashboard/src/ai-refine.ts @@ -332,6 +332,10 @@ export async function refineText( const effectivePrompt = resolvePrompt("ai-refine-system", promptOverrides); + /* + * 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. + */ const agentResult = await createFnAgent({ cwd: rootDir, systemPrompt: effectivePrompt, @@ -398,6 +402,10 @@ export async function draftGoalDescription( throw new AiServiceError("AI engine not available"); } + /* + * 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. + */ const agentResult = await createFnAgent({ cwd: rootDir, systemPrompt: GOAL_DRAFT_SYSTEM_PROMPT, diff --git a/packages/dashboard/src/milestone-slice-interview.ts b/packages/dashboard/src/milestone-slice-interview.ts index fce239de89..2bff22c840 100644 --- a/packages/dashboard/src/milestone-slice-interview.ts +++ b/packages/dashboard/src/milestone-slice-interview.ts @@ -96,7 +96,7 @@ function parseTargetInterviewResponseImpl(text: string): TargetInterviewResponse // Export the parse function for tests export { parseTargetInterviewResponseImpl as parseTargetInterviewResponse }; -import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent } from "@fusion/engine"; +import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine"; import { createPlanningBoardTools } from "./planning-board-tools.js"; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -727,7 +727,7 @@ function getSystemPrompt(targetType: TargetType): string { return targetType === "milestone" ? MILESTONE_INTERVIEW_SYSTEM_PROMPT : SLICE_INTERVIEW_SYSTEM_PROMPT; } -async function createTargetInterviewAgent( +export async function createTargetInterviewAgent( session: TargetInterviewSession, rootDir: string, store: TaskStore, @@ -736,10 +736,17 @@ async function createTargetInterviewAgent( await ensureEngineReady(); const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, pluginRunner); + /* + 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. + */ + const mcpServers = (await resolveMcpServersForStore(store)).servers; + return createFnAgent({ cwd: rootDir, systemPrompt: getSystemPrompt(session.targetType), tools: "readonly", + mcpServers, 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 a617c715c4..2f14729c2a 100644 --- a/packages/dashboard/src/mission-interview.ts +++ b/packages/dashboard/src/mission-interview.ts @@ -29,7 +29,7 @@ import { } from "./ai-session-diagnostics.js"; import { GenerationGuard, isAbortError } from "./ai-session-timeout.js"; -import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent } from "@fusion/engine"; +import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine"; import { createPlanningBoardTools } from "./planning-board-tools.js"; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -840,7 +840,7 @@ async function initializeAgent( } } -async function createMissionInterviewAgent( +export async function createMissionInterviewAgent( session: MissionInterviewSession, rootDir: string, store: TaskStore, @@ -855,11 +855,16 @@ async function createMissionInterviewAgent( /* FNXC:MissionInterviewSkills 2026-06-17-19:33: Mission interview sessions are agent-acting planning lanes, so they request executor role fallback skills plus enabled plugin skills to keep ce-debug-style skills available outside task execution. + + 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. */ + const mcpServers = (await resolveMcpServersForStore(store)).servers; return createFnAgent({ cwd: rootDir, systemPrompt: effectivePrompt, tools: "readonly", + mcpServers, ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), builtinToolsAllowlist: [...MISSION_INTERVIEW_BUILTIN_WEB_TOOLS], customTools: [...createPlanningBoardTools(store)], diff --git a/packages/dashboard/src/pr-conflict-resolver.ts b/packages/dashboard/src/pr-conflict-resolver.ts index 8cf248c858..c2e02e3f0c 100644 --- a/packages/dashboard/src/pr-conflict-resolver.ts +++ b/packages/dashboard/src/pr-conflict-resolver.ts @@ -1,7 +1,7 @@ import { access, mkdir, readFile, rm } from "node:fs/promises"; import { join, resolve } from "node:path"; import type { Settings, TaskStore } from "@fusion/core"; -import { createResolvedAgentSession } from "@fusion/engine"; +import { createResolvedAgentSession, resolveMcpServersForStore } from "@fusion/engine"; import { runGitCommand } from "./routes/resolve-diff-base.js"; const GIT_TIMEOUT_MS = 60_000; @@ -143,9 +143,15 @@ async function runResolutionAgent(params: { taskId: string; conflictedFiles: string[]; settings: Settings; + store: TaskStore; }): Promise { - const { cwd, taskId, conflictedFiles, settings } = params; + const { cwd, taskId, conflictedFiles, settings, store } = params; const sessionModel = getDefaultSessionModel(settings); + /* + * FNXC:McpConfig 2026-06-26-00:00: + * Create-PR conflict resolution is a merger-purpose coding-agent lane; forward configured MCP servers from the scoped task store so PR conflict work sees the same operator-approved tools as other merger surfaces. + */ + const mcpServers = (await resolveMcpServersForStore(store)).servers; const { session } = await createResolvedAgentSession({ cwd, systemPrompt: SESSION_PROMPT, @@ -156,6 +162,7 @@ async function runResolutionAgent(params: { fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, settings, + mcpServers, }); try { @@ -213,6 +220,7 @@ export async function resolvePrConflicts(input: ResolvePrConflictsInput): Promis taskId, conflictedFiles, settings: input.settings, + store, }); const unresolvedFiles = await findFilesWithConflictMarkers(cwd, conflictedFiles); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 7a78c3c507..68eb8e875a 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -5166,6 +5166,10 @@ async function executeSingleCommand( } } +export async function resolveManualAiPromptMcpServers(taskStore: TaskStore) { + return (await resolveMcpServersForStore(taskStore)).servers; +} + async function executeAiPromptStep( step: import("@fusion/core").AutomationStep, timeoutMs: number, @@ -5208,6 +5212,11 @@ async function executeAiPromptStep( const modelProvider = step.modelProvider?.trim() || defaultModel.provider; const modelId = step.modelId?.trim() || defaultModel.modelId; let responseText = ""; + /* + * FNXC:McpConfig 2026-06-26-00:00: + * Manual AI-prompt workflow runs are operator-triggered coding-agent sessions, so they must receive the task-store resolved MCP set just like task executor lanes. Do not log resolved MCP payloads because env/header values may contain materialized secrets. + */ + const mcpServers = await resolveManualAiPromptMcpServers(taskStore); const { session } = await createFnAgent({ cwd: process.cwd(), @@ -5216,6 +5225,7 @@ async function executeAiPromptStep( toolsAllowlist: step.allowedTools, defaultProvider: modelProvider, defaultModelId: modelId, + mcpServers, onText: (delta: string) => { responseText += delta; liveCallbacks?.onText?.(delta); diff --git a/packages/dashboard/src/subtask-breakdown.ts b/packages/dashboard/src/subtask-breakdown.ts index f15fdb23ef..7d948ac178 100644 --- a/packages/dashboard/src/subtask-breakdown.ts +++ b/packages/dashboard/src/subtask-breakdown.ts @@ -407,6 +407,10 @@ export async function decomposeForTriage( return generateFallbackSubtasks(description); } + /* + * 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. + */ const agent: SubtaskAgent = await createFnAgent({ cwd, systemPrompt, tools: "readonly" }); try { await agent.session.prompt(description); @@ -528,6 +532,10 @@ 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. */ + /* + 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. + */ const agentPromise = createFnAgent({ cwd, systemPrompt, diff --git a/packages/engine/src/__tests__/cron-runner.test.ts b/packages/engine/src/__tests__/cron-runner.test.ts index edaf36a982..acd937b0e6 100644 --- a/packages/engine/src/__tests__/cron-runner.test.ts +++ b/packages/engine/src/__tests__/cron-runner.test.ts @@ -211,6 +211,67 @@ describe("CronRunner", () => { expect(capturedOptions.toolsAllowlist).toBeUndefined(); }); + it("forwards store-resolved MCP servers to scheduled AI prompt sessions", async () => { + let capturedOptions: any; + piModuleMocks.createFnAgent.mockImplementation(async (options: any) => { + capturedOptions = options; + return { session: { dispose: vi.fn() } }; + }); + const store = { + async getSettingsByScope() { + return { + global: { mcpServers: { enabled: true, servers: [] } }, + project: { + mcpServers: { + enabled: true, + servers: [ + { + name: "cron-tools", + transport: "stdio", + command: "node", + env: { MCP_TOKEN: { secretRef: "cron-token", scope: "project" } }, + }, + ], + }, + }, + }; + }, + async getSecretsStore() { + return { + async revealSecret(id: string) { + expect(id).toBe("cron-token"); + return { key: id, plaintextValue: "materialized-cron-secret" }; + }, + }; + }, + } as unknown as TaskStore; + + const executor = await createAiPromptExecutor("/test/project", store); + await executor("Summarize this"); + + expect(capturedOptions.mcpServers).toEqual([ + { + name: "cron-tools", + transport: "stdio", + command: "node", + env: { MCP_TOKEN: "materialized-cron-secret" }, + }, + ]); + }); + + it("keeps scheduled AI prompt sessions working without a store", async () => { + let capturedOptions: any; + piModuleMocks.createFnAgent.mockImplementation(async (options: any) => { + capturedOptions = options; + return { session: { dispose: vi.fn() } }; + }); + + const executor = await createAiPromptExecutor("/test/project"); + await executor("Summarize this"); + + expect(capturedOptions.mcpServers).toBeUndefined(); + }); + it("returns response text even when session disposal throws", async () => { piModuleMocks.createFnAgent.mockImplementation(async (options: { onText?: (delta: string) => void }) => { options.onText?.("hello "); diff --git a/packages/engine/src/__tests__/mcp-lane-forwarding.test.ts b/packages/engine/src/__tests__/mcp-lane-forwarding.test.ts index 44221e6343..663bc507cc 100644 --- a/packages/engine/src/__tests__/mcp-lane-forwarding.test.ts +++ b/packages/engine/src/__tests__/mcp-lane-forwarding.test.ts @@ -28,7 +28,7 @@ const mcpServers: ResolvedMcpServerDefinition[] = [ }, ]; -async function createLaneSession(sessionPurpose: "executor" | "reviewer" | "validation" | "merger") { +async function createLaneSession(sessionPurpose: "executor" | "reviewer" | "validation" | "merger" | "heartbeat") { return createResolvedAgentSession({ sessionPurpose, cwd: "/tmp/fusion-test-worktree", @@ -50,6 +50,7 @@ describe("MCP lane forwarding", () => { ["reviewer"], ["validation"], ["merger"], + ["heartbeat"], ] as const)("forwards materialized MCP servers through the shared %s lane runtime seam", async (sessionPurpose) => { await createLaneSession(sessionPurpose); diff --git a/packages/engine/src/__tests__/mcp-pr-response-forwarding.test.ts b/packages/engine/src/__tests__/mcp-pr-response-forwarding.test.ts new file mode 100644 index 0000000000..927bec2a5c --- /dev/null +++ b/packages/engine/src/__tests__/mcp-pr-response-forwarding.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Settings, TaskStore } from "@fusion/core"; + +const { createResolvedAgentSessionMock, promptWithFallbackMock } = vi.hoisted(() => ({ + createResolvedAgentSessionMock: vi.fn(async () => ({ + session: { + dispose: vi.fn(), + }, + })), + promptWithFallbackMock: vi.fn(async () => undefined), +})); + +vi.mock("../agent-session-helpers.js", () => ({ + createResolvedAgentSession: createResolvedAgentSessionMock, + resolveMergerSessionModel: vi.fn((settings: Settings) => ({ + provider: settings.defaultProvider, + modelId: settings.defaultModelId, + })), +})); + +vi.mock("../pi.js", () => ({ + promptWithFallback: promptWithFallbackMock, +})); + +import { makePrResponseAgentRunner } from "../pr-response-run-ops.js"; + +const settings = { + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", +} as Settings; + +function createStore(enabled: boolean): TaskStore { + return { + async getSettingsByScope() { + return { + global: { mcpServers: { enabled: true, servers: [] } }, + project: { + mcpServers: { + enabled, + servers: enabled + ? [ + { + name: "pr-response-tools", + transport: "stdio", + command: "node", + env: { MCP_TOKEN: { secretRef: "pr-token", scope: "project" } }, + }, + ] + : [], + }, + }, + }; + }, + async getSecretsStore() { + return { + async revealSecret(id: string) { + expect(id).toBe("pr-token"); + return { key: id, plaintextValue: "materialized-pr-secret" }; + }, + }; + }, + } as unknown as TaskStore; +} + +async function runRunner(store?: TaskStore) { + const runner = makePrResponseAgentRunner(settings, "FN-7077", "/tmp/fusion-pr-response", store); + await runner({ + prompt: "Resolve review threads", + systemPrompt: "System", + threads: [{ id: "thread-1" }], + }); +} + +describe("PR response MCP forwarding", () => { + beforeEach(() => { + createResolvedAgentSessionMock.mockClear(); + promptWithFallbackMock.mockClear(); + }); + + it("forwards materialized MCP servers into the PR-response merger agent", async () => { + await runRunner(createStore(true)); + + expect(createResolvedAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ + sessionPurpose: "merger", + tools: "coding", + mcpServers: [ + expect.objectContaining({ + name: "pr-response-tools", + env: { MCP_TOKEN: "materialized-pr-secret" }, + }), + ], + })); + }); + + it("forwards an empty MCP array when PR-response MCP is disabled", async () => { + await runRunner(createStore(false)); + + expect(createResolvedAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ + mcpServers: [], + })); + }); + + it("keeps PR-response sessions working without a store", async () => { + await runRunner(undefined); + + expect(createResolvedAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ + mcpServers: undefined, + })); + }); +}); diff --git a/packages/engine/src/__tests__/mcp-surface-coverage.test.ts b/packages/engine/src/__tests__/mcp-surface-coverage.test.ts new file mode 100644 index 0000000000..07147d2359 --- /dev/null +++ b/packages/engine/src/__tests__/mcp-surface-coverage.test.ts @@ -0,0 +1,141 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { McpSecretReader } from "@fusion/core"; +import { resolveHeartbeatMcpForAgent } from "../agent-heartbeat.js"; + +function secrets(values: Record): McpSecretReader { + return { + async revealSecret(id, _scope, reader) { + expect(reader?.agentId).toBe("agent-heartbeat-1"); + const plaintextValue = values[id]; + if (plaintextValue === undefined) throw new Error(`missing ${id}`); + return { key: id, plaintextValue }; + }, + }; +} + +function expectResolvedMcpForwarded(sourcePath: string, resolveNeedle: string, createNeedle: string, forwardNeedle: 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); + + expect(resolveIndex).toBeGreaterThan(-1); + expect(createIndex).toBeGreaterThan(resolveIndex); + expect(forwardIndex).toBeGreaterThan(createIndex); +} + +describe("MCP surface coverage", () => { + it("resolves heartbeat MCP with the agent identity and materialized secrets", async () => { + const result = await resolveHeartbeatMcpForAgent({ + async getSettingsByScope() { + return { + global: { mcpServers: { enabled: true, servers: [] } }, + project: { + mcpServers: { + enabled: true, + servers: [ + { + name: "heartbeat-tools", + transport: "stdio", + command: "node", + args: ["server.js"], + env: { MCP_TOKEN: { secretRef: "heartbeat-token", scope: "project" } }, + }, + ], + }, + }, + }; + }, + async getSecretsStore() { + return secrets({ "heartbeat-token": "materialized-heartbeat-secret" }); + }, + }, "agent-heartbeat-1"); + + expect(result.errors).toEqual([]); + expect(result.servers).toEqual([ + { + name: "heartbeat-tools", + transport: "stdio", + command: "node", + args: ["server.js"], + env: { MCP_TOKEN: "materialized-heartbeat-secret" }, + }, + ]); + }); + + it("returns empty MCP for heartbeat when settings disable servers", async () => { + const result = await resolveHeartbeatMcpForAgent({ + async getSettingsByScope() { + return { + global: { mcpServers: { enabled: true, servers: [{ name: "disabled", transport: "stdio", command: "node" }] } }, + project: { mcpServers: { enabled: false, servers: [] } }, + }; + }, + async getSecretsStore() { + return secrets({}); + }, + }, "agent-heartbeat-1"); + + expect(result).toEqual({ servers: [], errors: [] }); + }); + + it("keeps heartbeat session creation working when no store is available", async () => { + await expect(resolveHeartbeatMcpForAgent(undefined, "agent-heartbeat-1")).resolves.toEqual({ servers: [], errors: [] }); + }); + + it("keeps the heartbeat createResolvedAgentSession seam wired to the resolved MCP result", () => { + expectResolvedMcpForwarded( + "src/agent-heartbeat.ts", + "const heartbeatMcp = await resolveHeartbeatMcpForAgent(taskStore, agentId);", + "const { session } = await createResolvedAgentSession({", + "mcpServers: heartbeatMcp.servers", + ); + }); + + it("keeps the PR response merger seam wired to resolved MCP", () => { + expectResolvedMcpForwarded( + "src/pr-response-run-ops.ts", + "const mcpServers = store ? (await resolveMcpServersForStore(store)).servers : undefined;", + "const { session } = await createResolvedAgentSession({", + "mcpServers,", + ); + }); + + it("keeps the dashboard PR conflict resolver merger seam wired to resolved MCP", () => { + expectResolvedMcpForwarded( + "../dashboard/src/pr-conflict-resolver.ts", + "const mcpServers = (await resolveMcpServersForStore(store)).servers;", + "const { session } = await createResolvedAgentSession({", + "mcpServers,", + ); + }); + + it("keeps the manual AI-prompt workflow step forwarding resolved MCP", () => { + expectResolvedMcpForwarded( + "../dashboard/src/routes.ts", + "const mcpServers = await resolveManualAiPromptMcpServers(taskStore);", + "const { session } = await createFnAgent({", + "mcpServers,", + ); + }); + + it("keeps mission interview forwarding the store-resolved MCP result", () => { + expectResolvedMcpForwarded( + "../dashboard/src/mission-interview.ts", + "const mcpServers = (await resolveMcpServersForStore(store)).servers;", + "return createFnAgent({", + "mcpServers,", + ); + }); + + it("keeps milestone and slice interview forwarding the store-resolved MCP result", () => { + expectResolvedMcpForwarded( + "../dashboard/src/milestone-slice-interview.ts", + "const mcpServers = (await resolveMcpServersForStore(store)).servers;", + "return createFnAgent({", + "mcpServers,", + ); + }); +}); diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 0193794901..91d64c7ad9 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -38,6 +38,7 @@ import { acquireTaskWorktree } from "./worktree-acquisition.js"; import { createRunAuditor, generateSyntheticRunId, type DatabaseMutationType, type EngineRunContext } from "./run-audit.js"; import { promptWithFallback } from "./pi.js"; import { createResolvedAgentSession, extractRuntimeHint, resolveHeartbeatSessionModels } from "./agent-session-helpers.js"; +import { resolveMcpServersForStore } from "./mcp-resolution.js"; import type { AgentActionGateContext } from "./agent-action-gate.js"; import { buildSessionSkillContextSync } from "./session-skill-context.js"; import type { AgentReflectionService } from "./agent-reflection.js"; @@ -64,6 +65,14 @@ interface SelfImproveServiceLike { recordSelfImprove(agentId: string): Promise; } +export async function resolveHeartbeatMcpForAgent( + taskStore: TaskStore | undefined, + agentId: string, +) { + if (!taskStore) return { servers: [], errors: [] }; + return resolveMcpServersForStore(taskStore, { agentId }); +} + /** Resolved per-agent heartbeat config after validation and fallback */ interface ResolvedHeartbeatConfig { pollIntervalMs: number; @@ -2661,6 +2670,14 @@ export class HeartbeatMonitor { } const heartbeatSessionModels = resolveHeartbeatSessionModels(heartbeatModelSettings, agent.runtimeConfig); + /* + * FNXC:McpConfig 2026-06-26-00:00: + * Heartbeat runs are coding-capable agent-work sessions, so configured MCP servers must be resolved with the waking agent identity and forwarded like executor/chat lanes. Log only server counts and resolution error counts; resolved env/header contents may contain materialized secrets. + */ + const heartbeatMcp = await resolveHeartbeatMcpForAgent(taskStore, agentId); + if (heartbeatMcp.errors.length > 0) { + heartbeatLog.warn(`Heartbeat MCP resolution for ${agentId} produced ${heartbeatMcp.errors.length} error(s); forwarding ${heartbeatMcp.servers.length} server(s)`); + } // Create agent session const { session } = await createResolvedAgentSession({ @@ -2678,6 +2695,7 @@ export class HeartbeatMonitor { fallbackModelId: heartbeatSessionModels.fallbackModelId, runAuditor: audit, settings: heartbeatModelSettings, + mcpServers: heartbeatMcp.servers, onText: (delta) => { outputLength += delta.length; appendStdoutExcerpt(delta); diff --git a/packages/engine/src/cli-agent-ask.ts b/packages/engine/src/cli-agent-ask.ts index fadcc31693..e198868bb4 100644 --- a/packages/engine/src/cli-agent-ask.ts +++ b/packages/engine/src/cli-agent-ask.ts @@ -56,6 +56,10 @@ export async function askAcpOnce(runtime: AgentRuntime, opts: AskAcpOnceOptions) let text = ""; let session: AgentSession | undefined; try { + /* + * FNXC:McpConfig 2026-06-26-00:00: + * `askAcpOnce` is a direct Route-B runtime helper that receives an already-resolved runtime plus readonly prompt options, not a TaskStore or secrets reader. MCP-capable callers must resolve MCP before this seam if they need it. + */ const created = await runtime.createSession({ cwd: opts.cwd, systemPrompt: opts.systemPrompt ?? "", diff --git a/packages/engine/src/cron-runner.ts b/packages/engine/src/cron-runner.ts index 340c6887b5..a2142dc135 100644 --- a/packages/engine/src/cron-runner.ts +++ b/packages/engine/src/cron-runner.ts @@ -20,6 +20,7 @@ import { defaultShell } from "./shell-utils.js"; import { createFnAgent, promptWithFallback } from "./pi.js"; import { HybridEvaluatorService } from "./evaluator.js"; import { buildSessionSkillContextSync } from "./session-skill-context.js"; +import { resolveMcpServersForStore } from "./mcp-resolution.js"; const log = createLogger("cron-runner"); @@ -1007,9 +1008,10 @@ const AI_AUTOMATION_SYSTEM_PROMPT = [ * text response, and disposes the session. * * @param cwd — Project root directory (file access scope for the agent). + * @param store — Optional task store used to resolve configured MCP servers for scheduled agent work. * @returns An AiPromptExecutor function suitable for CronRunnerOptions. */ -export async function createAiPromptExecutor(cwd: string): Promise { +export async function createAiPromptExecutor(cwd: string, store?: TaskStore): Promise { const disposeLog = createLogger("cron-runner"); return async (prompt: string, modelProvider?: string, modelId?: string, allowedTools?: string[], liveCallbacks?: AiPromptLiveCallbacks): Promise => { @@ -1019,7 +1021,11 @@ export async function createAiPromptExecutor(cwd: string): Promise { responseText += delta; liveCallbacks?.onText?.(delta); diff --git a/packages/engine/src/pr-nodes.ts b/packages/engine/src/pr-nodes.ts index ad54d8e593..1fbcac89f1 100644 --- a/packages/engine/src/pr-nodes.ts +++ b/packages/engine/src/pr-nodes.ts @@ -241,7 +241,7 @@ export function buildRespondCallback( const taskId = ops.getTaskId(entity); const cwd = ops.getCwd(entity); - const runAgent = makePrResponseAgentRunner(settings, taskId, cwd); + const runAgent = makePrResponseAgentRunner(settings, taskId, cwd, fullStore); const result = await runPrResponseRun({ entity, diff --git a/packages/engine/src/pr-response-run-ops.ts b/packages/engine/src/pr-response-run-ops.ts index fcab87b013..fc4ef07183 100644 --- a/packages/engine/src/pr-response-run-ops.ts +++ b/packages/engine/src/pr-response-run-ops.ts @@ -9,9 +9,10 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import type { PrEntity, Settings } from "@fusion/core"; +import type { PrEntity, Settings, TaskStore } from "@fusion/core"; import { resolveAgentPrompt } from "@fusion/core"; import { createResolvedAgentSession, resolveMergerSessionModel } from "./agent-session-helpers.js"; +import { resolveMcpServersForStore } from "./mcp-resolution.js"; import { promptWithFallback } from "./pi.js"; import { withRateLimitRetry } from "./rate-limit-retry.js"; import { checkSessionError } from "./usage-limit-detector.js"; @@ -86,6 +87,7 @@ export function makePrResponseAgentRunner( settings: Settings, taskId: string, cwd: string, + store?: TaskStore, ): (input: { prompt: string; systemPrompt: string; @@ -95,6 +97,11 @@ export function makePrResponseAgentRunner( return async ({ prompt, systemPrompt, signal, threads }) => { const model = resolveMergerSessionModel(settings); let captured = ""; + /* + * FNXC:McpConfig 2026-06-26-00:00: + * PR-response review threads are resolved by a merger-purpose coding agent, so this helper must forward the same store-resolved MCP set as the primary merger lane. Only counts/errors may be logged by callers; the server payload can contain materialized secrets. + */ + const mcpServers = store ? (await resolveMcpServersForStore(store)).servers : undefined; // Append the strict verdict-output contract to the (untrusted-declaring) // system prompt so the agent emits parseable per-thread decisions. const fullSystem = [ @@ -121,6 +128,7 @@ export function makePrResponseAgentRunner( defaultThinkingLevel: settings.defaultThinkingLevel, settings, taskId, + mcpServers, }); try { await withRateLimitRetry(async () => { diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 1ebae5c470..5ff816d4a5 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -666,7 +666,7 @@ export class ProjectEngine { this.automationStore = new AutomationStore(cwd); await this.automationStore.init(); - const aiPromptExecutor = await createAiPromptExecutor(cwd); + const aiPromptExecutor = await createAiPromptExecutor(cwd, store); this.cronRunner = new CronRunner(store, this.automationStore, { aiPromptExecutor, onScheduleRunProcessed: this.buildInsightRunHandler(cwd), diff --git a/packages/engine/src/research/providers/llm-synthesis-provider.ts b/packages/engine/src/research/providers/llm-synthesis-provider.ts index f14d055986..373b23087d 100644 --- a/packages/engine/src/research/providers/llm-synthesis-provider.ts +++ b/packages/engine/src/research/providers/llm-synthesis-provider.ts @@ -52,6 +52,10 @@ export class LLMSynthesisProvider implements ResearchProvider { const cappedSources = this.applySourceBudget(request.sources, modelSelection); const prompt = buildSynthesisPrompt(request, cappedSources); + /* + * FNXC:McpConfig 2026-06-26-00:00: + * Research synthesis is a readonly provider constructed with projectRoot/model options only; it has no TaskStore or secrets reader at this seam, so configured MCP servers are intentionally not resolved here. + */ const { session } = await createFnAgent({ cwd: this.options.projectRoot, tools: "readonly", diff --git a/packages/engine/src/research/providers/web-search-provider.ts b/packages/engine/src/research/providers/web-search-provider.ts index 60a4a080dd..684a737315 100644 --- a/packages/engine/src/research/providers/web-search-provider.ts +++ b/packages/engine/src/research/providers/web-search-provider.ts @@ -92,6 +92,10 @@ export class WebSearchProvider implements ResearchProvider { } try { + /* + * FNXC:McpConfig 2026-06-26-00:00: + * Built-in research web search is a readonly provider with projectRoot/model options but no TaskStore or secrets reader, so it intentionally skips user-configured MCP resolution at this seam. + */ const { session } = await createFnAgent({ cwd: this.options.projectRoot, tools: "readonly",