@mehmoodqureshi/chrome-mcp 0.9.12 → 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
@@ -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
@@ -1180,6 +1180,11 @@
1180
1180
  };
1181
1181
  if (enc.format === "jpeg") params.quality = enc.quality;
1182
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;
1183
1188
  const data = await withDebugger(tabId, async (target) => {
1184
1189
  const res = await chrome.debugger.sendCommand(target, "Page.captureScreenshot", params);
1185
1190
  return res.data ?? "";
@@ -1819,6 +1824,7 @@
1819
1824
  }
1820
1825
 
1821
1826
  // extension/src/sw/router.ts
1827
+ var CAPTURES = /* @__PURE__ */ new Set(["screenshot", "print_pdf"]);
1822
1828
  var CommandRouter = class {
1823
1829
  constructor(deps) {
1824
1830
  this.deps = deps;
@@ -1841,7 +1847,9 @@
1841
1847
  const verdict = evaluatePolicy(url, cmd.method, policy);
1842
1848
  if (!verdict.ok) throw new CmdError("POLICY_DENIED", verdict.reason);
1843
1849
  }
1844
- 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);
1845
1853
  const frame = { type: "result", v: PROTOCOL_VERSION, id: cmd.id, ok: true, data };
1846
1854
  const tabUrl = await observedTabUrl(cmd, tab);
1847
1855
  if (tabUrl) frame.tabUrl = tabUrl;
@@ -1860,6 +1868,19 @@
1860
1868
  this.deps.send(frame);
1861
1869
  }
1862
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
+ }
1863
1884
  };
1864
1885
 
1865
1886
  // extension/src/sw/observers.ts
@@ -1892,10 +1913,204 @@
1892
1913
  }
1893
1914
  }
1894
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
+
1895
2109
  // extension/src/sw/background.ts
1896
2110
  var KEEPALIVE_ALARM = "chrome-mcp-keepalive";
1897
2111
  var currentPolicy = null;
1898
2112
  var executor = new ChromeExecutor(() => currentPolicy);
2113
+ var border = new TabBorder(() => currentPolicy);
1899
2114
  var RECONNECT_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 1e4];
1900
2115
  var reconnectAttempt = 0;
1901
2116
  var reconnectTimer = null;
@@ -1919,6 +2134,7 @@
1919
2134
  void persistState(state);
1920
2135
  if (state === "connected") clearReconnect();
1921
2136
  else if (state === "idle") scheduleReconnect();
2137
+ if (state === "idle" || state === "unauthorized") void border.clearAll();
1922
2138
  if (state === "unauthorized") void adoptBundledPairing();
1923
2139
  },
1924
2140
  onPolicy: (policy) => {
@@ -1933,7 +2149,8 @@
1933
2149
  exec: executor,
1934
2150
  send: (frame) => ws.send(frame),
1935
2151
  getPolicy: () => currentPolicy,
1936
- log: (m) => console.debug("[chrome-mcp]", m)
2152
+ log: (m) => console.debug("[chrome-mcp]", m),
2153
+ border
1937
2154
  });
1938
2155
  async function readBundledPairing() {
1939
2156
  try {
@@ -2026,7 +2243,12 @@
2026
2243
  chrome.alarms.onAlarm.addListener((alarm) => {
2027
2244
  if (alarm.name === KEEPALIVE_ALARM) void keepalivePulse();
2028
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));
2029
2250
  chrome.storage.onChanged.addListener((changes, area) => {
2251
+ if (area === "local" && changes.tabBorder?.newValue === false) void border.clearAll();
2030
2252
  if (area === "local" && (changes.wsPort || changes.token || changes.profile)) {
2031
2253
  if (ws.state === "unauthorized") ws.state = "idle";
2032
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.12",
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.12",
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",