Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions packages/provider-pi/src/server/mcpBridge.testkit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @effect-diagnostics nodeBuiltinImport:off -- Executes the shipped Pi extension at its native JavaScript boundary.
import * as NodeModule from "node:module";
import * as NodePath from "node:path";
import * as NodeVM from "node:vm";
import { PI_T3_MCP_EXTENSION_SOURCE } from "./mcpExtensionSource.ts";

Expand Down Expand Up @@ -39,22 +40,32 @@ export async function loadMcpBridge(
readonly toolSearchAvailable?: boolean;
readonly toolSearchDisabled?: boolean;
readonly allowsTool?: (name: string) => boolean;
readonly runtimeMode?: string;
} = {},
) {
const handlers = new Map<string, AgentStartHook>();
const tools: RegisteredTool[] = [];
const requests: Array<{ readonly method: string; readonly params?: unknown }> = [];
let activeTools = ["read"];
let bridgeSourcePath = "/fixture/pi-t3-extension.ts";
const transports: Array<{
readonly url: string;
readonly authorization: string;
readonly signal: AbortSignal | undefined;
}> = [];
const servers: Array<{ readonly name: string; readonly config: Record<string, unknown> }> = [];
const catalog = [
{ name: "orchestrator_capabilities", description: "Discover available providers and models." },
{
name: "orchestrator_capabilities",
description: "Discover available providers and models.",
annotations: { readOnlyHint: true },
},
{ name: "delegate_task", description: "Delegate work to another agent." },
{ name: "task_status", description: "Check delegated work." },
{
name: "task_status",
description: "Check delegated work.",
annotations: { readOnlyHint: false },
},
{ name: "preview_snapshot", description: "Inspect the collaborative browser." },
].map((tool) => ({
...tool,
Expand All @@ -69,9 +80,15 @@ export async function loadMcpBridge(
);
await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, {
process: {
env: { T3_MCP_URL: "http://fixture.invalid/mcp", T3_MCP_BEARER_TOKEN: "fixture-token" },
env: {
T3_MCP_URL: "http://fixture.invalid/mcp",
T3_MCP_BEARER_TOKEN: "fixture-token",
T3_PI_MCP_EXTENSION_PATH: "/fixture/pi-t3-extension.ts",
T3_PI_RUNTIME_MODE: options.runtimeMode,
},
},
AbortSignal,
NodePath,
Type: { Unsafe: (schema: unknown) => schema },
fetch: async (
url: string,
Expand Down Expand Up @@ -111,12 +128,14 @@ export async function loadMcpBridge(
setActiveTools: (names: string[]) => {
activeTools = names.filter((name) => options.allowsTool?.(name) ?? true);
},
getAllTools: () =>
options.toolSearchAvailable &&
getAllTools: () => [
...tools.map((tool) => ({ name: tool.name, sourceInfo: { path: bridgeSourcePath } })),
...(options.toolSearchAvailable &&
!options.toolSearchDisabled &&
(options.allowsTool?.("tool_search") ?? true)
? [{ name: "tool_search", sourceInfo: { path: "builtin:tool-search" } }]
: [],
: []),
],
...(options.modern
? {
registerMcpServer: (name: string, config: Record<string, unknown>) =>
Expand All @@ -129,6 +148,9 @@ export async function loadMcpBridge(
return {
handlers,
tools,
setBridgeSourcePath: (path: string) => {
bridgeSourcePath = path;
},
requests,
servers,
transports,
Expand Down
46 changes: 46 additions & 0 deletions packages/provider-pi/src/server/mcpExtensionSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,52 @@ describe("Pi MCP tool exposure", () => {
});

describe("Pi tool discovery permissions", () => {
it.each(["approval-required", "auto-accept-edits", "auto"])(
"allows annotated T3 reads in %s while gating mutations and replacements",
async (runtimeMode) => {
const bridge = await loadMcpBridge({ modern: true, runtimeMode });
const hook = bridge.handlers.get("tool_call") as unknown as (
event: { toolName: string; input: unknown },
ctx: { ui: { confirm: (title: string) => Promise<boolean> } },
) => Promise<{ block: true; reason: string } | undefined>;
const confirmations: string[] = [];
const ctx = {
ui: {
confirm: async (title: string) => {
confirmations.push(title);
return false;
},
},
};
for (const prefix of ["mcp__t3-code__", "mcp__t3_code__"]) {
assert.isUndefined(
await hook({ toolName: prefix + "orchestrator_capabilities", input: {} }, ctx),
);
assert.equal(
(await hook({ toolName: prefix + "delegate_task", input: {} }, ctx))?.block,
true,
);
// task_status acknowledges result delivery, and is intentionally not read-only.
assert.equal(
(await hook({ toolName: prefix + "task_status", input: {} }, ctx))?.block,
true,
);
}
assert.equal(confirmations.length, 4);
bridge.setBridgeSourcePath("/user/extensions/replacement.ts");
assert.equal(
(await hook({ toolName: "mcp__t3-code__orchestrator_capabilities", input: {} }, ctx))
?.block,
true,
);
assert.equal(
(await hook({ toolName: "mcp__other__orchestrator_capabilities", input: {} }, ctx))?.block,
true,
);
assert.equal(confirmations.length, 6);
},
);

it("allows discovery without confirmation and still gates the discovered tool", async () => {
type ToolCallHook = (
event: { toolName: string; input: unknown },
Expand Down
18 changes: 17 additions & 1 deletion packages/provider-pi/src/server/mcpExtensionSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const PI_T3_MCP_EXTENSION_FILENAME = "pi-t3-mcp-extension.ts";
export const T3_MCP_URL_ENV = "T3_MCP_URL";
export const T3_MCP_BEARER_ENV = "T3_MCP_BEARER_TOKEN";
export const T3_PI_RUNTIME_MODE_ENV = "T3_PI_RUNTIME_MODE";
export const T3_PI_MCP_EXTENSION_PATH_ENV = "T3_PI_MCP_EXTENSION_PATH";

/**
* Pi tools whose confirmations the bridge raises as file-change approvals.
Expand All @@ -31,6 +32,7 @@ import { Type } from "typebox";
const URL_ENV = ${JSON.stringify(T3_MCP_URL_ENV)};
const TOKEN_ENV = ${JSON.stringify(T3_MCP_BEARER_ENV)};
const RUNTIME_MODE_ENV = ${JSON.stringify(T3_PI_RUNTIME_MODE_ENV)};
const EXTENSION_PATH_ENV = ${JSON.stringify(T3_PI_MCP_EXTENSION_PATH_ENV)};
const ORCHESTRATION_INSTRUCTIONS = ${JSON.stringify(T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim())};
const PROTOCOL = "2025-06-18";
const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
Expand All @@ -50,6 +52,7 @@ type McpTool = {
readonly name: string;
readonly description?: string;
readonly inputSchema?: Record<string, unknown>;
readonly annotations?: { readonly readOnlyHint?: boolean };
readonly outputSchema?: Record<string, unknown>;
};

Expand Down Expand Up @@ -306,10 +309,21 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
typeof pi.getAllTools === "function" &&
pi.getAllTools().some((tool) => tool.name === "tool_search" && tool.sourceInfo?.path === "builtin:tool-search");

const readOnlyMcpTools = new Set<string>();
const isReadOnlyMcpTool = (name: string) => {
const extensionPath = env(EXTENSION_PATH_ENV);
if (extensionPath === undefined || !readOnlyMcpTools.has(name) || typeof pi.getAllTools !== "function") return false;
// A user extension may own the same name. Only our registered HTTP bridge
// may inherit the canonical T3 server's read-only annotation.
return pi.getAllTools().some((tool) => tool.name === name &&
typeof tool.sourceInfo?.path === "string" &&
NodePath.resolve(tool.sourceInfo.path) === NodePath.resolve(extensionPath));
};

pi.on("tool_call", async (event, ctx) => {
const mode = runtimeMode();
if (mode === "full-access") return;
if (event.toolName === "tool_search" ? hasBuiltinToolSearch() : READ_ONLY_TOOLS.has(event.toolName)) {
if ((event.toolName === "tool_search" ? hasBuiltinToolSearch() : READ_ONLY_TOOLS.has(event.toolName)) || isReadOnlyMcpTool(event.toolName)) {
return;
}
if (mode === "auto-accept-edits" && FILE_CHANGE_TOOLS.has(event.toolName)) {
Expand Down Expand Up @@ -351,9 +365,11 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
// 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__"];
readOnlyMcpTools.clear();
for (const tool of catalog) {
const name = tool.name;
for (const prefix of prefixes) {
if (tool.annotations?.readOnlyHint === true) readOnlyMcpTools.add(\`\${prefix}\${name}\`);
const exposure = prefix === "mcp__t3_code__" ? "hidden" :
deferOptionalTools && !directTools.has(name) ? "deferred" : "direct";
pi.registerTool({
Expand Down
7 changes: 7 additions & 0 deletions packages/provider-pi/src/server/mcpInjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
T3_MCP_BEARER_ENV,
T3_MCP_URL_ENV,
T3_PI_RUNTIME_MODE_ENV,
T3_PI_MCP_EXTENSION_PATH_ENV,
} from "./mcpExtensionSource.ts";
import {
buildPiRpcLaunch,
Expand Down Expand Up @@ -65,12 +66,14 @@ describe("pi T3 MCP injection", () => {
assert.equal(launch.env[T3_MCP_URL_ENV], "http://127.0.0.1:43123/mcp");
assert.equal(launch.env[T3_MCP_BEARER_ENV], "secret-pi-token");
assert.equal(launch.env[T3_PI_RUNTIME_MODE_ENV], "approval-required");
assert.equal(launch.env[T3_PI_MCP_EXTENSION_PATH_ENV], "/tmp/cache/pi-t3-mcp-extension.ts");

const permissionOnly = buildPiRpcLaunch({
launchArgs: [],
environment: {
[T3_MCP_URL_ENV]: "http://127.0.0.1:9999/stale",
[T3_MCP_BEARER_ENV]: "stale-token",
[T3_PI_MCP_EXTENSION_PATH_ENV]: "/stale/extension.ts",
},
mcpSession: undefined,
extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts",
Expand All @@ -86,6 +89,10 @@ describe("pi T3 MCP injection", () => {
assert.isUndefined(permissionOnly.env[T3_MCP_URL_ENV]);
assert.isUndefined(permissionOnly.env[T3_MCP_BEARER_ENV]);
assert.equal(permissionOnly.env[T3_PI_RUNTIME_MODE_ENV], "auto-accept-edits");
assert.equal(
permissionOnly.env[T3_PI_MCP_EXTENSION_PATH_ENV],
"/tmp/cache/pi-t3-mcp-extension.ts",
);
});

it("falls back to Pi's first supported mode for legacy auto threads", () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/provider-pi/src/server/mcpInjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
T3_MCP_BEARER_ENV,
T3_MCP_URL_ENV,
T3_PI_RUNTIME_MODE_ENV,
T3_PI_MCP_EXTENSION_PATH_ENV,
} from "./mcpExtensionSource.ts";

const RESERVED_PI_LAUNCH_ARGUMENTS = new Set([
Expand Down Expand Up @@ -291,11 +292,15 @@ export function buildPiRpcLaunch(input: {
// credentials inherited from the server or a parent provider process.
delete environment[T3_MCP_URL_ENV];
delete environment[T3_MCP_BEARER_ENV];
delete environment[T3_PI_MCP_EXTENSION_PATH_ENV];

return {
args,
env: {
...environment,
...(hasT3Extension && input.extensionPath !== undefined
? { [T3_PI_MCP_EXTENSION_PATH_ENV]: input.extensionPath }
: {}),
...(hasT3Extension && input.runtimeMode !== undefined
? {
[T3_PI_RUNTIME_MODE_ENV]:
Expand Down
Loading