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`);