#!/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 this repository's own extensions/chrome-profile-bridge, the directory above // this one; PI_CHROME_PATH points at another copy 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 one in this repository by default, or any other copy of it. const EXTENSION = process.env.PI_CHROME_PATH?.trim() || resolve(here, "../extensions/chrome-profile-bridge/index.ts"); // --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() {}, // pi-chrome logs authorization changes to the session history; there is none here. appendEntry() {}, 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, and pi-tui at a stand-in. 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")), "@earendil-works/pi-tui": fileURLToPath(new URL("pi-tui.mjs", import.meta.url)), }, }); 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____* 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. const UPSTREAM_SECTION = "chrome-profile-bridge"; async function instructionsWith(otherTools) { const saved = process.env.PI_CHROME_OTHER_BROWSER_TOOLS; if (otherTools) process.env.PI_CHROME_OTHER_BROWSER_TOOLS = otherTools; // pi-chrome adds its primer as named prompt sections, one per handler (upstream's and a fork's), // or, written for an older pi, returns a whole systemPrompt instead. Take both; upstream's goes // first. const sections = {}; const pending = emit("before_agent_start", { systemPrompt: "", systemPromptOptions: { sections } }); process.env.PI_CHROME_OTHER_BROWSER_TOOLS = saved; const returned = (await pending)?.systemPrompt ?? ""; const names = Object.keys(sections).sort((a, b) => (b === UPSTREAM_SECTION) - (a === UPSTREAM_SECTION)); const primer = [...names.map((name) => `<${name}>\n${sections[name]}\n`), returned].join("\n"); 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's built-in MCP support introduces itself as pi, and pi-mcp-adapter as pi-mcp-; // 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 === "pi" || 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());