@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 +43 -1
- package/dist/shared/fill-form.d.ts +8 -0
- package/dist/shared/fill-form.js +9 -0
- package/dist/shared/protocol.d.ts +13 -0
- package/dist/shared/protocol.js +18 -1
- package/dist/shared/tab-border.d.ts +42 -0
- package/dist/shared/tab-border.js +79 -0
- package/dist/src/bridge/auth.d.ts +12 -0
- package/dist/src/bridge/auth.js +40 -0
- package/dist/src/cli.js +15 -3
- package/dist/src/executor/extension-executor.js +1 -1
- package/extension-dist/background.js +235 -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
|
|
@@ -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
|
/**
|
package/dist/shared/fill-form.js
CHANGED
|
@@ -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.
|
package/dist/shared/protocol.js
CHANGED
|
@@ -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
|
package/dist/src/bridge/auth.js
CHANGED
|
@@ -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
|
-
|
|
242
|
-
|
|
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
|
|
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.
|
|
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",
|