diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts index 9dc9fa4526e6..5498d2afec83 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts @@ -9,6 +9,308 @@ type RequestHook = ( ctx: { model: { provider: string } }, ) => Record | undefined; +interface RegisteredTool { + readonly name: string; + readonly description: string; + readonly parameters: unknown; + readonly exposure?: string; + readonly promptSnippet?: string; + readonly promptGuidelines?: ReadonlyArray; + readonly execute: ( + id: string, + args: Record, + signal?: AbortSignal, + ) => Promise<{ + readonly content: ReadonlyArray<{ readonly type: string; readonly text: string }>; + }>; +} + +type AgentStartHook = ( + event: { systemPrompt: string }, + ctx: { ui: { notify: (message: string, severity: string) => void } }, +) => Promise<{ systemPrompt: string }>; + +async function loadMcpBridge( + options: { + readonly modern?: boolean; + readonly toolSearchAvailable?: boolean; + readonly toolSearchDisabled?: boolean; + readonly allowsTool?: (name: string) => boolean; + } = {}, +) { + const handlers = new Map(); + const tools: RegisteredTool[] = []; + const requests: Array<{ readonly method: string; readonly params?: unknown }> = []; + let activeTools = ["read"]; + const transports: Array<{ + readonly url: string; + readonly authorization: string; + readonly signal: AbortSignal | undefined; + }> = []; + const servers: Array<{ readonly name: string; readonly config: Record }> = []; + const catalog = [ + { name: "orchestrator_capabilities", description: "Discover available providers and models." }, + { name: "delegate_task", description: "Delegate work to another agent." }, + { name: "task_status", description: "Check delegated work." }, + { name: "preview_snapshot", description: "Inspect the collaborative browser." }, + ].map((tool) => ({ ...tool, inputSchema: { type: "object", properties: {} } })); + const source = NodeModule.stripTypeScriptTypes( + PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace( + "export default async function", + "async function", + ), + ); + await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { + process: { + env: { T3_MCP_URL: "http://fixture.invalid/mcp", T3_MCP_BEARER_TOKEN: "fixture-token" }, + }, + AbortSignal, + Type: { Unsafe: (schema: unknown) => schema }, + fetch: async ( + url: string, + options: { body: string; headers: Record; signal?: AbortSignal }, + ) => { + transports.push({ + url, + authorization: options.headers.authorization!, + signal: options.signal, + }); + const request = JSON.parse(options.body) as { id: number; method: string; params?: unknown }; + requests.push(request); + const result = + request.method === "tools/list" + ? { tools: catalog } + : request.method === "tools/call" + ? { content: [{ type: "text", text: "browser snapshot" }] } + : {}; + return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }), { + headers: { "content-type": "application/json" }, + }); + }, + pi: { + on: (name: string, handler: AgentStartHook) => handlers.set(name, handler), + registerTool: (tool: RegisteredTool) => { + if (options.allowsTool && !options.allowsTool(tool.name)) return; + const index = tools.findIndex((current) => current.name === tool.name); + if (index === -1) tools.push(tool); + else tools[index] = tool; + }, + getActiveTools: () => activeTools, + setActiveTools: (names: string[]) => { + activeTools = names.filter((name) => options.allowsTool?.(name) ?? true); + }, + getAllTools: () => + options.toolSearchAvailable && + !options.toolSearchDisabled && + (options.allowsTool?.("tool_search") ?? true) + ? [{ name: "tool_search", sourceInfo: { path: "builtin:tool-search" } }] + : [], + ...(options.modern + ? { + registerMcpServer: (name: string, config: Record) => + servers.push({ name, config }), + unregisterMcpServer: () => servers.splice(0), + } + : {}), + }, + }); + return { + handlers, + tools, + requests, + servers, + transports, + getActiveTools: () => activeTools, + restoreActiveTools: (names: string[]) => { + activeTools = names; + }, + }; +} + +describe("Pi MCP tool exposure", () => { + it("keeps orchestration direct and optional bridge tools discoverable on modern Pi", async () => { + const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true }); + await bridge.handlers.get("session_start")!( + { systemPrompt: "" }, + { ui: { notify: () => undefined } }, + ); + const start = bridge.handlers.get("before_agent_start"); + assert.isDefined(start); + const prompt = await start!( + { systemPrompt: "Pi system prompt" }, + { ui: { notify: () => undefined } }, + ); + assert.include(prompt.systemPrompt, "orchestrator_capabilities"); + assert.equal(bridge.servers.length, 0); + assert.equal(bridge.tools.length, 8); + assert.deepEqual(bridge.getActiveTools(), ["read", "tool_search"]); + assert.deepEqual( + bridge.tools + .filter((tool) => tool.exposure !== "hidden") + .map((tool) => [tool.name, tool.exposure]), + [ + ["mcp__t3-code__orchestrator_capabilities", "direct"], + ["mcp__t3-code__delegate_task", "direct"], + ["mcp__t3-code__task_status", "direct"], + ["mcp__t3-code__preview_snapshot", "deferred"], + ], + ); + assert.deepEqual( + bridge.tools.filter((tool) => tool.exposure === "hidden").map((tool) => tool.name), + [ + "mcp__t3_code__orchestrator_capabilities", + "mcp__t3_code__delegate_task", + "mcp__t3_code__task_status", + "mcp__t3_code__preview_snapshot", + ], + ); + const result = await bridge.tools + .find((tool) => tool.name === "mcp__t3-code__preview_snapshot")! + .execute("call-1", { depth: 2 }); + assert.equal(result.content[0]?.text, "browser snapshot"); + assert.equal(bridge.requests.at(-1)?.method, "tools/call"); + }); + + it.each(["legacy Pi", "disabled tool search"])( + "keeps tool execution available with %s", + async (mode) => { + const bridge = await loadMcpBridge({ + modern: mode !== "legacy Pi", + toolSearchAvailable: mode === "disabled tool search", + toolSearchDisabled: mode === "disabled tool search", + }); + if (mode !== "legacy Pi") { + const start = bridge.handlers.get("session_start"); + await start!({ systemPrompt: "Pi system prompt" }, { ui: { notify: () => undefined } }); + } + assert.equal(bridge.tools.filter((tool) => tool.exposure !== "hidden").length, 4); + assert.isTrue( + bridge.tools + .filter((tool) => tool.exposure !== "hidden") + .every((tool) => tool.exposure === undefined || tool.exposure === "direct"), + ); + const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot"); + assert.isDefined(tool); + const controller = new AbortController(); + const result = await tool!.execute("call-1", { depth: 2 }, controller.signal); + assert.equal(result.content[0]?.text, "browser snapshot"); + assert.strictEqual(bridge.transports.at(-1)?.signal, controller.signal); + assert.equal(bridge.transports.at(-1)?.url, "http://fixture.invalid/mcp"); + assert.equal(bridge.transports.at(-1)?.authorization, "Bearer fixture-token"); + assert.deepEqual(JSON.parse(JSON.stringify(bridge.requests.at(-1))), { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "preview_snapshot", arguments: { depth: 2 } }, + }); + assert.isUndefined(tool?.promptSnippet); + assert.isUndefined(tool?.promptGuidelines); + }, + ); + + it("preserves legacy wildcard tool selection", async () => { + const bridge = await loadMcpBridge({ + modern: true, + toolSearchAvailable: true, + allowsTool: (name) => + name === "read" || name === "tool_search" || name.startsWith("mcp__t3-code__"), + }); + await bridge.handlers.get("session_start")!( + { systemPrompt: "" }, + { ui: { notify: () => undefined } }, + ); + assert.equal(bridge.tools.length, 4); + assert.equal(bridge.tools.filter((tool) => tool.exposure === "direct").length, 3); + const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot"); + assert.isDefined(tool); + assert.equal((await tool!.execute("selected", {})).content[0]?.text, "browser snapshot"); + }); + + it.each([false, true])( + "reconciles tree loadouts while honoring search exclusion: %s", + async (excludeSearch) => { + const bridge = await loadMcpBridge({ + modern: true, + toolSearchAvailable: true, + allowsTool: (name) => + name !== "mcp__t3-code__delegate_task" && (!excludeSearch || name !== "tool_search"), + }); + await bridge.handlers.get("session_start")!( + { systemPrompt: "" }, + { ui: { notify: () => undefined } }, + ); + bridge.restoreActiveTools([ + "read", + "mcp__t3-code__task_status", + "mcp__t3-code__preview_snapshot", + ]); + const tree = bridge.handlers.get("session_tree"); + assert.isDefined(tree); + await tree!({ systemPrompt: "" }, { ui: { notify: () => undefined } }); + assert.deepEqual(bridge.getActiveTools(), [ + "read", + "mcp__t3-code__task_status", + "mcp__t3-code__preview_snapshot", + ...(!excludeSearch ? ["tool_search"] : []), + ]); + assert.isFalse( + bridge.tools.some( + (tool) => tool.exposure !== "hidden" && tool.name.endsWith("__delegate_task"), + ), + ); + }, + ); +}); + +describe("Pi tool discovery permissions", () => { + it("allows discovery without confirmation and still gates the discovered tool", async () => { + type ToolCallHook = ( + event: { toolName: string; input: unknown }, + ctx: { ui: { confirm: (title: string, detail: string) => Promise } }, + ) => Promise<{ block: true; reason: string } | undefined>; + let toolCall: ToolCallHook | undefined; + let searchPath = "builtin:tool-search"; + const source = NodeModule.stripTypeScriptTypes( + PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace( + "export default async function", + "async function", + ), + ); + await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { + process: { env: { T3_PI_RUNTIME_MODE: "approval-required" } }, + pi: { + on: (name: string, handler: ToolCallHook) => { + if (name === "tool_call") toolCall = handler; + }, + getAllTools: () => [{ name: "tool_search", sourceInfo: { path: searchPath } }], + }, + }); + assert.isDefined(toolCall); + const confirmations: string[] = []; + const ctx = { + ui: { + confirm: async (title: string) => { + confirmations.push(title); + return false; + }, + }, + }; + assert.isUndefined( + await toolCall!({ toolName: "tool_search", input: { query: "preview_snapshot" } }, ctx), + ); + assert.equal(confirmations.length, 0); + const result = await toolCall!({ toolName: "mcp__t3-code__preview_snapshot", input: {} }, ctx); + assert.equal(result?.block, true); + assert.deepEqual(confirmations, ["Allow mcp__t3-code__preview_snapshot?"]); + + // An extension that replaces the search builtin is not known to be read-only. + searchPath = "/extensions/custom-search.ts"; + const replaced = await toolCall!({ toolName: "tool_search", input: {} }, ctx); + assert.equal(replaced?.block, true); + assert.equal(confirmations.at(-1), "Allow tool_search?"); + }); +}); + async function loadRequestHook(): Promise { const handlers = new Map(); // Execute the shipped extension with MCP disabled; this path needs no Typebox. diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts index bca79ce6fde1..d5c9f0669125 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts @@ -1,9 +1,9 @@ /** * Source for the T3-owned Pi extension that consumes T3's HTTP MCP server. * - * Pi core has no MCP client. This file is TypeScript that Pi itself loads via - * `--extension`. It is written to a cache path at session open so packaged - * AppImage builds do not need a sibling .ts file next to the bundled server. + * Pi 0.99+ discovers optional HTTP bridge tools on demand. Older versions + * keep them directly available. Pi loads this TypeScript via `--extension`; the + * server writes it to a cache so packaged builds need no sibling .ts file. * * Do not import t3code modules from the string body. The Pi process resolves * `@earendil-works/pi-coding-agent` and `typebox` from the user's pi install. @@ -234,9 +234,18 @@ export default async function t3McpExtension(pi: ExtensionAPI) { // Pi deliberately leaves permission policy to extensions. T3's injected // bridge uses Pi's public blocking tool hook so the shared runtime modes // keep their normal meaning without replacing or shadowing Pi's runtime. + // Only Pi's own search is known to be read-only. An extension that replaces + // it keeps the name, and cannot discover deferred tools either. + const hasBuiltinToolSearch = () => + typeof pi.getAllTools === "function" && + pi.getAllTools().some((tool) => tool.name === "tool_search" && tool.sourceInfo?.path === "builtin:tool-search"); + pi.on("tool_call", async (event, ctx) => { const mode = runtimeMode(); - if (mode === "full-access" || READ_ONLY_TOOLS.has(event.toolName)) return; + if (mode === "full-access") return; + if (event.toolName === "tool_search" ? hasBuiltinToolSearch() : READ_ONLY_TOOLS.has(event.toolName)) { + return; + } if (mode === "auto-accept-edits" && FILE_CHANGE_TOOLS.has(event.toolName)) { return; } @@ -261,28 +270,31 @@ export default async function t3McpExtension(pi: ExtensionAPI) { return; } + // Tool exposure arrived with registerMcpServer in Pi 0.99. Keep the HTTP + // bridge as the credential owner: mcp.json overrides native registrations. + const supportsExposure = "registerMcpServer" in pi && typeof pi.registerMcpServer === "function"; + const directTools = new Set(["orchestrator_capabilities", "delegate_task", "task_status"]); + let deferOptionalTools = supportsExposure; + let catalog: ReadonlyArray = []; + const client = createMcpClient(endpoint, token); let started: Promise | undefined; - const ensureStarted = () => { - if (started !== undefined) return started; - const attempt = (async () => { - const signal = AbortSignal.timeout(10_000); - await client.connect(signal); - const tools = await client.listTools(signal); - for (const tool of tools) { - const name = tool.name; - const registeredName = \`mcp__t3-code__\${name}\`; - const description = tool.description ?? name; + const registerTools = () => { + // Preserve public names for saved loadouts and tool selectors. Hidden + // canonical names reserve ownership against Pi's configured MCP servers. + const prefixes = supportsExposure ? ["mcp__t3-code__", "mcp__t3_code__"] : ["mcp__t3-code__"]; + for (const tool of catalog) { + const name = tool.name; + for (const prefix of prefixes) { + const exposure = prefix === "mcp__t3_code__" ? "hidden" : + deferOptionalTools && !directTools.has(name) ? "deferred" : "direct"; pi.registerTool({ - name: registeredName, + name: \`\${prefix}\${name}\`, label: name, - description, - promptSnippet: description.split("\\n")[0] ?? name, - promptGuidelines: [ - \`Use \${registeredName} from the t3-code MCP server when the user asks for T3 orchestration that this tool covers.\`, - ], + description: tool.description ?? name, parameters: jsonSchemaToTypebox(tool.inputSchema), + ...(supportsExposure ? { exposure } : {}), async execute(_toolCallId, params, signal) { const result = await client.callTool( name, @@ -298,6 +310,16 @@ export default async function t3McpExtension(pi: ExtensionAPI) { }, }); } + } + }; + + const ensureStarted = () => { + if (started !== undefined) return started; + const attempt = (async () => { + const signal = AbortSignal.timeout(10_000); + await client.connect(signal); + catalog = await client.listTools(signal); + registerTools(); })(); started = attempt; void attempt.catch(() => { @@ -306,13 +328,28 @@ export default async function t3McpExtension(pi: ExtensionAPI) { return attempt; }; - // Await here so tools exist before session_start and the first prompt. - // session_start is a retry if the process later reloads the extension. - // Best effort during extension load. A failed first connection is retried - // below on session_start instead of pinning this process to the failure. + // CLI extensions load before builtins, and Pi keeps the first registration + // of a tool name. Register now so the bridge owns the T3 namespace even when + // mcp.json configures it; retry a failed connection at session_start. await ensureStarted().catch(() => undefined); + const reconcileDiscovery = () => { + if (!supportsExposure) return; + // A disabled or replaced search builtin cannot discover deferred tools. + const hasToolSearch = hasBuiltinToolSearch(); + const exposureChanged = deferOptionalTools !== hasToolSearch; + deferOptionalTools = hasToolSearch; + if (exposureChanged) registerTools(); + if (hasToolSearch) { + const active = pi.getActiveTools(); + if (!active.includes("tool_search")) pi.setActiveTools([...active, "tool_search"]); + } + }; + + // Tree navigation restores its saved loadout after session_start. + pi.on("session_tree", reconcileDiscovery); pi.on("session_start", async (_event, ctx) => { + reconcileDiscovery(); try { await ensureStarted(); } catch (error) { diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts index f8f849246bfc..b462b98a612a 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts @@ -162,6 +162,7 @@ describe("pi T3 MCP injection", () => { assert.include(mcpSource, '"mcp-protocol-version"'); assert.include(mcpSource, '"tools/call"'); assert.include(mcpSource, "mcp__t3-code__"); + assert.include(mcpSource, "mcp__t3_code__"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); }); diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 5fc670a99c6d..14437ee7f690 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -145,8 +145,13 @@ provider-specific extensions; those remain in flavors such as Grok. ### Pi V2 -Pi core has no MCP client. When a provider session credential exists, the -adapter writes a T3-owned extension into the server cache and spawns +T3 keeps a provider-session HTTP bridge even when Pi supports native MCP. +`mcp.json` entries override native registrations, and native MCP's default +60-second request timeout can interrupt long-running T3 tools. The bridge owns +the injected endpoint and credential and forwards Pi's cancellation signal. + +When a provider session credential exists, the adapter writes a T3-owned +extension into the server cache and spawns `pi --mode rpc --extension /pi-t3-mcp-extension.ts` with: ```text @@ -154,9 +159,13 @@ T3_MCP_URL=http://127.0.0.1:/mcp T3_MCP_BEARER_TOKEN= ``` -The extension connects to that HTTP endpoint, lists tools, and registers each -one with `pi.registerTool` under a `mcp__t3-code__` namespace -(`mcp__t3-code__delegate_task`, `mcp__t3-code__t3_thread_launch`, and the rest). +The extension preserves public names under `mcp__t3-code__` for saved loadouts +and tool selectors. Modern Pi also receives hidden `mcp__t3_code__` aliases, +which reserve the normalized namespace against configured MCP servers without +adding declarations or search results. On Pi 0.99+, +`orchestrator_capabilities`, `delegate_task`, and `task_status` remain directly +available; optional tools are discovered through Pi's builtin `tool_search`. +On older Pi or without builtin search, all tools remain directly available. The bridge calls the original MCP tool name over HTTP. Follow-up requests send `mcp-protocol-version: 2025-06-18`; Effect's MCP transport returns 400 without it. The first turn of a session also receives the shared T3