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/CHANGELOG.md +44 -0
- package/CONTRACT.md +1 -1
- package/README.md +81 -46
- package/dist/bidi/mux-daemon.js +47 -0
- package/dist/cli.js +78 -5
- package/dist/index.js +73 -4
- package/dist/mcp.js +236 -50
- package/package.json +5 -3
- package/src/bidi/driver.ts +8 -0
- package/src/cdp/driver.ts +69 -4
- package/src/client.ts +22 -0
- package/src/driver.ts +2 -1
- package/src/frames.ts +169 -0
- package/src/manifest.ts +1 -1
- package/src/mcp.ts +177 -55
- package/src/shared-tools.ts +13 -0
- package/src/toolDocs.ts +3 -3
- package/src/toolGroups.ts +41 -10
- package/src/types.ts +13 -0
- package/src/version.ts +1 -1
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"
|
|
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
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
|
|
160
|
-
"
|
|
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,
|
|
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.
|
|
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.
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
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
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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: "
|
|
204
|
+
name: "search_tools",
|
|
200
205
|
description:
|
|
201
|
-
"
|
|
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
|
-
|
|
210
|
+
query: {
|
|
206
211
|
type: "string",
|
|
207
|
-
description: "
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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("
|
|
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
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|
package/src/shared-tools.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
import { installBeacon, probeRendererActivity, rendererProbeSupported } from "./activity.ts";
|
|
49
49
|
import { newTrackedPage, originIndex, resolveLiveLabel, type PageOrigin } from "./origins.ts";
|
|
50
50
|
import { isWorkerSelector, WORKER_SELECTOR_PAGE_ONLY_MESSAGE, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "./workers.ts";
|
|
51
|
+
import { FRAME_SELECTOR_PAGE_ONLY_MESSAGE, FRAME_SELECTOR_UNSUPPORTED_MESSAGE, isFrameSelector } from "./frames.ts";
|
|
51
52
|
import { reapStaleAgentTabs, type ReapedTab } from "./reap.ts";
|
|
52
53
|
|
|
53
54
|
const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
@@ -150,6 +151,18 @@ export async function pickPage(driver: BrowserDriver, pages: PageInfo[], selecto
|
|
|
150
151
|
backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE,
|
|
151
152
|
);
|
|
152
153
|
}
|
|
154
|
+
if (isFrameSelector(selector)) {
|
|
155
|
+
// Same contract as the worker: arm above, same reason. Every caller of
|
|
156
|
+
// pickPage (close_page / select_page / release_page via resolvePage, and
|
|
157
|
+
// claim_page's takeover) acts on a TAB; an out-of-process iframe has no
|
|
158
|
+
// independent existence to close, select, release or claim. Refusing with
|
|
159
|
+
// the reason beats the bare-id fall-through's "no target with id
|
|
160
|
+
// 'frame:foo'", which reads as a typo. The arm itself lives in client.ts's
|
|
161
|
+
// pickTarget and every other target-taking tool accepts it.
|
|
162
|
+
throw new SharedToolError(
|
|
163
|
+
backendOf(driver) === "firefox" ? FRAME_SELECTOR_UNSUPPORTED_MESSAGE : FRAME_SELECTOR_PAGE_ONLY_MESSAGE,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
153
166
|
const exactPage = pages.find((x) => x.id === selector);
|
|
154
167
|
if (exactPage) return exactPage;
|
|
155
168
|
const all = await driver.listPages({ all: true });
|
package/src/toolDocs.ts
CHANGED
|
@@ -3,10 +3,10 @@ export type ToolDoc = { readonly description: string; readonly params: Record<st
|
|
|
3
3
|
|
|
4
4
|
export const TOOL_DOCS: Record<string, ToolDoc> = {
|
|
5
5
|
"list_pages": {
|
|
6
|
-
"description": "Enumerate browser page targets (GET /json/list); page-type tabs only unless all=true. Each entry carries its targetId plus an origin field: 'agent' means this toolkit created it (also carries the creating label/createdAt, and stays 'agent' after the lease is released or the creator dies, making stray agent tabs findable), 'unknown' means no creation record - it never says 'human', the honest word for any tab it did not create; a record that exists but won't read adds originUnreadable. An entry under an active, readable lease also carries a 'lease' object {label,pid,idleMs,expiresAt,stale}, with idleMs and expiresAt computed fresh every call (never off disk), independent of probe. Under CDP_REQUIRE_LEASE this call also reaps abandoned agent tabs into an additive reaped array - but a lease reads reclaimable the moment its TTL elapses, while the tab is CLOSED only after a further grace (CDP_REAP_GRACE_MS, default 2700000ms/45min, so 60min total after last use); a dead-pid lease is reaped at once, a tab with no lease never. With probe, each page-type entry also gets 'responsive' (and humanActiveMs where human input is seen); both are absent, never false/null, without probe.",
|
|
6
|
+
"description": "Enumerate browser page targets (GET /json/list); page-type tabs only unless all=true, which also lists workers and out-of-process (OOPIF) iframes. Each entry carries its targetId plus an origin field: 'agent' means this toolkit created it (also carries the creating label/createdAt, and stays 'agent' after the lease is released or the creator dies, making stray agent tabs findable), 'unknown' means no creation record - it never says 'human', the honest word for any tab it did not create; a record that exists but won't read adds originUnreadable. An entry under an active, readable lease also carries a 'lease' object {label,pid,idleMs,expiresAt,stale}, with idleMs and expiresAt computed fresh every call (never off disk), independent of probe. Under CDP_REQUIRE_LEASE this call also reaps abandoned agent tabs into an additive reaped array - but a lease reads reclaimable the moment its TTL elapses, while the tab is CLOSED only after a further grace (CDP_REAP_GRACE_MS, default 2700000ms/45min, so 60min total after last use); a dead-pid lease is reaped at once, a tab with no lease never. With probe, each page-type entry also gets 'responsive' (and humanActiveMs where human input is seen); both are absent, never false/null, without probe.",
|
|
7
7
|
"params": {
|
|
8
8
|
"lease": "Lease token from claim_page. Omit for a tab this process holds; required for a tab held by another process or one claimed explicitly. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease). See server instructions.",
|
|
9
|
-
"all": "Include non-page targets (service/shared workers, background pages); default false lists page-type tabs only.",
|
|
9
|
+
"all": "Include non-page targets (service/shared workers, background pages, out-of-process (OOPIF) iframes); default false lists page-type tabs only. A returned non-page targetId is accepted as a bare id by any tool's `target` param.",
|
|
10
10
|
"probe": "Ping each page-type renderer before returning, one bounded 500ms check per target and never more, so a wedged tab can't stall the listing. Adds 'responsive' (false on timeout/exception/unreachable, never an error for the whole call) and, where the same round trip finds human input, humanActiveMs. Default false. On a backend that cannot answer the ping, responsive is absent too (not false)."
|
|
11
11
|
}
|
|
12
12
|
},
|
|
@@ -107,7 +107,7 @@ export const TOOL_DOCS: Record<string, ToolDoc> = {
|
|
|
107
107
|
}
|
|
108
108
|
},
|
|
109
109
|
"take_snapshot": {
|
|
110
|
-
"description": "Captures the page's accessibility tree as an indented text tree, each line prefixed [uid] (a CDP backendDOMNodeId). Every interaction tool (click/hover/fill/etc.) resolves that uid via DOM.resolveNode — run this first to discover uids to act on.",
|
|
110
|
+
"description": "Captures the page's accessibility tree as an indented text tree, each line prefixed [uid] (a CDP backendDOMNodeId). Every interaction tool (click/hover/fill/etc.) resolves that uid via DOM.resolveNode — run this first to discover uids to act on. Cross-origin iframe subtrees are not walked (they live in a separate renderer process); the iframe node itself is emitted with a frame=\"<targetId>\" marker (also in interactiveOnly mode), so the frame is discoverable and driveable via that id or frame:<url-substring>.",
|
|
111
111
|
"params": {
|
|
112
112
|
"lease": "Lease token from claim_page. Omit for a tab this process holds; required for a tab held by another process or one claimed explicitly. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease). See server instructions.",
|
|
113
113
|
"target": "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'. Grammar: see server instructions.",
|