aboutsummaryrefslogtreecommitdiff
path: root/mcp/server.mjs
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/server.mjs
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/server.mjs')
-rwxr-xr-xmcp/server.mjs630
1 files changed, 630 insertions, 0 deletions
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());