@mehmoodqureshi/chrome-mcp 0.9.7 → 0.9.8

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.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The sequencing behind a batched `fill_form`.
3
+ *
4
+ * Batching a form into ONE wire command removes a round-trip per field, but it
5
+ * also removes the router's per-command policy gate: the router evaluates once,
6
+ * before the first field, and then N writes happen with nothing re-checking
7
+ * where they land. That matters because a field's input handler — or a
8
+ * checkbox's click — can navigate the tab, and the values still to be written
9
+ * are exactly the sensitive ones (passwords, personal data). Typing them into
10
+ * whatever page replaced the allowlisted one would be a silent exfiltration.
11
+ *
12
+ * So the gate moves in here and runs per field, against the tab's CURRENT url,
13
+ * reproducing what N separate commands would have enforced. This lives in
14
+ * `shared/` for the same reason `evaluatePolicy` does: it is a security
15
+ * decision, so it belongs where both ends and the test suite can reach it
16
+ * (`extension/` is outside the tsconfig the tests compile).
17
+ */
18
+ import type { FillFieldOp, FillFormWireResult, WirePolicy } from './protocol';
19
+ /** What the caller must supply; everything chrome-specific stays outside. */
20
+ export interface FillFormHooks {
21
+ /** The target tab's url right now — re-read before every field. */
22
+ currentUrl: () => Promise<string>;
23
+ /** The live wire policy, or null before a handshake has delivered one. */
24
+ policy: () => WirePolicy | null;
25
+ /** Perform one field write. Throws to fail the batch at this field. */
26
+ write: (op: FillFieldOp) => Promise<void>;
27
+ /** Map a thrown value to a wire error. */
28
+ toError: (err: unknown) => {
29
+ code: FillFormError['code'];
30
+ message: string;
31
+ };
32
+ }
33
+ type FillFormError = NonNullable<FillFormWireResult['error']>;
34
+ /**
35
+ * Run `ops` in order, re-gating before each one, stopping at the first failure.
36
+ * Never throws: the failing field is reported in `error` and `filled` counts the
37
+ * writes that actually landed, so the server can tell the caller how far the
38
+ * form got rather than leaving it ambiguous.
39
+ */
40
+ export declare function runFillFields(ops: readonly FillFieldOp[], hooks: FillFormHooks): Promise<FillFormWireResult>;
41
+ export {};
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ /**
3
+ * The sequencing behind a batched `fill_form`.
4
+ *
5
+ * Batching a form into ONE wire command removes a round-trip per field, but it
6
+ * also removes the router's per-command policy gate: the router evaluates once,
7
+ * before the first field, and then N writes happen with nothing re-checking
8
+ * where they land. That matters because a field's input handler — or a
9
+ * checkbox's click — can navigate the tab, and the values still to be written
10
+ * are exactly the sensitive ones (passwords, personal data). Typing them into
11
+ * whatever page replaced the allowlisted one would be a silent exfiltration.
12
+ *
13
+ * So the gate moves in here and runs per field, against the tab's CURRENT url,
14
+ * reproducing what N separate commands would have enforced. This lives in
15
+ * `shared/` for the same reason `evaluatePolicy` does: it is a security
16
+ * decision, so it belongs where both ends and the test suite can reach it
17
+ * (`extension/` is outside the tsconfig the tests compile).
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.runFillFields = runFillFields;
21
+ const policy_1 = require("./policy");
22
+ /**
23
+ * Run `ops` in order, re-gating before each one, stopping at the first failure.
24
+ * Never throws: the failing field is reported in `error` and `filled` counts the
25
+ * writes that actually landed, so the server can tell the caller how far the
26
+ * form got rather than leaving it ambiguous.
27
+ */
28
+ async function runFillFields(ops, hooks) {
29
+ const res = { filled: 0 };
30
+ for (const op of ops) {
31
+ try {
32
+ // Fails CLOSED, exactly as the router does: no policy means nothing runs.
33
+ const policy = hooks.policy();
34
+ if (!policy)
35
+ throw new PolicyStop('no policy is in force; refusing to fill');
36
+ const verdict = (0, policy_1.evaluatePolicy)(await hooks.currentUrl(), 'fill_form', policy);
37
+ if (!verdict.ok)
38
+ throw new PolicyStop(verdict.reason);
39
+ await hooks.write(op);
40
+ }
41
+ catch (err) {
42
+ res.error =
43
+ err instanceof PolicyStop
44
+ ? { selector: op.selector, code: 'POLICY_DENIED', message: err.message }
45
+ : { selector: op.selector, ...hooks.toError(err) };
46
+ return res;
47
+ }
48
+ res.filled++;
49
+ }
50
+ return res;
51
+ }
52
+ /** A refusal raised by the gate itself, kept distinct from a page-op failure. */
53
+ class PolicyStop extends Error {
54
+ }
55
+ //# sourceMappingURL=fill-form.js.map
@@ -42,6 +42,7 @@ const MUTATE_CONTENT = new Set([
42
42
  'press',
43
43
  'hover',
44
44
  'scroll',
45
+ 'fill_form',
45
46
  ]);
46
47
  /** Navigation — URL-gated by the DESTINATION url, and mutation-gated. */
47
48
  const NAVIGATION = new Set([
@@ -8,9 +8,10 @@
8
8
  *
9
9
  * The server is the WebSocket SERVER; the extension is the single privileged
10
10
  * CLIENT that dials in. Methods on the wire mirror the MCP primitives 1:1.
11
- * Helpers (extract_links / read_as_markdown / fill_form) are NOT on the wire —
12
- * they are composed server-side from these primitives. Only `download_file` is a
13
- * wire method beyond the primitives.
11
+ * Helpers (extract_links / read_as_markdown) are NOT on the wire — they are
12
+ * composed server-side from these primitives. `download_file` is a wire method
13
+ * beyond the primitives, and so is `fill_form` (a batch of `type`/`click` writes
14
+ * in one round-trip; capability-gated, see `WIRE_CAP_FILL_FORM`).
14
15
  */
15
16
  /** Bumped on any breaking change to the frames below. */
16
17
  export declare const PROTOCOL_VERSION: 1;
@@ -39,12 +40,36 @@ export declare const CLOSE_SUPERSEDED: 4000;
39
40
  * every call. Without it, the server falls back to fetching the URL itself.
40
41
  */
41
42
  export declare const WIRE_CAP_TAB_URL: "tab-url";
43
+ /**
44
+ * `fill-form`: this extension handles the `fill_form` wire method — every field
45
+ * of a form written in ONE round-trip. Without it the server fills field by
46
+ * field over `type`/`click`, which every extension build understands.
47
+ */
48
+ export declare const WIRE_CAP_FILL_FORM: "fill-form";
49
+ /** One field write inside a `fill_form` command: set a value, or click to toggle. */
50
+ export interface FillFieldOp {
51
+ selector: string;
52
+ /** A string is value-set (cleared first); a boolean toggles via a click. */
53
+ value: string | boolean;
54
+ }
55
+ /**
56
+ * `fill_form` result. Ops run in order and stop at the first failure, so
57
+ * `filled` counts the fields that landed and `error` names the one that did not.
58
+ */
59
+ export interface FillFormWireResult {
60
+ filled: number;
61
+ error?: {
62
+ selector: string;
63
+ code: ExecutorErrorCode;
64
+ message: string;
65
+ };
66
+ }
42
67
  /**
43
68
  * Every method that may travel on the wire = the MCP primitives 1:1, plus
44
69
  * `download_file` (privileged, executor-owned) and `ping_probe` (a short-deadline
45
70
  * responsiveness check used to detect a dead-but-not-yet-reconnected worker).
46
71
  */
47
- export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'snapshot' | 'select_option' | 'get_cookies' | 'storage' | 'eval' | 'wait_for' | 'download_file' | 'upload_file' | 'frames_list' | 'observers' | 'print_pdf' | 'ping_probe';
72
+ export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'snapshot' | 'select_option' | 'get_cookies' | 'storage' | 'eval' | 'wait_for' | 'download_file' | 'upload_file' | 'frames_list' | 'observers' | 'print_pdf' | 'fill_form' | 'ping_probe';
48
73
  /** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
49
74
  export declare const WIRE_METHODS: readonly WireMethod[];
50
75
  export type ExecutorErrorCode = 'NO_TARGET' | 'TARGET_GONE' | 'DETACHED' | 'DEVTOOLS_OPEN' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_THREW' | 'TIMEOUT' | 'BAD_ARGS' | 'CDP_ERROR' | 'POLICY_DENIED' | 'DOWNLOAD_FAILED' | 'UPLOAD_FAILED' | 'FRAME_NOT_FOUND' | 'OBSERVERS_DISABLED' | 'UNKNOWN_METHOD';
@@ -9,12 +9,13 @@
9
9
  *
10
10
  * The server is the WebSocket SERVER; the extension is the single privileged
11
11
  * CLIENT that dials in. Methods on the wire mirror the MCP primitives 1:1.
12
- * Helpers (extract_links / read_as_markdown / fill_form) are NOT on the wire —
13
- * they are composed server-side from these primitives. Only `download_file` is a
14
- * wire method beyond the primitives.
12
+ * Helpers (extract_links / read_as_markdown) are NOT on the wire — they are
13
+ * composed server-side from these primitives. `download_file` is a wire method
14
+ * beyond the primitives, and so is `fill_form` (a batch of `type`/`click` writes
15
+ * in one round-trip; capability-gated, see `WIRE_CAP_FILL_FORM`).
15
16
  */
16
17
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.WIRE_METHODS = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
18
+ exports.WIRE_METHODS = exports.WIRE_CAP_FILL_FORM = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
18
19
  /** Bumped on any breaking change to the frames below. */
19
20
  exports.PROTOCOL_VERSION = 1;
20
21
  /**
@@ -41,6 +42,12 @@ exports.CLOSE_SUPERSEDED = 4000;
41
42
  * every call. Without it, the server falls back to fetching the URL itself.
42
43
  */
43
44
  exports.WIRE_CAP_TAB_URL = 'tab-url';
45
+ /**
46
+ * `fill-form`: this extension handles the `fill_form` wire method — every field
47
+ * of a form written in ONE round-trip. Without it the server fills field by
48
+ * field over `type`/`click`, which every extension build understands.
49
+ */
50
+ exports.WIRE_CAP_FILL_FORM = 'fill-form';
44
51
  /** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
45
52
  exports.WIRE_METHODS = [
46
53
  'tabs_list',
@@ -70,6 +77,7 @@ exports.WIRE_METHODS = [
70
77
  'frames_list',
71
78
  'observers',
72
79
  'print_pdf',
80
+ 'fill_form',
73
81
  'ping_probe',
74
82
  ];
75
83
  //# sourceMappingURL=protocol.js.map
@@ -12,6 +12,10 @@
12
12
  */
13
13
  import type { WebSocket } from 'ws';
14
14
  import { type WireEvent, type WireMethod } from '../../shared/protocol';
15
+ import { type ExecutorErrorCodeLocal } from '../executor/types';
16
+ /** Map a wire error code onto a local ExecutorError code (the wire enum is a
17
+ * near-superset; unknown codes degrade to CDP_ERROR while keeping the message). */
18
+ export declare function mapWireErrorCode(code: string): ExecutorErrorCodeLocal;
15
19
  export interface ConnectionDeps {
16
20
  ws: WebSocket;
17
21
  extId: string;
@@ -34,6 +38,8 @@ export declare class ExtensionConnection {
34
38
  private missedPongs;
35
39
  /** Whether this extension reports `tabUrl` and gates fail-closed. */
36
40
  private readonly reportsTabUrl;
41
+ /** Every capability the extension advertised in `hello`. */
42
+ private readonly caps;
37
43
  /** Last URL the ACTIVE tab reported, with the wall-clock it arrived. */
38
44
  private activeUrl;
39
45
  /** Last URL each explicitly-targeted tab reported, keyed by wire tab id. A
@@ -69,6 +75,8 @@ export declare class ExtensionConnection {
69
75
  * to forget what we knew, never to keep believing it.
70
76
  */
71
77
  private rememberActiveUrl;
78
+ /** Whether the extension advertised `cap` in its `hello`. */
79
+ hasCap(cap: string): boolean;
72
80
  /**
73
81
  * The active tab's last reported URL if it is younger than `maxAgeMs`, else
74
82
  * null — the caller then resolves it the slow way. Never returns a guess.
@@ -13,10 +13,11 @@
13
13
  */
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.ExtensionConnection = void 0;
16
+ exports.mapWireErrorCode = mapWireErrorCode;
16
17
  const protocol_1 = require("../../shared/protocol");
17
18
  const types_1 = require("../executor/types");
18
19
  const MAX_BUFFERED_BYTES = 8 * 1024 * 1024;
19
- const LONG_METHODS = new Set(['screenshot', 'wait_for', 'navigate', 'download_file']);
20
+ const LONG_METHODS = new Set(['screenshot', 'wait_for', 'navigate', 'download_file', 'fill_form']);
20
21
  function defaultTimeoutFor(method) {
21
22
  return LONG_METHODS.has(method) ? 60_000 : 30_000;
22
23
  }
@@ -51,6 +52,8 @@ class ExtensionConnection {
51
52
  missedPongs = 0;
52
53
  /** Whether this extension reports `tabUrl` and gates fail-closed. */
53
54
  reportsTabUrl;
55
+ /** Every capability the extension advertised in `hello`. */
56
+ caps;
54
57
  /** Last URL the ACTIVE tab reported, with the wall-clock it arrived. */
55
58
  activeUrl = null;
56
59
  /** Last URL each explicitly-targeted tab reported, keyed by wire tab id. A
@@ -64,7 +67,8 @@ class ExtensionConnection {
64
67
  this.ws = deps.ws;
65
68
  this.extId = deps.extId;
66
69
  this.sessionId = deps.sessionId;
67
- this.reportsTabUrl = deps.caps?.includes(protocol_1.WIRE_CAP_TAB_URL) ?? false;
70
+ this.caps = new Set(deps.caps ?? []);
71
+ this.reportsTabUrl = this.caps.has(protocol_1.WIRE_CAP_TAB_URL);
68
72
  this.onEvent = deps.onEvent;
69
73
  this.onClose = deps.onClose;
70
74
  this.onLog = deps.onLog;
@@ -237,6 +241,10 @@ class ExtensionConnection {
237
241
  return; // already invalidated; re-caching would race
238
242
  this.activeUrl = frame.tabUrl ? { url: frame.tabUrl, at: Date.now() } : null;
239
243
  }
244
+ /** Whether the extension advertised `cap` in its `hello`. */
245
+ hasCap(cap) {
246
+ return this.caps.has(cap);
247
+ }
240
248
  /**
241
249
  * The active tab's last reported URL if it is younger than `maxAgeMs`, else
242
250
  * null — the caller then resolves it the slow way. Never returns a guess.
@@ -122,6 +122,9 @@ export declare class BridgeServer {
122
122
  timeoutMs?: number;
123
123
  profile?: string;
124
124
  }): Promise<unknown>;
125
+ /** Whether the browser paired as `profile` advertised capability `cap`. A peer
126
+ * cannot see the hub's handshake, so it answers false (the conservative path). */
127
+ hasCap(profile: string | undefined, cap: string): boolean;
125
128
  /**
126
129
  * The active tab's URL for `profile` as last reported by the extension, if it
127
130
  * is younger than `maxAgeMs`. Null means "ask properly" — an extension too old
@@ -296,6 +296,14 @@ class BridgeServer {
296
296
  }
297
297
  return conn.sendCommand(method, params, opts);
298
298
  }
299
+ /** Whether the browser paired as `profile` advertised capability `cap`. A peer
300
+ * cannot see the hub's handshake, so it answers false (the conservative path). */
301
+ hasCap(profile, cap) {
302
+ if (this.hub)
303
+ return false;
304
+ const conn = this.conns.get(routeKey(profile));
305
+ return !!conn?.isOpen() && conn.hasCap(cap);
306
+ }
299
307
  /**
300
308
  * The active tab's URL for `profile` as last reported by the extension, if it
301
309
  * is younger than `maxAgeMs`. Null means "ask properly" — an extension too old
@@ -7,7 +7,7 @@
7
7
  * method-specific arguments travel in `params`. Results are trusted shapes
8
8
  * produced by the extension router (validated there).
9
9
  */
10
- import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type FrameInfo, type FrameOpts, type ObserverArgs, type ObserverReadResult, type PdfResult, type KeyModifier, type MouseButton, type NavResult, type ScreenshotEncoding, type ScreenshotResult, type SnapshotLocator, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
10
+ import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type FillFieldOp, type FrameInfo, type FrameOpts, type ObserverArgs, type ObserverReadResult, type PdfResult, type KeyModifier, type MouseButton, type NavResult, type ScreenshotEncoding, type ScreenshotResult, type SnapshotLocator, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
11
11
  import type { BridgeServer } from '../bridge/server';
12
12
  export declare class ExtensionExecutor implements Executor {
13
13
  private readonly bridge;
@@ -69,6 +69,11 @@ export declare class ExtensionExecutor implements Executor {
69
69
  fill(t: Target, value: string, opts?: {
70
70
  tabId?: TabId;
71
71
  } & FrameOpts): Promise<ActionOk>;
72
+ fillFields(fields: FillFieldOp[], opts?: {
73
+ tabId?: TabId;
74
+ } & FrameOpts): Promise<{
75
+ filled: number;
76
+ } | null>;
72
77
  press(key: string, opts?: {
73
78
  tabId?: TabId;
74
79
  modifiers?: KeyModifier[];
@@ -11,6 +11,8 @@
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.ExtensionExecutor = void 0;
13
13
  const types_1 = require("./types");
14
+ const protocol_1 = require("../../shared/protocol");
15
+ const connection_1 = require("../bridge/connection");
14
16
  const workspace_1 = require("../bridge/workspace");
15
17
  /**
16
18
  * How long a reported active-tab URL stays usable for the policy gate.
@@ -143,6 +145,19 @@ class ExtensionExecutor {
143
145
  // No dedicated wire method: a cleared insertText is the fill primitive.
144
146
  return (await this.send('type', { ...targetParams(t), ...frameParams(opts), text: value, clear: true, keyEvents: false }, { tabId: opts?.tabId }));
145
147
  }
148
+ async fillFields(fields, opts) {
149
+ // An extension that predates the op never advertised it: let the caller go field by field.
150
+ if (!this.bridge.hasCap(this.activeProfile(), protocol_1.WIRE_CAP_FILL_FORM))
151
+ return null;
152
+ const res = (await this.send('fill_form', { ops: fields, ...frameParams(opts) }, { tabId: opts?.tabId }));
153
+ if (res.error) {
154
+ // Say how far the batch got: the fields before this one DID land, and a
155
+ // blind retry of the whole form would write them a second time.
156
+ const where = `field ${res.filled + 1} of ${fields.length} (${res.error.selector}) failed after ${res.filled} filled`;
157
+ throw new types_1.ExecutorError((0, connection_1.mapWireErrorCode)(res.error.code), `${where}: ${res.error.message}`);
158
+ }
159
+ return { filled: res.filled };
160
+ }
146
161
  async press(key, opts) {
147
162
  return (await this.send('press', { key, modifiers: opts?.modifiers }, { tabId: opts?.tabId }));
148
163
  }
@@ -10,7 +10,8 @@
10
10
  * `mcp/helpers.ts` from these primitives; only `download` is privileged.
11
11
  */
12
12
  import type { ObserverReadResult, DialogPolicy } from '../../shared/observers';
13
- export type { ObserverReadResult, DialogPolicy };
13
+ import type { FillFieldOp } from '../../shared/protocol';
14
+ export type { ObserverReadResult, DialogPolicy, FillFieldOp };
14
15
  export type BackendKind = 'extension' | 'cdp';
15
16
  export type WaitUntil = 'load' | 'domcontentloaded' | 'networkidle';
16
17
  export type KeyModifier = 'Alt' | 'Control' | 'Meta' | 'Shift';
@@ -334,6 +335,17 @@ export interface Executor {
334
335
  gone?: boolean;
335
336
  timeoutMs?: number;
336
337
  } & FrameOpts): Promise<WaitResult>;
338
+ /**
339
+ * Write several fields in ONE round-trip (string = value-set like `fill`,
340
+ * boolean = toggle via click), in order, throwing the first field's failure.
341
+ * Resolves null when the live backend cannot batch (e.g. an extension build
342
+ * older than the op) — the caller then falls back to `fill`/`click` per field.
343
+ */
344
+ fillFields?(fields: FillFieldOp[], opts?: {
345
+ tabId?: TabId;
346
+ } & FrameOpts): Promise<{
347
+ filled: number;
348
+ } | null>;
337
349
  /** Every frame of a tab that the extension can inject into, with its URL. */
338
350
  framesList?(opts?: {
339
351
  tabId?: TabId;
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * src/mcp/helpers.ts — the high-level tools composed SERVER-SIDE from executor
3
3
  * primitives. They never touch the wire directly: `extract_links` and
4
- * `read_as_markdown` read via primitives; `fill_form` sequences fill+click.
4
+ * `read_as_markdown` read via primitives; `fill_form` batches its field writes
5
+ * when the backend can (else sequences fill+click).
5
6
  * (Only `download_file` is privileged and lives on the executor.)
6
7
  */
7
8
  import type { Executor, FrameOpts } from '../executor/types';
@@ -32,7 +33,12 @@ export declare function readAsMarkdown(ex: Executor, args: {
32
33
  selector?: string;
33
34
  tabId?: string;
34
35
  } & FrameOpts): Promise<string>;
35
- /** Fill a set of fields (keyed by selector) and optionally submit. */
36
+ /**
37
+ * Fill a set of fields (keyed by selector) and optionally submit. All writes go
38
+ * out as ONE executor call when the backend can batch them; otherwise (older
39
+ * extension, CDP) they fall back to one fill/click per field. Either way the
40
+ * first failing field throws, and submit is a separate click after the fills.
41
+ */
36
42
  export declare function fillForm(ex: Executor, args: {
37
43
  fields: Record<string, string | boolean>;
38
44
  submitSelector?: string;
@@ -2,7 +2,8 @@
2
2
  /**
3
3
  * src/mcp/helpers.ts — the high-level tools composed SERVER-SIDE from executor
4
4
  * primitives. They never touch the wire directly: `extract_links` and
5
- * `read_as_markdown` read via primitives; `fill_form` sequences fill+click.
5
+ * `read_as_markdown` read via primitives; `fill_form` batches its field writes
6
+ * when the backend can (else sequences fill+click).
6
7
  * (Only `download_file` is privileged and lives on the executor.)
7
8
  */
8
9
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -80,20 +81,29 @@ async function readAsMarkdown(ex, args) {
80
81
  });
81
82
  return (0, markdown_extract_1.htmlToMarkdown)(html);
82
83
  }
83
- /** Fill a set of fields (keyed by selector) and optionally submit. */
84
+ /**
85
+ * Fill a set of fields (keyed by selector) and optionally submit. All writes go
86
+ * out as ONE executor call when the backend can batch them; otherwise (older
87
+ * extension, CDP) they fall back to one fill/click per field. Either way the
88
+ * first failing field throws, and submit is a separate click after the fills.
89
+ */
84
90
  async function fillForm(ex, args) {
85
91
  const opts = { tabId: args.tabId, frameId: args.frameId, allFrames: args.allFrames };
86
- let filled = 0;
87
- for (const [selector, value] of Object.entries(args.fields)) {
88
- const target = { selector };
89
- if (typeof value === 'boolean') {
90
- // Checkbox/radio: a click toggles it.
91
- await ex.click(target, opts);
92
- }
93
- else {
94
- await ex.fill(target, value, opts);
92
+ const ops = Object.entries(args.fields).map(([selector, value]) => ({ selector, value }));
93
+ const batched = ops.length > 0 && ex.fillFields ? await ex.fillFields(ops, opts) : null;
94
+ let filled = batched?.filled ?? 0;
95
+ if (!batched) {
96
+ for (const { selector, value } of ops) {
97
+ const target = { selector };
98
+ if (typeof value === 'boolean') {
99
+ // Checkbox/radio: a click toggles it.
100
+ await ex.click(target, opts);
101
+ }
102
+ else {
103
+ await ex.fill(target, value, opts);
104
+ }
105
+ filled++;
95
106
  }
96
- filled++;
97
107
  }
98
108
  let submitted = false;
99
109
  if (args.submitSelector) {
@@ -3,6 +3,7 @@
3
3
  // shared/protocol.ts
4
4
  var PROTOCOL_VERSION = 1;
5
5
  var WIRE_CAP_TAB_URL = "tab-url";
6
+ var WIRE_CAP_FILL_FORM = "fill-form";
6
7
  var WIRE_METHODS = [
7
8
  "tabs_list",
8
9
  "tab_select",
@@ -31,6 +32,7 @@
31
32
  "frames_list",
32
33
  "observers",
33
34
  "print_pdf",
35
+ "fill_form",
34
36
  "ping_probe"
35
37
  ];
36
38
 
@@ -72,7 +74,7 @@
72
74
  // This build gates fail-closed and reports tab URLs on results, so the
73
75
  // server may skip its pre-flight tabs_list. An older build omits this and
74
76
  // the server keeps fetching the URL itself.
75
- caps: [WIRE_CAP_TAB_URL]
77
+ caps: [WIRE_CAP_TAB_URL, WIRE_CAP_FILL_FORM]
76
78
  };
77
79
  ws2.send(JSON.stringify(hello));
78
80
  };
@@ -146,7 +148,8 @@
146
148
  "type",
147
149
  "press",
148
150
  "hover",
149
- "scroll"
151
+ "scroll",
152
+ "fill_form"
150
153
  ]);
151
154
  var NAVIGATION = /* @__PURE__ */ new Set([
152
155
  "navigate",
@@ -236,6 +239,27 @@
236
239
  return `Blocked: "${method}" can't run on ${host} because it isn't on this browser tool's allowed-sites list. This is a safety limit (the tool drives your real, logged-in browser, so it only touches sites you've approved) \u2014 not an error. ${allowedLine} To allow ${host}, add it to the chrome-mcp settings as: --allow-domain "${host}" (or "*.${host}" to include subdomains), then restart/reconnect. To allow every site (less safe), use --unsafe-all-domains.`;
237
240
  }
238
241
 
242
+ // shared/fill-form.ts
243
+ async function runFillFields(ops, hooks) {
244
+ const res = { filled: 0 };
245
+ for (const op of ops) {
246
+ try {
247
+ const policy = hooks.policy();
248
+ if (!policy) throw new PolicyStop("no policy is in force; refusing to fill");
249
+ const verdict = evaluatePolicy(await hooks.currentUrl(), "fill_form", policy);
250
+ if (!verdict.ok) throw new PolicyStop(verdict.reason);
251
+ await hooks.write(op);
252
+ } catch (err) {
253
+ res.error = err instanceof PolicyStop ? { selector: op.selector, code: "POLICY_DENIED", message: err.message } : { selector: op.selector, ...hooks.toError(err) };
254
+ return res;
255
+ }
256
+ res.filled++;
257
+ }
258
+ return res;
259
+ }
260
+ var PolicyStop = class extends Error {
261
+ };
262
+
239
263
  // shared/download.ts
240
264
  var MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
241
265
  var DANGEROUS_EXTENSIONS = /* @__PURE__ */ new Set([
@@ -1225,7 +1249,8 @@
1225
1249
  "ping_probe",
1226
1250
  "frames_list",
1227
1251
  "observers",
1228
- "print_pdf"
1252
+ "print_pdf",
1253
+ "fill_form"
1229
1254
  ]);
1230
1255
  var ChromeExecutor = class {
1231
1256
  /**
@@ -1495,6 +1520,34 @@
1495
1520
  if (!out.found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
1496
1521
  return { ok: true, frameId: out.frameId };
1497
1522
  }
1523
+ case "fill_form": {
1524
+ const id = await targetTab2(cmd);
1525
+ const raw = Array.isArray(cmd.params.ops) ? cmd.params.ops : [];
1526
+ const ops = raw.map((f) => ({
1527
+ selector: String(f.selector ?? ""),
1528
+ value: typeof f.value === "boolean" ? f.value : String(f.value ?? "")
1529
+ }));
1530
+ return runFillFields(ops, {
1531
+ currentUrl: () => observedTabUrl(cmd, id),
1532
+ policy: () => this.getPolicy(),
1533
+ // Frame ids are re-probed per field too: an iframe that navigates
1534
+ // mid-batch must not keep a grant the policy would no longer give it.
1535
+ write: async (op) => {
1536
+ const out = await execOp(
1537
+ id,
1538
+ withWait(
1539
+ typeof op.value === "boolean" ? { op: "click", selector: op.selector } : { op: "type", selector: op.selector, text: op.value, clear: true }
1540
+ ),
1541
+ await this.frames(cmd, id)
1542
+ );
1543
+ if (!out.found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${op.selector}`);
1544
+ },
1545
+ toError: (err) => ({
1546
+ code: err instanceof CmdError ? err.code : "CDP_ERROR",
1547
+ message: err instanceof Error ? err.message : String(err)
1548
+ })
1549
+ });
1550
+ }
1498
1551
  case "press": {
1499
1552
  const id = await targetTab2(cmd);
1500
1553
  const key = String(cmd.params.key ?? "");
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "MCP Extension for Chrome",
4
- "version": "0.9.7",
4
+ "version": "0.9.8",
5
5
  "description": "Lets a local chrome-mcp server drive this browser. Pair it with the server's handshake token.",
6
6
  "homepage_url": "https://chrome-mcp-omega.vercel.app",
7
7
  "minimum_chrome_version": "116",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehmoodqureshi/chrome-mcp",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "description": "Drive your real Chrome browser over MCP — real logins, real cookies. A stdio MCP server (CLI) plus an MV3 extension, driving Chrome via chrome.scripting/chrome.tabs. Multi-tab batch automation, accessibility snapshots, deny-all security by default.",
5
5
  "author": "Mehmood Ur Rehman Qureshi",
6
6
  "license": "MIT",