all repos — sz-chrome-remote-extension @ master

pi-chrome, patched to drive a signed-in browser on another host through a broker, with an MCP server

mcp/server.mjs (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
#!/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__<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.
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</${name}>`), 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-<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 === "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());