@mehmoodqureshi/chrome-mcp 0.1.0 → 0.2.0
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 +38 -5
- package/dist/shared/protocol.d.ts +1 -1
- package/dist/shared/protocol.js +4 -0
- package/dist/shared/snapshot.d.ts +26 -0
- package/dist/shared/snapshot.js +92 -0
- package/dist/src/bridge/auth.d.ts +23 -1
- package/dist/src/bridge/auth.js +68 -1
- package/dist/src/cli.js +7 -1
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +8 -0
- package/dist/src/executor/cdp-executor.d.ts +24 -1
- package/dist/src/executor/cdp-executor.js +72 -1
- package/dist/src/executor/extension-executor.d.ts +24 -1
- package/dist/src/executor/extension-executor.js +14 -2
- package/dist/src/executor/stub-executor.d.ts +10 -1
- package/dist/src/executor/stub-executor.js +19 -0
- package/dist/src/executor/types.d.ts +60 -0
- package/dist/src/mcp/tools.js +43 -2
- package/extension-dist/background.js +307 -25
- package/extension-dist/manifest.json +2 -2
- package/extension-dist/options.js +6 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -34,6 +34,30 @@ By default everything is **deny-all** (no domains, no eval, no mutations). Grant
|
|
|
34
34
|
exactly what you need with `--allow-domain <glob>` (repeatable), `--enable-mutations`,
|
|
35
35
|
`--enable-downloads`, `--unsafe-enable-eval`, or `--unsafe-all-domains`.
|
|
36
36
|
|
|
37
|
+
**Drive only your real Chrome (recommended for the extension).** Add
|
|
38
|
+
`--no-cdp-fallback` so the server never launches a separate Chromium, and
|
|
39
|
+
`--persist-token` so the pairing token survives restarts — **pair once, never
|
|
40
|
+
again**:
|
|
41
|
+
|
|
42
|
+
```jsonc
|
|
43
|
+
{
|
|
44
|
+
"mcpServers": {
|
|
45
|
+
"chrome-mcp": {
|
|
46
|
+
"command": "npx",
|
|
47
|
+
"args": ["-y", "@mehmoodqureshi/chrome-mcp",
|
|
48
|
+
"--allow-domain", "example.com", "--enable-mutations",
|
|
49
|
+
"--no-cdp-fallback", "--persist-token"]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Without `--persist-token` a fresh token is minted every boot (the secure
|
|
56
|
+
default), which means re-pairing the extension on each restart. With it, the
|
|
57
|
+
token is stored 0600 at `~/.chrome-mcp/token` and reused; the extension's
|
|
58
|
+
keepalive auto-reconnects with no manual step. `CHROME_MCP_TOKEN` pins the token
|
|
59
|
+
explicitly (and is never written to disk).
|
|
60
|
+
|
|
37
61
|
**2. Load the extension** (to drive your *real* Chrome): build it, then
|
|
38
62
|
`chrome://extensions` → enable Developer mode → **Load unpacked** → select
|
|
39
63
|
`extension-dist/`.
|
|
@@ -43,15 +67,24 @@ print its path, open the extension's **Options** page, and paste the `port` +
|
|
|
43
67
|
`token` from `~/.chrome-mcp/handshake.json`. (Without the extension, the CLI
|
|
44
68
|
falls back to a Playwright-driven Chromium automatically.)
|
|
45
69
|
|
|
46
|
-
The
|
|
47
|
-
`scroll`), reads (`get_text`/`get_html`/`screenshot`/`eval`/`wait_for`),
|
|
48
|
-
(`
|
|
70
|
+
The tools cover tabs, navigation, interaction (`click`/`type`/`press`/`hover`/
|
|
71
|
+
`scroll`/`select_option`), reads (`get_text`/`get_html`/`screenshot`/`eval`/`wait_for`),
|
|
72
|
+
an accessibility `snapshot` (interactive elements with stable `ref`s the model can
|
|
73
|
+
target instead of guessing CSS selectors), session access (`get_cookies`/`storage`),
|
|
74
|
+
helpers (`extract_links`/`read_as_markdown`/`fill_form`/`download_file`), and
|
|
75
|
+
`chrome_status`.
|
|
76
|
+
|
|
77
|
+
`click`/`type` accept `trusted: true` for real OS-level input (works on
|
|
78
|
+
React/Vue controlled inputs); interactions auto-wait for the target to appear.
|
|
49
79
|
|
|
50
80
|
## Status
|
|
51
81
|
|
|
52
|
-
v0.
|
|
82
|
+
v0.2.0 — all six build phases complete and green (57 automated tests + a gated
|
|
53
83
|
headed extension smoke). End-to-end working: `npx chrome-mcp` ⇄ bridge ⇄
|
|
54
|
-
extension ⇄ your real Chrome, with a Playwright CDP fallback.
|
|
84
|
+
extension ⇄ your real Chrome, with a Playwright CDP fallback. v0.2 adds the
|
|
85
|
+
accessibility `snapshot` + element refs, auto-wait, cookies/storage/`select_option`,
|
|
86
|
+
trusted input (`chrome.debugger`), a toolbar status badge, and a stable pairing
|
|
87
|
+
token (`--persist-token`).
|
|
55
88
|
|
|
56
89
|
- [x] **Phase 0 — Contracts & skeleton:** `shared/protocol.ts` (wire contract),
|
|
57
90
|
`src/executor/types.ts` (Executor interface), `src/security/policy.ts`
|
|
@@ -32,7 +32,7 @@ export declare const CLOSE_SUPERSEDED: 4000;
|
|
|
32
32
|
* `download_file` (privileged, executor-owned) and `ping_probe` (a short-deadline
|
|
33
33
|
* responsiveness check used to detect a dead-but-not-yet-reconnected worker).
|
|
34
34
|
*/
|
|
35
|
-
export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'eval' | 'wait_for' | 'download_file' | 'ping_probe';
|
|
35
|
+
export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'snapshot' | 'select_option' | 'get_cookies' | 'storage' | 'eval' | 'wait_for' | 'download_file' | 'ping_probe';
|
|
36
36
|
/** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
|
|
37
37
|
export declare const WIRE_METHODS: readonly WireMethod[];
|
|
38
38
|
export type ExecutorErrorCode = 'NO_TARGET' | 'TARGET_GONE' | 'DETACHED' | 'DEVTOOLS_OPEN' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_THREW' | 'TIMEOUT' | 'BAD_ARGS' | 'CDP_ERROR' | 'POLICY_DENIED' | 'DOWNLOAD_FAILED' | 'UNKNOWN_METHOD';
|
package/dist/shared/protocol.js
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/snapshot.ts — the page-injected accessibility walk, shared VERBATIM by
|
|
3
|
+
* both backends (CDP `page.evaluate` and the extension's `chrome.scripting`).
|
|
4
|
+
*
|
|
5
|
+
* MUST be self-contained: it is serialized to source and runs in the PAGE
|
|
6
|
+
* context, so it may not close over anything from this module. It tags each
|
|
7
|
+
* returned element with a stable `data-mcp-ref` so a later click/type can target
|
|
8
|
+
* it by `ref` (resolved to `[data-mcp-ref="..."]`). Refs live until navigation.
|
|
9
|
+
*/
|
|
10
|
+
export interface RawSnapshotNode {
|
|
11
|
+
ref: string;
|
|
12
|
+
role: string;
|
|
13
|
+
name: string;
|
|
14
|
+
tag: string;
|
|
15
|
+
value?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
checked?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface RawSnapshot {
|
|
20
|
+
url: string;
|
|
21
|
+
title: string;
|
|
22
|
+
nodes: RawSnapshotNode[];
|
|
23
|
+
truncated: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** Runs IN THE PAGE. Returns interactive (and optionally landmark) elements with fresh refs. */
|
|
26
|
+
export declare function collectSnapshot(interactiveOnly?: boolean, max?: number): RawSnapshot;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* shared/snapshot.ts — the page-injected accessibility walk, shared VERBATIM by
|
|
4
|
+
* both backends (CDP `page.evaluate` and the extension's `chrome.scripting`).
|
|
5
|
+
*
|
|
6
|
+
* MUST be self-contained: it is serialized to source and runs in the PAGE
|
|
7
|
+
* context, so it may not close over anything from this module. It tags each
|
|
8
|
+
* returned element with a stable `data-mcp-ref` so a later click/type can target
|
|
9
|
+
* it by `ref` (resolved to `[data-mcp-ref="..."]`). Refs live until navigation.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.collectSnapshot = collectSnapshot;
|
|
13
|
+
/** Runs IN THE PAGE. Returns interactive (and optionally landmark) elements with fresh refs. */
|
|
14
|
+
function collectSnapshot(interactiveOnly = true, max = 200) {
|
|
15
|
+
const INTERACTIVE = 'a[href],button,input,select,textarea,[role=button],[role=link],[role=tab],[role=checkbox],[role=radio],[role=menuitem],[role=option],[role=switch],[contenteditable=true],[onclick]';
|
|
16
|
+
const LANDMARK = 'h1,h2,h3,[role=heading],nav,main,header,footer,[role=navigation]';
|
|
17
|
+
const sel = interactiveOnly ? INTERACTIVE : `${INTERACTIVE},${LANDMARK}`;
|
|
18
|
+
const visible = (el) => {
|
|
19
|
+
const r = el.getBoundingClientRect();
|
|
20
|
+
if (r.width === 0 && r.height === 0)
|
|
21
|
+
return false;
|
|
22
|
+
const s = window.getComputedStyle(el);
|
|
23
|
+
return s.visibility !== 'hidden' && s.display !== 'none';
|
|
24
|
+
};
|
|
25
|
+
const accName = (el) => {
|
|
26
|
+
const aria = el.getAttribute('aria-label');
|
|
27
|
+
if (aria)
|
|
28
|
+
return aria.trim();
|
|
29
|
+
const labelledby = el.getAttribute('aria-labelledby');
|
|
30
|
+
if (labelledby) {
|
|
31
|
+
const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.innerText ?? '').join(' ').trim();
|
|
32
|
+
if (t)
|
|
33
|
+
return t;
|
|
34
|
+
}
|
|
35
|
+
const ph = el.getAttribute('placeholder');
|
|
36
|
+
if (ph)
|
|
37
|
+
return ph.trim();
|
|
38
|
+
const title = el.getAttribute('title');
|
|
39
|
+
if (title)
|
|
40
|
+
return title.trim();
|
|
41
|
+
const text = el.innerText ?? '';
|
|
42
|
+
if (text)
|
|
43
|
+
return text.replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
44
|
+
const alt = el.querySelector('img[alt]')?.getAttribute('alt');
|
|
45
|
+
return (alt ?? '').trim();
|
|
46
|
+
};
|
|
47
|
+
const roleOf = (el) => {
|
|
48
|
+
const explicit = el.getAttribute('role');
|
|
49
|
+
if (explicit)
|
|
50
|
+
return explicit;
|
|
51
|
+
const tag = el.tagName.toLowerCase();
|
|
52
|
+
if (tag === 'a')
|
|
53
|
+
return 'link';
|
|
54
|
+
if (tag === 'button')
|
|
55
|
+
return 'button';
|
|
56
|
+
if (tag === 'select')
|
|
57
|
+
return 'combobox';
|
|
58
|
+
if (tag === 'textarea')
|
|
59
|
+
return 'textbox';
|
|
60
|
+
if (tag === 'input') {
|
|
61
|
+
const t = el.type;
|
|
62
|
+
if (t === 'checkbox')
|
|
63
|
+
return 'checkbox';
|
|
64
|
+
if (t === 'radio')
|
|
65
|
+
return 'radio';
|
|
66
|
+
if (t === 'button' || t === 'submit')
|
|
67
|
+
return 'button';
|
|
68
|
+
return 'textbox';
|
|
69
|
+
}
|
|
70
|
+
return tag;
|
|
71
|
+
};
|
|
72
|
+
const els = Array.from(document.querySelectorAll(sel)).filter(visible);
|
|
73
|
+
const nodes = [];
|
|
74
|
+
let n = 0;
|
|
75
|
+
for (const el of els) {
|
|
76
|
+
if (nodes.length >= max)
|
|
77
|
+
break;
|
|
78
|
+
const ref = `e${++n}`;
|
|
79
|
+
el.setAttribute('data-mcp-ref', ref);
|
|
80
|
+
const node = { ref, role: roleOf(el), name: accName(el), tag: el.tagName.toLowerCase() };
|
|
81
|
+
const v = el.value;
|
|
82
|
+
if (typeof v === 'string' && v)
|
|
83
|
+
node.value = v.slice(0, 200);
|
|
84
|
+
if (el.disabled)
|
|
85
|
+
node.disabled = true;
|
|
86
|
+
if (el.checked)
|
|
87
|
+
node.checked = true;
|
|
88
|
+
nodes.push(node);
|
|
89
|
+
}
|
|
90
|
+
return { url: location.href, title: document.title, nodes, truncated: els.length > nodes.length };
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=snapshot.js.map
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* src/bridge/auth.ts — the ONE auth model (every other variant in the design
|
|
3
3
|
* drafts was deleted on purpose).
|
|
4
4
|
*
|
|
5
|
-
* - Fresh 256-bit token EVERY boot, never persisted across restarts.
|
|
5
|
+
* - Fresh 256-bit token EVERY boot by default, never persisted across restarts.
|
|
6
|
+
* Persistence is OPT-IN (`--persist-token` / `CHROME_MCP_TOKEN`) for hosts
|
|
7
|
+
* that want a stable token so the extension never has to re-pair.
|
|
6
8
|
* - Written atomically (tmp + rename) to `handshake.json` at mode 0600; the
|
|
7
9
|
* mode is re-verified after write and we FAIL CLOSED if it can't be set.
|
|
8
10
|
* - Compared by hashing both sides to SHA-256 and `timingSafeEqual`-ing the
|
|
@@ -12,6 +14,26 @@
|
|
|
12
14
|
import { type HandshakeFile } from '../../shared/protocol';
|
|
13
15
|
/** A fresh 256-bit token, base64url. Generated once per server boot. */
|
|
14
16
|
export declare function generateToken(): string;
|
|
17
|
+
/** Path of the optional persisted-token file (only used when persistence is on). */
|
|
18
|
+
export declare function tokenPath(dir: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Read the persisted token (trimmed) if present and non-empty; else null.
|
|
21
|
+
* FAILS CLOSED if the file is group/other-readable — same trust boundary as the
|
|
22
|
+
* handshake, so a loose permission is a hard error rather than a silent reuse.
|
|
23
|
+
*/
|
|
24
|
+
export declare function readPersistedToken(dir: string): string | null;
|
|
25
|
+
/** Atomically write the persisted token at 0600 and verify the mode (fail closed). */
|
|
26
|
+
export declare function writePersistedToken(dir: string, token: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the token for this boot:
|
|
29
|
+
* 1. `CHROME_MCP_TOKEN` env — an explicit pin; used verbatim, never written to disk.
|
|
30
|
+
* 2. `persist` on — reuse the on-disk token (creating + saving one on first run),
|
|
31
|
+
* so the extension stays paired across restarts.
|
|
32
|
+
* 3. otherwise — a fresh per-boot token (the secure default; never persisted).
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveToken(dir: string, opts: {
|
|
35
|
+
persist: boolean;
|
|
36
|
+
}): string;
|
|
15
37
|
/** Constant-time token compare via fixed-length SHA-256 digests. */
|
|
16
38
|
export declare function tokensMatch(a: string, b: string): boolean;
|
|
17
39
|
export interface WriteHandshakeFields {
|
package/dist/src/bridge/auth.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* src/bridge/auth.ts — the ONE auth model (every other variant in the design
|
|
4
4
|
* drafts was deleted on purpose).
|
|
5
5
|
*
|
|
6
|
-
* - Fresh 256-bit token EVERY boot, never persisted across restarts.
|
|
6
|
+
* - Fresh 256-bit token EVERY boot by default, never persisted across restarts.
|
|
7
|
+
* Persistence is OPT-IN (`--persist-token` / `CHROME_MCP_TOKEN`) for hosts
|
|
8
|
+
* that want a stable token so the extension never has to re-pair.
|
|
7
9
|
* - Written atomically (tmp + rename) to `handshake.json` at mode 0600; the
|
|
8
10
|
* mode is re-verified after write and we FAIL CLOSED if it can't be set.
|
|
9
11
|
* - Compared by hashing both sides to SHA-256 and `timingSafeEqual`-ing the
|
|
@@ -12,12 +14,17 @@
|
|
|
12
14
|
*/
|
|
13
15
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
16
|
exports.generateToken = generateToken;
|
|
17
|
+
exports.tokenPath = tokenPath;
|
|
18
|
+
exports.readPersistedToken = readPersistedToken;
|
|
19
|
+
exports.writePersistedToken = writePersistedToken;
|
|
20
|
+
exports.resolveToken = resolveToken;
|
|
15
21
|
exports.tokensMatch = tokensMatch;
|
|
16
22
|
exports.writeHandshake = writeHandshake;
|
|
17
23
|
exports.readHandshake = readHandshake;
|
|
18
24
|
exports.removeHandshake = removeHandshake;
|
|
19
25
|
exports.redactToken = redactToken;
|
|
20
26
|
const node_fs_1 = require("node:fs");
|
|
27
|
+
const node_path_1 = require("node:path");
|
|
21
28
|
const node_crypto_1 = require("node:crypto");
|
|
22
29
|
const protocol_1 = require("../../shared/protocol");
|
|
23
30
|
const datadir_1 = require("./datadir");
|
|
@@ -25,6 +32,66 @@ const datadir_1 = require("./datadir");
|
|
|
25
32
|
function generateToken() {
|
|
26
33
|
return (0, node_crypto_1.randomBytes)(32).toString('base64url');
|
|
27
34
|
}
|
|
35
|
+
/** Path of the optional persisted-token file (only used when persistence is on). */
|
|
36
|
+
function tokenPath(dir) {
|
|
37
|
+
return (0, node_path_1.join)(dir, 'token');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Read the persisted token (trimmed) if present and non-empty; else null.
|
|
41
|
+
* FAILS CLOSED if the file is group/other-readable — same trust boundary as the
|
|
42
|
+
* handshake, so a loose permission is a hard error rather than a silent reuse.
|
|
43
|
+
*/
|
|
44
|
+
function readPersistedToken(dir) {
|
|
45
|
+
const path = tokenPath(dir);
|
|
46
|
+
let raw;
|
|
47
|
+
try {
|
|
48
|
+
raw = (0, node_fs_1.readFileSync)(path, 'utf8');
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const mode = (0, node_fs_1.statSync)(path).mode & 0o777;
|
|
54
|
+
if ((mode & 0o077) !== 0) {
|
|
55
|
+
throw new Error(`persisted token ${path} is group/other-accessible (mode ${mode.toString(8)}); refusing to reuse it`);
|
|
56
|
+
}
|
|
57
|
+
const token = raw.trim();
|
|
58
|
+
return token.length > 0 ? token : null;
|
|
59
|
+
}
|
|
60
|
+
/** Atomically write the persisted token at 0600 and verify the mode (fail closed). */
|
|
61
|
+
function writePersistedToken(dir, token) {
|
|
62
|
+
const path = tokenPath(dir);
|
|
63
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
64
|
+
(0, node_fs_1.writeFileSync)(tmp, token, { mode: 0o600 });
|
|
65
|
+
(0, node_fs_1.chmodSync)(tmp, 0o600);
|
|
66
|
+
(0, node_fs_1.renameSync)(tmp, path);
|
|
67
|
+
(0, node_fs_1.chmodSync)(path, 0o600);
|
|
68
|
+
const mode = (0, node_fs_1.statSync)(path).mode & 0o777;
|
|
69
|
+
if ((mode & 0o077) !== 0) {
|
|
70
|
+
throw new Error(`persisted token ${path} is group/other-accessible (mode ${mode.toString(8)}); refusing to expose it`);
|
|
71
|
+
}
|
|
72
|
+
return path;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the token for this boot:
|
|
76
|
+
* 1. `CHROME_MCP_TOKEN` env — an explicit pin; used verbatim, never written to disk.
|
|
77
|
+
* 2. `persist` on — reuse the on-disk token (creating + saving one on first run),
|
|
78
|
+
* so the extension stays paired across restarts.
|
|
79
|
+
* 3. otherwise — a fresh per-boot token (the secure default; never persisted).
|
|
80
|
+
*/
|
|
81
|
+
function resolveToken(dir, opts) {
|
|
82
|
+
const fromEnv = process.env.CHROME_MCP_TOKEN?.trim();
|
|
83
|
+
if (fromEnv)
|
|
84
|
+
return fromEnv;
|
|
85
|
+
if (opts.persist) {
|
|
86
|
+
const existing = readPersistedToken(dir);
|
|
87
|
+
if (existing)
|
|
88
|
+
return existing;
|
|
89
|
+
const fresh = generateToken();
|
|
90
|
+
writePersistedToken(dir, fresh);
|
|
91
|
+
return fresh;
|
|
92
|
+
}
|
|
93
|
+
return generateToken();
|
|
94
|
+
}
|
|
28
95
|
/** Constant-time token compare via fixed-length SHA-256 digests. */
|
|
29
96
|
function tokensMatch(a, b) {
|
|
30
97
|
const ha = (0, node_crypto_1.createHash)('sha256').update(a, 'utf8').digest();
|
package/dist/src/cli.js
CHANGED
|
@@ -39,7 +39,7 @@ async function main() {
|
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
41
|
const dataDir = (0, datadir_1.ensureDataDir)(cfg.dataDir);
|
|
42
|
-
const token = (0, auth_1.
|
|
42
|
+
const token = (0, auth_1.resolveToken)(dataDir, { persist: cfg.persistToken });
|
|
43
43
|
const bridge = new server_1.BridgeServer({
|
|
44
44
|
token,
|
|
45
45
|
serverVersion: version(),
|
|
@@ -50,6 +50,12 @@ async function main() {
|
|
|
50
50
|
const port = await bridge.start();
|
|
51
51
|
const handshakePath = (0, auth_1.writeHandshake)(dataDir, { port, token });
|
|
52
52
|
(0, server_2.logErr)(`pairing handshake written to ${handshakePath} (mode 0600; token not logged)`);
|
|
53
|
+
if (process.env.CHROME_MCP_TOKEN) {
|
|
54
|
+
(0, server_2.logErr)('token: pinned from CHROME_MCP_TOKEN (stable; pair once, never again).');
|
|
55
|
+
}
|
|
56
|
+
else if (cfg.persistToken) {
|
|
57
|
+
(0, server_2.logErr)('token: persisted across restarts (--persist-token; pair once, never again).');
|
|
58
|
+
}
|
|
53
59
|
const cleanup = () => {
|
|
54
60
|
(0, auth_1.removeHandshake)(dataDir);
|
|
55
61
|
};
|
package/dist/src/config.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface CliConfig {
|
|
|
26
26
|
headless: boolean;
|
|
27
27
|
/** `--print-pairing`: write the handshake and print its path (never the token). */
|
|
28
28
|
printPairing: boolean;
|
|
29
|
+
/** `--persist-token`: reuse a stable on-disk token so the extension never re-pairs. */
|
|
30
|
+
persistToken: boolean;
|
|
29
31
|
showHelp: boolean;
|
|
30
32
|
showVersion: boolean;
|
|
31
33
|
logLevel: LogLevel;
|
package/dist/src/config.js
CHANGED
|
@@ -40,6 +40,7 @@ function parseArgs(argv) {
|
|
|
40
40
|
let prefer = 'extension';
|
|
41
41
|
let headless = false;
|
|
42
42
|
let printPairing = false;
|
|
43
|
+
let persistToken = false;
|
|
43
44
|
let showHelp = false;
|
|
44
45
|
let showVersion = false;
|
|
45
46
|
let logLevel = 'info';
|
|
@@ -99,6 +100,9 @@ function parseArgs(argv) {
|
|
|
99
100
|
case '--print-pairing':
|
|
100
101
|
printPairing = true;
|
|
101
102
|
break;
|
|
103
|
+
case '--persist-token':
|
|
104
|
+
persistToken = true;
|
|
105
|
+
break;
|
|
102
106
|
case '--log-level':
|
|
103
107
|
logLevel = requireLogLevel(argv[++i]);
|
|
104
108
|
break;
|
|
@@ -117,6 +121,7 @@ function parseArgs(argv) {
|
|
|
117
121
|
prefer,
|
|
118
122
|
headless,
|
|
119
123
|
printPairing,
|
|
124
|
+
persistToken,
|
|
120
125
|
showHelp,
|
|
121
126
|
showVersion,
|
|
122
127
|
logLevel,
|
|
@@ -164,6 +169,9 @@ Connection:
|
|
|
164
169
|
--port <n> WebSocket bridge port (default ${protocol_1.DEFAULT_WS_PORT}; 0 = ephemeral)
|
|
165
170
|
--data-dir <path> Override the data dir (default ~/.chrome-mcp)
|
|
166
171
|
--print-pairing Write the handshake and print its path, then exit
|
|
172
|
+
--persist-token Reuse a stable on-disk token across restarts so the
|
|
173
|
+
extension never has to re-pair (default: fresh per boot).
|
|
174
|
+
CHROME_MCP_TOKEN env, if set, pins the token explicitly.
|
|
167
175
|
|
|
168
176
|
Backend:
|
|
169
177
|
--no-cdp-fallback Do not launch/attach Chromium when no extension is paired
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* stealth init on the launch path only, and tab resolution. `tabId`s are stamped
|
|
13
13
|
* `cdp:<sessionId>:<n>` so a handle never mis-routes across a backend switch.
|
|
14
14
|
*/
|
|
15
|
-
import { type ActionOk, type BackendKind, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type KeyModifier, type NavResult, type ScreenshotResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
|
|
15
|
+
import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type KeyModifier, type NavResult, type ScreenshotResult, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
|
|
16
16
|
export interface CdpOptions {
|
|
17
17
|
mode: 'connect' | 'launch';
|
|
18
18
|
cdpEndpoint?: string;
|
|
@@ -69,12 +69,17 @@ export declare class CdpExecutor implements Executor {
|
|
|
69
69
|
tabId?: TabId;
|
|
70
70
|
button?: 'left' | 'right' | 'middle';
|
|
71
71
|
clickCount?: number;
|
|
72
|
+
trusted?: boolean;
|
|
72
73
|
}): Promise<ActionOk>;
|
|
73
74
|
type(t: Target, text: string, opts?: {
|
|
74
75
|
tabId?: TabId;
|
|
75
76
|
clear?: boolean;
|
|
76
77
|
pressEnter?: boolean;
|
|
77
78
|
keyEvents?: boolean;
|
|
79
|
+
trusted?: boolean;
|
|
80
|
+
}): Promise<ActionOk>;
|
|
81
|
+
selectOption(t: Target, values: string[], opts?: {
|
|
82
|
+
tabId?: TabId;
|
|
78
83
|
}): Promise<ActionOk>;
|
|
79
84
|
fill(t: Target, value: string, opts?: {
|
|
80
85
|
tabId?: TabId;
|
|
@@ -106,6 +111,24 @@ export declare class CdpExecutor implements Executor {
|
|
|
106
111
|
}): Promise<{
|
|
107
112
|
html: string;
|
|
108
113
|
}>;
|
|
114
|
+
snapshot(opts?: {
|
|
115
|
+
tabId?: TabId;
|
|
116
|
+
interactiveOnly?: boolean;
|
|
117
|
+
max?: number;
|
|
118
|
+
}): Promise<SnapshotResult>;
|
|
119
|
+
getCookies(opts?: {
|
|
120
|
+
tabId?: TabId;
|
|
121
|
+
url?: string;
|
|
122
|
+
}): Promise<{
|
|
123
|
+
cookies: CookieItem[];
|
|
124
|
+
}>;
|
|
125
|
+
storage(args: {
|
|
126
|
+
op: StorageOp;
|
|
127
|
+
key?: string;
|
|
128
|
+
value?: string;
|
|
129
|
+
session?: boolean;
|
|
130
|
+
tabId?: TabId;
|
|
131
|
+
}): Promise<StorageResult>;
|
|
109
132
|
screenshot(opts?: {
|
|
110
133
|
tabId?: TabId;
|
|
111
134
|
fullPage?: boolean;
|
|
@@ -21,10 +21,15 @@ const node_path_1 = require("node:path");
|
|
|
21
21
|
const node_crypto_1 = require("node:crypto");
|
|
22
22
|
const playwright_1 = require("playwright");
|
|
23
23
|
const download_1 = require("../../shared/download");
|
|
24
|
+
const snapshot_1 = require("../../shared/snapshot");
|
|
24
25
|
const types_1 = require("./types");
|
|
25
26
|
const SINGLETON_FILES = ['SingletonLock', 'SingletonSocket', 'SingletonCookie'];
|
|
26
27
|
const STEALTH = `Object.defineProperty(navigator,'webdriver',{get:()=>undefined});`;
|
|
27
28
|
const NON_CONTENT = /^(file|data|devtools|chrome|about):/i;
|
|
29
|
+
/** Minimal CSS attribute-value escape for ref selectors (refs are `e\d+`, but be safe). */
|
|
30
|
+
function cssEscape(s) {
|
|
31
|
+
return s.replace(/["\\]/g, '\\$&');
|
|
32
|
+
}
|
|
28
33
|
// --- lock recovery helpers (ported) ---------------------------------------
|
|
29
34
|
function inspectProfileLock(profileDir) {
|
|
30
35
|
try {
|
|
@@ -239,7 +244,11 @@ class CdpExecutor {
|
|
|
239
244
|
locator(page, t) {
|
|
240
245
|
if ('selector' in t && t.selector !== undefined)
|
|
241
246
|
return page.locator(t.selector).first();
|
|
242
|
-
|
|
247
|
+
if ('ref' in t && t.ref !== undefined) {
|
|
248
|
+
// Refs are minted by snapshot() as data-mcp-ref attributes on the page.
|
|
249
|
+
return page.locator(`[data-mcp-ref="${cssEscape(t.ref)}"]`).first();
|
|
250
|
+
}
|
|
251
|
+
throw new types_1.ExecutorError('SELECTOR_NOT_FOUND', 'provide a selector or a ref from snapshot()');
|
|
243
252
|
}
|
|
244
253
|
// -- tabs ---------------------------------------------------------------
|
|
245
254
|
async tabsList() {
|
|
@@ -298,6 +307,7 @@ class CdpExecutor {
|
|
|
298
307
|
ok = { ok: true };
|
|
299
308
|
async click(t, opts) {
|
|
300
309
|
const p = await this.resolveTab(opts?.tabId);
|
|
310
|
+
// Playwright already drives real (trusted) input, so `trusted` is a no-op here.
|
|
301
311
|
await this.locator(p, t).click({ button: opts?.button, clickCount: opts?.clickCount });
|
|
302
312
|
return this.ok;
|
|
303
313
|
}
|
|
@@ -314,6 +324,18 @@ class CdpExecutor {
|
|
|
314
324
|
await loc.press('Enter');
|
|
315
325
|
return this.ok;
|
|
316
326
|
}
|
|
327
|
+
async selectOption(t, values, opts) {
|
|
328
|
+
const p = await this.resolveTab(opts?.tabId);
|
|
329
|
+
// Try by value, then fall back to visible label.
|
|
330
|
+
const loc = this.locator(p, t);
|
|
331
|
+
try {
|
|
332
|
+
await loc.selectOption(values);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
await loc.selectOption(values.map((label) => ({ label })));
|
|
336
|
+
}
|
|
337
|
+
return this.ok;
|
|
338
|
+
}
|
|
317
339
|
async fill(t, value, opts) {
|
|
318
340
|
const p = await this.resolveTab(opts?.tabId);
|
|
319
341
|
await this.locator(p, t).fill(value);
|
|
@@ -354,6 +376,55 @@ class CdpExecutor {
|
|
|
354
376
|
const html = opts?.outer ? await loc.evaluate((el) => el.outerHTML) : await loc.innerHTML();
|
|
355
377
|
return { html };
|
|
356
378
|
}
|
|
379
|
+
async snapshot(opts) {
|
|
380
|
+
const p = await this.resolveTab(opts?.tabId);
|
|
381
|
+
// Inject collectSnapshot's source and run it in the page (it can't close over module scope).
|
|
382
|
+
const raw = await p.evaluate(([fnSrc, interactiveOnly, max]) => {
|
|
383
|
+
// eslint-disable-next-line no-eval
|
|
384
|
+
const fn = (0, eval)(`(${fnSrc})`);
|
|
385
|
+
return fn(interactiveOnly, max);
|
|
386
|
+
}, [snapshot_1.collectSnapshot.toString(), opts?.interactiveOnly ?? true, opts?.max ?? 200]);
|
|
387
|
+
return raw;
|
|
388
|
+
}
|
|
389
|
+
async getCookies(opts) {
|
|
390
|
+
const p = await this.resolveTab(opts?.tabId);
|
|
391
|
+
const url = opts?.url ?? p.url();
|
|
392
|
+
const ctx = await this.getContext();
|
|
393
|
+
const raw = await ctx.cookies(url);
|
|
394
|
+
return {
|
|
395
|
+
cookies: raw.map((c) => ({
|
|
396
|
+
name: c.name, value: c.value, domain: c.domain, path: c.path,
|
|
397
|
+
secure: c.secure, httpOnly: c.httpOnly, expires: c.expires >= 0 ? c.expires : undefined,
|
|
398
|
+
})),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
async storage(args) {
|
|
402
|
+
const p = await this.resolveTab(args.tabId);
|
|
403
|
+
return p.evaluate((a) => {
|
|
404
|
+
const store = a.session ? window.sessionStorage : window.localStorage;
|
|
405
|
+
if (a.op === 'set') {
|
|
406
|
+
store.setItem(String(a.key), String(a.value ?? ''));
|
|
407
|
+
return { ok: true };
|
|
408
|
+
}
|
|
409
|
+
if (a.op === 'remove') {
|
|
410
|
+
store.removeItem(String(a.key));
|
|
411
|
+
return { ok: true };
|
|
412
|
+
}
|
|
413
|
+
if (a.op === 'clear') {
|
|
414
|
+
store.clear();
|
|
415
|
+
return { ok: true };
|
|
416
|
+
}
|
|
417
|
+
if (a.key)
|
|
418
|
+
return { ok: true, value: store.getItem(a.key) };
|
|
419
|
+
const entries = {};
|
|
420
|
+
for (let i = 0; i < store.length; i++) {
|
|
421
|
+
const k = store.key(i);
|
|
422
|
+
if (k)
|
|
423
|
+
entries[k] = store.getItem(k) ?? '';
|
|
424
|
+
}
|
|
425
|
+
return { ok: true, entries };
|
|
426
|
+
}, args);
|
|
427
|
+
}
|
|
357
428
|
async screenshot(opts) {
|
|
358
429
|
const p = await this.resolveTab(opts?.tabId);
|
|
359
430
|
const buf = opts?.target
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* method-specific arguments travel in `params`. Results are trusted shapes
|
|
8
8
|
* produced by the extension router (validated there).
|
|
9
9
|
*/
|
|
10
|
-
import { type ActionOk, type BackendKind, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type KeyModifier, type MouseButton, type NavResult, type ScreenshotResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
|
|
10
|
+
import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type KeyModifier, type MouseButton, type NavResult, type ScreenshotResult, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
|
|
11
11
|
import type { BridgeServer } from '../bridge/server';
|
|
12
12
|
export declare class ExtensionExecutor implements Executor {
|
|
13
13
|
private readonly bridge;
|
|
@@ -40,12 +40,17 @@ export declare class ExtensionExecutor implements Executor {
|
|
|
40
40
|
tabId?: TabId;
|
|
41
41
|
button?: MouseButton;
|
|
42
42
|
clickCount?: number;
|
|
43
|
+
trusted?: boolean;
|
|
43
44
|
}): Promise<ActionOk>;
|
|
44
45
|
type(t: Target, text: string, opts?: {
|
|
45
46
|
tabId?: TabId;
|
|
46
47
|
clear?: boolean;
|
|
47
48
|
pressEnter?: boolean;
|
|
48
49
|
keyEvents?: boolean;
|
|
50
|
+
trusted?: boolean;
|
|
51
|
+
}): Promise<ActionOk>;
|
|
52
|
+
selectOption(t: Target, values: string[], opts?: {
|
|
53
|
+
tabId?: TabId;
|
|
49
54
|
}): Promise<ActionOk>;
|
|
50
55
|
fill(t: Target, value: string, opts?: {
|
|
51
56
|
tabId?: TabId;
|
|
@@ -77,6 +82,24 @@ export declare class ExtensionExecutor implements Executor {
|
|
|
77
82
|
}): Promise<{
|
|
78
83
|
html: string;
|
|
79
84
|
}>;
|
|
85
|
+
snapshot(opts?: {
|
|
86
|
+
tabId?: TabId;
|
|
87
|
+
interactiveOnly?: boolean;
|
|
88
|
+
max?: number;
|
|
89
|
+
}): Promise<SnapshotResult>;
|
|
90
|
+
getCookies(opts?: {
|
|
91
|
+
tabId?: TabId;
|
|
92
|
+
url?: string;
|
|
93
|
+
}): Promise<{
|
|
94
|
+
cookies: CookieItem[];
|
|
95
|
+
}>;
|
|
96
|
+
storage(args: {
|
|
97
|
+
op: StorageOp;
|
|
98
|
+
key?: string;
|
|
99
|
+
value?: string;
|
|
100
|
+
session?: boolean;
|
|
101
|
+
tabId?: TabId;
|
|
102
|
+
}): Promise<StorageResult>;
|
|
80
103
|
screenshot(opts?: {
|
|
81
104
|
tabId?: TabId;
|
|
82
105
|
fullPage?: boolean;
|
|
@@ -81,10 +81,13 @@ class ExtensionExecutor {
|
|
|
81
81
|
}
|
|
82
82
|
// -- interaction --------------------------------------------------------
|
|
83
83
|
async click(t, opts) {
|
|
84
|
-
return (await this.send('click', { ...targetParams(t), button: opts?.button, clickCount: opts?.clickCount }, { tabId: opts?.tabId }));
|
|
84
|
+
return (await this.send('click', { ...targetParams(t), button: opts?.button, clickCount: opts?.clickCount, trusted: opts?.trusted }, { tabId: opts?.tabId }));
|
|
85
85
|
}
|
|
86
86
|
async type(t, text, opts) {
|
|
87
|
-
return (await this.send('type', { ...targetParams(t), text, clear: opts?.clear, pressEnter: opts?.pressEnter, keyEvents: opts?.keyEvents }, { tabId: opts?.tabId }));
|
|
87
|
+
return (await this.send('type', { ...targetParams(t), text, clear: opts?.clear, pressEnter: opts?.pressEnter, keyEvents: opts?.keyEvents, trusted: opts?.trusted }, { tabId: opts?.tabId }));
|
|
88
|
+
}
|
|
89
|
+
async selectOption(t, values, opts) {
|
|
90
|
+
return (await this.send('select_option', { ...targetParams(t), values }, { tabId: opts?.tabId }));
|
|
88
91
|
}
|
|
89
92
|
async fill(t, value, opts) {
|
|
90
93
|
// No dedicated wire method: a cleared insertText is the fill primitive.
|
|
@@ -106,6 +109,15 @@ class ExtensionExecutor {
|
|
|
106
109
|
async getHtml(t, opts) {
|
|
107
110
|
return (await this.send('get_html', { ...targetParams(t), outer: opts?.outer }, { tabId: opts?.tabId }));
|
|
108
111
|
}
|
|
112
|
+
async snapshot(opts) {
|
|
113
|
+
return (await this.send('snapshot', { interactiveOnly: opts?.interactiveOnly, max: opts?.max }, { tabId: opts?.tabId }));
|
|
114
|
+
}
|
|
115
|
+
async getCookies(opts) {
|
|
116
|
+
return (await this.send('get_cookies', { url: opts?.url }, { tabId: opts?.tabId }));
|
|
117
|
+
}
|
|
118
|
+
async storage(args) {
|
|
119
|
+
return (await this.send('storage', { op: args.op, key: args.key, value: args.value, session: args.session }, { tabId: args.tabId }));
|
|
120
|
+
}
|
|
109
121
|
async screenshot(opts) {
|
|
110
122
|
return (await this.send('screenshot', { fullPage: opts?.fullPage, ...targetParams(opts?.target) }, { tabId: opts?.tabId }));
|
|
111
123
|
}
|