fix(FN-8350): keep tasks running through MCP outages

Retry MCP bootstrap with bounded backoff and request deadlines, then continue with healthy servers when an integration remains unavailable.

Exclude definitions with unresolved secrets so graceful degradation never connects a partially authenticated server.
This commit is contained in:
gsxdsm
2026-07-18 21:58:29 -07:00
parent 74de68af72
commit b4c1a1ac77
10 changed files with 238 additions and 120 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep tasks running when an MCP server is temporarily unavailable.
category: fix
dev: Retries MCP bootstrap three times, then continues without servers that remain unavailable or lack resolved secrets.

View File

@@ -66,12 +66,10 @@ describe("executor project MCP bootstrap and approval resume invariant", () => {
expect(closes.every((close) => close.mock.calls.length === 1)).toBe(true); expect(closes.every((close) => close.mock.calls.length === 1)).toBe(true);
}); });
it("fails executor bootstrap with a sanitized outcome when secret materialization fails", async () => { it("continues executor bootstrap without a server whose secret materialization fails", async () => {
const executor = new TaskExecutor(createStore({ secretFailure: true }), "/tmp/project"); const executor = new TaskExecutor(createStore({ secretFailure: true }), "/tmp/project");
await expect((executor as any).resolveMcpServers("agent-main-007")).rejects.toThrow( await expect((executor as any).resolveMcpServers("agent-main-007")).resolves.toEqual([]);
/^MCP resolution failed: server=postiz reason=secret-materialization$/,
);
}); });
it("defers an approval decision received during unwind and dispatches exactly one resume", async () => { it("defers an approval decision received during unwind and dispatches exactly one resume", async () => {
@@ -113,7 +111,7 @@ describe("executor project MCP bootstrap and approval resume invariant", () => {
const toolset = await connectMcpSessionTools( const toolset = await connectMcpSessionTools(
[{ name: "postiz", transport: "stdio", command: "fake" }], [{ name: "postiz", transport: "stdio", command: "fake" }],
{ clientFactory: () => client, transportFactory }, { clientFactory: () => client, transportFactory, maxAttempts: 1 },
); );
expect(toolset.skipped).toEqual([{ name: "postiz", reason: "TypeError" }]); expect(toolset.skipped).toEqual([{ name: "postiz", reason: "TypeError" }]);

View File

@@ -118,8 +118,27 @@ describe("resolveMcpServersForRuntime", () => {
secrets: secrets({}), secrets: secrets({}),
}); });
expect(result.servers).toEqual([{ name: "broken", transport: "streamable-http", url: "https://mcp.example" }]); expect(result.servers).toEqual([]);
expect(result.errors).toHaveLength(1); expect(result.errors).toHaveLength(1);
expect(result.errors[0]?.serverName).toBe("broken"); expect(result.errors[0]?.serverName).toBe("broken");
}); });
it("keeps healthy servers while excluding only definitions with unresolved secrets", async () => {
const result = await resolveMcpServersForRuntime({
globalSettings: {
mcpServers: {
enabled: true,
servers: [
{ name: "healthy", transport: "stdio", command: "node" },
{ name: "broken", transport: "streamable-http", url: "https://mcp.example", headers: { Authorization: { secretRef: "missing", scope: "project" } } },
],
},
},
projectSettings: null,
secrets: secrets({}),
});
expect(result.servers).toEqual([{ name: "healthy", transport: "stdio", command: "node" }]);
expect(result.errors.map((error) => error.serverName)).toEqual(["broken"]);
});
}); });

View File

@@ -45,6 +45,8 @@ describe("connectMcpSessionTools", () => {
expect(toolset.connected).toEqual(["context7"]); expect(toolset.connected).toEqual(["context7"]);
expect(toolset.tools.map((tool) => tool.name)).toEqual(["mcp__context7__lookup"]); expect(toolset.tools.map((tool) => tool.name)).toEqual(["mcp__context7__lookup"]);
expect(client.connect).toHaveBeenCalledWith(expect.anything(), { timeout: 15_000 });
expect(client.listTools).toHaveBeenCalledWith(undefined, { timeout: 15_000 });
expect(toolset.tools[0]!.parameters).toMatchObject({ expect(toolset.tools[0]!.parameters).toMatchObject({
type: "object", type: "object",
properties: { topic: { type: "string", description: "Topic to look up" } }, properties: { topic: { type: "string", description: "Topic to look up" } },
@@ -77,6 +79,7 @@ describe("connectMcpSessionTools", () => {
const toolset = await connectMcpSessionTools([stdioServer("disabled", false), stdioServer("bad"), stdioServer("good")], { const toolset = await connectMcpSessionTools([stdioServer("disabled", false), stdioServer("bad"), stdioServer("good")], {
clientFactory: (server) => server.name === "bad" ? bad : good, clientFactory: (server) => server.name === "bad" ? bad : good,
transportFactory, transportFactory,
maxAttempts: 1,
}); });
expect(toolset.skipped).toEqual([ expect(toolset.skipped).toEqual([
@@ -86,6 +89,76 @@ describe("connectMcpSessionTools", () => {
expect(toolset.tools.map((tool) => tool.name)).toEqual(["mcp__good__read"]); expect(toolset.tools.map((tool) => tool.name)).toEqual(["mcp__good__read"]);
}); });
it("retries a failed server with fresh clients before exposing its tools", async () => {
const clients = [
{ ...fakeClient([]), connect: vi.fn(async () => { throw new TypeError("offline"); }) },
{ ...fakeClient([]), connect: vi.fn(async () => { throw new TypeError("offline"); }) },
fakeClient(["lookup"]),
];
const clientFactory = vi.fn(() => clients.shift()!);
const logger = { log: vi.fn(), warn: vi.fn() };
const toolset = await connectMcpSessionTools([stdioServer("context7")], {
clientFactory,
transportFactory,
retryDelayMs: 0,
logger,
});
expect(clientFactory).toHaveBeenCalledTimes(3);
expect(toolset.connected).toEqual(["context7"]);
expect(toolset.skipped).toEqual([]);
expect(toolset.tools.map((tool) => tool.name)).toEqual(["mcp__context7__lookup"]);
expect(logger.warn).toHaveBeenCalledWith("Retrying MCP server for pi session: name=context7 transport=stdio attempt=2/3 reason=TypeError");
expect(logger.warn).toHaveBeenCalledWith("Retrying MCP server for pi session: name=context7 transport=stdio attempt=3/3 reason=TypeError");
});
it("records one skipped server after bounded retries are exhausted", async () => {
const clients: McpSessionClient[] = [];
const clientFactory = vi.fn(() => {
const client: McpSessionClient = {
...fakeClient([]),
connect: vi.fn(async () => { throw new RangeError("unavailable"); }),
};
clients.push(client);
return client;
});
const toolset = await connectMcpSessionTools([stdioServer("context7")], {
clientFactory,
transportFactory,
retryDelayMs: 0,
});
expect(clientFactory).toHaveBeenCalledTimes(3);
expect(toolset.connected).toEqual([]);
expect(toolset.skipped).toEqual([{ name: "context7", reason: "RangeError" }]);
expect(clients).toHaveLength(3);
for (const client of clients) expect(client.close).toHaveBeenCalledTimes(1);
});
it("stops retrying when bootstrap is aborted", async () => {
const controller = new AbortController();
const client = {
...fakeClient([]),
connect: vi.fn(async () => {
controller.abort();
throw new TypeError("offline");
}),
};
const clientFactory = vi.fn(() => client);
const toolset = await connectMcpSessionTools([stdioServer("context7")], {
clientFactory,
transportFactory,
signal: controller.signal,
});
expect(clientFactory).toHaveBeenCalledTimes(1);
expect(toolset.skipped).toEqual([{ name: "context7", reason: "aborted" }]);
expect(client.close).toHaveBeenCalledTimes(1);
});
it("keeps empty tool-list connections and creates no tools", async () => { it("keeps empty tool-list connections and creates no tools", async () => {
const toolset = await connectMcpSessionTools([stdioServer("empty")], { const toolset = await connectMcpSessionTools([stdioServer("empty")], {
clientFactory: () => fakeClient([]), clientFactory: () => fakeClient([]),

View File

@@ -2400,33 +2400,52 @@ describe("createFnAgent", () => {
it.each([ it.each([
["connect", "TypeError"], ["connect", "TypeError"],
["list", "RangeError"], ["list", "RangeError"],
] as const)("fails configured MCP session bootstrap explicitly on %s failure and closes once", async (phase, reason) => { ] as const)("continues without an MCP server after bounded %s retries are exhausted", async (phase, reason) => {
const close = vi.fn(async () => undefined); const clients: Array<{ close: ReturnType<typeof vi.fn> }> = [];
const mcpClient = { const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
connect: vi.fn(async () => { const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
if (phase === "connect") throw new TypeError("sensitive connection detail"); const mcpClientFactory = vi.fn(() => {
}), const client = {
listTools: vi.fn(async () => { connect: vi.fn(async () => {
if (phase === "list") throw new RangeError("sensitive listing detail"); if (phase === "connect") throw new TypeError("sensitive connection detail");
return { tools: [] }; }),
}), listTools: vi.fn(async () => {
callTool: vi.fn(), if (phase === "list") throw new RangeError("sensitive listing detail");
close, return { tools: [] };
}; }),
callTool: vi.fn(),
close: vi.fn(async () => undefined),
};
clients.push(client);
return client;
});
const { createFnAgent } = await import("../pi.js"); const { createFnAgent } = await import("../pi.js");
await expect(createFnAgent({ try {
cwd: "/test/project", await expect(createFnAgent({
systemPrompt: "test", cwd: "/test/project",
tools: "coding", systemPrompt: "test",
defaultProvider: "anthropic", tools: "coding",
defaultModelId: "claude-sonnet-4-5", defaultProvider: "anthropic",
mcpServers: [{ name: "postiz", transport: "stdio", command: "redacted", enabled: true }], defaultModelId: "claude-sonnet-4-5",
mcpClientFactory: () => mcpClient as any, mcpServers: [{ name: "postiz", transport: "stdio", command: "redacted", enabled: true }],
})).rejects.toThrow(`MCP session bootstrap failed: server=postiz reason=${reason}`); mcpClientFactory: mcpClientFactory as any,
mcpBootstrapRetryDelayMs: 0,
})).resolves.toBeDefined();
expect(close).toHaveBeenCalledTimes(1); expect(mcpClientFactory).toHaveBeenCalledTimes(3);
expect(createAgentSessionMock).not.toHaveBeenCalled(); expect(clients).toHaveLength(3);
for (const client of clients) expect(client.close).toHaveBeenCalledTimes(1);
expect(createAgentSessionMock).toHaveBeenCalledTimes(1);
const logs = [...consoleError.mock.calls, ...consoleWarn.mock.calls].flat().join("\n");
expect(logs).toContain(`reason=${reason}`);
expect(logs).toContain("MCP session continuing with unavailable servers: count=1");
expect(logs).not.toContain("sensitive connection detail");
expect(logs).not.toContain("sensitive listing detail");
} finally {
consoleError.mockRestore();
consoleWarn.mockRestore();
}
}); });
it("keeps MCP tools out of readonly sessions without the explicit opt-in", async () => { it("keeps MCP tools out of readonly sessions without the explicit opt-in", async () => {

View File

@@ -70,7 +70,7 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
inMemory: vi.fn(() => ({})), inMemory: vi.fn(() => ({})),
}, },
})); }));
// FNXC:McpConfig 2026-07-13: Mock connectMcpSessionTools so createFnAgent doesn't attempt real MCP server bootstrap (which fails in tests because the server binary doesn't exist). MAIN-008 made bootstrap failures throw McpSessionBootstrapError; this mock returns a clean toolset so the MCP forwarding path is exercised without a live server. // FNXC:McpConfig 2026-07-13: Mock connectMcpSessionTools so createFnAgent doesn't attempt real MCP server bootstrap (the configured test binary doesn't exist). The clean toolset keeps MCP forwarding tests deterministic without a live server.
vi.mock("../mcp-session-tools.js", () => ({ vi.mock("../mcp-session-tools.js", () => ({
connectMcpSessionTools: vi.fn().mockResolvedValue({ connectMcpSessionTools: vi.fn().mockResolvedValue({
tools: [], tools: [],
@@ -78,14 +78,6 @@ vi.mock("../mcp-session-tools.js", () => ({
skipped: [], skipped: [],
dispose: vi.fn().mockResolvedValue(undefined), dispose: vi.fn().mockResolvedValue(undefined),
}), }),
McpSessionBootstrapError: class McpSessionBootstrapError extends Error {
failures: Array<{ name: string; reason: string }>;
constructor(failures: Array<{ name: string; reason: string }>) {
super("MCP session bootstrap failed");
this.name = "McpSessionBootstrapError";
this.failures = failures;
}
},
})); }));
// Import mock accessors after mocking (must use dynamic import for hoisted mocks) // Import mock accessors after mocking (must use dynamic import for hoisted mocks)

View File

@@ -112,7 +112,7 @@ import {
resolveValidatorFallbackThinkingLevel, resolveValidatorFallbackThinkingLevel,
} from "./agent-session-helpers.js"; } from "./agent-session-helpers.js";
import { buildSessionSkillContext } from "./session-skill-context.js"; import { buildSessionSkillContext } from "./session-skill-context.js";
import { assertMcpResolutionSucceeded, resolveMcpServersForStore } from "./mcp-resolution.js"; import { resolveMcpServersForStore } from "./mcp-resolution.js";
import { reviewStep, proseSignalsClearApproval, extractJsonObjectCandidates, ReviewerProviderError, type ReviewVerdict, type ReviewResult } from "./reviewer.js"; import { reviewStep, proseSignalsClearApproval, extractJsonObjectCandidates, ReviewerProviderError, type ReviewVerdict, type ReviewResult } from "./reviewer.js";
import { buildUserCommentsPromptSection, selectUserCommentsForAgentContext } from "./agent-user-comments.js"; import { buildUserCommentsPromptSection, selectUserCommentsForAgentContext } from "./agent-user-comments.js";
import { resolveSandboxBackend } from "./sandbox/index.js"; import { resolveSandboxBackend } from "./sandbox/index.js";
@@ -2962,18 +2962,16 @@ export class TaskExecutor {
* 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. * 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.
* *
* FNXC:McpConfig 2026-07-12-17:02: * FNXC:McpConfig 2026-07-12-17:02:
* MAIN-008 forbids executor paths from silently consuming a partially * Secret-resolution failures remain content-free and observable. The
* materialized server set. Convert secret-resolution errors into a * resolver excludes each affected server so it cannot connect with missing
* content-free bootstrap failure before any runtime can connect with * credentials, while healthy MCP servers and task execution continue.
* missing credentials; only server names/counts and a coarse category may
* cross this seam.
*/ */
const resolved = await resolveMcpServersForStore(this.store, { agentId: agentId ?? undefined }); const resolved = await resolveMcpServersForStore(this.store, { agentId: agentId ?? undefined });
if (resolved.errors.length > 0) { if (resolved.errors.length > 0) {
const serverNames = [...new Set(resolved.errors.map((error) => error.serverName))].sort(); const serverNames = [...new Set(resolved.errors.map((error) => error.serverName))].sort();
executorLog.warn(`MCP executor resolution failed: servers=${serverNames.join(",")} count=${serverNames.length} reason=secret-materialization`); executorLog.warn(`MCP executor resolution failed: servers=${serverNames.join(",")} count=${serverNames.length} reason=secret-materialization`);
} }
return assertMcpResolutionSucceeded(resolved); return resolved.servers;
} }
/** /**

View File

@@ -21,32 +21,6 @@ export interface ResolvedMcpServersForRuntime {
errors: McpSecretResolutionError[]; errors: McpSecretResolutionError[];
} }
/**
* A content-free executor bootstrap failure. The message deliberately contains
* only server names and a coarse category; raw secret-store errors can contain
* credential-bearing configuration details.
*/
export class McpResolutionBootstrapError extends Error {
readonly serverNames: string[];
readonly reason = "secret-materialization" as const;
constructor(errors: McpSecretResolutionError[]) {
const serverNames = [...new Set(errors.map((error) => error.serverName))].sort();
super(`MCP resolution failed: server=${serverNames.join(",")} reason=secret-materialization`);
this.name = "McpResolutionBootstrapError";
this.serverNames = serverNames;
}
}
export function assertMcpResolutionSucceeded(
result: ResolvedMcpServersForRuntime,
): ResolvedMcpServerDefinition[] {
if (result.errors.length > 0) {
throw new McpResolutionBootstrapError(result.errors);
}
return result.servers;
}
/** /**
* FNXC:McpConfig 2026-06-25-21:43: * 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. * 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.
@@ -62,8 +36,12 @@ export async function resolveMcpServersForRuntime(
options.secrets, options.secrets,
options.reader ?? {}, options.reader ?? {},
); );
const failedServerNames = new Set(materialized.errors.map((error) => error.serverName));
return { return {
servers: materialized.value ?? [], // Never forward a partially materialized definition: it could connect
// without the operator-required credential. Other healthy MCP servers and
// the owning agent session remain available.
servers: (materialized.value ?? []).filter((server) => !failedServerNames.has(server.name)),
errors: materialized.errors, errors: materialized.errors,
}; };
} }

View File

@@ -7,6 +7,7 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { ResolvedMcpServerDefinition } from "@fusion/core"; import type { ResolvedMcpServerDefinition } from "@fusion/core";
import type { AgentToolResult, ToolDefinition } from "@earendil-works/pi-coding-agent"; import type { AgentToolResult, ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox"; import { Type } from "typebox";
import { cancellableSleep, computeBackoff } from "./retry-with-backoff.js";
export interface McpSessionToolset { export interface McpSessionToolset {
tools: ToolDefinition[]; tools: ToolDefinition[];
@@ -15,22 +16,9 @@ export interface McpSessionToolset {
skipped: Array<{ name: string; reason: string }>; skipped: Array<{ name: string; reason: string }>;
} }
export class McpSessionBootstrapError extends Error {
readonly failures: Array<{ name: string; reason: string }>;
constructor(failures: Array<{ name: string; reason: string }>) {
const sanitized = failures
.map(({ name, reason }) => ({ name, reason }))
.sort((a, b) => a.name.localeCompare(b.name) || a.reason.localeCompare(b.reason));
super(`MCP session bootstrap failed: ${sanitized.map(({ name, reason }) => `server=${name} reason=${reason}`).join("; ")}`);
this.name = "McpSessionBootstrapError";
this.failures = sanitized;
}
}
export interface McpSessionClient { export interface McpSessionClient {
connect(transport: Transport): Promise<void>; connect(transport: Transport, options?: { timeout?: number }): Promise<void>;
listTools(): Promise<{ tools?: McpToolMetadata[] }>; listTools(params?: undefined, options?: { timeout?: number }): Promise<{ tools?: McpToolMetadata[] }>;
callTool(params: { name: string; arguments?: Record<string, unknown> }, resultSchema?: unknown, options?: { signal?: AbortSignal }): Promise<McpToolCallResult>; callTool(params: { name: string; arguments?: Record<string, unknown> }, resultSchema?: unknown, options?: { signal?: AbortSignal }): Promise<McpToolCallResult>;
close(): Promise<void>; close(): Promise<void>;
} }
@@ -59,9 +47,18 @@ export interface McpSessionToolsOptions {
transportFactory?: McpTransportFactory; transportFactory?: McpTransportFactory;
logger?: Pick<Console, "log" | "warn">; logger?: Pick<Console, "log" | "warn">;
closeTimeoutMs?: number; closeTimeoutMs?: number;
/** Maximum fresh-client bootstrap attempts per enabled server. */
maxAttempts?: number;
/** Base delay for exponential retry backoff. Set to zero in tests. */
retryDelayMs?: number;
/** Per-attempt deadline for the MCP initialize and tool-list requests. */
requestTimeoutMs?: number;
} }
const DEFAULT_CLOSE_TIMEOUT_MS = 2_000; const DEFAULT_CLOSE_TIMEOUT_MS = 2_000;
const DEFAULT_MAX_ATTEMPTS = 3;
const DEFAULT_RETRY_DELAY_MS = 250;
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
/* /*
* FNXC:McpConfig 2026-06-27-13:55: * FNXC:McpConfig 2026-06-27-13:55:
@@ -112,31 +109,46 @@ export async function connectMcpSessionTools(
skipped.push({ name: server.name, reason: "aborted" }); skipped.push({ name: server.name, reason: "aborted" });
break; break;
} }
const client = (opts.clientFactory ?? defaultClientFactory)(server); const maxAttempts = normalizeMaxAttempts(opts.maxAttempts);
// Track the client before transport creation/connect so abort and every const requestTimeoutMs = normalizeRequestTimeout(opts.requestTimeoutMs);
// partial-bootstrap failure can close it exactly once. for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
clients.push(client); const client = (opts.clientFactory ?? defaultClientFactory)(server);
try { // Every retry gets a fresh client/transport. A failed SDK client may
const transport = (opts.transportFactory ?? defaultTransportFactory)(server, { cwd: opts.cwd }); // already be closed or hold partial protocol state and is not reusable.
await client.connect(transport); clients.push(client);
if (opts.signal?.aborted || disposed) { try {
throw new DOMException("MCP bootstrap aborted", "AbortError"); const transport = (opts.transportFactory ?? defaultTransportFactory)(server, { cwd: opts.cwd });
await client.connect(transport, { timeout: requestTimeoutMs });
if (opts.signal?.aborted || disposed) {
throw new DOMException("MCP bootstrap aborted", "AbortError");
}
const listed = await client.listTools(undefined, { timeout: requestTimeoutMs });
if (opts.signal?.aborted || disposed) {
throw new DOMException("MCP bootstrap aborted", "AbortError");
}
connected.push(server.name);
const listedTools = listed.tools ?? [];
opts.logger?.log?.(`MCP server connected for pi session: name=${server.name} transport=${server.transport} tools=${listedTools.length} attempt=${attempt}/${maxAttempts}`);
for (const tool of listedTools) {
tools.push(wrapMcpTool(server.name, tool, client, usedToolNames));
}
break;
} catch (error) {
const reason = opts.signal?.aborted ? "aborted" : safeErrorReason(error);
await closeOnce(client);
if (opts.signal?.aborted || attempt === maxAttempts) {
skipped.push({ name: server.name, reason });
opts.logger?.warn?.(`Skipping MCP server for pi session after retries: name=${server.name} transport=${server.transport} attempts=${attempt}/${maxAttempts} reason=${reason}`);
break;
}
opts.logger?.warn?.(`Retrying MCP server for pi session: name=${server.name} transport=${server.transport} attempt=${attempt + 1}/${maxAttempts} reason=${reason}`);
const baseDelayMs = Math.max(0, opts.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS);
const delayMs = computeBackoff(attempt - 1, baseDelayMs, Number.MAX_SAFE_INTEGER);
if (!await retryDelay(delayMs, opts.signal)) {
skipped.push({ name: server.name, reason: "aborted" });
break;
}
} }
const listed = await client.listTools();
if (opts.signal?.aborted || disposed) {
throw new DOMException("MCP bootstrap aborted", "AbortError");
}
connected.push(server.name);
const listedTools = listed.tools ?? [];
opts.logger?.log?.(`MCP server connected for pi session: name=${server.name} transport=${server.transport} tools=${listedTools.length}`);
for (const tool of listedTools) {
tools.push(wrapMcpTool(server.name, tool, client, usedToolNames));
}
} catch (error) {
const reason = opts.signal?.aborted ? "aborted" : safeErrorReason(error);
skipped.push({ name: server.name, reason });
opts.logger?.warn?.(`Skipping MCP server for pi session: name=${server.name} transport=${server.transport} reason=${reason}`);
await closeOnce(client);
} }
} }
} finally { } finally {
@@ -149,6 +161,27 @@ export async function connectMcpSessionTools(
return { tools, connected, skipped, dispose: closeAll }; return { tools, connected, skipped, dispose: closeAll };
} }
function normalizeMaxAttempts(value: number | undefined): number {
if (value === undefined || !Number.isFinite(value)) return DEFAULT_MAX_ATTEMPTS;
return Math.max(1, Math.floor(value));
}
function normalizeRequestTimeout(value: number | undefined): number {
if (value === undefined || !Number.isFinite(value) || value <= 0) return DEFAULT_REQUEST_TIMEOUT_MS;
return Math.floor(value);
}
async function retryDelay(ms: number, signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted) return false;
if (ms === 0) return true;
try {
await cancellableSleep(ms, signal);
return true;
} catch {
return false;
}
}
function defaultClientFactory(): McpSessionClient { function defaultClientFactory(): McpSessionClient {
return new Client({ name: "fusion-pi-mcp-session", version: "0.1.0" }, { capabilities: {} }) as unknown as McpSessionClient; return new Client({ name: "fusion-pi-mcp-session", version: "0.1.0" }, { capabilities: {} }) as unknown as McpSessionClient;
} }

View File

@@ -80,7 +80,7 @@ import { READONLY_ALLOWLIST, filterCustomToolsForReadonly, isReadonlyAllowed } f
import { createStreamingDeltaNormalizer } from "./streaming-delta.js"; import { createStreamingDeltaNormalizer } from "./streaming-delta.js";
import { isModelAuthTierIncompatibilityError, isProviderModelNotFoundError, isUnsupportedMessageRoleError } from "./transient-error-detector.js"; import { isModelAuthTierIncompatibilityError, isProviderModelNotFoundError, isUnsupportedMessageRoleError } from "./transient-error-detector.js";
import { logMcpForwardingSkipped, runtimeSupportsMcp } from "./mcp-runtime-support.js"; import { logMcpForwardingSkipped, runtimeSupportsMcp } from "./mcp-runtime-support.js";
import { connectMcpSessionTools, McpSessionBootstrapError, type McpClientFactory, type McpSessionToolset } from "./mcp-session-tools.js"; import { connectMcpSessionTools, type McpClientFactory, type McpSessionToolset } from "./mcp-session-tools.js";
export { isModelAuthTierIncompatibilityError } from "./transient-error-detector.js"; export { isModelAuthTierIncompatibilityError } from "./transient-error-detector.js";
const RTK_ACCEPTED_REWRITE_EXIT_CODES = new Set([0, 3]); const RTK_ACCEPTED_REWRITE_EXIT_CODES = new Set([0, 3]);
@@ -1078,6 +1078,8 @@ export interface AgentOptions {
allowMcpToolsInReadonly?: boolean; allowMcpToolsInReadonly?: boolean;
/** Test seam for MCP session tools; production uses the SDK client/transport factories. */ /** Test seam for MCP session tools; production uses the SDK client/transport factories. */
mcpClientFactory?: McpClientFactory; mcpClientFactory?: McpClientFactory;
/** Test seam for MCP retry timing. */
mcpBootstrapRetryDelayMs?: number;
/** Optional task-scoped env injected into this session's subprocess tools only. */ /** Optional task-scoped env injected into this session's subprocess tools only. */
taskEnv?: NodeJS.ProcessEnv; taskEnv?: NodeJS.ProcessEnv;
/** Last-chance abort hook fired immediately before `createAgentSession`. /** Last-chance abort hook fired immediately before `createAgentSession`.
@@ -2358,18 +2360,17 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
cwd: options.cwd, cwd: options.cwd,
clientFactory: options.mcpClientFactory, clientFactory: options.mcpClientFactory,
logger: piLog, logger: piLog,
retryDelayMs: options.mcpBootstrapRetryDelayMs,
}); });
/* /*
* FNXC:McpConfig 2026-07-12-17:02: * FNXC:McpConfig 2026-07-18-19:41:
* MAIN-008 requires a configured MCP bootstrap failure to be observably * MCP integrations are auxiliary capabilities. Exhausted bootstrap retries
* different from a genuine zero-server/tool catalog. Fail session creation * remain observably different from a zero-server catalog, but must not
* using names plus coarse categories only, and dispose every partially * terminate the owning agent session or discard unrelated task work.
* connected client before the error crosses the runtime boundary.
*/ */
const bootstrapFailures = mcpToolset.skipped.filter(({ reason }) => reason !== "disabled"); const bootstrapFailures = mcpToolset.skipped.filter(({ reason }) => reason !== "disabled");
if (bootstrapFailures.length > 0) { if (bootstrapFailures.length > 0) {
await mcpToolset.dispose(); piLog.warn(`MCP session continuing with unavailable servers: count=${bootstrapFailures.length}`);
throw new McpSessionBootstrapError(bootstrapFailures);
} }
} else if (forwardedMcpServers.length > 0 && isReadonly) { } else if (forwardedMcpServers.length > 0 && isReadonly) {
piLog.log(`readonly session — MCP servers (${forwardedMcpServers.length}) skipped`); piLog.log(`readonly session — MCP servers (${forwardedMcpServers.length}) skipped`);