From b4c1a1ac773b58f22b7d4a31eb2156c2b80c38a9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 21:58:29 -0700 Subject: [PATCH] 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. --- .changeset/mcp-bootstrap-resilience.md | 7 ++ .../executor-project-mcp-resume.test.ts | 8 +- .../src/__tests__/mcp-resolution.test.ts | 21 +++- .../src/__tests__/mcp-session-tools.test.ts | 73 ++++++++++++ .../src/__tests__/pi-create-fn-agent.test.ts | 67 +++++++---- packages/engine/src/__tests__/pi.test.ts | 10 +- packages/engine/src/executor.ts | 12 +- packages/engine/src/mcp-resolution.ts | 32 +---- packages/engine/src/mcp-session-tools.ts | 111 ++++++++++++------ packages/engine/src/pi.ts | 17 +-- 10 files changed, 238 insertions(+), 120 deletions(-) create mode 100644 .changeset/mcp-bootstrap-resilience.md diff --git a/.changeset/mcp-bootstrap-resilience.md b/.changeset/mcp-bootstrap-resilience.md new file mode 100644 index 0000000000..299db3958f --- /dev/null +++ b/.changeset/mcp-bootstrap-resilience.md @@ -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. diff --git a/packages/engine/src/__tests__/executor-project-mcp-resume.test.ts b/packages/engine/src/__tests__/executor-project-mcp-resume.test.ts index e228c822ae..6eba29f30b 100644 --- a/packages/engine/src/__tests__/executor-project-mcp-resume.test.ts +++ b/packages/engine/src/__tests__/executor-project-mcp-resume.test.ts @@ -66,12 +66,10 @@ describe("executor project MCP bootstrap and approval resume invariant", () => { 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"); - await expect((executor as any).resolveMcpServers("agent-main-007")).rejects.toThrow( - /^MCP resolution failed: server=postiz reason=secret-materialization$/, - ); + await expect((executor as any).resolveMcpServers("agent-main-007")).resolves.toEqual([]); }); 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( [{ name: "postiz", transport: "stdio", command: "fake" }], - { clientFactory: () => client, transportFactory }, + { clientFactory: () => client, transportFactory, maxAttempts: 1 }, ); expect(toolset.skipped).toEqual([{ name: "postiz", reason: "TypeError" }]); diff --git a/packages/engine/src/__tests__/mcp-resolution.test.ts b/packages/engine/src/__tests__/mcp-resolution.test.ts index 6165e0d513..b5319d0a5f 100644 --- a/packages/engine/src/__tests__/mcp-resolution.test.ts +++ b/packages/engine/src/__tests__/mcp-resolution.test.ts @@ -118,8 +118,27 @@ describe("resolveMcpServersForRuntime", () => { 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[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"]); + }); }); diff --git a/packages/engine/src/__tests__/mcp-session-tools.test.ts b/packages/engine/src/__tests__/mcp-session-tools.test.ts index 096f94c401..43b1e4079b 100644 --- a/packages/engine/src/__tests__/mcp-session-tools.test.ts +++ b/packages/engine/src/__tests__/mcp-session-tools.test.ts @@ -45,6 +45,8 @@ describe("connectMcpSessionTools", () => { expect(toolset.connected).toEqual(["context7"]); 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({ type: "object", 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")], { clientFactory: (server) => server.name === "bad" ? bad : good, transportFactory, + maxAttempts: 1, }); expect(toolset.skipped).toEqual([ @@ -86,6 +89,76 @@ describe("connectMcpSessionTools", () => { 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 () => { const toolset = await connectMcpSessionTools([stdioServer("empty")], { clientFactory: () => fakeClient([]), diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index ab89d28b00..66f5159693 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -2400,33 +2400,52 @@ describe("createFnAgent", () => { it.each([ ["connect", "TypeError"], ["list", "RangeError"], - ] as const)("fails configured MCP session bootstrap explicitly on %s failure and closes once", async (phase, reason) => { - const close = vi.fn(async () => undefined); - const mcpClient = { - connect: vi.fn(async () => { - if (phase === "connect") throw new TypeError("sensitive connection detail"); - }), - listTools: vi.fn(async () => { - if (phase === "list") throw new RangeError("sensitive listing detail"); - return { tools: [] }; - }), - callTool: vi.fn(), - close, - }; + ] as const)("continues without an MCP server after bounded %s retries are exhausted", async (phase, reason) => { + const clients: Array<{ close: ReturnType }> = []; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const mcpClientFactory = vi.fn(() => { + const client = { + connect: vi.fn(async () => { + if (phase === "connect") throw new TypeError("sensitive connection detail"); + }), + listTools: vi.fn(async () => { + if (phase === "list") throw new RangeError("sensitive listing detail"); + return { tools: [] }; + }), + callTool: vi.fn(), + close: vi.fn(async () => undefined), + }; + clients.push(client); + return client; + }); const { createFnAgent } = await import("../pi.js"); - await expect(createFnAgent({ - cwd: "/test/project", - systemPrompt: "test", - tools: "coding", - defaultProvider: "anthropic", - defaultModelId: "claude-sonnet-4-5", - mcpServers: [{ name: "postiz", transport: "stdio", command: "redacted", enabled: true }], - mcpClientFactory: () => mcpClient as any, - })).rejects.toThrow(`MCP session bootstrap failed: server=postiz reason=${reason}`); + try { + await expect(createFnAgent({ + cwd: "/test/project", + systemPrompt: "test", + tools: "coding", + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + mcpServers: [{ name: "postiz", transport: "stdio", command: "redacted", enabled: true }], + mcpClientFactory: mcpClientFactory as any, + mcpBootstrapRetryDelayMs: 0, + })).resolves.toBeDefined(); - expect(close).toHaveBeenCalledTimes(1); - expect(createAgentSessionMock).not.toHaveBeenCalled(); + expect(mcpClientFactory).toHaveBeenCalledTimes(3); + 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 () => { diff --git a/packages/engine/src/__tests__/pi.test.ts b/packages/engine/src/__tests__/pi.test.ts index 6843f2134d..740025443e 100644 --- a/packages/engine/src/__tests__/pi.test.ts +++ b/packages/engine/src/__tests__/pi.test.ts @@ -70,7 +70,7 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({ 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", () => ({ connectMcpSessionTools: vi.fn().mockResolvedValue({ tools: [], @@ -78,14 +78,6 @@ vi.mock("../mcp-session-tools.js", () => ({ skipped: [], 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) diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index fce38efb16..b312a93d92 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -112,7 +112,7 @@ import { resolveValidatorFallbackThinkingLevel, } from "./agent-session-helpers.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 { buildUserCommentsPromptSection, selectUserCommentsForAgentContext } from "./agent-user-comments.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. * * FNXC:McpConfig 2026-07-12-17:02: - * MAIN-008 forbids executor paths from silently consuming a partially - * materialized server set. Convert secret-resolution errors into a - * content-free bootstrap failure before any runtime can connect with - * missing credentials; only server names/counts and a coarse category may - * cross this seam. + * Secret-resolution failures remain content-free and observable. The + * resolver excludes each affected server so it cannot connect with missing + * credentials, while healthy MCP servers and task execution continue. */ const resolved = await resolveMcpServersForStore(this.store, { agentId: agentId ?? undefined }); if (resolved.errors.length > 0) { 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`); } - return assertMcpResolutionSucceeded(resolved); + return resolved.servers; } /** diff --git a/packages/engine/src/mcp-resolution.ts b/packages/engine/src/mcp-resolution.ts index 14f105b06a..b61e06355f 100644 --- a/packages/engine/src/mcp-resolution.ts +++ b/packages/engine/src/mcp-resolution.ts @@ -21,32 +21,6 @@ export interface ResolvedMcpServersForRuntime { 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: * 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.reader ?? {}, ); + const failedServerNames = new Set(materialized.errors.map((error) => error.serverName)); 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, }; } diff --git a/packages/engine/src/mcp-session-tools.ts b/packages/engine/src/mcp-session-tools.ts index 85591e69ea..3b4e02d11c 100644 --- a/packages/engine/src/mcp-session-tools.ts +++ b/packages/engine/src/mcp-session-tools.ts @@ -7,6 +7,7 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { ResolvedMcpServerDefinition } from "@fusion/core"; import type { AgentToolResult, ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; +import { cancellableSleep, computeBackoff } from "./retry-with-backoff.js"; export interface McpSessionToolset { tools: ToolDefinition[]; @@ -15,22 +16,9 @@ export interface McpSessionToolset { 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 { - connect(transport: Transport): Promise; - listTools(): Promise<{ tools?: McpToolMetadata[] }>; + connect(transport: Transport, options?: { timeout?: number }): Promise; + listTools(params?: undefined, options?: { timeout?: number }): Promise<{ tools?: McpToolMetadata[] }>; callTool(params: { name: string; arguments?: Record }, resultSchema?: unknown, options?: { signal?: AbortSignal }): Promise; close(): Promise; } @@ -59,9 +47,18 @@ export interface McpSessionToolsOptions { transportFactory?: McpTransportFactory; logger?: Pick; 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_MAX_ATTEMPTS = 3; +const DEFAULT_RETRY_DELAY_MS = 250; +const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; /* * FNXC:McpConfig 2026-06-27-13:55: @@ -112,31 +109,46 @@ export async function connectMcpSessionTools( skipped.push({ name: server.name, reason: "aborted" }); break; } - const client = (opts.clientFactory ?? defaultClientFactory)(server); - // Track the client before transport creation/connect so abort and every - // partial-bootstrap failure can close it exactly once. - clients.push(client); - try { - const transport = (opts.transportFactory ?? defaultTransportFactory)(server, { cwd: opts.cwd }); - await client.connect(transport); - if (opts.signal?.aborted || disposed) { - throw new DOMException("MCP bootstrap aborted", "AbortError"); + const maxAttempts = normalizeMaxAttempts(opts.maxAttempts); + const requestTimeoutMs = normalizeRequestTimeout(opts.requestTimeoutMs); + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const client = (opts.clientFactory ?? defaultClientFactory)(server); + // Every retry gets a fresh client/transport. A failed SDK client may + // already be closed or hold partial protocol state and is not reusable. + clients.push(client); + try { + 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 { @@ -149,6 +161,27 @@ export async function connectMcpSessionTools( 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 { + if (signal?.aborted) return false; + if (ms === 0) return true; + try { + await cancellableSleep(ms, signal); + return true; + } catch { + return false; + } +} + function defaultClientFactory(): McpSessionClient { return new Client({ name: "fusion-pi-mcp-session", version: "0.1.0" }, { capabilities: {} }) as unknown as McpSessionClient; } diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 73b9186fa4..8f546176c0 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -80,7 +80,7 @@ import { READONLY_ALLOWLIST, filterCustomToolsForReadonly, isReadonlyAllowed } f import { createStreamingDeltaNormalizer } from "./streaming-delta.js"; import { isModelAuthTierIncompatibilityError, isProviderModelNotFoundError, isUnsupportedMessageRoleError } from "./transient-error-detector.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"; const RTK_ACCEPTED_REWRITE_EXIT_CODES = new Set([0, 3]); @@ -1078,6 +1078,8 @@ export interface AgentOptions { allowMcpToolsInReadonly?: boolean; /** Test seam for MCP session tools; production uses the SDK client/transport factories. */ mcpClientFactory?: McpClientFactory; + /** Test seam for MCP retry timing. */ + mcpBootstrapRetryDelayMs?: number; /** Optional task-scoped env injected into this session's subprocess tools only. */ taskEnv?: NodeJS.ProcessEnv; /** Last-chance abort hook fired immediately before `createAgentSession`. @@ -2358,18 +2360,17 @@ export async function createFnAgent(options: AgentOptions): Promise cwd: options.cwd, clientFactory: options.mcpClientFactory, logger: piLog, + retryDelayMs: options.mcpBootstrapRetryDelayMs, }); /* - * FNXC:McpConfig 2026-07-12-17:02: - * MAIN-008 requires a configured MCP bootstrap failure to be observably - * different from a genuine zero-server/tool catalog. Fail session creation - * using names plus coarse categories only, and dispose every partially - * connected client before the error crosses the runtime boundary. + * FNXC:McpConfig 2026-07-18-19:41: + * MCP integrations are auxiliary capabilities. Exhausted bootstrap retries + * remain observably different from a zero-server catalog, but must not + * terminate the owning agent session or discard unrelated task work. */ const bootstrapFailures = mcpToolset.skipped.filter(({ reason }) => reason !== "disabled"); if (bootstrapFailures.length > 0) { - await mcpToolset.dispose(); - throw new McpSessionBootstrapError(bootstrapFailures); + piLog.warn(`MCP session continuing with unavailable servers: count=${bootstrapFailures.length}`); } } else if (forwardedMcpServers.length > 0 && isReadonly) { piLog.log(`readonly session — MCP servers (${forwardedMcpServers.length}) skipped`);