aboutsummaryrefslogtreecommitdiff
path: root/mcp/pi/browser-gate.ts
diff options
context:
space:
mode:
Diffstat (limited to 'mcp/pi/browser-gate.ts')
-rw-r--r--mcp/pi/browser-gate.ts60
1 files changed, 33 insertions, 27 deletions
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) => {