diff --git a/.changeset/fn-7023-mcp-runtime-forwarding.md b/.changeset/fn-7023-mcp-runtime-forwarding.md new file mode 100644 index 0000000000..075f977f79 --- /dev/null +++ b/.changeset/fn-7023-mcp-runtime-forwarding.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Forward configured MCP servers to all AI lanes and add reachability validation. +category: feature +dev: Adds runtime MCP support gating, materialized MCP forwarding, and POST /api/mcp/validate. diff --git a/docs/agents.md b/docs/agents.md index 9b0a73533f..db1e945470 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -570,6 +570,8 @@ Assignment-triggered heartbeats are completion-resilient: if an `agent:assigned` Self-healing also covers abnormal run/session loss for assigned `in-progress` work. If the task remains assigned but the durable agent has no active heartbeat run and no active executor session after the orphan grace window, `reattach-orphaned-assigned-executions` re-dispatches the task forward via `executor.resumeTaskForAgent(agentId)` without pausing, failing, or moving the task backward. +Agent runtime sessions receive the project/global effective MCP server set when the selected runtime supports MCP (pi/Claude/ACP-compatible runtimes). Mock and unsupported runtimes skip MCP forwarding with content-free structured logs. MCP secret references are materialized only at session creation and are never stored on the agent or written to logs. + Heartbeat values are validated and minimum-clamped to 5 minutes (300,000 ms). Project setting `heartbeatMultiplier` (default `1`) scales resolved heartbeat timing globally: both the heartbeat interval (`pollIntervalMs`) and unresponsive timeout base (`heartbeatTimeoutMs`) are multiplied. Per-agent `heartbeatIntervalMs`/`heartbeatTimeoutMs` remain base values before multiplier scaling. This setting is configured from the **Agents** screen's **Controls** popup under "Heartbeat Speed". diff --git a/docs/secrets.md b/docs/secrets.md index ec3147b669..256bb7fa2c 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -39,6 +39,7 @@ Threat-model baseline: - Ciphertext + nonce are persisted; plaintext exists only in process memory during create/reveal. - Secret values must never be logged. - MCP server settings store only secret references for sensitive env/header/token fields; imports surface plaintext as secret-creation descriptors instead of persisting it in settings. +- MCP server secret references are materialized only at session/probe creation time for MCP-capable AI lanes and `POST /api/mcp/validate`; responses and structured logs include status/count metadata only, never resolved env/header values. See also: [Storage](./storage.md), [Multi-project](./multi-project.md), [Architecture](./architecture.md), [Settings reference](./settings-reference.md). diff --git a/docs/settings-reference.md b/docs/settings-reference.md index c7330d813d..a05db70e18 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -160,7 +160,11 @@ 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. -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. +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. + +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. + +`POST /api/mcp/validate` validates an MCP server definition or configured server name against the current project context. The route resolves and materializes the target server with the same secret rules, then performs a bounded reachability probe (`stdio` supervised spawn, `sse`/`streamable-http` bounded fetch) and returns only `{ status, message? }` without resolved env/header contents. ### Notification providers (pluggable) diff --git a/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts b/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts new file mode 100644 index 0000000000..f97fcd49e0 --- /dev/null +++ b/packages/dashboard/src/__tests__/mcp-lane-forwarding.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { createFnAgentMock, resolveMcpServersForStoreMock } = vi.hoisted(() => ({ + createFnAgentMock: vi.fn(async () => ({ + session: { + state: { messages: [] as Array<{ role: string; content: string }> }, + prompt: vi.fn(async function (this: { state: { messages: Array<{ role: string; content: string }> } }, _message: string) { + this.state.messages.push({ + role: "assistant", + content: JSON.stringify({ + type: "question", + data: { + id: "q1", + text: "What should be built?", + type: "text", + required: true, + }, + }), + }); + }), + dispose: vi.fn(), + }, + })), + resolveMcpServersForStoreMock: vi.fn(async () => ({ + servers: [{ name: "docs", transport: "stdio", command: "node", env: { TOKEN: "materialized-secret" } }], + errors: [], + })), +})); + +vi.mock("@fusion/core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + DEFAULT_TASK_PRIORITY: "normal", + TASK_PRIORITIES: ["low", "normal", "high", "urgent"], + resolvePrompt: vi.fn(() => undefined), + summarizeTitle: vi.fn((value: string) => value.slice(0, 80)), + }; +}); + +vi.mock("@fusion/engine", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildSessionSkillContextSync: vi.fn(() => ({ skillSelectionContext: undefined })), + createChatTaskDocumentTools: vi.fn(() => []), + createWorkflowAuthoringTools: vi.fn(() => []), + createFnAgent: createFnAgentMock, + resolveMcpServersForStore: resolveMcpServersForStoreMock, + }; +}); + +vi.mock("../planning-board-tools.js", () => ({ + createPlanningBoardTools: vi.fn(() => []), +})); + +import { __resetPlanningState, createSession } from "../planning.js"; + +describe("dashboard MCP lane forwarding", () => { + beforeEach(() => { + __resetPlanningState(); + createFnAgentMock.mockClear(); + resolveMcpServersForStoreMock.mockClear(); + }); + + it("forwards the materialized MCP set to chat/planning createFnAgent sessions", async () => { + const store = {} as never; + + await createSession("127.0.0.1", "Build a feature", store, "/tmp/fusion-dashboard-test"); + + expect(resolveMcpServersForStoreMock).toHaveBeenCalledWith(store); + expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ + cwd: "/tmp/fusion-dashboard-test", + mcpServers: [expect.objectContaining({ name: "docs", env: { TOKEN: "materialized-secret" } })], + })); + }); +}); diff --git a/packages/dashboard/src/__tests__/mcp-validate-route.test.ts b/packages/dashboard/src/__tests__/mcp-validate-route.test.ts new file mode 100644 index 0000000000..4c0e860a6d --- /dev/null +++ b/packages/dashboard/src/__tests__/mcp-validate-route.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import express from "express"; +import { createApiRoutes } from "../routes.js"; +import { request } from "../test-request.js"; + +const engineMocks = vi.hoisted(() => ({ + validateMcpServer: vi.fn(), + resolveMcpServersForRuntime: vi.fn(), + resolveMcpServersForStore: vi.fn(), +})); + +vi.mock("@fusion/engine", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createFnAgent: vi.fn(), + getExemptToolNames: vi.fn(() => []), + promptWithFallback: vi.fn(), + reloadExemptTools: vi.fn(), + resolveIntegrationBranch: vi.fn(() => "main"), + resolveMcpServersForRuntime: engineMocks.resolveMcpServersForRuntime, + resolveMcpServersForStore: engineMocks.resolveMcpServersForStore, + validateMcpServer: engineMocks.validateMcpServer, + }; +}); + +function createMockStore() { + return { + getRootDir: () => "/workspace", + getSecretsStore: () => ({ revealSecret: vi.fn() }), + getSettingsByScope: async () => ({ global: { mcpServers: { enabled: true, servers: [] } }, project: {} }), + }; +} + +function createApp(store = createMockStore()) { + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store as never)); + return app; +} + +describe("POST /api/mcp/validate", () => { + beforeEach(() => { + vi.clearAllMocks(); + engineMocks.validateMcpServer.mockResolvedValue({ status: "valid", message: "ok" }); + engineMocks.resolveMcpServersForRuntime.mockResolvedValue({ + servers: [{ name: "local", transport: "stdio", command: "node", env: { TOKEN: "resolved-secret" } }], + errors: [], + }); + engineMocks.resolveMcpServersForStore.mockResolvedValue({ servers: [], errors: [] }); + }); + + it("validates a supplied server definition and returns only status JSON", async () => { + const app = createApp(); + const response = await request( + app, + "POST", + "/api/mcp/validate", + JSON.stringify({ server: { name: "local", transport: "stdio", command: "node", env: { TOKEN: { secretRef: "token", scope: "project" } } } }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: "valid", message: "ok" }); + expect(engineMocks.resolveMcpServersForRuntime).toHaveBeenCalledTimes(1); + expect(engineMocks.validateMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ name: "local", env: { TOKEN: "resolved-secret" } }), + expect.objectContaining({ cwd: "/workspace" }), + ); + expect(JSON.stringify(response.body)).not.toContain("resolved-secret"); + }); + + it("returns 400 for malformed request bodies", async () => { + const app = createApp(); + const response = await request( + app, + "POST", + "/api/mcp/validate", + JSON.stringify({ timeoutMs: 1000 }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: "Provide either name or server" }); + expect(engineMocks.validateMcpServer).not.toHaveBeenCalled(); + }); + + it("returns probe error status without converting it to HTTP failure", async () => { + engineMocks.resolveMcpServersForStore.mockResolvedValue({ + servers: [{ name: "remote", transport: "streamable-http", url: "https://example.test/mcp", headers: { Authorization: "resolved-secret" } }], + errors: [], + }); + engineMocks.validateMcpServer.mockResolvedValue({ status: "error", message: "server responded with HTTP 503" }); + + const app = createApp(); + const response = await request( + app, + "POST", + "/api/mcp/validate", + JSON.stringify({ name: "remote" }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: "error", message: "server responded with HTTP 503" }); + expect(engineMocks.resolveMcpServersForStore).toHaveBeenCalledTimes(1); + expect(JSON.stringify(response.body)).not.toContain("resolved-secret"); + }); +}); diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index a765133825..f9e74c4256 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -48,6 +48,7 @@ import { createChatArtifactTools, createChatTaskDocumentTools, createWorkflowAuthoringTools, + resolveMcpServersForStore, } from "@fusion/engine"; import * as engineModule from "@fusion/engine"; @@ -1932,6 +1933,8 @@ export class ChatManager { Regular chat and QuickChat must request bound-agent skills plus enabled plugin skills so dashboard chat loads capabilities such as ce-debug instead of creating skill-less sessions. */ ...(mergedChatSkillSelection ? { skillSelection: mergedChatSkillSelection } : {}), + // FNXC:McpConfig 2026-06-25-22:36: Dashboard chat/QuickChat reuses the scoped task store when available to resolve trusted MCP servers at session creation without persisting materialized secrets. + ...(this.taskStore ? { mcpServers: (await resolveMcpServersForStore(this.taskStore, { agentId: agent?.id })).servers } : {}), ...sessionOptions, }); this.activeGenerations.set(sessionId, { abortController, agentResult, generationId }); diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index c69b121e8a..3912abaa8f 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -37,6 +37,7 @@ import { createChatTaskDocumentTools, createFnAgent as engineCreateFnAgent, createWorkflowAuthoringTools, + resolveMcpServersForStore, } from "@fusion/engine"; import * as engineModule from "@fusion/engine"; import { createPlanningBoardTools } from "./planning-board-tools.js"; @@ -939,6 +940,8 @@ export async function createSession( tools: "readonly", ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), builtinToolsAllowlist: [...PLANNING_BUILTIN_WEB_TOOLS], + // FNXC:McpConfig 2026-06-25-22:31: Planning/chat session creation resolves trusted MCP servers through the dashboard-scoped store and forwards only the materialized in-memory set to the engine runtime guard. + mcpServers: (await resolveMcpServersForStore(store)).servers, customTools: [ ...createPlanningBoardTools(store), ...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }), @@ -1537,6 +1540,8 @@ async function createPlanningAgent( tools: "readonly", ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), builtinToolsAllowlist: [...PLANNING_BUILTIN_WEB_TOOLS], + // FNXC:McpConfig 2026-06-25-22:31: Streaming planning uses the same dashboard-scoped MCP resolution seam as non-streaming planning so no planning lane silently drops enabled servers. + mcpServers: (await resolveMcpServersForStore(store)).servers, customTools: [ ...createPlanningBoardTools(store), ...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }), diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 0c6c7a3b96..a7ee52c44e 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -13,7 +13,7 @@ import * as nodeFs from "node:fs"; import os from "node:os"; import v8 from "node:v8"; -import type { TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType, WorkflowStepTemplate } from "@fusion/core"; +import type { TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType, WorkflowStepTemplate, McpServerDefinition } from "@fusion/core"; import { type Task, type PiExtensionEntry, @@ -34,6 +34,7 @@ import { resolveExecutionSettingsModel, resolveTitleSummarizerSettingsModel, writeAgentMemoryFile, + validateMcpServerDefinitionDetailed, } from "@fusion/core"; import type { ServerOptions } from "./server.js"; import { verifyWebhookSignature } from "./github-webhooks.js"; @@ -373,8 +374,79 @@ import { promptWithFallback as enginePromptWithFallback, reloadExemptTools as engineReloadExemptTools, resolveIntegrationBranch, + resolveMcpServersForRuntime, + resolveMcpServersForStore, + validateMcpServer, } from "@fusion/engine"; +interface McpValidateRequestBody { + name?: unknown; + server?: unknown; + definition?: unknown; + timeoutMs?: unknown; +} + +function parseMcpValidationTimeout(value: unknown): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw badRequest("timeoutMs must be a positive number when provided"); + } + return Math.min(value, 30_000); +} + +function parseMcpValidationBody(body: unknown): { name?: string; definition?: McpServerDefinition; timeoutMs?: number } { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw badRequest("Request body must be an object"); + } + + const input = body as McpValidateRequestBody; + const name = typeof input.name === "string" ? input.name.trim() : undefined; + const rawDefinition = input.server ?? input.definition; + if (!name && rawDefinition === undefined) { + throw badRequest("Provide either name or server"); + } + if (input.name !== undefined && !name) { + throw badRequest("name must be a non-empty string when provided"); + } + + let definition: McpServerDefinition | undefined; + if (rawDefinition !== undefined) { + const parsed = validateMcpServerDefinitionDetailed(rawDefinition, "server"); + if (!parsed.value) { + throw badRequest("Invalid MCP server definition", { errors: parsed.errors.map((error) => error.message) }); + } + definition = parsed.value; + } + + return { name, definition, timeoutMs: parseMcpValidationTimeout(input.timeoutMs) }; +} + +async function resolveMcpServerForValidation( + scopedStore: TaskStore, + request: { name?: string; definition?: McpServerDefinition }, +) { + if (request.definition) { + const secrets = await scopedStore.getSecretsStore(); + const resolved = await resolveMcpServersForRuntime({ + globalSettings: { mcpServers: { enabled: true, servers: [request.definition] } }, + projectSettings: undefined, + secrets, + reader: {}, + }); + if (resolved.errors.length > 0 || resolved.servers.length === 0) { + throw badRequest("Unable to resolve MCP server secrets", { errors: resolved.errors.map((error) => ({ serverName: error.serverName, path: error.path, message: error.message })) }); + } + return resolved.servers[0]; + } + + const resolved = await resolveMcpServersForStore(scopedStore); + const server = resolved.servers.find((candidate) => candidate.name === request.name); + if (!server) { + throw badRequest("MCP server was not found or could not be resolved"); + } + return server; +} + // Test-injectable override; defaults to the statically imported engine binding. let createFnAgentForRefine: typeof import("@fusion/engine").createFnAgent | undefined = engineCreateFnAgentForRefine; @@ -1276,6 +1348,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout } }); + router.post("/mcp/validate", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const request = parseMcpValidationBody(req.body); + const server = await resolveMcpServerForValidation(scopedStore, request); + // FNXC:McpConfig 2026-06-25-23:38: The validation API materializes MCP secrets only for the bounded probe and returns only status metadata, never resolved env/header values. + const result = await validateMcpServer(server, { + timeoutMs: request.timeoutMs, + cwd: scopedStore.getRootDir(), + }); + res.json(result); + } catch (error) { + rethrowAsApiError(error, "Failed to validate MCP server"); + } + }); + router.get("/pi-settings", async (_req, res) => { try { const { SettingsManager, getAgentDir } = await import("@earendil-works/pi-coding-agent"); diff --git a/packages/engine/src/__tests__/mcp-lane-forwarding.test.ts b/packages/engine/src/__tests__/mcp-lane-forwarding.test.ts new file mode 100644 index 0000000000..44221e6343 --- /dev/null +++ b/packages/engine/src/__tests__/mcp-lane-forwarding.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ResolvedMcpServerDefinition } from "@fusion/core"; + +const { createFnAgentMock } = vi.hoisted(() => ({ + createFnAgentMock: vi.fn(async () => ({ + session: { + prompt: vi.fn(async () => undefined), + dispose: vi.fn(), + }, + })), +})); + +vi.mock("../pi.js", () => ({ + createFnAgent: createFnAgentMock, + promptWithFallback: vi.fn(async () => undefined), + describeModel: vi.fn(() => "mock-model"), +})); + +import { createResolvedAgentSession } from "../agent-session-helpers.js"; + +const mcpServers: ResolvedMcpServerDefinition[] = [ + { + name: "docs", + transport: "stdio", + command: "node", + args: ["server.js"], + env: { MCP_TOKEN: "materialized-secret" }, + }, +]; + +async function createLaneSession(sessionPurpose: "executor" | "reviewer" | "validation" | "merger") { + return createResolvedAgentSession({ + sessionPurpose, + cwd: "/tmp/fusion-test-worktree", + systemPrompt: `You are the ${sessionPurpose} lane`, + tools: "readonly", + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4", + mcpServers, + }); +} + +describe("MCP lane forwarding", () => { + beforeEach(() => { + createFnAgentMock.mockClear(); + }); + + it.each([ + ["executor"], + ["reviewer"], + ["validation"], + ["merger"], + ] as const)("forwards materialized MCP servers through the shared %s lane runtime seam", async (sessionPurpose) => { + await createLaneSession(sessionPurpose); + + expect(createFnAgentMock).toHaveBeenCalledTimes(1); + expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ + mcpServers, + systemPrompt: `You are the ${sessionPurpose} lane`, + })); + }); + + it("passes mcpServers through the shared helper seam used by workflow-node and summarization callers", async () => { + await createResolvedAgentSession({ + sessionPurpose: "executor", + cwd: "/tmp/fusion-test-worktree", + systemPrompt: "Workflow model node and summarization lanes share this helper.", + tools: "readonly", + defaultProvider: "anthropic", + mcpServers, + runtimeContext: { lane: "workflow-node+summarization" }, + }); + + expect(createFnAgentMock).toHaveBeenCalledTimes(1); + expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({ + mcpServers, + runtimeContext: expect.objectContaining({ lane: "workflow-node+summarization" }), + })); + }); + + it("does not send mock-provider sessions through the pi createFnAgent seam", async () => { + const result = await createResolvedAgentSession({ + sessionPurpose: "executor", + cwd: "/tmp/fusion-test-worktree", + systemPrompt: "Mock providers are MCP-incapable.", + tools: "readonly", + defaultProvider: "mock", + defaultModelId: "scripted", + mcpServers, + }); + + expect(createFnAgentMock).not.toHaveBeenCalled(); + expect(result.runtimeId).toBe("mock"); + }); +}); diff --git a/packages/engine/src/__tests__/mcp-resolution.test.ts b/packages/engine/src/__tests__/mcp-resolution.test.ts new file mode 100644 index 0000000000..0b72177261 --- /dev/null +++ b/packages/engine/src/__tests__/mcp-resolution.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import type { McpSecretReader } from "@fusion/core"; +import { resolveMcpServersForRuntime } from "../mcp-resolution.js"; + +function secrets(values: Record): McpSecretReader { + return { + async revealSecret(id) { + const plaintextValue = values[id]; + if (plaintextValue === undefined) throw new Error(`missing ${id}`); + return { key: id, plaintextValue }; + }, + }; +} + +describe("resolveMcpServersForRuntime", () => { + it("resolves effective settings and materializes secret references", async () => { + const result = await resolveMcpServersForRuntime({ + globalSettings: { + mcpServers: { + enabled: true, + servers: [ + { name: "global", transport: "stdio", command: "node", args: ["server.js"], env: { API_KEY: { secretRef: "global-key", scope: "global" } } }, + ], + }, + }, + projectSettings: null, + secrets: secrets({ "global-key": "SECRET_VALUE" }), + reader: { agentId: "agent-1" }, + }); + + expect(result.errors).toEqual([]); + expect(result.servers).toEqual([ + { name: "global", transport: "stdio", command: "node", args: ["server.js"], env: { API_KEY: "SECRET_VALUE" } }, + ]); + }); + + it("excludes disabled servers and lets project definitions override global definitions", async () => { + const result = await resolveMcpServersForRuntime({ + globalSettings: { + mcpServers: { + enabled: true, + servers: [ + { name: "override", transport: "stdio", command: "old" }, + { name: "removed", transport: "stdio", command: "remove-me" }, + ], + }, + }, + projectSettings: { + mcpServers: { + enabled: true, + servers: [ + { name: "override", transport: "sse", url: "https://mcp.example/sse", headers: { Authorization: { secretRef: "auth", scope: "project" } } }, + { name: "removed", enabled: false, transport: "stdio", command: "noop" }, + ], + }, + }, + secrets: secrets({ auth: "Bearer SECRET" }), + }); + + expect(result.errors).toEqual([]); + expect(result.servers).toEqual([ + { name: "override", transport: "sse", url: "https://mcp.example/sse", headers: { Authorization: "Bearer SECRET" } }, + ]); + }); + + it("resolves through the TaskStore-compatible settings split seam", async () => { + const { resolveMcpServersForStore } = await import("../mcp-resolution.js"); + const result = await resolveMcpServersForStore({ + async getSettingsByScope() { + return { + global: { mcpServers: { enabled: true, servers: [{ name: "store", transport: "stdio", command: "node" }] } }, + project: { mcpServers: { enabled: true, servers: [] } }, + }; + }, + async getSecretsStore() { + return secrets({}); + }, + }); + + expect(result).toEqual({ + servers: [{ name: "store", transport: "stdio", command: "node" }], + errors: [], + }); + }); + + it("returns materialization errors without leaking through logs", async () => { + const result = await resolveMcpServersForRuntime({ + globalSettings: { + mcpServers: { + enabled: true, + servers: [ + { name: "broken", transport: "streamable-http", url: "https://mcp.example", headers: { Authorization: { secretRef: "missing", scope: "project" } } }, + ], + }, + }, + projectSettings: null, + secrets: secrets({}), + }); + + expect(result.servers).toEqual([{ name: "broken", transport: "streamable-http", url: "https://mcp.example" }]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]?.serverName).toBe("broken"); + }); +}); diff --git a/packages/engine/src/__tests__/mcp-runtime-support.test.ts b/packages/engine/src/__tests__/mcp-runtime-support.test.ts new file mode 100644 index 0000000000..dad2d3b3d7 --- /dev/null +++ b/packages/engine/src/__tests__/mcp-runtime-support.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { logMcpForwardingSkipped, runtimeSupportsMcp } from "../mcp-runtime-support.js"; + +describe("runtimeSupportsMcp", () => { + it("allows the default pi runtime for non-mock providers", () => { + expect(runtimeSupportsMcp("pi", "anthropic")).toBe(true); + expect(runtimeSupportsMcp("default-pi", undefined)).toBe(true); + }); + + it("allows Claude/ACP runtime identifiers", () => { + expect(runtimeSupportsMcp("claude-code", "anthropic")).toBe(true); + expect(runtimeSupportsMcp("vendor-acp-runtime", "anthropic")).toBe(true); + }); + + it("rejects mock provider even on an otherwise supported runtime", () => { + expect(runtimeSupportsMcp("pi", "mock")).toBe(false); + }); + + it("rejects unknown, undefined, or empty runtime identifiers", () => { + expect(runtimeSupportsMcp(undefined, "anthropic")).toBe(false); + expect(runtimeSupportsMcp("", "anthropic")).toBe(false); + expect(runtimeSupportsMcp("paperclip", "anthropic")).toBe(false); + }); +}); + +describe("logMcpForwardingSkipped", () => { + it("logs only metadata and server count", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + logMcpForwardingSkipped({ runtimeId: "mock", provider: "mock", skippedCount: 2, lane: "executor" }); + const output = String(spy.mock.calls[0]?.[0] ?? ""); + spy.mockRestore(); + expect(output).toContain("mcp.forwarding.skipped"); + expect(output).toContain("skippedCount"); + expect(output).not.toContain("command"); + expect(output).not.toContain("SECRET"); + }); + + it("does not log when no servers were skipped", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + logMcpForwardingSkipped({ runtimeId: "mock", provider: "mock", skippedCount: 0 }); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); +}); diff --git a/packages/engine/src/__tests__/mcp-validation-service.test.ts b/packages/engine/src/__tests__/mcp-validation-service.test.ts new file mode 100644 index 0000000000..168614fff7 --- /dev/null +++ b/packages/engine/src/__tests__/mcp-validation-service.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ResolvedMcpServerDefinition } from "@fusion/core"; +import { validateMcpServer } from "../mcp-validation-service.js"; + +describe("mcp-validation-service", () => { + it("uses an injected stdio probe for stdio servers", async () => { + const server: ResolvedMcpServerDefinition = { + name: "local", + transport: "stdio", + command: "node", + args: ["server.js"], + env: { TOKEN: "secret-value" }, + }; + const stdioProbe = vi.fn(async () => ({ status: "valid" as const, message: "ok" })); + + await expect(validateMcpServer(server, { stdioProbe, timeoutMs: 25 })).resolves.toEqual({ status: "valid", message: "ok" }); + expect(stdioProbe).toHaveBeenCalledWith(server, { timeoutMs: 25, cwd: undefined }); + }); + + it("treats reachable SSE responses below 500 as valid", async () => { + const fetchImpl = vi.fn(async () => new Response(null, { status: 401 })); + const server: ResolvedMcpServerDefinition = { + name: "events", + transport: "sse", + url: "https://example.test/sse", + headers: { Authorization: "Bearer secret-value" }, + }; + + const result = await validateMcpServer(server, { fetchImpl, timeoutMs: 25 }); + + expect(result).toEqual({ status: "valid", message: "server responded with HTTP 401" }); + expect(fetchImpl).toHaveBeenCalledWith("https://example.test/sse", expect.objectContaining({ + method: "GET", + headers: { Authorization: "Bearer secret-value" }, + })); + }); + + it("returns error for streamable HTTP 5xx responses", async () => { + const fetchImpl = vi.fn(async () => new Response(null, { status: 503 })); + const server: ResolvedMcpServerDefinition = { + name: "http", + transport: "streamable-http", + url: "https://example.test/mcp", + }; + + await expect(validateMcpServer(server, { fetchImpl, timeoutMs: 25 })).resolves.toEqual({ + status: "error", + message: "server responded with HTTP 503", + }); + }); + + it("returns unreachable for fetch failures without echoing headers", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }); + const server: ResolvedMcpServerDefinition = { + name: "http", + transport: "streamable-http", + url: "https://example.test/mcp", + headers: { Authorization: "super-secret" }, + }; + + const result = await validateMcpServer(server, { fetchImpl, timeoutMs: 25 }); + + expect(result.status).toBe("unreachable"); + expect(result.message).toBe("ECONNREFUSED"); + expect(JSON.stringify(result)).not.toContain("super-secret"); + }); +}); diff --git a/packages/engine/src/__tests__/pi.test.ts b/packages/engine/src/__tests__/pi.test.ts index 48c211f721..3d3936314e 100644 --- a/packages/engine/src/__tests__/pi.test.ts +++ b/packages/engine/src/__tests__/pi.test.ts @@ -714,6 +714,71 @@ describe("session failure diagnostics", () => { expect(sessionWithThinking.setThinkingLevel).toHaveBeenCalledWith("xhigh"); }); + it("forwards materialized MCP servers into session creation and prompt options for supported providers", async () => { + const createAgentSessionMock = vi.mocked(createAgentSession); + const session = { + model: { provider: "test", id: "primary-model" }, + prompt: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + sessionFile: undefined, + } as unknown as AgentSession; + const mcpServers = [ + { name: "docs", transport: "stdio" as const, command: "node", args: ["server.js"], env: { API_KEY: "SECRET" } }, + ]; + + createAgentSessionMock.mockReset(); + createAgentSessionMock.mockResolvedValueOnce({ session } as any); + + const created = await createFnAgent({ + cwd: "/test/project", + systemPrompt: "Test MCP forwarding", + defaultProvider: "anthropic", + defaultModelId: "primary-model", + mcpServers, + }); + await (created.session as any).promptWithFallback("Use docs"); + + expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ mcpServers })); + expect(session.prompt).toHaveBeenCalledWith("Use docs", expect.objectContaining({ mcpServers })); + }); + + it("skips MCP forwarding for unsupported mock provider and emits a content-free skip log", async () => { + const createAgentSessionMock = vi.mocked(createAgentSession); + const session = { + model: { provider: "test", id: "primary-model" }, + prompt: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + sessionFile: undefined, + } as unknown as AgentSession; + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + createAgentSessionMock.mockReset(); + createAgentSessionMock.mockResolvedValueOnce({ session } as any); + + try { + const created = await createFnAgent({ + cwd: "/test/project", + systemPrompt: "Test MCP skip", + defaultProvider: "mock", + defaultModelId: "scripted", + mcpServers: [{ name: "docs", transport: "stdio", command: "node", env: { TOKEN: "SECRET" } }], + }); + await (created.session as any).promptWithFallback("Use docs"); + + expect(createAgentSessionMock.mock.calls[0]?.[0]).not.toHaveProperty("mcpServers"); + expect(session.prompt).toHaveBeenCalledWith("Use docs"); + const skipLog = consoleErrorSpy.mock.calls.find(([message]) => String(message).includes("mcp.forwarding.skipped")); + expect(skipLog?.[0]).toContain('"skippedCount":1'); + expect(skipLog?.[0]).toContain('"provider":"mock"'); + expect(skipLog?.[0]).not.toContain("SECRET"); + expect(skipLog?.[0]).not.toContain("docs"); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + it("retries prompt on thinking/reasoning conflict without switching fallback models", async () => { const createAgentSessionMock = vi.mocked(createAgentSession); diff --git a/packages/engine/src/agent-reflection.ts b/packages/engine/src/agent-reflection.ts index 8af5d36688..523367b25a 100644 --- a/packages/engine/src/agent-reflection.ts +++ b/packages/engine/src/agent-reflection.ts @@ -14,6 +14,7 @@ import type { } from "@fusion/core"; import { createLogger } from "./logger.js"; import { createFnAgent, promptWithFallback } from "./pi.js"; +import { resolveMcpServersForStore } from "./mcp-resolution.js"; const reflectionLog = createLogger("reflection"); @@ -102,12 +103,14 @@ export class AgentReflectionService { } let responseText = ""; + // FNXC:McpConfig 2026-06-25-23:05: Agent-reflection sessions receive the resolved MCP set for the reflected agent identity while preserving the no-secret-logging contract at the runtime forwarding seam. const { session } = await createFnAgent({ cwd: this.rootDir, systemPrompt: REFLECTION_SYSTEM_PROMPT, tools: "readonly", defaultProvider: this.modelProvider, defaultModelId: this.modelId, + mcpServers: (await resolveMcpServersForStore(this.taskStore, { agentId })).servers, onText: (delta: string) => { responseText += delta; }, diff --git a/packages/engine/src/agent-runtime.ts b/packages/engine/src/agent-runtime.ts index ad8ba417a3..48de6808f4 100644 --- a/packages/engine/src/agent-runtime.ts +++ b/packages/engine/src/agent-runtime.ts @@ -15,7 +15,7 @@ */ import type { AgentSession, SessionManager, ToolDefinition } from "@earendil-works/pi-coding-agent"; -import type { PermanentAgentGatingContext } from "@fusion/core"; +import type { PermanentAgentGatingContext, ResolvedMcpServerDefinition } from "@fusion/core"; import type { SkillSelectionContext } from "./skill-resolver.js"; import type { FallbackModelUsedPayload } from "./pi.js"; import type { AgentActionGateContext } from "./agent-action-gate.js"; @@ -44,6 +44,24 @@ export interface AgentMcpServerConfig { env: { name: string; value: string }[]; } +export type AgentRuntimeMcpServerConfig = ResolvedMcpServerDefinition | AgentMcpServerConfig; + +export function normalizeAgentRuntimeMcpServers( + servers: AgentRuntimeMcpServerConfig[] | undefined, +): ResolvedMcpServerDefinition[] | undefined { + if (!servers || servers.length === 0) return undefined; + return servers.map((server) => { + if ("transport" in server) return server; + return { + name: server.name, + transport: "stdio", + command: server.command, + args: server.args, + env: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])), + }; + }); +} + export interface AgentRuntimeOptions { /** Working directory for the agent session */ cwd: string; @@ -97,12 +115,18 @@ export interface AgentRuntimeOptions { /** Runtime-facing context for non-pi runtimes that cannot consume JS ToolDefinition objects directly. */ runtimeContext?: AgentRuntimeContext; /** - * MCP servers to forward to the runtime's agent session (U10 — Route A ACP). - * Consumed by runtimes that speak MCP (e.g. the ACP runtime forwards them on - * `session/new`); ignored by runtimes that don't. Tool calls still route - * through the runtime's permission floor. + * MCP servers to forward to the runtime's agent session. New callers pass the + * FN-7022 resolved/materialized three-transport shape; the legacy U10 Route A + * stdio-only ACP shape remains accepted as a subset/adapter. + * + * FNXC:McpConfig 2026-06-25-21:55: + * All AI lanes share this runtime option so executor, reviewer, validator, + * merger, workflow-node, summarization, evaluator, planning, and chat sessions + * receive the same trusted MCP server set when their selected runtime supports + * MCP. Runtime implementations that cannot consume MCP must skip it without + * logging server contents. */ - mcpServers?: AgentMcpServerConfig[]; + mcpServers?: AgentRuntimeMcpServerConfig[]; /** Optional task-scoped environment variables for session-local subprocesses. */ taskEnv?: NodeJS.ProcessEnv; /** diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index 7d252371d5..55c53d8a71 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -308,6 +308,8 @@ export async function createResolvedAgentSession( ...runtimeOptionsRaw, ...(mergedSkillNames.length > 0 ? { skills: mergedSkillNames } : {}), }; + // FNXC:McpConfig 2026-06-25-22:06: + // createResolvedAgentSession is the common lane helper for executor, reviewer, validator, workflow model-node, summarization, and merger-adjacent paths that pass MCP through this seam. Preserve `mcpServers` verbatim here; runtime-resolution/pi own support-gated forwarding and content-free skip logging. const useMockRuntime = isMockProviderId(runtimeOptions.defaultProvider); const effectiveRuntimeOptions = useMockRuntime diff --git a/packages/engine/src/evaluator.ts b/packages/engine/src/evaluator.ts index 09ac2fe910..7acbb1687d 100644 --- a/packages/engine/src/evaluator.ts +++ b/packages/engine/src/evaluator.ts @@ -19,6 +19,7 @@ import { collectTaskEvaluationEvidence } from "./evaluator-evidence.js"; import { materializeEvalFollowUps, normalizeEvalFollowUps, resolveEvalFollowUpPolicyMode } from "./eval-followups.js"; import { createFnAgent, promptWithFallback } from "./pi.js"; import { createLogger } from "./logger.js"; +import { resolveMcpServersForStore } from "./mcp-resolution.js"; const log = createLogger("evaluator"); @@ -149,12 +150,14 @@ export class HybridEvaluatorService { } let text = ""; + // FNXC:McpConfig 2026-06-25-23:05: Evaluator sessions are an AI lane and receive the store-resolved MCP set at session creation; createFnAgent applies runtime support gating without logging plaintext env/header secrets. const { session } = await createFnAgent({ cwd: this.deps.cwd, systemPrompt: "You are a strict evaluator. Reply with JSON only.", tools: "readonly", defaultProvider: provider, defaultModelId: modelId, + mcpServers: this.deps.store ? (await resolveMcpServersForStore(this.deps.store)).servers : undefined, onText: (delta) => { text += delta; }, diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 2e7a5d853f..0b0677c1b2 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -76,6 +76,7 @@ import { resolveExecutorSessionModel, } from "./agent-session-helpers.js"; import { buildSessionSkillContext } from "./session-skill-context.js"; +import { resolveMcpServersForStore } from "./mcp-resolution.js"; import { reviewStep, type ReviewVerdict, type ReviewResult } from "./reviewer.js"; import { selectUserCommentsForAgentContext } from "./agent-user-comments.js"; import { resolveSandboxBackend } from "./sandbox/index.js"; @@ -2451,6 +2452,12 @@ export class TaskExecutor { * prevents new work dispatch — running sessions continue to completion. * Paused tasks are moved back to `todo` rather than marked as `failed`. */ + private async resolveMcpServers(agentId?: string | null) { + // FNXC:McpConfig 2026-06-25-22:20: + // Executor-owned lanes (main execution, retry, workflow model nodes, self-fix, and spawned child sessions) resolve the same trusted MCP server set from the task store immediately before session creation so secret material is never persisted in task state. + return (await resolveMcpServersForStore(this.store, { agentId: agentId ?? undefined })).servers; + } + constructor( private store: TaskStore, private rootDir: string, @@ -7941,6 +7948,8 @@ export class TaskExecutor { effectiveAgentId: stepColumnAgent?.agent.id, actionGateContext: this.buildActionGateContext(task.id, stepIdentityAgent, settings.defaultAgentPermissionPolicy), permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, stepIdentityAgent, settings.defaultAgentPermissionPolicy), + // FNXC:McpConfig 2026-06-25-23:03: Per-step workflow sessions are an executor lane, so they inherit the task's resolved MCP set from the effective step identity agent and never re-read or log plaintext secret values. + mcpServers: await this.resolveMcpServers(stepIdentityAgent?.id), // Pass skill selection context from the main executor session skillSelection: skillContext.skillSelectionContext, // Pass agentStore and messageStore for delegation and messaging tools @@ -8753,6 +8762,7 @@ export class TaskExecutor { settings, sessionManager, taskEnv, + mcpServers: await this.resolveMcpServers(identityAgent?.id), // Skill selection: use assigned agent skills if available, otherwise role fallback ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), // Column-agent principal alignment (plan U5, R5): action gating is @@ -9199,6 +9209,7 @@ export class TaskExecutor { settings, sessionManager: SessionManager.create(worktreePath), taskEnv, + mcpServers: await this.resolveMcpServers(identityAgent?.id), // Skill selection: use assigned agent skills if available, otherwise role fallback ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), // U5 (R5): retry session re-keys gating to the effective principal, @@ -12273,6 +12284,7 @@ Do not refactor, rename broadly, or make opportunistic improvements. runAuditor: createRunAuditor(this.store, this.getRunContextFor(task.id)), settings, taskEnv: extraEnv, + mcpServers: await this.resolveMcpServers(undefined), // FNXC:SessionRouting 2026-06-24-11:20: // #1675: propagate task id so verification-fix requests carry the same // X-Session-Id/X-Session-Affinity as the primary session. @@ -13581,6 +13593,7 @@ You have access to the file system to review changes.${verdictBlock}`; runAuditor: createRunAuditor(this.store, this.getRunContextFor(task.id)), settings, taskEnv: stepEnv, + mcpServers: await this.resolveMcpServers(undefined), // FNXC:SessionRouting 2026-06-24-11:20: // #1675: propagate task id so workflow-step requests carry the same // X-Session-Id/X-Session-Affinity as the primary session. @@ -16346,6 +16359,7 @@ Child agent: ${agent.id} (${name})`; runAuditor: createRunAuditor(this.store, this.getRunContextFor(taskId)), settings, taskEnv, + mcpServers: await this.resolveMcpServers(agent.id), // FNXC:SessionRouting 2026-06-24-11:20: // #1675: propagate task id so child-agent requests carry the same // X-Session-Id/X-Session-Affinity as the parent task session. diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 18bd8716c4..1fb9137f57 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -300,6 +300,9 @@ export { } from "./merger-squash-audit.js"; export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js"; export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js"; +export { resolveMcpServersForRuntime, resolveMcpServersForStore, type ResolvedMcpServersForRuntime } from "./mcp-resolution.js"; +export { runtimeSupportsMcp, logMcpForwardingSkipped } from "./mcp-runtime-support.js"; +export { validateMcpServer, type McpValidationResult, type ValidateMcpServerOptions } from "./mcp-validation-service.js"; export { createInteractiveAiSessionWith, createCliAgentPlanningSessionWith, diff --git a/packages/engine/src/mcp-resolution.ts b/packages/engine/src/mcp-resolution.ts new file mode 100644 index 0000000000..0edb127dfd --- /dev/null +++ b/packages/engine/src/mcp-resolution.ts @@ -0,0 +1,81 @@ +import { + materializeMcpServersSecrets, + resolveEffectiveMcpServers, + type GlobalSettings, + type McpSecretReader, + type McpSecretReaderIdentity, + type McpSecretResolutionError, + type ProjectSettings, + type ResolvedMcpServerDefinition, +} from "@fusion/core"; + +export interface ResolveMcpServersForRuntimeOptions { + globalSettings?: Pick | null; + projectSettings?: Pick | null; + secrets: McpSecretReader; + reader?: McpSecretReaderIdentity; +} + +export interface ResolvedMcpServersForRuntime { + servers: ResolvedMcpServerDefinition[]; + errors: McpSecretResolutionError[]; +} + +/** + * FNXC:McpConfig 2026-06-25-21:43: + * Runtime MCP forwarding uses Fusion's trusted-once-enabled model: enabled effective servers are materialized once at session/probe creation and then forwarded without per-call prompts. Plaintext env/header values exist only in this in-memory return value and callers must log only counts/errors, never server contents. + */ +export async function resolveMcpServersForRuntime( + options: ResolveMcpServersForRuntimeOptions, +): Promise { + const effective = resolveEffectiveMcpServers(options.globalSettings, options.projectSettings); + if (effective.length === 0) return { servers: [], errors: [] }; + + const materialized = await materializeMcpServersSecrets( + effective, + options.secrets, + options.reader ?? {}, + ); + return { + servers: materialized.value ?? [], + errors: materialized.errors, + }; +} + +export interface McpSettingsAndSecretsStore { + getSettingsByScope?(): Promise<{ + global: Pick; + project: Partial>; + }>; + getSecretsStore?(): Promise | McpSecretReader; +} + +const emptyMcpSecretReader: McpSecretReader = { + async revealSecret() { + throw new Error("MCP secret reader is unavailable"); + }, +}; + +export async function resolveMcpServersForStore( + store: McpSettingsAndSecretsStore, + reader?: McpSecretReaderIdentity, +): Promise { + /* + * FNXC:McpConfig 2026-06-26-01:07: + * Older tests and lightweight TaskStore doubles may not implement the settings/secrets seams because they never configure MCP. Treat those stores as having no enabled MCP servers so all AI lanes keep their existing behavior while real stores still forward the resolved runtime configuration. + */ + if (typeof store.getSettingsByScope !== "function") { + return { servers: [], errors: [] }; + } + + const [settings, secrets] = await Promise.all([ + store.getSettingsByScope(), + typeof store.getSecretsStore === "function" ? store.getSecretsStore() : emptyMcpSecretReader, + ]); + return resolveMcpServersForRuntime({ + globalSettings: settings.global, + projectSettings: settings.project, + secrets, + reader, + }); +} diff --git a/packages/engine/src/mcp-runtime-support.ts b/packages/engine/src/mcp-runtime-support.ts new file mode 100644 index 0000000000..bf353d6975 --- /dev/null +++ b/packages/engine/src/mcp-runtime-support.ts @@ -0,0 +1,41 @@ +import { MOCK_PROVIDER_ID } from "@fusion/core"; +import { createLogger } from "./logger.js"; + +const mcpRuntimeLog = createLogger("mcp-runtime"); +const SUPPORTED_RUNTIME_IDS = new Set(["pi", "default-pi", "claude", "claude-code", "claude-acp", "acp"]); + +function normalizeId(value: string | undefined): string { + return value?.trim().toLowerCase() ?? ""; +} + +/** + * FNXC:McpConfig 2026-06-25-21:35: + * MCP server definitions may contain materialized secrets by the time they reach runtime forwarding, so support decisions must be pure and content-free. Only known MCP-capable pi/Claude/ACP runtimes receive servers; mock and unknown runtimes skip forwarding without inspecting or logging server definitions. + */ +export function runtimeSupportsMcp(runtimeId: string | undefined, provider?: string): boolean { + if (normalizeId(provider) === MOCK_PROVIDER_ID) return false; + const normalizedRuntimeId = normalizeId(runtimeId); + if (!normalizedRuntimeId) return false; + if (SUPPORTED_RUNTIME_IDS.has(normalizedRuntimeId)) return true; + // Vendor/plugin runtime ids often include their transport family in a prefix/suffix; keep this content-free fuzzy match so future Claude/ACP runtimes do not need to expose server definitions to prove support. + return normalizedRuntimeId.includes("claude") || normalizedRuntimeId.includes("acp"); +} + +export interface McpForwardingSkipDetails { + runtimeId?: string; + provider?: string; + skippedCount: number; + lane?: string; +} + +export function logMcpForwardingSkipped(details: McpForwardingSkipDetails): void { + if (details.skippedCount <= 0) return; + mcpRuntimeLog.log(JSON.stringify({ + event: "mcp.forwarding.skipped", + reason: "unsupported-runtime", + runtimeId: details.runtimeId ?? null, + provider: details.provider ?? null, + lane: details.lane ?? null, + skippedCount: details.skippedCount, + })); +} diff --git a/packages/engine/src/mcp-validation-service.ts b/packages/engine/src/mcp-validation-service.ts new file mode 100644 index 0000000000..e140269560 --- /dev/null +++ b/packages/engine/src/mcp-validation-service.ts @@ -0,0 +1,146 @@ +import { superviseSpawn, type SupervisedChild } from "@fusion/core"; +import type { ResolvedMcpServerDefinition } from "@fusion/core"; + +export type McpValidationStatus = "valid" | "unreachable" | "error"; + +export interface McpValidationResult { + status: McpValidationStatus; + message?: string; +} + +export interface McpStdioProbeOptions { + timeoutMs: number; + cwd?: string; +} + +export type McpStdioProbe = ( + server: Extract, + options: McpStdioProbeOptions, +) => Promise; + +export type McpFetch = (input: string | URL | Request, init?: RequestInit) => Promise; + +export interface ValidateMcpServerOptions { + timeoutMs?: number; + cwd?: string; + stdioProbe?: McpStdioProbe; + fetchImpl?: McpFetch; +} + +const DEFAULT_TIMEOUT_MS = 5_000; +const SIGKILL_GRACE_MS = 1_000; + +/** + * FNXC:McpConfig 2026-06-25-23:26: + * MCP validation probes are bounded and content-free: stdio uses superviseSpawn with a lifetime cap, HTTP transports use AbortController timeouts, and neither path logs or returns resolved env/header secret values. + */ +export async function validateMcpServer( + server: ResolvedMcpServerDefinition, + options: ValidateMcpServerOptions = {}, +): Promise { + const timeoutMs = normalizeTimeout(options.timeoutMs); + if (server.transport === "stdio") { + return (options.stdioProbe ?? defaultStdioProbe)(server, { timeoutMs, cwd: options.cwd }); + } + + return validateHttpMcpServer(server, { + timeoutMs, + fetchImpl: options.fetchImpl ?? globalThis.fetch?.bind(globalThis), + }); +} + +function normalizeTimeout(timeoutMs: number | undefined): number { + return Number.isFinite(timeoutMs) && timeoutMs !== undefined && timeoutMs > 0 + ? Math.min(timeoutMs, 30_000) + : DEFAULT_TIMEOUT_MS; +} + +async function validateHttpMcpServer( + server: Extract, + options: { timeoutMs: number; fetchImpl?: McpFetch }, +): Promise { + if (!options.fetchImpl) { + return { status: "error", message: "fetch is unavailable in this runtime" }; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), options.timeoutMs); + try { + const response = await options.fetchImpl(server.url, { + method: "GET", + headers: server.headers, + signal: controller.signal, + }); + + if (response.status >= 500) { + return { status: "error", message: `server responded with HTTP ${response.status}` }; + } + + return { status: "valid", message: `server responded with HTTP ${response.status}` }; + } catch (error) { + if (isAbortError(error)) { + return { status: "unreachable", message: "connection timed out" }; + } + return { status: "unreachable", message: errorMessage(error, "connection failed") }; + } finally { + clearTimeout(timer); + } +} + +async function defaultStdioProbe( + server: Extract, + options: McpStdioProbeOptions, +): Promise { + let supervised: SupervisedChild; + try { + supervised = superviseSpawn(server.command, server.args ?? [], { + cwd: options.cwd, + stdio: ["ignore", "ignore", "ignore"], + env: { + ...process.env, + ...(server.env ?? {}), + }, + killGraceMs: SIGKILL_GRACE_MS, + maxLifetimeMs: options.timeoutMs + SIGKILL_GRACE_MS, + }); + } catch (error) { + return { status: "error", message: errorMessage(error, "failed to start stdio server") }; + } + + return new Promise((resolve) => { + let settled = false; + const settle = (result: McpValidationResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + + const timer = setTimeout(() => { + supervised.kill("SIGTERM"); + settle({ status: "valid", message: "stdio server stayed reachable through the probe window" }); + }, options.timeoutMs); + + supervised.child.once("error", (error) => { + settle({ status: "error", message: errorMessage(error, "stdio probe failed") }); + }); + + supervised.waitExit().then((exit) => { + if (exit.code === 0) { + settle({ status: "valid", message: "stdio command exited successfully" }); + } else { + settle({ status: "unreachable", message: `stdio command exited with ${exit.signal ?? exit.code ?? "unknown status"}` }); + } + }).catch((error) => { + settle({ status: "error", message: errorMessage(error, "stdio probe failed") }); + }); + }); +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message.trim() ? error.message : fallback; +} diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 7f73ed1497..1999c87200 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -70,6 +70,7 @@ import { createLogger } from "./logger.js"; import { captureSingleCommitLandedMetadata, type MergerOptions } from "./merger.js"; import { installWorktreeDependencies } from "./merge-dependency-sync.js"; import { activeSessionRegistry } from "./active-session-registry.js"; +import { resolveMcpServersForStore } from "./mcp-resolution.js"; /* FNXC:Workspace 2026-06-22-14:10 (Phase D review G — cycle dissolved): `isRepoLanded` + `FUSION_TASK_ID_TRAILER_KEY` moved to the dependency-free `workspace-land-predicate` @@ -255,6 +256,8 @@ function makeMutatingAgent(store: TaskStore, settings: Settings, taskId: string, defaultThinkingLevel: settings.defaultThinkingLevel, runAuditor: audit, settings, + // FNXC:McpConfig 2026-06-25-22:48: merger-ai is the production merge path, so the mutating agent resolves enabled MCP servers at session creation and relies on the shared runtime guard for unsupported providers. + mcpServers: (await resolveMcpServersForStore(store)).servers, taskId, }); options.onSession?.(session); @@ -312,6 +315,8 @@ function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, o defaultThinkingLevel: settings.defaultThinkingLevel, runAuditor: audit, settings, + // FNXC:McpConfig 2026-06-25-22:48: The production merge reviewer receives the same materialized MCP set as the mutating merge agent, preserving all-lane forwarding without logging server contents. + mcpServers: (await resolveMcpServersForStore(store)).servers, taskId, }); options.onSession?.(session); diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index d9c372906f..b595cf048e 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -115,6 +115,7 @@ import { accumulateSessionTokenUsage } from "./session-token-usage.js"; import { createResolvedAgentSession, extractRuntimeHint, resolveMergerSessionModel } from "./agent-session-helpers.js"; import { createFallbackModelObserver } from "./fallback-model-observer.js"; import { buildSessionSkillContext } from "./session-skill-context.js"; +import { resolveMcpServersForStore } from "./mcp-resolution.js"; import { classifyTaskWorktree, getRegisteredWorktreeBranches, isRepoRootPath, RemovalReason, removeWorktree, type WorktreePool } from "./worktree-pool.js"; import { activeSessionRegistry } from "./active-session-registry.js"; import { AgentLogger } from "./agent-logger.js"; @@ -161,6 +162,12 @@ import { appendAutoWidenedScopeToPrompt, evaluateScopeAutoWiden } from "./merger export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js"; export { IntegrationBranchConcurrentAdvanceError } from "./merger-ref-update-advance.js"; +async function resolveMergerMcpServers(store?: TaskStore, agentId?: string | null) { + // FNXC:McpConfig 2026-06-25-22:27: + // Merger-owned sessions resolve enabled MCP servers at session creation for conflict resolution, verification fixes, autostash recovery, and post-merge workflow nodes. Secret material stays in memory and is forwarded only through the shared runtime guard. + return store ? (await resolveMcpServersForStore(store, { agentId: agentId ?? undefined })).servers : undefined; +} + /** * After `advanceIntegrationBranchRef` ff-updates `refs/heads/`, * any other worktree still checked out on that branch keeps its index + working @@ -2012,6 +2019,7 @@ Do not refactor, rename broadly, or make opportunistic improvements. source: "merger", }), settings, + mcpServers: await resolveMergerMcpServers(store, assignedAgent?.id), // Skill selection: use assigned agent skills if available, otherwise role fallback ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), taskId, @@ -3205,6 +3213,7 @@ ${fileList} source: "merger", }), settings, + mcpServers: await resolveMergerMcpServers(store, assignedAgent?.id), ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), taskId, taskTitle: taskForSkillContext?.title, @@ -3621,6 +3630,7 @@ ${fileList} source: "merger", }), settings, + mcpServers: await resolveMergerMcpServers(store, assignedAgent?.id), ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), taskId, taskTitle: taskForSkillContext?.title, @@ -7117,6 +7127,7 @@ You are assisting with a paused \`git pull --rebase\`. source: "merger", }), settings, + mcpServers: await resolveMergerMcpServers(store), taskId, onFallbackModelUsed: createFallbackModelObserver({ agent: "merger", @@ -12120,6 +12131,8 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo source: "merger", }), settings, + // FNXC:McpConfig 2026-06-25-23:04: The primary merge-authoring agent is part of the merger lane and receives the resolved MCP set under the shared runtime-support guard, matching conflict/verification merge sessions without exposing secret material. + mcpServers: await resolveMergerMcpServers(store, assignedAgent?.id), // Skill selection: use assigned agent skills if available, otherwise role fallback ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), taskId, @@ -12815,6 +12828,7 @@ If issues are found that need attention, describe them clearly and include concr source: "merger", }), settings, + mcpServers: await resolveMergerMcpServers(store), // Skill selection: use assigned agent skills if available, otherwise role fallback ...(postMergeSkillContext?.skillSelectionContext ? { skillSelection: postMergeSkillContext.skillSelectionContext } : {}), ...(readonlyCustomTools.allowed.length > 0 ? { customTools: readonlyCustomTools.allowed } : {}), diff --git a/packages/engine/src/mission-execution-loop.ts b/packages/engine/src/mission-execution-loop.ts index 3f8819bf1e..49c39bc5cd 100644 --- a/packages/engine/src/mission-execution-loop.ts +++ b/packages/engine/src/mission-execution-loop.ts @@ -33,6 +33,7 @@ import { } from "./agent-session-helpers.js"; import { createLogger } from "./logger.js"; import { createFallbackModelObserver } from "./fallback-model-observer.js"; +import { resolveMcpServersForStore } from "./mcp-resolution.js"; import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js"; /** Logger for the mission execution loop subsystem. */ @@ -564,6 +565,8 @@ export class MissionExecutionLoop extends EventEmitter { defaultThinkingLevel: "medium", runAuditor, settings, + // FNXC:McpConfig 2026-06-25-23:19: Mission validation is a validator lane and receives the store-resolved MCP set at session creation; runtime gating and content-free skip logging remain centralized in pi. + mcpServers: (await resolveMcpServersForStore(this.taskStore)).servers, onText: (_delta) => { // Could stream this to a log entry if needed }, diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index cad52e0130..acaa89d8bb 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -50,6 +50,7 @@ import type { AgentPermissionPolicyActionCategory, PermanentAgentActionCategory, PermanentAgentGatingContext, + ResolvedMcpServerDefinition, } from "@fusion/core"; import { resolveSessionSkills, @@ -72,6 +73,7 @@ import type { SystemPromptLayers } from "./prompt-layers.js"; import { READONLY_ALLOWLIST, filterCustomToolsForReadonly, isReadonlyAllowed } from "./workflow-step-tool-policy.js"; import { createStreamingDeltaNormalizer } from "./streaming-delta.js"; import { isModelAuthTierIncompatibilityError, isUnsupportedMessageRoleError } from "./transient-error-detector.js"; +import { logMcpForwardingSkipped, runtimeSupportsMcp } from "./mcp-runtime-support.js"; export { isModelAuthTierIncompatibilityError } from "./transient-error-detector.js"; const RTK_ACCEPTED_REWRITE_EXIT_CODES = new Set([0, 3]); @@ -991,6 +993,11 @@ export interface AgentOptions { * loader so callers (e.g. plugins that install skills to a private dir) can * make `skills`/`skillSelection` names discoverable in the live session. */ additionalSkillPaths?: string[]; + /** + * Resolved/materialized MCP servers for the session. The runtime-support guard + * below decides whether to forward or skip them without logging contents. + */ + mcpServers?: ResolvedMcpServerDefinition[]; /** Optional task-scoped env injected into this session's subprocess tools only. */ taskEnv?: NodeJS.ProcessEnv; /** Last-chance abort hook fired immediately before `createAgentSession`. @@ -1960,8 +1967,23 @@ export function attachSessionRoutingHeaders(modelRegistry: ModelRegistry, sessio * Returned sessions are wrapped so `session.dispose()` emits pi's * `session_shutdown` extension event before teardown. */ +function withMcpPromptOptions(promptOptions: unknown, mcpServers: ResolvedMcpServerDefinition[] | undefined): unknown { + if (!mcpServers || mcpServers.length === 0) return promptOptions; + if (promptOptions && typeof promptOptions === "object" && !Array.isArray(promptOptions)) { + return { ...(promptOptions as Record), mcpServers }; + } + return { mcpServers }; +} + export async function createFnAgent(options: AgentOptions): Promise { piLog.log(`createFnAgent called (tools=${options.tools}, provider=${options.defaultProvider}, model=${options.defaultModelId})`); + // FNXC:McpConfig 2026-06-25-22:02: + // The pi session is the final shared forwarding seam for direct createFnAgent lanes. Forward the resolved MCP set only to MCP-capable provider/runtime combinations and keep unsupported lanes content-free by logging just provider/runtime/count metadata. + const requestedMcpServers = options.mcpServers ?? []; + const forwardedMcpServers = runtimeSupportsMcp("pi", options.defaultProvider) ? requestedMcpServers : []; + if (requestedMcpServers.length > 0 && forwardedMcpServers.length === 0) { + logMcpForwardingSkipped({ runtimeId: "pi", provider: options.defaultProvider, skippedCount: requestedMcpServers.length, lane: "createFnAgent" }); + } const authStorage = createFusionAuthStorage(); const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath()); @@ -2222,7 +2244,7 @@ export async function createFnAgent(options: AgentOptions): Promise if (options.beforeSpawnSession) { await options.beforeSpawnSession(); } - const createSessionOptions: Parameters[0] = { + const createSessionOptions: Parameters[0] & { mcpServers?: ResolvedMcpServerDefinition[] } = { cwd: options.cwd, authStorage, modelRegistry, @@ -2231,6 +2253,7 @@ export async function createFnAgent(options: AgentOptions): Promise customTools: customToolList, sessionManager, settingsManager, + ...(forwardedMcpServers.length > 0 ? { mcpServers: forwardedMcpServers } : {}), ...(modelOverride ? { model: modelOverride } : {}), }; @@ -2357,14 +2380,15 @@ export async function createFnAgent(options: AgentOptions): Promise }; promptableSession.promptWithFallback = async (prompt: string, promptOptions?: unknown) => { + const effectivePromptOptions = withMcpPromptOptions(promptOptions, forwardedMcpServers); try { - await promptSessionAndCheck(activeSession, prompt, promptOptions); + await promptSessionAndCheck(activeSession, prompt, effectivePromptOptions); return; } catch (err: any) { const errorMessage = err?.message || ""; if (isContextLimitError(errorMessage)) { // Context limit error — attempt auto-compaction and retry once - const promptMemoryRetry = await retryWithCompactedPromptMemory(activeSession, prompt, promptOptions); + const promptMemoryRetry = await retryWithCompactedPromptMemory(activeSession, prompt, effectivePromptOptions); if (promptMemoryRetry.recovered) { return; } @@ -2375,7 +2399,7 @@ export async function createFnAgent(options: AgentOptions): Promise } } - const promptSectionRetry = await retryWithCompactedPromptSections(activeSession, prompt, promptOptions); + const promptSectionRetry = await retryWithCompactedPromptSections(activeSession, prompt, effectivePromptOptions); if (promptSectionRetry.recovered) { return; } @@ -2392,7 +2416,7 @@ export async function createFnAgent(options: AgentOptions): Promise if (compactResult) { piLog.log(`promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`); try { - await promptSessionAndCheck(activeSession, prompt, promptOptions); + await promptSessionAndCheck(activeSession, prompt, effectivePromptOptions); return; } catch (retryErr: any) { const retryErrorMessage = retryErr?.message || ""; @@ -2410,7 +2434,7 @@ export async function createFnAgent(options: AgentOptions): Promise thinkingCompatibilityDisabled = true; piLog.warn(`Prompt failed with thinking/reasoning conflict; retrying without explicit thinking level: ${errorMessage}`); const recoveredSession = await swapPromptSession(selectedModel); - await promptSessionAndCheck(recoveredSession, prompt, promptOptions); + await promptSessionAndCheck(recoveredSession, prompt, effectivePromptOptions); return; } @@ -2424,12 +2448,12 @@ export async function createFnAgent(options: AgentOptions): Promise // Retry with fallback model, also with auto-compaction support try { - await promptSessionAndCheck(fallbackSession, prompt, promptOptions); + await promptSessionAndCheck(fallbackSession, prompt, effectivePromptOptions); return; } catch (fallbackErr: any) { const fallbackErrorMessage = fallbackErr?.message || ""; if (isContextLimitError(fallbackErrorMessage)) { - const promptMemoryRetry = await retryWithCompactedPromptMemory(fallbackSession, prompt, promptOptions); + const promptMemoryRetry = await retryWithCompactedPromptMemory(fallbackSession, prompt, effectivePromptOptions); if (promptMemoryRetry.recovered) { return; } @@ -2440,7 +2464,7 @@ export async function createFnAgent(options: AgentOptions): Promise } } - const promptSectionRetry = await retryWithCompactedPromptSections(fallbackSession, prompt, promptOptions); + const promptSectionRetry = await retryWithCompactedPromptSections(fallbackSession, prompt, effectivePromptOptions); if (promptSectionRetry.recovered) { return; } @@ -2457,7 +2481,7 @@ export async function createFnAgent(options: AgentOptions): Promise if (compactResult) { piLog.log(`promptWithFallback: fallback compaction succeeded (${compactResult.tokensBefore} tokens) — retrying`); try { - await promptSessionAndCheck(fallbackSession, prompt, promptOptions); + await promptSessionAndCheck(fallbackSession, prompt, effectivePromptOptions); return; } catch (retryErr: any) { const retryErrorMessage = retryErr?.message || ""; diff --git a/packages/engine/src/reviewer.ts b/packages/engine/src/reviewer.ts index 558111ee9c..3c08cb1296 100644 --- a/packages/engine/src/reviewer.ts +++ b/packages/engine/src/reviewer.ts @@ -35,6 +35,7 @@ import { createFallbackModelObserver } from "./fallback-model-observer.js"; import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js"; import { createMemoryGetTool, createMemorySearchTool, createWebFetchTool } from "./agent-tools.js"; import { buildUserCommentsPromptSection } from "./agent-user-comments.js"; +import { resolveMcpServersForStore } from "./mcp-resolution.js"; export type ReviewType = "plan" | "code" | "spec"; export type ReviewVerdict = "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE"; @@ -362,6 +363,8 @@ export async function reviewStep( ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), taskId: options.taskId, taskTitle: options.taskTitle, + // FNXC:McpConfig 2026-06-25-22:45: Reviewer and validator sessions resolve the same trusted MCP server set as executor lanes at session creation; secret values are passed only in memory to the runtime guard. + mcpServers: options.store ? (await resolveMcpServersForStore(options.store, { agentId: options.agentId })).servers : undefined, onFallbackModelUsed: createFallbackModelObserver({ agent: "reviewer", label: "reviewer", diff --git a/packages/engine/src/runtime-resolution.ts b/packages/engine/src/runtime-resolution.ts index 34f9793f23..634867cbaf 100644 --- a/packages/engine/src/runtime-resolution.ts +++ b/packages/engine/src/runtime-resolution.ts @@ -8,6 +8,7 @@ */ import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult } from "./agent-runtime.js"; +import { normalizeAgentRuntimeMcpServers } from "./agent-runtime.js"; import type { PluginRunner } from "./plugin-runner.js"; import * as fusionCore from "@fusion/core"; import type { AgentSession } from "@earendil-works/pi-coding-agent"; @@ -93,7 +94,12 @@ export class DefaultPiRuntime implements AgentRuntime { readonly name = "Default PI Runtime"; async createSession(options: AgentRuntimeOptions): Promise { - return createFnAgent(options); + // FNXC:McpConfig 2026-06-25-22:04: + // DefaultPiRuntime is the typed bridge from shared AgentRuntimeOptions into createFnAgent. Normalize the legacy stdio ACP shape here so all lanes can pass the FN-7022 three-transport shape without breaking older Route A callers. + return createFnAgent({ + ...options, + mcpServers: normalizeAgentRuntimeMcpServers(options.mcpServers), + }); } async promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise { diff --git a/packages/engine/src/step-session-executor.ts b/packages/engine/src/step-session-executor.ts index 8f13578c95..683a9feaba 100644 --- a/packages/engine/src/step-session-executor.ts +++ b/packages/engine/src/step-session-executor.ts @@ -17,7 +17,7 @@ const execAsync = promisify(exec); import { existsSync } from "node:fs"; import { rm } from "node:fs/promises"; import type { AgentSession } from "@earendil-works/pi-coding-agent"; -import type { AgentStore, MessageStore, PermanentAgentGatingContext, TaskDetail, Settings, SteeringComment, TaskStore } from "@fusion/core"; +import type { AgentStore, MessageStore, PermanentAgentGatingContext, ResolvedMcpServerDefinition, TaskDetail, Settings, SteeringComment, TaskStore } from "@fusion/core"; import { resolvePersistAgentThinkingLog } from "@fusion/core"; import { @@ -127,6 +127,8 @@ export interface StepSessionExecutorOptions { actionGateContext?: AgentActionGateContext; /** Optional permanent-agent action gating context. */ permanentAgentGating?: PermanentAgentGatingContext; + /** Optional resolved MCP servers to forward into workflow step sessions. */ + mcpServers?: ResolvedMcpServerDefinition[]; /** Task-scoped environment injected into non-git subprocesses. */ taskEnv?: NodeJS.ProcessEnv; /** @@ -1131,6 +1133,8 @@ Follow instructions precisely and avoid unrelated changes.`, source: "step-session-executor", }), settings, + // FNXC:McpConfig 2026-06-25-23:02: Workflow model-node step sessions receive the same resolved, secret-materialized MCP server set as the parent executor; runtime support is still enforced inside the pi session seam without logging server contents. + mcpServers: this.options.mcpServers, customTools: [ ...pluginTools, ...documentTools, diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index ad02b27e75..e1688f04aa 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -94,6 +94,7 @@ import { import { buildPromptLayers, collapsePromptLayers } from "./prompt-layers.js"; import { createFallbackModelObserver } from "./fallback-model-observer.js"; import { planLog, reviewerLog, formatError } from "./logger.js"; +import { resolveMcpServersForStore } from "./mcp-resolution.js"; import { isUsageLimitError, checkSessionError, @@ -931,6 +932,8 @@ export class TriageProcessor { defaultThinkingLevel: settings.defaultThinkingLevel, runAuditor, settings, + // FNXC:McpConfig 2026-06-25-23:17: Primary triage planning is an AI lane, so it receives the store-resolved MCP set while the pi runtime-support guard decides whether to forward it without logging secret material. + mcpServers: (await resolveMcpServersForStore(this.store)).servers, // Skill selection: use assigned agent skills if available, otherwise role fallback ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), taskId: task.id, @@ -1161,6 +1164,8 @@ export class TriageProcessor { defaultThinkingLevel: settings.defaultThinkingLevel, runAuditor, settings, + // FNXC:McpConfig 2026-06-25-23:18: Fallback triage uses the same resolved MCP forwarding contract as the primary planning session so model fallback does not silently drop configured servers. + mcpServers: (await resolveMcpServersForStore(this.store)).servers, ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), taskId: task.id, taskTitle: task.title, diff --git a/packages/pi-claude-cli/index.ts b/packages/pi-claude-cli/index.ts index 110386876e..c161867dfa 100644 --- a/packages/pi-claude-cli/index.ts +++ b/packages/pi-claude-cli/index.ts @@ -21,6 +21,7 @@ import { writeMcpConfig, buildAcpMcpServers, type McpToolDef, + type UserMcpServerSpec, } from "./src/mcp-config.js"; /** @@ -83,6 +84,11 @@ function debugMcp(message: string): void { console.error(`[pi-claude-cli] ${message}`); } +function getUserMcpServers(options: unknown): UserMcpServerSpec[] { + const servers = (options as { mcpServers?: unknown } | undefined)?.mcpServers; + return Array.isArray(servers) ? servers.filter((server): server is UserMcpServerSpec => Boolean(server && typeof server === "object" && "name" in server)) : []; +} + /** * Resolve the MCP config path for the current request, regenerating it when * the set of custom tools changes. @@ -113,6 +119,7 @@ function ensureMcpConfig( description: string; parameters: Record; }>, + userMcpServers: UserMcpServerSpec[] = [], ): string | undefined { try { let toolDefs: McpToolDef[] = toolsFromContext(contextTools); @@ -132,13 +139,13 @@ function ensureMcpConfig( toolDefs = getCustomToolDefs(pi); } - if (toolDefs.length === 0) { + if (toolDefs.length === 0 && userMcpServers.length === 0) { cachedMcpConfig = undefined; return undefined; } const hash = createHash("sha1") - .update(JSON.stringify(toolDefs)) + .update(JSON.stringify({ toolDefs, userMcpServerNames: userMcpServers.map((server) => server.name) })) .digest("hex") .slice(0, 12); @@ -147,7 +154,7 @@ function ensureMcpConfig( return cachedMcpConfig.configPath; } - const configPath = writeMcpConfig(toolDefs, hash); + const configPath = writeMcpConfig(toolDefs, hash, userMcpServers); cachedMcpConfig = { hash, configPath }; const toolNames = toolDefs.map((t) => t.name).join(", "); debugMcp( @@ -255,18 +262,19 @@ export default function (pi: ExtensionAPI) { const bridgePath = resolveAcpBridgePath(); if (bridgePath) { const toolDefs = resolveToolDefs(pi, contextTools); - const hash = createHash("sha1").update(JSON.stringify(toolDefs)).digest("hex").slice(0, 12); + const userMcpServers = getUserMcpServers(options); + const hash = createHash("sha1").update(JSON.stringify({ toolDefs, userMcpServerNames: userMcpServers.map((server) => server.name) })).digest("hex").slice(0, 12); return streamViaAcp(model, context, { ...options, bridgePath, - mcpServers: buildAcpMcpServers(toolDefs, hash), + mcpServers: buildAcpMcpServers(toolDefs, hash, userMcpServers), // Forward only HOME/PATH so the bridged `claude` authenticates from the // login/keychain session (R17); never inherited process.env or API keys. bridgeEnv: { HOME: process.env.HOME, PATH: process.env.PATH }, }); } - const configPath = ensureMcpConfig(pi, contextTools); + const configPath = ensureMcpConfig(pi, contextTools, getUserMcpServers(options)); return streamViaCli(model, context, { ...options, mcpConfigPath: configPath, diff --git a/packages/pi-claude-cli/src/mcp-config.ts b/packages/pi-claude-cli/src/mcp-config.ts index 82ad4cb112..f90d2ebcef 100644 --- a/packages/pi-claude-cli/src/mcp-config.ts +++ b/packages/pi-claude-cli/src/mcp-config.ts @@ -92,6 +92,39 @@ export function toolsFromContext( })); } +export interface UserMcpServerSpec { + name: string; + enabled?: boolean; + transport?: "stdio" | "sse" | "streamable-http"; + command?: string; + args?: string[]; + env?: Record; + url?: string; + headers?: Record; +} + +function userServerToConfig(server: UserMcpServerSpec): Record | undefined { + if (server.enabled === false) return undefined; + if (server.transport === "sse" || server.transport === "streamable-http") { + if (!server.url) return undefined; + return { + transport: server.transport, + url: server.url, + ...(server.headers ? { headers: server.headers } : {}), + }; + } + if (!server.command) return undefined; + return { + command: server.command, + ...(server.args ? { args: server.args } : {}), + ...(server.env ? { env: server.env } : {}), + }; +} + +/** + * FNXC:McpConfig 2026-06-25-22:12: + * The Claude CLI MCP config combines Fusion's schema-only custom-tools server with operator-enabled user MCP servers. User env/header values are already secret-materialized by the engine, so this module must write them only to the transient MCP config and never log server contents. + */ /** * Write MCP config and tool schemas to temp files. * @@ -108,6 +141,7 @@ export function toolsFromContext( export function writeMcpConfig( toolDefs: McpToolDef[], cacheKey?: string, + userMcpServers: UserMcpServerSpec[] = [], ): string { const suffix = cacheKey ? `${process.pid}-${cacheKey}` : `${process.pid}`; @@ -124,14 +158,17 @@ export function writeMcpConfig( const serverPath = join(__dirname, "mcp-schema-server.cjs"); // Build MCP config - const config = { - mcpServers: { - "custom-tools": { - command: "node", - args: [serverPath, schemaFilePath], - }, + const mcpServers: Record = { + "custom-tools": { + command: "node", + args: [serverPath, schemaFilePath], }, }; + for (const server of userMcpServers) { + const config = userServerToConfig(server); + if (config) mcpServers[server.name] = config; + } + const config = { mcpServers }; // Write config to temp file const configFilePath = join( @@ -151,6 +188,16 @@ export interface AcpMcpServerSpec { env: { name: string; value: string }[]; } +function userServerToAcp(server: UserMcpServerSpec): AcpMcpServerSpec | undefined { + if (server.enabled === false || (server.transport && server.transport !== "stdio") || !server.command) return undefined; + return { + name: server.name, + command: server.command, + args: server.args ?? [], + env: Object.entries(server.env ?? {}).map(([name, value]) => ({ name, value })), + }; +} + /** * Build the ACP `mcpServers` spec for the same schema-only `custom-tools` server * `writeMcpConfig` produces for `--mcp-config` — but as the inline ACP shape @@ -161,13 +208,17 @@ export interface AcpMcpServerSpec { export function buildAcpMcpServers( toolDefs: McpToolDef[], cacheKey?: string, + userMcpServers: UserMcpServerSpec[] = [], ): AcpMcpServerSpec[] { - if (toolDefs.length === 0) return []; + if (toolDefs.length === 0) { + return userMcpServers.map(userServerToAcp).filter((server): server is AcpMcpServerSpec => Boolean(server)); + } const suffix = cacheKey ? `${process.pid}-${cacheKey}` : `${process.pid}`; const schemaFilePath = join(tmpdir(), `pi-claude-mcp-schemas-${suffix}.json`); writeFileSync(schemaFilePath, JSON.stringify(toolDefs)); const serverPath = join(dirname(fileURLToPath(import.meta.url)), "mcp-schema-server.cjs"); return [ { name: "custom-tools", command: "node", args: [serverPath, schemaFilePath], env: [] }, + ...userMcpServers.map(userServerToAcp).filter((server): server is AcpMcpServerSpec => Boolean(server)), ]; }