Files
fusion/packages/engine/src/mcp-validation-service.ts
gsxdsm 6c94ee069c FN-7023: forward MCP runtime config to AI lanes
Forward enabled MCP server configuration through AI session creation and validation paths.

- Resolve and materialize effective MCP servers with secret values at runtime for triage, execution, review, merge, evaluation, reflection, mission validation, and dashboard chat/planning lanes.
- Add runtime support guards, Claude CLI MCP config generation, and a dashboard validation route for MCP server reachability probes.
- Cover MCP forwarding, provider support behavior, settings resolution, validation, and pi extension config generation with targeted tests and docs.

Files changed:
 .changeset/fn-7023-mcp-runtime-forwarding.md       |   7 +
 docs/agents.md                                     |   2 +
 docs/secrets.md                                    |   1 +
 docs/settings-reference.md                         |   6 +-
 .../src/__tests__/mcp-lane-forwarding.test.ts      |  79 +++++++++++
 .../src/__tests__/mcp-validate-route.test.ts       | 111 ++++++++++++++++
 packages/dashboard/src/chat.ts                     |   3 +
 packages/dashboard/src/planning.ts                 |   5 +
 packages/dashboard/src/routes.ts                   |  90 ++++++++++++-
 .../src/__tests__/mcp-lane-forwarding.test.ts      |  95 ++++++++++++++
 .../engine/src/__tests__/mcp-resolution.test.ts    | 104 +++++++++++++++
 .../src/__tests__/mcp-runtime-support.test.ts      |  44 +++++++
 .../src/__tests__/mcp-validation-service.test.ts   |  69 ++++++++++
 packages/engine/src/__tests__/pi.test.ts           |  65 +++++++++
 packages/engine/src/agent-reflection.ts            |   3 +
 packages/engine/src/agent-runtime.ts               |  36 ++++-
 packages/engine/src/agent-session-helpers.ts       |   2 +
 packages/engine/src/evaluator.ts                   |   3 +
 packages/engine/src/executor.ts                    |  14 ++
 packages/engine/src/index.ts                       |   3 +
 packages/engine/src/mcp-resolution.ts              |  81 ++++++++++++
 packages/engine/src/mcp-runtime-support.ts         |  41 ++++++
 packages/engine/src/mcp-validation-service.ts      | 146 +++++++++++++++++++++
 packages/engine/src/merger-ai.ts                   |   5 +
 packages/engine/src/merger.ts                      |  14 ++
 packages/engine/src/mission-execution-loop.ts      |   3 +
 packages/engine/src/pi.ts                          |  44 +++++--
 packages/engine/src/reviewer.ts                    |   3 +
 packages/engine/src/runtime-resolution.ts          |   8 +-
 packages/engine/src/step-session-executor.ts       |   6 +-
 packages/engine/src/triage.ts                      |   5 +
 packages/pi-claude-cli/index.ts                    |  20 ++-
 packages/pi-claude-cli/src/mcp-config.ts           |  65 ++++++++-
 33 files changed, 1150 insertions(+), 33 deletions(-)

Fusion-Task-Id: FN-7023
Fusion-Task-Lineage: ea09c6c7-b3c3-4af6-9ca6-0ebbfec17139
2026-06-26 00:57:32 -07:00

147 lines
4.7 KiB
TypeScript

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<ResolvedMcpServerDefinition, { transport: "stdio" }>,
options: McpStdioProbeOptions,
) => Promise<McpValidationResult>;
export type McpFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
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<McpValidationResult> {
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<ResolvedMcpServerDefinition, { transport: "sse" | "streamable-http" }>,
options: { timeoutMs: number; fetchImpl?: McpFetch },
): Promise<McpValidationResult> {
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<ResolvedMcpServerDefinition, { transport: "stdio" }>,
options: McpStdioProbeOptions,
): Promise<McpValidationResult> {
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<McpValidationResult>((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;
}