diff options
| -rw-r--r-- | mcp/.gitignore | 5 | ||||
| -rw-r--r-- | mcp/LICENSE | 28 | ||||
| -rw-r--r-- | mcp/README.md | 254 | ||||
| -rw-r--r-- | mcp/package-lock.json | 1239 | ||||
| -rw-r--r-- | mcp/package.json | 13 | ||||
| -rw-r--r-- | mcp/pi/browser-gate.ts | 134 | ||||
| -rwxr-xr-x | mcp/run.sh | 83 | ||||
| -rwxr-xr-x | mcp/server.mjs | 630 |
8 files changed, 2386 insertions, 0 deletions
diff --git a/mcp/.gitignore b/mcp/.gitignore new file mode 100644 index 0000000..d8c089f --- /dev/null +++ b/mcp/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.npm-cache/ +.install-lock/ +extension/ +.extension-source diff --git a/mcp/LICENSE b/mcp/LICENSE new file mode 100644 index 0000000..eea1273 --- /dev/null +++ b/mcp/LICENSE @@ -0,0 +1,28 @@ +MIT License + +Copyright (c) 2026 Igor Soarez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +This server loads its browser tools from pi-chrome +(https://github.com/tianrendong/pi-chrome), MIT licensed, Copyright (c) +Earendil Inc. pi-chrome is a dependency, not a copy: no part of it is included +in this repository. diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..d9c6c52 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,254 @@ +# pi-chrome-mcp + +**Your everyday browser, driven by any MCP client.** + +Claude Code, Claude Desktop and other MCP clients get tools that read, click, type in and +screenshot the browser you are already signed into — your real profile, your sessions, your +logins — instead of a fresh automation browser that is logged into nothing. + +The tools themselves are [pi-chrome](https://github.com/tianrendong/pi-chrome)'s, loaded as a +dependency rather than copied. pi-chrome gives them to the [pi](https://pi.dev) agent; this server +gives the same tools to everyone else. + +## What you get + +21 tools: page snapshots with stable element ids, click, type, fill, scroll, keys, navigation, tab +management, console and network inspection, screenshots (returned inline, so the model sees them), +file uploads, and JavaScript evaluation. + +Plus, over plain pi-chrome: + +- **A session per line of work.** Every call takes a `session` name you choose. Each name gets its + own tab and tab group, so parallel subagents stop overwriting each other's page. +- **A shared server, optionally.** `--http` serves every client from one process instead of one per + client session. + +## Requirements + +- **Node 20.6 or newer**, with npm. (20.6 is where `import.meta.resolve` works without a flag.) +- **git**, and network access to this repo and to the npm registry. +- **Chrome, or another Chromium browser** — Brave, Edge, Arc, Vivaldi — in which you can load an + unpacked extension. A work profile under extension policy often cannot; check before starting. +- **An MCP client**: Claude Code, Claude Desktop, or anything else that speaks MCP. +- **macOS or Linux** for `run.sh`. On Windows, run `node server.mjs` directly with the same + environment variables. +- **A human, once:** loading an unpacked extension is a browser dialog, so an agent cannot do it. + +## Setup + +**1. Install.** The repo is public; the clone needs no credentials. + +```bash +git clone https://git.soarez.net/sz/pi-chrome-mcp.git +cd pi-chrome-mcp +./run.sh --install-only # fetches dependencies, then prints the extension path +``` + +**2. Load the companion extension** (this is the human step). In Chrome, open +`chrome://extensions`, turn on **Developer mode**, click **Load unpacked**, and select the +`extension` folder that the previous command created — it prints the full path. The folder is not +in git: it is copied out of the installed pi-chrome. + +Load it once, whatever you drive it from. If pi already loaded pi-chrome's extension on this +machine, that copy serves this server too; see [Using it from pi](#using-it-from-pi). + +It is about 2000 lines of plain JavaScript with no dependencies, and worth reading before you load +it. It comes from pi-chrome; `run.sh` copies it out of `node_modules` so that updating dependencies +cannot delete the folder Chrome is loading from. + +Chrome shows "Pi Chrome Connector started debugging this browser" while a tool is attached. That +banner is Chrome's, and expected. + +**3. Register the server with Claude Code:** + +```bash +claude mcp add-json --scope user my-chrome '{"type":"stdio","command":"'"$PWD"'/run.sh"}' +``` + +Two notes. `--scope user` matters: without it the server is registered for the current directory +only, so it would exist in this repo and nowhere else. And the name is yours to choose — `my-chrome` +rather than `chrome` keeps it distinct from Anthropic's own Claude in Chrome tools, so you and the +model can say which you mean. (If your path contains a quote or a backslash, write that JSON by hand +rather than splicing `$PWD`.) + +**4. Check it, in two steps.** + +`claude mcp list` shows `my-chrome` connected once the server starts — but that only proves the +server runs, not that the browser is attached. For that, with a client connected: + +```bash +curl -s http://127.0.0.1:17318/status # "connected": true means the extension is polling +``` + +Then, in a **new** Claude Code session (servers start per session, so the one you registered from +will not have the tools), ask: + +> Using my-chrome, open example.com with session "main", and tell me the page title. + +Expect a permission prompt per tool call, a new tab in a tab group named `Pi Session: …`, and the +title back. Your own tab is not touched: the first call opens a tab of its own. + +**Tired of the prompts?** Allow the tools you trust rather than turning permissions off, for +example in `~/.claude/settings.json`: + +```json +{ "permissions": { "allow": ["mcp__my-chrome__chrome_snapshot", "mcp__my-chrome__chrome_navigate"] } } +``` + +Allowing the whole server (`mcp__my-chrome`) also allows `chrome_evaluate`, which runs any +JavaScript as you. Consider a `deny` entry for that one; deny rules hold even in bypass mode. + +### Claude Desktop + +Add it to `claude_desktop_config.json` with an absolute path, and restart Desktop: + +```json +{ + "mcpServers": { + "chrome": { "command": "/absolute/path/to/pi-chrome-mcp/run.sh" } + } +} +``` + +Desktop starts servers with almost no environment, so `run.sh` looks for node in the usual install +locations (Homebrew, nvm, volta, n). If yours is somewhere else, put it on the PATH Desktop sees. + +## Using it from pi + +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.** + +```bash +pi install npm:pi-mcp-adapter +``` + +**2. Register this server** in `~/.pi/agent/mcp.json`, with the absolute path to `run.sh`: + +```json +{ + "mcpServers": { + "my-chrome": { + "command": "/absolute/path/to/pi-chrome-mcp/run.sh", + "approveTools": true + } + } +} +``` + +**3. 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. + +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. + +That is all pi needs: no `pi install npm:pi-chrome`, and no `/chrome authorize`. + +**Both agents at once.** The server starts one process per client session, and whichever starts +first owns the local bridge port; the others connect through it. A pi session and a Claude Code +session drive the same browser, each in its own tabs, because every call names its session. + +### The direct alternative + +pi-chrome is a pi extension first, so `pi install npm:pi-chrome` does give pi the tools without any +of the above. The cost is two definitions of the browser on one machine, pi's own `/chrome +authorize` every session, and no shared session naming. If you go that way, still load the +extension only once: pi's `/chrome onboard` offers to load its own copy, and two copies in one +browser both poll the bridge, so commands and tabs go to whichever answers first. + +## How it works + +The companion extension polls a small HTTP bridge on `127.0.0.1:17318` for commands, and runs them +in your browser through `chrome.debugger`. That bridge is part of pi-chrome, and this server loads +pi-chrome's code and hosts it in the same process. So the chain is: + +``` +MCP client --MCP--> this server --loopback--> companion extension --> your tabs +``` + +Nothing listens on the network unless you ask for `--http`. + +## Troubleshooting + +- **"the Chrome extension is not polling".** The extension is not loaded, is disabled, or its + browser is closed. Check `chrome://extensions`, and that the browser is running. +- **Is the bridge alive?** With a client connected, `curl -s http://127.0.0.1:17318/status` returns + JSON; `"connected": true` means the extension is polling it. +- **Port 17318 already in use.** Another copy of this server, or a pi session, already owns the + bridge; that is fine, they share it through it. Some unrelated program on that port is not fine: + the extension hardcodes 17318, so `PI_CHROME_BRIDGE_PORT` only helps if you patch the extension + to match. +- **After an update, reload the extension.** `run.sh` says when it has changed; Chrome keeps + running the old copy until you press reload at `chrome://extensions`. +- **Tools missing in a client.** Restart the client: MCP servers are started once per session. +- **Sitting next to Claude in Chrome?** Anthropic's own extension offers similar tools. Give this + server a distinctive name (the registration name, or `MCP_SERVER_NAME`) so a model does not + confuse the two, and say which one you mean. + +## Options + +| Setting | What it does | +|---|---| +| `--http HOST:PORT` | Serve every client over streamable HTTP at `http://HOST:PORT/mcp` instead of stdio. Needs a token. | +| `--no-auth` | Deliberately run `--http` with no token. Anything that reaches the port can then drive your browser. | +| `MCP_TOKEN` | Bearer token for `--http`. | +| `MCP_TOKEN_COMMAND` | A command whose output is that token, for a secret store. | +| `MCP_SERVER_NAME` | What clients call this server, default `chrome`. Also names its temp and state files. | +| `MCP_INSTRUCTIONS_FILE` | A file whose contents lead the instructions the model reads. Use it to say which browser this is, or how your machine differs. | +| `PI_CHROME_PATH` | Load pi-chrome's `index.ts` from somewhere else, e.g. a fork or a checkout. | +| `PI_CHROME_BRIDGE_PORT` | The bridge port, default 17318. The packaged extension polls 17318, so changing this means patching it too. | +| `MCP_IDLE_CLEANUP_MS` | How long an HTTP client's tabs stay open after its last call, default an hour. | +| `MCP_STATE_FILE` | Where `--http` records open sessions so a restart can hand them back. | + +pi-chrome reads a few of its own, which matter only when `PI_CHROME_PATH` points at a fork that +supports them: `PI_CHROME_REMOTE` (drive a browser on another host through a broker), +`PI_CHROME_OTHER_BROWSER_TOOLS` (what its prompt calls the other browser's tools) and +`PI_CHROME_AUTHORIZE`. This server sets `PI_CHROME_AUTHORIZE` itself, since the client's prompts are +the gate. + +## Safety + +This drives a browser that is signed in as you, so treat it accordingly: + +- **Your MCP client's permission prompts are the gate.** pi-chrome's own `/chrome authorize` step + has no equivalent here; the server assumes the client asks. In Claude Code, that means every tool + call prompts unless you allow it. Bypass mode skips those prompts, and only a deny rule still + holds. +- **`chrome_evaluate` runs arbitrary JavaScript as you**, and `chrome_wait_for` can too. They can + read tokens a page holds and call its APIs with your session. +- **Chrome protects its own pages.** Extension pages, including password managers and wallets, and + `chrome://` settings cannot be read or clicked by an extension, this one included. +- **Web pages are data, not instructions.** Anything an agent reads may try to redirect it; that + risk is the reason to keep the prompts on. +- **Over `--http`, use a token** and bind an address you trust. Without one, anything that can + reach the port drives your browser. + +## Updating + +pi-chrome is pinned in `package.json`. To move to a newer one, change the version, run +`./run.sh --install-only`, and reload the extension at `chrome://extensions` if it reports that the +extension changed. + +## Uninstall + +```bash +claude mcp remove --scope user my-chrome # and delete the entry from Claude Desktop's config +rm -rf /path/to/pi-chrome-mcp # the repo, its node_modules and ./extension +``` + +Then remove "Pi Chrome Connector" at `chrome://extensions`. + +## Credits + +The browser tools, the companion extension and the bridge are pi-chrome: +[the npm package `pi-chrome`](https://www.npmjs.com/package/pi-chrome), published by tianrendong of +Earendil Inc., source at [github.com/tianrendong/pi-chrome](https://github.com/tianrendong/pi-chrome), +MIT licensed. It is pinned by version in `package.json` and by hash in `package-lock.json`, so an +install fetches exactly what was tested. This repository adds the MCP server around it and is MIT +licensed too. diff --git a/mcp/package-lock.json b/mcp/package-lock.json new file mode 100644 index 0000000..ffe8520 --- /dev/null +++ b/mcp/package-lock.json @@ -0,0 +1,1239 @@ +{ + "name": "pi-chrome-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-chrome-mcp", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "1.30.0", + "jiti": "2.7.0", + "pi-chrome": "0.15.51", + "typebox": "1.3.7" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.9.tgz", + "integrity": "sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pi-chrome": { + "version": "0.15.51", + "resolved": "https://registry.npmjs.org/pi-chrome/-/pi-chrome-0.15.51.tgz", + "integrity": "sha512-CUMBJmtSbjjkcukKdhPOyDXZHal+acnl7BbtU/82qj8dgSjFLrpv/3ckLx0+ueBQ72C5C/0mEdimFBmBfXuIhw==", + "license": "MIT", + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "typebox": "*" + }, + "peerDependenciesMeta": { + "@earendil-works/pi-coding-agent": { + "optional": true + }, + "typebox": { + "optional": true + } + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 0000000..8fddfa3 --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,13 @@ +{ + "name": "pi-chrome-mcp", + "version": "0.1.0", + "description": "An MCP server for the Chrome you are already signed into, built on pi-chrome's tools.", + "license": "MIT", + "type": "module", + "dependencies": { + "@modelcontextprotocol/sdk": "1.30.0", + "jiti": "2.7.0", + "pi-chrome": "0.15.51", + "typebox": "1.3.7" + } +} diff --git a/mcp/pi/browser-gate.ts b/mcp/pi/browser-gate.ts new file mode 100644 index 0000000..2ebe460 --- /dev/null +++ b/mcp/pi/browser-gate.ts @@ -0,0 +1,134 @@ +/** + * 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. + * + * It answers the adapter's approval broker for one server only: + * 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. + * + * 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. + * + * 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. + * + * /browser show the grant + * /browser authorize [15m|<minutes>|indefinite] + * /browser revoke + * PI_BROWSER_AUTHORIZE=indefinite|<minutes> grant every session up front, headless included + */ + +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; + +// 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"; +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; + if (value === "indefinite" || value === "session" || value === "forever") return "indefinite"; + const minutes = Number(value.endsWith("m") ? value.slice(0, -1) : value); + return Number.isFinite(minutes) && minutes > 0 ? Date.now() + minutes * 60_000 : undefined; +} + +export default function browserGate(pi: ExtensionAPI): void { + let grant: Grant = parseGrant(process.env.PI_BROWSER_AUTHORIZE); + let ctx: ExtensionContext | undefined; + let asking: Promise<boolean> | undefined; + + const authorized = (): boolean => grant === "indefinite" || (typeof grant === "number" && grant > Date.now()); + + const summary = (): string => { + if (grant === "indefinite") return `${SERVER}: authorized for this session`; + if (typeof grant === "number" && grant > Date.now()) { + return `${SERVER}: authorized for ~${Math.ceil((grant - Date.now()) / 60_000)} more minutes`; + } + return `${SERVER}: locked; the next call asks (or run /browser authorize)`; + }; + + const showStatus = (): void => { + ctx?.ui.setStatus("browser-gate", authorized() ? `● ${SERVER}` : undefined); + }; + + // One question at a time: calls made in parallel wait on the same answer rather than each + // raising a prompt of its own. + const ask = (toolName: string): Promise<boolean> => { + asking ??= (async () => { + try { + const session = "For this session"; + const timed = `For ${DEFAULT_MINUTES} minutes`; + const choice = await ctx!.ui.select( + `${SERVER}: let this pi session drive your browser? (first call: ${toolName})`, + [session, timed, "Deny"], + ); + if (choice === session) grant = "indefinite"; + else if (choice === timed) grant = Date.now() + DEFAULT_MINUTES * 60_000; + showStatus(); + return authorized(); + } finally { + asking = undefined; + } + })(); + 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("session_start", async (_event, sessionCtx) => { + ctx = sessionCtx; + showStatus(); + }); + + pi.registerCommand("browser", { + description: + `Browser access for this pi session (${SERVER}).\n /browser show the grant\n /browser authorize [15m|<minutes>|indefinite]\n /browser revoke`, + handler: async (args, commandCtx) => { + ctx = commandCtx; + const [action, value] = (args ?? "").trim().split(/\s+/); + if (action === "authorize") { + const next = parseGrant(value || `${DEFAULT_MINUTES}m`); + if (!next) { + commandCtx.ui.notify("Use /browser authorize 15m, /browser authorize 45, or /browser authorize indefinite.", "warning"); + return; + } + grant = next; + } else if (action === "revoke") { + grant = undefined; + } else if (action) { + commandCtx.ui.notify(`Unknown /browser action "${action}": use authorize or revoke.`, "warning"); + return; + } + showStatus(); + commandCtx.ui.notify(summary(), "info"); + }, + }); +} diff --git a/mcp/run.sh b/mcp/run.sh new file mode 100755 index 0000000..27c8ad9 --- /dev/null +++ b/mcp/run.sh @@ -0,0 +1,83 @@ +#!/bin/sh + +# Starts the MCP server (server.mjs), installing its pinned dependencies first when they are +# missing or the lockfile changed. Arguments go to server.mjs: none for stdio, which MCP clients +# launch once per session, or --http HOST:PORT for a shared server. --install-only installs and +# exits, which is worth doing once after cloning so the first client start is not a long one. +# +# stdout is the MCP protocol, so everything here writes to stderr. + +set -eu + +here="$(cd "$(dirname "$0")" && pwd)" + +# MCP clients start servers with a bare environment: Claude Desktop in particular has no node on +# its PATH. Add the usual places one is installed, without disturbing an inherited PATH. +PATH="${PATH}:${HOME}/.local/share/n/bin:${HOME}/.volta/bin:/opt/homebrew/bin:/usr/local/bin" +export PATH + +# nvm installs per version, with no stable bin directory, so take the newest it has. +if ! command -v node >/dev/null 2>&1; then + for dir in "${HOME}"/.nvm/versions/node/*/bin; do + [ -x "${dir}/node" ] && nvm_bin="${dir}" + done + [ -n "${nvm_bin:-}" ] && PATH="${nvm_bin}:${PATH}" && export PATH +fi + +if ! command -v node >/dev/null 2>&1; then + echo "pi-chrome-mcp: no node on PATH; install Node 20.6 or newer, or start this with one on PATH" >&2 + exit 1 +fi + +stamp="${here}/node_modules/.pi-chrome-mcp-deps" +if ! cmp -s "${here}/package-lock.json" "${stamp}" 2>/dev/null; then + # Two clients starting at once would otherwise run npm ci over each other. + lock="${here}/.install-lock" + tries=0 + until mkdir "${lock}" 2>/dev/null; do + tries=$((tries + 1)) + if [ "${tries}" -gt 120 ]; then + echo "pi-chrome-mcp: gave up waiting for ${lock}; remove it if no install is running" >&2 + exit 1 + fi + sleep 1 + done + trap 'rmdir "${lock}" 2>/dev/null || true' EXIT + if ! cmp -s "${here}/package-lock.json" "${stamp}" 2>/dev/null; then + echo "pi-chrome-mcp: installing pinned dependencies" >&2 + npm_config_cache="${here}/.npm-cache" \ + npm ci --prefix "${here}" --no-audit --no-fund --silent >&2 + cp "${here}/package-lock.json" "${stamp}" + fi + rmdir "${lock}" 2>/dev/null || true + trap - EXIT +fi + +# Chrome loads the companion extension from ./extension rather than from node_modules, where it +# ships: npm ci deletes that whole tree when dependencies change, and an unpacked extension whose +# folder disappears is disabled until someone loads it again by hand. This copy is refreshed only +# when pi-chrome's version of it differs, so a reinstall that changes nothing leaves Chrome alone. +# Compared by content, not by the manifest's version: two releases could ship the same version of +# the extension with different files, and a copy that silently stayed behind would be debugged as a +# server bug. +packaged="${here}/node_modules/pi-chrome/extensions/chrome-profile-bridge/browser-extension" +if [ -d "${packaged}" ]; then + digest="$(cd "${packaged}" && find . -type f -print | sort | xargs cat | cksum)" + # Kept beside the folder rather than in it: Chrome loads that folder, and nothing there should + # be anything but the extension. + stamp_ext="${here}/.extension-source" + if [ "${digest}" != "$(cat "${stamp_ext}" 2>/dev/null || true)" ]; then + echo "pi-chrome-mcp: updating ./extension; reload it at chrome://extensions" >&2 + rm -rf "${here}/extension.new" + cp -R "${packaged}" "${here}/extension.new" + rm -rf "${here}/extension" + mv "${here}/extension.new" "${here}/extension" + echo "${digest}" > "${stamp_ext}" + fi +fi + +if [ "${1:-}" = "--install-only" ]; then + echo "pi-chrome-mcp: ready. Load this folder in chrome://extensions: ${here}/extension" >&2 + exit 0 +fi +exec node "${here}/server.mjs" "$@" diff --git a/mcp/server.mjs b/mcp/server.mjs new file mode 100755 index 0000000..3554767 --- /dev/null +++ b/mcp/server.mjs @@ -0,0 +1,630 @@ +#!/usr/bin/env node +// pi-chrome-mcp: the Chrome you are already signed into, as an MCP server. +// +// It loads pi-chrome's browser tools - the same code the pi agent runs - against a small stand-in +// for pi's extension API, and serves them over MCP. So Claude Code, Claude Desktop, or any other +// MCP client drives the browser through pi-chrome's companion extension, with one implementation +// of the tools, the page snapshots and the bridge client rather than a second copy to drift. +// +// pi-chrome itself is a dependency, loaded from node_modules; PI_CHROME_PATH points at another +// copy (a fork, a checkout) instead. +// +// Two transports: +// +// stdio (default) one client, the process's own. What MCP clients start for themselves. +// --http HOST:PORT every client, over streamable HTTP at http://HOST:PORT/mcp. One process +// for a machine, rather than one per client session; needs a bearer token. +// +// pi-chrome refuses to load twice in one process, and does not need to: it asks for the session id +// on every bridge command, and the answer here depends on who is calling. Each MCP connection, and +// each session name within it, is its own pi-chrome session - own automation tab, own tab group. +// +// With stdio, stdout belongs to the MCP protocol; everything else goes to stderr. + +console.log = console.error; +console.info = console.error; + +import { AsyncLocalStorage } from "node:async_hooks"; +import { execSync } from "node:child_process"; +import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { homedir, tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createJiti } from "jiti"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { CallToolRequestSchema, isInitializeRequest, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const VERSION = JSON.parse(readFileSync(resolve(here, "package.json"), "utf8")).version; +// What clients see this server called, and what its logs, temp files and state file are named. +const NAME = process.env.MCP_SERVER_NAME?.trim() || "chrome"; +const log = (message) => console.error(`[${NAME}] ${message}`); +// Base64 grows a file by a third; this keeps the encoded image under Claude Desktop's 1MB limit. +const MAX_INLINE_IMAGE_BYTES = 700 * 1024; + +// pi-chrome's tool layer: the dependency by default, or any other copy of it. +const EXTENSION = (() => { + const override = process.env.PI_CHROME_PATH?.trim(); + if (override) return override; + try { + return fileURLToPath(import.meta.resolve("pi-chrome/extensions/chrome-profile-bridge/index.ts")); + } catch { + log("cannot find pi-chrome. Run ./run.sh (or npm ci) here, or set PI_CHROME_PATH to a copy of its index.ts"); + process.exit(2); + } +})(); + +// --http HOST:PORT, or unset for stdio. +const HTTP = (() => { + const at = process.argv.indexOf("--http"); + if (at === -1) return undefined; + const match = /^([^:\s]+):(\d{1,5})$/.exec(process.argv[at + 1] ?? ""); + if (!match) { + log("--http needs HOST:PORT"); + process.exit(2); + } + return { host: match[1], port: Number(match[2]) }; +})(); + +// Where pi-chrome's bridge listens, which this server also calls directly to close tabs. The +// extension polls that bridge; with a plain pi-chrome it is this process's own, on loopback. +// A fork that drives a browser on another host points PI_CHROME_REMOTE at its broker instead. +const LOCAL_BRIDGE = new Set(["local", "localhost", "off", "none", "0", "no"]); +const remote = process.env.PI_CHROME_REMOTE?.trim(); +const BRIDGE = remote && !LOCAL_BRIDGE.has(remote.toLowerCase()) + ? remote + : `127.0.0.1:${process.env.PI_CHROME_BRIDGE_PORT ?? 17318}`; + +// An HTTP client's named sessions have their tabs closed after this long without a call. Clients +// rarely say goodbye - Claude Code just drops the connection - so this is what stops an exited +// session's tabs from staying open for good. A session that comes back gets a fresh tab. +const IDLE_CLEANUP_MS = Number(process.env.MCP_IDLE_CLEANUP_MS ?? 60 * 60_000); +// ...and its connection state is dropped after this long. Harmless either way: a client that +// comes back later with the old session id is taken back in (see adopt below). +const IDLE_FORGET_MS = 24 * 60 * 60_000; +// Which pi-chrome sessions are open, so a restart neither loses them nor leaks them. Per port, +// so a test server on another port keeps its own. +const STATE_PATH = process.env.MCP_STATE_FILE + ?? (HTTP ? join(homedir(), ".local", "state", `${NAME}-mcp`, `sessions-${HTTP.port}.json`) : undefined); + +// The MCP client's own permission prompts gate each tool call, so pi-chrome's /chrome authorize +// has no equivalent here - and no way to be run: it is a pi command. +// +// pi-chrome keeps that grant on globalThis, so that a pi /reload - which re-evaluates the module - +// does not silently drop one, and reads it back as it loads. Setting it before the load is +// therefore how this server starts authorized. PI_CHROME_AUTHORIZE does the same in forks that +// read it; both are set, and either alone is enough. +globalThis.__piChromeProfileBridgeAuth__ ??= { until: "indefinite" }; +process.env.PI_CHROME_AUTHORIZE ??= "indefinite"; + +// --- instructions ------------------------------------------------------------------------------- +// +// Claude Code keeps only the first 2KB of a server's instructions, so what matters leads: anything +// the operator supplied, then how sessions work, then pi-chrome's own primer. + +const EXTRA = (() => { + const path = process.env.MCP_INSTRUCTIONS_FILE?.trim(); + if (!path) return ""; + try { + return `${readFileSync(path, "utf8").trim()}\n`; + } catch (error) { + log(`could not read MCP_INSTRUCTIONS_FILE ${path}: ${error.message}`); + return ""; + } +})(); + +const ESSENTIALS = `${EXTRA}This server drives the user's own browser, signed in as they are, through the pi-chrome companion extension. Every action acts as the user: ask before posting, sending, buying, deleting, or changing account settings. +- Every call needs a session: a short name you choose, e.g. main or research-a. Calls with the same session share its tabs, and a call without targetId acts on that session's automation tab; a new name starts fresh with its own. Parallel subagents must each use a different session - give each one its name. +- chrome_snapshot before interacting; prefer uid over selector; includeSnapshot=true on click/type/fill/key verifies in one call. +- Screenshots come back inline. Uploads take absolute paths that the browser's machine can read.`; + +// --- sessions the agent names ----------------------------------------------------------------- +// +// pi gets one pi-chrome session per process, and runs one agent per process. An MCP client does +// not: Claude Code gives every subagent the parent's connection to this server, and nothing in an +// MCP call says which agent made it. So the caller names its session on every call. Each name is +// its own pi-chrome session - its own automation tab, tab group and cleanup - and the same name is +// the caller's way of deliberately continuing one. Names are scoped to the connection, so two +// clients that both pick "main" still never share. +// +// The name is applied where pi-chrome asks for the current session id rather than threaded through +// each tool's params, so every bridge command a call sends is keyed to it, whatever the tool does +// with its arguments. + +const SESSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$/; +const SESSION_PARAM = { + type: "string", + pattern: SESSION_PATTERN.source, + description: + 'Required. A short name you choose for this line of work, e.g. "main" or "research-a" (letters, digits, . _ -). Calls with the same session share its tabs; a new name starts fresh with its own tab. Parallel subagents must each use a different session.', +}; +const SESSION_ERROR = + 'Missing or invalid "session". Every call to this server needs one: a short name you choose for this line of work, e.g. "main" (letters, digits, . _ -, up to 48 characters). Calls with the same session share its tabs; a new name starts fresh with its own tab. Parallel subagents must each use a different session.'; + +// What a call runs under: { conn, name }. conn is one MCP connection (stdio has exactly one); +// name is the session the call named. +const callSession = new AsyncLocalStorage(); + +// --- a stand-in for the parts of pi's extension API that pi-chrome uses ---------------------- + +const sessionId = randomUUID(); + +// One MCP connection. key prefixes every pi-chrome session id made for it, so two connections that +// both name "main" never meet; label is what its tab groups show. +function newConn(key, label) { + return { key, label, clientName: "mcp", used: new Set(), lastUsed: Date.now() }; +} +// Screenshots land here rather than in whatever project the client was started in. +const cwd = mkdtempSync(join(tmpdir(), `${NAME}-mcp-`)); +const tools = new Map(); +let active = new Set(); +const handlers = new Map(); + +const ui = { + notify: (message, level = "info") => log(`${level}: ${message}`), + setStatus: () => {}, + confirm: async () => false, + select: async () => undefined, + input: async () => undefined, + theme: { fg: (_color, text) => text }, +}; + +const ctx = { + cwd, + ui, + hasUI: false, + sessionManager: { + // Read by pi-chrome on every bridge command: inside a tool call, the caller's named session. + getSessionId: () => { + const call = callSession.getStore(); + return call ? `${call.conn.key}:${call.name}` : sessionId; + }, + // Names the session's tab group in the browser, e.g. "Pi Session: claude-code 1a2b research-a". + getSessionName: () => { + const call = callSession.getStore(); + return call ? `${call.conn.clientName} ${call.conn.label} ${call.name}` : `mcp ${sessionId.slice(0, 4)}`; + }, + }, +}; + +const pi = { + // pi makes a tool active as it is registered; pi-chrome's own active-tool list omits some + // (chrome_find, chrome_inspect) and relies on that. + registerTool(tool) { + tools.set(tool.name, tool); + active.add(tool.name); + }, + // /chrome's authorize, background and doctor controls have no MCP equivalent. + registerCommand() {}, + registerFlag() {}, + getFlag: () => undefined, + on(event, handler) { + if (!handlers.has(event)) handlers.set(event, []); + handlers.get(event).push(handler); + }, + getActiveTools: () => [...active], + setActiveTools(names) { + active = new Set(names); + }, + sendMessage() {}, + exec: async () => ({ code: 1, stdout: "", stderr: "not available in the MCP server" }), +}; + +async function emit(event, payload) { + let last; + for (const handler of handlers.get(event) ?? []) last = await handler(payload, ctx); + return last; +} + +// --- load pi-chrome --------------------------------------------------------------------------- + +// pi-chrome imports typebox at runtime, and its own directory may have no node_modules: point it +// at the copy pinned here, the version pi ships. jiti rather than Node's type stripping, because +// pi-chrome uses constructor parameter properties, which strip-only mode rejects. +const jiti = createJiti(import.meta.url, { + alias: { typebox: fileURLToPath(import.meta.resolve("typebox")) }, +}); +const extension = await jiti.import(EXTENSION, { default: true }); +extension(pi); +await emit("session_start", { reason: "startup" }); + +if (active.size === 0) { + // pi-chrome registers its tools only once authorized, so this means the grant above did not + // take: most likely its authorization moved in a version this server has not been tried with. + log(`pi-chrome at ${EXTENSION} registered no tools. Its authorization hook may have changed; this server is pinned to the pi-chrome in package.json.`); + process.exit(1); +} + +// pi-chrome's primer: its usage rules, and anything its own configuration adds. Lines about +// /chrome commands are dropped, since none exist here. +// +// A fork's primer may name another browser's tools, which differ by client: browser_* in pi, +// mcp__<server>__* elsewhere. pi-chrome reads PI_CHROME_OTHER_BROWSER_TOOLS as it builds the +// primer, synchronously inside the handler, so the variable only has to hold for that call. +async function instructionsWith(otherTools) { + const saved = process.env.PI_CHROME_OTHER_BROWSER_TOOLS; + if (otherTools) process.env.PI_CHROME_OTHER_BROWSER_TOOLS = otherTools; + const pending = emit("before_agent_start", { systemPrompt: "" }); + process.env.PI_CHROME_OTHER_BROWSER_TOOLS = saved; + const primer = (await pending)?.systemPrompt ?? ""; + return [ESSENTIALS, primer.split("\n").filter((line) => !/\/chrome\b/.test(line)).join("\n")].join("\n"); +} +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. +const piInstructions = OTHER_BROWSER_TOOLS ? await instructionsWith("browser_*") : instructions; + +// --- MCP -------------------------------------------------------------------------------------- + +function inputSchema(toolName, parameters) { + // TypeBox schemas are JSON Schema plus symbol-keyed metadata, which a JSON round trip drops. + const schema = JSON.parse(JSON.stringify(parameters ?? {})); + if (schema.properties?.session) throw new Error(`${toolName} already has a "session" parameter; the MCP session name needs another`); + return { + ...schema, + type: "object", + properties: { session: SESSION_PARAM, ...(schema.properties ?? {}) }, + required: ["session", ...(schema.required ?? [])], + }; +} + +function toMcpContent(toolName, result) { + const content = []; + for (const item of result?.content ?? []) { + if (item.type === "text") content.push({ type: "text", text: item.text }); + else if (item.type === "image") content.push({ type: "image", data: item.data, mimeType: item.mimeType }); + } + // pi-chrome saves screenshots to a file and returns only the path. Hand the image itself back + // too, so the model can see it without another tool call; full-page tiles stay as the manifest. + // Claude Desktop refuses a returned file over 1MB, so a bigger image stays a path. + const details = result?.details ?? {}; + if (toolName === "chrome_screenshot" && typeof details.path === "string") { + try { + const bytes = readFileSync(details.path); + const mimeType = details.format === "jpeg" ? "image/jpeg" : "image/png"; + if (bytes.length > MAX_INLINE_IMAGE_BYTES) { + content.push({ type: "text", text: `(screenshot is ${Math.round(bytes.length / 1024)}KB, too big to attach; retake it with format "jpeg" to see it inline)` }); + } else { + content.push({ type: "image", data: bytes.toString("base64"), mimeType }); + } + } catch (error) { + content.push({ type: "text", text: `(could not attach the screenshot: ${error.message})` }); + } + } + return content; +} + +function slug(name) { + return (name ?? "mcp").toLowerCase().replace(/[^a-z0-9.-]+/g, "-"); +} + +// Set by the HTTP server, which demands the same token from its clients; a bridge on another host +// wants it too. With a bridge of our own on loopback there is none, and none is needed. +let TOKEN; + +// Close named sessions: their tabs and tab groups. Sent to the bridge directly rather than through +// pi-chrome's session_shutdown, which also stops its bridge client - for every session at once, so +// the first one to run would strand the rest. +async function closeSessions(key, names, label, why) { + if (!names.length) return; + log(`closing ${label}: sessions ${names.join(", ")} (${why})`); + await Promise.allSettled( + names.map((name) => + fetch(`http://${BRIDGE}/command`, { + method: "POST", + headers: { "content-type": "application/json", ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}) }, + body: JSON.stringify({ action: "automation.cleanup", params: { sessionKey: `session:${key}:${name}` }, timeoutMs: 2_000 }), + signal: AbortSignal.timeout(4_000), + }), + ), + ); +} + +// One MCP server object per connection, all serving the same loaded tools. +function makeServer(conn, serverInstructions) { + const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: {} }, instructions: serverInstructions }); + + server.oninitialized = () => { + conn.clientName = slug(server.getClientVersion()?.name); + }; + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [...tools.values()] + .filter((tool) => active.has(tool.name)) + .map((tool) => ({ + name: tool.name, + title: tool.label, + description: tool.description, + inputSchema: inputSchema(tool.name, tool.parameters), + })), + })); + + server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + const tool = tools.get(request.params.name); + if (!tool || !active.has(tool.name)) { + return { content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }], isError: true }; + } + // Refused rather than defaulted: a default would be one shared session again. + const { session, ...args } = request.params.arguments ?? {}; + if (typeof session !== "string" || !SESSION_PATTERN.test(session)) { + return { content: [{ type: "text", text: SESSION_ERROR }], isError: true }; + } + if (!conn.used.has(session)) { + conn.used.add(session); + // Only the HTTP server sets this: it records the session so a restart can hand it back. + conn.onUse?.(); + } + conn.lastUsed = Date.now(); + try { + const result = await callSession.run({ conn, name: session }, () => tool.execute(randomUUID(), args, extra.signal, () => {}, ctx)); + return { content: toMcpContent(tool.name, result) }; + } catch (error) { + return { content: [{ type: "text", text: error?.message ?? String(error) }], isError: true }; + } finally { + conn.lastUsed = Date.now(); + } + }); + + return server; +} + +// --- stdio ------------------------------------------------------------------------------------ + +async function serveStdio() { + const conn = newConn(sessionId, sessionId.slice(0, 4)); + const server = makeServer(conn, instructions); + + // Close every session's tabs through the bridge, then go. pi-chrome's own session_shutdown is + // not used: it stops the bridge as it runs, so only the first session would be cleaned, and + // with the bridge in this process, exiting closes it anyway. + let closing = false; + async function shutdown(why) { + if (closing) return; + closing = true; + const names = [...conn.used]; + if (!names.length) process.exit(0); + await Promise.race([ + closeSessions(conn.key, names, `${conn.label} (${conn.clientName})`, why), + new Promise((r) => setTimeout(r, 3_000)), + ]); + process.exit(0); + } + + const transport = new StdioServerTransport(); + transport.onclose = () => void shutdown("transport closed"); + process.stdin.on("end", () => void shutdown("stdin closed")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); + await server.connect(transport); + log(`v${VERSION} ready: ${active.size} tools, session ${sessionId.slice(0, 8)}`); +} + +// --- streamable HTTP -------------------------------------------------------------------------- + +// Over HTTP anything that can reach the port could drive the browser, so a token is required. +// MCP_TOKEN, or MCP_TOKEN_COMMAND for a secret store; --no-auth is the deliberate way out. +function loadToken() { + const fromEnv = process.env.MCP_TOKEN?.trim(); + if (fromEnv) return fromEnv; + const command = process.env.MCP_TOKEN_COMMAND?.trim(); + if (command) { + const token = execSync(command, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); + if (!token) throw new Error(`MCP_TOKEN_COMMAND produced nothing: ${command}`); + return token; + } + if (process.argv.includes("--no-auth")) return undefined; + log("--http needs a token: set MCP_TOKEN, or MCP_TOKEN_COMMAND to read one from a secret store (or pass --no-auth deliberately)"); + process.exit(2); +} + +async function serveHttp() { + const token = loadToken(); + TOKEN = token; + // A bridge that wants the same token (a broker on another host) would otherwise go looking for + // it itself; hand it the one we have. + if (token) process.env.PI_CHROME_TOKEN ??= token; + const tokenDigest = token ? createHash("sha256").update(token).digest() : undefined; + // Compare digests: timingSafeEqual needs equal lengths, and a guess's length must not leak. + const hasValidToken = (request) => { + if (!tokenDigest) return true; + const match = /^Bearer\s+(.+)$/i.exec(String(request.headers.authorization ?? "")); + return !!match && timingSafeEqual(createHash("sha256").update(match[1].trim()).digest(), tokenDigest); + }; + + // Taking back a session id this process never issued relies on two private fields of the + // SDK's transport. The SDK is pinned; this makes a bump that renames them fail at startup + // rather than as 400s later. + { + const probe = new StreamableHTTPServerTransport({ sessionIdGenerator: () => "probe" }); + if (!probe._webStandardTransport || !("_initialized" in probe._webStandardTransport)) { + throw new Error("the MCP SDK's streamable HTTP transport changed shape; adopt() needs updating"); + } + } + + const conns = new Map(); // MCP session id -> conn + // Sessions this process is not serving right now: read back from the state file at startup, + // after a restart. Each is either claimed by the client coming back (adopt) or swept like any + // other idle session, so a restart leaves no tabs behind either way. + const pending = new Map(); // MCP session id -> { key, names, lastUsed } + + function save() { + if (!STATE_PATH) return; + const open = {}; + for (const [id, conn] of conns) if (conn.used.size) open[id] = { key: conn.key, names: [...conn.used], lastUsed: conn.lastUsed }; + for (const [id, held] of pending) open[id] = { key: held.key, names: [...held.names], lastUsed: held.lastUsed }; + try { + mkdirSync(dirname(STATE_PATH), { recursive: true }); + writeFileSync(`${STATE_PATH}.tmp`, JSON.stringify(open)); + renameSync(`${STATE_PATH}.tmp`, STATE_PATH); + } catch (error) { + log(`could not save ${STATE_PATH}: ${error.message}`); + } + } + + if (STATE_PATH) { + try { + for (const [id, held] of Object.entries(JSON.parse(readFileSync(STATE_PATH, "utf8")))) { + pending.set(id, { key: held.key, names: new Set(held.names), lastUsed: held.lastUsed ?? Date.now() }); + } + if (pending.size) log(`${pending.size} session(s) from a previous run; each is taken back or swept when idle`); + } catch (error) { + if (error.code !== "ENOENT") log(`could not read ${STATE_PATH}: ${error.message}`); + } + } + + async function cleanup(conn, why) { + const names = [...conn.used]; + conn.used.clear(); + save(); + await closeSessions(conn.key, names, `${conn.label} (${conn.clientName})`, why); + } + + async function forget(conn, why) { + conns.delete(conn.id); + await cleanup(conn, why); + await conn.transport.close().catch(() => {}); + } + + async function dropPending(id, held, why) { + pending.delete(id); + save(); + await closeSessions(held.key, [...held.names], `${id.slice(0, 4)} (previous run)`, why); + } + + // A connection for a new client (id undefined, taken from the initialize request it is about to + // handle) or, with id, one that already holds a session id this process does not know. + // + // The second case is adopt: a client whose session this process forgot, or which predates a + // restart. The protocol answer is 404 and the client re-initializes, but Claude Code does not: + // it reports the error and the tools stay dead until someone runs /mcp. So the id is taken back + // in, as if it had just been initialized. Nothing is lost that mattered - the tools, their + // schemas and the instructions are the same for everyone. + async function open(id, clientName) { + const conn = newConn(undefined, undefined); + conn.clientName = clientName; + conn.onUse = save; + const adopt = (sid) => { + conn.id = sid; + // The MCP session id alone, not this process's: a client taken back after a restart must + // key the same pi-chrome sessions, or its tabs are stranded and it gets a blank one. + conn.key = `mcp-${sid}`; + conn.label = sid.slice(0, 4); + conns.set(sid, conn); + const held = pending.get(sid); + if (held) { + pending.delete(sid); + for (const name of held.names) conn.used.add(name); + } + save(); + }; + conn.transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: adopt, + // The transport closes itself once this returns. + onsessionclosed: () => { + conns.delete(conn.id); + void cleanup(conn, "client ended the session"); + }, + enableDnsRebindingProtection: true, + allowedHosts: [`${HTTP.host}:${HTTP.port}`], + }); + conn.server = makeServer(conn, clientName.startsWith("pi-mcp") ? piInstructions : instructions); + await conn.server.connect(conn.transport); + if (id) { + conn.transport._webStandardTransport._initialized = true; + conn.transport._webStandardTransport.sessionId = id; + adopt(id); + log(`took back session ${id.slice(0, 8)}`); + } + return conn; + } + + async function readJson(request) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 4 * 1024 * 1024) throw new Error("request body too large"); + chunks.push(chunk); + } + return chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : undefined; + } + + const reply = (response, status, message) => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null })); + }; + + const http = createServer(async (request, response) => { + try { + if (new URL(request.url ?? "/", "http://x").pathname !== "/mcp") return reply(response, 404, "Not found: the endpoint is /mcp"); + if (!hasValidToken(request)) return reply(response, 401, "Unauthorized: this server needs its bearer token"); + const body = request.method === "POST" ? await readJson(request) : undefined; + const id = request.headers["mcp-session-id"]; + let conn = typeof id === "string" ? conns.get(id) : undefined; + if (!conn) { + const initialize = [body].flat().find((message) => message && isInitializeRequest(message)); + if (initialize) conn = await open(undefined, slug(initialize.params?.clientInfo?.name)); + else if (typeof id === "string" && id && request.method !== "DELETE") conn = await open(id, "mcp"); + else if (request.method === "DELETE") { + // A client saying goodbye to a session restored from the state file: close its + // tabs now rather than leaving them to the idle sweep. + const held = typeof id === "string" ? pending.get(id) : undefined; + if (!held) return reply(response, 404, "Session not found"); + await dropPending(id, held, "client ended the session"); + response.writeHead(200).end(); + return; + } + else return reply(response, 400, "Bad Request: Mcp-Session-Id header is required"); + } + conn.lastUsed = Date.now(); + await conn.transport.handleRequest(request, response, body); + } catch (error) { + log(`${request.method} failed: ${error?.message ?? error}`); + if (!response.headersSent) reply(response, error instanceof SyntaxError ? 400 : 500, error?.message ?? "error"); + } + }); + + const sweep = setInterval(() => { + const now = Date.now(); + for (const conn of conns.values()) { + const idle = now - conn.lastUsed; + if (idle > IDLE_FORGET_MS) void forget(conn, "idle"); + else if (idle > IDLE_CLEANUP_MS && conn.used.size) void cleanup(conn, "idle"); + } + for (const [id, held] of pending) { + if (now - held.lastUsed > IDLE_CLEANUP_MS) void dropPending(id, held, "idle, from a previous run"); + } + }, 60_000); + sweep.unref(); + + let closing = false; + // Tabs are deliberately left open. A client whose session this process was serving is still + // alive and will be taken back when the server returns (a restart is seconds), so closing its + // tabs would strand it: it would find a blank automation tab, and a navigation it had just been + // told succeeded would be gone. What the next run does not reclaim, its sweep closes once idle, + // from the state file saved here. + async function shutdown(why) { + if (closing) return; + closing = true; + log(`stopping (${why}): ${conns.size} connections, tabs left for the next run`); + http.close(); + save(); + process.exit(0); + } + process.on("SIGTERM", () => void shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); + + await new Promise((resolveListen, rejectListen) => { + http.once("error", rejectListen); + http.listen(HTTP.port, HTTP.host, resolveListen); + }); + log(`v${VERSION} ready: ${active.size} tools at http://${HTTP.host}:${HTTP.port}/mcp, bridge ${BRIDGE}${token ? "" : ", NO AUTH"}`); +} + +await (HTTP ? serveHttp() : serveStdio()); |
