aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-02 06:31:21 +0300
committerIgor Soarez <igor@soarez.org>2026-10-02 06:31:21 +0300
commit50c251e082b8122bfd1a6e80427d0dc19fa4f246 (patch)
treee623fe61126e54ffc193efe4e0be9fe3b63d9ea8
parent66dcc84d51bed88cbfb608b9dc6800a8edd32b3c (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.md28
-rw-r--r--mcp/pi/browser-gate.ts60
-rwxr-xr-xmcp/server.mjs6
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;