@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.
@@ -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 this window trades a little
22
- * pre-check precision for half the traffic — never enforcement itself.
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 = 1_000;
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', { fullPage: opts?.fullPage, ...targetParams(opts?.target), ...frameParams(opts) }, { tabId: opts?.tabId }));
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;
@@ -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, server-side, and the caller never sees the tree. Matching runs
13
- * strongest-first (exact, then case-insensitive, then contains) so an
14
- * unambiguous name wins outright, and an ambiguous one fails loudly with the
15
- * candidates rather than silently clicking the first row.
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 {
@@ -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, server-side, and the caller never sees the tree. Matching runs
14
- * strongest-first (exact, then case-insensitive, then contains) so an
15
- * unambiguous name wins outright, and an ambiguous one fails loudly with the
16
- * candidates rather than silently clicking the first row.
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
- const sample = snap.nodes
83
- .filter((n) => !want.role || norm(n.role) === norm(want.role))
84
- .slice(0, 8)
85
- .map(describe);
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(', ')}. `
@@ -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;
@@ -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
- /** Shared selector|ref target — both optional; a handler that needs one calls `requireTarget`. */
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 (exactly one of selector|ref)').optional(),
41
- ref: zod_1.z.string().describe('Element ref from a prior read (exactly one of selector|ref)').optional(),
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, which is what every call did before
47
- * frames were addressable. `allFrames` is the one to reach for when a selector
48
- * "should" match but does not: the element is almost always inside an iframe
49
- * (checkout widgets, OAuth consent, embedded editors). Every frame is
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('Act inside this frame (ids come from frames_list)').optional(),
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
- * Target an element by role + accessible name instead of a CSS selector, so an
62
- * action needs no snapshot first. Resolution is server-side and fails loudly on
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
- role: zod_1.z.string().describe('Target by ARIA role (e.g. button, link, textbox) - alternative to selector/ref').optional(),
67
- name: zod_1.z.string().describe('Target by accessible name/visible label (pairs with role)').optional(),
68
- nth: zod_1.z.number().describe('Pick the nth (0-based) match when a role+name locator is legitimately ambiguous').optional(),
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 (added/removed/changed elements vs the last snapshot) instead of making you re-read the page')
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 the returned content at this many UTF-8 bytes (default ${limits_1.DEFAULT_MAX_OUTPUT_BYTES}). A truncated result reports truncated/totalBytes/returnedBytes. The full payload is still written to the task's results/ dir.`)
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: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_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 } },
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: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_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 } },
90
- { name: 'select_option', description: 'Select option(s) of a <select> by value or visible label.', inputSchema: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_PROPS, values: zod_1.z.array(zod_1.z.string()), tabId: tabIdField, snapshotAfter: snapshotAfterField, failOnAuthWall: authWallField } },
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: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_PROPS, tabId: tabIdField, snapshotAfter: snapshotAfterField } },
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 PNG screenshot (page or element).', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, fullPage: zod_1.z.boolean().optional(), tabId: tabIdField } },
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. That cache only ever describes
205
- * the active tab, so it is bypassed whenever an explicit `tabId` is in play.
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
- if (!tabId) {
209
- const known = ex.cachedActiveUrl?.();
210
- if (known)
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; }