cdp-toolkit 2.5.1 → 2.7.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 +30 -0
- package/CONTRACT.md +1 -1
- package/README.md +3 -3
- package/dist/bidi/mux-daemon.js +47 -0
- package/dist/cli.js +97 -10
- package/dist/index.js +73 -4
- package/dist/mcp.js +190 -30
- package/package.json +2 -1
- package/src/bidi/driver.ts +8 -0
- package/src/cdp/driver.ts +69 -4
- package/src/cli.ts +29 -7
- package/src/client.ts +22 -0
- package/src/driver.ts +2 -1
- package/src/frames.ts +169 -0
- package/src/manifest.ts +1 -1
- package/src/mcp.ts +200 -49
- package/src/shared-tools.ts +13 -0
- package/src/toolDocs.ts +3 -3
- package/src/types.ts +13 -0
- package/src/version.ts +1 -1
package/src/frames.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "frame:<url-substring>" arm of the TargetSelector grammar (issue #7).
|
|
3
|
+
*
|
|
4
|
+
* WHAT IT IS FOR. Chrome site isolation gives a CROSS-ORIGIN iframe its own
|
|
5
|
+
* process, and with it its own CDP target that shows up in /json/list with
|
|
6
|
+
* type "iframe" and its own webSocketDebuggerUrl. Until now the only way to
|
|
7
|
+
* name one was its 32-hex targetId, discovered by reading list_pages{all:true}
|
|
8
|
+
* output by eye. This arm names it by a substring of its url — `frame:ads.example.com`,
|
|
9
|
+
* `frame:player.html` — exactly the way `url:` names a tab.
|
|
10
|
+
*
|
|
11
|
+
* THE OOPIF FACT, which is what the miss message below exists to teach: only
|
|
12
|
+
* OUT-OF-PROCESS iframes appear as targets at all. A SAME-ORIGIN iframe runs
|
|
13
|
+
* inside its parent page's process and has no target, no id, no listing entry —
|
|
14
|
+
* no substring can ever select it, and a caller who does not know that goes
|
|
15
|
+
* hunting for a typo that is not there. This is the frame arm's counterpart of
|
|
16
|
+
* the MV3-eviction fact the worker messages teach (see workers.ts).
|
|
17
|
+
*
|
|
18
|
+
* WHY DATA, NEVER THROWS (the resolveLiveLabel precedent in origins.ts, and
|
|
19
|
+
* workers.ts's copy of this rule). The grammar has three independent resolver
|
|
20
|
+
* copies — `pickTarget` in client.ts, `pickPage` in shared-tools.ts,
|
|
21
|
+
* `pickContext` in bidi/driver.ts — each with its own error class (CdpError /
|
|
22
|
+
* SharedToolError / DriverError). A shared resolver that threw would force one
|
|
23
|
+
* class on all three, so this returns the facts and each caller raises its own
|
|
24
|
+
* error. What IS shared is the message TEXT below, because the teaching in it
|
|
25
|
+
* is the point of the feature and three hand-written variants of it would drift
|
|
26
|
+
* apart within one release.
|
|
27
|
+
*
|
|
28
|
+
* WHY THE ALL-TARGETS LISTING. An iframe is not a page target, so the page-only
|
|
29
|
+
* listing the other url:/title: arms resolve against never contains one. This
|
|
30
|
+
* arm reads the unfiltered listing, same as the worker: arm. That does NOT
|
|
31
|
+
* merge the id sets that reap and leases keep apart: see ReapInput.livePageIds's
|
|
32
|
+
* header. Unlike a worker, an iframe hit is NOT exempted from the lease gate in
|
|
33
|
+
* client.ts's resolveTarget — a bare iframe id goes through that gate today, and
|
|
34
|
+
* `frame:` is a way of NAMING the same target, not a new permission for it.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Selector prefix owned by this arm. */
|
|
38
|
+
export const FRAME_SELECTOR_PREFIX = "frame:";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The page-only tools that REFUSE this arm, and the single source of truth for
|
|
42
|
+
* it: the page-only refusal message below is built from this list.
|
|
43
|
+
*
|
|
44
|
+
* WHY A REFUSAL LIST AND NOT workers.ts's WORKER_CAPABLE_TOOLS allowlist: the
|
|
45
|
+
* worker arm is resolved per-tool (evaluate_script and the three recorders route
|
|
46
|
+
* it through resolveWorkerSelectorFor), so which tools accept it is a finite,
|
|
47
|
+
* deliberate list. The frame arm instead lives in client.ts's pickTarget, the
|
|
48
|
+
* ONE resolver every Chrome tool's `target` argument already flows through, so
|
|
49
|
+
* every target-taking tool accepts it with no per-tool work — the finite,
|
|
50
|
+
* deliberate list is the four that must NOT (closing, selecting, releasing or
|
|
51
|
+
* claiming an iframe is meaningless; those act on the embedder TAB).
|
|
52
|
+
*/
|
|
53
|
+
export const FRAME_PAGE_ONLY_TOOLS = ["close_page", "select_page", "release_page", "claim_page"] as const;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The CDP target type this arm resolves: "iframe". /json/list reports an
|
|
57
|
+
* out-of-process iframe under exactly this type (a same-origin one is absent
|
|
58
|
+
* entirely, which is the OOPIF fact the miss message teaches).
|
|
59
|
+
*/
|
|
60
|
+
export const FRAME_TARGET_TYPE = "iframe";
|
|
61
|
+
|
|
62
|
+
/** The two fields every listing shape (CDP Target, PageInfo) already carries. */
|
|
63
|
+
export interface FrameTargetLike {
|
|
64
|
+
id: string;
|
|
65
|
+
url: string;
|
|
66
|
+
type?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isFrameSelector(selector: string | undefined): selector is string {
|
|
70
|
+
return typeof selector === "string" && selector.startsWith(FRAME_SELECTOR_PREFIX);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The substring half of "frame:<substring>". May be empty; callers reject that. */
|
|
74
|
+
export function frameNeedle(selector: string): string {
|
|
75
|
+
return selector.slice(FRAME_SELECTOR_PREFIX.length);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isFrameTargetType(type: string | undefined): boolean {
|
|
79
|
+
return type === FRAME_TARGET_TYPE;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface FrameResolution<T> {
|
|
83
|
+
/** Out-of-process iframe targets whose url contains the needle. */
|
|
84
|
+
matches: T[];
|
|
85
|
+
/** Every live iframe target, for a miss message that lists the alternatives. */
|
|
86
|
+
liveFrames: T[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Pure: split a target listing into "iframes matching the needle" and "all live
|
|
91
|
+
* iframes". An EMPTY needle matches nothing here rather than matching
|
|
92
|
+
* everything, because `"anything".includes("")` is true and a bare `frame:`
|
|
93
|
+
* would otherwise silently mean "whichever iframe happens to be first".
|
|
94
|
+
* Callers reject the empty needle outright with FRAME_EMPTY_NEEDLE_MESSAGE.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveFrameTargets<T extends FrameTargetLike>(targets: readonly T[], needle: string): FrameResolution<T> {
|
|
97
|
+
const liveFrames = targets.filter((t) => isFrameTargetType(t.type));
|
|
98
|
+
return {
|
|
99
|
+
matches: needle === "" ? [] : liveFrames.filter((t) => t.url.includes(needle)),
|
|
100
|
+
liveFrames,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* -------------------------------- shared message text -------------------------------- */
|
|
105
|
+
|
|
106
|
+
export const FRAME_EMPTY_NEEDLE_MESSAGE =
|
|
107
|
+
"'frame:' needs a substring of the iframe's url, e.g. frame:ads.example.com or frame:player.html";
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The Firefox refusal. A PARAM-level gap, exactly like drag's mode:'html5' and
|
|
111
|
+
* the worker arm's WORKER_SELECTOR_UNSUPPORTED_MESSAGE: the target-taking tools
|
|
112
|
+
* stay in tools/list on Firefox because every tab selector still works there,
|
|
113
|
+
* and only this one arm is refused. Naming the reason matters — WebDriver BiDi
|
|
114
|
+
* has no iframe TARGET: iframes are realms inside a browsing context, and a
|
|
115
|
+
* context IS a tab, so there is nothing for a target selector to resolve to.
|
|
116
|
+
* This is a protocol-model gap, not an unimplemented to-do.
|
|
117
|
+
*/
|
|
118
|
+
export const FRAME_SELECTOR_UNSUPPORTED_MESSAGE =
|
|
119
|
+
"target 'frame:<substring>' selects an out-of-process iframe, which this backend cannot address " +
|
|
120
|
+
"(WebDriver BiDi has no iframe target — an iframe is a realm inside its tab's browsing context, not a " +
|
|
121
|
+
"selectable target; Capability 'frame.targets' is Chrome-only). Use --browser chrome to act on a cross-origin iframe.";
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Refusal from the page-only resolvers (close_page / select_page /
|
|
125
|
+
* release_page / claim_page): those act on the embedder TAB, and an iframe has
|
|
126
|
+
* no independent existence to close, select, release or claim. Because the arm
|
|
127
|
+
* is accepted by every OTHER target-taking tool (it resolves inside the shared
|
|
128
|
+
* pickTarget), the message names the refusing minority rather than an allowlist
|
|
129
|
+
* of the accepting majority.
|
|
130
|
+
*/
|
|
131
|
+
export const FRAME_SELECTOR_PAGE_ONLY_MESSAGE =
|
|
132
|
+
"target 'frame:<substring>' selects an out-of-process iframe, and this tool is page-only " +
|
|
133
|
+
"(closing, selecting, releasing or claiming an iframe is meaningless; these act on the embedder tab). " +
|
|
134
|
+
`The page-only tools are ${FRAME_PAGE_ONLY_TOOLS.join(", ")}; every other tool that takes a 'target' ` +
|
|
135
|
+
"accepts a frame: selector.";
|
|
136
|
+
|
|
137
|
+
/** Ambiguity never resolves to a silent first match: name every candidate. */
|
|
138
|
+
export function frameAmbiguityMessage(needle: string, matches: readonly FrameTargetLike[]): string {
|
|
139
|
+
return (
|
|
140
|
+
`'frame:${needle}' matches more than one out-of-process iframe: ` +
|
|
141
|
+
matches.map((m) => `${m.url} (${m.id})`).join(", ") +
|
|
142
|
+
". Narrow the substring, or pass the iframe's target id directly."
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The miss message, and the whole reason this module owns its text. A miss
|
|
148
|
+
* here is usually NOT a typo, for one of two reasons the caller can act on:
|
|
149
|
+
* - the iframe is SAME-ORIGIN: it runs inside its parent page's process, has
|
|
150
|
+
* no target at all, and can never be selected by any substring. The fix is
|
|
151
|
+
* to go through the parent page (evaluate_script on the page, then
|
|
152
|
+
* contentDocument through the iframe element).
|
|
153
|
+
* - the frame navigated away or was removed: an OOPIF target is as
|
|
154
|
+
* short-lived as the document inside it, so the id seen a moment ago is
|
|
155
|
+
* simply gone. The fix is to re-list.
|
|
156
|
+
*/
|
|
157
|
+
export function frameMissMessage(needle: string, liveFrames: readonly FrameTargetLike[]): string {
|
|
158
|
+
const live = liveFrames.length
|
|
159
|
+
? ` (out-of-process iframes: ${liveFrames.map((f) => f.url).join(", ")})`
|
|
160
|
+
: " (no out-of-process iframes are live)";
|
|
161
|
+
return (
|
|
162
|
+
`no out-of-process iframe whose url contains '${needle}'${live}. ` +
|
|
163
|
+
"Only a CROSS-ORIGIN iframe gets its own process and therefore its own target; a same-origin iframe " +
|
|
164
|
+
"runs inside its parent page and is NOT a target at all, so no substring can ever select it — reach a " +
|
|
165
|
+
"same-origin iframe through its parent page instead (evaluate_script on the page, then the iframe " +
|
|
166
|
+
"element's contentDocument). An out-of-process iframe whose document navigated away is likewise gone " +
|
|
167
|
+
"from the listing: re-list with list_pages{all:true} and pass the new target id."
|
|
168
|
+
);
|
|
169
|
+
}
|
package/src/manifest.ts
CHANGED
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
|
|
@@ -65,8 +158,8 @@ function auditCoverage(): void {
|
|
|
65
158
|
const INSTRUCTIONS = [
|
|
66
159
|
"cdp-toolkit drives a real browser over Chrome DevTools Protocol (or Firefox WebDriver-BiDi): open and drive pages, click/fill/type, screenshot and accessibility-snapshot, read console + network, set cookies, emulate devices, run Lighthouse and performance traces, record screencasts. Conventions shared across its tools:",
|
|
67
160
|
"TOOL AVAILABILITY: tools/list is complete and fixed for this process's life — cache it. Its one-liners are not the full docs: call describe_tool {name} on an unfamiliar tool, or describe_tool {} for the grouped catalog. CDP_TOOL_PROFILE=core (or a group list) lists only those groups; unlisted tools stay callable by name.",
|
|
68
|
-
"TARGET SELECTOR (the `target` param, unless a tool says otherwise): 'active' (default = first page) | 'index:N' (0-based) | 'url:<substr>' | 'title:<substr>' | 'label:<name>' (exact,
|
|
69
|
-
"LEASES (the `lease` param): claim_page, and new_page{claim:true}, mint an opaque token. Omit it for a tab THIS process already holds. It is required for a tab held by ANOTHER process, or one claimed explicitly. Under CDP_REQUIRE_LEASE the gate auto-acquires a lease for any tab this process drives — no token is surfaced, so pass `target`, not `lease` — while an explicit claim:true still demands its token on every later call.
|
|
161
|
+
"TARGET SELECTOR (the `target` param, unless a tool says otherwise): 'active' (default = first page) | 'index:N' (0-based) | 'url:<substr>' | 'title:<substr>' | 'label:<name>' (exact, ledger or live lease) | a 32-hex '<targetId>' of any target type — an iframe id from list_pages{all:true} drives like a tab. Chrome tools also accept 'frame:<substr>' for OOPIF iframe targets. Four Chrome-only tools (evaluate_script, list_network_requests, get_network_request, list_console_messages) also accept 'worker:<substr>' to reach a service/shared worker (e.g. an MV3 background worker); an idle-evicted worker is started first (see `wake`).",
|
|
162
|
+
"LEASES (the `lease` param): claim_page, and new_page{claim:true}, mint an opaque token. Omit it for a tab THIS process already holds. It is required for a tab held by ANOTHER process, or one claimed explicitly. Under CDP_REQUIRE_LEASE the gate auto-acquires a lease for any tab this process drives — no token is surfaced, so pass `target`, not `lease` — while an explicit claim:true still demands its token on every later call.",
|
|
70
163
|
"ORIGIN: list_pages and list_leases tag each tab's `origin` as 'agent' (this toolkit created it) or 'unknown' — never 'human', because the toolkit cannot prove a person opened a tab. An 'agent' tab stays findable after its creator releases the lease or dies.",
|
|
71
164
|
].join("\n\n");
|
|
72
165
|
|
|
@@ -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/shared-tools.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
import { installBeacon, probeRendererActivity, rendererProbeSupported } from "./activity.ts";
|
|
49
49
|
import { newTrackedPage, originIndex, resolveLiveLabel, type PageOrigin } from "./origins.ts";
|
|
50
50
|
import { isWorkerSelector, WORKER_SELECTOR_PAGE_ONLY_MESSAGE, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "./workers.ts";
|
|
51
|
+
import { FRAME_SELECTOR_PAGE_ONLY_MESSAGE, FRAME_SELECTOR_UNSUPPORTED_MESSAGE, isFrameSelector } from "./frames.ts";
|
|
51
52
|
import { reapStaleAgentTabs, type ReapedTab } from "./reap.ts";
|
|
52
53
|
|
|
53
54
|
const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
@@ -150,6 +151,18 @@ export async function pickPage(driver: BrowserDriver, pages: PageInfo[], selecto
|
|
|
150
151
|
backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE,
|
|
151
152
|
);
|
|
152
153
|
}
|
|
154
|
+
if (isFrameSelector(selector)) {
|
|
155
|
+
// Same contract as the worker: arm above, same reason. Every caller of
|
|
156
|
+
// pickPage (close_page / select_page / release_page via resolvePage, and
|
|
157
|
+
// claim_page's takeover) acts on a TAB; an out-of-process iframe has no
|
|
158
|
+
// independent existence to close, select, release or claim. Refusing with
|
|
159
|
+
// the reason beats the bare-id fall-through's "no target with id
|
|
160
|
+
// 'frame:foo'", which reads as a typo. The arm itself lives in client.ts's
|
|
161
|
+
// pickTarget and every other target-taking tool accepts it.
|
|
162
|
+
throw new SharedToolError(
|
|
163
|
+
backendOf(driver) === "firefox" ? FRAME_SELECTOR_UNSUPPORTED_MESSAGE : FRAME_SELECTOR_PAGE_ONLY_MESSAGE,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
153
166
|
const exactPage = pages.find((x) => x.id === selector);
|
|
154
167
|
if (exactPage) return exactPage;
|
|
155
168
|
const all = await driver.listPages({ all: true });
|
package/src/toolDocs.ts
CHANGED
|
@@ -3,10 +3,10 @@ export type ToolDoc = { readonly description: string; readonly params: Record<st
|
|
|
3
3
|
|
|
4
4
|
export const TOOL_DOCS: Record<string, ToolDoc> = {
|
|
5
5
|
"list_pages": {
|
|
6
|
-
"description": "Enumerate browser page targets (GET /json/list); page-type tabs only unless all=true. Each entry carries its targetId plus an origin field: 'agent' means this toolkit created it (also carries the creating label/createdAt, and stays 'agent' after the lease is released or the creator dies, making stray agent tabs findable), 'unknown' means no creation record - it never says 'human', the honest word for any tab it did not create; a record that exists but won't read adds originUnreadable. An entry under an active, readable lease also carries a 'lease' object {label,pid,idleMs,expiresAt,stale}, with idleMs and expiresAt computed fresh every call (never off disk), independent of probe. Under CDP_REQUIRE_LEASE this call also reaps abandoned agent tabs into an additive reaped array - but a lease reads reclaimable the moment its TTL elapses, while the tab is CLOSED only after a further grace (CDP_REAP_GRACE_MS, default 2700000ms/45min, so 60min total after last use); a dead-pid lease is reaped at once, a tab with no lease never. With probe, each page-type entry also gets 'responsive' (and humanActiveMs where human input is seen); both are absent, never false/null, without probe.",
|
|
6
|
+
"description": "Enumerate browser page targets (GET /json/list); page-type tabs only unless all=true, which also lists workers and out-of-process (OOPIF) iframes. Each entry carries its targetId plus an origin field: 'agent' means this toolkit created it (also carries the creating label/createdAt, and stays 'agent' after the lease is released or the creator dies, making stray agent tabs findable), 'unknown' means no creation record - it never says 'human', the honest word for any tab it did not create; a record that exists but won't read adds originUnreadable. An entry under an active, readable lease also carries a 'lease' object {label,pid,idleMs,expiresAt,stale}, with idleMs and expiresAt computed fresh every call (never off disk), independent of probe. Under CDP_REQUIRE_LEASE this call also reaps abandoned agent tabs into an additive reaped array - but a lease reads reclaimable the moment its TTL elapses, while the tab is CLOSED only after a further grace (CDP_REAP_GRACE_MS, default 2700000ms/45min, so 60min total after last use); a dead-pid lease is reaped at once, a tab with no lease never. With probe, each page-type entry also gets 'responsive' (and humanActiveMs where human input is seen); both are absent, never false/null, without probe.",
|
|
7
7
|
"params": {
|
|
8
8
|
"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.",
|
|
9
|
-
"all": "Include non-page targets (service/shared workers, background pages); default false lists page-type tabs only.",
|
|
9
|
+
"all": "Include non-page targets (service/shared workers, background pages, out-of-process (OOPIF) iframes); default false lists page-type tabs only. A returned non-page targetId is accepted as a bare id by any tool's `target` param.",
|
|
10
10
|
"probe": "Ping each page-type renderer before returning, one bounded 500ms check per target and never more, so a wedged tab can't stall the listing. Adds 'responsive' (false on timeout/exception/unreachable, never an error for the whole call) and, where the same round trip finds human input, humanActiveMs. Default false. On a backend that cannot answer the ping, responsive is absent too (not false)."
|
|
11
11
|
}
|
|
12
12
|
},
|
|
@@ -107,7 +107,7 @@ export const TOOL_DOCS: Record<string, ToolDoc> = {
|
|
|
107
107
|
}
|
|
108
108
|
},
|
|
109
109
|
"take_snapshot": {
|
|
110
|
-
"description": "Captures the page's accessibility tree as an indented text tree, each line prefixed [uid] (a CDP backendDOMNodeId). Every interaction tool (click/hover/fill/etc.) resolves that uid via DOM.resolveNode — run this first to discover uids to act on.",
|
|
110
|
+
"description": "Captures the page's accessibility tree as an indented text tree, each line prefixed [uid] (a CDP backendDOMNodeId). Every interaction tool (click/hover/fill/etc.) resolves that uid via DOM.resolveNode — run this first to discover uids to act on. Cross-origin iframe subtrees are not walked (they live in a separate renderer process); the iframe node itself is emitted with a frame=\"<targetId>\" marker (also in interactiveOnly mode), so the frame is discoverable and driveable via that id or frame:<url-substring>.",
|
|
111
111
|
"params": {
|
|
112
112
|
"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.",
|
|
113
113
|
"target": "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'. Grammar: see server instructions.",
|
package/src/types.ts
CHANGED
|
@@ -63,6 +63,19 @@ export interface CdpEvent<P = Record<string, unknown>> {
|
|
|
63
63
|
* listing, because a worker is not a page target — and a worker that is
|
|
64
64
|
* idle-evicted is in no listing at all, which is what `wake` exists for.
|
|
65
65
|
* See src/workers.ts and src/cdp/workers.ts.
|
|
66
|
+
* - "frame:<substring>" -> the out-of-process (cross-origin) IFRAME target
|
|
67
|
+
* whose url contains the substring (site isolation gives an OOPIF its own
|
|
68
|
+
* process and its own target, type "iframe"). Chrome-only, like "worker:",
|
|
69
|
+
* but accepted by EVERY target-taking tool except the four page-only ones
|
|
70
|
+
* (close/select/release/claim act on the embedder tab): the arm lives in
|
|
71
|
+
* pickTarget itself, the one resolver every Chrome tool's target flows
|
|
72
|
+
* through, so no per-tool wiring was needed or added. Reads the UNFILTERED
|
|
73
|
+
* listing; a SAME-ORIGIN iframe is not a target at all (it runs inside its
|
|
74
|
+
* parent page) and can only be reached through the parent. Refused with a
|
|
75
|
+
* capability error on Firefox (no iframe targets exist in BiDi's context
|
|
76
|
+
* model) and by the page-only resolvers. See src/frames.ts. A bare iframe
|
|
77
|
+
* targetId from list_pages{all:true} also still resolves via the bare-id
|
|
78
|
+
* arm below.
|
|
66
79
|
*/
|
|
67
80
|
export type TargetSelector = string | undefined;
|
|
68
81
|
|
package/src/version.ts
CHANGED