aboutsummaryrefslogtreecommitdiff
path: root/mcp
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-09-25 16:42:26 +0300
committerIgor Soarez <igor@soarez.org>2026-09-25 16:42:26 +0300
commit66dcc84d51bed88cbfb608b9dc6800a8edd32b3c (patch)
treef4a49b8182c7160ec8d850bd39e595fdcf59afd7 /mcp
parentfd06303bec47430f4644896d587f8f677245c9ac (diff)
ship the pi side we actually run, not a different arrangement
The README told pi users to install pi-chrome directly and run /chrome authorize every session. That is not how this is used: pi reaches the same MCP server through pi-mcp-adapter, with pi's own pi-chrome switched off, which is why nobody types /chrome authorize. pi/browser-gate.ts is that setup's missing piece, generalised from the one in use: it answers pi-mcp-adapter's approval broker for one server, asking once per session instead of once per call (the adapter's own grant only covers identical arguments, so for a browser it asks nearly every time). /browser authorize, /browser revoke, and PI_BROWSER_AUTHORIZE for headless runs. approveTools stays on in mcp.json as the backstop if the gate does not load. The README now leads with that: install the adapter, register the server in mcp.json, copy the gate. Installing pi-chrome in pi is kept as the alternative, with what it costs - two definitions of the browser, /chrome authorize per session, and the trap of loading the extension twice. Tested on an isolated pi config against a stand-in browser: with PI_BROWSER_AUTHORIZE the call reaches the browser, without it the gate denies it.
Diffstat (limited to 'mcp')
-rw-r--r--mcp/.gitignore5
-rw-r--r--mcp/LICENSE28
-rw-r--r--mcp/README.md254
-rw-r--r--mcp/package-lock.json1239
-rw-r--r--mcp/package.json13
-rw-r--r--mcp/pi/browser-gate.ts134
-rwxr-xr-xmcp/run.sh83
-rwxr-xr-xmcp/server.mjs630
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());