cdp-toolkit 2.5.0 → 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,25 @@ 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
+
21
+ ## [2.5.1] - 2026-09-12
22
+
23
+ ### Fixed
24
+
25
+ - **`extract_page { prompt: "schematron" }` no longer sends `response_format`.** Measured against a locally served open-weight Schematron-8B on `mlx_vlm.server` (llguidance-constrained decoding): with `response_format` present, a real CNN zone came back as `{"stories": []}` in 6 completion tokens; the IDENTICAL messages with `response_format` omitted returned valid, schema-conformant JSON with 5 stories in 285 tokens. A 3-link toy page worked either way, so the collapse only shows on real markup. The schema already rides inline in the schematron prompt, so constrained decoding on top of it was redundant and, on real pages, actively harmful; `response_format` is now omitted entirely (never sent as `null`) in `schematron` mode, unchanged in `html` mode. Since nothing constrains the shape server-side anymore in `schematron` mode, `extractPage` now also rejects a syntactically valid but non-object JSON response (array/null/primitive) with the same invalid-JSON error shape. One new unit test pins `html` mode still sending `response_format`; the existing schematron wire-shape test now asserts its absence (`test/extract.test.ts`).
26
+
8
27
  ## [2.5.0] - 2026-09-12
9
28
 
10
29
  ### Added
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
 
@@ -254,7 +254,7 @@ That shape is deliberate. The MCP **2026-07-28** revision puts lazy discovery on
254
254
  With no `name` it returns the grouped catalog of everything this server can run. The whole 48-tool surface:
255
255
 
256
256
  ```
257
- cdp-toolkit 2.5.0 · browser=chrome · 48 tools available, 49 in tools/list (CDP_TOOL_PROFILE=full)
257
+ cdp-toolkit 2.5.1 · browser=chrome · 48 tools available, 49 in tools/list (CDP_TOOL_PROFILE=full)
258
258
  [listed] core (12): list_pages, new_page, close_page, select_page, navigate_page, wait_for, take_snapshot, click, fill, type_text, evaluate_script, take_screenshot
259
259
  [listed] input (9): hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file, focus_emulation, click_focus_gated
260
260
  [listed] cookies (3): list_cookies, set_cookie, delete_cookies
@@ -314,7 +314,7 @@ Measured 2026-09-01: Claude Code 2.1.258 and Codex CLI 0.147.0 both connect and
314
314
  {"tool": "extract_page", "arguments": {"target": "index:0", "schema": {"type": "object", "properties": {"title": {"type": "string", "description": "the page title, exactly as printed"}}}}}
315
315
  ```
316
316
 
317
- **Prompt shape (`prompt`, `CDP_EXTRACT_PROMPT`).** By default (`prompt:"html"`) the cleaned HTML is the *only* user message and your schema rides in `response_format` alone — which is what the **hosted** Schematron API expects, since it injects the schema into the prompt server-side. Point `baseUrl` at a **locally served open-weight Schematron** (e.g. an MLX `schematron8B`) and that breaks: the open weights were fine-tuned on a prompt with the schema *inside* the user message, and a bare-HTML prompt returns garbage even under constrained decoding. `prompt:"schematron"` sends the model card's own messages instead — a `You are a helpful assistant` system message plus a user message carrying the compact `JSON.stringify`'d schema, then the HTML, then `MAKE SURE ITS VALID JSON.` That adds roughly the stringified schema's length to the prompt (`maxChars` caps the **HTML** only; the true count still comes back in `usage.promptTokens`). `response_format` is sent in both modes. The per-call arg wins over `CDP_EXTRACT_PROMPT`, and anything but `html`/`schematron` is rejected before any page or network work.
317
+ **Prompt shape (`prompt`, `CDP_EXTRACT_PROMPT`).** By default (`prompt:"html"`) the cleaned HTML is the *only* user message and your schema rides in `response_format` alone — which is what the **hosted** Schematron API expects, since it injects the schema into the prompt server-side. Point `baseUrl` at a **locally served open-weight Schematron** (e.g. an MLX `schematron8B`) and that breaks: the open weights were fine-tuned on a prompt with the schema *inside* the user message, and a bare-HTML prompt returns garbage even under constrained decoding. `prompt:"schematron"` sends the model card's own messages instead — a `You are a helpful assistant` system message plus a user message carrying the compact `JSON.stringify`'d schema, then the HTML, then `MAKE SURE ITS VALID JSON.` That adds roughly the stringified schema's length to the prompt (`maxChars` caps the **HTML** only; the true count still comes back in `usage.promptTokens`). `schematron` mode sends **no `response_format`** at all — the schema already lives in the prompt, and the client validates only that the response parses as JSON. (Measured 2026-09-12 against a locally served open-weight Schematron-8B under llguidance-constrained decoding: identical messages returned `{"stories": []}` in 6 completion tokens with `response_format` present, versus a valid, schema-conformant 5-story result in 285 tokens with it omitted — constrained decoding on top of an already-inlined schema is redundant and, on real markup, harmful.) The per-call arg wins over `CDP_EXTRACT_PROMPT`, and anything but `html`/`schematron` is rejected before any page or network work.
318
318
 
319
319
  Works on both backends (Chrome and Firefox); see the `extract_page` row in [The tools](#the-tools-29-parity--19-superset--48) for the full parameter set.
320
320
 
package/dist/cli.js CHANGED
@@ -8187,12 +8187,13 @@ function buildExtractionMessages(mode, html, schema) {
8187
8187
  }
8188
8188
  async function callExtractionEndpoint(opts) {
8189
8189
  const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
8190
+ const promptMode = opts.prompt ?? "html";
8190
8191
  const body = JSON.stringify({
8191
8192
  model: opts.model,
8192
- messages: buildExtractionMessages(opts.prompt ?? "html", opts.html, opts.schema),
8193
+ messages: buildExtractionMessages(promptMode, opts.html, opts.schema),
8193
8194
  temperature: 0,
8194
8195
  max_tokens: COMPLESION_TOKENS,
8195
- response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } }
8196
+ ...promptMode === "schematron" ? {} : { response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } } }
8196
8197
  });
8197
8198
  const headers = { "content-type": "application/json" };
8198
8199
  if (opts.apiKey)
@@ -8286,6 +8287,9 @@ async function extractPage(driver, args) {
8286
8287
  } catch (e) {
8287
8288
  throw new CdpError(`extract_page: endpoint returned invalid JSON (${e.message}${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`);
8288
8289
  }
8290
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
8291
+ throw new CdpError(`extract_page: endpoint returned invalid JSON (expected a JSON object${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`);
8292
+ }
8289
8293
  const result = {
8290
8294
  data,
8291
8295
  model: respondedModel,
@@ -11225,6 +11229,18 @@ Warning: could not find the ${browser} binary at its expected location. The alia
11225
11229
  }
11226
11230
 
11227
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
+ }
11228
11244
  var USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
11229
11245
 
11230
11246
  Usage:
@@ -11462,9 +11478,11 @@ async function main() {
11462
11478
  }
11463
11479
  });
11464
11480
  }
11465
- main().then((code) => process.exit(code)).catch((err) => {
11466
- const message = err instanceof Error ? err.message : String(err);
11467
- 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 })}
11468
11485
  `);
11469
- process.exit(1);
11470
- });
11486
+ process.exit(1);
11487
+ });
11488
+ }
package/dist/index.js CHANGED
@@ -5589,12 +5589,13 @@ function buildExtractionMessages(mode, html, schema) {
5589
5589
  }
5590
5590
  async function callExtractionEndpoint(opts) {
5591
5591
  const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
5592
+ const promptMode = opts.prompt ?? "html";
5592
5593
  const body = JSON.stringify({
5593
5594
  model: opts.model,
5594
- messages: buildExtractionMessages(opts.prompt ?? "html", opts.html, opts.schema),
5595
+ messages: buildExtractionMessages(promptMode, opts.html, opts.schema),
5595
5596
  temperature: 0,
5596
5597
  max_tokens: COMPLESION_TOKENS,
5597
- response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } }
5598
+ ...promptMode === "schematron" ? {} : { response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } } }
5598
5599
  });
5599
5600
  const headers = { "content-type": "application/json" };
5600
5601
  if (opts.apiKey)
@@ -5688,6 +5689,9 @@ async function extractPage(driver, args) {
5688
5689
  } catch (e) {
5689
5690
  throw new CdpError(`extract_page: endpoint returned invalid JSON (${e.message}${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`);
5690
5691
  }
5692
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
5693
+ throw new CdpError(`extract_page: endpoint returned invalid JSON (expected a JSON object${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`);
5694
+ }
5691
5695
  const result = {
5692
5696
  data,
5693
5697
  model: respondedModel,
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();
@@ -8191,12 +8200,13 @@ function buildExtractionMessages(mode, html, schema) {
8191
8200
  }
8192
8201
  async function callExtractionEndpoint(opts) {
8193
8202
  const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
8203
+ const promptMode = opts.prompt ?? "html";
8194
8204
  const body = JSON.stringify({
8195
8205
  model: opts.model,
8196
- messages: buildExtractionMessages(opts.prompt ?? "html", opts.html, opts.schema),
8206
+ messages: buildExtractionMessages(promptMode, opts.html, opts.schema),
8197
8207
  temperature: 0,
8198
8208
  max_tokens: COMPLESION_TOKENS,
8199
- response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } }
8209
+ ...promptMode === "schematron" ? {} : { response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } } }
8200
8210
  });
8201
8211
  const headers = { "content-type": "application/json" };
8202
8212
  if (opts.apiKey)
@@ -8290,6 +8300,9 @@ async function extractPage(driver, args) {
8290
8300
  } catch (e) {
8291
8301
  throw new CdpError(`extract_page: endpoint returned invalid JSON (${e.message}${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`);
8292
8302
  }
8303
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
8304
+ throw new CdpError(`extract_page: endpoint returned invalid JSON (expected a JSON object${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`);
8305
+ }
8293
8306
  const result = {
8294
8307
  data,
8295
8308
  model: respondedModel,
@@ -11248,7 +11261,7 @@ var TOOL_DOCS = {
11248
11261
  params: {}
11249
11262
  },
11250
11263
  extract_page: {
11251
- description: "Structured extraction: take the target page's HTML, clean it, and send it to an OpenAI-compatible /chat/completions endpoint along with your JSON Schema, getting back schema-conformant JSON instead of prose. THE DEFAULT ENDPOINT IS LOOPBACK - llm-ferry at http://127.0.0.1:8090/v1 with model 'schematron' - so page content stays ON THIS MACHINE unless the operator overrides baseUrl (or sets CDP_EXTRACT_BASE_URL). Configuration: CDP_EXTRACT_BASE_URL (endpoint override), CDP_EXTRACT_MODEL (model override), CDP_EXTRACT_API_KEY (bearer token; never logged and REDACTED from every error), CDP_EXTRACT_TIMEOUT_MS, CDP_EXTRACT_MAX_CHARS, CDP_EXTRACT_PROMPT. PROMPT SHAPE: by default only the cleaned HTML is sent and the schema rides in response_format, which is what the HOSTED Schematron API expects (it injects the schema server-side); set prompt:'schematron' when baseUrl points at a LOCALLY SERVED open-weight Schematron (e.g. an MLX schematron8B), because that model was fine-tuned on a prompt with the schema INSIDE the user message and an HTML-only prompt returns garbage from it even under constrained decoding. THE SCHEMA-DESCRIPTION RULE: this is prompt-driven extraction, and the extraction INSTRUCTIONS live in the schema's property descriptions - write them like prompts ('the job title, exactly as printed', 'ISO date, not 'yesterday'). A schema whose properties carry no descriptions is REJECTED up front with an error saying so, because it would produce garbage extraction with no hint why. Source: 'dom' (live DOM, the rendered, JS-executed page) is the default; 'served' (the raw server response before rendering) currently returns a not-implemented error rather than guessing. Size guard: cleaned HTML over maxChars (default 300000) fails with html_too_large naming the actual size - shrink the payload with 'selector' to scope to a subtree, clean:'aggressive' to strip harder (standard already drops scripts/styles/hidden), or a larger maxChars if you truly need it all. Endpoint failures surface as upstream_error (with status and body, API key redacted) or timeout; the response never contains the key. A completion the endpoint cut off at its 8192-token output cap fails naming finish_reason=length and the fix (narrow with 'selector', or ask the schema for less) - it is NOT reported as invalid JSON, because a truncated tail is a size problem, not a model one. COST IS ALWAYS VISIBLE: every successful call reports the endpoint's usage token counts inline, so a slow/bloated extraction is visible in the answer itself, not discovered on a bill later.",
11264
+ description: "Structured extraction: take the target page's HTML, clean it, and send it to an OpenAI-compatible /chat/completions endpoint along with your JSON Schema, getting back schema-conformant JSON instead of prose. THE DEFAULT ENDPOINT IS LOOPBACK - llm-ferry at http://127.0.0.1:8090/v1 with model 'schematron' - so page content stays ON THIS MACHINE unless the operator overrides baseUrl (or sets CDP_EXTRACT_BASE_URL). Configuration: CDP_EXTRACT_BASE_URL (endpoint override), CDP_EXTRACT_MODEL (model override), CDP_EXTRACT_API_KEY (bearer token; never logged and REDACTED from every error), CDP_EXTRACT_TIMEOUT_MS, CDP_EXTRACT_MAX_CHARS, CDP_EXTRACT_PROMPT. PROMPT SHAPE: by default only the cleaned HTML is sent and the schema rides in response_format, which is what the HOSTED Schematron API expects (it injects the schema server-side); set prompt:'schematron' when baseUrl points at a LOCALLY SERVED open-weight Schematron (e.g. an MLX schematron8B), because that model was fine-tuned on a prompt with the schema INSIDE the user message and an HTML-only prompt returns garbage from it even under constrained decoding. In 'schematron' mode NO response_format is sent at all - the schema already lives in the prompt, and the client validates only that the response parses as JSON. THE SCHEMA-DESCRIPTION RULE: this is prompt-driven extraction, and the extraction INSTRUCTIONS live in the schema's property descriptions - write them like prompts ('the job title, exactly as printed', 'ISO date, not 'yesterday'). A schema whose properties carry no descriptions is REJECTED up front with an error saying so, because it would produce garbage extraction with no hint why. Source: 'dom' (live DOM, the rendered, JS-executed page) is the default; 'served' (the raw server response before rendering) currently returns a not-implemented error rather than guessing. Size guard: cleaned HTML over maxChars (default 300000) fails with html_too_large naming the actual size - shrink the payload with 'selector' to scope to a subtree, clean:'aggressive' to strip harder (standard already drops scripts/styles/hidden), or a larger maxChars if you truly need it all. Endpoint failures surface as upstream_error (with status and body, API key redacted) or timeout; the response never contains the key. A completion the endpoint cut off at its 8192-token output cap fails naming finish_reason=length and the fix (narrow with 'selector', or ask the schema for less) - it is NOT reported as invalid JSON, because a truncated tail is a size problem, not a model one. COST IS ALWAYS VISIBLE: every successful call reports the endpoint's usage token counts inline, so a slow/bloated extraction is visible in the answer itself, not discovered on a bill later.",
11252
11265
  params: {
11253
11266
  lease: "Lease token from claim_page. Omit for a tab this process holds; required for a tab held by another process or one claimed explicitly. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease). See server instructions.",
11254
11267
  target: "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'. Grammar: see server instructions.",
@@ -11258,7 +11271,7 @@ var TOOL_DOCS = {
11258
11271
  clean: "HTML cleaning level before the payload is measured and sent. 'standard' (default) strips scripts, styles, hidden elements and other non-content noise. 'aggressive' additionally collapses whitespace, drops nav/footer/boilerplate - use when html_too_large bites or the extractor chases junk. 'none' sends the raw HTML; almost never what you want.",
11259
11272
  maxChars: "Character cap on the cleaned HTML payload. Default 300000 (also settable via CDP_EXTRACT_MAX_CHARS). Exceeding it fails with html_too_large reporting actual vs cap - it does NOT silently truncate, because a half page extracted as though it were whole is a wrong answer that looks right.",
11260
11273
  timeoutMs: "Total budget in ms for the endpoint call. Default 90000, hard max 300000 (CDP_EXTRACT_TIMEOUT_MS overrides the default). Expiry fails with a timeout error, never a hang.",
11261
- prompt: "Prompt shape sent upstream. 'html' (default, also CDP_EXTRACT_PROMPT=html) sends the cleaned HTML as the only user message and lets response_format carry the schema - correct for the hosted Schematron API, which injects the schema server-side. 'schematron' sends the open-weight Schematron model card's own messages: a 'You are a helpful assistant' system message plus a user message with the compact JSON-stringified schema inline, then the HTML, then 'MAKE SURE ITS VALID JSON.' - use it when baseUrl points at a locally served open-weight Schematron. It adds roughly the stringified schema's length to the prompt (maxChars caps the HTML only); response_format is sent in both modes. The arg wins over CDP_EXTRACT_PROMPT, and any other value is rejected up front.",
11274
+ prompt: "Prompt shape sent upstream. 'html' (default, also CDP_EXTRACT_PROMPT=html) sends the cleaned HTML as the only user message and lets response_format carry the schema - correct for the hosted Schematron API, which injects the schema server-side. 'schematron' sends the open-weight Schematron model card's own messages: a 'You are a helpful assistant' system message plus a user message with the compact JSON-stringified schema inline, then the HTML, then 'MAKE SURE ITS VALID JSON.' - use it when baseUrl points at a locally served open-weight Schematron. It adds roughly the stringified schema's length to the prompt (maxChars caps the HTML only); 'schematron' mode sends NO response_format - the schema lives in the prompt and the client validates only that the response parses as JSON. The arg wins over CDP_EXTRACT_PROMPT, and any other value is rejected up front.",
11262
11275
  model: "Model name for the endpoint call. Default 'schematron' on the loopback llm-ferry; override per-call or via CDP_EXTRACT_MODEL.",
11263
11276
  baseUrl: "OpenAI-compatible endpoint base URL (the server appends /chat/completions). DEFAULT IS http://127.0.0.1:8090/v1 - loopback llm-ferry, page content never leaves the machine. Setting this (or CDP_EXTRACT_BASE_URL) to a remote host is an operator decision about where page content may travel; the response never echoes the URL with credentials.",
11264
11277
  savePath: "Write the extracted JSON here instead of returning it inline: the response then carries the file path and usage counts only, keeping large payloads out of the transcript. An absolute path (starting with /) is used as-is; a relative path resolves under the artifact dir (/tmp/cdp-toolkit); missing parents are created."
@@ -11267,9 +11280,48 @@ var TOOL_DOCS = {
11267
11280
  };
11268
11281
 
11269
11282
  // src/version.ts
11270
- var VERSION = "2.5.0";
11283
+ var VERSION = "2.6.0";
11271
11284
 
11272
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
+ }
11273
11325
  var { kind: BROWSER, endpoint: FIREFOX_ENDPOINT } = resolveBackend(process.argv.slice(2));
11274
11326
  var AVAILABILITY = toolAvailability(BROWSER);
11275
11327
  var AVAILABLE_NAMES = new Set(AVAILABILITY.available);
@@ -11403,25 +11455,64 @@ function buildServer() {
11403
11455
  });
11404
11456
  return server;
11405
11457
  }
11406
- auditCoverage();
11407
- markLongLivedProcess();
11408
- var handle = serveStdio(({ era }) => {
11409
- console.error(`[cdp-toolkit] connection pinned to the ${era} protocol era`);
11410
- return buildServer();
11411
- }, { onerror: (err) => console.error(`[cdp-toolkit] stdio: ${err.message}`) });
11412
- 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}`);
11413
- var shuttingDown = false;
11414
- async function shutdown() {
11415
- if (shuttingDown)
11416
- return;
11417
- shuttingDown = true;
11418
- await disposeFirefoxSession();
11419
- await disposeBrowserSession();
11420
- 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}`);
11421
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)
11422
11507
  });
11423
- 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}`);
11424
11509
  }
11425
- process.on("SIGINT", () => void shutdown());
11426
- process.on("SIGTERM", () => void shutdown());
11427
- 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.0",
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/toolDocs.ts CHANGED
@@ -486,7 +486,7 @@ export const TOOL_DOCS: Record<string, ToolDoc> = {
486
486
  "params": {}
487
487
  },
488
488
  "extract_page": {
489
- "description": "Structured extraction: take the target page's HTML, clean it, and send it to an OpenAI-compatible /chat/completions endpoint along with your JSON Schema, getting back schema-conformant JSON instead of prose. THE DEFAULT ENDPOINT IS LOOPBACK - llm-ferry at http://127.0.0.1:8090/v1 with model 'schematron' - so page content stays ON THIS MACHINE unless the operator overrides baseUrl (or sets CDP_EXTRACT_BASE_URL). Configuration: CDP_EXTRACT_BASE_URL (endpoint override), CDP_EXTRACT_MODEL (model override), CDP_EXTRACT_API_KEY (bearer token; never logged and REDACTED from every error), CDP_EXTRACT_TIMEOUT_MS, CDP_EXTRACT_MAX_CHARS, CDP_EXTRACT_PROMPT. PROMPT SHAPE: by default only the cleaned HTML is sent and the schema rides in response_format, which is what the HOSTED Schematron API expects (it injects the schema server-side); set prompt:'schematron' when baseUrl points at a LOCALLY SERVED open-weight Schematron (e.g. an MLX schematron8B), because that model was fine-tuned on a prompt with the schema INSIDE the user message and an HTML-only prompt returns garbage from it even under constrained decoding. THE SCHEMA-DESCRIPTION RULE: this is prompt-driven extraction, and the extraction INSTRUCTIONS live in the schema's property descriptions - write them like prompts ('the job title, exactly as printed', 'ISO date, not 'yesterday'). A schema whose properties carry no descriptions is REJECTED up front with an error saying so, because it would produce garbage extraction with no hint why. Source: 'dom' (live DOM, the rendered, JS-executed page) is the default; 'served' (the raw server response before rendering) currently returns a not-implemented error rather than guessing. Size guard: cleaned HTML over maxChars (default 300000) fails with html_too_large naming the actual size - shrink the payload with 'selector' to scope to a subtree, clean:'aggressive' to strip harder (standard already drops scripts/styles/hidden), or a larger maxChars if you truly need it all. Endpoint failures surface as upstream_error (with status and body, API key redacted) or timeout; the response never contains the key. A completion the endpoint cut off at its 8192-token output cap fails naming finish_reason=length and the fix (narrow with 'selector', or ask the schema for less) - it is NOT reported as invalid JSON, because a truncated tail is a size problem, not a model one. COST IS ALWAYS VISIBLE: every successful call reports the endpoint's usage token counts inline, so a slow/bloated extraction is visible in the answer itself, not discovered on a bill later.",
489
+ "description": "Structured extraction: take the target page's HTML, clean it, and send it to an OpenAI-compatible /chat/completions endpoint along with your JSON Schema, getting back schema-conformant JSON instead of prose. THE DEFAULT ENDPOINT IS LOOPBACK - llm-ferry at http://127.0.0.1:8090/v1 with model 'schematron' - so page content stays ON THIS MACHINE unless the operator overrides baseUrl (or sets CDP_EXTRACT_BASE_URL). Configuration: CDP_EXTRACT_BASE_URL (endpoint override), CDP_EXTRACT_MODEL (model override), CDP_EXTRACT_API_KEY (bearer token; never logged and REDACTED from every error), CDP_EXTRACT_TIMEOUT_MS, CDP_EXTRACT_MAX_CHARS, CDP_EXTRACT_PROMPT. PROMPT SHAPE: by default only the cleaned HTML is sent and the schema rides in response_format, which is what the HOSTED Schematron API expects (it injects the schema server-side); set prompt:'schematron' when baseUrl points at a LOCALLY SERVED open-weight Schematron (e.g. an MLX schematron8B), because that model was fine-tuned on a prompt with the schema INSIDE the user message and an HTML-only prompt returns garbage from it even under constrained decoding. In 'schematron' mode NO response_format is sent at all - the schema already lives in the prompt, and the client validates only that the response parses as JSON. THE SCHEMA-DESCRIPTION RULE: this is prompt-driven extraction, and the extraction INSTRUCTIONS live in the schema's property descriptions - write them like prompts ('the job title, exactly as printed', 'ISO date, not 'yesterday'). A schema whose properties carry no descriptions is REJECTED up front with an error saying so, because it would produce garbage extraction with no hint why. Source: 'dom' (live DOM, the rendered, JS-executed page) is the default; 'served' (the raw server response before rendering) currently returns a not-implemented error rather than guessing. Size guard: cleaned HTML over maxChars (default 300000) fails with html_too_large naming the actual size - shrink the payload with 'selector' to scope to a subtree, clean:'aggressive' to strip harder (standard already drops scripts/styles/hidden), or a larger maxChars if you truly need it all. Endpoint failures surface as upstream_error (with status and body, API key redacted) or timeout; the response never contains the key. A completion the endpoint cut off at its 8192-token output cap fails naming finish_reason=length and the fix (narrow with 'selector', or ask the schema for less) - it is NOT reported as invalid JSON, because a truncated tail is a size problem, not a model one. COST IS ALWAYS VISIBLE: every successful call reports the endpoint's usage token counts inline, so a slow/bloated extraction is visible in the answer itself, not discovered on a bill later.",
490
490
  "params": {
491
491
  "lease": "Lease token from claim_page. Omit for a tab this process holds; required for a tab held by another process or one claimed explicitly. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease). See server instructions.",
492
492
  "target": "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'. Grammar: see server instructions.",
@@ -496,7 +496,7 @@ export const TOOL_DOCS: Record<string, ToolDoc> = {
496
496
  "clean": "HTML cleaning level before the payload is measured and sent. 'standard' (default) strips scripts, styles, hidden elements and other non-content noise. 'aggressive' additionally collapses whitespace, drops nav/footer/boilerplate - use when html_too_large bites or the extractor chases junk. 'none' sends the raw HTML; almost never what you want.",
497
497
  "maxChars": "Character cap on the cleaned HTML payload. Default 300000 (also settable via CDP_EXTRACT_MAX_CHARS). Exceeding it fails with html_too_large reporting actual vs cap - it does NOT silently truncate, because a half page extracted as though it were whole is a wrong answer that looks right.",
498
498
  "timeoutMs": "Total budget in ms for the endpoint call. Default 90000, hard max 300000 (CDP_EXTRACT_TIMEOUT_MS overrides the default). Expiry fails with a timeout error, never a hang.",
499
- "prompt": "Prompt shape sent upstream. 'html' (default, also CDP_EXTRACT_PROMPT=html) sends the cleaned HTML as the only user message and lets response_format carry the schema - correct for the hosted Schematron API, which injects the schema server-side. 'schematron' sends the open-weight Schematron model card's own messages: a 'You are a helpful assistant' system message plus a user message with the compact JSON-stringified schema inline, then the HTML, then 'MAKE SURE ITS VALID JSON.' - use it when baseUrl points at a locally served open-weight Schematron. It adds roughly the stringified schema's length to the prompt (maxChars caps the HTML only); response_format is sent in both modes. The arg wins over CDP_EXTRACT_PROMPT, and any other value is rejected up front.",
499
+ "prompt": "Prompt shape sent upstream. 'html' (default, also CDP_EXTRACT_PROMPT=html) sends the cleaned HTML as the only user message and lets response_format carry the schema - correct for the hosted Schematron API, which injects the schema server-side. 'schematron' sends the open-weight Schematron model card's own messages: a 'You are a helpful assistant' system message plus a user message with the compact JSON-stringified schema inline, then the HTML, then 'MAKE SURE ITS VALID JSON.' - use it when baseUrl points at a locally served open-weight Schematron. It adds roughly the stringified schema's length to the prompt (maxChars caps the HTML only); 'schematron' mode sends NO response_format - the schema lives in the prompt and the client validates only that the response parses as JSON. The arg wins over CDP_EXTRACT_PROMPT, and any other value is rejected up front.",
500
500
  "model": "Model name for the endpoint call. Default 'schematron' on the loopback llm-ferry; override per-call or via CDP_EXTRACT_MODEL.",
501
501
  "baseUrl": "OpenAI-compatible endpoint base URL (the server appends /chat/completions). DEFAULT IS http://127.0.0.1:8090/v1 - loopback llm-ferry, page content never leaves the machine. Setting this (or CDP_EXTRACT_BASE_URL) to a remote host is an operator decision about where page content may travel; the response never echoes the URL with credentials.",
502
502
  "savePath": "Write the extracted JSON here instead of returning it inline: the response then carries the file path and usage counts only, keeping large payloads out of the transcript. An absolute path (starting with /) is used as-is; a relative path resolves under the artifact dir (/tmp/cdp-toolkit); missing parents are created."
@@ -14,10 +14,13 @@
14
14
  * remote host.
15
15
  *
16
16
  * The caller's JSON Schema is BOTH the response contract (sent as
17
- * response_format json_schema strict) AND the extraction prompt: Schematron
18
- * reads the schema's property DESCRIPTIONS as its only instructions. A schema
19
- * with no descriptions anywhere would extract garbage with no hint why, so it
20
- * is rejected up front, before any page work happens.
17
+ * response_format json_schema strict in "html" mode) AND the extraction
18
+ * prompt: Schematron reads the schema's property DESCRIPTIONS as its only
19
+ * instructions. A schema with no descriptions anywhere would extract garbage
20
+ * with no hint why, so it is rejected up front, before any page work happens.
21
+ * In "schematron" mode response_format is OMITTED — the schema already rides
22
+ * inline in the prompt, and constrained decoding on top of it collapses real
23
+ * pages to an empty result (see buildExtractionMessages/callExtractionEndpoint).
21
24
  *
22
25
  * COST IS ALWAYS VISIBLE: every successful call reports the endpoint's token
23
26
  * counts, so a bloated extraction is visible in the answer itself, not
@@ -99,7 +102,9 @@ export interface ExtractPageArgs {
99
102
  * locally served open-weight Schematron was fine-tuned on: without it, an
100
103
  * HTML-only prompt yields garbage even under constrained decoding. It adds
101
104
  * roughly the JSON-stringified schema's length to the prompt. Default from
102
- * CDP_EXTRACT_PROMPT, then "html". response_format is sent in BOTH modes.
105
+ * CDP_EXTRACT_PROMPT, then "html". "schematron" mode sends NO
106
+ * response_format — the schema is already inline in the prompt, and the
107
+ * client validates only that the response parses as JSON.
103
108
  */
104
109
  prompt?: "html" | "schematron";
105
110
  /** Model name override (default CDP_EXTRACT_MODEL, then "schematron"). */
@@ -564,12 +569,23 @@ export async function callExtractionEndpoint(opts: {
564
569
  prompt?: "html" | "schematron";
565
570
  }): Promise<{ content: string; usage: ExtractUsage; model: string; finishReason: string | undefined }> {
566
571
  const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
572
+ const promptMode = opts.prompt ?? "html";
573
+ // "schematron" mode OMITS response_format entirely (never sends it as null).
574
+ // Measured 2026-09-12: against a locally served open-weight Schematron-8B
575
+ // (llguidance-constrained decoding), the IDENTICAL messages with
576
+ // response_format present returned {"stories": []} in 6 completion tokens
577
+ // for a real CNN zone, while omitting response_format returned valid,
578
+ // schema-conformant JSON with 5 stories (285 tokens) — the schema is
579
+ // already inline in the schematron user message, so constrained decoding
580
+ // on top of it is redundant and, on real markup, harmful.
567
581
  const body = JSON.stringify({
568
582
  model: opts.model,
569
- messages: buildExtractionMessages(opts.prompt ?? "html", opts.html, opts.schema),
583
+ messages: buildExtractionMessages(promptMode, opts.html, opts.schema),
570
584
  temperature: 0,
571
585
  max_tokens: COMPLESION_TOKENS,
572
- response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } },
586
+ ...(promptMode === "schematron"
587
+ ? {}
588
+ : { response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } } }),
573
589
  });
574
590
  const headers: Record<string, string> = { "content-type": "application/json" };
575
591
  if (opts.apiKey) headers.authorization = `Bearer ${opts.apiKey}`;
@@ -715,6 +731,15 @@ export async function extractPage(
715
731
  `extract_page: endpoint returned invalid JSON (${(e as Error).message}${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`,
716
732
  );
717
733
  }
734
+ // In "schematron" mode nothing constrains the shape server-side (no
735
+ // response_format), so a syntactically valid but non-object payload
736
+ // (array/null/primitive) is caught here with the same invalid-JSON error
737
+ // shape used above.
738
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
739
+ throw new CdpError(
740
+ `extract_page: endpoint returned invalid JSON (expected a JSON object${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`,
741
+ );
742
+ }
718
743
 
719
744
  const result: ExtractPageResult = {
720
745
  data,
@@ -759,7 +784,8 @@ export async function extractPage(
759
784
  * method is used, and the live DOM is never mutated (cleaning runs on a clone).
760
785
  *
761
786
  * Upstream dependency (global fetch, zero npm deps): POST ${baseUrl}/chat/completions
762
- * with response_format json_schema strict; default baseUrl is the loopback
787
+ * with response_format json_schema strict in "html" mode only ("schematron"
788
+ * mode omits it — see callExtractionEndpoint); default baseUrl is the loopback
763
789
  * llm-ferry (http://127.0.0.1:8090/v1, model "schematron"). If it is not
764
790
  * running, the network error names the URL and asks "is ferry running?".
765
791
  *
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.0";
9
+ export const VERSION = "2.6.0";