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/dist/mcp.js CHANGED
@@ -473,6 +473,37 @@ var init_workers = __esm(() => {
473
473
  WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless). The tools that accept a worker: target are " + `${WORKER_CAPABLE_TOOLS.join(", ")}.`;
474
474
  });
475
475
 
476
+ // src/frames.ts
477
+ function isFrameSelector(selector) {
478
+ return typeof selector === "string" && selector.startsWith(FRAME_SELECTOR_PREFIX);
479
+ }
480
+ function frameNeedle(selector) {
481
+ return selector.slice(FRAME_SELECTOR_PREFIX.length);
482
+ }
483
+ function isFrameTargetType(type) {
484
+ return type === FRAME_TARGET_TYPE;
485
+ }
486
+ function resolveFrameTargets(targets, needle) {
487
+ const liveFrames = targets.filter((t) => isFrameTargetType(t.type));
488
+ return {
489
+ matches: needle === "" ? [] : liveFrames.filter((t) => t.url.includes(needle)),
490
+ liveFrames
491
+ };
492
+ }
493
+ function frameAmbiguityMessage(needle, matches) {
494
+ return `'frame:${needle}' matches more than one out-of-process iframe: ` + matches.map((m) => `${m.url} (${m.id})`).join(", ") + ". Narrow the substring, or pass the iframe's target id directly.";
495
+ }
496
+ function frameMissMessage(needle, liveFrames) {
497
+ const live = liveFrames.length ? ` (out-of-process iframes: ${liveFrames.map((f) => f.url).join(", ")})` : " (no out-of-process iframes are live)";
498
+ return `no out-of-process iframe whose url contains '${needle}'${live}. ` + "Only a CROSS-ORIGIN iframe gets its own process and therefore its own target; a same-origin iframe " + "runs inside its parent page and is NOT a target at all, so no substring can ever select it — reach a " + "same-origin iframe through its parent page instead (evaluate_script on the page, then the iframe " + "element's contentDocument). An out-of-process iframe whose document navigated away is likewise gone " + "from the listing: re-list with list_pages{all:true} and pass the new target id.";
499
+ }
500
+ var FRAME_SELECTOR_PREFIX = "frame:", FRAME_PAGE_ONLY_TOOLS, FRAME_TARGET_TYPE = "iframe", FRAME_EMPTY_NEEDLE_MESSAGE = "'frame:' needs a substring of the iframe's url, e.g. frame:ads.example.com or frame:player.html", FRAME_SELECTOR_UNSUPPORTED_MESSAGE, FRAME_SELECTOR_PAGE_ONLY_MESSAGE;
501
+ var init_frames = __esm(() => {
502
+ FRAME_PAGE_ONLY_TOOLS = ["close_page", "select_page", "release_page", "claim_page"];
503
+ FRAME_SELECTOR_UNSUPPORTED_MESSAGE = "target 'frame:<substring>' selects an out-of-process iframe, which this backend cannot address " + "(WebDriver BiDi has no iframe target — an iframe is a realm inside its tab's browsing context, not a " + "selectable target; Capability 'frame.targets' is Chrome-only). Use --browser chrome to act on a cross-origin iframe.";
504
+ FRAME_SELECTOR_PAGE_ONLY_MESSAGE = "target 'frame:<substring>' selects an out-of-process iframe, and this tool is page-only " + "(closing, selecting, releasing or claiming an iframe is meaningless; these act on the embedder tab). " + `The page-only tools are ${FRAME_PAGE_ONLY_TOOLS.join(", ")}; every other tool that takes a 'target' ` + "accepts a frame: selector.";
505
+ });
506
+
476
507
  // src/client.ts
477
508
  class CdpConnection {
478
509
  wsUrl;
@@ -671,6 +702,17 @@ async function pickTarget(targets, selector) {
671
702
  return matches[0];
672
703
  throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
673
704
  }
705
+ if (isFrameSelector(selector)) {
706
+ const needle = frameNeedle(selector);
707
+ if (needle === "")
708
+ throw new CdpError(FRAME_EMPTY_NEEDLE_MESSAGE);
709
+ const { matches, liveFrames } = resolveFrameTargets(targets, needle);
710
+ if (matches.length > 1)
711
+ throw new CdpError(frameAmbiguityMessage(needle, matches));
712
+ if (matches.length === 1)
713
+ return matches[0];
714
+ throw new CdpError(frameMissMessage(needle, liveFrames));
715
+ }
674
716
  const byId = targets.find((t) => t.id === selector);
675
717
  if (!byId)
676
718
  throw new CdpError(`no target with id '${selector}'`);
@@ -697,6 +739,7 @@ var init_client = __esm(() => {
697
739
  init_leases();
698
740
  init_origins();
699
741
  init_workers();
742
+ init_frames();
700
743
  BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
701
744
  DEFAULT_TIMEOUT_MS = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
702
745
  CdpError = class CdpError extends Error {
@@ -2844,6 +2887,9 @@ async function pickContext(conn, contexts, selector) {
2844
2887
  if (isWorkerSelector(selector)) {
2845
2888
  throw driverError2("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
2846
2889
  }
2890
+ if (isFrameSelector(selector)) {
2891
+ throw driverError2("unsupported", FRAME_SELECTOR_UNSUPPORTED_MESSAGE);
2892
+ }
2847
2893
  const exact = contexts.find((c) => c.context === selector);
2848
2894
  if (exact)
2849
2895
  return exact;
@@ -3055,6 +3101,7 @@ var init_driver2 = __esm(() => {
3055
3101
  init_leases();
3056
3102
  init_origins();
3057
3103
  init_workers();
3104
+ init_frames();
3058
3105
  init_activity();
3059
3106
  init_client2();
3060
3107
  init_session_lease();
@@ -3697,6 +3744,15 @@ var init_driver2 = __esm(() => {
3697
3744
  // src/mcp.ts
3698
3745
  import { Server } from "@modelcontextprotocol/server";
3699
3746
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
3747
+ import {
3748
+ createMcpHandler,
3749
+ hostHeaderValidationResponse,
3750
+ localhostAllowedHostnames,
3751
+ localhostAllowedOrigins,
3752
+ originValidationResponse
3753
+ } from "@modelcontextprotocol/server";
3754
+ import { realpathSync } from "node:fs";
3755
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
3700
3756
 
3701
3757
  // src/cdp/driver.ts
3702
3758
  init_client();
@@ -4166,7 +4222,8 @@ var CDP_CAPABILITIES = new Set([
4166
4222
  "emulate.focus",
4167
4223
  "browser.downloads",
4168
4224
  "browser.permissions",
4169
- "worker.targets"
4225
+ "worker.targets",
4226
+ "frame.targets"
4170
4227
  ]);
4171
4228
 
4172
4229
  class CdpPageDriver {
@@ -4299,6 +4356,7 @@ class CdpPageDriver {
4299
4356
  const interactiveOnly = opts?.interactiveOnly ?? false;
4300
4357
  await this.ensureDomain("Accessibility");
4301
4358
  const { nodes } = await this.conn.send("Accessibility.getFullAXTree");
4359
+ const oopifFrames = await this.detectOopifFrames();
4302
4360
  const byId = new Map;
4303
4361
  for (const n of nodes)
4304
4362
  byId.set(n.nodeId, n);
@@ -4307,11 +4365,17 @@ class CdpPageDriver {
4307
4365
  const walk = (node, depth) => {
4308
4366
  const role = axString(node.role);
4309
4367
  const name = axString(node.name);
4310
- const backendNodeId = node.backendDOMNodeId;
4311
- const emitted = !node.ignored && backendNodeId !== undefined && (interactiveOnly ? isInteractive(role) : !!(role || name));
4368
+ const backendDOMNodeId = node.backendDOMNodeId;
4369
+ const marker = backendDOMNodeId !== undefined ? oopifFrames.get(backendDOMNodeId) : undefined;
4370
+ const emitted = !node.ignored && backendDOMNodeId !== undefined && (interactiveOnly ? isInteractive(role) || marker !== undefined : !!(role || name));
4312
4371
  if (emitted) {
4313
4372
  const extras = axExtras(node);
4314
- out.push({ uid: encodeUid(backendNodeId), role: role ?? "generic", depth, ...name ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} });
4373
+ if (marker) {
4374
+ extras.frame = marker.frameId;
4375
+ if (marker.frameUrl)
4376
+ extras.frameUrl = marker.frameUrl;
4377
+ }
4378
+ out.push({ uid: encodeUid(backendDOMNodeId), role: role ?? "generic", depth, ...name ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} });
4315
4379
  }
4316
4380
  const childDepth = emitted && !interactiveOnly ? depth + 1 : depth;
4317
4381
  for (const childId of node.childIds ?? []) {
@@ -4324,6 +4388,20 @@ class CdpPageDriver {
4324
4388
  walk(root, 0);
4325
4389
  return out;
4326
4390
  }
4391
+ async detectOopifFrames() {
4392
+ const markers = new Map;
4393
+ try {
4394
+ await this.ensureDomainSoft("DOM");
4395
+ const iframeTargets = (await listTargets()).filter((t) => t.type === "iframe");
4396
+ for (const target of iframeTargets) {
4397
+ const owner = await this.conn.send("DOM.getFrameOwner", { frameId: target.id }).catch(() => ({ backendNodeId: undefined }));
4398
+ if (typeof owner.backendNodeId === "number") {
4399
+ markers.set(owner.backendNodeId, { frameId: target.id, ...target.url ? { frameUrl: target.url } : {} });
4400
+ }
4401
+ }
4402
+ } catch {}
4403
+ return markers;
4404
+ }
4327
4405
  async locate(loc) {
4328
4406
  return encodeUid(await backendNodeIdOf(this.conn, loc));
4329
4407
  }
@@ -5501,6 +5579,7 @@ init_leases();
5501
5579
  init_activity();
5502
5580
  init_origins();
5503
5581
  init_workers();
5582
+ init_frames();
5504
5583
  var ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
5505
5584
  var STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
5506
5585
 
@@ -5557,6 +5636,9 @@ async function pickPage(driver, pages, selector) {
5557
5636
  if (isWorkerSelector(selector)) {
5558
5637
  throw new SharedToolError(backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE);
5559
5638
  }
5639
+ if (isFrameSelector(selector)) {
5640
+ throw new SharedToolError(backendOf(driver) === "firefox" ? FRAME_SELECTOR_UNSUPPORTED_MESSAGE : FRAME_SELECTOR_PAGE_ONLY_MESSAGE);
5641
+ }
5560
5642
  const exactPage = pages.find((x) => x.id === selector);
5561
5643
  if (exactPage)
5562
5644
  return exactPage;
@@ -8396,7 +8478,7 @@ var MANIFEST = [
8396
8478
  },
8397
8479
  all: {
8398
8480
  type: "boolean",
8399
- description: "Include non-page targets."
8481
+ description: "Include non-page targets (workers, iframes)."
8400
8482
  },
8401
8483
  probe: {
8402
8484
  type: "boolean",
@@ -10769,10 +10851,10 @@ function resolveProfile(spec) {
10769
10851
  // src/toolDocs.ts
10770
10852
  var TOOL_DOCS = {
10771
10853
  list_pages: {
10772
- 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.",
10854
+ 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.",
10773
10855
  params: {
10774
10856
  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.",
10775
- all: "Include non-page targets (service/shared workers, background pages); default false lists page-type tabs only.",
10857
+ 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.",
10776
10858
  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)."
10777
10859
  }
10778
10860
  },
@@ -10873,7 +10955,7 @@ var TOOL_DOCS = {
10873
10955
  }
10874
10956
  },
10875
10957
  take_snapshot: {
10876
- 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.",
10958
+ 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>.`,
10877
10959
  params: {
10878
10960
  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.",
10879
10961
  target: "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'. Grammar: see server instructions.",
@@ -11271,9 +11353,48 @@ var TOOL_DOCS = {
11271
11353
  };
11272
11354
 
11273
11355
  // src/version.ts
11274
- var VERSION = "2.5.1";
11356
+ var VERSION = "2.7.0";
11275
11357
 
11276
11358
  // src/mcp.ts
11359
+ function isDirectRun() {
11360
+ const entry = process.argv[1];
11361
+ if (!entry)
11362
+ return false;
11363
+ try {
11364
+ return fileURLToPath2(import.meta.url) === realpathSync(entry);
11365
+ } catch {
11366
+ return false;
11367
+ }
11368
+ }
11369
+ function parseServeOptions(argv) {
11370
+ const out = { transport: "stdio", port: 3000, host: "127.0.0.1" };
11371
+ const transportIdx = argv.indexOf("--transport");
11372
+ if (transportIdx !== -1) {
11373
+ const value = argv[transportIdx + 1];
11374
+ if (value !== "stdio" && value !== "streamable-http") {
11375
+ throw new Error(`unknown --transport '${value ?? ""}': expected 'stdio' or 'streamable-http'`);
11376
+ }
11377
+ out.transport = value;
11378
+ }
11379
+ const portIdx = argv.indexOf("--port");
11380
+ if (portIdx !== -1) {
11381
+ const raw = argv[portIdx + 1];
11382
+ const port = Number(raw);
11383
+ if (raw === undefined || !/^\d+$/.test(raw) || port < 0 || port > 65535) {
11384
+ throw new Error(`invalid --port '${raw ?? ""}': expected an integer 0-65535`);
11385
+ }
11386
+ out.port = port;
11387
+ }
11388
+ const hostIdx = argv.indexOf("--host");
11389
+ if (hostIdx !== -1) {
11390
+ const raw = argv[hostIdx + 1];
11391
+ if (raw === undefined || raw === "") {
11392
+ throw new Error(`invalid --host '${raw ?? ""}': expected a hostname or address`);
11393
+ }
11394
+ out.host = raw;
11395
+ }
11396
+ return out;
11397
+ }
11277
11398
  var { kind: BROWSER, endpoint: FIREFOX_ENDPOINT } = resolveBackend(process.argv.slice(2));
11278
11399
  var AVAILABILITY = toolAvailability(BROWSER);
11279
11400
  var AVAILABLE_NAMES = new Set(AVAILABILITY.available);
@@ -11292,8 +11413,8 @@ function auditCoverage() {
11292
11413
  var INSTRUCTIONS = [
11293
11414
  "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:",
11294
11415
  "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.",
11295
- "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`).",
11296
- "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.",
11416
+ "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`).",
11417
+ "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.",
11297
11418
  "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."
11298
11419
  ].join(`
11299
11420
 
@@ -11407,25 +11528,64 @@ function buildServer() {
11407
11528
  });
11408
11529
  return server;
11409
11530
  }
11410
- auditCoverage();
11411
- markLongLivedProcess();
11412
- var handle = serveStdio(({ era }) => {
11413
- console.error(`[cdp-toolkit] connection pinned to the ${era} protocol era`);
11414
- return buildServer();
11415
- }, { onerror: (err) => console.error(`[cdp-toolkit] stdio: ${err.message}`) });
11416
- 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}`);
11417
- var shuttingDown = false;
11418
- async function shutdown() {
11419
- if (shuttingDown)
11420
- return;
11421
- shuttingDown = true;
11422
- await disposeFirefoxSession();
11423
- await disposeBrowserSession();
11424
- await handle.close().catch(() => {
11531
+ async function run2(argv) {
11532
+ const serve = parseServeOptions(argv);
11533
+ auditCoverage();
11534
+ markLongLivedProcess();
11535
+ let stdioHandle;
11536
+ let httpHandler;
11537
+ let listener;
11538
+ let shuttingDown = false;
11539
+ async function shutdown() {
11540
+ if (shuttingDown)
11541
+ return;
11542
+ shuttingDown = true;
11543
+ await disposeFirefoxSession();
11544
+ await disposeBrowserSession();
11545
+ if (stdioHandle)
11546
+ await stdioHandle.close().catch(() => {
11547
+ return;
11548
+ });
11549
+ if (httpHandler)
11550
+ await httpHandler.close().catch(() => {
11551
+ return;
11552
+ });
11553
+ if (listener)
11554
+ listener.stop();
11555
+ process.exit(0);
11556
+ }
11557
+ process.on("SIGINT", () => void shutdown());
11558
+ process.on("SIGTERM", () => void shutdown());
11559
+ if (serve.transport === "stdio") {
11560
+ stdioHandle = serveStdio(({ era }) => {
11561
+ console.error(`[cdp-toolkit] connection pinned to the ${era} protocol era`);
11562
+ return buildServer();
11563
+ }, { onerror: (err) => console.error(`[cdp-toolkit] stdio: ${err.message}`) });
11564
+ process.stdin.on("close", () => void shutdown());
11565
+ console.error(`[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools available, ${LISTING.length} listed (CDP_TOOL_PROFILE=${PROFILE.label}), CDP_BASE=${BASE}`);
11425
11566
  return;
11567
+ }
11568
+ if (typeof Bun === "undefined") {
11569
+ console.error("[cdp-toolkit] --transport streamable-http needs Bun's HTTP listener (Bun.serve). Re-run with bun, or use the default stdio transport.");
11570
+ process.exit(1);
11571
+ }
11572
+ httpHandler = createMcpHandler(({ era }) => {
11573
+ console.error(`[cdp-toolkit] connection pinned to the ${era} protocol era`);
11574
+ return buildServer();
11575
+ }, { onerror: (err) => console.error(`[cdp-toolkit] http: ${err.message}`) });
11576
+ listener = Bun.serve({
11577
+ port: serve.port,
11578
+ hostname: serve.host,
11579
+ fetch: (req) => hostHeaderValidationResponse(req, localhostAllowedHostnames()) ?? originValidationResponse(req, localhostAllowedOrigins()) ?? httpHandler.fetch(req)
11580
+ });
11581
+ 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}`);
11582
+ }
11583
+ if (isDirectRun()) {
11584
+ run2(process.argv.slice(2)).catch((err) => {
11585
+ console.error(`[cdp-toolkit] ${err instanceof Error ? err.message : String(err)}`);
11586
+ process.exit(1);
11426
11587
  });
11427
- process.exit(0);
11428
11588
  }
11429
- process.on("SIGINT", () => void shutdown());
11430
- process.on("SIGTERM", () => void shutdown());
11431
- process.stdin.on("close", () => void shutdown());
11589
+ export {
11590
+ buildServer
11591
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-toolkit",
3
- "version": "2.5.1",
3
+ "version": "2.7.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",
@@ -46,6 +46,7 @@ import {
46
46
  import { assertLeaseOk, defaultLabel, isPidAlive } from "../leases.ts";
47
47
  import { resolveLiveLabel } from "../origins.ts";
48
48
  import { isWorkerSelector, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "../workers.ts";
49
+ import { FRAME_SELECTOR_UNSUPPORTED_MESSAGE, isFrameSelector } from "../frames.ts";
49
50
  import { BEACON_FUNCTION_DECLARATION, BEACON_READ_EXPRESSION, BEACON_SOURCE, recordDispatch } from "../activity.ts";
50
51
  import { BidiConnection, BidiError, connectBidiSessionUrl } from "./client.ts";
51
52
  import {
@@ -845,6 +846,13 @@ async function pickContext(
845
846
  // "no context matching 'worker:abc'" that reads like a typo.
846
847
  throw driverError("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
847
848
  }
849
+ if (isFrameSelector(selector)) {
850
+ // Capability-gated for the same reason as the worker: arm above. WebDriver
851
+ // BiDi has no iframe TARGET to resolve to — an iframe is a realm inside its
852
+ // tab's browsing context — so the honest error names the capability
853
+ // ('frame.targets', Chrome-only) rather than retrying or reading as a typo.
854
+ throw driverError("unsupported", FRAME_SELECTOR_UNSUPPORTED_MESSAGE);
855
+ }
848
856
  const exact = contexts.find((c) => c.context === selector);
849
857
  if (exact) return exact;
850
858
  throw driverError("no-such-target", `no context matching '${selector}'`);
package/src/cdp/driver.ts CHANGED
@@ -652,7 +652,7 @@ const CDP_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
652
652
  "screenshot.fullPage", "screenshot.element", "screenshot.scale", "screenshot.renderSize", "screenshot.tile", "network.intercept",
653
653
  "snapshot.accessibilityTree", "input.insertTextAtomic", "locate.text", "locate.xpath",
654
654
  "capture.screencast", "input.raw", "input.html5Drag", "emulate.focus",
655
- "browser.downloads", "browser.permissions", "worker.targets",
655
+ "browser.downloads", "browser.permissions", "worker.targets", "frame.targets",
656
656
  ]);
657
657
  /* ---------------------------------- PageDriver ---------------------------------- */
658
658
  class CdpPageDriver implements PageDriver {
@@ -820,10 +820,36 @@ class CdpPageDriver implements PageDriver {
820
820
  const { objectId } = await resolveElementLocator(this.conn, loc);
821
821
  return this.callFn(objectId, functionDeclaration, args, true);
822
822
  }
823
+ /**
824
+ * OOPIF MARKERS (issue #7 fix 1): which emitted snapshot nodes own an out-of-process iframe.
825
+ *
826
+ * Chrome's Accessibility.getFullAXTree includes the <iframe> ELEMENT's AX node (role "Iframe",
827
+ * with a backendDOMNodeId) but not the subtree of an out-of-process iframe (OOPIF): that subtree
828
+ * lives in a separate renderer/AX tree, so its contents are simply absent and a caller reading
829
+ * the snapshot has no signal that an entire interactable document ends at that line. Same-origin
830
+ * iframes ARE merged into the one response, so they need no marker — only process-isolated
831
+ * frames do, and process isolation is decided by Chrome, not by any origin heuristic here.
832
+ *
833
+ * Truth source: the browser's target list (`/json/list`) names every OOPIF as its own target of
834
+ * type "iframe", and an OOPIF's target id IS its frame id. `Page.getFrameTree` is deliberately
835
+ * NOT used here: on a page-level websocket attachment it omits out-of-process child frames
836
+ * (observed Chrome 153: same DOM with the <iframe>, frame tree with no childFrames), which is
837
+ * exactly the set this marker exists for. DOM.getFrameOwner maps each candidate frame id to the
838
+ * backendNodeId of the owning <iframe> element — the node snapshot() is about to emit; it throws
839
+ * for frame ids this page doesn't own, which is the filter.
840
+ *
841
+ * The marker rides the node's existing `extras` map ("frame" = frame/target id, "frameUrl" when
842
+ * the frame tree reported one) because SnapshotNode is owned by ../driver.ts and shared with the
843
+ * BiDi driver; renderSnapshotLine renders unknown extras keys quoted, which is the desired shape
844
+ * (`[frame="A1B2..."]`). Everything here is failure-soft by design: a snapshot must never fail
845
+ * because marker detection did, so any error returns an empty map and the snapshot degrades to
846
+ * exactly its pre-marker output.
847
+ */
823
848
  async snapshot(opts?: { interactiveOnly?: boolean }): Promise<SnapshotNode[]> {
824
849
  const interactiveOnly = opts?.interactiveOnly ?? false;
825
850
  await this.ensureDomain("Accessibility");
826
851
  const { nodes } = await this.conn.send<{ nodes: AxNode[] }>("Accessibility.getFullAXTree");
852
+ const oopifFrames = await this.detectOopifFrames();
827
853
  const byId = new Map<string, AxNode>();
828
854
  for (const n of nodes) byId.set(n.nodeId, n);
829
855
  const roots = nodes.filter((n) => !n.parentId || !byId.has(n.parentId));
@@ -837,11 +863,20 @@ class CdpPageDriver implements PageDriver {
837
863
  const walk = (node: AxNode, depth: number): void => {
838
864
  const role = axString(node.role);
839
865
  const name = axString(node.name);
840
- const backendNodeId = node.backendDOMNodeId;
841
- const emitted = !node.ignored && backendNodeId !== undefined && (interactiveOnly ? isInteractive(role) : !!(role || name));
866
+ const backendDOMNodeId = node.backendDOMNodeId;
867
+ const marker = backendDOMNodeId !== undefined ? oopifFrames.get(backendDOMNodeId) : undefined;
868
+ // interactiveOnly drops "Iframe" (not in INTERACTIVE_ROLES), but an iframe carrying an
869
+ // OOPIF marker is itself the accessibility boundary of an entire process, so it is
870
+ // emitted anyway: the default view must surface that the boundary exists. Everything
871
+ // else about the emit rule is unchanged, and the depth rule below is untouched.
872
+ const emitted = !node.ignored && backendDOMNodeId !== undefined && (interactiveOnly ? isInteractive(role) || marker !== undefined : !!(role || name));
842
873
  if (emitted) {
843
874
  const extras = axExtras(node);
844
- out.push({ uid: encodeUid(backendNodeId as number), role: role ?? "generic", depth, ...(name ? { name } : {}), ...(Object.keys(extras).length ? { extras } : {}) });
875
+ if (marker) {
876
+ extras.frame = marker.frameId;
877
+ if (marker.frameUrl) extras.frameUrl = marker.frameUrl;
878
+ }
879
+ out.push({ uid: encodeUid(backendDOMNodeId as number), role: role ?? "generic", depth, ...(name ? { name } : {}), ...(Object.keys(extras).length ? { extras } : {}) });
845
880
  }
846
881
  const childDepth = emitted && !interactiveOnly ? depth + 1 : depth;
847
882
  for (const childId of node.childIds ?? []) {
@@ -852,6 +887,36 @@ class CdpPageDriver implements PageDriver {
852
887
  for (const root of roots) walk(root, 0);
853
888
  return out;
854
889
  }
890
+ /**
891
+ * The marker map behind snapshot()'s OOPIF annotations (see snapshot()'s OOPIF MARKERS block):
892
+ * backendNodeId of the owning <iframe> element -> { frameId (= CDP target id), frameUrl }.
893
+ * Failure-soft end to end: any error anywhere in the round-trip yields an EMPTY map, never a
894
+ * failed snapshot. DOM.getFrameOwner throws for the main frame, which is why only non-main
895
+ * frames are visited; ensureDomainSoft covers the DOM.enable it requires without making a
896
+ * failed enable break anything else (getFrameOwner would simply throw into the same catch).
897
+ */
898
+ private async detectOopifFrames(): Promise<Map<number, { frameId: string; frameUrl?: string }>> {
899
+ const markers = new Map<number, { frameId: string; frameUrl?: string }>();
900
+ try {
901
+ await this.ensureDomainSoft("DOM");
902
+ // Process-isolation truth, not an origin heuristic: every OOPIF has its own iframe-type
903
+ // CDP target whose id IS its frame id (listTargets is client.ts's /json/list). This page
904
+ // owns only some of them — DOM.getFrameOwner throws for any frame id the page doesn't own
905
+ // (notably iframe targets inside OTHER tabs), and that throw is the ownership filter.
906
+ const iframeTargets = (await listTargets()).filter((t) => t.type === "iframe");
907
+ for (const target of iframeTargets) {
908
+ const owner = await this.conn
909
+ .send<{ backendNodeId?: number }>("DOM.getFrameOwner", { frameId: target.id })
910
+ .catch(() => ({ backendNodeId: undefined }));
911
+ if (typeof owner.backendNodeId === "number") {
912
+ markers.set(owner.backendNodeId, { frameId: target.id, ...(target.url ? { frameUrl: target.url } : {}) });
913
+ }
914
+ }
915
+ } catch {
916
+ // Deliberately empty: see the doc comment. Markers are an annotation, not a guarantee.
917
+ }
918
+ return markers;
919
+ }
855
920
  async locate(loc: ElementLocator): Promise<DriverUid> {
856
921
  return encodeUid(await backendNodeIdOf(this.conn, loc));
857
922
  }
package/src/cli.ts CHANGED
@@ -29,6 +29,26 @@ import { FIREFOX_TOOLS } from "./firefox-tools.ts";
29
29
  import { leaseFromArgs, withLeaseScope } from "./leases.ts";
30
30
  import { MANIFEST } from "./manifest.ts";
31
31
  import { runInstaller } from "./install/wizard.ts";
32
+ import { realpathSync } from "node:fs";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ /**
36
+ * True when this module is the process's entry script — the ONLY condition under which main()
37
+ * runs. Works under both node and bun, ESM included: compare this module's real path with the
38
+ * realpath of argv[1] (realpath resolves the bunx/npm bin symlink and any /tmp-style symlinked
39
+ * directory). Importing this module (tests, embedders) leaves argv[1] pointing elsewhere, so the
40
+ * import stays silent — no tool dispatch, no usage message.
41
+ */
42
+ function isDirectRun(): boolean {
43
+ const entry = process.argv[1];
44
+ if (!entry) return false;
45
+ try {
46
+ return fileURLToPath(import.meta.url) === realpathSync(entry);
47
+ } catch {
48
+ // argv[1] may not exist as a file (eval strings, wrappers); never treat that as direct.
49
+ return false;
50
+ }
51
+ }
32
52
 
33
53
  const USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
34
54
 
@@ -330,10 +350,12 @@ async function main(): Promise<number> {
330
350
  });
331
351
  }
332
352
 
333
- main()
334
- .then((code) => process.exit(code))
335
- .catch((err: unknown) => {
336
- const message = err instanceof Error ? err.message : String(err);
337
- process.stderr.write(`${JSON.stringify({ error: message })}\n`);
338
- process.exit(1);
339
- });
353
+ if (isDirectRun()) {
354
+ main()
355
+ .then((code) => process.exit(code))
356
+ .catch((err: unknown) => {
357
+ const message = err instanceof Error ? err.message : String(err);
358
+ process.stderr.write(`${JSON.stringify({ error: message })}\n`);
359
+ process.exit(1);
360
+ });
361
+ }
package/src/client.ts CHANGED
@@ -15,6 +15,14 @@ import {
15
15
  workerNeedle,
16
16
  WORKER_EMPTY_NEEDLE_MESSAGE,
17
17
  } from "./workers.ts";
18
+ import {
19
+ frameAmbiguityMessage,
20
+ frameMissMessage,
21
+ frameNeedle,
22
+ isFrameSelector,
23
+ resolveFrameTargets,
24
+ FRAME_EMPTY_NEEDLE_MESSAGE,
25
+ } from "./frames.ts";
18
26
 
19
27
  /** Base HTTP origin of the DevTools endpoint. Override with CDP_BASE. */
20
28
  export const BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
@@ -284,6 +292,20 @@ async function pickTarget(targets: Target[], selector: TargetSelector): Promise<
284
292
  if (matches.length === 1) return matches[0]!;
285
293
  throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
286
294
  }
295
+ if (isFrameSelector(selector)) {
296
+ // Also resolved against the UNFILTERED listing: an out-of-process iframe is
297
+ // its own target (site isolation), but not a PAGE target, so `pages` never
298
+ // contains it. Unlike the worker arm, the returned hit deliberately still
299
+ // flows through resolveTarget's lease gate below — a bare iframe id does
300
+ // too today, and this arm is a way of NAMING that same target, not a new
301
+ // permission to bypass who holds the embedder tab's lease.
302
+ const needle = frameNeedle(selector);
303
+ if (needle === "") throw new CdpError(FRAME_EMPTY_NEEDLE_MESSAGE);
304
+ const { matches, liveFrames } = resolveFrameTargets(targets, needle);
305
+ if (matches.length > 1) throw new CdpError(frameAmbiguityMessage(needle, matches));
306
+ if (matches.length === 1) return matches[0]!;
307
+ throw new CdpError(frameMissMessage(needle, liveFrames));
308
+ }
287
309
  // bare id
288
310
  const byId = targets.find((t) => t.id === selector);
289
311
  if (!byId) throw new CdpError(`no target with id '${selector}'`);
package/src/driver.ts CHANGED
@@ -153,7 +153,8 @@ export type Capability =
153
153
  | "emulate.focus" // fake page focus without moving the OS window (focus_emulation / click_focus_gated); Chrome only — BiDi's emulation module has no focus primitive
154
154
  | "browser.downloads" // capture a file download to a known path (wait_for_download); Chrome only
155
155
  | "browser.permissions" // grant/reset browser permissions for an origin (grant_permissions); Chrome only
156
- | "worker.targets"; // address a service/shared worker as a target ("worker:<substr>"), incl. waking an evicted MV3 worker; Chrome only
156
+ | "worker.targets" // address a service/shared worker as a target ("worker:<substr>"), incl. waking an evicted MV3 worker; Chrome only
157
+ | "frame.targets"; // address an out-of-process (cross-origin) iframe as a target ("frame:<substr>"); Chrome only — same-site iframes are not targets at all
157
158
 
158
159
  /**
159
160
  * Tools whose availability depends on a capability. A tool absent from this