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 +19 -0
- package/README.md +3 -3
- package/dist/cli.js +25 -7
- package/dist/index.js +6 -2
- package/dist/mcp.js +115 -24
- package/package.json +2 -1
- package/src/cli.ts +29 -7
- package/src/mcp.ts +198 -47
- package/src/toolDocs.ts +2 -2
- package/src/tools/extract.ts +34 -8
- package/src/version.ts +1 -1
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.
|
|
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`
|
|
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(
|
|
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
|
-
|
|
11466
|
-
|
|
11467
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
|
|
11407
|
-
|
|
11408
|
-
|
|
11409
|
-
|
|
11410
|
-
|
|
11411
|
-
|
|
11412
|
-
|
|
11413
|
-
|
|
11414
|
-
async function shutdown() {
|
|
11415
|
-
|
|
11416
|
-
|
|
11417
|
-
|
|
11418
|
-
|
|
11419
|
-
|
|
11420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11426
|
-
process.
|
|
11427
|
-
|
|
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/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
|
|
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."
|
package/src/tools/extract.ts
CHANGED
|
@@ -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
|
|
18
|
-
* reads the schema's property DESCRIPTIONS as its only
|
|
19
|
-
* with no descriptions anywhere would extract garbage
|
|
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".
|
|
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(
|
|
583
|
+
messages: buildExtractionMessages(promptMode, opts.html, opts.schema),
|
|
570
584
|
temperature: 0,
|
|
571
585
|
max_tokens: COMPLESION_TOKENS,
|
|
572
|
-
|
|
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
|
|
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