@mehmoodqureshi/chrome-mcp 0.6.1 → 0.6.4
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 +80 -19
- package/dist/shared/policy.js +25 -4
- package/dist/src/bridge/auth.d.ts +1 -0
- package/dist/src/bridge/auth.js +21 -12
- package/dist/src/bridge/evict.d.ts +32 -0
- package/dist/src/bridge/evict.js +135 -0
- package/dist/src/bridge/server.d.ts +3 -0
- package/dist/src/bridge/server.js +20 -8
- package/dist/src/cli.js +1 -0
- package/dist/src/config.js +16 -7
- package/dist/src/executor/cdp-executor.js +7 -1
- package/dist/src/mcp/helpers.d.ts +6 -0
- package/dist/src/mcp/helpers.js +40 -11
- package/dist/src/mcp/tools.js +3 -1
- package/extension-dist/background.js +15 -10
- package/package.json +2 -2
- package/scripts/postinstall.js +7 -1
package/README.md
CHANGED
|
@@ -4,17 +4,18 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@mehmoodqureshi/chrome-mcp)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
Drive a **real Chrome browser** from Claude (or any MCP host).
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
logins, real cookies — via `chrome.scripting`/`chrome.tabs`. The CLI runs a
|
|
12
|
-
localhost WebSocket server; the extension dials in.
|
|
13
|
-
- **CDP fallback:** when no extension is paired, the CLI launches/attaches a
|
|
14
|
-
Playwright-driven Chromium for scripted/headless use.
|
|
7
|
+
Drive a **real Chrome browser** from Claude (or any MCP host). An MV3 extension
|
|
8
|
+
drives your real Chrome — real logins, real cookies — via
|
|
9
|
+
`chrome.scripting`/`chrome.tabs`. The CLI runs a localhost WebSocket server; the
|
|
10
|
+
extension dials in.
|
|
15
11
|
|
|
16
12
|
Distributed as an `npx` CLI (the MCP server) plus a load-unpacked extension.
|
|
17
13
|
|
|
14
|
+
> **This build is extension-only.** It never launches or attaches a Chromium of
|
|
15
|
+
> its own, so **the extension is required, not optional** — without it, no tool
|
|
16
|
+
> can run. The CDP flags (`--cdp-fallback`, `--no-cdp-fallback`, `--cdp-endpoint`,
|
|
17
|
+
> `--prefer`) are still accepted for back-compat but are **ignored**.
|
|
18
|
+
|
|
18
19
|
> **Full design:** [`docs/BLUEPRINT.md`](docs/BLUEPRINT.md) — architecture, wire
|
|
19
20
|
> protocol, the complete tool surface, the extension manifest, the security
|
|
20
21
|
> model, and the phased build plan.
|
|
@@ -44,10 +45,8 @@ exactly what you need with `--allow-domain <glob>` (repeatable), `--enable-mutat
|
|
|
44
45
|
> with `--uploads-dir <path>` to restrict uploads to files inside that directory
|
|
45
46
|
> (`..` traversal is blocked) — strongly recommended for unattended use.
|
|
46
47
|
|
|
47
|
-
**
|
|
48
|
-
|
|
49
|
-
`--persist-token` so the pairing token survives restarts — **pair once, never
|
|
50
|
-
again**:
|
|
48
|
+
**Pair once, never again.** Add `--persist-token` so the pairing token survives
|
|
49
|
+
restarts:
|
|
51
50
|
|
|
52
51
|
```jsonc
|
|
53
52
|
{
|
|
@@ -56,7 +55,7 @@ again**:
|
|
|
56
55
|
"command": "npx",
|
|
57
56
|
"args": ["-y", "@mehmoodqureshi/chrome-mcp",
|
|
58
57
|
"--allow-domain", "example.com", "--enable-mutations",
|
|
59
|
-
"--
|
|
58
|
+
"--persist-token"]
|
|
60
59
|
}
|
|
61
60
|
}
|
|
62
61
|
}
|
|
@@ -68,14 +67,72 @@ token is stored 0600 at `~/.chrome-mcp/token` and reused; the extension's
|
|
|
68
67
|
keepalive auto-reconnects with no manual step. `CHROME_MCP_TOKEN` pins the token
|
|
69
68
|
explicitly (and is never written to disk).
|
|
70
69
|
|
|
71
|
-
**2. Load the extension**
|
|
72
|
-
|
|
73
|
-
`extension-dist
|
|
70
|
+
**2. Load the extension** — **required**; the server can drive nothing without it.
|
|
71
|
+
|
|
72
|
+
`extension-dist/` ships prebuilt inside the npm package, so there is nothing to
|
|
73
|
+
compile. Install globally to get a stable path to it:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm install -g @mehmoodqureshi/chrome-mcp
|
|
77
|
+
npm root -g # → <root>; the extension is at <root>/@mehmoodqureshi/chrome-mcp/extension-dist
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Then `chrome://extensions` → enable **Developer mode** → **Load unpacked** →
|
|
81
|
+
select that `extension-dist/` directory. (Working from a git clone instead? Run
|
|
82
|
+
`npm install && npm run build:ext` first — `extension-dist/` is gitignored.)
|
|
74
83
|
|
|
75
84
|
**3. Pair it:** run `npx chrome-mcp --print-pairing` to write the handshake and
|
|
76
85
|
print its path, open the extension's **Options** page, and paste the `port` +
|
|
77
|
-
`token` from `~/.chrome-mcp/handshake.json`.
|
|
78
|
-
|
|
86
|
+
`token` from `~/.chrome-mcp/handshake.json`.
|
|
87
|
+
|
|
88
|
+
### Running more than one session
|
|
89
|
+
|
|
90
|
+
The extension dials exactly **one** bridge port, so only one chrome-mcp can drive
|
|
91
|
+
your Chrome at a time — but every MCP host session (each Claude tab/window)
|
|
92
|
+
spawns its own server. With a pinned `--port`, the newest session **takes the
|
|
93
|
+
port over**: it reads the owning pid from `handshake.json`, confirms that process
|
|
94
|
+
really is a chrome-mcp, and stops it. Newest tab wins; the older session's browser
|
|
95
|
+
tools go quiet until it reconnects. Nothing that isn't a verified chrome-mcp is
|
|
96
|
+
ever touched — a port held by some other program is reported, never killed.
|
|
97
|
+
|
|
98
|
+
Two servers can only run side by side if each has its own port **and** its own
|
|
99
|
+
paired extension — i.e. a separate Chrome profile running its own copy of the
|
|
100
|
+
extension, pointed at the other port (`--port 9223`). A single Chrome pairs to one
|
|
101
|
+
server at a time, so a second server with no extension of its own can drive
|
|
102
|
+
nothing.
|
|
103
|
+
|
|
104
|
+
One server can, however, serve **several browsers at once**: connections are
|
|
105
|
+
routed by profile key (`--profile <name>`, matching the profile set in the
|
|
106
|
+
extension's Options), so each paired Chrome gets its own routing slot.
|
|
107
|
+
|
|
108
|
+
Without `--port`, each server binds an ephemeral port (no conflict ever), but the
|
|
109
|
+
port changes every boot — so you'd re-pair the extension each time. Pin `--port`
|
|
110
|
+
plus `--persist-token` for a pair-once setup.
|
|
111
|
+
|
|
112
|
+
### Windows
|
|
113
|
+
|
|
114
|
+
WSL2 is **not** required — native Windows works. One config change is, though:
|
|
115
|
+
on Windows `npx` is `npx.cmd`, a batch shim, and MCP hosts spawn the server
|
|
116
|
+
without a shell, which cannot execute a `.cmd`. So `"command": "npx"` fails to
|
|
117
|
+
start. Wrap it in `cmd /c`:
|
|
118
|
+
|
|
119
|
+
```jsonc
|
|
120
|
+
{
|
|
121
|
+
"mcpServers": {
|
|
122
|
+
"chrome-mcp": {
|
|
123
|
+
"command": "cmd",
|
|
124
|
+
"args": ["/c", "npx", "-y", "@mehmoodqureshi/chrome-mcp",
|
|
125
|
+
"--allow-domain", "example.com", "--enable-mutations",
|
|
126
|
+
"--persist-token"]
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Or from Claude Code: `claude mcp add chrome-mcp -- cmd /c npx -y @mehmoodqureshi/chrome-mcp --allow-domain example.com`
|
|
133
|
+
|
|
134
|
+
Everything else is the same — load `extension-dist/` from `npm root -g` and pair
|
|
135
|
+
as above.
|
|
79
136
|
|
|
80
137
|
The tools cover tabs, navigation, interaction (`click`/`type`/`press`/`hover`/
|
|
81
138
|
`scroll`/`select_option`), reads (`get_text`/`get_html`/`screenshot`/`eval`/`wait_for`),
|
|
@@ -183,7 +240,11 @@ chrome-mcp --unsafe-all-domains # loud footgun
|
|
|
183
240
|
```
|
|
184
241
|
|
|
185
242
|
The per-boot 256-bit token in `~/.chrome-mcp/handshake.json` (mode 0600) is the
|
|
186
|
-
only trust boundary; it is never written to stdout/stderr.
|
|
243
|
+
only trust boundary; it is never written to stdout/stderr. On POSIX the mode is
|
|
244
|
+
re-verified after every write and the server **fails closed** if the file ends up
|
|
245
|
+
group/other-accessible. Windows has no such bits — `chmod` there only toggles the
|
|
246
|
+
read-only attribute — so the check is skipped and the token's confidentiality
|
|
247
|
+
rests on the per-user ACL of `%USERPROFILE%\.chrome-mcp`.
|
|
187
248
|
|
|
188
249
|
## Develop
|
|
189
250
|
|
package/dist/shared/policy.js
CHANGED
|
@@ -80,17 +80,38 @@ function hostOf(url) {
|
|
|
80
80
|
function isAboutBlank(url) {
|
|
81
81
|
return url === 'about:blank' || url === '' || url.startsWith('about:');
|
|
82
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Reduce an allowlist entry to the bare host it constrains. Users routinely paste
|
|
85
|
+
* a full URL ("https://example.com/app") or a "host:port/path" instead of a bare
|
|
86
|
+
* host; those forms would never equal a hostname and so silently match nothing.
|
|
87
|
+
* We strip the scheme, userinfo, port, and path/query/fragment, preserving a
|
|
88
|
+
* leading "*." wildcard and the two catch-all forms. Returns '' for a pattern
|
|
89
|
+
* that carries no host (which then matches nothing).
|
|
90
|
+
*/
|
|
91
|
+
function normalizeDomainPattern(pattern) {
|
|
92
|
+
let p = pattern.trim().toLowerCase();
|
|
93
|
+
if (p === '*' || p === '*://*/*')
|
|
94
|
+
return '*';
|
|
95
|
+
// Strip a leading scheme ("https://", "http://", any "scheme://").
|
|
96
|
+
p = p.replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
|
|
97
|
+
// Drop everything from the first path/query/fragment separator onward.
|
|
98
|
+
p = p.replace(/[/?#].*$/, '');
|
|
99
|
+
// Strip userinfo ("user:pass@") then a trailing port (":8080").
|
|
100
|
+
p = p.replace(/^[^@]*@/, '').replace(/:\d+$/, '');
|
|
101
|
+
return p;
|
|
102
|
+
}
|
|
83
103
|
/** Convert a single domain glob to a predicate. '*' matches everything;
|
|
84
|
-
* '*.example.com' matches example.com and any subdomain; otherwise exact host.
|
|
104
|
+
* '*.example.com' matches example.com and any subdomain; otherwise exact host.
|
|
105
|
+
* URL/port/path forms are normalized to their bare host first. */
|
|
85
106
|
function globMatches(host, pattern) {
|
|
86
|
-
const p = pattern
|
|
87
|
-
if (p === '*'
|
|
107
|
+
const p = normalizeDomainPattern(pattern);
|
|
108
|
+
if (p === '*')
|
|
88
109
|
return true;
|
|
89
110
|
if (p.startsWith('*.')) {
|
|
90
111
|
const base = p.slice(2);
|
|
91
112
|
return host === base || host.endsWith('.' + base);
|
|
92
113
|
}
|
|
93
|
-
return host === p;
|
|
114
|
+
return p !== '' && host === p;
|
|
94
115
|
}
|
|
95
116
|
function isDomainAllowed(url, policy) {
|
|
96
117
|
const host = hostOf(url);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* that want a stable token so the extension never has to re-pair.
|
|
8
8
|
* - Written atomically (tmp + rename) to `handshake.json` at mode 0600; the
|
|
9
9
|
* mode is re-verified after write and we FAIL CLOSED if it can't be set.
|
|
10
|
+
* POSIX only — Windows has no group/other bits to check (see assertPrivate).
|
|
10
11
|
* - Compared by hashing both sides to SHA-256 and `timingSafeEqual`-ing the
|
|
11
12
|
* digests — no length precondition, no length leak.
|
|
12
13
|
* - The token is NEVER written to stdout/stderr or any log (a test asserts it).
|
package/dist/src/bridge/auth.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* that want a stable token so the extension never has to re-pair.
|
|
9
9
|
* - Written atomically (tmp + rename) to `handshake.json` at mode 0600; the
|
|
10
10
|
* mode is re-verified after write and we FAIL CLOSED if it can't be set.
|
|
11
|
+
* POSIX only — Windows has no group/other bits to check (see assertPrivate).
|
|
11
12
|
* - Compared by hashing both sides to SHA-256 and `timingSafeEqual`-ing the
|
|
12
13
|
* digests — no length precondition, no length leak.
|
|
13
14
|
* - The token is NEVER written to stdout/stderr or any log (a test asserts it).
|
|
@@ -32,6 +33,23 @@ const datadir_1 = require("./datadir");
|
|
|
32
33
|
function generateToken() {
|
|
33
34
|
return (0, node_crypto_1.randomBytes)(32).toString('base64url');
|
|
34
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Fail closed if `path` is group/other-accessible.
|
|
38
|
+
*
|
|
39
|
+
* No-op on Windows: NTFS ACLs don't map onto the POSIX mode bits, `chmodSync`
|
|
40
|
+
* there only toggles the read-only attribute, and `statSync` reports a synthetic
|
|
41
|
+
* 0o666 for any writable file. Enforcing the check would therefore throw on
|
|
42
|
+
* every well-formed file. The token's confidentiality on Windows rests on the
|
|
43
|
+
* per-user ACL of the profile directory holding it.
|
|
44
|
+
*/
|
|
45
|
+
function assertPrivate(path, what) {
|
|
46
|
+
if (process.platform === 'win32')
|
|
47
|
+
return;
|
|
48
|
+
const mode = (0, node_fs_1.statSync)(path).mode & 0o777;
|
|
49
|
+
if ((mode & 0o077) !== 0) {
|
|
50
|
+
throw new Error(`${what} ${path} is group/other-accessible (mode ${mode.toString(8)}); refusing to expose the token`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
35
53
|
/** Path of the optional persisted-token file (only used when persistence is on). */
|
|
36
54
|
function tokenPath(dir) {
|
|
37
55
|
return (0, node_path_1.join)(dir, 'token');
|
|
@@ -50,10 +68,7 @@ function readPersistedToken(dir) {
|
|
|
50
68
|
catch {
|
|
51
69
|
return null;
|
|
52
70
|
}
|
|
53
|
-
|
|
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
|
-
}
|
|
71
|
+
assertPrivate(path, 'persisted token');
|
|
57
72
|
const token = raw.trim();
|
|
58
73
|
return token.length > 0 ? token : null;
|
|
59
74
|
}
|
|
@@ -65,10 +80,7 @@ function writePersistedToken(dir, token) {
|
|
|
65
80
|
(0, node_fs_1.chmodSync)(tmp, 0o600);
|
|
66
81
|
(0, node_fs_1.renameSync)(tmp, path);
|
|
67
82
|
(0, node_fs_1.chmodSync)(path, 0o600);
|
|
68
|
-
|
|
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
|
-
}
|
|
83
|
+
assertPrivate(path, 'persisted token');
|
|
72
84
|
return path;
|
|
73
85
|
}
|
|
74
86
|
/**
|
|
@@ -118,10 +130,7 @@ function writeHandshake(dir, fields) {
|
|
|
118
130
|
(0, node_fs_1.chmodSync)(tmp, 0o600);
|
|
119
131
|
(0, node_fs_1.renameSync)(tmp, path);
|
|
120
132
|
(0, node_fs_1.chmodSync)(path, 0o600);
|
|
121
|
-
|
|
122
|
-
if ((mode & 0o077) !== 0) {
|
|
123
|
-
throw new Error(`handshake file ${path} is group/other-accessible (mode ${mode.toString(8)}); refusing to expose the token`);
|
|
124
|
-
}
|
|
133
|
+
assertPrivate(path, 'handshake file');
|
|
125
134
|
return path;
|
|
126
135
|
}
|
|
127
136
|
function readHandshake(dir) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/bridge/evict.ts — take a pinned port back from a stale chrome-mcp.
|
|
3
|
+
*
|
|
4
|
+
* The extension dials exactly ONE bridge port, so only one server can own a
|
|
5
|
+
* given Chrome at a time. But every MCP host session spawns its own chrome-mcp
|
|
6
|
+
* child, so a second session (another Claude tab/window) racing for a pinned
|
|
7
|
+
* `--port` would otherwise just fail with EADDRINUSE and force the user to kill
|
|
8
|
+
* the old process by hand. Newest-session-wins: we do that kill for them.
|
|
9
|
+
*
|
|
10
|
+
* The safety bar is high, because a pid can be recycled onto an unrelated
|
|
11
|
+
* process. We evict ONLY when every check agrees:
|
|
12
|
+
* - the handshake names that port (so it describes *this* listener), and
|
|
13
|
+
* - the pid is not us, and
|
|
14
|
+
* - the pid is alive, and
|
|
15
|
+
* - its command line mentions chrome-mcp.
|
|
16
|
+
* If any check fails we leave the process alone and let the caller surface the
|
|
17
|
+
* plain-English port-busy error instead. Refusing to evict is always safe; a
|
|
18
|
+
* wrong kill is not.
|
|
19
|
+
*/
|
|
20
|
+
/** Does this command line belong to a chrome-mcp server? */
|
|
21
|
+
export declare function looksLikeChromeMcp(cmdline: string): boolean;
|
|
22
|
+
export interface EvictDeps {
|
|
23
|
+
/** Injectable for tests; defaults to the real probes. */
|
|
24
|
+
isAlive?: (pid: number) => boolean;
|
|
25
|
+
commandLine?: (pid: number) => string | null;
|
|
26
|
+
kill?: (pid: number, signal: NodeJS.Signals) => void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Try to free `port` by terminating the chrome-mcp that owns it.
|
|
30
|
+
* Returns true only if an owner was found, killed, and confirmed gone.
|
|
31
|
+
*/
|
|
32
|
+
export declare function evictPortOwner(dataDir: string, port: number, log: (message: string) => void, deps?: EvictDeps): Promise<boolean>;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/bridge/evict.ts — take a pinned port back from a stale chrome-mcp.
|
|
4
|
+
*
|
|
5
|
+
* The extension dials exactly ONE bridge port, so only one server can own a
|
|
6
|
+
* given Chrome at a time. But every MCP host session spawns its own chrome-mcp
|
|
7
|
+
* child, so a second session (another Claude tab/window) racing for a pinned
|
|
8
|
+
* `--port` would otherwise just fail with EADDRINUSE and force the user to kill
|
|
9
|
+
* the old process by hand. Newest-session-wins: we do that kill for them.
|
|
10
|
+
*
|
|
11
|
+
* The safety bar is high, because a pid can be recycled onto an unrelated
|
|
12
|
+
* process. We evict ONLY when every check agrees:
|
|
13
|
+
* - the handshake names that port (so it describes *this* listener), and
|
|
14
|
+
* - the pid is not us, and
|
|
15
|
+
* - the pid is alive, and
|
|
16
|
+
* - its command line mentions chrome-mcp.
|
|
17
|
+
* If any check fails we leave the process alone and let the caller surface the
|
|
18
|
+
* plain-English port-busy error instead. Refusing to evict is always safe; a
|
|
19
|
+
* wrong kill is not.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.looksLikeChromeMcp = looksLikeChromeMcp;
|
|
23
|
+
exports.evictPortOwner = evictPortOwner;
|
|
24
|
+
const node_child_process_1 = require("node:child_process");
|
|
25
|
+
const auth_1 = require("./auth");
|
|
26
|
+
/** How long to let a SIGTERM'd owner exit before escalating to SIGKILL. */
|
|
27
|
+
const EXIT_WAIT_MS = 3_000;
|
|
28
|
+
const POLL_MS = 100;
|
|
29
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
30
|
+
/** Whether `pid` exists. Signal 0 checks liveness without delivering anything. */
|
|
31
|
+
function isAlive(pid) {
|
|
32
|
+
try {
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
// EPERM means it exists but belongs to another user — alive, just not ours.
|
|
38
|
+
return err?.code === 'EPERM';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Full command line of `pid`, or null if it can't be read.
|
|
43
|
+
*
|
|
44
|
+
* Windows needs PowerShell/CIM: `tasklist` reports only the image name
|
|
45
|
+
* ("node.exe"), which can't distinguish chrome-mcp from any other Node process
|
|
46
|
+
* — far too weak a signal to kill on.
|
|
47
|
+
*/
|
|
48
|
+
function commandLine(pid) {
|
|
49
|
+
const [cmd, args] = process.platform === 'win32'
|
|
50
|
+
? [
|
|
51
|
+
'powershell.exe',
|
|
52
|
+
[
|
|
53
|
+
'-NoProfile',
|
|
54
|
+
'-Command',
|
|
55
|
+
`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`,
|
|
56
|
+
],
|
|
57
|
+
]
|
|
58
|
+
: ['ps', ['-p', String(pid), '-o', 'command=']];
|
|
59
|
+
try {
|
|
60
|
+
const out = (0, node_child_process_1.execFileSync)(cmd, args, {
|
|
61
|
+
encoding: 'utf8',
|
|
62
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
63
|
+
timeout: 5_000,
|
|
64
|
+
});
|
|
65
|
+
return out.trim() || null;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Does this command line belong to a chrome-mcp server? */
|
|
72
|
+
function looksLikeChromeMcp(cmdline) {
|
|
73
|
+
return /chrome-mcp/i.test(cmdline);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Try to free `port` by terminating the chrome-mcp that owns it.
|
|
77
|
+
* Returns true only if an owner was found, killed, and confirmed gone.
|
|
78
|
+
*/
|
|
79
|
+
async function evictPortOwner(dataDir, port, log, deps = {}) {
|
|
80
|
+
const alive = deps.isAlive ?? isAlive;
|
|
81
|
+
const cmdOf = deps.commandLine ?? commandLine;
|
|
82
|
+
const kill = deps.kill ?? ((pid, sig) => process.kill(pid, sig));
|
|
83
|
+
let pid;
|
|
84
|
+
let hsPort;
|
|
85
|
+
try {
|
|
86
|
+
const hs = (0, auth_1.readHandshake)(dataDir);
|
|
87
|
+
pid = Number(hs?.pid);
|
|
88
|
+
hsPort = Number(hs?.port);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false; // no handshake (clean shutdown) or unreadable — nothing to evict
|
|
92
|
+
}
|
|
93
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
94
|
+
return false;
|
|
95
|
+
// The handshake describes a different listener; it says nothing about ours.
|
|
96
|
+
if (hsPort !== port)
|
|
97
|
+
return false;
|
|
98
|
+
if (pid === process.pid)
|
|
99
|
+
return false;
|
|
100
|
+
if (!alive(pid))
|
|
101
|
+
return false;
|
|
102
|
+
const cmdline = cmdOf(pid);
|
|
103
|
+
if (!cmdline || !looksLikeChromeMcp(cmdline)) {
|
|
104
|
+
log(`port ${port} is held by pid ${pid}, which does not look like chrome-mcp — ` +
|
|
105
|
+
`leaving it alone. Stop it yourself, or use a different --port.`);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
log(`port ${port} is held by chrome-mcp pid ${pid} (another session) — taking the port over`);
|
|
109
|
+
try {
|
|
110
|
+
kill(pid, 'SIGTERM');
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false; // vanished or not ours to signal
|
|
114
|
+
}
|
|
115
|
+
const deadline = Date.now() + EXIT_WAIT_MS;
|
|
116
|
+
while (Date.now() < deadline) {
|
|
117
|
+
if (!alive(pid)) {
|
|
118
|
+
log(`previous owner (pid ${pid}) exited; reclaiming port ${port}`);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
await delay(POLL_MS);
|
|
122
|
+
}
|
|
123
|
+
// Wouldn't go quietly.
|
|
124
|
+
try {
|
|
125
|
+
kill(pid, 'SIGKILL');
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
/* already gone */
|
|
129
|
+
}
|
|
130
|
+
const gone = !alive(pid);
|
|
131
|
+
if (gone)
|
|
132
|
+
log(`previous owner (pid ${pid}) force-killed; reclaiming port ${port}`);
|
|
133
|
+
return gone;
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=evict.js.map
|
|
@@ -26,6 +26,9 @@ export interface BridgeOptions {
|
|
|
26
26
|
port?: number;
|
|
27
27
|
host?: string;
|
|
28
28
|
heartbeatMs?: number;
|
|
29
|
+
/** Data dir holding the handshake. Enables reclaiming a pinned port from a
|
|
30
|
+
* stale chrome-mcp (see ./evict). Omit to disable eviction entirely. */
|
|
31
|
+
dataDir?: string;
|
|
29
32
|
/** Diagnostics — MUST never receive the token (a test asserts this). */
|
|
30
33
|
onLog?: (message: string) => void;
|
|
31
34
|
onDisplacement?: (info: DisplacementInfo) => void;
|
|
@@ -20,6 +20,7 @@ const policy_1 = require("../../shared/policy");
|
|
|
20
20
|
const types_1 = require("../executor/types");
|
|
21
21
|
const connection_1 = require("./connection");
|
|
22
22
|
const auth_1 = require("./auth");
|
|
23
|
+
const evict_1 = require("./evict");
|
|
23
24
|
const config_1 = require("../config");
|
|
24
25
|
/** The routing label for a hello with no/blank profile — the back-compat default. */
|
|
25
26
|
const DEFAULT_PROFILE = 'default';
|
|
@@ -46,11 +47,12 @@ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
46
47
|
/** A friendly, actionable message for the rare case the port stays busy past PORT_WAIT_MS. */
|
|
47
48
|
function portBusyMessage(host, port) {
|
|
48
49
|
return (`Couldn't start: another program is already using ${host}:${port}.\n` +
|
|
49
|
-
`
|
|
50
|
-
`
|
|
51
|
-
`
|
|
50
|
+
`A stale chrome-mcp is reclaimed automatically, so this is some OTHER program.\n` +
|
|
51
|
+
`To fix it:\n` +
|
|
52
|
+
` 1. Stop whatever owns the port, then reconnect:\n` +
|
|
52
53
|
` macOS/Linux: lsof -nP -iTCP:${port} -sTCP:LISTEN then kill <PID>\n` +
|
|
53
|
-
`
|
|
54
|
+
` Windows: netstat -ano | findstr :${port} then taskkill /PID <PID> /F\n` +
|
|
55
|
+
` 2. Or run chrome-mcp with a different port: --port <number>`);
|
|
54
56
|
}
|
|
55
57
|
class BridgeServer {
|
|
56
58
|
opts;
|
|
@@ -76,6 +78,7 @@ class BridgeServer {
|
|
|
76
78
|
// wait-and-retry for a few seconds so the old process can release it; only if
|
|
77
79
|
// it never frees up do we surface a plain-English, actionable error.
|
|
78
80
|
const deadline = Date.now() + PORT_WAIT_MS;
|
|
81
|
+
let evicted = false;
|
|
79
82
|
for (let attempt = 1;; attempt++) {
|
|
80
83
|
try {
|
|
81
84
|
const wss = await this.listenOnce(host, port);
|
|
@@ -87,11 +90,20 @@ class BridgeServer {
|
|
|
87
90
|
}
|
|
88
91
|
catch (err) {
|
|
89
92
|
const inUse = err?.code === 'EADDRINUSE';
|
|
90
|
-
if (!inUse || port === 0
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
if (!inUse || port === 0)
|
|
94
|
+
throw inUse ? new Error(portBusyMessage(host, port)) : err;
|
|
95
|
+
// A live chrome-mcp from another session will never release the port on
|
|
96
|
+
// its own, so waiting it out is futile — take it over (once). Only a
|
|
97
|
+
// verified chrome-mcp is ever killed; anything else falls through to the
|
|
98
|
+
// wait-and-retry below, which covers our own just-replaced instance.
|
|
99
|
+
if (!evicted && this.opts.dataDir) {
|
|
100
|
+
evicted = true;
|
|
101
|
+
if (await (0, evict_1.evictPortOwner)(this.opts.dataDir, port, (m) => this.log(m))) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
94
104
|
}
|
|
105
|
+
if (Date.now() >= deadline)
|
|
106
|
+
throw new Error(portBusyMessage(host, port));
|
|
95
107
|
if (attempt === 1)
|
|
96
108
|
this.log(`port ${host}:${port} busy — waiting for the previous instance to release it…`);
|
|
97
109
|
await delay(PORT_RETRY_MS);
|
package/dist/src/cli.js
CHANGED
|
@@ -159,6 +159,7 @@ async function main() {
|
|
|
159
159
|
// Wire-serializable policy subset (no local uploadsDir) so the extension mirrors the gate.
|
|
160
160
|
policy: { allowDomains, allowEval, allowDownloads, allowUploads, allowAllTabs, enableMutations },
|
|
161
161
|
port: cfg.wsPort,
|
|
162
|
+
dataDir,
|
|
162
163
|
onLog: (m) => (0, server_2.logErr)(m),
|
|
163
164
|
onDisplacement: (d) => (0, server_2.logErr)(`SECURITY: extension connection displaced (different id: ${d.differentId})`),
|
|
164
165
|
});
|
package/dist/src/config.js
CHANGED
|
@@ -162,6 +162,14 @@ function parseArgs(argv) {
|
|
|
162
162
|
throw new Error(`unknown argument: ${arg}`);
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
|
+
// Fallback permanently removed — this build is EXTENSION-ONLY. It NEVER
|
|
166
|
+
// launches or attaches a Chromium of its own; it only ever drives the user's
|
|
167
|
+
// real Chrome through the paired extension. Any CDP flags (--cdp-fallback,
|
|
168
|
+
// --cdp-endpoint, --prefer cdp) are still accepted for back-compat but are
|
|
169
|
+
// hard-overridden here so no separate/"fallback" browser can ever open.
|
|
170
|
+
cdpFallback = false;
|
|
171
|
+
cdpEndpoint = undefined;
|
|
172
|
+
prefer = 'extension';
|
|
165
173
|
// File first, then flags win.
|
|
166
174
|
const policy = (0, policy_1.resolvePolicy)({ ...policyFile, ...policyFlags });
|
|
167
175
|
// Uploads must be confined to a directory — refuse to start with uploads enabled
|
|
@@ -242,13 +250,14 @@ Connection:
|
|
|
242
250
|
CHROME_MCP_TOKEN env, if set, pins the token explicitly.
|
|
243
251
|
|
|
244
252
|
Backend:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
--
|
|
249
|
-
--cdp-
|
|
250
|
-
--
|
|
251
|
-
--
|
|
253
|
+
This build is EXTENSION-ONLY — it drives ONLY your real Chrome via the paired
|
|
254
|
+
extension and never launches or attaches a Chromium of its own. The CDP flags
|
|
255
|
+
below are accepted for back-compat but IGNORED (there is no fallback).
|
|
256
|
+
--cdp-fallback (ignored — fallback permanently removed)
|
|
257
|
+
--no-cdp-fallback (ignored — extension-only is always on)
|
|
258
|
+
--cdp-endpoint <url> (ignored — no CDP attach)
|
|
259
|
+
--prefer <which> (ignored — always "extension")
|
|
260
|
+
--headless (ignored — no CDP Chromium to run headless)
|
|
252
261
|
|
|
253
262
|
Security (default: deny-all safe mode):
|
|
254
263
|
--policy <file> Load a JSON policy file
|
|
@@ -65,8 +65,14 @@ function clearProfileLocks(profileDir) {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
function isChromiumProcess(pid) {
|
|
68
|
+
// Windows has no `ps`; tasklist is the equivalent and is a real .exe, so it
|
|
69
|
+
// needs no shell. Its CSV row leads with the image name (e.g. "chrome.exe"),
|
|
70
|
+
// and the no-match case prints an INFO line that fails the same test.
|
|
71
|
+
const [cmd, args] = process.platform === 'win32'
|
|
72
|
+
? ['tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH']]
|
|
73
|
+
: ['ps', ['-p', String(pid), '-o', 'command=']];
|
|
68
74
|
try {
|
|
69
|
-
const out = (0, node_child_process_1.execFileSync)(
|
|
75
|
+
const out = (0, node_child_process_1.execFileSync)(cmd, args, {
|
|
70
76
|
encoding: 'utf8',
|
|
71
77
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
72
78
|
});
|
|
@@ -13,10 +13,16 @@ export interface LinkOut {
|
|
|
13
13
|
/**
|
|
14
14
|
* Collect anchors from the page (or a subtree). Implemented as a single page
|
|
15
15
|
* eval so it is one round-trip; falls back to parsing getHtml if eval is denied.
|
|
16
|
+
*
|
|
17
|
+
* `dedupe` collapses anchors that share an href (nav/footer repetition is common
|
|
18
|
+
* noise when crawling); `limit` caps the number of links returned. Both are
|
|
19
|
+
* applied server-side after collection, so they work on either code path.
|
|
16
20
|
*/
|
|
17
21
|
export declare function extractLinks(ex: Executor, args: {
|
|
18
22
|
selector?: string;
|
|
19
23
|
sameOriginOnly?: boolean;
|
|
24
|
+
dedupe?: boolean;
|
|
25
|
+
limit?: number;
|
|
20
26
|
tabId?: string;
|
|
21
27
|
}): Promise<{
|
|
22
28
|
links: LinkOut[];
|
package/dist/src/mcp/helpers.js
CHANGED
|
@@ -13,6 +13,10 @@ const markdown_extract_1 = require("./markdown-extract");
|
|
|
13
13
|
/**
|
|
14
14
|
* Collect anchors from the page (or a subtree). Implemented as a single page
|
|
15
15
|
* eval so it is one round-trip; falls back to parsing getHtml if eval is denied.
|
|
16
|
+
*
|
|
17
|
+
* `dedupe` collapses anchors that share an href (nav/footer repetition is common
|
|
18
|
+
* noise when crawling); `limit` caps the number of links returned. Both are
|
|
19
|
+
* applied server-side after collection, so they work on either code path.
|
|
16
20
|
*/
|
|
17
21
|
async function extractLinks(ex, args) {
|
|
18
22
|
const root = args.selector ? JSON.stringify(args.selector) : 'null';
|
|
@@ -24,21 +28,46 @@ async function extractLinks(ex, args) {
|
|
|
24
28
|
href: a.href, text: (a.textContent || '').trim().slice(0, 200),
|
|
25
29
|
})).filter(l => l.href && (${args.sameOriginOnly ? 'l.href.startsWith(here)' : 'true'}));
|
|
26
30
|
})()`;
|
|
31
|
+
let links;
|
|
27
32
|
const res = await ex.eval(expr, { tabId: args.tabId });
|
|
28
33
|
if (res.ok && Array.isArray(res.value)) {
|
|
29
|
-
|
|
34
|
+
links = res.value;
|
|
30
35
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
else {
|
|
37
|
+
// Fallback: parse hrefs out of the HTML (e.g. when eval is policy-denied).
|
|
38
|
+
const { html } = await ex.getHtml(args.selector ? { selector: args.selector } : undefined, {
|
|
39
|
+
tabId: args.tabId,
|
|
40
|
+
});
|
|
41
|
+
links = [];
|
|
42
|
+
const re = /<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
|
43
|
+
let m;
|
|
44
|
+
while ((m = re.exec(html)) !== null) {
|
|
45
|
+
links.push({ href: m[1], text: m[2].replace(/<[^>]+>/g, '').trim().slice(0, 200) });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { links: refineLinks(links, args) };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Collapse links that share an href (keeping the first, but preferring a
|
|
52
|
+
* non-empty label) when `dedupe` is set, then cap to `limit`. Order is
|
|
53
|
+
* preserved so the first occurrence of each href wins.
|
|
54
|
+
*/
|
|
55
|
+
function refineLinks(links, opts) {
|
|
56
|
+
let out = links;
|
|
57
|
+
if (opts.dedupe) {
|
|
58
|
+
const byHref = new Map();
|
|
59
|
+
for (const l of links) {
|
|
60
|
+
const existing = byHref.get(l.href);
|
|
61
|
+
if (!existing)
|
|
62
|
+
byHref.set(l.href, { ...l });
|
|
63
|
+
else if (!existing.text && l.text)
|
|
64
|
+
existing.text = l.text;
|
|
65
|
+
}
|
|
66
|
+
out = [...byHref.values()];
|
|
40
67
|
}
|
|
41
|
-
|
|
68
|
+
if (opts.limit !== undefined && out.length > opts.limit)
|
|
69
|
+
out = out.slice(0, opts.limit);
|
|
70
|
+
return out;
|
|
42
71
|
}
|
|
43
72
|
/** Read a page (or subtree) as readable markdown. */
|
|
44
73
|
async function readAsMarkdown(ex, args) {
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -58,7 +58,7 @@ exports.TOOL_DEFINITIONS = [
|
|
|
58
58
|
{ name: 'storage', description: 'Read/write localStorage (or sessionStorage). op: get|set|remove|clear.', inputSchema: { op: zod_1.z.enum(['get', 'set', 'remove', 'clear']), key: zod_1.z.string().optional(), value: zod_1.z.string().optional(), session: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
59
59
|
{ name: 'eval', description: 'Evaluate JavaScript in the page (disabled in safe-mode).', inputSchema: { expression: zod_1.z.string(), awaitPromise: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
60
60
|
{ name: 'wait_for', description: 'Wait for a selector or text to appear/disappear.', inputSchema: { selector: zod_1.z.string().optional(), textContains: zod_1.z.string().optional(), gone: zod_1.z.boolean().optional(), timeoutMs: zod_1.z.number().optional(), tabId: tabIdField } },
|
|
61
|
-
{ name: 'extract_links', description: 'Extract anchors from the page or a subtree.', inputSchema: { selector: zod_1.z.string().optional(), sameOriginOnly: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
61
|
+
{ name: 'extract_links', description: 'Extract anchors from the page or a subtree. dedupe=true collapses links sharing an href (nav/footer noise); limit caps the count.', inputSchema: { selector: zod_1.z.string().optional(), sameOriginOnly: zod_1.z.boolean().optional(), dedupe: zod_1.z.boolean().optional(), limit: zod_1.z.number().optional(), tabId: tabIdField } },
|
|
62
62
|
{ name: 'read_as_markdown', description: 'Read the page (or subtree) as readable markdown.', inputSchema: { selector: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
63
63
|
{ name: 'fill_form', description: 'Fill multiple fields (keyed by selector) and optionally submit.', inputSchema: { fields: zod_1.z.record(zod_1.z.string(), zod_1.z.union([zod_1.z.string(), zod_1.z.boolean()])), submitSelector: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
64
64
|
{ name: 'download_file', description: 'Download a file by URL or from a link element.', inputSchema: { url: zod_1.z.string().optional(), ...TARGET_PROPS, suggestedName: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
@@ -276,6 +276,8 @@ exports.TOOL_HANDLERS = {
|
|
|
276
276
|
const res = await (0, helpers_1.extractLinks)(ctx.ex, {
|
|
277
277
|
selector: (0, validators_1.optionalString)(a, 'selector'),
|
|
278
278
|
sameOriginOnly: (0, validators_1.optionalBoolean)(a, 'sameOriginOnly'),
|
|
279
|
+
dedupe: (0, validators_1.optionalBoolean)(a, 'dedupe'),
|
|
280
|
+
limit: (0, validators_1.optionalNumber)(a, 'limit', { min: 1, max: 10_000 }),
|
|
279
281
|
tabId: tabId(a),
|
|
280
282
|
});
|
|
281
283
|
(0, workspace_1.saveResult)('extract_links', 'json', JSON.stringify(res, null, 2));
|
|
@@ -39,7 +39,6 @@
|
|
|
39
39
|
constructor(deps) {
|
|
40
40
|
this.deps = deps;
|
|
41
41
|
}
|
|
42
|
-
deps;
|
|
43
42
|
ws = null;
|
|
44
43
|
state = "idle";
|
|
45
44
|
isConnected() {
|
|
@@ -160,14 +159,22 @@
|
|
|
160
159
|
function isAboutBlank(url) {
|
|
161
160
|
return url === "about:blank" || url === "" || url.startsWith("about:");
|
|
162
161
|
}
|
|
162
|
+
function normalizeDomainPattern(pattern) {
|
|
163
|
+
let p = pattern.trim().toLowerCase();
|
|
164
|
+
if (p === "*" || p === "*://*/*") return "*";
|
|
165
|
+
p = p.replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
|
|
166
|
+
p = p.replace(/[/?#].*$/, "");
|
|
167
|
+
p = p.replace(/^[^@]*@/, "").replace(/:\d+$/, "");
|
|
168
|
+
return p;
|
|
169
|
+
}
|
|
163
170
|
function globMatches(host, pattern) {
|
|
164
|
-
const p = pattern
|
|
165
|
-
if (p === "*"
|
|
171
|
+
const p = normalizeDomainPattern(pattern);
|
|
172
|
+
if (p === "*") return true;
|
|
166
173
|
if (p.startsWith("*.")) {
|
|
167
174
|
const base = p.slice(2);
|
|
168
175
|
return host === base || host.endsWith("." + base);
|
|
169
176
|
}
|
|
170
|
-
return host === p;
|
|
177
|
+
return p !== "" && host === p;
|
|
171
178
|
}
|
|
172
179
|
function isDomainAllowed(url, policy) {
|
|
173
180
|
const host = hostOf(url);
|
|
@@ -425,7 +432,6 @@
|
|
|
425
432
|
super(message);
|
|
426
433
|
this.code = code;
|
|
427
434
|
}
|
|
428
|
-
code;
|
|
429
435
|
};
|
|
430
436
|
var DOWNLOAD_TIMEOUT_MS = 12e4;
|
|
431
437
|
function waitForDownloadComplete(id) {
|
|
@@ -527,7 +533,7 @@
|
|
|
527
533
|
async function waitForSelector(tabId, selector, timeoutMs = 5e3) {
|
|
528
534
|
const found = await execInTab(
|
|
529
535
|
tabId,
|
|
530
|
-
(
|
|
536
|
+
(s, timeout, interval) => new Promise((resolve) => {
|
|
531
537
|
const deadline = Date.now() + timeout;
|
|
532
538
|
const tick = () => {
|
|
533
539
|
if (document.querySelector(s)) return resolve(true);
|
|
@@ -535,7 +541,7 @@
|
|
|
535
541
|
setTimeout(tick, interval);
|
|
536
542
|
};
|
|
537
543
|
tick();
|
|
538
|
-
})
|
|
544
|
+
}),
|
|
539
545
|
[selector, timeoutMs, 120]
|
|
540
546
|
);
|
|
541
547
|
return found === true;
|
|
@@ -1036,7 +1042,7 @@
|
|
|
1036
1042
|
const start = Date.now();
|
|
1037
1043
|
const matched = await execInTab(
|
|
1038
1044
|
id,
|
|
1039
|
-
(
|
|
1045
|
+
(sel, text, gone, timeoutMs, interval) => new Promise((resolve) => {
|
|
1040
1046
|
const deadline = Date.now() + timeoutMs;
|
|
1041
1047
|
const hit = () => {
|
|
1042
1048
|
let present;
|
|
@@ -1051,7 +1057,7 @@
|
|
|
1051
1057
|
setTimeout(tick, interval);
|
|
1052
1058
|
};
|
|
1053
1059
|
tick();
|
|
1054
|
-
})
|
|
1060
|
+
}),
|
|
1055
1061
|
[cmd.params.selector ?? null, cmd.params.textContains ?? null, cmd.params.gone === true, timeout, 150]
|
|
1056
1062
|
);
|
|
1057
1063
|
return { matched: matched === true, waitedMs: Date.now() - start };
|
|
@@ -1115,7 +1121,6 @@
|
|
|
1115
1121
|
if (!HANDLED.has(m)) throw new Error(`router drift: no handler for wire method "${m}"`);
|
|
1116
1122
|
}
|
|
1117
1123
|
}
|
|
1118
|
-
deps;
|
|
1119
1124
|
async dispatch(cmd) {
|
|
1120
1125
|
try {
|
|
1121
1126
|
const policy = this.deps.getPolicy();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehmoodqureshi/chrome-mcp",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "Drive
|
|
3
|
+
"version": "0.6.4",
|
|
4
|
+
"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.",
|
|
5
5
|
"author": "Mehmood Ur Rehman Qureshi",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://github.com/Mehmoodqureshi/chrome-mcp#readme",
|
package/scripts/postinstall.js
CHANGED
|
@@ -39,7 +39,13 @@ function main() {
|
|
|
39
39
|
}
|
|
40
40
|
try {
|
|
41
41
|
const { execFileSync } = require('node:child_process');
|
|
42
|
-
|
|
42
|
+
const { dirname, join } = require('node:path');
|
|
43
|
+
// Drive Playwright's CLI through node rather than the `npx`/`playwright` bin
|
|
44
|
+
// shim: on Windows those are .cmd files, which execFileSync cannot spawn
|
|
45
|
+
// without a shell. `cli.js` is playwright's own bin target; resolving it via
|
|
46
|
+
// package.json avoids the exports map, which exposes no './cli' subpath.
|
|
47
|
+
const cli = join(dirname(require.resolve('playwright/package.json')), 'cli.js');
|
|
48
|
+
execFileSync(process.execPath, [cli, 'install', 'chromium'], { stdio: 'inherit' });
|
|
43
49
|
} catch (err) {
|
|
44
50
|
process.stdout.write(
|
|
45
51
|
`[postinstall] Chromium install skipped (non-fatal): ${err && err.message ? err.message : err}\n`,
|