authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-10-11 01:19:04+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 18:19:04-05:00
log5f7294d452a5c6d6e0b7bd2c992478f830fbe212
treea0c5716de675b87944442d54d48640f0901a14cc
parent953edeec9dcddd7afc582926eb1b65cfd82dc784
signature Signed by PGP key B5690EEEBB952194

fix(pi): allow known read-only T3 tools without approval (#17852)

Co-authored-by: Yash Singh <saiansh2525@gmail.com>

5 files changed, 103 insertions(+), 7 deletions(-)

packages/provider-pi/src/server/mcpBridge.testkit.ts+28-6
......@@ -1,5 +1,6 @@
11// @effect-diagnostics nodeBuiltinImport:off -- Executes the shipped Pi extension at its native JavaScript boundary.
22import * as NodeModule from "node:module";
3import * as NodePath from "node:path";
34import * as NodeVM from "node:vm";
45import { PI_T3_MCP_EXTENSION_SOURCE } from "./mcpExtensionSource.ts";
56
......@@ -39,12 +40,14 @@ export async function loadMcpBridge(
3940 readonly toolSearchAvailable?: boolean;
4041 readonly toolSearchDisabled?: boolean;
4142 readonly allowsTool?: (name: string) => boolean;
43 readonly runtimeMode?: string;
4244 } = {},
4345) {
4446 const handlers = new Map<string, AgentStartHook>();
4547 const tools: RegisteredTool[] = [];
4648 const requests: Array<{ readonly method: string; readonly params?: unknown }> = [];
4749 let activeTools = ["read"];
50 let bridgeSourcePath = "/fixture/pi-t3-extension.ts";
4851 const transports: Array<{
4952 readonly url: string;
5053 readonly authorization: string;
......@@ -52,9 +55,17 @@ export async function loadMcpBridge(
5255 }> = [];
5356 const servers: Array<{ readonly name: string; readonly config: Record<string, unknown> }> = [];
5457 const catalog = [
55 { name: "orchestrator_capabilities", description: "Discover available providers and models." },
58 {
59 name: "orchestrator_capabilities",
60 description: "Discover available providers and models.",
61 annotations: { readOnlyHint: true },
62 },
5663 { name: "delegate_task", description: "Delegate work to another agent." },
57 { name: "task_status", description: "Check delegated work." },
64 {
65 name: "task_status",
66 description: "Check delegated work.",
67 annotations: { readOnlyHint: false },
68 },
5869 { name: "preview_snapshot", description: "Inspect the collaborative browser." },
5970 ].map((tool) => ({
6071 ...tool,
......@@ -69,9 +80,15 @@ export async function loadMcpBridge(
6980 );
7081 await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, {
7182 process: {
72 env: { T3_MCP_URL: "http://fixture.invalid/mcp", T3_MCP_BEARER_TOKEN: "fixture-token" },
83 env: {
84 T3_MCP_URL: "http://fixture.invalid/mcp",
85 T3_MCP_BEARER_TOKEN: "fixture-token",
86 T3_PI_MCP_EXTENSION_PATH: "/fixture/pi-t3-extension.ts",
87 T3_PI_RUNTIME_MODE: options.runtimeMode,
88 },
7389 },
7490 AbortSignal,
91 NodePath,
7592 Type: { Unsafe: (schema: unknown) => schema },
7693 fetch: async (
7794 url: string,
......@@ -111,12 +128,14 @@ export async function loadMcpBridge(
111128 setActiveTools: (names: string[]) => {
112129 activeTools = names.filter((name) => options.allowsTool?.(name) ?? true);
113130 },
114 getAllTools: () =>
115 options.toolSearchAvailable &&
131 getAllTools: () => [
132 ...tools.map((tool) => ({ name: tool.name, sourceInfo: { path: bridgeSourcePath } })),
133 ...(options.toolSearchAvailable &&
116134 !options.toolSearchDisabled &&
117135 (options.allowsTool?.("tool_search") ?? true)
118136 ? [{ name: "tool_search", sourceInfo: { path: "builtin:tool-search" } }]
119 : [],
137 : []),
138 ],
120139 ...(options.modern
121140 ? {
122141 registerMcpServer: (name: string, config: Record<string, unknown>) =>
......@@ -129,6 +148,9 @@ export async function loadMcpBridge(
129148 return {
130149 handlers,
131150 tools,
151 setBridgeSourcePath: (path: string) => {
152 bridgeSourcePath = path;
153 },
132154 requests,
133155 servers,
134156 transports,
packages/provider-pi/src/server/mcpExtensionSource.test.ts+46
......@@ -246,6 +246,52 @@ describe("Pi MCP tool exposure", () => {
246246});
247247
248248describe("Pi tool discovery permissions", () => {
249 it.each(["approval-required", "auto-accept-edits", "auto"])(
250 "allows annotated T3 reads in %s while gating mutations and replacements",
251 async (runtimeMode) => {
252 const bridge = await loadMcpBridge({ modern: true, runtimeMode });
253 const hook = bridge.handlers.get("tool_call") as unknown as (
254 event: { toolName: string; input: unknown },
255 ctx: { ui: { confirm: (title: string) => Promise<boolean> } },
256 ) => Promise<{ block: true; reason: string } | undefined>;
257 const confirmations: string[] = [];
258 const ctx = {
259 ui: {
260 confirm: async (title: string) => {
261 confirmations.push(title);
262 return false;
263 },
264 },
265 };
266 for (const prefix of ["mcp__t3-code__", "mcp__t3_code__"]) {
267 assert.isUndefined(
268 await hook({ toolName: prefix + "orchestrator_capabilities", input: {} }, ctx),
269 );
270 assert.equal(
271 (await hook({ toolName: prefix + "delegate_task", input: {} }, ctx))?.block,
272 true,
273 );
274 // task_status acknowledges result delivery, and is intentionally not read-only.
275 assert.equal(
276 (await hook({ toolName: prefix + "task_status", input: {} }, ctx))?.block,
277 true,
278 );
279 }
280 assert.equal(confirmations.length, 4);
281 bridge.setBridgeSourcePath("/user/extensions/replacement.ts");
282 assert.equal(
283 (await hook({ toolName: "mcp__t3-code__orchestrator_capabilities", input: {} }, ctx))
284 ?.block,
285 true,
286 );
287 assert.equal(
288 (await hook({ toolName: "mcp__other__orchestrator_capabilities", input: {} }, ctx))?.block,
289 true,
290 );
291 assert.equal(confirmations.length, 6);
292 },
293 );
294
249295 it("allows discovery without confirmation and still gates the discovered tool", async () => {
250296 type ToolCallHook = (
251297 event: { toolName: string; input: unknown },
packages/provider-pi/src/server/mcpExtensionSource.ts+17-1
......@@ -15,6 +15,7 @@ export const PI_T3_MCP_EXTENSION_FILENAME = "pi-t3-mcp-extension.ts";
1515export const T3_MCP_URL_ENV = "T3_MCP_URL";
1616export const T3_MCP_BEARER_ENV = "T3_MCP_BEARER_TOKEN";
1717export const T3_PI_RUNTIME_MODE_ENV = "T3_PI_RUNTIME_MODE";
18export const T3_PI_MCP_EXTENSION_PATH_ENV = "T3_PI_MCP_EXTENSION_PATH";
1819
1920/**
2021 * Pi tools whose confirmations the bridge raises as file-change approvals.
......@@ -31,6 +32,7 @@ import { Type } from "typebox";
3132const URL_ENV = ${JSON.stringify(T3_MCP_URL_ENV)};
3233const TOKEN_ENV = ${JSON.stringify(T3_MCP_BEARER_ENV)};
3334const RUNTIME_MODE_ENV = ${JSON.stringify(T3_PI_RUNTIME_MODE_ENV)};
35const EXTENSION_PATH_ENV = ${JSON.stringify(T3_PI_MCP_EXTENSION_PATH_ENV)};
3436const ORCHESTRATION_INSTRUCTIONS = ${JSON.stringify(T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim())};
3537const PROTOCOL = "2025-06-18";
3638const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
......@@ -50,6 +52,7 @@ type McpTool = {
5052 readonly name: string;
5153 readonly description?: string;
5254 readonly inputSchema?: Record<string, unknown>;
55 readonly annotations?: { readonly readOnlyHint?: boolean };
5356 readonly outputSchema?: Record<string, unknown>;
5457};
5558
......@@ -306,10 +309,21 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
306309 typeof pi.getAllTools === "function" &&
307310 pi.getAllTools().some((tool) => tool.name === "tool_search" && tool.sourceInfo?.path === "builtin:tool-search");
308311
312 const readOnlyMcpTools = new Set<string>();
313 const isReadOnlyMcpTool = (name: string) => {
314 const extensionPath = env(EXTENSION_PATH_ENV);
315 if (extensionPath === undefined || !readOnlyMcpTools.has(name) || typeof pi.getAllTools !== "function") return false;
316 // A user extension may own the same name. Only our registered HTTP bridge
317 // may inherit the canonical T3 server's read-only annotation.
318 return pi.getAllTools().some((tool) => tool.name === name &&
319 typeof tool.sourceInfo?.path === "string" &&
320 NodePath.resolve(tool.sourceInfo.path) === NodePath.resolve(extensionPath));
321 };
322
309323 pi.on("tool_call", async (event, ctx) => {
310324 const mode = runtimeMode();
311325 if (mode === "full-access") return;
312 if (event.toolName === "tool_search" ? hasBuiltinToolSearch() : READ_ONLY_TOOLS.has(event.toolName)) {
326 if ((event.toolName === "tool_search" ? hasBuiltinToolSearch() : READ_ONLY_TOOLS.has(event.toolName)) || isReadOnlyMcpTool(event.toolName)) {
313327 return;
314328 }
315329 if (mode === "auto-accept-edits" && FILE_CHANGE_TOOLS.has(event.toolName)) {
......@@ -351,9 +365,11 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
351365 // Preserve public names for saved loadouts and tool selectors. Hidden
352366 // canonical names reserve ownership against Pi's configured MCP servers.
353367 const prefixes = supportsExposure ? ["mcp__t3-code__", "mcp__t3_code__"] : ["mcp__t3-code__"];
368 readOnlyMcpTools.clear();
354369 for (const tool of catalog) {
355370 const name = tool.name;
356371 for (const prefix of prefixes) {
372 if (tool.annotations?.readOnlyHint === true) readOnlyMcpTools.add(\`\${prefix}\${name}\`);
357373 const exposure = prefix === "mcp__t3_code__" ? "hidden" :
358374 deferOptionalTools && !directTools.has(name) ? "deferred" : "direct";
359375 pi.registerTool({
packages/provider-pi/src/server/mcpInjection.test.ts+7
......@@ -9,6 +9,7 @@ import {
99 T3_MCP_BEARER_ENV,
1010 T3_MCP_URL_ENV,
1111 T3_PI_RUNTIME_MODE_ENV,
12 T3_PI_MCP_EXTENSION_PATH_ENV,
1213} from "./mcpExtensionSource.ts";
1314import {
1415 buildPiRpcLaunch,
......@@ -65,12 +66,14 @@ describe("pi T3 MCP injection", () => {
6566 assert.equal(launch.env[T3_MCP_URL_ENV], "http://127.0.0.1:43123/mcp");
6667 assert.equal(launch.env[T3_MCP_BEARER_ENV], "secret-pi-token");
6768 assert.equal(launch.env[T3_PI_RUNTIME_MODE_ENV], "approval-required");
69 assert.equal(launch.env[T3_PI_MCP_EXTENSION_PATH_ENV], "/tmp/cache/pi-t3-mcp-extension.ts");
6870
6971 const permissionOnly = buildPiRpcLaunch({
7072 launchArgs: [],
7173 environment: {
7274 [T3_MCP_URL_ENV]: "http://127.0.0.1:9999/stale",
7375 [T3_MCP_BEARER_ENV]: "stale-token",
76 [T3_PI_MCP_EXTENSION_PATH_ENV]: "/stale/extension.ts",
7477 },
7578 mcpSession: undefined,
7679 extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts",
......@@ -86,6 +89,10 @@ describe("pi T3 MCP injection", () => {
8689 assert.isUndefined(permissionOnly.env[T3_MCP_URL_ENV]);
8790 assert.isUndefined(permissionOnly.env[T3_MCP_BEARER_ENV]);
8891 assert.equal(permissionOnly.env[T3_PI_RUNTIME_MODE_ENV], "auto-accept-edits");
92 assert.equal(
93 permissionOnly.env[T3_PI_MCP_EXTENSION_PATH_ENV],
94 "/tmp/cache/pi-t3-mcp-extension.ts",
95 );
8996 });
9097
9198 it("falls back to Pi's first supported mode for legacy auto threads", () => {
packages/provider-pi/src/server/mcpInjection.ts+5
......@@ -9,6 +9,7 @@ import {
99 T3_MCP_BEARER_ENV,
1010 T3_MCP_URL_ENV,
1111 T3_PI_RUNTIME_MODE_ENV,
12 T3_PI_MCP_EXTENSION_PATH_ENV,
1213} from "./mcpExtensionSource.ts";
1314
1415const RESERVED_PI_LAUNCH_ARGUMENTS = new Set([
......@@ -291,11 +292,15 @@ export function buildPiRpcLaunch(input: {
291292 // credentials inherited from the server or a parent provider process.
292293 delete environment[T3_MCP_URL_ENV];
293294 delete environment[T3_MCP_BEARER_ENV];
295 delete environment[T3_PI_MCP_EXTENSION_PATH_ENV];
294296
295297 return {
296298 args,
297299 env: {
298300 ...environment,
301 ...(hasT3Extension && input.extensionPath !== undefined
302 ? { [T3_PI_MCP_EXTENSION_PATH_ENV]: input.extensionPath }
303 : {}),
299304 ...(hasT3Extension && input.runtimeMode !== undefined
300305 ? {
301306 [T3_PI_RUNTIME_MODE_ENV]: