From c1613ad7f60e7cfd072583b160c48732f998e6ca Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 14:09:47 -0700 Subject: [PATCH] FN-7122: expose MCP servers as pi session tools Connect configured MCP servers during pi chat and agent session startup so their discovered tools are available as namespaced custom tools. - Add MCP session tool connection, tool-name sanitization, result mapping, and disposal handling. - Merge discovered MCP tools into pi customTools while skipping readonly sessions and avoiding secret-bearing logs. - Cover MCP tool wrapping and pi session integration with targeted tests. - Document MCP forwarding behavior and promote the MCP SDK to an engine runtime dependency. Files changed: .changeset/fn-7122-mcp-session-tools.md | 7 + docs/mcp.md | 2 + packages/engine/package.json | 2 +- .../engine/src/__tests__/mcp-session-tools.test.ts | 105 ++++++++ .../src/__tests__/pi-mcp-session-tools.test.ts | 24 ++ packages/engine/src/mcp-session-tools.ts | 262 ++++++++++++++++++++ packages/engine/src/pi.ts | 71 +++++- pnpm-lock.yaml | 264 ++++++++++++++++++++- 8 files changed, 713 insertions(+), 24 deletions(-) Fusion-Task-Id: FN-7122 Fusion-Task-Lineage: e7ff57e7-9bfb-41a4-9ebd-6c868bac2745 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7122-mcp-session-tools.md | 7 + docs/mcp.md | 2 + packages/engine/package.json | 2 +- .../src/__tests__/mcp-session-tools.test.ts | 105 +++++++ .../__tests__/pi-mcp-session-tools.test.ts | 24 ++ packages/engine/src/mcp-session-tools.ts | 262 +++++++++++++++++ packages/engine/src/pi.ts | 71 ++++- pnpm-lock.yaml | 264 +++++++++++++++++- 8 files changed, 713 insertions(+), 24 deletions(-) create mode 100644 .changeset/fn-7122-mcp-session-tools.md create mode 100644 packages/engine/src/__tests__/mcp-session-tools.test.ts create mode 100644 packages/engine/src/__tests__/pi-mcp-session-tools.test.ts create mode 100644 packages/engine/src/mcp-session-tools.ts diff --git a/.changeset/fn-7122-mcp-session-tools.md b/.changeset/fn-7122-mcp-session-tools.md new file mode 100644 index 0000000000..bef5886ef1 --- /dev/null +++ b/.changeset/fn-7122-mcp-session-tools.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Configured MCP servers now connect to chat and agent sessions and expose their tools. +category: feature +dev: Adds the engine mcp-session-tools module and mcp____ tool namespacing. diff --git a/docs/mcp.md b/docs/mcp.md index 1df661e467..50c0b334df 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -266,6 +266,8 @@ When an AI lane or readonly dashboard helper starts a session, Fusion resolves t Runtime support is guarded. Claude/pi/ACP-compatible runtimes receive MCP servers; mock or unsupported runtimes skip forwarding and emit only structured count/provider/runtime metadata. Skipped forwarding is not a settings error: it means the selected runtime does not accept MCP server declarations. +The default pi runtime connects resolved MCP servers inside the engine because pi does not consume raw `mcpServers` declarations itself. For each reachable server, Fusion performs the MCP handshake, lists tools, and registers each tool as a pi custom tool named `mcp____` with sanitized, deterministic suffixes for collisions. Unreachable or disabled servers fail soft with content-free logs, and all MCP clients/transports are closed when the agent session is disposed so stdio subprocesses are reaped. + Expected outcome: enabling a server makes it available to subsequent supported AI sessions, while unsupported sessions continue without MCP tools and without logging secret-bearing server definitions. See [Settings Reference](./settings-reference.md) for the `mcpServers` settings contract and [Agents](./agents.md) for runtime/model lane behavior. diff --git a/packages/engine/package.json b/packages/engine/package.json index e8faab611c..68d3a61ae2 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -42,6 +42,7 @@ "@fusion/pi-claude-cli": "workspace:*", "@earendil-works/pi-ai": "^0.79.9", "@earendil-works/pi-coding-agent": "^0.79.9", + "@modelcontextprotocol/sdk": "^1.0.0", "cron-parser": "^5.5.0", "esbuild": "^0.25.12", "node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1", @@ -49,7 +50,6 @@ "typebox": "^1.0.0" }, "devDependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", "@types/node": "^25.5.0", "@types/proper-lockfile": "^4.1.4", "@vitest/coverage-v8": "^4.1.0", diff --git a/packages/engine/src/__tests__/mcp-session-tools.test.ts b/packages/engine/src/__tests__/mcp-session-tools.test.ts new file mode 100644 index 0000000000..096f94c401 --- /dev/null +++ b/packages/engine/src/__tests__/mcp-session-tools.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { ResolvedMcpServerDefinition } from "@fusion/core"; +import { connectMcpSessionTools, uniqueMcpToolName, type McpSessionClient } from "../mcp-session-tools.js"; + +function stdioServer(name: string, enabled = true): ResolvedMcpServerDefinition { + return { name, transport: "stdio", command: "fake", enabled }; +} + +function fakeClient(toolNames: string[], calls: string[] = []): McpSessionClient { + return { + connect: vi.fn(async () => undefined), + listTools: vi.fn(async () => ({ + tools: toolNames.map((name) => ({ + name, + description: `tool ${name}`, + inputSchema: name === "lookup" + ? { + type: "object", + properties: { topic: { type: "string", description: "Topic to look up" } }, + required: ["topic"], + } + : undefined, + })), + })), + callTool: vi.fn(async ({ name, arguments: args }) => { + calls.push(name); + if (name === "fail") return { content: [{ type: "text", text: "failed" }], isError: true }; + return { content: [{ type: "text", text: JSON.stringify(args ?? {}) }] }; + }), + close: vi.fn(async () => undefined), + }; +} + +const transportFactory = () => ({}) as Transport; + +describe("connectMcpSessionTools", () => { + it("registers namespaced tools and routes calls to the owning MCP client", async () => { + const calls: string[] = []; + const client = fakeClient(["lookup"], calls); + const toolset = await connectMcpSessionTools([stdioServer("context7")], { + clientFactory: () => client, + transportFactory, + }); + + expect(toolset.connected).toEqual(["context7"]); + expect(toolset.tools.map((tool) => tool.name)).toEqual(["mcp__context7__lookup"]); + expect(toolset.tools[0]!.parameters).toMatchObject({ + type: "object", + properties: { topic: { type: "string", description: "Topic to look up" } }, + required: ["topic"], + }); + const result = await toolset.tools[0]!.execute("call", { topic: "mcp" } as never, undefined, undefined, {} as never); + expect(calls).toEqual(["lookup"]); + expect(result.content[0].text).toContain("mcp"); + await toolset.dispose(); + expect(client.close).toHaveBeenCalledTimes(1); + }); + + it("maps tool errors without throwing", async () => { + const toolset = await connectMcpSessionTools([stdioServer("srv")], { + clientFactory: () => fakeClient(["fail"]), + transportFactory, + }); + + const result = await toolset.tools[0]!.execute("call", {}, undefined, undefined, {} as never); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("failed"); + }); + + it("skips disabled and failed servers while keeping reachable tools", async () => { + const good = fakeClient(["read"]); + const bad: McpSessionClient = { + ...fakeClient([]), + connect: vi.fn(async () => { throw new Error("offline"); }), + }; + const toolset = await connectMcpSessionTools([stdioServer("disabled", false), stdioServer("bad"), stdioServer("good")], { + clientFactory: (server) => server.name === "bad" ? bad : good, + transportFactory, + }); + + expect(toolset.skipped).toEqual([ + { name: "disabled", reason: "disabled" }, + { name: "bad", reason: "error" }, + ]); + expect(toolset.tools.map((tool) => tool.name)).toEqual(["mcp__good__read"]); + }); + + it("keeps empty tool-list connections and creates no tools", async () => { + const toolset = await connectMcpSessionTools([stdioServer("empty")], { + clientFactory: () => fakeClient([]), + transportFactory, + }); + + expect(toolset.connected).toEqual(["empty"]); + expect(toolset.tools).toEqual([]); + }); + + it("deduplicates sanitized server and tool name collisions deterministically", () => { + const used = new Set(); + expect(uniqueMcpToolName("a.b", "bash", used)).toBe("mcp__a_b__bash"); + expect(uniqueMcpToolName("a_b", "bash", used)).toBe("mcp__a_b__bash__2"); + expect(uniqueMcpToolName("a_b", "read", used)).toBe("mcp__a_b__read"); + }); +}); diff --git a/packages/engine/src/__tests__/pi-mcp-session-tools.test.ts b/packages/engine/src/__tests__/pi-mcp-session-tools.test.ts new file mode 100644 index 0000000000..d57e9e4d33 --- /dev/null +++ b/packages/engine/src/__tests__/pi-mcp-session-tools.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const piSource = () => readFileSync(join(process.cwd(), "src/pi.ts"), "utf8"); + +describe("pi MCP session tool integration", () => { + it("registers MCP tools through customTools instead of passing mcpServers to pi", () => { + const source = piSource(); + expect(source).toContain("connectMcpSessionTools(forwardedMcpServers"); + expect(source).toContain("...(mcpToolset?.tools ?? [])"); + expect(source).toContain("wrapToolsWithActionGate("); + expect(source).toContain("wrapToolsWithBoundary("); + expect(source).not.toContain("mcpServers: forwardedMcpServers"); + }); + + it("skips MCP servers in readonly sessions and chains disposal into session dispose", () => { + const source = piSource(); + expect(source).toContain("forwardedMcpServers.length > 0 && !isReadonly"); + expect(source).toContain("readonly session — MCP servers"); + expect(source).toContain("await mcpToolset.dispose()"); + expect(source).toContain("await mcpToolset?.dispose()"); + }); +}); diff --git a/packages/engine/src/mcp-session-tools.ts b/packages/engine/src/mcp-session-tools.ts new file mode 100644 index 0000000000..8a0c6d761c --- /dev/null +++ b/packages/engine/src/mcp-session-tools.ts @@ -0,0 +1,262 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +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"; + +export interface McpSessionToolset { + tools: ToolDefinition[]; + dispose: () => Promise; + connected: string[]; + skipped: Array<{ name: string; reason: string }>; +} + +export interface McpSessionClient { + connect(transport: Transport): Promise; + listTools(): Promise<{ tools?: McpToolMetadata[] }>; + callTool(params: { name: string; arguments?: Record }, resultSchema?: unknown, options?: { signal?: AbortSignal }): Promise; + close(): Promise; +} + +export interface McpToolMetadata { + name: string; + title?: string; + description?: string; + inputSchema?: Record; +} + +export interface McpToolCallResult { + content?: unknown[]; + structuredContent?: Record; + isError?: boolean; + [key: string]: unknown; +} + +export type McpClientFactory = (server: ResolvedMcpServerDefinition) => McpSessionClient; +export type McpTransportFactory = (server: ResolvedMcpServerDefinition, opts: { cwd?: string }) => Transport; + +export interface McpSessionToolsOptions { + cwd?: string; + signal?: AbortSignal; + clientFactory?: McpClientFactory; + transportFactory?: McpTransportFactory; + logger?: Pick; + closeTimeoutMs?: number; +} + +const DEFAULT_CLOSE_TIMEOUT_MS = 2_000; + +/* + * FNXC:McpConfig 2026-06-27-13:55: + * Pi does not consume `mcpServers` natively, so Fusion must run the MCP handshake in the engine and expose discovered tools through pi customTools. MCP definitions can contain materialized secret env vars, args, URLs, and headers; logs from this module stay content-free and include only server names, transports, tool counts, and sanitized error messages. + * + * FNXC:McpConfig 2026-06-27-14:18: + * MCP tool names are globally visible inside the pi session. Generate deterministic `mcp____` names and suffix sanitizer collisions so an MCP server can never shadow built-ins/fn_* tools or another server's advertised tool. + * + * FNXC:McpConfig 2026-06-27-14:43: + * Preserve each MCP tool input schema when registering pi customTools so providers see required arguments. Connection logs use coarse error categories instead of raw MCP exception messages because spawned server args/env/header/url values can carry secrets. + */ +export async function connectMcpSessionTools( + servers: ResolvedMcpServerDefinition[], + opts: McpSessionToolsOptions = {}, +): Promise { + const tools: ToolDefinition[] = []; + const connected: string[] = []; + const skipped: Array<{ name: string; reason: string }> = []; + const clients: McpSessionClient[] = []; + const usedToolNames = new Set(); + let disposed = false; + + const closeAll = async (): Promise => { + if (disposed) return; + disposed = true; + await Promise.allSettled(clients.map((client) => closeClient(client, opts.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS))); + }; + + const onAbort = (): void => { + void closeAll(); + }; + opts.signal?.addEventListener("abort", onAbort, { once: true }); + + try { + for (const server of servers) { + if (server.enabled === false) { + skipped.push({ name: server.name, reason: "disabled" }); + continue; + } + if (opts.signal?.aborted) { + skipped.push({ name: server.name, reason: "aborted" }); + break; + } + const client = (opts.clientFactory ?? defaultClientFactory)(server); + let didConnect = false; + try { + const transport = (opts.transportFactory ?? defaultTransportFactory)(server, { cwd: opts.cwd }); + await client.connect(transport); + didConnect = true; + clients.push(client); + const listed = await client.listTools(); + 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 = 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}`); + if (didConnect) { + await closeClient(client, opts.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS); + } + } + } + } finally { + opts.signal?.removeEventListener("abort", onAbort); + if (opts.signal?.aborted) { + await closeAll(); + } + } + + return { tools, connected, skipped, dispose: closeAll }; +} + +function defaultClientFactory(): McpSessionClient { + return new Client({ name: "fusion-pi-mcp-session", version: "0.1.0" }, { capabilities: {} }) as unknown as McpSessionClient; +} + +function defaultTransportFactory(server: ResolvedMcpServerDefinition, opts: { cwd?: string }): Transport { + if (server.transport === "stdio") { + return new StdioClientTransport({ + command: server.command, + args: server.args, + env: { ...process.env, ...(server.env ?? {}) } as Record, + cwd: opts.cwd, + stderr: "pipe", + }); + } + const headers = "headers" in server ? server.headers : undefined; + const requestInit = headers ? { headers } : undefined; + if (server.transport === "sse") { + return new SSEClientTransport(new URL(server.url), { + eventSourceInit: requestInit ? { fetch: (input, init) => fetch(input, { ...init, ...requestInit }) } : undefined, + requestInit, + }); + } + return new StreamableHTTPClientTransport(new URL(server.url), { requestInit }); +} + +function wrapMcpTool( + serverName: string, + tool: McpToolMetadata, + client: McpSessionClient, + usedToolNames: Set, +): ToolDefinition { + const name = uniqueMcpToolName(serverName, tool.name, usedToolNames); + return { + name, + label: tool.title ?? tool.name, + description: tool.description ?? `MCP tool ${tool.name} from ${serverName}`, + parameters: mcpInputSchemaToParameters(tool.inputSchema), + execute: async (_toolCallId: string, params: unknown, signal?: AbortSignal): Promise => { + try { + const result = await client.callTool( + { name: tool.name, arguments: isRecord(params) ? params : {} }, + undefined, + signal ? { signal } : undefined, + ); + return { + content: mapMcpContent(result), + details: result.structuredContent ? { structuredContent: result.structuredContent } : {}, + isError: result.isError === true, + }; + } catch (error) { + const message = safeErrorReason(error); + return { + content: [{ type: "text" as const, text: `MCP tool ${tool.name} failed: ${message}` }], + details: {}, + isError: true, + }; + } + }, + }; +} + +export function uniqueMcpToolName(serverName: string, toolName: string, usedToolNames = new Set()): string { + const base = `mcp__${sanitizeToolSegment(serverName)}__${sanitizeToolSegment(toolName)}`; + let candidate = base; + let suffix = 2; + while (usedToolNames.has(candidate)) { + candidate = `${base}__${suffix}`; + suffix += 1; + } + usedToolNames.add(candidate); + return candidate; +} + +function sanitizeToolSegment(value: string): string { + const sanitized = value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_"); + return sanitized || "unnamed"; +} + +function mapMcpContent(result: McpToolCallResult): AgentToolResult["content"] { + if (Array.isArray(result.content) && result.content.length > 0) { + return result.content.map((entry) => { + if (isRecord(entry) && entry.type === "text" && typeof entry.text === "string") { + return { type: "text" as const, text: entry.text }; + } + if (isRecord(entry) && entry.type === "image" && typeof entry.data === "string" && typeof entry.mimeType === "string") { + return { type: "image" as const, data: entry.data, mimeType: entry.mimeType }; + } + return { type: "text" as const, text: JSON.stringify(entry) }; + }); + } + if (result.structuredContent) { + return [{ type: "text" as const, text: JSON.stringify(result.structuredContent) }]; + } + if ("toolResult" in result) { + return [{ type: "text" as const, text: JSON.stringify(result.toolResult) }]; + } + return [{ type: "text" as const, text: "" }]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function mcpInputSchemaToParameters(inputSchema: Record | undefined): any { + if (!inputSchema || !isRecord(inputSchema)) { + return Type.Object({}, { additionalProperties: true }); + } + + const type = typeof inputSchema.type === "string" ? inputSchema.type : undefined; + if (type === "object" || inputSchema.properties || inputSchema.required) { + return { + type: "object", + properties: isRecord(inputSchema.properties) ? inputSchema.properties : {}, + ...(Array.isArray(inputSchema.required) ? { required: inputSchema.required.filter((value) => typeof value === "string") } : {}), + additionalProperties: inputSchema.additionalProperties ?? true, + ...(typeof inputSchema.description === "string" ? { description: inputSchema.description } : {}), + }; + } + + return Type.Object({}, { additionalProperties: true }); +} + +function safeErrorReason(error: unknown): string { + if (error instanceof Error) { + return error.name && error.name !== "Error" ? error.name : "error"; + } + return typeof error; +} + +async function closeClient(client: McpSessionClient, timeoutMs: number): Promise { + await Promise.race([ + client.close().catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); +} diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 9d4108edd3..bead351727 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -74,6 +74,7 @@ import { READONLY_ALLOWLIST, filterCustomToolsForReadonly, isReadonlyAllowed } f import { createStreamingDeltaNormalizer } from "./streaming-delta.js"; import { isModelAuthTierIncompatibilityError, isUnsupportedMessageRoleError } from "./transient-error-detector.js"; import { logMcpForwardingSkipped, runtimeSupportsMcp } from "./mcp-runtime-support.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]); @@ -1005,6 +1006,8 @@ export interface AgentOptions { * below decides whether to forward or skip them without logging contents. */ mcpServers?: ResolvedMcpServerDefinition[]; + /** Test seam for MCP session tools; production uses the SDK client/transport factories. */ + mcpClientFactory?: McpClientFactory; /** Optional task-scoped env injected into this session's subprocess tools only. */ taskEnv?: NodeJS.ProcessEnv; /** Last-chance abort hook fired immediately before `createAgentSession`. @@ -2210,9 +2213,28 @@ export async function createFnAgent(options: AgentOptions): Promise // suppress the defaults with `noTools: "builtin"` and register our wrapped // tools through `customTools` instead. The wrapped tools preserve the same // names (`read`, `bash`, ...) as the built-ins they replace. + let mcpToolset: McpSessionToolset | undefined; + if (forwardedMcpServers.length > 0 && !isReadonly) { + /* + * FNXC:McpConfig 2026-06-27-14:06: + * pi-coding-agent does not have a createAgentSession `mcpServers` option, so passing resolved servers is silently ignored. Connect MCP servers here and merge namespaced tools into the same customTools filtering/gating/boundary pipeline as engine tools before the session sees them. + */ + mcpToolset = await connectMcpSessionTools(forwardedMcpServers, { + cwd: options.cwd, + clientFactory: options.mcpClientFactory, + logger: piLog, + }); + } else if (forwardedMcpServers.length > 0 && isReadonly) { + piLog.log(`readonly session — MCP servers (${forwardedMcpServers.length}) skipped`); + } + + const candidateCustomTools = [ + ...(options.customTools ?? []), + ...(mcpToolset?.tools ?? []), + ]; const readonlyFilteredCustomTools = isReadonly - ? filterCustomToolsForReadonly(options.customTools ?? []) - : { allowed: options.customTools ?? [], denied: [] }; + ? filterCustomToolsForReadonly(candidateCustomTools) + : { allowed: candidateCustomTools, denied: [] }; const allowlistFilteredCustomTools = { ...readonlyFilteredCustomTools, allowed: readonlyFilteredCustomTools.allowed.filter((tool) => isAllowedByToolAllowlist(tool.name)), @@ -2258,9 +2280,14 @@ export async function createFnAgent(options: AgentOptions): Promise // This is the latest synchronous decision point where the engine can // honor a pause that flipped during this function's setup window. if (options.beforeSpawnSession) { - await options.beforeSpawnSession(); + try { + await options.beforeSpawnSession(); + } catch (error) { + await mcpToolset?.dispose(); + throw error; + } } - const createSessionOptions: Parameters[0] & { mcpServers?: ResolvedMcpServerDefinition[] } = { + const createSessionOptions: Parameters[0] = { cwd: options.cwd, authStorage, modelRegistry, @@ -2269,7 +2296,6 @@ export async function createFnAgent(options: AgentOptions): Promise customTools: customToolList, sessionManager, settingsManager, - ...(forwardedMcpServers.length > 0 ? { mcpServers: forwardedMcpServers } : {}), ...(modelOverride ? { model: modelOverride } : {}), }; @@ -2293,15 +2319,34 @@ export async function createFnAgent(options: AgentOptions): Promise ].sort(); } - const result = await createAgentSession(createSessionOptions); - /* - * FNXC:TokenAnalytics 2026-06-26-13:58: - * Token analytics depends on every resolved lane model being visible on `session.model` after session creation. Some pi providers accept the explicit model override but do not mirror it back onto the session, so backfill the snapshot here before shared token accounting reads it. - */ - if (modelOverride && !(result.session as AgentSession & { model?: unknown }).model) { - (result.session as AgentSession & { model?: typeof modelOverride }).model = modelOverride; + try { + const result = await createAgentSession(createSessionOptions); + if (mcpToolset) { + const sessionWithDispose = result.session as AgentSession & { dispose?: () => void | Promise }; + const originalDispose = typeof sessionWithDispose.dispose === "function" + ? sessionWithDispose.dispose.bind(sessionWithDispose) + : () => undefined; + let mcpDisposeStarted = false; + sessionWithDispose.dispose = async () => { + if (!mcpDisposeStarted) { + mcpDisposeStarted = true; + await mcpToolset.dispose(); + } + await Promise.resolve(originalDispose()); + }; + } + /* + * FNXC:TokenAnalytics 2026-06-26-13:58: + * Token analytics depends on every resolved lane model being visible on `session.model` after session creation. Some pi providers accept the explicit model override but do not mirror it back onto the session, so backfill the snapshot here before shared token accounting reads it. + */ + if (modelOverride && !(result.session as AgentSession & { model?: unknown }).model) { + (result.session as AgentSession & { model?: typeof modelOverride }).model = modelOverride; + } + return result; + } catch (error) { + await mcpToolset?.dispose(); + throw error; } - return result; }; const emitFallbackUsed = async (triggerPoint: "session-creation" | "prompt-time"): Promise => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7aea78d08d..e6501164bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,10 +47,10 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.79.9 - version: 0.79.9(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.79.9(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@earendil-works/pi-coding-agent': specifier: ^0.79.9 - version: 0.79.9(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.79.9(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) dockerode: specifier: ^4.0.12 version: 4.0.12 @@ -475,10 +475,10 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@earendil-works/pi-coding-agent': specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@fusion-plugin-examples/droid-runtime': specifier: workspace:* version: link:../../plugins/fusion-plugin-droid-runtime @@ -507,6 +507,9 @@ importers: '@fusion/pi-claude-cli': specifier: workspace:* version: link:../pi-claude-cli + '@modelcontextprotocol/sdk': + specifier: ^1.0.0 + version: 1.28.0(zod@4.3.6) cron-parser: specifier: ^5.5.0 version: 5.5.0 @@ -523,9 +526,6 @@ importers: specifier: ^1.0.0 version: 1.1.32 devDependencies: - '@modelcontextprotocol/sdk': - specifier: ^1.0.0 - version: 1.28.0(zod@4.3.6) '@types/node': specifier: ^25.5.2 version: 25.5.2 @@ -630,7 +630,7 @@ importers: dependencies: '@earendil-works/pi-coding-agent': specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.77.0 devDependencies: '@types/node': specifier: ^25.5.2 @@ -7661,6 +7661,10 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.2.4 + '@anthropic-ai/sdk@0.91.1': + dependencies: + json-schema-to-ts: 3.1.1 + '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 @@ -8388,6 +8392,34 @@ snapshots: ajv: 6.14.0 ajv-keywords: 3.5.2(ajv@6.14.0) + '@earendil-works/pi-agent-core@0.77.0': + dependencies: + '@earendil-works/pi-ai': 0.77.0 + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8416,6 +8448,20 @@ snapshots: - ws - zod + '@earendil-works/pi-agent-core@0.79.9(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-ai': 0.79.9(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-agent-core@0.79.9(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-ai': 0.79.9(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8444,6 +8490,46 @@ snapshots: - ws - zod + '@earendil-works/pi-ai@0.77.0': + dependencies: + '@anthropic-ai/sdk': 0.91.1 + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0 + '@mistralai/mistralai': 2.2.1 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0 + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) + '@mistralai/mistralai': 2.2.1 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.20.0)(zod@3.25.76) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.3.6) @@ -8484,6 +8570,27 @@ snapshots: - ws - zod + '@earendil-works/pi-ai@0.79.9(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.20.0)(zod@3.25.76) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-ai@0.79.9(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.3.6) @@ -8509,7 +8616,7 @@ snapshots: dependencies: '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) + '@google/genai': 1.52.0 '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) '@opentelemetry/api': 1.9.0 '@smithy/node-http-handler': 4.7.3 @@ -8526,6 +8633,64 @@ snapshots: - ws - zod + '@earendil-works/pi-coding-agent@0.77.0': + dependencies: + '@earendil-works/pi-agent-core': 0.77.0 + '@earendil-works/pi-ai': 0.77.0 + '@earendil-works/pi-tui': 0.77.0 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + typebox: 1.1.38 + undici: 8.3.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-tui': 0.77.0 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + typebox: 1.1.38 + undici: 8.3.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8584,6 +8749,36 @@ snapshots: - ws - zod + '@earendil-works/pi-coding-agent@0.79.9(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-agent-core': 0.79.9(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-ai': 0.79.9(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-tui': 0.79.9 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + semver: 7.8.0 + typebox: 1.1.38 + undici: 8.5.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-coding-agent@0.79.9(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-agent-core': 0.79.9(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8970,6 +9165,30 @@ snapshots: '@exodus/bytes@1.15.0': {} + '@google/genai@1.52.0': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.8 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.8 + ws: 8.20.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.28.0(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))': dependencies: google-auth-library: 10.6.2 @@ -9498,6 +9717,29 @@ snapshots: - bufferutil - utf-8-validate + '@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.12(hono@4.12.9) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.9 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - supports-color + optional: true + '@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.12(hono@4.12.9) @@ -10279,7 +10521,7 @@ snapshots: obug: 2.1.2 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.8': dependencies: @@ -13576,6 +13818,8 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openai@6.26.0: {} + openai@6.26.0(ws@8.20.0)(zod@3.25.76): optionalDependencies: ws: 8.20.0