cdp-toolkit 2.5.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ All notable changes to cdp-toolkit are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.6.0] - 2026-09-15
9
+
10
+ **Bun is now a first-class, CI-tested runtime, and the MCP server can serve streamable HTTP.** The package has always declared `engines.bun`, but nothing proved the server actually booted under Bun — or under Node, for that matter. This release pins both: the server and CLI run under Bun ≥ 1.1 or Node ≥ 22, importing `dist/mcp.js` / `dist/cli.js` as libraries is now silent (no server start, no CLI usage dump), and under Bun the same `mcp.js` entry can serve the full MCP surface over loopback HTTP with `--transport streamable-http` for hosts that launch it as an HTTP service instead of a stdio child.
11
+
12
+ ### Added
13
+
14
+ - **`--transport streamable-http` (Bun only).** `mcp.js` accepts `--transport <stdio|streamable-http>` (default `stdio`, byte-identical behavior to previous releases), plus `--port` (default 3000; `0` picks an OS port, announced on the stderr listening line) and `--host` (default 127.0.0.1). The HTTP path is built on the MCP SDK 2.0's Web-standard `createMcpHandler` behind the same `buildServer()` factory stdio uses, so both transports can never drift apart; loopback host-header and origin validation are enforced in front of it. Launched under Node with `--transport streamable-http`, the server fails fast with a clear stderr message rather than half-serving. The listening URL goes to stderr only — stdout remains the stdio JSON-RPC channel. (`src/mcp.ts`)
15
+ - **CI `runtime` job that gates the badge.** Boots the BUILT `dist/mcp.js` under both `node` (22, matching the `engines.node` floor) and `bun` on every push/PR: silent-import checks for `dist/mcp.js` and `dist/cli.js` under both runtimes, a hand-rolled stdio JSON-RPC `initialize` + `tools/list` handshake under both runtimes (no SDK client, so the wire is what's asserted), and a streamable-http `initialize` round-trip under Bun. No browser involved, so every failure is a real regression. New `bun run test:runtime` runs the same suite locally (`test/runtime-entry.test.ts`, 7 tests). (`.github/workflows/ci.yml`)
16
+
17
+ ### Fixed
18
+
19
+ - **Importing the entries is now silent under both runtimes.** `dist/mcp.js` and `dist/cli.js` used to start serving / print usage the moment any bundler or `require.resolve`-style host imported them. Both files now gate on a direct-run check (`realpathSync(process.argv[1])` against `import.meta.url`, which handles bin symlinks under npm/bunx and works identically under Node and Bun, ESM-only): only direct/bin invocation runs the server or CLI; library import is side-effect free. `buildServer` is exported from `src/mcp.ts` for embedders. (`src/mcp.ts`, `src/cli.ts`)
20
+
8
21
  ## [2.5.1] - 2026-09-12
9
22
 
10
23
  ### Fixed
package/README.md CHANGED
@@ -87,7 +87,7 @@ npx -y --package cdp-toolkit cdp navigate_page --target index:0 --url https://ex
87
87
  # …or from a clone: `bun run src/cli.ts <tool> …`
88
88
  ```
89
89
 
90
- **Requirements:** Node ≥ 22 **or** Bun ≥ 1.1: `npx -y cdp-toolkit` and `bunx -y cdp-toolkit` both work (the published bins are plain Node ESM). Chrome/Chromium with `--remote-debugging-port=9222`. Smoke-check the port: `curl -s http://127.0.0.1:9222/json/version`.
90
+ **Requirements:** Node ≥ 22 **or** Bun ≥ 1.1: `npx -y cdp-toolkit` and `bunx -y cdp-toolkit` both work (the published bins are plain Node ESM, and the server + CLI are CI-tested under both runtimes). Chrome/Chromium with `--remote-debugging-port=9222`. Smoke-check the port: `curl -s http://127.0.0.1:9222/json/version`. Under Bun only, `--transport streamable-http` (with `--port`/`--host`, default `127.0.0.1:3000`) serves the same MCP surface over loopback HTTP instead of stdio.
91
91
 
92
92
  ### MCP client setup
93
93
 
package/dist/cli.js CHANGED
@@ -11229,6 +11229,18 @@ Warning: could not find the ${browser} binary at its expected location. The alia
11229
11229
  }
11230
11230
 
11231
11231
  // src/cli.ts
11232
+ import { realpathSync } from "node:fs";
11233
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
11234
+ function isDirectRun() {
11235
+ const entry = process.argv[1];
11236
+ if (!entry)
11237
+ return false;
11238
+ try {
11239
+ return fileURLToPath2(import.meta.url) === realpathSync(entry);
11240
+ } catch {
11241
+ return false;
11242
+ }
11243
+ }
11232
11244
  var USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
11233
11245
 
11234
11246
  Usage:
@@ -11466,9 +11478,11 @@ async function main() {
11466
11478
  }
11467
11479
  });
11468
11480
  }
11469
- main().then((code) => process.exit(code)).catch((err) => {
11470
- const message = err instanceof Error ? err.message : String(err);
11471
- process.stderr.write(`${JSON.stringify({ error: message })}
11481
+ if (isDirectRun()) {
11482
+ main().then((code) => process.exit(code)).catch((err) => {
11483
+ const message = err instanceof Error ? err.message : String(err);
11484
+ process.stderr.write(`${JSON.stringify({ error: message })}
11472
11485
  `);
11473
- process.exit(1);
11474
- });
11486
+ process.exit(1);
11487
+ });
11488
+ }
package/dist/mcp.js CHANGED
@@ -3697,6 +3697,15 @@ var init_driver2 = __esm(() => {
3697
3697
  // src/mcp.ts
3698
3698
  import { Server } from "@modelcontextprotocol/server";
3699
3699
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
3700
+ import {
3701
+ createMcpHandler,
3702
+ hostHeaderValidationResponse,
3703
+ localhostAllowedHostnames,
3704
+ localhostAllowedOrigins,
3705
+ originValidationResponse
3706
+ } from "@modelcontextprotocol/server";
3707
+ import { realpathSync } from "node:fs";
3708
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
3700
3709
 
3701
3710
  // src/cdp/driver.ts
3702
3711
  init_client();
@@ -11271,9 +11280,48 @@ var TOOL_DOCS = {
11271
11280
  };
11272
11281
 
11273
11282
  // src/version.ts
11274
- var VERSION = "2.5.1";
11283
+ var VERSION = "2.6.0";
11275
11284
 
11276
11285
  // src/mcp.ts
11286
+ function isDirectRun() {
11287
+ const entry = process.argv[1];
11288
+ if (!entry)
11289
+ return false;
11290
+ try {
11291
+ return fileURLToPath2(import.meta.url) === realpathSync(entry);
11292
+ } catch {
11293
+ return false;
11294
+ }
11295
+ }
11296
+ function parseServeOptions(argv) {
11297
+ const out = { transport: "stdio", port: 3000, host: "127.0.0.1" };
11298
+ const transportIdx = argv.indexOf("--transport");
11299
+ if (transportIdx !== -1) {
11300
+ const value = argv[transportIdx + 1];
11301
+ if (value !== "stdio" && value !== "streamable-http") {
11302
+ throw new Error(`unknown --transport '${value ?? ""}': expected 'stdio' or 'streamable-http'`);
11303
+ }
11304
+ out.transport = value;
11305
+ }
11306
+ const portIdx = argv.indexOf("--port");
11307
+ if (portIdx !== -1) {
11308
+ const raw = argv[portIdx + 1];
11309
+ const port = Number(raw);
11310
+ if (raw === undefined || !/^\d+$/.test(raw) || port < 0 || port > 65535) {
11311
+ throw new Error(`invalid --port '${raw ?? ""}': expected an integer 0-65535`);
11312
+ }
11313
+ out.port = port;
11314
+ }
11315
+ const hostIdx = argv.indexOf("--host");
11316
+ if (hostIdx !== -1) {
11317
+ const raw = argv[hostIdx + 1];
11318
+ if (raw === undefined || raw === "") {
11319
+ throw new Error(`invalid --host '${raw ?? ""}': expected a hostname or address`);
11320
+ }
11321
+ out.host = raw;
11322
+ }
11323
+ return out;
11324
+ }
11277
11325
  var { kind: BROWSER, endpoint: FIREFOX_ENDPOINT } = resolveBackend(process.argv.slice(2));
11278
11326
  var AVAILABILITY = toolAvailability(BROWSER);
11279
11327
  var AVAILABLE_NAMES = new Set(AVAILABILITY.available);
@@ -11407,25 +11455,64 @@ function buildServer() {
11407
11455
  });
11408
11456
  return server;
11409
11457
  }
11410
- auditCoverage();
11411
- markLongLivedProcess();
11412
- var handle = serveStdio(({ era }) => {
11413
- console.error(`[cdp-toolkit] connection pinned to the ${era} protocol era`);
11414
- return buildServer();
11415
- }, { onerror: (err) => console.error(`[cdp-toolkit] stdio: ${err.message}`) });
11416
- console.error(`[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools available, ${LISTING.length} listed (CDP_TOOL_PROFILE=${PROFILE.label}), CDP_BASE=${BASE}`);
11417
- var shuttingDown = false;
11418
- async function shutdown() {
11419
- if (shuttingDown)
11420
- return;
11421
- shuttingDown = true;
11422
- await disposeFirefoxSession();
11423
- await disposeBrowserSession();
11424
- await handle.close().catch(() => {
11458
+ async function run2(argv) {
11459
+ const serve = parseServeOptions(argv);
11460
+ auditCoverage();
11461
+ markLongLivedProcess();
11462
+ let stdioHandle;
11463
+ let httpHandler;
11464
+ let listener;
11465
+ let shuttingDown = false;
11466
+ async function shutdown() {
11467
+ if (shuttingDown)
11468
+ return;
11469
+ shuttingDown = true;
11470
+ await disposeFirefoxSession();
11471
+ await disposeBrowserSession();
11472
+ if (stdioHandle)
11473
+ await stdioHandle.close().catch(() => {
11474
+ return;
11475
+ });
11476
+ if (httpHandler)
11477
+ await httpHandler.close().catch(() => {
11478
+ return;
11479
+ });
11480
+ if (listener)
11481
+ listener.stop();
11482
+ process.exit(0);
11483
+ }
11484
+ process.on("SIGINT", () => void shutdown());
11485
+ process.on("SIGTERM", () => void shutdown());
11486
+ if (serve.transport === "stdio") {
11487
+ stdioHandle = serveStdio(({ era }) => {
11488
+ console.error(`[cdp-toolkit] connection pinned to the ${era} protocol era`);
11489
+ return buildServer();
11490
+ }, { onerror: (err) => console.error(`[cdp-toolkit] stdio: ${err.message}`) });
11491
+ process.stdin.on("close", () => void shutdown());
11492
+ console.error(`[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools available, ${LISTING.length} listed (CDP_TOOL_PROFILE=${PROFILE.label}), CDP_BASE=${BASE}`);
11425
11493
  return;
11494
+ }
11495
+ if (typeof Bun === "undefined") {
11496
+ console.error("[cdp-toolkit] --transport streamable-http needs Bun's HTTP listener (Bun.serve). Re-run with bun, or use the default stdio transport.");
11497
+ process.exit(1);
11498
+ }
11499
+ httpHandler = createMcpHandler(({ era }) => {
11500
+ console.error(`[cdp-toolkit] connection pinned to the ${era} protocol era`);
11501
+ return buildServer();
11502
+ }, { onerror: (err) => console.error(`[cdp-toolkit] http: ${err.message}`) });
11503
+ listener = Bun.serve({
11504
+ port: serve.port,
11505
+ hostname: serve.host,
11506
+ fetch: (req) => hostHeaderValidationResponse(req, localhostAllowedHostnames()) ?? originValidationResponse(req, localhostAllowedOrigins()) ?? httpHandler.fetch(req)
11426
11507
  });
11427
- process.exit(0);
11508
+ console.error(`[cdp-toolkit] MCP server v${VERSION} listening on streamable-http port ${listener.port} (http://${serve.host}:${listener.port}/), browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools available, ${LISTING.length} listed (CDP_TOOL_PROFILE=${PROFILE.label}), CDP_BASE=${BASE}`);
11428
11509
  }
11429
- process.on("SIGINT", () => void shutdown());
11430
- process.on("SIGTERM", () => void shutdown());
11431
- process.stdin.on("close", () => void shutdown());
11510
+ if (isDirectRun()) {
11511
+ run2(process.argv.slice(2)).catch((err) => {
11512
+ console.error(`[cdp-toolkit] ${err instanceof Error ? err.message : String(err)}`);
11513
+ process.exit(1);
11514
+ });
11515
+ }
11516
+ export {
11517
+ buildServer
11518
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-toolkit",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 48 tools including built-in network mocking, a cookie group, tab leases with human-activity detection, real HTML5 drag-and-drop, raw mouse/scroll dispatch, downloads, permission grants, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs or a human's. No Puppeteer, no all-target fan-out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -73,6 +73,7 @@
73
73
  "mcp": "bun run ./src/mcp.ts",
74
74
  "typecheck": "tsc --noEmit",
75
75
  "test": "bun test",
76
+ "test:runtime": "bun test test/runtime-entry.test.ts",
76
77
  "smoke": "bun run ./test/smoke.ts",
77
78
  "mock:smoke": "bun run ./test/mock-smoke.ts",
78
79
  "screencast:smoke": "bun run ./test/screencast-smoke.ts",
package/src/cli.ts CHANGED
@@ -29,6 +29,26 @@ import { FIREFOX_TOOLS } from "./firefox-tools.ts";
29
29
  import { leaseFromArgs, withLeaseScope } from "./leases.ts";
30
30
  import { MANIFEST } from "./manifest.ts";
31
31
  import { runInstaller } from "./install/wizard.ts";
32
+ import { realpathSync } from "node:fs";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ /**
36
+ * True when this module is the process's entry script — the ONLY condition under which main()
37
+ * runs. Works under both node and bun, ESM included: compare this module's real path with the
38
+ * realpath of argv[1] (realpath resolves the bunx/npm bin symlink and any /tmp-style symlinked
39
+ * directory). Importing this module (tests, embedders) leaves argv[1] pointing elsewhere, so the
40
+ * import stays silent — no tool dispatch, no usage message.
41
+ */
42
+ function isDirectRun(): boolean {
43
+ const entry = process.argv[1];
44
+ if (!entry) return false;
45
+ try {
46
+ return fileURLToPath(import.meta.url) === realpathSync(entry);
47
+ } catch {
48
+ // argv[1] may not exist as a file (eval strings, wrappers); never treat that as direct.
49
+ return false;
50
+ }
51
+ }
32
52
 
33
53
  const USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
34
54
 
@@ -330,10 +350,12 @@ async function main(): Promise<number> {
330
350
  });
331
351
  }
332
352
 
333
- main()
334
- .then((code) => process.exit(code))
335
- .catch((err: unknown) => {
336
- const message = err instanceof Error ? err.message : String(err);
337
- process.stderr.write(`${JSON.stringify({ error: message })}\n`);
338
- process.exit(1);
339
- });
353
+ if (isDirectRun()) {
354
+ main()
355
+ .then((code) => process.exit(code))
356
+ .catch((err: unknown) => {
357
+ const message = err instanceof Error ? err.message : String(err);
358
+ process.stderr.write(`${JSON.stringify({ error: message })}\n`);
359
+ process.exit(1);
360
+ });
361
+ }
package/src/mcp.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * cdp-toolkit MCP server (stdio).
3
+ * cdp-toolkit MCP server (stdio by default, streamable-http on request).
4
4
  *
5
5
  * Exposes the toolkit's raw-CDP tools to any MCP client (Claude Code, etc.) over
6
- * the standard stdio transport. It does NOT connect to Chrome at startup: each
7
- * tool call lazily opens a single-target CDP connection (with its own timeout),
8
- * so the server loads cleanly even when Chrome isn't running; individual calls
9
- * then fail with a clear error if the browser is unreachable.
6
+ * the standard stdio transport, or — with `--transport streamable-http` — over
7
+ * an HTTP/SSE listener served by Bun. It does NOT connect to Chrome at startup:
8
+ * each tool call lazily opens a single-target CDP connection (with its own
9
+ * timeout), so the server loads cleanly even when Chrome isn't running;
10
+ * individual calls then fail with a clear error if the browser is unreachable.
11
+ *
12
+ * Importing this module is side-effect-free: the server starts only when the
13
+ * file is the directly executed entry (bin invocation or `bun run src/mcp.ts`),
14
+ * never when imported (see isDirectRun below).
10
15
  *
11
16
  * Launch: `bunx -y cdp-toolkit` (or `bun run src/mcp.ts` from a checkout)
17
+ * HTTP: `bun run src/mcp.ts --transport streamable-http [--port 3000] [--host 127.0.0.1]`
12
18
  * Config: CDP_BASE (default http://127.0.0.1:9222), CDP_TIMEOUT_MS, CDP_ARTIFACT_DIR,
13
19
  * CDP_TOOL_PROFILE (`full` — the default — advertises every group; `core` advertises
14
20
  * just the 12 everyday tools; or a comma-separated group list, e.g. `core,network`.
@@ -21,6 +27,15 @@
21
27
  import { Server } from "@modelcontextprotocol/server";
22
28
  import type { Tool } from "@modelcontextprotocol/server";
23
29
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
30
+ import {
31
+ createMcpHandler,
32
+ hostHeaderValidationResponse,
33
+ localhostAllowedHostnames,
34
+ localhostAllowedOrigins,
35
+ originValidationResponse,
36
+ } from "@modelcontextprotocol/server";
37
+ import { realpathSync } from "node:fs";
38
+ import { fileURLToPath } from "node:url";
24
39
  import { TOOLS, TOOL_NAMES, BASE } from "./index.ts";
25
40
  import { MANIFEST } from "./manifest.ts";
26
41
  import { resolveBackend, getOrCreateFirefoxSession, disposeFirefoxSession } from "./backend.ts";
@@ -32,6 +47,84 @@ import { resolveProfile, TOOL_GROUP, TOOL_GROUPS, GROUP_TOOLS } from "./toolGrou
32
47
  import { TOOL_DOCS } from "./toolDocs.ts";
33
48
  import { VERSION } from "./version.ts";
34
49
 
50
+ // Bun.serve is the HTTP transport's listener: a Bun-only global, typed here (not via bun-types,
51
+ // which CONTRACT.md keeps out of the dependency set) in the narrow shape this file uses. The code
52
+ // below never touches it unless --transport streamable-http was requested, and checks
53
+ // `typeof Bun === "undefined"` first so running under plain node fails with a clear message
54
+ // instead of a ReferenceError. The declaration emits nothing — at runtime the identifier resolves
55
+ // to the real global when one exists.
56
+ declare const Bun: {
57
+ serve(opts: {
58
+ port: number;
59
+ hostname: string;
60
+ fetch(req: Request): Promise<Response> | Response;
61
+ }): { stop(): void; port: number };
62
+ };
63
+
64
+ /**
65
+ * True when this module is the process's entry script — the ONLY condition under which the
66
+ * server starts. Works under both node and bun, ESM included: compare this module's real path
67
+ * with the realpath of argv[1]. realpathSync resolves the bunx/npm bin symlink (argv[1] is the
68
+ * symlink, the module URL is its target) and any /tmp-style symlinked directory; when argv[1] is
69
+ * absent (e.g. `node -e`/REPL importing this file) the answer is simply "not direct". A plain
70
+ * `import(".../mcp.js")` from another module leaves argv[1] pointing at that other entry, so the
71
+ * import stays silent.
72
+ */
73
+ function isDirectRun(): boolean {
74
+ const entry = process.argv[1];
75
+ if (!entry) return false;
76
+ try {
77
+ return fileURLToPath(import.meta.url) === realpathSync(entry);
78
+ } catch {
79
+ // argv[1] may not exist as a file (eval strings, wrappers); never treat that as direct.
80
+ return false;
81
+ }
82
+ }
83
+
84
+ /** What --transport/--port/--host asked for. stdio is the default and stays byte-for-byte unchanged. */
85
+ interface ServeOptions {
86
+ transport: "stdio" | "streamable-http";
87
+ port: number;
88
+ host: string;
89
+ }
90
+
91
+ /**
92
+ * Parse the serving flags from the same argv resolveBackend reads (it scans only --browser and
93
+ * --connect, so these extra tokens were already invisible to it). An unknown --transport value or
94
+ * a non-port --port is a hard configuration error, mirroring resolveBackend's loud failures.
95
+ */
96
+ function parseServeOptions(argv: readonly string[]): ServeOptions {
97
+ const out: ServeOptions = { transport: "stdio", port: 3000, host: "127.0.0.1" };
98
+ const transportIdx = argv.indexOf("--transport");
99
+ if (transportIdx !== -1) {
100
+ const value = argv[transportIdx + 1];
101
+ if (value !== "stdio" && value !== "streamable-http") {
102
+ throw new Error(`unknown --transport '${value ?? ""}': expected 'stdio' or 'streamable-http'`);
103
+ }
104
+ out.transport = value;
105
+ }
106
+ const portIdx = argv.indexOf("--port");
107
+ if (portIdx !== -1) {
108
+ const raw = argv[portIdx + 1];
109
+ const port = Number(raw);
110
+ // 0 is valid HERE (unlike backend.ts's dial-out endpoints): for a listener it means "let the
111
+ // OS pick a free port", and the resolved port is then announced on stderr (see run()).
112
+ if (raw === undefined || !/^\d+$/.test(raw) || port < 0 || port > 65_535) {
113
+ throw new Error(`invalid --port '${raw ?? ""}': expected an integer 0-65535`);
114
+ }
115
+ out.port = port;
116
+ }
117
+ const hostIdx = argv.indexOf("--host");
118
+ if (hostIdx !== -1) {
119
+ const raw = argv[hostIdx + 1];
120
+ if (raw === undefined || raw === "") {
121
+ throw new Error(`invalid --host '${raw ?? ""}': expected a hostname or address`);
122
+ }
123
+ out.host = raw;
124
+ }
125
+ return out;
126
+ }
127
+
35
128
  // Backend + (Firefox only) attach endpoint are read once at startup (MCP has no per-call notion
36
129
  // of backend): --browser flag / CDP_BROWSER env, else "chrome" (zero behavior change for existing
37
130
  // users/configs); --connect flag / CDP_FIREFOX_ENDPOINT env selects Firefox ATTACH mode and
@@ -163,7 +256,7 @@ const LIST_CACHE_HINT = { ttlMs: 3_600_000, cacheScope: "public" } as const;
163
256
  * our manifest is plain JSON Schema (src/manifest.ts). McpServer also cannot express
164
257
  * "hidden tools remain callable by name", which is the whole point of CDP_TOOL_PROFILE.
165
258
  */
166
- function buildServer(): Server {
259
+ export function buildServer(): Server {
167
260
  const server = new Server(
168
261
  { name: "cdp-toolkit", version: VERSION },
169
262
  {
@@ -236,47 +329,105 @@ function buildServer(): Server {
236
329
  return server;
237
330
  }
238
331
 
239
- auditCoverage();
240
- // This process is the long-lived MCP server, which is what makes strict mode
241
- // (CDP_REQUIRE_LEASE) safe to honor here and unsafe in cli.ts. See requireLease.
242
- markLongLivedProcess();
332
+ /**
333
+ * Start serving: stdio (the default) or the streamable-http listener. Only ever called from the
334
+ * isDirectRun guard below — importing this module must stay silent.
335
+ */
336
+ async function run(argv: string[]): Promise<void> {
337
+ const serve = parseServeOptions(argv);
338
+ auditCoverage();
339
+ // This process is the long-lived MCP server, which is what makes strict mode
340
+ // (CDP_REQUIRE_LEASE) safe to honor here and unsafe in cli.ts. See requireLease.
341
+ markLongLivedProcess();
243
342
 
244
- // serveStdio owns the era decision for the connection: a modern opening (server/discover
245
- // with the per-request _meta envelope) pins a 2026-07-28 instance, a 2025-era `initialize`
246
- // pins a legacy one. legacy:'serve' (the default) is deliberate — 2025-era clients keep the
247
- // initialize handshake they expect, so this upgrade is invisible to them.
248
- const handle = serveStdio(
249
- ({ era }) => {
250
- console.error(`[cdp-toolkit] connection pinned to the ${era} protocol era`);
251
- return buildServer();
252
- },
253
- { onerror: (err) => console.error(`[cdp-toolkit] stdio: ${err.message}`) },
254
- );
255
- console.error(
256
- `[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools available, ${LISTING.length} listed (CDP_TOOL_PROFILE=${PROFILE.label}), CDP_BASE=${BASE}`,
257
- );
343
+ // Per-transport state the shutdown path closes (only one is ever set).
344
+ let stdioHandle: Awaited<ReturnType<typeof serveStdio>> | undefined;
345
+ let httpHandler: ReturnType<typeof createMcpHandler> | undefined;
346
+ let listener: { stop(): void; port: number } | undefined;
347
+
348
+ // LAUNCH-mode Firefox owns a real OS process (see bidi/launch.ts): it must be reaped on every
349
+ // shutdown path, not just a clean exit. ATTACH-mode Firefox (CDP_FIREFOX_ENDPOINT / --connect)
350
+ // owns no process here — disposeFirefoxSession() below only ends its BiDi session (freeing
351
+ // Firefox's single session slot) and closes the socket, leaving the user's browser running.
352
+ // SIGINT/SIGTERM cover ctrl-C and a supervising client killing the server; in stdio mode the
353
+ // stdin 'close' event covers the normal MCP shutdown (the client closes the pipe). All are
354
+ // idempotent through disposeFirefoxSession(), and a no-op entirely when Firefox was never
355
+ // launched/attached.
356
+ let shuttingDown = false;
357
+ async function shutdown(): Promise<void> {
358
+ if (shuttingDown) return;
359
+ shuttingDown = true;
360
+ await disposeFirefoxSession();
361
+ // The standing browser-endpoint connection behind wait_for_download / grant_permissions (1.8.0
362
+ // Track P3). Unlike Firefox it owns no OS process, so process.exit would collect it anyway; it is
363
+ // closed explicitly so shutdown does not depend on that, and is a no-op when neither tool ran.
364
+ await disposeBrowserSession();
365
+ // Close the active transport last, so a tool call still in flight over it cannot outlive the
366
+ // browser resources it was driving. For http this also stops accepting new connections.
367
+ if (stdioHandle) await stdioHandle.close().catch(() => undefined);
368
+ if (httpHandler) await httpHandler.close().catch(() => undefined);
369
+ if (listener) listener.stop();
370
+ process.exit(0);
371
+ }
372
+ process.on("SIGINT", () => void shutdown());
373
+ process.on("SIGTERM", () => void shutdown());
258
374
 
259
- // LAUNCH-mode Firefox owns a real OS process (see bidi/launch.ts): it must be reaped on every
260
- // shutdown path, not just a clean exit. ATTACH-mode Firefox (CDP_FIREFOX_ENDPOINT / --connect)
261
- // owns no process here — disposeFirefoxSession() below only ends its BiDi session (freeing
262
- // Firefox's single session slot) and closes the socket, leaving the user's browser running.
263
- // SIGINT/SIGTERM cover ctrl-C and a supervising client killing the server; stdin 'close' covers
264
- // the normal MCP shutdown (the client closes the stdio pipe). All three are idempotent through
265
- // disposeFirefoxSession(), and a no-op entirely when Firefox was never launched/attached.
266
- let shuttingDown = false;
267
- async function shutdown(): Promise<void> {
268
- if (shuttingDown) return;
269
- shuttingDown = true;
270
- await disposeFirefoxSession();
271
- // The standing browser-endpoint connection behind wait_for_download / grant_permissions (1.8.0
272
- // Track P3). Unlike Firefox it owns no OS process, so process.exit would collect it anyway; it is
273
- // closed explicitly so shutdown does not depend on that, and is a no-op when neither tool ran.
274
- await disposeBrowserSession();
275
- // Close the stdio connection (pinned instance + transport) last, so a tool call still
276
- // in flight over it cannot outlive the browser resources it was driving.
277
- await handle.close().catch(() => undefined);
278
- process.exit(0);
375
+ if (serve.transport === "stdio") {
376
+ // serveStdio owns the era decision for the connection: a modern opening (server/discover
377
+ // with the per-request _meta envelope) pins a 2026-07-28 instance, a 2025-era `initialize`
378
+ // pins a legacy one. legacy:'serve' (the default) is deliberate — 2025-era clients keep the
379
+ // initialize handshake they expect, so this upgrade is invisible to them.
380
+ stdioHandle = serveStdio(
381
+ ({ era }) => {
382
+ console.error(`[cdp-toolkit] connection pinned to the ${era} protocol era`);
383
+ return buildServer();
384
+ },
385
+ { onerror: (err) => console.error(`[cdp-toolkit] stdio: ${err.message}`) },
386
+ );
387
+ process.stdin.on("close", () => void shutdown());
388
+ console.error(
389
+ `[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools available, ${LISTING.length} listed (CDP_TOOL_PROFILE=${PROFILE.label}), CDP_BASE=${BASE}`,
390
+ );
391
+ return;
392
+ }
393
+
394
+ // streamable-http: Bun.serve + the SDK's web-standard handler. DNS-rebinding protection comes
395
+ // from the SDK's host/origin validators composed in front (each returns an error Response for a
396
+ // disallowed request, undefined to fall through); the localhost allowlists match the default
397
+ // loopback bind. Everything diagnostic goes to stderr — stdout stays the stdio JSON-RPC channel
398
+ // only, and even here it must carry nothing (an http client never reads it).
399
+ if (typeof Bun === "undefined") {
400
+ console.error(
401
+ "[cdp-toolkit] --transport streamable-http needs Bun's HTTP listener (Bun.serve). Re-run with bun, or use the default stdio transport.",
402
+ );
403
+ process.exit(1);
404
+ }
405
+ httpHandler = createMcpHandler(
406
+ ({ era }) => {
407
+ console.error(`[cdp-toolkit] connection pinned to the ${era} protocol era`);
408
+ return buildServer();
409
+ },
410
+ { onerror: (err) => console.error(`[cdp-toolkit] http: ${err.message}`) },
411
+ );
412
+ listener = Bun.serve({
413
+ port: serve.port,
414
+ hostname: serve.host,
415
+ fetch: (req) =>
416
+ hostHeaderValidationResponse(req, localhostAllowedHostnames()) ??
417
+ originValidationResponse(req, localhostAllowedOrigins()) ??
418
+ httpHandler!.fetch(req),
419
+ });
420
+ // The resolved port is announced as a bare `port N` token BEFORE the URL text, so a stderr
421
+ // scanner finds the real listening port first (matters under --port 0, where N is the
422
+ // OS-assigned one and the only place it is knowable).
423
+ console.error(
424
+ `[cdp-toolkit] MCP server v${VERSION} listening on streamable-http port ${listener.port} (http://${serve.host}:${listener.port}/), browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools available, ${LISTING.length} listed (CDP_TOOL_PROFILE=${PROFILE.label}), CDP_BASE=${BASE}`,
425
+ );
426
+ }
427
+
428
+ if (isDirectRun()) {
429
+ run(process.argv.slice(2)).catch((err: unknown) => {
430
+ console.error(`[cdp-toolkit] ${err instanceof Error ? err.message : String(err)}`);
431
+ process.exit(1);
432
+ });
279
433
  }
280
- process.on("SIGINT", () => void shutdown());
281
- process.on("SIGTERM", () => void shutdown());
282
- process.stdin.on("close", () => void shutdown());
package/src/version.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * ready line; keeping it in its own module means a release bump touches one
7
7
  * line here plus package.json, not a const buried in the server.
8
8
  */
9
- export const VERSION = "2.5.1";
9
+ export const VERSION = "2.6.0";