cdp-toolkit 2.6.0 → 3.0.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/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
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
@@ -43,7 +43,7 @@ import { disposeBrowserSession } from "./tools/browser-session.ts";
43
43
  import { toolAvailability } from "./capabilities.ts";
44
44
  import { FIREFOX_TOOLS } from "./firefox-tools.ts";
45
45
  import { leaseFromArgs, markLongLivedProcess, withLeaseScope } from "./leases.ts";
46
- import { resolveProfile, TOOL_GROUP, TOOL_GROUPS, GROUP_TOOLS } from "./toolGroups.ts";
46
+ import { isListed, resolveProfile, TOOL_GROUP, TOOL_GROUPS, GROUP_TOOLS } from "./toolGroups.ts";
47
47
  import { TOOL_DOCS } from "./toolDocs.ts";
48
48
  import { VERSION } from "./version.ts";
49
49
 
@@ -156,10 +156,10 @@ function auditCoverage(): void {
156
156
  // payload without losing a single behavior. Kept under Claude Code's 2KB instructions
157
157
  // cap and front-loaded (grammar + leases before origin) so nothing critical is clipped.
158
158
  const INSTRUCTIONS = [
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:",
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.",
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, 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`).",
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. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all.",
159
+ "cdp-toolkit drives a real browser over CDP (or Firefox WebDriver-BiDi): pages, input, screenshots, snapshots, console, network, cookies, emulation, Lighthouse, traces, screencasts.",
160
+ "TOOLS: by default only 5 are listed (navigate_page, take_snapshot, click, fill, evaluate_script). ~45 more exist: find one with search_tools {query} (returns its schema; no query = catalog), run it with call_tool {name, arguments}. No tab yet? call_tool {name:'new_page'}. The listing is fixed for the process's life; CDP_TOOL_PROFILE=full lists everything.",
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.",
163
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.",
164
164
  ].join("\n\n");
165
165
 
@@ -168,12 +168,17 @@ const INSTRUCTIONS = [
168
168
  // it (the 2026-07-28 revision requires the tool set not to change as a side effect of
169
169
  // other requests, which is exactly what the 2.0 browser_tools toggle did). Discovery is
170
170
  // the HOST's job per the MCP client best-practices: the client picks which of the listed
171
- // tools to put in front of the model. `describe_tool` is the inspect layer — one call
172
- // returns the full prose for any tool, listed or not — and CDP_TOOL_PROFILE is the only
173
- // filter, applied once at startup by whoever configures the server. A tool that the
174
- // profile hides is still callable by name; hiding only trims the listing's token cost.
171
+ // tools to put in front of the model. CDP_TOOL_PROFILE is the only filter, applied once at
172
+ // startup by whoever configures the server. 3.0 defaults it to `gateway` (5 tools) and adds
173
+ // two STATIC meta-tools, search_tools (inspect) and call_tool (execute), so a model whose host
174
+ // forwards only the listing can still reach every tool — without the listing ever changing.
175
175
  const PROFILE = startupProfile();
176
176
 
177
+ /** Names handled at the MCP layer rather than dispatched to a browser tool. */
178
+ const META_TOOLS = new Set(["search_tools", "call_tool", "describe_tool"]);
179
+ const SEARCH_DEFAULT_LIMIT = 8;
180
+ const SEARCH_MAX_LIMIT = 20;
181
+
177
182
  /** Read CDP_TOOL_PROFILE once; an unknown group name is a configuration error, not a warning. */
178
183
  function startupProfile() {
179
184
  try {
@@ -187,35 +192,53 @@ function startupProfile() {
187
192
  /**
188
193
  * The one and only tools/list payload, in manifest order (a stable order is a spec
189
194
  * SHOULD, and it keeps the response byte-identical across calls so caching is real):
190
- * describe_tool first, then every manifest tool the selected backend can run whose
191
- * group the profile advertises. Descriptions here are the compressed one-liners —
192
- * full prose is served on demand by describe_tool.
195
+ * the two meta-tools first, then every manifest tool the selected backend can run that
196
+ * the profile advertises. Descriptions here are the compressed one-liners — full prose
197
+ * is served on demand by search_tools.
193
198
  *
194
199
  * Frozen at runtime (the handler hands the SAME array to every caller); the cast back to
195
200
  * a mutable Tool[] is only because ListToolsResult declares `tools` mutable.
196
201
  */
197
202
  const LISTING: Tool[] = Object.freeze([
198
203
  {
199
- name: "describe_tool",
204
+ name: "search_tools",
200
205
  description:
201
- "Full description and per-parameter docs for any cdp-toolkit tool by name, including tools not in tools/list (unlisted tools stay callable by name). Call it before using an unfamiliar tool: the live tools/list carries only terse one-liners. With no name it returns the grouped catalog of every tool the selected browser supports.",
206
+ "Find a cdp-toolkit tool that is not in your tool list (cookies, network, console, screenshots, tabs, emulation, performance, recording, leases, downloads, …). Returns the best matches with full docs and inputSchema; run one with call_tool. With no query, returns the catalog of every tool by group.",
202
207
  inputSchema: {
203
208
  type: "object",
204
209
  properties: {
205
- name: {
210
+ query: {
206
211
  type: "string",
207
- description: "Tool name, e.g. take_screenshot. Omit it to get the catalog: every group, its tool names, and whether the group is in tools/list.",
212
+ description: "Keywords or an exact tool name, e.g. 'screenshot', 'network requests', 'new_page'. Omit for the catalog.",
213
+ },
214
+ limit: {
215
+ type: "integer",
216
+ description: `Max matches to return (default ${SEARCH_DEFAULT_LIMIT}, max ${SEARCH_MAX_LIMIT}).`,
208
217
  },
209
218
  },
210
219
  additionalProperties: false,
211
220
  } as Tool["inputSchema"],
212
221
  },
222
+ {
223
+ name: "call_tool",
224
+ description:
225
+ "Run any cdp-toolkit tool by name, including the ones not in your tool list. Look its arguments up with search_tools first. Behaves exactly like calling the tool directly (same leases, same errors).",
226
+ inputSchema: {
227
+ type: "object",
228
+ properties: {
229
+ name: { type: "string", description: "Tool name from search_tools, e.g. new_page." },
230
+ arguments: { type: "object", description: "That tool's arguments, per its inputSchema.", additionalProperties: true },
231
+ },
232
+ required: ["name"],
233
+ additionalProperties: false,
234
+ } as Tool["inputSchema"],
235
+ },
213
236
  ...MANIFEST
214
- .filter((s) => AVAILABLE_NAMES.has(s.name) && PROFILE.groups.has(TOOL_GROUP[s.name]!))
237
+ .filter((s) => AVAILABLE_NAMES.has(s.name) && isListed(PROFILE, s.name))
215
238
  .map((s) => ({ name: s.name, description: s.description, inputSchema: s.inputSchema as Tool["inputSchema"] })),
216
239
  ] satisfies Tool[]) as Tool[];
217
240
 
218
- /** describe_tool with no name: every group the backend supports, and whether it is listed. */
241
+ /** search_tools with no query: every group the backend supports, and what of it is listed. */
219
242
  function renderCatalog(): string {
220
243
  const lines = [
221
244
  `cdp-toolkit ${VERSION} · browser=${BROWSER} · ${AVAILABILITY.available.length} tools available, ${LISTING.length} in tools/list (CDP_TOOL_PROFILE=${PROFILE.label})`,
@@ -223,12 +246,76 @@ function renderCatalog(): string {
223
246
  for (const g of TOOL_GROUPS) {
224
247
  const shown = GROUP_TOOLS[g].filter((n) => AVAILABLE_NAMES.has(n));
225
248
  if (!shown.length) continue;
226
- lines.push(`${PROFILE.groups.has(g) ? "[listed]" : "[hidden]"} ${g} (${shown.length}): ${shown.join(", ")}`);
249
+ const listed = shown.filter((n) => isListed(PROFILE, n));
250
+ const state = listed.length === shown.length ? "[listed]" : listed.length === 0 ? "[hidden]" : "[partly listed]";
251
+ lines.push(`${state} ${g} (${shown.length}): ${shown.join(", ")}`);
227
252
  }
228
- lines.push("Unlisted tools are callable by name; describe_tool {name} documents any of them.");
253
+ lines.push("Run any tool with call_tool {name, arguments}; search_tools {query:<name>} returns its docs and inputSchema.");
229
254
  return lines.join("\n");
230
255
  }
231
256
 
257
+ /** Lowercased word tokens: 'list_network_requests' and 'Network requests' both split cleanly. */
258
+ function tokenize(text: string): string[] {
259
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 1);
260
+ }
261
+
262
+ const MANIFEST_BY_NAME = new Map(MANIFEST.map((s) => [s.name, s]));
263
+
264
+ /**
265
+ * search_tools with a query: rank the backend's available tools against it. An exact tool
266
+ * name returns just that tool. Otherwise each query token scores against the tool's name
267
+ * (strongest), its group, and its full docs; ties keep manifest order. Capped, because an
268
+ * uncapped broad query would hand back the very 49-schema payload the gateway exists to avoid.
269
+ */
270
+ function searchTools(query: string, rawLimit: unknown) {
271
+ const limit = typeof rawLimit === "number" && Number.isInteger(rawLimit) && rawLimit > 0
272
+ ? Math.min(rawLimit, SEARCH_MAX_LIMIT)
273
+ : SEARCH_DEFAULT_LIMIT;
274
+ const q = query.trim().toLowerCase();
275
+ const describe = (name: string) => {
276
+ const spec = MANIFEST_BY_NAME.get(name)!;
277
+ return {
278
+ name,
279
+ group: TOOL_GROUP[name] ?? null,
280
+ listed: isListed(PROFILE, name),
281
+ description: TOOL_DOCS[name]?.description ?? spec.description,
282
+ inputSchema: spec.inputSchema,
283
+ };
284
+ };
285
+ const candidates = MANIFEST.map((s) => s.name).filter((n) => AVAILABLE_NAMES.has(n));
286
+ let ranked: string[];
287
+ if (candidates.includes(q)) {
288
+ ranked = [q];
289
+ } else {
290
+ const tokens = tokenize(q);
291
+ const scored = candidates.map((name, order) => {
292
+ const nameTokens = tokenize(name);
293
+ const group = TOOL_GROUP[name] ?? "";
294
+ const docs = `${MANIFEST_BY_NAME.get(name)!.description} ${TOOL_DOCS[name]?.description ?? ""}`.toLowerCase();
295
+ let score = 0;
296
+ for (const t of tokens) {
297
+ if (nameTokens.includes(t)) score += 5;
298
+ else if (name.includes(t)) score += 3;
299
+ if (group === t || group.includes(t)) score += 2;
300
+ if (docs.includes(t)) score += 1;
301
+ }
302
+ return { name, order, score };
303
+ });
304
+ ranked = scored
305
+ .filter((s) => s.score > 0)
306
+ .sort((a, b) => b.score - a.score || a.order - b.order)
307
+ .map((s) => s.name);
308
+ }
309
+ const unavailable = AVAILABILITY.unavailable.find((u) => u.name === q);
310
+ return {
311
+ query,
312
+ total: ranked.length,
313
+ matches: ranked.slice(0, limit).map(describe),
314
+ ...(unavailable ? { unavailable: `${q} is not available under --browser ${BROWSER} (needs: ${unavailable.missing.join(", ")})` } : {}),
315
+ next: ranked.length ? "Run one with call_tool {name, arguments}." : "No match. Call search_tools with no query for the full catalog.",
316
+ };
317
+ }
318
+
232
319
  /** describe_tool body: full prose for one tool from the on-demand docs map. */
233
320
  function renderToolDoc(toolName: string): string {
234
321
  const doc = TOOL_DOCS[toolName];
@@ -273,9 +360,19 @@ export function buildServer(): Server {
273
360
  const { name } = request.params;
274
361
  const args = (request.params.arguments ?? {}) as unknown;
275
362
 
276
- // describe_tool is handled at the MCP layer (it reads only static docs) and never
277
- // enters the browser dispatch or a lease scope. With a name it documents one tool —
278
- // listed or not; with no name it returns the grouped catalog.
363
+ // The meta-tools are handled at the MCP layer (they read only static docs) and never
364
+ // enter the browser dispatch or a lease scope.
365
+ if (name === "search_tools") {
366
+ const { query, limit } = args as { query?: unknown; limit?: unknown };
367
+ if (typeof query !== "string" || query.trim() === "") {
368
+ return { content: [{ type: "text" as const, text: renderCatalog() }] };
369
+ }
370
+ return { content: [{ type: "text" as const, text: JSON.stringify(searchTools(query, limit), null, 2) }] };
371
+ }
372
+
373
+ // describe_tool is the 2.x inspect tool: no longer listed (search_tools {query:<name>}
374
+ // replaces it), still answered so 2.x callers keep working. With a name it documents
375
+ // one tool — listed or not; with no name it returns the grouped catalog.
279
376
  if (name === "describe_tool") {
280
377
  const requested = (args as { name?: unknown }).name;
281
378
  if (typeof requested !== "string" || requested === "") {
@@ -285,48 +382,73 @@ export function buildServer(): Server {
285
382
  return { content: [{ type: "text" as const, text: renderToolDoc(requested) }], isError: !known };
286
383
  }
287
384
 
288
- if (!AVAILABLE_NAMES.has(name)) {
289
- const gap = AVAILABILITY.unavailable.find((u) => u.name === name);
290
- return {
291
- content: [{ type: "text" as const, text: `unknown tool: ${name}${gap ? ` (not available under --browser ${BROWSER}, needs: ${gap.missing.join(", ")})` : ""}` }],
292
- isError: true,
293
- };
385
+ // call_tool unwraps to the inner tool and re-enters the SAME dispatch below, so the
386
+ // availability check, the lease scope (read off the INNER arguments) and the error
387
+ // shapes are identical to calling the tool directly. It exists because a model can
388
+ // only invoke tools its host put in front of it — an unlisted tool is callable by name
389
+ // over the wire, but not by a model whose host only forwards tools/list.
390
+ if (name === "call_tool") {
391
+ const inner = args as { name?: unknown; arguments?: unknown };
392
+ if (typeof inner.name !== "string" || inner.name === "") {
393
+ return { content: [{ type: "text" as const, text: "call_tool: `name` is required (find one with search_tools)" }], isError: true };
394
+ }
395
+ if (META_TOOLS.has(inner.name)) {
396
+ return { content: [{ type: "text" as const, text: `call_tool: '${inner.name}' is a meta-tool; call it directly` }], isError: true };
397
+ }
398
+ const innerArgs = inner.arguments ?? {};
399
+ if (typeof innerArgs !== "object" || Array.isArray(innerArgs)) {
400
+ return { content: [{ type: "text" as const, text: "call_tool: `arguments` must be an object" }], isError: true };
401
+ }
402
+ return runTool(inner.name, innerArgs);
294
403
  }
295
404
 
296
- // ONE lease scope per dispatch, wrapping BOTH backend branches. This is the
297
- // reason no tool takes a lease parameter: the token rides the async context
298
- // down to whichever resolution path the tool eventually reaches, so a tool
299
- // added tomorrow is covered with no action from whoever writes it. Reading
300
- // 'lease' off args here is the only place the MCP layer knows the key exists.
301
- return withLeaseScope(leaseFromArgs(args), async () => {
302
- if (BROWSER === "chrome") {
303
- const fn = dispatch[name];
304
- if (!fn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
305
- try {
306
- const result = await fn(args);
307
- return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
308
- } catch (err) {
309
- const message = err instanceof Error ? err.message : String(err);
310
- return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
311
- }
312
- }
405
+ return runTool(name, args);
406
+ });
407
+
408
+ return server;
409
+ }
313
410
 
314
- // Firefox: one BiDi session memoized for the life of this server process (lifetime "session",
315
- // ADR-001); launched lazily on the first Firefox tool call, torn down on shutdown below.
316
- const neutralFn = neutralDispatch[name];
317
- if (!neutralFn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
411
+ /** Dispatch one browser tool by name: the path both a direct call and call_tool take. */
412
+ async function runTool(name: string, args: unknown) {
413
+ if (!AVAILABLE_NAMES.has(name)) {
414
+ const gap = AVAILABILITY.unavailable.find((u) => u.name === name);
415
+ return {
416
+ content: [{ type: "text" as const, text: `unknown tool: ${name}${gap ? ` (not available under --browser ${BROWSER}, needs: ${gap.missing.join(", ")})` : ""}` }],
417
+ isError: true,
418
+ };
419
+ }
420
+
421
+ // ONE lease scope per dispatch, wrapping BOTH backend branches. This is the
422
+ // reason no tool takes a lease parameter: the token rides the async context
423
+ // down to whichever resolution path the tool eventually reaches, so a tool
424
+ // added tomorrow is covered with no action from whoever writes it. Reading
425
+ // 'lease' off args here is the only place the MCP layer knows the key exists.
426
+ return withLeaseScope(leaseFromArgs(args), async () => {
427
+ if (BROWSER === "chrome") {
428
+ const fn = dispatch[name];
429
+ if (!fn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
318
430
  try {
319
- const session = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
320
- const result = await neutralFn(session.driver, args);
431
+ const result = await fn(args);
321
432
  return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
322
433
  } catch (err) {
323
434
  const message = err instanceof Error ? err.message : String(err);
324
435
  return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
325
436
  }
326
- });
327
- });
437
+ }
328
438
 
329
- return server;
439
+ // Firefox: one BiDi session memoized for the life of this server process (lifetime "session",
440
+ // ADR-001); launched lazily on the first Firefox tool call, torn down on shutdown below.
441
+ const neutralFn = neutralDispatch[name];
442
+ if (!neutralFn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
443
+ try {
444
+ const session = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
445
+ const result = await neutralFn(session.driver, args);
446
+ return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
447
+ } catch (err) {
448
+ const message = err instanceof Error ? err.message : String(err);
449
+ return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
450
+ }
451
+ });
330
452
  }
331
453
 
332
454
  /**
@@ -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.",