aboutsummaryrefslogtreecommitdiff
path: root/mcp/server.mjs
blob: 9c3bea41c2aedf1985712e203ead08aa7ecd5837 (plain)
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
#!/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'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());