@mehmoodqureshi/chrome-mcp 0.9.6 → 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.
package/README.md CHANGED
@@ -520,6 +520,31 @@ group/other-accessible. Windows has no such bits — `chmod` there only toggles
520
520
  read-only attribute — so the check is skipped and the token's confidentiality
521
521
  rests on the per-user ACL of `%USERPROFILE%\.chrome-mcp`.
522
522
 
523
+ ## Telemetry
524
+
525
+ The chrome-mcp **server** sends anonymous usage statistics to PostHog, so the
526
+ project can see how many installs are active, which versions and platforms are
527
+ in use, and which tools fail most. A notice is printed the first time it runs.
528
+
529
+ What is sent: a random install id (kept in `~/.chrome-mcp/telemetry.json`), the
530
+ chrome-mcp version, OS, CPU architecture and Node major version, whether the
531
+ session owns the bridge port or shares it, how many browsers are paired, and
532
+ per-tool call and error **counts** with error codes — batched every 10 minutes.
533
+
534
+ What is never sent: URLs, domains, tool arguments, page content, screenshots,
535
+ cookies, profile names, tokens, file paths, or anything you type. Events are
536
+ personless and GeoIP lookup is disabled.
537
+
538
+ The **browser extension sends nothing** — it only ever talks to `127.0.0.1`.
539
+
540
+ Turn it off with any of:
541
+
542
+ ```bash
543
+ CHROME_MCP_TELEMETRY=0 # or false / off
544
+ DO_NOT_TRACK=1
545
+ --no-telemetry # server flag
546
+ ```
547
+
523
548
  ## Develop
524
549
 
525
550
  ```
@@ -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
package/dist/src/cli.js CHANGED
@@ -22,6 +22,7 @@ const workspace_1 = require("./bridge/workspace");
22
22
  const auth_1 = require("./bridge/auth");
23
23
  const server_2 = require("./mcp/server");
24
24
  const tools_1 = require("./mcp/tools");
25
+ const telemetry_1 = require("./telemetry");
25
26
  const extension_install_1 = require("./extension-install");
26
27
  /** Hard deadline for clean shutdown before we force-exit (a stuck socket must not hang us). */
27
28
  const SHUTDOWN_DEADLINE_MS = 3000;
@@ -242,6 +243,13 @@ async function main() {
242
243
  };
243
244
  const port = await bridge.start();
244
245
  (0, tools_1.setProfileBridge)(bridge);
246
+ (0, telemetry_1.initTelemetry)({
247
+ dataDir,
248
+ version: version(),
249
+ disabledByFlag: cfg.noTelemetry,
250
+ log: (m) => (0, server_2.logErr)(m),
251
+ context: () => ({ role: bridge.role, browsers: bridge.connectedProfiles().length }),
252
+ });
245
253
  // Never includes the pairing token — only the resolved, non-secret config.
246
254
  (0, server_2.logDebug)(`resolved config: ${JSON.stringify({
247
255
  wsPort: port,
@@ -309,7 +317,9 @@ async function main() {
309
317
  return;
310
318
  shuttingDown = true;
311
319
  cleanup();
312
- exitWithDeadline(Promise.allSettled([(0, server_2.stopMcpServer)(), bridge.stop()]));
320
+ // Telemetry first: its final summary reads the bridge's role and paired
321
+ // browsers, which bridge.stop() clears synchronously.
322
+ exitWithDeadline(Promise.allSettled([(0, telemetry_1.stopTelemetry)(), (0, server_2.stopMcpServer)(), bridge.stop()]));
313
323
  };
314
324
  process.on('SIGINT', shutdown);
315
325
  process.on('SIGTERM', shutdown);
@@ -34,6 +34,8 @@ export interface CliConfig {
34
34
  showExtensionPath: boolean;
35
35
  /** `--persist-token`: reuse a stable on-disk token so the extension never re-pairs. */
36
36
  persistToken: boolean;
37
+ /** `--no-telemetry`: send no anonymous usage statistics (see src/telemetry.ts). */
38
+ noTelemetry: boolean;
37
39
  /**
38
40
  * `--tools`: advertise only these tools. `undefined` = the whole catalog.
39
41
  * Names are validated against the catalog by `setToolAllowlist` at startup —
@@ -78,6 +78,7 @@ function parseArgs(argv) {
78
78
  let headless = false;
79
79
  let printPairing = false;
80
80
  let persistToken = false;
81
+ let noTelemetry = false;
81
82
  let showHelp = false;
82
83
  let showVersion = false;
83
84
  let showExtensionPath = false;
@@ -178,6 +179,9 @@ function parseArgs(argv) {
178
179
  case '--persist-token':
179
180
  persistToken = true;
180
181
  break;
182
+ case '--no-telemetry':
183
+ noTelemetry = true;
184
+ break;
181
185
  case '--tools':
182
186
  toolsFlagSeen = true;
183
187
  for (const name of splitList(requireValue(argv[++i], '--tools')))
@@ -234,6 +238,7 @@ function parseArgs(argv) {
234
238
  headless,
235
239
  printPairing,
236
240
  persistToken,
241
+ noTelemetry,
237
242
  showHelp,
238
243
  showVersion,
239
244
  showExtensionPath,
@@ -309,6 +314,10 @@ Connection:
309
314
  --persist-token Reuse a stable on-disk token across restarts so the
310
315
  extension never has to re-pair (default: fresh per boot).
311
316
  CHROME_MCP_TOKEN env, if set, pins the token explicitly.
317
+ --no-telemetry Send no anonymous usage statistics. Same as
318
+ CHROME_MCP_TELEMETRY=0 or DO_NOT_TRACK=1. Only counts are
319
+ ever sent (version, OS, tool calls, error codes) — never
320
+ URLs, page content or arguments.
312
321
 
313
322
  Backend:
314
323
  This build is EXTENSION-ONLY — it drives ONLY your real Chrome via the paired
@@ -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) {
@@ -38,6 +38,7 @@ const auth_wall_1 = require("../../shared/auth-wall");
38
38
  const audit_1 = require("./audit");
39
39
  const log_1 = require("./log");
40
40
  const tasks_1 = require("../bridge/tasks");
41
+ const telemetry_1 = require("../telemetry");
41
42
  const config_1 = require("../config");
42
43
  const workspace_1 = require("../bridge/workspace");
43
44
  const validators_1 = require("./validators");
@@ -1075,6 +1076,8 @@ function summarizeArgs(rawArgs) {
1075
1076
  */
1076
1077
  function recordHistory(tool, rawArgs, ok, extra = {}) {
1077
1078
  const a = extra.audit ?? {};
1079
+ // Counts only — the tool name and error code, never the args or URL below.
1080
+ (0, telemetry_1.noteToolCall)(tool, ok, extra.error);
1078
1081
  (0, workspace_1.appendHistory)({
1079
1082
  ts: new Date().toISOString(),
1080
1083
  tool,
@@ -0,0 +1,53 @@
1
+ /**
2
+ * src/telemetry.ts — anonymous usage statistics from the chrome-mcp SERVER.
3
+ *
4
+ * What it sends, to PostHog: a random per-install id, the chrome-mcp version,
5
+ * OS, CPU architecture and Node major version, whether this session owns the
6
+ * port or shares it, how many browsers are paired, and per-tool call and error
7
+ * COUNTS. Never URLs, domains, tool arguments, page content, profile names,
8
+ * tokens, file paths, or anything typed. Events are marked personless and ask
9
+ * PostHog not to geolocate them.
10
+ *
11
+ * The browser extension sends nothing; this lives only in the npm server.
12
+ *
13
+ * On by default with a one-time notice on first run. Off with
14
+ * CHROME_MCP_TELEMETRY=0 (or false/off), DO_NOT_TRACK=1, or --no-telemetry.
15
+ * Every failure is swallowed: telemetry can never break or slow a tool call.
16
+ */
17
+ /** PostHog project key. Public by design: it can only WRITE events, never read them. */
18
+ export declare const POSTHOG_KEY = "phc_CG5QX5JEkRokfUN87rZQnPa3URdLWePCZnfqW6MCyCdU";
19
+ export declare const POSTHOG_HOST = "https://us.i.posthog.com";
20
+ export declare const TELEMETRY_NOTICE: string;
21
+ interface Event {
22
+ event: string;
23
+ distinct_id: string;
24
+ timestamp: string;
25
+ properties: Record<string, unknown>;
26
+ }
27
+ export interface TelemetryOptions {
28
+ dataDir: string;
29
+ version: string;
30
+ /** --no-telemetry */
31
+ disabledByFlag?: boolean;
32
+ env?: NodeJS.ProcessEnv;
33
+ /** Override the project key (tests, forks). Empty = telemetry off. */
34
+ key?: string;
35
+ log?: (message: string) => void;
36
+ /** Test seam: replaces the HTTP POST. */
37
+ send?: (events: Event[]) => Promise<void>;
38
+ /** Live context added to each summary (role, paired browsers). */
39
+ context?: () => Record<string, unknown>;
40
+ }
41
+ /** Whether the user has turned telemetry off, by env or flag. */
42
+ export declare function telemetryDisabled(env: NodeJS.ProcessEnv, disabledByFlag?: boolean): boolean;
43
+ /** Pull the `[CODE]` prefix off a tool error message, if any. */
44
+ export declare function errorCodeOf(message: string | undefined): string;
45
+ /** Start telemetry for this server process, unless turned off or unconfigured. */
46
+ export declare function initTelemetry(opts: TelemetryOptions): boolean;
47
+ /** Count one tool call. A no-op when telemetry is off. */
48
+ export declare function noteToolCall(tool: string, ok: boolean, error?: string): void;
49
+ /** Flush and send session_ended. Safe to call when telemetry is off. */
50
+ export declare function stopTelemetry(): Promise<void>;
51
+ /** Test seam: forget the active instance. */
52
+ export declare function resetTelemetryForTesting(): void;
53
+ export {};
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+ /**
3
+ * src/telemetry.ts — anonymous usage statistics from the chrome-mcp SERVER.
4
+ *
5
+ * What it sends, to PostHog: a random per-install id, the chrome-mcp version,
6
+ * OS, CPU architecture and Node major version, whether this session owns the
7
+ * port or shares it, how many browsers are paired, and per-tool call and error
8
+ * COUNTS. Never URLs, domains, tool arguments, page content, profile names,
9
+ * tokens, file paths, or anything typed. Events are marked personless and ask
10
+ * PostHog not to geolocate them.
11
+ *
12
+ * The browser extension sends nothing; this lives only in the npm server.
13
+ *
14
+ * On by default with a one-time notice on first run. Off with
15
+ * CHROME_MCP_TELEMETRY=0 (or false/off), DO_NOT_TRACK=1, or --no-telemetry.
16
+ * Every failure is swallowed: telemetry can never break or slow a tool call.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.TELEMETRY_NOTICE = exports.POSTHOG_HOST = exports.POSTHOG_KEY = void 0;
20
+ exports.telemetryDisabled = telemetryDisabled;
21
+ exports.errorCodeOf = errorCodeOf;
22
+ exports.initTelemetry = initTelemetry;
23
+ exports.noteToolCall = noteToolCall;
24
+ exports.stopTelemetry = stopTelemetry;
25
+ exports.resetTelemetryForTesting = resetTelemetryForTesting;
26
+ const node_crypto_1 = require("node:crypto");
27
+ const node_fs_1 = require("node:fs");
28
+ const node_os_1 = require("node:os");
29
+ const node_path_1 = require("node:path");
30
+ /** PostHog project key. Public by design: it can only WRITE events, never read them. */
31
+ exports.POSTHOG_KEY = 'phc_CG5QX5JEkRokfUN87rZQnPa3URdLWePCZnfqW6MCyCdU';
32
+ exports.POSTHOG_HOST = 'https://us.i.posthog.com';
33
+ /** How often the aggregated counts are sent while a session runs. */
34
+ const FLUSH_INTERVAL_MS = 10 * 60_000;
35
+ /** A send never holds up the process longer than this. */
36
+ const SEND_TIMEOUT_MS = 3_000;
37
+ const STATE_FILE = 'telemetry.json';
38
+ exports.TELEMETRY_NOTICE = 'chrome-mcp collects anonymous usage statistics (version, OS, tool call and error counts; never URLs, ' +
39
+ 'page content or arguments) to see how it is used. Turn it off with CHROME_MCP_TELEMETRY=0 or DO_NOT_TRACK=1. ' +
40
+ 'Details: https://github.com/Mehmoodqureshi/chrome-mcp#telemetry';
41
+ /** Whether the user has turned telemetry off, by env or flag. */
42
+ function telemetryDisabled(env, disabledByFlag = false) {
43
+ if (disabledByFlag)
44
+ return true;
45
+ const own = (env.CHROME_MCP_TELEMETRY ?? '').trim().toLowerCase();
46
+ if (['0', 'false', 'off', 'no'].includes(own))
47
+ return true;
48
+ const dnt = (env.DO_NOT_TRACK ?? '').trim().toLowerCase();
49
+ return dnt !== '' && dnt !== '0' && dnt !== 'false';
50
+ }
51
+ /** Pull the `[CODE]` prefix off a tool error message, if any. */
52
+ function errorCodeOf(message) {
53
+ const m = /^\[([A-Z_]+)\]/.exec(message ?? '');
54
+ return m ? m[1] : 'OTHER';
55
+ }
56
+ class Telemetry {
57
+ opts;
58
+ installId;
59
+ calls = new Map();
60
+ errorCodes = new Map();
61
+ timer = null;
62
+ base;
63
+ /** Sends still on the wire, so a quick exit doesn't cut session_started off. */
64
+ inflight = new Set();
65
+ constructor(opts) {
66
+ this.opts = opts;
67
+ this.installId = this.loadInstallId();
68
+ this.base = {
69
+ version: opts.version,
70
+ os: (0, node_os_1.platform)(),
71
+ arch: (0, node_os_1.arch)(),
72
+ node: process.versions.node.split('.')[0],
73
+ // Anonymous: no person profile, no GeoIP lookup from the request IP.
74
+ $process_person_profile: false,
75
+ $geoip_disable: true,
76
+ };
77
+ }
78
+ start() {
79
+ void this.capture('session_started', this.opts.context?.() ?? {});
80
+ this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
81
+ this.timer.unref();
82
+ }
83
+ noteCall(tool, ok, error) {
84
+ const c = this.calls.get(tool) ?? { calls: 0, errors: 0 };
85
+ c.calls++;
86
+ if (!ok) {
87
+ c.errors++;
88
+ const code = errorCodeOf(error);
89
+ this.errorCodes.set(code, (this.errorCodes.get(code) ?? 0) + 1);
90
+ }
91
+ this.calls.set(tool, c);
92
+ }
93
+ /** Send the counts gathered since the last flush (skipped when idle). */
94
+ async flush() {
95
+ const summary = this.takeSummary();
96
+ if (summary)
97
+ await this.send([summary]);
98
+ }
99
+ /** The counts since the last summary as an event, resetting them; null when idle. */
100
+ takeSummary() {
101
+ if (this.calls.size === 0)
102
+ return null;
103
+ const tools = Object.fromEntries(this.calls);
104
+ const errors = Object.fromEntries(this.errorCodes);
105
+ let total = 0;
106
+ let failed = 0;
107
+ for (const c of this.calls.values()) {
108
+ total += c.calls;
109
+ failed += c.errors;
110
+ }
111
+ this.calls = new Map();
112
+ this.errorCodes = new Map();
113
+ return this.event('usage_summary', { ...(this.opts.context?.() ?? {}), calls: total, errors: failed, tools, error_codes: errors });
114
+ }
115
+ /** Last summary and session_ended in ONE request, so both fit the shutdown deadline. */
116
+ async stop() {
117
+ if (this.timer)
118
+ clearInterval(this.timer);
119
+ this.timer = null;
120
+ const summary = this.takeSummary();
121
+ const final = this.send([...(summary ? [summary] : []), this.event('session_ended', {})]);
122
+ // Also wait for anything already sent (session_started on a short session).
123
+ await Promise.allSettled([...this.inflight, final]);
124
+ }
125
+ capture(event, props) {
126
+ return this.send([this.event(event, props)]);
127
+ }
128
+ event(event, props) {
129
+ return {
130
+ event,
131
+ distinct_id: this.installId,
132
+ timestamp: new Date().toISOString(),
133
+ properties: { ...this.base, ...props },
134
+ };
135
+ }
136
+ send(batch) {
137
+ const p = (async () => {
138
+ try {
139
+ await (this.opts.send ?? ((b) => this.post(b)))(batch);
140
+ }
141
+ catch {
142
+ /* offline, blocked, or PostHog down — never matters to the user */
143
+ }
144
+ })();
145
+ this.inflight.add(p);
146
+ void p.finally(() => this.inflight.delete(p));
147
+ return p;
148
+ }
149
+ async post(batch) {
150
+ await fetch(`${exports.POSTHOG_HOST}/batch/`, {
151
+ method: 'POST',
152
+ headers: { 'content-type': 'application/json' },
153
+ body: JSON.stringify({ api_key: this.opts.key, batch }),
154
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
155
+ });
156
+ }
157
+ /** The random install id, created (with the first-run notice) on first use. */
158
+ loadInstallId() {
159
+ const path = (0, node_path_1.join)(this.opts.dataDir, STATE_FILE);
160
+ try {
161
+ if ((0, node_fs_1.existsSync)(path)) {
162
+ const saved = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
163
+ if (typeof saved.installId === 'string' && saved.installId.length > 0)
164
+ return saved.installId;
165
+ }
166
+ }
167
+ catch {
168
+ /* unreadable: start over with a new id */
169
+ }
170
+ const installId = (0, node_crypto_1.randomUUID)();
171
+ this.opts.log?.(exports.TELEMETRY_NOTICE);
172
+ try {
173
+ const tmp = `${path}.tmp`;
174
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify({ installId, noticeShownAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
175
+ (0, node_fs_1.renameSync)(tmp, path);
176
+ }
177
+ catch {
178
+ /* read-only data dir: a new id (and notice) next boot is harmless */
179
+ }
180
+ return installId;
181
+ }
182
+ }
183
+ let active = null;
184
+ /** Start telemetry for this server process, unless turned off or unconfigured. */
185
+ function initTelemetry(opts) {
186
+ const key = opts.key ?? exports.POSTHOG_KEY;
187
+ if (!key || telemetryDisabled(opts.env ?? process.env, opts.disabledByFlag))
188
+ return false;
189
+ active = new Telemetry({ ...opts, key });
190
+ active.start();
191
+ return true;
192
+ }
193
+ /** Count one tool call. A no-op when telemetry is off. */
194
+ function noteToolCall(tool, ok, error) {
195
+ active?.noteCall(tool, ok, error);
196
+ }
197
+ /** Flush and send session_ended. Safe to call when telemetry is off. */
198
+ async function stopTelemetry() {
199
+ const t = active;
200
+ active = null;
201
+ await t?.stop();
202
+ }
203
+ /** Test seam: forget the active instance. */
204
+ function resetTelemetryForTesting() {
205
+ active = null;
206
+ }
207
+ //# sourceMappingURL=telemetry.js.map
@@ -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.6",
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.6",
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",