diff options
Diffstat (limited to 'mcp/server.mjs')
| -rwxr-xr-x | mcp/server.mjs | 630 |
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()); |
