authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-10-08 20:34:44+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 13:34:44-05:00
logde72286ee5777319fde054e7a82023916ce6f481
treece0eeb094ca8b6431ce4af2f7075c981869682b8
parentf999eb2a8e7d8db74d4f4ab7dd66f86163e1a226
signature Signed by PGP key B5690EEEBB952194

fix(server): Pi discovers optional T3 tools on demand (#17220)


4 files changed, 378 insertions(+), 29 deletions(-)

apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts+302
......@@ -9,6 +9,308 @@ type RequestHook = (
99 ctx: { model: { provider: string } },
1010) => Record<string, unknown> | undefined;
1111
12interface RegisteredTool {
13 readonly name: string;
14 readonly description: string;
15 readonly parameters: unknown;
16 readonly exposure?: string;
17 readonly promptSnippet?: string;
18 readonly promptGuidelines?: ReadonlyArray<string>;
19 readonly execute: (
20 id: string,
21 args: Record<string, unknown>,
22 signal?: AbortSignal,
23 ) => Promise<{
24 readonly content: ReadonlyArray<{ readonly type: string; readonly text: string }>;
25 }>;
26}
27
28type AgentStartHook = (
29 event: { systemPrompt: string },
30 ctx: { ui: { notify: (message: string, severity: string) => void } },
31) => Promise<{ systemPrompt: string }>;
32
33async function loadMcpBridge(
34 options: {
35 readonly modern?: boolean;
36 readonly toolSearchAvailable?: boolean;
37 readonly toolSearchDisabled?: boolean;
38 readonly allowsTool?: (name: string) => boolean;
39 } = {},
40) {
41 const handlers = new Map<string, AgentStartHook>();
42 const tools: RegisteredTool[] = [];
43 const requests: Array<{ readonly method: string; readonly params?: unknown }> = [];
44 let activeTools = ["read"];
45 const transports: Array<{
46 readonly url: string;
47 readonly authorization: string;
48 readonly signal: AbortSignal | undefined;
49 }> = [];
50 const servers: Array<{ readonly name: string; readonly config: Record<string, unknown> }> = [];
51 const catalog = [
52 { name: "orchestrator_capabilities", description: "Discover available providers and models." },
53 { name: "delegate_task", description: "Delegate work to another agent." },
54 { name: "task_status", description: "Check delegated work." },
55 { name: "preview_snapshot", description: "Inspect the collaborative browser." },
56 ].map((tool) => ({ ...tool, inputSchema: { type: "object", properties: {} } }));
57 const source = NodeModule.stripTypeScriptTypes(
58 PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace(
59 "export default async function",
60 "async function",
61 ),
62 );
63 await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, {
64 process: {
65 env: { T3_MCP_URL: "http://fixture.invalid/mcp", T3_MCP_BEARER_TOKEN: "fixture-token" },
66 },
67 AbortSignal,
68 Type: { Unsafe: (schema: unknown) => schema },
69 fetch: async (
70 url: string,
71 options: { body: string; headers: Record<string, string>; signal?: AbortSignal },
72 ) => {
73 transports.push({
74 url,
75 authorization: options.headers.authorization!,
76 signal: options.signal,
77 });
78 const request = JSON.parse(options.body) as { id: number; method: string; params?: unknown };
79 requests.push(request);
80 const result =
81 request.method === "tools/list"
82 ? { tools: catalog }
83 : request.method === "tools/call"
84 ? { content: [{ type: "text", text: "browser snapshot" }] }
85 : {};
86 return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }), {
87 headers: { "content-type": "application/json" },
88 });
89 },
90 pi: {
91 on: (name: string, handler: AgentStartHook) => handlers.set(name, handler),
92 registerTool: (tool: RegisteredTool) => {
93 if (options.allowsTool && !options.allowsTool(tool.name)) return;
94 const index = tools.findIndex((current) => current.name === tool.name);
95 if (index === -1) tools.push(tool);
96 else tools[index] = tool;
97 },
98 getActiveTools: () => activeTools,
99 setActiveTools: (names: string[]) => {
100 activeTools = names.filter((name) => options.allowsTool?.(name) ?? true);
101 },
102 getAllTools: () =>
103 options.toolSearchAvailable &&
104 !options.toolSearchDisabled &&
105 (options.allowsTool?.("tool_search") ?? true)
106 ? [{ name: "tool_search", sourceInfo: { path: "builtin:tool-search" } }]
107 : [],
108 ...(options.modern
109 ? {
110 registerMcpServer: (name: string, config: Record<string, unknown>) =>
111 servers.push({ name, config }),
112 unregisterMcpServer: () => servers.splice(0),
113 }
114 : {}),
115 },
116 });
117 return {
118 handlers,
119 tools,
120 requests,
121 servers,
122 transports,
123 getActiveTools: () => activeTools,
124 restoreActiveTools: (names: string[]) => {
125 activeTools = names;
126 },
127 };
128}
129
130describe("Pi MCP tool exposure", () => {
131 it("keeps orchestration direct and optional bridge tools discoverable on modern Pi", async () => {
132 const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true });
133 await bridge.handlers.get("session_start")!(
134 { systemPrompt: "" },
135 { ui: { notify: () => undefined } },
136 );
137 const start = bridge.handlers.get("before_agent_start");
138 assert.isDefined(start);
139 const prompt = await start!(
140 { systemPrompt: "Pi system prompt" },
141 { ui: { notify: () => undefined } },
142 );
143 assert.include(prompt.systemPrompt, "orchestrator_capabilities");
144 assert.equal(bridge.servers.length, 0);
145 assert.equal(bridge.tools.length, 8);
146 assert.deepEqual(bridge.getActiveTools(), ["read", "tool_search"]);
147 assert.deepEqual(
148 bridge.tools
149 .filter((tool) => tool.exposure !== "hidden")
150 .map((tool) => [tool.name, tool.exposure]),
151 [
152 ["mcp__t3-code__orchestrator_capabilities", "direct"],
153 ["mcp__t3-code__delegate_task", "direct"],
154 ["mcp__t3-code__task_status", "direct"],
155 ["mcp__t3-code__preview_snapshot", "deferred"],
156 ],
157 );
158 assert.deepEqual(
159 bridge.tools.filter((tool) => tool.exposure === "hidden").map((tool) => tool.name),
160 [
161 "mcp__t3_code__orchestrator_capabilities",
162 "mcp__t3_code__delegate_task",
163 "mcp__t3_code__task_status",
164 "mcp__t3_code__preview_snapshot",
165 ],
166 );
167 const result = await bridge.tools
168 .find((tool) => tool.name === "mcp__t3-code__preview_snapshot")!
169 .execute("call-1", { depth: 2 });
170 assert.equal(result.content[0]?.text, "browser snapshot");
171 assert.equal(bridge.requests.at(-1)?.method, "tools/call");
172 });
173
174 it.each(["legacy Pi", "disabled tool search"])(
175 "keeps tool execution available with %s",
176 async (mode) => {
177 const bridge = await loadMcpBridge({
178 modern: mode !== "legacy Pi",
179 toolSearchAvailable: mode === "disabled tool search",
180 toolSearchDisabled: mode === "disabled tool search",
181 });
182 if (mode !== "legacy Pi") {
183 const start = bridge.handlers.get("session_start");
184 await start!({ systemPrompt: "Pi system prompt" }, { ui: { notify: () => undefined } });
185 }
186 assert.equal(bridge.tools.filter((tool) => tool.exposure !== "hidden").length, 4);
187 assert.isTrue(
188 bridge.tools
189 .filter((tool) => tool.exposure !== "hidden")
190 .every((tool) => tool.exposure === undefined || tool.exposure === "direct"),
191 );
192 const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot");
193 assert.isDefined(tool);
194 const controller = new AbortController();
195 const result = await tool!.execute("call-1", { depth: 2 }, controller.signal);
196 assert.equal(result.content[0]?.text, "browser snapshot");
197 assert.strictEqual(bridge.transports.at(-1)?.signal, controller.signal);
198 assert.equal(bridge.transports.at(-1)?.url, "http://fixture.invalid/mcp");
199 assert.equal(bridge.transports.at(-1)?.authorization, "Bearer fixture-token");
200 assert.deepEqual(JSON.parse(JSON.stringify(bridge.requests.at(-1))), {
201 jsonrpc: "2.0",
202 id: 3,
203 method: "tools/call",
204 params: { name: "preview_snapshot", arguments: { depth: 2 } },
205 });
206 assert.isUndefined(tool?.promptSnippet);
207 assert.isUndefined(tool?.promptGuidelines);
208 },
209 );
210
211 it("preserves legacy wildcard tool selection", async () => {
212 const bridge = await loadMcpBridge({
213 modern: true,
214 toolSearchAvailable: true,
215 allowsTool: (name) =>
216 name === "read" || name === "tool_search" || name.startsWith("mcp__t3-code__"),
217 });
218 await bridge.handlers.get("session_start")!(
219 { systemPrompt: "" },
220 { ui: { notify: () => undefined } },
221 );
222 assert.equal(bridge.tools.length, 4);
223 assert.equal(bridge.tools.filter((tool) => tool.exposure === "direct").length, 3);
224 const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot");
225 assert.isDefined(tool);
226 assert.equal((await tool!.execute("selected", {})).content[0]?.text, "browser snapshot");
227 });
228
229 it.each([false, true])(
230 "reconciles tree loadouts while honoring search exclusion: %s",
231 async (excludeSearch) => {
232 const bridge = await loadMcpBridge({
233 modern: true,
234 toolSearchAvailable: true,
235 allowsTool: (name) =>
236 name !== "mcp__t3-code__delegate_task" && (!excludeSearch || name !== "tool_search"),
237 });
238 await bridge.handlers.get("session_start")!(
239 { systemPrompt: "" },
240 { ui: { notify: () => undefined } },
241 );
242 bridge.restoreActiveTools([
243 "read",
244 "mcp__t3-code__task_status",
245 "mcp__t3-code__preview_snapshot",
246 ]);
247 const tree = bridge.handlers.get("session_tree");
248 assert.isDefined(tree);
249 await tree!({ systemPrompt: "" }, { ui: { notify: () => undefined } });
250 assert.deepEqual(bridge.getActiveTools(), [
251 "read",
252 "mcp__t3-code__task_status",
253 "mcp__t3-code__preview_snapshot",
254 ...(!excludeSearch ? ["tool_search"] : []),
255 ]);
256 assert.isFalse(
257 bridge.tools.some(
258 (tool) => tool.exposure !== "hidden" && tool.name.endsWith("__delegate_task"),
259 ),
260 );
261 },
262 );
263});
264
265describe("Pi tool discovery permissions", () => {
266 it("allows discovery without confirmation and still gates the discovered tool", async () => {
267 type ToolCallHook = (
268 event: { toolName: string; input: unknown },
269 ctx: { ui: { confirm: (title: string, detail: string) => Promise<boolean> } },
270 ) => Promise<{ block: true; reason: string } | undefined>;
271 let toolCall: ToolCallHook | undefined;
272 let searchPath = "builtin:tool-search";
273 const source = NodeModule.stripTypeScriptTypes(
274 PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace(
275 "export default async function",
276 "async function",
277 ),
278 );
279 await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, {
280 process: { env: { T3_PI_RUNTIME_MODE: "approval-required" } },
281 pi: {
282 on: (name: string, handler: ToolCallHook) => {
283 if (name === "tool_call") toolCall = handler;
284 },
285 getAllTools: () => [{ name: "tool_search", sourceInfo: { path: searchPath } }],
286 },
287 });
288 assert.isDefined(toolCall);
289 const confirmations: string[] = [];
290 const ctx = {
291 ui: {
292 confirm: async (title: string) => {
293 confirmations.push(title);
294 return false;
295 },
296 },
297 };
298 assert.isUndefined(
299 await toolCall!({ toolName: "tool_search", input: { query: "preview_snapshot" } }, ctx),
300 );
301 assert.equal(confirmations.length, 0);
302 const result = await toolCall!({ toolName: "mcp__t3-code__preview_snapshot", input: {} }, ctx);
303 assert.equal(result?.block, true);
304 assert.deepEqual(confirmations, ["Allow mcp__t3-code__preview_snapshot?"]);
305
306 // An extension that replaces the search builtin is not known to be read-only.
307 searchPath = "/extensions/custom-search.ts";
308 const replaced = await toolCall!({ toolName: "tool_search", input: {} }, ctx);
309 assert.equal(replaced?.block, true);
310 assert.equal(confirmations.at(-1), "Allow tool_search?");
311 });
312});
313
12314async function loadRequestHook(): Promise<RequestHook> {
13315 const handlers = new Map<string, RequestHook>();
14316 // Execute the shipped extension with MCP disabled; this path needs no Typebox.
apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts+61-24
......@@ -1,9 +1,9 @@
11/**
22 * Source for the T3-owned Pi extension that consumes T3's HTTP MCP server.
33 *
4 * Pi core has no MCP client. This file is TypeScript that Pi itself loads via
5 * `--extension`. It is written to a cache path at session open so packaged
6 * AppImage builds do not need a sibling .ts file next to the bundled server.
4 * Pi 0.99+ discovers optional HTTP bridge tools on demand. Older versions
5 * keep them directly available. Pi loads this TypeScript via `--extension`; the
6 * server writes it to a cache so packaged builds need no sibling .ts file.
77 *
88 * Do not import t3code modules from the string body. The Pi process resolves
99 * `@earendil-works/pi-coding-agent` and `typebox` from the user's pi install.
......@@ -234,9 +234,18 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
234234 // Pi deliberately leaves permission policy to extensions. T3's injected
235235 // bridge uses Pi's public blocking tool hook so the shared runtime modes
236236 // keep their normal meaning without replacing or shadowing Pi's runtime.
237 // Only Pi's own search is known to be read-only. An extension that replaces
238 // it keeps the name, and cannot discover deferred tools either.
239 const hasBuiltinToolSearch = () =>
240 typeof pi.getAllTools === "function" &&
241 pi.getAllTools().some((tool) => tool.name === "tool_search" && tool.sourceInfo?.path === "builtin:tool-search");
242
237243 pi.on("tool_call", async (event, ctx) => {
238244 const mode = runtimeMode();
239 if (mode === "full-access" || READ_ONLY_TOOLS.has(event.toolName)) return;
245 if (mode === "full-access") return;
246 if (event.toolName === "tool_search" ? hasBuiltinToolSearch() : READ_ONLY_TOOLS.has(event.toolName)) {
247 return;
248 }
240249 if (mode === "auto-accept-edits" && FILE_CHANGE_TOOLS.has(event.toolName)) {
241250 return;
242251 }
......@@ -261,28 +270,31 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
261270 return;
262271 }
263272
273 // Tool exposure arrived with registerMcpServer in Pi 0.99. Keep the HTTP
274 // bridge as the credential owner: mcp.json overrides native registrations.
275 const supportsExposure = "registerMcpServer" in pi && typeof pi.registerMcpServer === "function";
276 const directTools = new Set(["orchestrator_capabilities", "delegate_task", "task_status"]);
277 let deferOptionalTools = supportsExposure;
278 let catalog: ReadonlyArray<McpTool> = [];
279
264280 const client = createMcpClient(endpoint, token);
265281 let started: Promise<void> | undefined;
266282
267 const ensureStarted = () => {
268 if (started !== undefined) return started;
269 const attempt = (async () => {
270 const signal = AbortSignal.timeout(10_000);
271 await client.connect(signal);
272 const tools = await client.listTools(signal);
273 for (const tool of tools) {
274 const name = tool.name;
275 const registeredName = \`mcp__t3-code__\${name}\`;
276 const description = tool.description ?? name;
283 const registerTools = () => {
284 // Preserve public names for saved loadouts and tool selectors. Hidden
285 // canonical names reserve ownership against Pi's configured MCP servers.
286 const prefixes = supportsExposure ? ["mcp__t3-code__", "mcp__t3_code__"] : ["mcp__t3-code__"];
287 for (const tool of catalog) {
288 const name = tool.name;
289 for (const prefix of prefixes) {
290 const exposure = prefix === "mcp__t3_code__" ? "hidden" :
291 deferOptionalTools && !directTools.has(name) ? "deferred" : "direct";
277292 pi.registerTool({
278 name: registeredName,
293 name: \`\${prefix}\${name}\`,
279294 label: name,
280 description,
281 promptSnippet: description.split("\\n")[0] ?? name,
282 promptGuidelines: [
283 \`Use \${registeredName} from the t3-code MCP server when the user asks for T3 orchestration that this tool covers.\`,
284 ],
295 description: tool.description ?? name,
285296 parameters: jsonSchemaToTypebox(tool.inputSchema),
297 ...(supportsExposure ? { exposure } : {}),
286298 async execute(_toolCallId, params, signal) {
287299 const result = await client.callTool(
288300 name,
......@@ -298,6 +310,16 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
298310 },
299311 });
300312 }
313 }
314 };
315
316 const ensureStarted = () => {
317 if (started !== undefined) return started;
318 const attempt = (async () => {
319 const signal = AbortSignal.timeout(10_000);
320 await client.connect(signal);
321 catalog = await client.listTools(signal);
322 registerTools();
301323 })();
302324 started = attempt;
303325 void attempt.catch(() => {
......@@ -306,13 +328,28 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
306328 return attempt;
307329 };
308330
309 // Await here so tools exist before session_start and the first prompt.
310 // session_start is a retry if the process later reloads the extension.
311 // Best effort during extension load. A failed first connection is retried
312 // below on session_start instead of pinning this process to the failure.
331 // CLI extensions load before builtins, and Pi keeps the first registration
332 // of a tool name. Register now so the bridge owns the T3 namespace even when
333 // mcp.json configures it; retry a failed connection at session_start.
313334 await ensureStarted().catch(() => undefined);
314335
336 const reconcileDiscovery = () => {
337 if (!supportsExposure) return;
338 // A disabled or replaced search builtin cannot discover deferred tools.
339 const hasToolSearch = hasBuiltinToolSearch();
340 const exposureChanged = deferOptionalTools !== hasToolSearch;
341 deferOptionalTools = hasToolSearch;
342 if (exposureChanged) registerTools();
343 if (hasToolSearch) {
344 const active = pi.getActiveTools();
345 if (!active.includes("tool_search")) pi.setActiveTools([...active, "tool_search"]);
346 }
347 };
348
349 // Tree navigation restores its saved loadout after session_start.
350 pi.on("session_tree", reconcileDiscovery);
315351 pi.on("session_start", async (_event, ctx) => {
352 reconcileDiscovery();
316353 try {
317354 await ensureStarted();
318355 } catch (error) {
apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts+1
......@@ -162,6 +162,7 @@ describe("pi T3 MCP injection", () => {
162162 assert.include(mcpSource, '"mcp-protocol-version"');
163163 assert.include(mcpSource, '"tools/call"');
164164 assert.include(mcpSource, "mcp__t3-code__");
165 assert.include(mcpSource, "mcp__t3_code__");
165166 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
166167 );
167168});
docs/orchestration-v2/orchestrator-mcp-server.md+14-5
......@@ -145,8 +145,13 @@ provider-specific extensions; those remain in flavors such as Grok.
145145
146146### Pi V2
147147
148Pi core has no MCP client. When a provider session credential exists, the
149adapter writes a T3-owned extension into the server cache and spawns
148T3 keeps a provider-session HTTP bridge even when Pi supports native MCP.
149`mcp.json` entries override native registrations, and native MCP's default
15060-second request timeout can interrupt long-running T3 tools. The bridge owns
151the injected endpoint and credential and forwards Pi's cancellation signal.
152
153When a provider session credential exists, the adapter writes a T3-owned
154extension into the server cache and spawns
150155`pi --mode rpc --extension <cache>/pi-t3-mcp-extension.ts` with:
151156
152157```text
......@@ -154,9 +159,13 @@ T3_MCP_URL=http://127.0.0.1:<port>/mcp
154159T3_MCP_BEARER_TOKEN=<provider-session-token>
155160```
156161
157The extension connects to that HTTP endpoint, lists tools, and registers each
158one with `pi.registerTool` under a `mcp__t3-code__` namespace
159(`mcp__t3-code__delegate_task`, `mcp__t3-code__t3_thread_launch`, and the rest).
162The extension preserves public names under `mcp__t3-code__` for saved loadouts
163and tool selectors. Modern Pi also receives hidden `mcp__t3_code__` aliases,
164which reserve the normalized namespace against configured MCP servers without
165adding declarations or search results. On Pi 0.99+,
166`orchestrator_capabilities`, `delegate_task`, and `task_status` remain directly
167available; optional tools are discovered through Pi's builtin `tool_search`.
168On older Pi or without builtin search, all tools remain directly available.
160169The bridge calls the original MCP tool name over HTTP. Follow-up requests send
161170`mcp-protocol-version: 2025-06-18`; Effect's MCP transport returns 400
162171without it. The first turn of a session also receives the shared T3