FN-7077: inject configured MCP servers across agent surfaces
Configured MCP servers now follow every agent-work lane that launches model-backed tools. - Thread MCP server forwarding through chat, task execution, tools, PR response, cron, memory, research, and planning surfaces. - Add regression coverage for dashboard and engine lanes that previously omitted MCP server configuration. - Document all covered MCP surfaces and add a published package changeset. Files changed: .changeset/fn-7077-mcp-all-surfaces.md | 7 + docs/mcp.md | 3 + docs/settings-reference.md | 2 +- packages/cli/src/commands/__tests__/serve.test.ts | 3 +- packages/core/src/memory-compaction.ts | 4 + .../src/__tests__/mcp-lane-forwarding.test.ts | 62 +++++++++ packages/dashboard/src/agent-generation.ts | 4 + packages/dashboard/src/ai-refine.ts | 8 ++ .../dashboard/src/milestone-slice-interview.ts | 11 +- packages/dashboard/src/mission-interview.ts | 9 +- packages/dashboard/src/pr-conflict-resolver.ts | 12 +- packages/dashboard/src/routes.ts | 10 ++ packages/dashboard/src/subtask-breakdown.ts | 8 ++ packages/engine/src/__tests__/cron-runner.test.ts | 61 +++++++++ .../src/__tests__/mcp-lane-forwarding.test.ts | 3 +- .../__tests__/mcp-pr-response-forwarding.test.ts | 110 ++++++++++++++++ .../src/__tests__/mcp-surface-coverage.test.ts | 141 +++++++++++++++++++++ packages/engine/src/agent-heartbeat.ts | 18 +++ packages/engine/src/cli-agent-ask.ts | 4 + packages/engine/src/cron-runner.ts | 9 +- packages/engine/src/pr-nodes.ts | 2 +- packages/engine/src/pr-response-run-ops.ts | 10 +- packages/engine/src/project-engine.ts | 2 +- .../research/providers/llm-synthesis-provider.ts | 4 + .../src/research/providers/web-search-provider.ts | 4 + 25 files changed, 497 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-7077 Fusion-Task-Lineage: 00d36357-c3d8-4954-b647-d3aea24a967b
This commit is contained in:
7
.changeset/fn-7077-mcp-all-surfaces.md
Normal file
7
.changeset/fn-7077-mcp-all-surfaces.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
<!-- FNXC:McpConfig 2026-06-26-00:00: FN-7077 completed the agent-work surface audit. Configured MCP servers must be injected into MCP-capable Chat, Tools, and Tasks lanes, including heartbeat, manual AI-prompt workflow steps, PR-response/PR-conflict merger helpers, cron automation, and mission/milestone interview sessions. Terminal sessions and DB-row chat session creation remain non-agent-runtime surfaces and intentionally receive no MCP payload. -->
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [] }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
110
packages/engine/src/__tests__/mcp-pr-response-forwarding.test.ts
Normal file
110
packages/engine/src/__tests__/mcp-pr-response-forwarding.test.ts
Normal file
@@ -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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
141
packages/engine/src/__tests__/mcp-surface-coverage.test.ts
Normal file
141
packages/engine/src/__tests__/mcp-surface-coverage.test.ts
Normal file
@@ -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<string, string>): 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,",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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<AiPromptExecutor> {
|
||||
export async function createAiPromptExecutor(cwd: string, store?: TaskStore): Promise<AiPromptExecutor> {
|
||||
const disposeLog = createLogger("cron-runner");
|
||||
|
||||
return async (prompt: string, modelProvider?: string, modelId?: string, allowedTools?: string[], liveCallbacks?: AiPromptLiveCallbacks): Promise<string> => {
|
||||
@@ -1019,7 +1021,11 @@ export async function createAiPromptExecutor(cwd: string): Promise<AiPromptExecu
|
||||
/*
|
||||
FNXC:CronAutomationSkills 2026-06-17-19:33:
|
||||
Scheduled AI automation is an agent-acting lane; even without a plugin runner in this seam, it must request executor fallback skills and tolerate the degraded no-plugin path.
|
||||
|
||||
FNXC:McpConfig 2026-06-26-00:00:
|
||||
Scheduled AI automations are coding-agent work surfaces. ProjectEngine passes the TaskStore so configured MCP servers are forwarded; lightweight in-process runtime callers may omit the store and keep the pre-existing empty-MCP behavior.
|
||||
*/
|
||||
const mcpServers = store ? (await resolveMcpServersForStore(store)).servers : undefined;
|
||||
const { session } = await createFnAgent({
|
||||
cwd,
|
||||
systemPrompt: AI_AUTOMATION_SYSTEM_PROMPT,
|
||||
@@ -1028,6 +1034,7 @@ export async function createAiPromptExecutor(cwd: string): Promise<AiPromptExecu
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
defaultProvider: modelProvider,
|
||||
defaultModelId: modelId,
|
||||
mcpServers,
|
||||
onText: (delta: string) => {
|
||||
responseText += delta;
|
||||
liveCallbacks?.onText?.(delta);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user