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 +13 -0
- package/README.md +1 -1
- package/dist/cli.js +19 -5
- package/dist/mcp.js +107 -20
- package/package.json +2 -1
- package/src/cli.ts +29 -7
- package/src/mcp.ts +198 -47
- package/src/version.ts +1 -1
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
|
-
|
|
11470
|
-
|
|
11471
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
11411
|
-
|
|
11412
|
-
|
|
11413
|
-
|
|
11414
|
-
|
|
11415
|
-
|
|
11416
|
-
|
|
11417
|
-
|
|
11418
|
-
async function shutdown() {
|
|
11419
|
-
|
|
11420
|
-
|
|
11421
|
-
|
|
11422
|
-
|
|
11423
|
-
|
|
11424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11430
|
-
process.
|
|
11431
|
-
|
|
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.
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
(
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
)
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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