@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 +43 -1
- package/dist/shared/tab-border.d.ts +42 -0
- package/dist/shared/tab-border.js +79 -0
- package/extension-dist/background.js +224 -2
- package/extension-dist/manifest.json +1 -1
- package/extension-dist/options.html +7 -0
- package/extension-dist/options.js +3 -0
- package/package.json +1 -1
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
|
|
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
|
|
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.
|
|
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.
|
|
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",
|