@mehmoodqureshi/chrome-mcp 0.9.11 → 0.9.13

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
@@ -16,7 +16,9 @@ localhost WebSocket server and drives the browser you already have open, through
16
16
  `chrome.scripting`/`chrome.tabs`. Works with Claude Code, Claude Desktop, and any
17
17
  other MCP host.
18
18
 
19
- Distributed as an `npx` CLI (the MCP server) plus a load-unpacked extension.
19
+ Distributed as an `npx` CLI (the MCP server) plus an extension, from the
20
+ [Chrome Web Store](https://chromewebstore.google.com/detail/mcp-extension-for-chrome/jelfhdlkhbfmlpbghoeaepijllcnplgh)
21
+ or loaded unpacked.
20
22
 
21
23
  > **This build is extension-only.** It never launches or attaches a Chromium of
22
24
  > its own, so **the extension is required, not optional** — without it, no tool
@@ -27,6 +29,46 @@ Distributed as an `npx` CLI (the MCP server) plus a load-unpacked extension.
27
29
  > protocol, the complete tool surface, the extension manifest, the security
28
30
  > model, and the phased build plan.
29
31
 
32
+ ## Use Claude in your signed-in Chrome
33
+
34
+ You are already signed in to Gmail, GitHub, your analytics dashboards, the admin
35
+ panel, the CRM. chrome-mcp lets Claude (Claude Code, Claude Desktop, or any
36
+ other MCP host) work in those same tabs: no re-login, no second 2FA prompt, no
37
+ password or cookie in a config file. The extension runs inside your normal
38
+ Chrome, so a page the agent opens is the page you would see.
39
+
40
+ - **Gmail** — read a thread or search results in a tab you already have open.
41
+ - **GitHub** — go through PRs, issues and settings pages as yourself, private
42
+ repositories included.
43
+ - **Dashboards** — pull numbers from analytics, billing or admin screens that
44
+ have no API, or whose API you never set up.
45
+
46
+ Other tools drive a signed-in Chrome too (the `hangwin/mcp-chrome` extension,
47
+ for one), so the question is what you get on top of the session:
48
+
49
+ - **A per-domain allowlist, deny-all by default.** With no flags the agent can
50
+ read nothing. `--allow-domain mail.google.com --allow-domain github.com`
51
+ opens exactly those hosts (`*.example.com` covers a domain and its
52
+ subdomains); every other site is refused before the call reaches the page.
53
+ Reads are gated, not only clicks, and the extension re-checks the same policy
54
+ on its side. Mutations, eval, downloads and uploads are separate opt-ins.
55
+ - **Password values are never returned**, and `--redact` scrubs tokens, API
56
+ keys and JWTs out of page reads.
57
+ - **An audit trail**: every call is logged with the URL, the allow/deny
58
+ verdict, duration and bytes returned.
59
+ - **Background tabs and `batch`**: open pages with `active: false` and read many
60
+ at once in one call, without stealing focus from the tab you are working in.
61
+ - **Several Chrome profiles**: load the extension in your work and personal
62
+ profiles and switch between them with `profile_use`.
63
+ - **`auth_check`**: when a session does expire, the agent gets an
64
+ `[AUTH_REQUIRED]` signal instead of a confusing timeout, so it can stop and
65
+ ask you to sign in again. chrome-mcp never holds credentials or signs in for
66
+ you.
67
+
68
+ Setup is two pieces: the MCP server (`npx`, below) and the extension, which you
69
+ can **[install from the Chrome Web Store](https://chromewebstore.google.com/detail/mcp-extension-for-chrome/jelfhdlkhbfmlpbghoeaepijllcnplgh)**
70
+ or load from the folder the server unpacks.
71
+
30
72
  ## Quickstart
31
73
 
32
74
  ### Up and running in one paste
@@ -29,6 +29,14 @@ export interface FillFormHooks {
29
29
  code: FillFormError['code'];
30
30
  message: string;
31
31
  };
32
+ /**
33
+ * Epoch ms after which no further field is started — the moment the server
34
+ * stops waiting for this command. Without it a timed-out batch kept typing
35
+ * into the page after the caller had already been told it failed.
36
+ */
37
+ deadline?: number;
38
+ /** Clock, injectable for tests. */
39
+ now?: () => number;
32
40
  }
33
41
  type FillFormError = NonNullable<FillFormWireResult['error']>;
34
42
  /**
@@ -27,7 +27,16 @@ const policy_1 = require("./policy");
27
27
  */
28
28
  async function runFillFields(ops, hooks) {
29
29
  const res = { filled: 0 };
30
+ const now = hooks.now ?? Date.now;
30
31
  for (const op of ops) {
32
+ if (hooks.deadline !== undefined && now() >= hooks.deadline) {
33
+ res.error = {
34
+ selector: op.selector,
35
+ code: 'TIMEOUT',
36
+ message: `fill_form ran out of time after ${res.filled} of ${ops.length} fields; the rest were not written`,
37
+ };
38
+ return res;
39
+ }
31
40
  try {
32
41
  // Fails CLOSED, exactly as the router does: no policy means nothing runs.
33
42
  const policy = hooks.policy();
@@ -52,6 +52,19 @@ export interface FillFieldOp {
52
52
  /** A string is value-set (cleared first); a boolean toggles via a click. */
53
53
  value: string | boolean;
54
54
  }
55
+ /** Floor for a batched `fill_form` — the old flat budget, so small forms keep it. */
56
+ export declare const FILL_FORM_MIN_TIMEOUT_MS = 60000;
57
+ /** Per-field allowance: the extension's 5 s element wait plus headroom for the write. */
58
+ export declare const FILL_FORM_PER_FIELD_MS = 6000;
59
+ /** Ceiling, so a runaway field count can't hold the tab for longer than this. */
60
+ export declare const FILL_FORM_MAX_TIMEOUT_MS = 600000;
61
+ /**
62
+ * Wire timeout for a `fill_form` of `fields` fields. One flat 60 s used to cover
63
+ * the whole batch, so a long form whose fields each waited for their element
64
+ * timed out server-side while the extension was still typing. The budget now
65
+ * grows with the field count (10 s base + 6 s per field), clamped to 60 s..10 min.
66
+ */
67
+ export declare function fillFormTimeoutMs(fields: number): number;
55
68
  /**
56
69
  * `fill_form` result. Ops run in order and stop at the first failure, so
57
70
  * `filled` counts the fields that landed and `error` names the one that did not.
@@ -15,7 +15,8 @@
15
15
  * in one round-trip; capability-gated, see `WIRE_CAP_FILL_FORM`).
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
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
+ exports.WIRE_METHODS = exports.FILL_FORM_MAX_TIMEOUT_MS = exports.FILL_FORM_PER_FIELD_MS = exports.FILL_FORM_MIN_TIMEOUT_MS = 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;
19
+ exports.fillFormTimeoutMs = fillFormTimeoutMs;
19
20
  /** Bumped on any breaking change to the frames below. */
20
21
  exports.PROTOCOL_VERSION = 1;
21
22
  /**
@@ -48,6 +49,22 @@ exports.WIRE_CAP_TAB_URL = 'tab-url';
48
49
  * field over `type`/`click`, which every extension build understands.
49
50
  */
50
51
  exports.WIRE_CAP_FILL_FORM = 'fill-form';
52
+ /** Floor for a batched `fill_form` — the old flat budget, so small forms keep it. */
53
+ exports.FILL_FORM_MIN_TIMEOUT_MS = 60_000;
54
+ /** Per-field allowance: the extension's 5 s element wait plus headroom for the write. */
55
+ exports.FILL_FORM_PER_FIELD_MS = 6_000;
56
+ /** Ceiling, so a runaway field count can't hold the tab for longer than this. */
57
+ exports.FILL_FORM_MAX_TIMEOUT_MS = 600_000;
58
+ /**
59
+ * Wire timeout for a `fill_form` of `fields` fields. One flat 60 s used to cover
60
+ * the whole batch, so a long form whose fields each waited for their element
61
+ * timed out server-side while the extension was still typing. The budget now
62
+ * grows with the field count (10 s base + 6 s per field), clamped to 60 s..10 min.
63
+ */
64
+ function fillFormTimeoutMs(fields) {
65
+ const n = Number.isFinite(fields) && fields > 0 ? Math.floor(fields) : 0;
66
+ return Math.min(exports.FILL_FORM_MAX_TIMEOUT_MS, Math.max(exports.FILL_FORM_MIN_TIMEOUT_MS, 10_000 + n * exports.FILL_FORM_PER_FIELD_MS));
67
+ }
51
68
  /** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
52
69
  exports.WIRE_METHODS = [
53
70
  'tabs_list',
@@ -0,0 +1,42 @@
1
+ /**
2
+ * shared/tab-border.ts — the pure half of the "chrome-mcp is working here"
3
+ * border: choosing its colour from the page's theme and writing the CSS.
4
+ *
5
+ * The extension paints the border with `chrome.scripting.insertCSS` on
6
+ * `html::after`, a user stylesheet rather than a DOM node, so get_html,
7
+ * get_text and snapshot never see it. Kept free of chrome.* so it is tested
8
+ * in plain Node.
9
+ */
10
+ /** Default border colour on a light page. */
11
+ export declare const BORDER_BLUE = "#2563eb";
12
+ /** Default border colour on a dark page (the light blue stays visible). */
13
+ export declare const BORDER_BLUE_ON_DARK = "#60a5fa";
14
+ /** What the page reports about its theme; colours already normalised by a
15
+ * canvas `fillStyle` round-trip to `#rrggbb` or `rgba(r, g, b, a)`. */
16
+ export interface PageTheme {
17
+ /** The matching `<meta name="theme-color">`, or '' when the page has none. */
18
+ themeColor: string;
19
+ /** The effective page background (body, else html), or ''. */
20
+ background: string;
21
+ /** `prefers-color-scheme: dark`, used when the background is transparent. */
22
+ prefersDark: boolean;
23
+ }
24
+ interface Rgb {
25
+ r: number;
26
+ g: number;
27
+ b: number;
28
+ a: number;
29
+ }
30
+ /** Parse `#rgb`, `#rrggbb` or `rgb(a)(...)`; anything else is null. */
31
+ export declare function parseColor(value: string): Rgb | null;
32
+ /**
33
+ * Pick the border colour for a page:
34
+ * 1. the site's own theme-color, when it is opaque and stands out from the
35
+ * background (so the border matches the site),
36
+ * 2. otherwise blue, the lighter blue on a dark page.
37
+ */
38
+ export declare function pickBorderColor(theme: PageTheme): string;
39
+ /** The user stylesheet that draws the border. Only `color` varies, and it is
40
+ * always a `#rrggbb` from pickBorderColor or a constant above. */
41
+ export declare function borderCss(color: string): string;
42
+ export {};
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ /**
3
+ * shared/tab-border.ts — the pure half of the "chrome-mcp is working here"
4
+ * border: choosing its colour from the page's theme and writing the CSS.
5
+ *
6
+ * The extension paints the border with `chrome.scripting.insertCSS` on
7
+ * `html::after`, a user stylesheet rather than a DOM node, so get_html,
8
+ * get_text and snapshot never see it. Kept free of chrome.* so it is tested
9
+ * in plain Node.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.BORDER_BLUE_ON_DARK = exports.BORDER_BLUE = void 0;
13
+ exports.parseColor = parseColor;
14
+ exports.pickBorderColor = pickBorderColor;
15
+ exports.borderCss = borderCss;
16
+ /** Default border colour on a light page. */
17
+ exports.BORDER_BLUE = '#2563eb';
18
+ /** Default border colour on a dark page (the light blue stays visible). */
19
+ exports.BORDER_BLUE_ON_DARK = '#60a5fa';
20
+ /** Parse `#rgb`, `#rrggbb` or `rgb(a)(...)`; anything else is null. */
21
+ function parseColor(value) {
22
+ const v = value.trim().toLowerCase();
23
+ let m = /^#([0-9a-f]{3})$/.exec(v);
24
+ if (m) {
25
+ const [r, g, b] = m[1].split('').map((c) => parseInt(c + c, 16));
26
+ return { r, g, b, a: 1 };
27
+ }
28
+ m = /^#([0-9a-f]{6})$/.exec(v);
29
+ if (m) {
30
+ const n = parseInt(m[1], 16);
31
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255, a: 1 };
32
+ }
33
+ m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+))?\s*\)$/.exec(v);
34
+ if (m) {
35
+ return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]), a: m[4] === undefined ? 1 : Number(m[4]) };
36
+ }
37
+ return null;
38
+ }
39
+ function luminance({ r, g, b }) {
40
+ const lin = (c) => {
41
+ const s = c / 255;
42
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
43
+ };
44
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
45
+ }
46
+ function contrast(a, b) {
47
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
48
+ return (hi + 0.05) / (lo + 0.05);
49
+ }
50
+ const toHex = ({ r, g, b }) => '#' + [r, g, b].map((c) => Math.round(c).toString(16).padStart(2, '0')).join('');
51
+ /** A border has to stand out from the page to be worth drawing. */
52
+ const MIN_CONTRAST = 2;
53
+ /**
54
+ * Pick the border colour for a page:
55
+ * 1. the site's own theme-color, when it is opaque and stands out from the
56
+ * background (so the border matches the site),
57
+ * 2. otherwise blue, the lighter blue on a dark page.
58
+ */
59
+ function pickBorderColor(theme) {
60
+ const bg = parseColor(theme.background);
61
+ const opaqueBg = bg && bg.a > 0 ? bg : null;
62
+ const dark = opaqueBg ? luminance(opaqueBg) < 0.2 : theme.prefersDark;
63
+ const fallback = dark ? exports.BORDER_BLUE_ON_DARK : exports.BORDER_BLUE;
64
+ const tc = parseColor(theme.themeColor);
65
+ if (!tc || tc.a < 1)
66
+ return fallback;
67
+ const against = opaqueBg ?? (dark ? { r: 0, g: 0, b: 0, a: 1 } : { r: 255, g: 255, b: 255, a: 1 });
68
+ return contrast(tc, against) >= MIN_CONTRAST ? toHex(tc) : fallback;
69
+ }
70
+ /** The user stylesheet that draws the border. Only `color` varies, and it is
71
+ * always a `#rrggbb` from pickBorderColor or a constant above. */
72
+ function borderCss(color) {
73
+ const c = /^#[0-9a-f]{6}$/i.test(color) ? color : exports.BORDER_BLUE;
74
+ return ('html::after{content:"" !important;display:block !important;position:fixed !important;' +
75
+ 'inset:0 !important;box-sizing:border-box !important;' +
76
+ `border:3px solid ${c} !important;box-shadow:inset 0 0 12px ${c}66 !important;` +
77
+ 'pointer-events:none !important;z-index:2147483647 !important;}');
78
+ }
79
+ //# sourceMappingURL=tab-border.js.map
@@ -56,6 +56,18 @@ export declare const BUNDLED_PAIRING_FILE = "pairing.json";
56
56
  * still works without it.
57
57
  */
58
58
  export declare function writeBundledPairing(extDir: string, fields: WriteHandshakeFields): string | null;
59
+ /** The port an existing `<extDir>/pairing.json` points at, or null when absent/unreadable. */
60
+ export declare function readBundledPairingPort(extDir: string): number | null;
61
+ /** True when something is accepting TCP connections on 127.0.0.1:`port`. */
62
+ export declare function portIsListening(port: number, timeoutMs?: number): Promise<boolean>;
63
+ /**
64
+ * Should a hub on `port` leave `<extDir>/pairing.json` alone? Yes when the file
65
+ * already points the extension at a DIFFERENT port that is still live: another
66
+ * chrome-mcp (e.g. one started with its own CHROME_MCP_DATA / --ws-port) owns
67
+ * that pairing, and overwriting it would silently steal the user's extension.
68
+ * A file naming our own port, a dead port, or no file at all is fair game.
69
+ */
70
+ export declare function bundledPairingHeldByOther(extDir: string, port: number): Promise<boolean>;
59
71
  /**
60
72
  * Atomically write the handshake at 0600 and verify the mode. Throws (fail
61
73
  * closed) if the file ends up group/other-readable — the token is the entire
@@ -22,6 +22,9 @@ exports.writePersistedToken = writePersistedToken;
22
22
  exports.resolveToken = resolveToken;
23
23
  exports.tokensMatch = tokensMatch;
24
24
  exports.writeBundledPairing = writeBundledPairing;
25
+ exports.readBundledPairingPort = readBundledPairingPort;
26
+ exports.portIsListening = portIsListening;
27
+ exports.bundledPairingHeldByOther = bundledPairingHeldByOther;
25
28
  exports.writeHandshake = writeHandshake;
26
29
  exports.readHandshake = readHandshake;
27
30
  exports.removeHandshake = removeHandshake;
@@ -29,6 +32,7 @@ exports.redactToken = redactToken;
29
32
  const node_fs_1 = require("node:fs");
30
33
  const node_path_1 = require("node:path");
31
34
  const node_crypto_1 = require("node:crypto");
35
+ const node_net_1 = require("node:net");
32
36
  const protocol_1 = require("../../shared/protocol");
33
37
  const datadir_1 = require("./datadir");
34
38
  /** A fresh 256-bit token, base64url. Generated once per server boot. */
@@ -146,6 +150,42 @@ function writeBundledPairing(extDir, fields) {
146
150
  return null;
147
151
  }
148
152
  }
153
+ /** The port an existing `<extDir>/pairing.json` points at, or null when absent/unreadable. */
154
+ function readBundledPairingPort(extDir) {
155
+ try {
156
+ const raw = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(extDir, exports.BUNDLED_PAIRING_FILE), 'utf8'));
157
+ return typeof raw.port === 'number' && Number.isInteger(raw.port) && raw.port > 0 ? raw.port : null;
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ /** True when something is accepting TCP connections on 127.0.0.1:`port`. */
164
+ function portIsListening(port, timeoutMs = 500) {
165
+ return new Promise((resolve) => {
166
+ const sock = (0, node_net_1.connect)({ host: '127.0.0.1', port });
167
+ const done = (live) => {
168
+ sock.destroy();
169
+ resolve(live);
170
+ };
171
+ sock.setTimeout(timeoutMs, () => done(false));
172
+ sock.once('connect', () => done(true));
173
+ sock.once('error', () => done(false));
174
+ });
175
+ }
176
+ /**
177
+ * Should a hub on `port` leave `<extDir>/pairing.json` alone? Yes when the file
178
+ * already points the extension at a DIFFERENT port that is still live: another
179
+ * chrome-mcp (e.g. one started with its own CHROME_MCP_DATA / --ws-port) owns
180
+ * that pairing, and overwriting it would silently steal the user's extension.
181
+ * A file naming our own port, a dead port, or no file at all is fair game.
182
+ */
183
+ async function bundledPairingHeldByOther(extDir, port) {
184
+ const current = readBundledPairingPort(extDir);
185
+ if (current === null || current === port)
186
+ return false;
187
+ return portIsListening(current);
188
+ }
149
189
  /**
150
190
  * Atomically write the handshake at 0600 and verify the mode. Throws (fail
151
191
  * closed) if the file ends up group/other-readable — the token is the entire
package/dist/src/cli.js CHANGED
@@ -223,12 +223,22 @@ async function main() {
223
223
  },
224
224
  });
225
225
  const publishPairing = (port, pairToken) => {
226
+ // Synchronous so a handshake that can't be made private still fails closed.
226
227
  const path = (0, auth_1.writeHandshake)(dataDir, { port, token: pairToken });
227
228
  (0, server_2.logErr)(`pairing handshake written to ${path} (mode 0600; token not logged)`);
229
+ publishBundledPairing(port, pairToken).catch((err) => (0, server_2.logDebug)(`auto-pairing file not written: ${err instanceof Error ? err.message : String(err)}`));
230
+ };
231
+ const publishBundledPairing = async (port, pairToken) => {
228
232
  // Drop the same port + token into the bundled extension folder so a Load
229
233
  // unpacked from there pairs itself. Best-effort: a read-only install just
230
- // falls back to the Options-page paste.
234
+ // falls back to the Options-page paste. Never take over a pairing file that
235
+ // a different, still-running chrome-mcp published (a second server with its
236
+ // own data dir or port would otherwise re-point the user's extension).
231
237
  const extDir = installExtension();
238
+ if (await (0, auth_1.bundledPairingHeldByOther)(extDir, port)) {
239
+ (0, server_2.logErr)(`auto-pairing file at ${extDir} belongs to another running chrome-mcp — left as is; pair this server from the extension Options page if you want it`);
240
+ return;
241
+ }
232
242
  const bundled = (0, auth_1.writeBundledPairing)(extDir, { port, token: pairToken });
233
243
  if (bundled) {
234
244
  (0, server_2.logErr)(`auto-pairing file written to ${bundled} — Load unpacked from ${extDir} needs no token paste`);
@@ -238,8 +248,10 @@ async function main() {
238
248
  }
239
249
  // Anyone who loaded the extension straight from the package folder (0.8.0
240
250
  // docs) keeps pairing too.
241
- if (extDir !== (0, extension_install_1.bundledExtensionDir)())
242
- (0, auth_1.writeBundledPairing)((0, extension_install_1.bundledExtensionDir)(), { port, token: pairToken });
251
+ const pkgDir = (0, extension_install_1.bundledExtensionDir)();
252
+ if (extDir !== pkgDir && !(await (0, auth_1.bundledPairingHeldByOther)(pkgDir, port))) {
253
+ (0, auth_1.writeBundledPairing)(pkgDir, { port, token: pairToken });
254
+ }
243
255
  };
244
256
  const port = await bridge.start();
245
257
  (0, tools_1.setProfileBridge)(bridge);
@@ -149,7 +149,7 @@ class ExtensionExecutor {
149
149
  // An extension that predates the op never advertised it: let the caller go field by field.
150
150
  if (!this.bridge.hasCap(this.activeProfile(), protocol_1.WIRE_CAP_FILL_FORM))
151
151
  return null;
152
- const res = (await this.send('fill_form', { ops: fields, ...frameParams(opts) }, { tabId: opts?.tabId }));
152
+ const res = (await this.send('fill_form', { ops: fields, ...frameParams(opts) }, { tabId: opts?.tabId, timeoutMs: (0, protocol_1.fillFormTimeoutMs)(fields.length) }));
153
153
  if (res.error) {
154
154
  // Say how far the batch got: the fields before this one DID land, and a
155
155
  // blind retry of the whole form would write them a second time.
@@ -242,7 +242,16 @@
242
242
  // shared/fill-form.ts
243
243
  async function runFillFields(ops, hooks) {
244
244
  const res = { filled: 0 };
245
+ const now = hooks.now ?? Date.now;
245
246
  for (const op of ops) {
247
+ if (hooks.deadline !== void 0 && now() >= hooks.deadline) {
248
+ res.error = {
249
+ selector: op.selector,
250
+ code: "TIMEOUT",
251
+ message: `fill_form ran out of time after ${res.filled} of ${ops.length} fields; the rest were not written`
252
+ };
253
+ return res;
254
+ }
246
255
  try {
247
256
  const policy = hooks.policy();
248
257
  if (!policy) throw new PolicyStop("no policy is in force; refusing to fill");
@@ -1171,6 +1180,11 @@
1171
1180
  };
1172
1181
  if (enc.format === "jpeg") params.quality = enc.quality;
1173
1182
  if (plan.clip) params.clip = plan.clip;
1183
+ if (!plan.clip) {
1184
+ const d = measured.dims;
1185
+ params.clip = { x: d.scrollX ?? 0, y: d.scrollY ?? 0, width: d.w, height: d.h, scale: 1 / (d.dpr && d.dpr > 0 ? d.dpr : 1) };
1186
+ }
1187
+ params.captureBeyondViewport = true;
1174
1188
  const data = await withDebugger(tabId, async (target) => {
1175
1189
  const res = await chrome.debugger.sendCommand(target, "Page.captureScreenshot", params);
1176
1190
  return res.data ?? "";
@@ -1527,7 +1541,9 @@
1527
1541
  selector: String(f.selector ?? ""),
1528
1542
  value: typeof f.value === "boolean" ? f.value : String(f.value ?? "")
1529
1543
  }));
1544
+ const deadline = typeof cmd.timeoutMs === "number" && cmd.timeoutMs > 0 ? Date.now() + cmd.timeoutMs - 1e3 : void 0;
1530
1545
  return runFillFields(ops, {
1546
+ deadline,
1531
1547
  currentUrl: () => observedTabUrl(cmd, id),
1532
1548
  policy: () => this.getPolicy(),
1533
1549
  // Frame ids are re-probed per field too: an iframe that navigates
@@ -1808,6 +1824,7 @@
1808
1824
  }
1809
1825
 
1810
1826
  // extension/src/sw/router.ts
1827
+ var CAPTURES = /* @__PURE__ */ new Set(["screenshot", "print_pdf"]);
1811
1828
  var CommandRouter = class {
1812
1829
  constructor(deps) {
1813
1830
  this.deps = deps;
@@ -1830,7 +1847,9 @@
1830
1847
  const verdict = evaluatePolicy(url, cmd.method, policy);
1831
1848
  if (!verdict.ok) throw new CmdError("POLICY_DENIED", verdict.reason);
1832
1849
  }
1833
- const data = await this.deps.exec.run(cmd, tab);
1850
+ const border2 = this.deps.border;
1851
+ const data = border2 && CAPTURES.has(cmd.method) ? await border2.aroundCapture(tab, () => this.deps.exec.run(cmd, tab)) : await this.deps.exec.run(cmd, tab);
1852
+ if (border2) void this.markBorder(border2, cmd, tab, data);
1834
1853
  const frame = { type: "result", v: PROTOCOL_VERSION, id: cmd.id, ok: true, data };
1835
1854
  const tabUrl = await observedTabUrl(cmd, tab);
1836
1855
  if (tabUrl) frame.tabUrl = tabUrl;
@@ -1849,6 +1868,19 @@
1849
1868
  this.deps.send(frame);
1850
1869
  }
1851
1870
  }
1871
+ /** Border the tab a successful command worked in. Never delays or fails the reply. */
1872
+ async markBorder(border2, cmd, tab, data) {
1873
+ try {
1874
+ if (cmd.method === "tab_new") {
1875
+ const handle = data?.tabId;
1876
+ const id = typeof handle === "string" ? Number(handle.split(":")[2]) : NaN;
1877
+ if (Number.isInteger(id)) await border2.opened(id);
1878
+ } else if (tab !== null && cmd.method !== "tab_close") {
1879
+ await border2.used(tab);
1880
+ }
1881
+ } catch {
1882
+ }
1883
+ }
1852
1884
  };
1853
1885
 
1854
1886
  // extension/src/sw/observers.ts
@@ -1881,10 +1913,204 @@
1881
1913
  }
1882
1914
  }
1883
1915
 
1916
+ // shared/tab-border.ts
1917
+ var BORDER_BLUE = "#2563eb";
1918
+ var BORDER_BLUE_ON_DARK = "#60a5fa";
1919
+ function parseColor(value) {
1920
+ const v = value.trim().toLowerCase();
1921
+ let m = /^#([0-9a-f]{3})$/.exec(v);
1922
+ if (m) {
1923
+ const [r, g, b] = m[1].split("").map((c) => parseInt(c + c, 16));
1924
+ return { r, g, b, a: 1 };
1925
+ }
1926
+ m = /^#([0-9a-f]{6})$/.exec(v);
1927
+ if (m) {
1928
+ const n = parseInt(m[1], 16);
1929
+ return { r: n >> 16 & 255, g: n >> 8 & 255, b: n & 255, a: 1 };
1930
+ }
1931
+ m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+))?\s*\)$/.exec(v);
1932
+ if (m) {
1933
+ return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]), a: m[4] === void 0 ? 1 : Number(m[4]) };
1934
+ }
1935
+ return null;
1936
+ }
1937
+ function luminance({ r, g, b }) {
1938
+ const lin = (c) => {
1939
+ const s = c / 255;
1940
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
1941
+ };
1942
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
1943
+ }
1944
+ function contrast(a, b) {
1945
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
1946
+ return (hi + 0.05) / (lo + 0.05);
1947
+ }
1948
+ var toHex = ({ r, g, b }) => "#" + [r, g, b].map((c) => Math.round(c).toString(16).padStart(2, "0")).join("");
1949
+ var MIN_CONTRAST = 2;
1950
+ function pickBorderColor(theme) {
1951
+ const bg = parseColor(theme.background);
1952
+ const opaqueBg = bg && bg.a > 0 ? bg : null;
1953
+ const dark = opaqueBg ? luminance(opaqueBg) < 0.2 : theme.prefersDark;
1954
+ const fallback = dark ? BORDER_BLUE_ON_DARK : BORDER_BLUE;
1955
+ const tc = parseColor(theme.themeColor);
1956
+ if (!tc || tc.a < 1) return fallback;
1957
+ const against = opaqueBg ?? (dark ? { r: 0, g: 0, b: 0, a: 1 } : { r: 255, g: 255, b: 255, a: 1 });
1958
+ return contrast(tc, against) >= MIN_CONTRAST ? toHex(tc) : fallback;
1959
+ }
1960
+ function borderCss(color) {
1961
+ const c = /^#[0-9a-f]{6}$/i.test(color) ? color : BORDER_BLUE;
1962
+ return `html::after{content:"" !important;display:block !important;position:fixed !important;inset:0 !important;box-sizing:border-box !important;border:3px solid ${c} !important;box-shadow:inset 0 0 12px ${c}66 !important;pointer-events:none !important;z-index:2147483647 !important;}`;
1963
+ }
1964
+
1965
+ // extension/src/sw/tab-border.ts
1966
+ var KEY = "borderTabs";
1967
+ async function loadMarked() {
1968
+ const got = await chrome.storage.session.get(KEY).catch(() => ({}));
1969
+ const v = got[KEY];
1970
+ return v && typeof v === "object" ? { ...v } : {};
1971
+ }
1972
+ var saveMarked = (m) => chrome.storage.session.set({ [KEY]: m }).catch(() => void 0);
1973
+ async function enabled() {
1974
+ const { tabBorder } = await chrome.storage.local.get("tabBorder");
1975
+ return tabBorder !== false;
1976
+ }
1977
+ function readPageTheme() {
1978
+ const ctx = document.createElement("canvas").getContext("2d");
1979
+ const norm = (v) => {
1980
+ if (!ctx || !v) return "";
1981
+ ctx.fillStyle = "#010203";
1982
+ ctx.fillStyle = v;
1983
+ const out = String(ctx.fillStyle);
1984
+ return out === "#010203" ? "" : out;
1985
+ };
1986
+ const meta = Array.from(document.querySelectorAll('meta[name="theme-color"]')).find((m) => {
1987
+ const media = m.getAttribute("media");
1988
+ try {
1989
+ return !media || matchMedia(media).matches;
1990
+ } catch {
1991
+ return false;
1992
+ }
1993
+ });
1994
+ const clear = (v) => !v || v === "transparent" || /,\s*0\)$/.test(v);
1995
+ let bg = document.body ? getComputedStyle(document.body).backgroundColor : "";
1996
+ if (clear(bg)) bg = getComputedStyle(document.documentElement).backgroundColor;
1997
+ return {
1998
+ themeColor: norm(meta?.getAttribute("content") ?? ""),
1999
+ background: clear(bg) ? "" : norm(bg),
2000
+ prefersDark: matchMedia("(prefers-color-scheme: dark)").matches
2001
+ };
2002
+ }
2003
+ async function tabAllowed(tabId, policy) {
2004
+ if (!policy) return false;
2005
+ const tab = await chrome.tabs.get(tabId).catch(() => null);
2006
+ return !!tab?.url && evaluatePolicy(tab.url, "get_text", policy).ok;
2007
+ }
2008
+ async function paint(tabId) {
2009
+ try {
2010
+ const [res] = await chrome.scripting.executeScript({ target: { tabId }, func: readPageTheme });
2011
+ const css = borderCss(pickBorderColor(res?.result ?? { themeColor: "", background: "", prefersDark: false }));
2012
+ await chrome.scripting.insertCSS({ target: { tabId }, css, origin: "USER" });
2013
+ return css;
2014
+ } catch {
2015
+ return "";
2016
+ }
2017
+ }
2018
+ async function unpaint(tabId, css) {
2019
+ if (css) await chrome.scripting.removeCSS({ target: { tabId }, css, origin: "USER" }).catch(() => void 0);
2020
+ }
2021
+ var TabBorder = class {
2022
+ constructor(getPolicy) {
2023
+ this.getPolicy = getPolicy;
2024
+ }
2025
+ /** One lock for all border state: parallel batch ops touching the same tab
2026
+ * must not both inject (removeCSS takes out one copy, not both). */
2027
+ lock = new KeyedMutex();
2028
+ /** Captures in flight per tab: the border comes back after the last one. */
2029
+ capturing = /* @__PURE__ */ new Map();
2030
+ locked(fn) {
2031
+ return this.lock.run("border", fn);
2032
+ }
2033
+ /** A command just ran in `tabId`: border it if it isn't already. */
2034
+ async used(tabId) {
2035
+ return this.locked(async () => {
2036
+ if (!await enabled() || !await tabAllowed(tabId, this.getPolicy())) return;
2037
+ const marked = await loadMarked();
2038
+ if (marked[tabId]) return;
2039
+ marked[tabId] = await paint(tabId);
2040
+ await saveMarked(marked);
2041
+ });
2042
+ }
2043
+ /** A tab opened by tab_new: remember it, and paint once it loads. */
2044
+ async opened(tabId) {
2045
+ return this.locked(async () => {
2046
+ if (!await enabled()) return;
2047
+ const marked = await loadMarked();
2048
+ marked[tabId] = marked[tabId] ?? "";
2049
+ await saveMarked(marked);
2050
+ });
2051
+ }
2052
+ /** A marked tab finished loading a page: the old document took its CSS with
2053
+ * it, so repaint (the new page may have another theme), or drop the tab if
2054
+ * it left the allowed sites. */
2055
+ async loaded(tabId) {
2056
+ return this.locked(async () => {
2057
+ const marked = await loadMarked();
2058
+ if (!(tabId in marked)) return;
2059
+ if (!await enabled() || !await tabAllowed(tabId, this.getPolicy())) {
2060
+ await unpaint(tabId, marked[tabId]);
2061
+ delete marked[tabId];
2062
+ } else {
2063
+ await unpaint(tabId, marked[tabId]);
2064
+ marked[tabId] = await paint(tabId);
2065
+ }
2066
+ await saveMarked(marked);
2067
+ });
2068
+ }
2069
+ async closed(tabId) {
2070
+ return this.locked(async () => {
2071
+ const marked = await loadMarked();
2072
+ if (!(tabId in marked)) return;
2073
+ delete marked[tabId];
2074
+ await saveMarked(marked);
2075
+ });
2076
+ }
2077
+ /** Take the border down for a capture (screenshot, PDF) and put it back after. */
2078
+ async aroundCapture(tabId, fn) {
2079
+ if (tabId === null) return fn();
2080
+ const css = await this.locked(async () => {
2081
+ const c = (await loadMarked())[tabId] ?? "";
2082
+ if (c) this.capturing.set(tabId, (this.capturing.get(tabId) ?? 0) + 1);
2083
+ await unpaint(tabId, c);
2084
+ return c;
2085
+ });
2086
+ if (!css) return fn();
2087
+ try {
2088
+ return await fn();
2089
+ } finally {
2090
+ await this.locked(async () => {
2091
+ const left = (this.capturing.get(tabId) ?? 1) - 1;
2092
+ if (left > 0) return void this.capturing.set(tabId, left);
2093
+ this.capturing.delete(tabId);
2094
+ if ((await loadMarked())[tabId] !== css) return;
2095
+ await chrome.scripting.insertCSS({ target: { tabId }, css, origin: "USER" }).catch(() => void 0);
2096
+ });
2097
+ }
2098
+ }
2099
+ /** Server gone or border switched off: clear every tab. */
2100
+ async clearAll() {
2101
+ return this.locked(async () => {
2102
+ const marked = await loadMarked();
2103
+ await Promise.all(Object.entries(marked).map(([id, css]) => unpaint(Number(id), css)));
2104
+ await saveMarked({});
2105
+ });
2106
+ }
2107
+ };
2108
+
1884
2109
  // extension/src/sw/background.ts
1885
2110
  var KEEPALIVE_ALARM = "chrome-mcp-keepalive";
1886
2111
  var currentPolicy = null;
1887
2112
  var executor = new ChromeExecutor(() => currentPolicy);
2113
+ var border = new TabBorder(() => currentPolicy);
1888
2114
  var RECONNECT_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 1e4];
1889
2115
  var reconnectAttempt = 0;
1890
2116
  var reconnectTimer = null;
@@ -1908,6 +2134,7 @@
1908
2134
  void persistState(state);
1909
2135
  if (state === "connected") clearReconnect();
1910
2136
  else if (state === "idle") scheduleReconnect();
2137
+ if (state === "idle" || state === "unauthorized") void border.clearAll();
1911
2138
  if (state === "unauthorized") void adoptBundledPairing();
1912
2139
  },
1913
2140
  onPolicy: (policy) => {
@@ -1922,7 +2149,8 @@
1922
2149
  exec: executor,
1923
2150
  send: (frame) => ws.send(frame),
1924
2151
  getPolicy: () => currentPolicy,
1925
- log: (m) => console.debug("[chrome-mcp]", m)
2152
+ log: (m) => console.debug("[chrome-mcp]", m),
2153
+ border
1926
2154
  });
1927
2155
  async function readBundledPairing() {
1928
2156
  try {
@@ -2015,7 +2243,12 @@
2015
2243
  chrome.alarms.onAlarm.addListener((alarm) => {
2016
2244
  if (alarm.name === KEEPALIVE_ALARM) void keepalivePulse();
2017
2245
  });
2246
+ chrome.tabs.onUpdated.addListener((tabId, info) => {
2247
+ if (info.status === "complete") void border.loaded(tabId);
2248
+ });
2249
+ chrome.tabs.onRemoved.addListener((tabId) => void border.closed(tabId));
2018
2250
  chrome.storage.onChanged.addListener((changes, area) => {
2251
+ if (area === "local" && changes.tabBorder?.newValue === false) void border.clearAll();
2019
2252
  if (area === "local" && (changes.wsPort || changes.token || changes.profile)) {
2020
2253
  if (ws.state === "unauthorized") ws.state = "idle";
2021
2254
  if (changes.profile) ws.close();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "MCP Extension for Chrome",
4
- "version": "0.9.8",
4
+ "version": "0.9.13",
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",
@@ -12,6 +12,8 @@
12
12
  .status { margin-top: 1rem; padding: 0.5rem 0.75rem; border-radius: 0.4rem; background: #eef; }
13
13
  code { background: #f3f3f3; padding: 0 0.25rem; }
14
14
  .hint { color: #555; font-size: 0.9rem; }
15
+ label.check { display: flex; gap: 0.5rem; align-items: center; font-weight: 600; margin-top: 1rem; }
16
+ label.check input { width: auto; }
15
17
  </style>
16
18
  </head>
17
19
  <body>
@@ -35,6 +37,11 @@
35
37
  <code>profile_use</code>. Type a name here only to pin one yourself.
36
38
  </p>
37
39
  <p class="hint" id="paired-as"></p>
40
+ <label class="check"><input id="tab-border" type="checkbox" checked /> Outline the tabs chrome-mcp is working in</label>
41
+ <p class="hint">
42
+ A border in the page's theme colour (blue when the site has none) marks every tab the
43
+ agent is driving. It never shows in screenshots or PDFs, and is removed when the server disconnects.
44
+ </p>
38
45
  <button id="save">Save & connect</button>
39
46
  <div class="status" id="status">Status: unknown</div>
40
47
  <script src="options.js"></script>
@@ -11,6 +11,9 @@
11
11
  var statusEl = document.getElementById("status");
12
12
  var sourceEl = document.getElementById("source");
13
13
  var pairedAsEl = document.getElementById("paired-as");
14
+ var borderEl = document.getElementById("tab-border");
15
+ void chrome.storage.local.get("tabBorder").then(({ tabBorder }) => borderEl.checked = tabBorder !== false);
16
+ borderEl.addEventListener("change", () => void chrome.storage.local.set({ tabBorder: borderEl.checked }));
14
17
  function renderPairedAs(name) {
15
18
  pairedAsEl.textContent = typeof name === "string" && name ? `This browser is paired as "${name}".` : "";
16
19
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mehmoodqureshi/chrome-mcp",
3
3
  "mcpName": "io.github.Mehmoodqureshi/chrome-mcp",
4
- "version": "0.9.11",
4
+ "version": "0.9.13",
5
5
  "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.",
6
6
  "author": "Mehmood Ur Rehman Qureshi",
7
7
  "license": "MIT",