diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-02 06:31:21 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-02 06:31:21 +0300 |
| commit | 50c251e082b8122bfd1a6e80427d0dc19fa4f246 (patch) | |
| tree | e623fe61126e54ffc193efe4e0be9fe3b63d9ea8 | |
| parent | 66dcc84d51bed88cbfb608b9dc6800a8edd32b3c (diff) | |
pi: native MCP instead of pi-mcp-adapter
pi 0.99+ ships MCP as a built-in, and the adapter suppresses it. The gate moves
from the adapter's approval event, which the built-in never emits, to pi's own
tool_call hook on mcp__<server>__*, which every call passes through, codemode
included. approveTools went with the adapter, so nothing backs the gate up: a
decision it cannot reach now denies rather than throws. server.mjs also treats
a client named plain "pi" as pi, which is how the built-in introduces itself.
From change request 562974, filed from the work laptop.
| -rw-r--r-- | mcp/README.md | 28 | ||||
| -rw-r--r-- | mcp/pi/browser-gate.ts | 60 | ||||
| -rwxr-xr-x | mcp/server.mjs | 6 |
3 files changed, 53 insertions, 41 deletions
diff --git a/mcp/README.md b/mcp/README.md index d9c6c52..51a53af 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -118,35 +118,41 @@ locations (Homebrew, nvm, volta, n). If yours is somewhere else, put it on the P The [pi](https://pi.dev) agent reaches this same server, so both your agents share one definition of the browser, one extension and one set of tools. -**1. Give pi an MCP client.** +**1. Register this server** in `~/.pi/agent/mcp.json`, with the absolute path to `run.sh`. pi's +built-in MCP support reads that file; no package is needed for it: ```bash -pi install npm:pi-mcp-adapter +pi mcp add my-chrome -- "$PWD/run.sh" ``` -**2. Register this server** in `~/.pi/agent/mcp.json`, with the absolute path to `run.sh`: +which adds an entry like: ```json { "mcpServers": { "my-chrome": { - "command": "/absolute/path/to/pi-chrome-mcp/run.sh", - "approveTools": true + "command": "/absolute/path/to/pi-chrome-mcp/run.sh" } } } ``` -**3. Add the session gate.** Copy `pi/browser-gate.ts` from this repo into +`pi mcp list` should then show `my-chrome: connected, 21 tools`. + +**2. Add the session gate.** Copy `pi/browser-gate.ts` from this repo into `~/.pi/agent/extensions/`. The first browser call in a pi session asks once — for the session, for 15 minutes, or deny — and `/browser authorize`, `/browser revoke` and `/browser` manage it from there. For headless runs (`pi -p`), `PI_BROWSER_AUTHORIZE=indefinite` grants it up front; without -it, a headless call is denied. +it, a headless call is denied. Calls made from codemode scripts are gated the same way. + +Why the gate: pi has no permission prompts of its own. Every MCP call passes through pi's tool +pipeline, and an extension's `tool_call` handler is the only thing that can stop one. **Nothing +backs the gate up:** if it fails to load, browser calls run unchecked. Run `/browser` in a new +session to confirm it is there. -Why the gate: pi has no permission prompts of its own, and the adapter's own approval remembers a -grant only for identical arguments, which for a browser means a prompt on nearly every call. Keep -`approveTools: true` as well — if the gate ever fails to load, the adapter asks per call and fails -closed headless, rather than running unchecked. +If an extension that replaces pi's MCP support is installed — `pi-mcp-adapter`, for one — pi's +built-in support does not load, and this gate sees none of its calls. Use one or the other; this +setup assumes the built-in. That is all pi needs: no `pi install npm:pi-chrome`, and no `/chrome authorize`. diff --git a/mcp/pi/browser-gate.ts b/mcp/pi/browser-gate.ts index 2ebe460..3c0c888 100644 --- a/mcp/pi/browser-gate.ts +++ b/mcp/pi/browser-gate.ts @@ -1,20 +1,19 @@ /** * One grant per pi session for a browser MCP server. * - * pi reaches this server through pi-mcp-adapter. pi has no permission prompts of its own, and the - * adapter's approval remembers a grant only for identical arguments - for a browser, where no two - * calls are alike, that is a prompt on nearly every call. This asks once instead, and the answer - * covers the session. + * pi reaches this server through its built-in MCP support, which has no permission prompts of its + * own: every MCP call goes through pi's tool pipeline, and an extension's `tool_call` handler is + * the only place to stop one. This asks once, and the answer covers the session. * - * It answers the adapter's approval broker for one server only: + * It gates the tools of one server only, mcp__<server>__*, whether the model calls them directly + * or a codemode script does: * authorized -> allow * not yet, interactive -> ask once: for this session, for 15 minutes, or deny * not yet, headless (-p) -> deny, unless PI_BROWSER_AUTHORIZE granted it - * Calls to any other MCP server are left to the adapter. + * Calls to any other tool are left alone. * - * Keep `approveTools: true` on the server in mcp.json as well. If this extension ever fails to - * load, the adapter then falls back to asking on every call, and to failing closed when headless - - * never to running unchecked. + * Nothing backs this up: if this extension fails to load, browser calls run unchecked. `/browser` + * answering is how to tell that it loaded. * * Install: copy this file into ~/.pi/agent/extensions/ (or list it in settings.json's * "extensions"), and set PI_BROWSER_GATE_SERVER if your server is not named my-chrome. @@ -29,20 +28,15 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a // The MCP server this gates, as named in mcp.json. const SERVER = process.env.PI_BROWSER_GATE_SERVER?.trim() || "my-chrome"; -// pi-mcp-adapter's MCP_TOOL_APPROVAL_REQUEST_EVENT. Named here rather than imported: this file is -// loaded from ~/.pi/agent/extensions, where the adapter's package does not resolve. -const APPROVAL_EVENT = "pi-mcp-adapter:tool-approval-request"; +// pi names a server's tools mcp__<server>__<tool>, with "-" in the server name turned into "_" +// (server names may hold only letters, digits, "_" and "-"). A tool whose name collides gets a +// hash suffix, never a different prefix. +const TOOL_PREFIX = `mcp__${SERVER.replace(/-/g, "_")}__`; const DEFAULT_MINUTES = 15; // Epoch ms it runs out at, or the rest of this pi process. type Grant = number | "indefinite" | undefined; -interface ApprovalRequest { - serverName: string; - originalToolName: string; - claim(handler: () => Promise<"allow_once" | "deny">): boolean; -} - function parseGrant(raw: string | undefined): Grant { const value = raw?.trim().toLowerCase(); if (!value) return undefined; @@ -92,15 +86,27 @@ export default function browserGate(pi: ExtensionAPI): void { return asking; }; - pi.events.on(APPROVAL_EVENT, (data: unknown) => { - const request = data as ApprovalRequest; - if (request?.serverName !== SERVER) return; - // Claimed synchronously, as the adapter requires; the decision itself can take its time. - request.claim(async () => { - if (authorized()) return "allow_once"; - if (!ctx?.hasUI) return "deny"; - return (await ask(request.originalToolName)) ? "allow_once" : "deny"; - }); + pi.on("tool_call", async (event, callCtx) => { + if (!event.toolName.startsWith(TOOL_PREFIX)) return; + ctx = callCtx; + const tool = event.toolName.slice(TOOL_PREFIX.length); + try { + if (authorized()) return; + if (ctx?.hasUI && (await ask(tool))) return; + return { + block: true, + reason: ctx?.hasUI + ? `${SERVER}: the user did not allow this pi session to drive the browser` + : `${SERVER}: browser access is not authorized in a headless session (set PI_BROWSER_AUTHORIZE)`, + }; + } catch (error) { + // Nothing else asks, so a gate that cannot decide denies rather than leaving the call to + // whatever pi does with a hook that threw. + return { + block: true, + reason: `${SERVER}: the gate could not decide (${error instanceof Error ? error.message : String(error)})`, + }; + } }); pi.on("session_start", async (_event, sessionCtx) => { diff --git a/mcp/server.mjs b/mcp/server.mjs index 3554767..9c3bea4 100755 --- a/mcp/server.mjs +++ b/mcp/server.mjs @@ -255,8 +255,8 @@ async function instructionsWith(otherTools) { } const OTHER_BROWSER_TOOLS = process.env.PI_CHROME_OTHER_BROWSER_TOOLS?.trim(); const instructions = await instructionsWith(OTHER_BROWSER_TOOLS); -// pi-mcp-adapter introduces itself as pi-mcp-<server name>; in pi, the other browser's tools are -// pi's own, not another MCP server's. +// pi's built-in MCP support introduces itself as pi, and pi-mcp-adapter as pi-mcp-<server name>; +// in pi, the other browser's tools are pi's own, not another MCP server's. const piInstructions = OTHER_BROWSER_TOOLS ? await instructionsWith("browser_*") : instructions; // --- MCP -------------------------------------------------------------------------------------- @@ -533,7 +533,7 @@ async function serveHttp() { enableDnsRebindingProtection: true, allowedHosts: [`${HTTP.host}:${HTTP.port}`], }); - conn.server = makeServer(conn, clientName.startsWith("pi-mcp") ? piInstructions : instructions); + conn.server = makeServer(conn, (clientName === "pi" || clientName.startsWith("pi-mcp")) ? piInstructions : instructions); await conn.server.connect(conn.transport); if (id) { conn.transport._webStandardTransport._initialized = true; |
