@mehmoodqureshi/chrome-mcp 0.9.2 → 0.9.4
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/README.md +31 -2
- package/dist/shared/page-fns.js +208 -182
- package/dist/shared/screenshot.d.ts +14 -0
- package/dist/shared/screenshot.js +19 -4
- package/dist/shared/snapshot.d.ts +24 -2
- package/dist/shared/snapshot.js +117 -33
- package/dist/src/bridge/connection.d.ts +13 -0
- package/dist/src/bridge/connection.js +55 -1
- package/dist/src/bridge/server.d.ts +2 -0
- package/dist/src/bridge/server.js +5 -0
- package/dist/src/bridge/workspace.d.ts +1 -1
- package/dist/src/bridge/workspace.js +2 -2
- package/dist/src/cli.js +24 -3
- package/dist/src/config.d.ts +6 -0
- package/dist/src/config.js +32 -0
- package/dist/src/executor/cdp-executor.d.ts +3 -2
- package/dist/src/executor/cdp-executor.js +21 -6
- package/dist/src/executor/extension-executor.d.ts +6 -2
- package/dist/src/executor/extension-executor.js +18 -5
- package/dist/src/executor/types.d.ts +25 -2
- package/dist/src/mcp/locate.d.ts +7 -4
- package/dist/src/mcp/locate.js +15 -8
- package/dist/src/mcp/tools.d.ts +21 -0
- package/dist/src/mcp/tools.js +114 -38
- package/docs/BLUEPRINT.md +1 -1
- package/extension-dist/background.js +494 -263
- package/extension-dist/manifest.json +4 -3
- package/package.json +2 -2
|
@@ -18,10 +18,11 @@ const workspace_1 = require("../bridge/workspace");
|
|
|
18
18
|
* Deliberately short. It exists to cover back-to-back calls (a `batch`, or an
|
|
19
19
|
* agent's read → click → read), where the tab demonstrably has not changed
|
|
20
20
|
* between them. Past that, pay the round-trip. Note the extension re-gates every
|
|
21
|
-
* command against the tab's live URL regardless, so
|
|
22
|
-
* pre-check precision for half the traffic — never
|
|
21
|
+
* command against the tab's live URL regardless (fail-closed, authoritative), so
|
|
22
|
+
* this window trades a little pre-check precision for half the traffic — never
|
|
23
|
+
* enforcement itself.
|
|
23
24
|
*/
|
|
24
|
-
const ACTIVE_URL_TTL_MS =
|
|
25
|
+
const ACTIVE_URL_TTL_MS = 2_000;
|
|
25
26
|
/** Flatten frame options into the params a wire command carries. */
|
|
26
27
|
function frameParams(o) {
|
|
27
28
|
if (!o)
|
|
@@ -88,6 +89,11 @@ class ExtensionExecutor {
|
|
|
88
89
|
cachedActiveUrl() {
|
|
89
90
|
return this.bridge.lastActiveUrl(this.activeProfile(), ACTIVE_URL_TTL_MS);
|
|
90
91
|
}
|
|
92
|
+
/** A specific tab's URL as last reported (by a result for that tab, or by a
|
|
93
|
+
* `tabs_list`), if fresh enough to gate against. */
|
|
94
|
+
cachedTabUrl(tabId) {
|
|
95
|
+
return this.bridge.lastTabUrl(this.activeProfile(), tabId, ACTIVE_URL_TTL_MS);
|
|
96
|
+
}
|
|
91
97
|
// -- tabs ---------------------------------------------------------------
|
|
92
98
|
async tabsList() {
|
|
93
99
|
return (await this.send('tabs_list', {}));
|
|
@@ -145,7 +151,7 @@ class ExtensionExecutor {
|
|
|
145
151
|
return (await this.send('get_html', { ...targetParams(t), ...frameParams(opts), outer: opts?.outer }, { tabId: opts?.tabId }));
|
|
146
152
|
}
|
|
147
153
|
async snapshot(opts) {
|
|
148
|
-
return (await this.send('snapshot', { interactiveOnly: opts?.interactiveOnly, max: opts?.max, ...frameParams(opts) }, { tabId: opts?.tabId }));
|
|
154
|
+
return (await this.send('snapshot', { interactiveOnly: opts?.interactiveOnly, max: opts?.max, locator: opts?.locator, ...frameParams(opts) }, { tabId: opts?.tabId }));
|
|
149
155
|
}
|
|
150
156
|
async getCookies(opts) {
|
|
151
157
|
return (await this.send('get_cookies', { url: opts?.url }, { tabId: opts?.tabId }));
|
|
@@ -154,7 +160,14 @@ class ExtensionExecutor {
|
|
|
154
160
|
return (await this.send('storage', { op: args.op, key: args.key, value: args.value, session: args.session }, { tabId: args.tabId }));
|
|
155
161
|
}
|
|
156
162
|
async screenshot(opts) {
|
|
157
|
-
return (await this.send('screenshot', {
|
|
163
|
+
return (await this.send('screenshot', {
|
|
164
|
+
fullPage: opts?.fullPage,
|
|
165
|
+
format: opts?.format,
|
|
166
|
+
quality: opts?.quality,
|
|
167
|
+
scale: opts?.scale,
|
|
168
|
+
...targetParams(opts?.target),
|
|
169
|
+
...frameParams(opts),
|
|
170
|
+
}, { tabId: opts?.tabId }));
|
|
158
171
|
}
|
|
159
172
|
async eval(expression, opts) {
|
|
160
173
|
const result = (await this.send('eval', { expression, awaitPromise: opts?.awaitPromise, ...frameParams(opts) }, { tabId: opts?.tabId }));
|
|
@@ -111,9 +111,18 @@ export interface WaitResult {
|
|
|
111
111
|
export interface ActionOk {
|
|
112
112
|
ok: true;
|
|
113
113
|
}
|
|
114
|
+
export type ScreenshotFormat = 'png' | 'jpeg';
|
|
115
|
+
/** Encoding knobs every backend accepts. All optional; see shared/screenshot.ts for defaults. */
|
|
116
|
+
export interface ScreenshotEncoding {
|
|
117
|
+
format?: ScreenshotFormat;
|
|
118
|
+
/** JPEG only, 1-100. */
|
|
119
|
+
quality?: number;
|
|
120
|
+
/** Output pixels per CSS pixel (1 = CSS size, 2 = device pixels on a Retina display). */
|
|
121
|
+
scale?: number;
|
|
122
|
+
}
|
|
114
123
|
export interface ScreenshotResult {
|
|
115
124
|
dataBase64: string;
|
|
116
|
-
mimeType: 'image/png';
|
|
125
|
+
mimeType: 'image/png' | 'image/jpeg';
|
|
117
126
|
width: number;
|
|
118
127
|
height: number;
|
|
119
128
|
/** fullPage capture exceeded the height cap; `fullHeight` reports the real size. */
|
|
@@ -153,6 +162,13 @@ export interface SnapshotResult {
|
|
|
153
162
|
title: string;
|
|
154
163
|
nodes: SnapshotNode[];
|
|
155
164
|
truncated: boolean;
|
|
165
|
+
/** Locator mode, no match: what the page had of that role (for the error message). */
|
|
166
|
+
nearby?: string[];
|
|
167
|
+
}
|
|
168
|
+
/** A role/name query the page resolves itself (see shared/snapshot.ts). */
|
|
169
|
+
export interface SnapshotLocator {
|
|
170
|
+
role?: string;
|
|
171
|
+
name?: string;
|
|
156
172
|
}
|
|
157
173
|
export interface CookieItem {
|
|
158
174
|
name: string;
|
|
@@ -205,6 +221,12 @@ export interface Executor {
|
|
|
205
221
|
* report cheaply simply omit it.
|
|
206
222
|
*/
|
|
207
223
|
cachedActiveUrl?(): string | null;
|
|
224
|
+
/**
|
|
225
|
+
* Same idea for an explicitly-targeted tab: its URL if the backend already
|
|
226
|
+
* knows it recently enough (the extension reports it on every result for that
|
|
227
|
+
* tab, and a `tabsList` reports it for every tab). Null → resolve properly.
|
|
228
|
+
*/
|
|
229
|
+
cachedTabUrl?(tabId: TabId): string | null;
|
|
208
230
|
tabsList(): Promise<TabInfo[]>;
|
|
209
231
|
tabSelect(tabId: TabId): Promise<TabInfo>;
|
|
210
232
|
/** Open a tab. `active` (default true) focuses it; pass false to open in the background. */
|
|
@@ -279,6 +301,7 @@ export interface Executor {
|
|
|
279
301
|
tabId?: TabId;
|
|
280
302
|
interactiveOnly?: boolean;
|
|
281
303
|
max?: number;
|
|
304
|
+
locator?: SnapshotLocator;
|
|
282
305
|
} & FrameOpts): Promise<SnapshotResult>;
|
|
283
306
|
/** Read cookies visible to the active tab's URL (or a given url). */
|
|
284
307
|
getCookies(opts?: {
|
|
@@ -299,7 +322,7 @@ export interface Executor {
|
|
|
299
322
|
tabId?: TabId;
|
|
300
323
|
fullPage?: boolean;
|
|
301
324
|
target?: Target;
|
|
302
|
-
} & FrameOpts): Promise<ScreenshotResult>;
|
|
325
|
+
} & ScreenshotEncoding & FrameOpts): Promise<ScreenshotResult>;
|
|
303
326
|
eval(expression: string, opts?: {
|
|
304
327
|
tabId?: TabId;
|
|
305
328
|
awaitPromise?: boolean;
|
package/dist/src/mcp/locate.d.ts
CHANGED
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
* redeploy.
|
|
10
10
|
*
|
|
11
11
|
* A locator closes that: `{ role: 'button', name: 'Sign in' }` resolves through
|
|
12
|
-
* one snapshot
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* one snapshot and the caller never sees the tree. The page does the matching
|
|
13
|
+
* itself (`collectSnapshot` with a locator returns only the strongest-tier
|
|
14
|
+
* hits, and stamps refs on those alone), so what crosses the bridge is a
|
|
15
|
+
* handful of nodes rather than 400; this module re-scores them — same tiers:
|
|
16
|
+
* exact, then case-insensitive, then prefix, then contains — so an unambiguous
|
|
17
|
+
* name wins outright and an ambiguous one fails loudly with the candidates
|
|
18
|
+
* rather than silently clicking the first row.
|
|
16
19
|
*/
|
|
17
20
|
import type { Executor, SnapshotNode, Target } from '../executor/types';
|
|
18
21
|
export interface Locator {
|
package/dist/src/mcp/locate.js
CHANGED
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
* redeploy.
|
|
11
11
|
*
|
|
12
12
|
* A locator closes that: `{ role: 'button', name: 'Sign in' }` resolves through
|
|
13
|
-
* one snapshot
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* one snapshot and the caller never sees the tree. The page does the matching
|
|
14
|
+
* itself (`collectSnapshot` with a locator returns only the strongest-tier
|
|
15
|
+
* hits, and stamps refs on those alone), so what crosses the bridge is a
|
|
16
|
+
* handful of nodes rather than 400; this module re-scores them — same tiers:
|
|
17
|
+
* exact, then case-insensitive, then prefix, then contains — so an unambiguous
|
|
18
|
+
* name wins outright and an ambiguous one fails loudly with the candidates
|
|
19
|
+
* rather than silently clicking the first row.
|
|
17
20
|
*/
|
|
18
21
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
22
|
exports.hasLocator = hasLocator;
|
|
@@ -60,6 +63,7 @@ async function resolveLocator(ex, loc, opts = {}) {
|
|
|
60
63
|
tabId: opts.tabId,
|
|
61
64
|
interactiveOnly: false,
|
|
62
65
|
max: 400,
|
|
66
|
+
locator: want,
|
|
63
67
|
frameId: opts.frameId,
|
|
64
68
|
allFrames: opts.allFrames,
|
|
65
69
|
});
|
|
@@ -79,10 +83,13 @@ async function resolveLocator(ex, loc, opts = {}) {
|
|
|
79
83
|
}
|
|
80
84
|
const describe = (n) => `${n.role} "${n.name}"`;
|
|
81
85
|
if (winners.length === 0) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
.
|
|
86
|
+
// A page that scored in place reports what it had of that role as `nearby`;
|
|
87
|
+
// a backend that returned the full tree leaves it to us.
|
|
88
|
+
const sample = snap.nearby ??
|
|
89
|
+
snap.nodes
|
|
90
|
+
.filter((n) => !want.role || norm(n.role) === norm(want.role))
|
|
91
|
+
.slice(0, 8)
|
|
92
|
+
.map(describe);
|
|
86
93
|
throw new validators_1.McpToolError(`no element matches ${JSON.stringify(want)}. ` +
|
|
87
94
|
(sample.length
|
|
88
95
|
? `Closest by role: ${sample.join(', ')}. `
|
package/dist/src/mcp/tools.d.ts
CHANGED
|
@@ -22,6 +22,27 @@ export interface ToolDefinition {
|
|
|
22
22
|
inputSchema: z.ZodRawShape;
|
|
23
23
|
}
|
|
24
24
|
export declare const TOOL_DEFINITIONS: ToolDefinition[];
|
|
25
|
+
/** Every advertised tool name, in catalog order. */
|
|
26
|
+
export declare const TOOL_NAMES: readonly string[];
|
|
27
|
+
/**
|
|
28
|
+
* Restrict the tool surface to `names` (`--tools`). The catalog is the single
|
|
29
|
+
* largest fixed cost of having this server connected: every tool's JSON Schema
|
|
30
|
+
* is re-sent to the model on EVERY turn. A run that only reads pages has no use
|
|
31
|
+
* for uploads, PDFs or task management, and should not pay for their schemas.
|
|
32
|
+
*
|
|
33
|
+
* Excluded tools are neither advertised in `tools/list` nor callable — a
|
|
34
|
+
* `batch` op naming one is refused exactly like an unknown tool, so hiding a
|
|
35
|
+
* tool is a real restriction and not just a display filter.
|
|
36
|
+
*
|
|
37
|
+
* Passing `null`/`undefined`/`[]` restores the full catalog. Unknown names
|
|
38
|
+
* throw rather than being ignored: a typo that silently drops `click` from the
|
|
39
|
+
* surface is far more expensive to debug than a startup error.
|
|
40
|
+
*/
|
|
41
|
+
export declare function setToolAllowlist(names: readonly string[] | null | undefined): void;
|
|
42
|
+
/** Is `name` on the surface? True for every catalog tool when no allowlist is set. */
|
|
43
|
+
export declare function isToolEnabled(name: string): boolean;
|
|
44
|
+
/** The names actually advertised, in catalog order. */
|
|
45
|
+
export declare function enabledToolNames(): readonly string[];
|
|
25
46
|
interface ToolCtx {
|
|
26
47
|
ex: Executor;
|
|
27
48
|
policy: Policy;
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
* `Error` as an `isError` result.
|
|
13
13
|
*/
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
-
exports.TOOL_HANDLERS = exports.TOOL_DEFINITIONS = void 0;
|
|
15
|
+
exports.TOOL_HANDLERS = exports.TOOL_NAMES = exports.TOOL_DEFINITIONS = void 0;
|
|
16
|
+
exports.setToolAllowlist = setToolAllowlist;
|
|
17
|
+
exports.isToolEnabled = isToolEnabled;
|
|
18
|
+
exports.enabledToolNames = enabledToolNames;
|
|
16
19
|
exports.resetRateLimiter = resetRateLimiter;
|
|
17
20
|
exports.dispatchToolCall = dispatchToolCall;
|
|
18
21
|
exports.assertNoDrift = assertNoDrift;
|
|
@@ -35,45 +38,59 @@ const log_1 = require("./log");
|
|
|
35
38
|
const tasks_1 = require("../bridge/tasks");
|
|
36
39
|
const workspace_1 = require("../bridge/workspace");
|
|
37
40
|
const validators_1 = require("./validators");
|
|
38
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Element targeting, defined ONCE and spread whole by every tool that acts on
|
|
43
|
+
* an element. `TARGET_PROPS` is the plain selector|ref pair (for tools whose
|
|
44
|
+
* handler resolves a node and nothing else); `LOCATOR_PROPS` is the full block
|
|
45
|
+
* — selector | ref | role+name, in any frame — and is what click/type/hover/
|
|
46
|
+
* select_option spread as a single object.
|
|
47
|
+
*
|
|
48
|
+
* Keeping the block in one place is also what keeps `tools/list` cheap: these
|
|
49
|
+
* seven fields repeat across a dozen tools, so every byte of prose here is paid
|
|
50
|
+
* a dozen times on EVERY turn. Detail an agent does not need on every call
|
|
51
|
+
* belongs in the owning tool's description (`frames_list`, `auth_check`) or the
|
|
52
|
+
* README, not here.
|
|
53
|
+
*
|
|
54
|
+
* A handler that needs a target calls `requireTarget`; ambiguity fails loudly
|
|
55
|
+
* rather than acting on the wrong element.
|
|
56
|
+
*/
|
|
39
57
|
const TARGET_PROPS = {
|
|
40
|
-
selector: zod_1.z.string().describe('CSS selector (
|
|
41
|
-
ref: zod_1.z.string().describe('Element ref from a
|
|
58
|
+
selector: zod_1.z.string().describe('CSS selector (or pass ref)').optional(),
|
|
59
|
+
ref: zod_1.z.string().describe('Element ref from a snapshot').optional(),
|
|
42
60
|
};
|
|
43
|
-
const tabIdField = zod_1.z.string().describe('Target tab id (defaults to the active tab)').optional();
|
|
44
|
-
const authWallField = zod_1.z.boolean().describe('Fail with [AUTH_REQUIRED] when the page this call lands on is a high-confidence sign-in wall (session expired). Off by default unless the server runs with --fail-on-auth-wall; snapshot still reports the verdict as `authWall` either way.').optional();
|
|
45
61
|
/**
|
|
46
|
-
* Frame targeting. Omitted = the top frame
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* authorized against its own URL, so a scan never reaches a site the allowlist
|
|
51
|
-
* does not cover.
|
|
62
|
+
* Frame targeting. Omitted = the top frame. `allFrames` is the one to reach for
|
|
63
|
+
* when a selector "should" match but does not — the element is almost always
|
|
64
|
+
* inside an iframe. Every frame is authorized against its own URL, so a scan
|
|
65
|
+
* never reaches a site the allowlist does not cover.
|
|
52
66
|
*/
|
|
53
67
|
const FRAME_PROPS = {
|
|
54
|
-
frameId: zod_1.z.number().describe('
|
|
55
|
-
allFrames: zod_1.z
|
|
56
|
-
.boolean()
|
|
57
|
-
.describe('Search every frame of the tab and act on the first that matches - use when a selector should match but does not (the element is in an iframe)')
|
|
58
|
-
.optional(),
|
|
68
|
+
frameId: zod_1.z.number().describe('Frame id from frames_list').optional(),
|
|
69
|
+
allFrames: zod_1.z.boolean().describe('Act on the first match in ANY frame (the element may be in an iframe)').optional(),
|
|
59
70
|
};
|
|
60
71
|
/**
|
|
61
|
-
*
|
|
62
|
-
* action needs no snapshot first
|
|
63
|
-
* ambiguity rather than acting on the wrong element.
|
|
72
|
+
* The full locator block. role+name targets an element the way a person reads
|
|
73
|
+
* the page, so an action needs no snapshot first; resolution is server-side.
|
|
64
74
|
*/
|
|
65
75
|
const LOCATOR_PROPS = {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
76
|
+
...TARGET_PROPS,
|
|
77
|
+
// These tools take role+name too, so their `selector` says so; the tools that
|
|
78
|
+
// only resolve a node keep the plain TARGET_PROPS wording.
|
|
79
|
+
selector: zod_1.z.string().describe('CSS selector (or ref, or role+name)').optional(),
|
|
80
|
+
role: zod_1.z.string().describe('ARIA role, e.g. button|link|textbox (pair with name)').optional(),
|
|
81
|
+
name: zod_1.z.string().describe('Accessible name / visible label (pair with role)').optional(),
|
|
82
|
+
nth: zod_1.z.number().describe('0-based index when role+name is ambiguous').optional(),
|
|
83
|
+
...FRAME_PROPS,
|
|
69
84
|
};
|
|
85
|
+
const tabIdField = zod_1.z.string().describe('Tab id (default: active tab)').optional();
|
|
86
|
+
const authWallField = zod_1.z.boolean().describe('Error with [AUTH_REQUIRED] if this lands on a sign-in wall (expired session)').optional();
|
|
70
87
|
const snapshotAfterField = zod_1.z
|
|
71
88
|
.boolean()
|
|
72
|
-
.describe('Return what CHANGED on the page after this action
|
|
89
|
+
.describe('Return what CHANGED on the page after this action instead of making you re-read it')
|
|
73
90
|
.optional();
|
|
74
91
|
const maxBytesField = zod_1.z
|
|
75
92
|
.number()
|
|
76
|
-
.describe(`Cap
|
|
93
|
+
.describe(`Cap returned content at N UTF-8 bytes (default ${limits_1.DEFAULT_MAX_OUTPUT_BYTES}); the full payload still lands in results/`)
|
|
77
94
|
.optional();
|
|
78
95
|
const waitUntilField = zod_1.z.enum(['load', 'domcontentloaded', 'networkidle']).describe('When to consider navigation done').optional();
|
|
79
96
|
exports.TOOL_DEFINITIONS = [
|
|
@@ -85,13 +102,13 @@ exports.TOOL_DEFINITIONS = [
|
|
|
85
102
|
{ name: 'back', description: 'Go back in history.', inputSchema: { tabId: tabIdField, failOnAuthWall: authWallField } },
|
|
86
103
|
{ name: 'forward', description: 'Go forward in history.', inputSchema: { tabId: tabIdField, failOnAuthWall: authWallField } },
|
|
87
104
|
{ name: 'reload', description: 'Reload the active (or given) tab.', inputSchema: { tabId: tabIdField, waitUntil: waitUntilField, failOnAuthWall: authWallField } },
|
|
88
|
-
{ name: 'click', description: 'Click an element. Target by selector, a snapshot ref, or role+name (e.g. role:"button", name:"Sign in") - the locator needs no snapshot first. trusted=true uses real OS-level input.', inputSchema: { ...
|
|
89
|
-
{ name: 'type', description: 'Type text into an element (target by selector, ref, or role+name). trusted=true sends real keystrokes (works on React/Vue controlled inputs).', inputSchema: { ...
|
|
90
|
-
{ name: 'select_option', description: 'Select option(s) of a <select> by value or visible label.', inputSchema: { ...
|
|
105
|
+
{ name: 'click', description: 'Click an element. Target by selector, a snapshot ref, or role+name (e.g. role:"button", name:"Sign in") - the locator needs no snapshot first. trusted=true uses real OS-level input.', inputSchema: { ...LOCATOR_PROPS, tabId: tabIdField, button: zod_1.z.enum(['left', 'right', 'middle']).optional(), clickCount: zod_1.z.number().optional(), trusted: zod_1.z.boolean().optional(), snapshotAfter: snapshotAfterField, failOnAuthWall: authWallField } },
|
|
106
|
+
{ name: 'type', description: 'Type text into an element (target by selector, ref, or role+name). trusted=true sends real keystrokes (works on React/Vue controlled inputs).', inputSchema: { ...LOCATOR_PROPS, text: zod_1.z.string(), tabId: tabIdField, clear: zod_1.z.boolean().optional(), pressEnter: zod_1.z.boolean().optional(), keyEvents: zod_1.z.boolean().optional(), trusted: zod_1.z.boolean().optional(), snapshotAfter: snapshotAfterField, failOnAuthWall: authWallField } },
|
|
107
|
+
{ name: 'select_option', description: 'Select option(s) of a <select> by value or visible label.', inputSchema: { ...LOCATOR_PROPS, values: zod_1.z.array(zod_1.z.string()), tabId: tabIdField, snapshotAfter: snapshotAfterField, failOnAuthWall: authWallField } },
|
|
91
108
|
{ name: 'press', description: 'Press a key (with optional modifiers).', inputSchema: { key: zod_1.z.string(), modifiers: zod_1.z.array(zod_1.z.string()).optional(), tabId: tabIdField, failOnAuthWall: authWallField } },
|
|
92
|
-
{ name: 'hover', description: 'Hover over an element.', inputSchema: { ...
|
|
109
|
+
{ name: 'hover', description: 'Hover over an element.', inputSchema: { ...LOCATOR_PROPS, tabId: tabIdField, snapshotAfter: snapshotAfterField } },
|
|
93
110
|
{ name: 'scroll', description: 'Scroll the page or to an element.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, x: zod_1.z.number().optional(), y: zod_1.z.number().optional(), deltaX: zod_1.z.number().optional(), deltaY: zod_1.z.number().optional(), tabId: tabIdField } },
|
|
94
|
-
{ name: 'screenshot', description: 'Capture a
|
|
111
|
+
{ name: 'screenshot', description: 'Capture a screenshot (page or element). Default is JPEG (quality 70) at CSS-pixel size, which is several times smaller than PNG and reads fine. Pass format:"png" for lossless, quality 1-100 for JPEG, scale 2 for device pixels on a Retina display or 0.5 to shrink.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, fullPage: zod_1.z.boolean().optional(), format: zod_1.z.enum(['jpeg', 'png']).optional(), quality: zod_1.z.number().optional(), scale: zod_1.z.number().optional(), tabId: tabIdField } },
|
|
95
112
|
{ name: 'get_text', description: 'Get visible text of the page or an element.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, tabId: tabIdField, maxBytes: maxBytesField } },
|
|
96
113
|
{ name: 'get_html', description: 'Get HTML of the page or an element. Output is capped (see maxBytes) and cut at a tag boundary; narrow it with `selector` rather than raising the cap when you can. Password field values are always blanked.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, outer: zod_1.z.boolean().optional(), tabId: tabIdField, maxBytes: maxBytesField } },
|
|
97
114
|
{ name: 'snapshot', description: 'Accessibility snapshot: interactive elements with refs to target by `ref` (more reliable than guessing CSS selectors). Pass diff:true to get only what changed since the last snapshot of this tab - far cheaper in a click/read loop. Password fields appear as secret:true with no value.', inputSchema: { interactiveOnly: zod_1.z.boolean().optional(), max: zod_1.z.number().optional(), diff: zod_1.z.boolean().describe('Return added/removed/changed elements since the previous snapshot of this tab instead of the whole tree').optional(), failOnAuthWall: authWallField, ...FRAME_PROPS, tabId: tabIdField } },
|
|
@@ -182,6 +199,48 @@ exports.TOOL_DEFINITIONS = [
|
|
|
182
199
|
},
|
|
183
200
|
},
|
|
184
201
|
];
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Tool allowlist (--tools)
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
/** Every advertised tool name, in catalog order. */
|
|
206
|
+
exports.TOOL_NAMES = exports.TOOL_DEFINITIONS.map((d) => d.name);
|
|
207
|
+
/** `null` = the whole catalog (the default). */
|
|
208
|
+
let toolAllowlist = null;
|
|
209
|
+
/**
|
|
210
|
+
* Restrict the tool surface to `names` (`--tools`). The catalog is the single
|
|
211
|
+
* largest fixed cost of having this server connected: every tool's JSON Schema
|
|
212
|
+
* is re-sent to the model on EVERY turn. A run that only reads pages has no use
|
|
213
|
+
* for uploads, PDFs or task management, and should not pay for their schemas.
|
|
214
|
+
*
|
|
215
|
+
* Excluded tools are neither advertised in `tools/list` nor callable — a
|
|
216
|
+
* `batch` op naming one is refused exactly like an unknown tool, so hiding a
|
|
217
|
+
* tool is a real restriction and not just a display filter.
|
|
218
|
+
*
|
|
219
|
+
* Passing `null`/`undefined`/`[]` restores the full catalog. Unknown names
|
|
220
|
+
* throw rather than being ignored: a typo that silently drops `click` from the
|
|
221
|
+
* surface is far more expensive to debug than a startup error.
|
|
222
|
+
*/
|
|
223
|
+
function setToolAllowlist(names) {
|
|
224
|
+
if (!names || names.length === 0) {
|
|
225
|
+
toolAllowlist = null;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const known = new Set(exports.TOOL_NAMES);
|
|
229
|
+
const unknown = names.filter((n) => !known.has(n));
|
|
230
|
+
if (unknown.length > 0) {
|
|
231
|
+
throw new Error(`--tools: unknown tool ${unknown.map((n) => JSON.stringify(n)).join(', ')}. ` +
|
|
232
|
+
`Known tools: ${exports.TOOL_NAMES.join(', ')}`);
|
|
233
|
+
}
|
|
234
|
+
toolAllowlist = new Set(names);
|
|
235
|
+
}
|
|
236
|
+
/** Is `name` on the surface? True for every catalog tool when no allowlist is set. */
|
|
237
|
+
function isToolEnabled(name) {
|
|
238
|
+
return toolAllowlist === null || toolAllowlist.has(name);
|
|
239
|
+
}
|
|
240
|
+
/** The names actually advertised, in catalog order. */
|
|
241
|
+
function enabledToolNames() {
|
|
242
|
+
return exports.TOOL_NAMES.filter(isToolEnabled);
|
|
243
|
+
}
|
|
185
244
|
const GATE_CONTEXT = 'cannot resolve the target tab URL for the policy gate';
|
|
186
245
|
/**
|
|
187
246
|
* Resolve the URL the policy should be evaluated against: the URL of the tab
|
|
@@ -201,15 +260,15 @@ const GATE_CONTEXT = 'cannot resolve the target tab URL for the policy gate';
|
|
|
201
260
|
*
|
|
202
261
|
* Prefers a URL the backend already reported over asking again: the extension
|
|
203
262
|
* rides the tab's landing URL home on every result frame, which is what keeps a
|
|
204
|
-
* gated call to ONE round-trip instead of two.
|
|
205
|
-
* the
|
|
263
|
+
* gated call to ONE round-trip instead of two. The active-tab cache serves calls
|
|
264
|
+
* without a `tabId`; the per-tab cache (fed by results for that tab and by any
|
|
265
|
+
* `tabs_list`) serves explicitly-targeted ones, so a parallel batch over N tabs
|
|
266
|
+
* gates on the one listing that opened it rather than N more.
|
|
206
267
|
*/
|
|
207
268
|
async function gatedUrl(ex, tabId) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
return known;
|
|
212
|
-
}
|
|
269
|
+
const known = tabId ? ex.cachedTabUrl?.(tabId) : ex.cachedActiveUrl?.();
|
|
270
|
+
if (known)
|
|
271
|
+
return known;
|
|
213
272
|
let tabs;
|
|
214
273
|
try {
|
|
215
274
|
tabs = await ex.tabsList();
|
|
@@ -547,13 +606,20 @@ exports.TOOL_HANDLERS = {
|
|
|
547
606
|
},
|
|
548
607
|
screenshot: async (a, ctx) => {
|
|
549
608
|
await gate(ctx, 'screenshot', { tabId: tabId(a) });
|
|
609
|
+
const format = (0, validators_1.optionalString)(a, 'format');
|
|
610
|
+
if (format !== undefined && format !== 'jpeg' && format !== 'png') {
|
|
611
|
+
throw new validators_1.McpToolError('"format" must be "jpeg" or "png"');
|
|
612
|
+
}
|
|
550
613
|
const shot = await ctx.ex.screenshot({
|
|
551
614
|
tabId: tabId(a),
|
|
552
615
|
...frameOpts(a),
|
|
553
616
|
fullPage: (0, validators_1.optionalBoolean)(a, 'fullPage'),
|
|
554
617
|
target: (0, validators_1.optionalTarget)(a),
|
|
618
|
+
format,
|
|
619
|
+
quality: (0, validators_1.optionalNumber)(a, 'quality', { min: 1, max: 100 }),
|
|
620
|
+
scale: (0, validators_1.optionalNumber)(a, 'scale', { min: 0.1, max: 4 }),
|
|
555
621
|
});
|
|
556
|
-
(0, workspace_1.saveScreenshot)(shot.dataBase64);
|
|
622
|
+
(0, workspace_1.saveScreenshot)(shot.dataBase64, shot.mimeType === 'image/jpeg' ? 'jpg' : 'png');
|
|
557
623
|
const caption = shot.truncated ? `(truncated; full height ${shot.fullHeight}px)` : undefined;
|
|
558
624
|
return (0, envelopes_1.imageResult)(shot.dataBase64, shot.mimeType, caption);
|
|
559
625
|
},
|
|
@@ -977,6 +1043,10 @@ function isRetryableFault(name, err) {
|
|
|
977
1043
|
return err instanceof types_1.ExecutorError && err.code === 'EXTENSION_DISCONNECTED' && RETRY_SAFE_TOOLS.has(name);
|
|
978
1044
|
}
|
|
979
1045
|
async function dispatchToolCall(name, rawArgs) {
|
|
1046
|
+
// The allowlist is checked here, not only at registration, so a `batch` op
|
|
1047
|
+
// cannot reach a tool the operator kept off the surface.
|
|
1048
|
+
if (!isToolEnabled(name))
|
|
1049
|
+
return (0, envelopes_1.errorResult)(`tool not enabled on this server (--tools): ${name}`);
|
|
980
1050
|
const handler = exports.TOOL_HANDLERS[name];
|
|
981
1051
|
if (!handler)
|
|
982
1052
|
return (0, envelopes_1.errorResult)(`unknown tool: ${name}`);
|
|
@@ -1039,7 +1109,13 @@ function registerTools(server) {
|
|
|
1039
1109
|
// just routes back through `dispatchToolCall` — our never-throw firewall that
|
|
1040
1110
|
// applies the rate limit, executor readiness, policy gate, and history log.
|
|
1041
1111
|
for (const d of exports.TOOL_DEFINITIONS) {
|
|
1112
|
+
if (!isToolEnabled(d.name))
|
|
1113
|
+
continue;
|
|
1042
1114
|
server.registerTool(d.name, { description: d.description, inputSchema: d.inputSchema }, async (args) => dispatchToolCall(d.name, args));
|
|
1043
1115
|
}
|
|
1116
|
+
const advertised = enabledToolNames();
|
|
1117
|
+
if (advertised.length < exports.TOOL_NAMES.length) {
|
|
1118
|
+
(0, log_1.logErr)(`--tools: advertising ${advertised.length} of ${exports.TOOL_NAMES.length} tools (${advertised.join(', ')})`);
|
|
1119
|
+
}
|
|
1044
1120
|
}
|
|
1045
1121
|
//# sourceMappingURL=tools.js.map
|
package/docs/BLUEPRINT.md
CHANGED
|
@@ -173,7 +173,7 @@ export interface EvalResult { ok: boolean; value?: unknown; type?: string; error
|
|
|
173
173
|
export interface WaitResult { matched: boolean; ref?: string; waitedMs: number; }
|
|
174
174
|
export interface ActionOk { ok: true; }
|
|
175
175
|
export interface ScreenshotResult {
|
|
176
|
-
dataBase64: string; mimeType: 'image/png';
|
|
176
|
+
dataBase64: string; mimeType: 'image/png' | 'image/jpeg'; // jpeg q70 @ CSS px by default; format/quality/scale opt-in
|
|
177
177
|
width: number; height: number; truncated: boolean; fullHeight?: number; // fullPage cap metadata
|
|
178
178
|
}
|
|
179
179
|
export interface DownloadResult { path: string; backend: BackendKind; bytes: number; mimeType?: string; suggestedName?: string; }
|