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/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
@@ -35,7 +35,7 @@ export const MANIFEST: ToolSpec[] = [
35
35
  },
36
36
  "all": {
37
37
  "type": "boolean",
38
- "description": "Include non-page targets."
38
+ "description": "Include non-page targets (workers, iframes)."
39
39
  },
40
40
  "probe": {
41
41
  "type": "boolean",
package/src/mcp.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * cdp-toolkit MCP server (stdio).
3
+ * cdp-toolkit MCP server (stdio by default, streamable-http on request).
4
4
  *
5
5
  * Exposes the toolkit's raw-CDP tools to any MCP client (Claude Code, etc.) over
6
- * the standard stdio transport. It does NOT connect to Chrome at startup: each
7
- * tool call lazily opens a single-target CDP connection (with its own timeout),
8
- * so the server loads cleanly even when Chrome isn't running; individual calls
9
- * then fail with a clear error if the browser is unreachable.
6
+ * the standard stdio transport, or — with `--transport streamable-http` — over
7
+ * an HTTP/SSE listener served by Bun. It does NOT connect to Chrome at startup:
8
+ * each tool call lazily opens a single-target CDP connection (with its own
9
+ * timeout), so the server loads cleanly even when Chrome isn't running;
10
+ * individual calls then fail with a clear error if the browser is unreachable.
11
+ *
12
+ * Importing this module is side-effect-free: the server starts only when the
13
+ * file is the directly executed entry (bin invocation or `bun run src/mcp.ts`),
14
+ * never when imported (see isDirectRun below).
10
15
  *
11
16
  * Launch: `bunx -y cdp-toolkit` (or `bun run src/mcp.ts` from a checkout)
17
+ * HTTP: `bun run src/mcp.ts --transport streamable-http [--port 3000] [--host 127.0.0.1]`
12
18
  * Config: CDP_BASE (default http://127.0.0.1:9222), CDP_TIMEOUT_MS, CDP_ARTIFACT_DIR,
13
19
  * CDP_TOOL_PROFILE (`full` — the default — advertises every group; `core` advertises
14
20
  * just the 12 everyday tools; or a comma-separated group list, e.g. `core,network`.
@@ -21,6 +27,15 @@
21
27
  import { Server } from "@modelcontextprotocol/server";
22
28
  import type { Tool } from "@modelcontextprotocol/server";
23
29
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
30
+ import {
31
+ createMcpHandler,
32
+ hostHeaderValidationResponse,
33
+ localhostAllowedHostnames,
34
+ localhostAllowedOrigins,
35
+ originValidationResponse,
36
+ } from "@modelcontextprotocol/server";
37
+ import { realpathSync } from "node:fs";
38
+ import { fileURLToPath } from "node:url";
24
39
  import { TOOLS, TOOL_NAMES, BASE } from "./index.ts";
25
40
  import { MANIFEST } from "./manifest.ts";
26
41
  import { resolveBackend, getOrCreateFirefoxSession, disposeFirefoxSession } from "./backend.ts";
@@ -32,6 +47,84 @@ import { resolveProfile, TOOL_GROUP, TOOL_GROUPS, GROUP_TOOLS } from "./toolGrou
32
47
  import { TOOL_DOCS } from "./toolDocs.ts";
33
48
  import { VERSION } from "./version.ts";
34
49
 
50
+ // Bun.serve is the HTTP transport's listener: a Bun-only global, typed here (not via bun-types,
51
+ // which CONTRACT.md keeps out of the dependency set) in the narrow shape this file uses. The code
52
+ // below never touches it unless --transport streamable-http was requested, and checks
53
+ // `typeof Bun === "undefined"` first so running under plain node fails with a clear message
54
+ // instead of a ReferenceError. The declaration emits nothing — at runtime the identifier resolves
55
+ // to the real global when one exists.
56
+ declare const Bun: {
57
+ serve(opts: {
58
+ port: number;
59
+ hostname: string;
60
+ fetch(req: Request): Promise<Response> | Response;
61
+ }): { stop(): void; port: number };
62
+ };
63
+
64
+ /**
65
+ * True when this module is the process's entry script — the ONLY condition under which the
66
+ * server starts. Works under both node and bun, ESM included: compare this module's real path
67
+ * with the realpath of argv[1]. realpathSync resolves the bunx/npm bin symlink (argv[1] is the
68
+ * symlink, the module URL is its target) and any /tmp-style symlinked directory; when argv[1] is
69
+ * absent (e.g. `node -e`/REPL importing this file) the answer is simply "not direct". A plain
70
+ * `import(".../mcp.js")` from another module leaves argv[1] pointing at that other entry, so the
71
+ * import stays silent.
72
+ */
73
+ function isDirectRun(): boolean {
74
+ const entry = process.argv[1];
75
+ if (!entry) return false;
76
+ try {
77
+ return fileURLToPath(import.meta.url) === realpathSync(entry);
78
+ } catch {
79
+ // argv[1] may not exist as a file (eval strings, wrappers); never treat that as direct.
80
+ return false;
81
+ }
82
+ }
83
+
84
+ /** What --transport/--port/--host asked for. stdio is the default and stays byte-for-byte unchanged. */
85
+ interface ServeOptions {
86
+ transport: "stdio" | "streamable-http";
87
+ port: number;
88
+ host: string;
89
+ }
90
+
91
+ /**
92
+ * Parse the serving flags from the same argv resolveBackend reads (it scans only --browser and
93
+ * --connect, so these extra tokens were already invisible to it). An unknown --transport value or
94
+ * a non-port --port is a hard configuration error, mirroring resolveBackend's loud failures.
95
+ */
96
+ function parseServeOptions(argv: readonly string[]): ServeOptions {
97
+ const out: ServeOptions = { transport: "stdio", port: 3000, host: "127.0.0.1" };
98
+ const transportIdx = argv.indexOf("--transport");
99
+ if (transportIdx !== -1) {
100
+ const value = argv[transportIdx + 1];
101
+ if (value !== "stdio" && value !== "streamable-http") {
102
+ throw new Error(`unknown --transport '${value ?? ""}': expected 'stdio' or 'streamable-http'`);
103
+ }
104
+ out.transport = value;
105
+ }
106
+ const portIdx = argv.indexOf("--port");
107
+ if (portIdx !== -1) {
108
+ const raw = argv[portIdx + 1];
109
+ const port = Number(raw);
110
+ // 0 is valid HERE (unlike backend.ts's dial-out endpoints): for a listener it means "let the
111
+ // OS pick a free port", and the resolved port is then announced on stderr (see run()).
112
+ if (raw === undefined || !/^\d+$/.test(raw) || port < 0 || port > 65_535) {
113
+ throw new Error(`invalid --port '${raw ?? ""}': expected an integer 0-65535`);
114
+ }
115
+ out.port = port;
116
+ }
117
+ const hostIdx = argv.indexOf("--host");
118
+ if (hostIdx !== -1) {
119
+ const raw = argv[hostIdx + 1];
120
+ if (raw === undefined || raw === "") {
121
+ throw new Error(`invalid --host '${raw ?? ""}': expected a hostname or address`);
122
+ }
123
+ out.host = raw;
124
+ }
125
+ return out;
126
+ }
127
+
35
128
  // Backend + (Firefox only) attach endpoint are read once at startup (MCP has no per-call notion
36
129
  // of backend): --browser flag / CDP_BROWSER env, else "chrome" (zero behavior change for existing
37
130
  // users/configs); --connect flag / CDP_FIREFOX_ENDPOINT env selects Firefox ATTACH mode and
@@ -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, from the origin ledger or a live lease) | a 32-hex '<targetId>'. 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 extension's background worker (worker:<extension-id> or worker:background.js); an idle-evicted worker is started first (see each tool's `wake`).",
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. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all.",
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
- auditCoverage();
240
- // This process is the long-lived MCP server, which is what makes strict mode
241
- // (CDP_REQUIRE_LEASE) safe to honor here and unsafe in cli.ts. See requireLease.
242
- markLongLivedProcess();
332
+ /**
333
+ * Start serving: stdio (the default) or the streamable-http listener. Only ever called from the
334
+ * isDirectRun guard below — importing this module must stay silent.
335
+ */
336
+ async function run(argv: string[]): Promise<void> {
337
+ const serve = parseServeOptions(argv);
338
+ auditCoverage();
339
+ // This process is the long-lived MCP server, which is what makes strict mode
340
+ // (CDP_REQUIRE_LEASE) safe to honor here and unsafe in cli.ts. See requireLease.
341
+ markLongLivedProcess();
243
342
 
244
- // serveStdio owns the era decision for the connection: a modern opening (server/discover
245
- // with the per-request _meta envelope) pins a 2026-07-28 instance, a 2025-era `initialize`
246
- // pins a legacy one. legacy:'serve' (the default) is deliberate — 2025-era clients keep the
247
- // initialize handshake they expect, so this upgrade is invisible to them.
248
- const handle = serveStdio(
249
- ({ era }) => {
250
- console.error(`[cdp-toolkit] connection pinned to the ${era} protocol era`);
251
- return buildServer();
252
- },
253
- { onerror: (err) => console.error(`[cdp-toolkit] stdio: ${err.message}`) },
254
- );
255
- console.error(
256
- `[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools available, ${LISTING.length} listed (CDP_TOOL_PROFILE=${PROFILE.label}), CDP_BASE=${BASE}`,
257
- );
343
+ // Per-transport state the shutdown path closes (only one is ever set).
344
+ let stdioHandle: Awaited<ReturnType<typeof serveStdio>> | undefined;
345
+ let httpHandler: ReturnType<typeof createMcpHandler> | undefined;
346
+ let listener: { stop(): void; port: number } | undefined;
347
+
348
+ // LAUNCH-mode Firefox owns a real OS process (see bidi/launch.ts): it must be reaped on every
349
+ // shutdown path, not just a clean exit. ATTACH-mode Firefox (CDP_FIREFOX_ENDPOINT / --connect)
350
+ // owns no process here — disposeFirefoxSession() below only ends its BiDi session (freeing
351
+ // Firefox's single session slot) and closes the socket, leaving the user's browser running.
352
+ // SIGINT/SIGTERM cover ctrl-C and a supervising client killing the server; in stdio mode the
353
+ // stdin 'close' event covers the normal MCP shutdown (the client closes the pipe). All are
354
+ // idempotent through disposeFirefoxSession(), and a no-op entirely when Firefox was never
355
+ // launched/attached.
356
+ let shuttingDown = false;
357
+ async function shutdown(): Promise<void> {
358
+ if (shuttingDown) return;
359
+ shuttingDown = true;
360
+ await disposeFirefoxSession();
361
+ // The standing browser-endpoint connection behind wait_for_download / grant_permissions (1.8.0
362
+ // Track P3). Unlike Firefox it owns no OS process, so process.exit would collect it anyway; it is
363
+ // closed explicitly so shutdown does not depend on that, and is a no-op when neither tool ran.
364
+ await disposeBrowserSession();
365
+ // Close the active transport last, so a tool call still in flight over it cannot outlive the
366
+ // browser resources it was driving. For http this also stops accepting new connections.
367
+ if (stdioHandle) await stdioHandle.close().catch(() => undefined);
368
+ if (httpHandler) await httpHandler.close().catch(() => undefined);
369
+ if (listener) listener.stop();
370
+ process.exit(0);
371
+ }
372
+ process.on("SIGINT", () => void shutdown());
373
+ process.on("SIGTERM", () => void shutdown());
258
374
 
259
- // LAUNCH-mode Firefox owns a real OS process (see bidi/launch.ts): it must be reaped on every
260
- // shutdown path, not just a clean exit. ATTACH-mode Firefox (CDP_FIREFOX_ENDPOINT / --connect)
261
- // owns no process here — disposeFirefoxSession() below only ends its BiDi session (freeing
262
- // Firefox's single session slot) and closes the socket, leaving the user's browser running.
263
- // SIGINT/SIGTERM cover ctrl-C and a supervising client killing the server; stdin 'close' covers
264
- // the normal MCP shutdown (the client closes the stdio pipe). All three are idempotent through
265
- // disposeFirefoxSession(), and a no-op entirely when Firefox was never launched/attached.
266
- let shuttingDown = false;
267
- async function shutdown(): Promise<void> {
268
- if (shuttingDown) return;
269
- shuttingDown = true;
270
- await disposeFirefoxSession();
271
- // The standing browser-endpoint connection behind wait_for_download / grant_permissions (1.8.0
272
- // Track P3). Unlike Firefox it owns no OS process, so process.exit would collect it anyway; it is
273
- // closed explicitly so shutdown does not depend on that, and is a no-op when neither tool ran.
274
- await disposeBrowserSession();
275
- // Close the stdio connection (pinned instance + transport) last, so a tool call still
276
- // in flight over it cannot outlive the browser resources it was driving.
277
- await handle.close().catch(() => undefined);
278
- process.exit(0);
375
+ if (serve.transport === "stdio") {
376
+ // serveStdio owns the era decision for the connection: a modern opening (server/discover
377
+ // with the per-request _meta envelope) pins a 2026-07-28 instance, a 2025-era `initialize`
378
+ // pins a legacy one. legacy:'serve' (the default) is deliberate — 2025-era clients keep the
379
+ // initialize handshake they expect, so this upgrade is invisible to them.
380
+ stdioHandle = serveStdio(
381
+ ({ era }) => {
382
+ console.error(`[cdp-toolkit] connection pinned to the ${era} protocol era`);
383
+ return buildServer();
384
+ },
385
+ { onerror: (err) => console.error(`[cdp-toolkit] stdio: ${err.message}`) },
386
+ );
387
+ process.stdin.on("close", () => void shutdown());
388
+ console.error(
389
+ `[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools available, ${LISTING.length} listed (CDP_TOOL_PROFILE=${PROFILE.label}), CDP_BASE=${BASE}`,
390
+ );
391
+ return;
392
+ }
393
+
394
+ // streamable-http: Bun.serve + the SDK's web-standard handler. DNS-rebinding protection comes
395
+ // from the SDK's host/origin validators composed in front (each returns an error Response for a
396
+ // disallowed request, undefined to fall through); the localhost allowlists match the default
397
+ // loopback bind. Everything diagnostic goes to stderr — stdout stays the stdio JSON-RPC channel
398
+ // only, and even here it must carry nothing (an http client never reads it).
399
+ if (typeof Bun === "undefined") {
400
+ console.error(
401
+ "[cdp-toolkit] --transport streamable-http needs Bun's HTTP listener (Bun.serve). Re-run with bun, or use the default stdio transport.",
402
+ );
403
+ process.exit(1);
404
+ }
405
+ httpHandler = createMcpHandler(
406
+ ({ era }) => {
407
+ console.error(`[cdp-toolkit] connection pinned to the ${era} protocol era`);
408
+ return buildServer();
409
+ },
410
+ { onerror: (err) => console.error(`[cdp-toolkit] http: ${err.message}`) },
411
+ );
412
+ listener = Bun.serve({
413
+ port: serve.port,
414
+ hostname: serve.host,
415
+ fetch: (req) =>
416
+ hostHeaderValidationResponse(req, localhostAllowedHostnames()) ??
417
+ originValidationResponse(req, localhostAllowedOrigins()) ??
418
+ httpHandler!.fetch(req),
419
+ });
420
+ // The resolved port is announced as a bare `port N` token BEFORE the URL text, so a stderr
421
+ // scanner finds the real listening port first (matters under --port 0, where N is the
422
+ // OS-assigned one and the only place it is knowable).
423
+ console.error(
424
+ `[cdp-toolkit] MCP server v${VERSION} listening on streamable-http port ${listener.port} (http://${serve.host}:${listener.port}/), browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools available, ${LISTING.length} listed (CDP_TOOL_PROFILE=${PROFILE.label}), CDP_BASE=${BASE}`,
425
+ );
426
+ }
427
+
428
+ if (isDirectRun()) {
429
+ run(process.argv.slice(2)).catch((err: unknown) => {
430
+ console.error(`[cdp-toolkit] ${err instanceof Error ? err.message : String(err)}`);
431
+ process.exit(1);
432
+ });
279
433
  }
280
- process.on("SIGINT", () => void shutdown());
281
- process.on("SIGTERM", () => void shutdown());
282
- process.stdin.on("close", () => void shutdown());
@@ -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
@@ -6,4 +6,4 @@
6
6
  * ready line; keeping it in its own module means a release bump touches one
7
7
  * line here plus package.json, not a const buried in the server.
8
8
  */
9
- export const VERSION = "2.5.1";
9
+ export const VERSION = "2.7.0";