@mehmoodqureshi/chrome-mcp 0.6.3 → 0.6.5
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 +116 -31
- package/dist/shared/protocol.d.ts +22 -0
- package/dist/shared/protocol.js +13 -1
- package/dist/src/bridge/auth.d.ts +1 -0
- package/dist/src/bridge/auth.js +21 -12
- package/dist/src/bridge/connection.d.ts +18 -0
- package/dist/src/bridge/connection.js +39 -1
- package/dist/src/bridge/evict.d.ts +32 -0
- package/dist/src/bridge/evict.js +135 -0
- package/dist/src/bridge/server.d.ts +9 -0
- package/dist/src/bridge/server.js +32 -10
- package/dist/src/cli.js +1 -0
- package/dist/src/executor/cdp-executor.js +7 -1
- package/dist/src/executor/extension-executor.d.ts +3 -0
- package/dist/src/executor/extension-executor.js +15 -0
- package/dist/src/executor/stub-executor.d.ts +18 -0
- package/dist/src/executor/stub-executor.js +26 -2
- package/dist/src/executor/types.d.ts +8 -0
- package/dist/src/mcp/tools.js +54 -8
- package/extension-dist/background.js +17 -1
- package/package.json +13 -5
- package/scripts/postinstall.js +7 -1
package/README.md
CHANGED
|
@@ -4,36 +4,73 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@mehmoodqureshi/chrome-mcp)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
**Let Claude use the Chrome you are already logged into.** Not a fresh
|
|
8
|
+
automated browser that greets every site as a stranger — *your* Chrome, with
|
|
9
|
+
your sessions, your cookies, your 2FA already done. If you can see a page in
|
|
10
|
+
your browser, your agent can read it, without logging in again and without
|
|
11
|
+
pasting credentials anywhere.
|
|
12
|
+
|
|
13
|
+
Most browser MCP servers launch their own Chromium and hand your agent a
|
|
14
|
+
signed-out window. chrome-mcp does the opposite: an MV3 extension dials into a
|
|
15
|
+
localhost WebSocket server and drives the browser you already have open, through
|
|
16
|
+
`chrome.scripting`/`chrome.tabs`. Works with Claude Code, Claude Desktop, and any
|
|
17
|
+
other MCP host.
|
|
15
18
|
|
|
16
19
|
Distributed as an `npx` CLI (the MCP server) plus a load-unpacked extension.
|
|
17
20
|
|
|
21
|
+
> **This build is extension-only.** It never launches or attaches a Chromium of
|
|
22
|
+
> its own, so **the extension is required, not optional** — without it, no tool
|
|
23
|
+
> can run. The CDP flags (`--cdp-fallback`, `--no-cdp-fallback`, `--cdp-endpoint`,
|
|
24
|
+
> `--prefer`) are still accepted for back-compat but are **ignored**.
|
|
25
|
+
|
|
18
26
|
> **Full design:** [`docs/BLUEPRINT.md`](docs/BLUEPRINT.md) — architecture, wire
|
|
19
27
|
> protocol, the complete tool surface, the extension manifest, the security
|
|
20
28
|
> model, and the phased build plan.
|
|
21
29
|
|
|
22
30
|
## Quickstart
|
|
23
31
|
|
|
24
|
-
**1. Register the MCP server** with your host
|
|
32
|
+
**1. Register the MCP server** with your host.
|
|
33
|
+
|
|
34
|
+
<details open>
|
|
35
|
+
<summary><b>Claude Code (terminal)</b> — one command, no config file to find</summary>
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
claude mcp add chrome-mcp -s user -- \
|
|
39
|
+
npx -y @mehmoodqureshi/chrome-mcp \
|
|
40
|
+
--allow-domain example.com --enable-mutations --persist-token
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Everything **before** `--` belongs to Claude Code; everything **after** it is this
|
|
44
|
+
server's command and flags. Keep the `--` or `--allow-domain` gets read as a
|
|
45
|
+
Claude Code option.
|
|
46
|
+
|
|
47
|
+
`-s user` registers it for every project on your machine. Use `-s local` (the
|
|
48
|
+
default) for just the current project, or `-s project` to write a `.mcp.json`
|
|
49
|
+
your team can commit.
|
|
50
|
+
|
|
51
|
+
Check it came up with `claude mcp list`. After upgrading the server, reconnect it
|
|
52
|
+
with `/mcp` inside a session — no restart needed.
|
|
53
|
+
|
|
54
|
+
</details>
|
|
55
|
+
|
|
56
|
+
<details>
|
|
57
|
+
<summary><b>Claude Desktop</b> and other MCP hosts — JSON config</summary>
|
|
25
58
|
|
|
26
59
|
```jsonc
|
|
27
60
|
{
|
|
28
61
|
"mcpServers": {
|
|
29
62
|
"chrome-mcp": {
|
|
30
63
|
"command": "npx",
|
|
31
|
-
"args": ["-y", "@mehmoodqureshi/chrome-mcp",
|
|
64
|
+
"args": ["-y", "@mehmoodqureshi/chrome-mcp",
|
|
65
|
+
"--allow-domain", "example.com", "--enable-mutations",
|
|
66
|
+
"--persist-token"]
|
|
32
67
|
}
|
|
33
68
|
}
|
|
34
69
|
}
|
|
35
70
|
```
|
|
36
71
|
|
|
72
|
+
</details>
|
|
73
|
+
|
|
37
74
|
By default everything is **deny-all** (no domains, no eval, no mutations). Grant
|
|
38
75
|
exactly what you need with `--allow-domain <glob>` (repeatable), `--enable-mutations`,
|
|
39
76
|
`--enable-downloads`, `--enable-uploads`, `--unsafe-enable-eval`, or `--unsafe-all-domains`.
|
|
@@ -44,38 +81,82 @@ exactly what you need with `--allow-domain <glob>` (repeatable), `--enable-mutat
|
|
|
44
81
|
> with `--uploads-dir <path>` to restrict uploads to files inside that directory
|
|
45
82
|
> (`..` traversal is blocked) — strongly recommended for unattended use.
|
|
46
83
|
|
|
47
|
-
**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
84
|
+
**Pair once, never again.** Both examples above include `--persist-token`, which
|
|
85
|
+
is what makes the pairing survive a restart — drop it if you'd rather have the
|
|
86
|
+
stricter default described next.
|
|
87
|
+
|
|
88
|
+
Without `--persist-token` a fresh token is minted every boot (the secure
|
|
89
|
+
default), which means re-pairing the extension on each restart. With it, the
|
|
90
|
+
token is stored 0600 at `~/.chrome-mcp/token` and reused; the extension's
|
|
91
|
+
keepalive auto-reconnects with no manual step. `CHROME_MCP_TOKEN` pins the token
|
|
92
|
+
explicitly (and is never written to disk).
|
|
93
|
+
|
|
94
|
+
**2. Load the extension** — **required**; the server can drive nothing without it.
|
|
95
|
+
|
|
96
|
+
`extension-dist/` ships prebuilt inside the npm package, so there is nothing to
|
|
97
|
+
compile. Install globally to get a stable path to it:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npm install -g @mehmoodqureshi/chrome-mcp
|
|
101
|
+
npm root -g # → <root>; the extension is at <root>/@mehmoodqureshi/chrome-mcp/extension-dist
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Then `chrome://extensions` → enable **Developer mode** → **Load unpacked** →
|
|
105
|
+
select that `extension-dist/` directory. (Working from a git clone instead? Run
|
|
106
|
+
`npm install && npm run build:ext` first — `extension-dist/` is gitignored.)
|
|
107
|
+
|
|
108
|
+
**3. Pair it:** run `npx chrome-mcp --print-pairing` to write the handshake and
|
|
109
|
+
print its path, open the extension's **Options** page, and paste the `port` +
|
|
110
|
+
`token` from `~/.chrome-mcp/handshake.json`.
|
|
111
|
+
|
|
112
|
+
### Running more than one session
|
|
113
|
+
|
|
114
|
+
The extension dials exactly **one** bridge port, so only one chrome-mcp can drive
|
|
115
|
+
your Chrome at a time — but every MCP host session (each Claude tab/window)
|
|
116
|
+
spawns its own server. With a pinned `--port`, the newest session **takes the
|
|
117
|
+
port over**: it reads the owning pid from `handshake.json`, confirms that process
|
|
118
|
+
really is a chrome-mcp, and stops it. Newest tab wins; the older session's browser
|
|
119
|
+
tools go quiet until it reconnects. Nothing that isn't a verified chrome-mcp is
|
|
120
|
+
ever touched — a port held by some other program is reported, never killed.
|
|
121
|
+
|
|
122
|
+
Two servers can only run side by side if each has its own port **and** its own
|
|
123
|
+
paired extension — i.e. a separate Chrome profile running its own copy of the
|
|
124
|
+
extension, pointed at the other port (`--port 9223`). A single Chrome pairs to one
|
|
125
|
+
server at a time, so a second server with no extension of its own can drive
|
|
126
|
+
nothing.
|
|
127
|
+
|
|
128
|
+
One server can, however, serve **several browsers at once**: connections are
|
|
129
|
+
routed by profile key (`--profile <name>`, matching the profile set in the
|
|
130
|
+
extension's Options), so each paired Chrome gets its own routing slot.
|
|
131
|
+
|
|
132
|
+
Without `--port`, each server binds an ephemeral port (no conflict ever), but the
|
|
133
|
+
port changes every boot — so you'd re-pair the extension each time. Pin `--port`
|
|
134
|
+
plus `--persist-token` for a pair-once setup.
|
|
135
|
+
|
|
136
|
+
### Windows
|
|
137
|
+
|
|
138
|
+
WSL2 is **not** required — native Windows works. One config change is, though:
|
|
139
|
+
on Windows `npx` is `npx.cmd`, a batch shim, and MCP hosts spawn the server
|
|
140
|
+
without a shell, which cannot execute a `.cmd`. So `"command": "npx"` fails to
|
|
141
|
+
start. Wrap it in `cmd /c`:
|
|
51
142
|
|
|
52
143
|
```jsonc
|
|
53
144
|
{
|
|
54
145
|
"mcpServers": {
|
|
55
146
|
"chrome-mcp": {
|
|
56
|
-
"command": "
|
|
57
|
-
"args": ["-y", "@mehmoodqureshi/chrome-mcp",
|
|
147
|
+
"command": "cmd",
|
|
148
|
+
"args": ["/c", "npx", "-y", "@mehmoodqureshi/chrome-mcp",
|
|
58
149
|
"--allow-domain", "example.com", "--enable-mutations",
|
|
59
|
-
"--
|
|
150
|
+
"--persist-token"]
|
|
60
151
|
}
|
|
61
152
|
}
|
|
62
153
|
}
|
|
63
154
|
```
|
|
64
155
|
|
|
65
|
-
|
|
66
|
-
default), which means re-pairing the extension on each restart. With it, the
|
|
67
|
-
token is stored 0600 at `~/.chrome-mcp/token` and reused; the extension's
|
|
68
|
-
keepalive auto-reconnects with no manual step. `CHROME_MCP_TOKEN` pins the token
|
|
69
|
-
explicitly (and is never written to disk).
|
|
70
|
-
|
|
71
|
-
**2. Load the extension** (to drive your *real* Chrome): build it, then
|
|
72
|
-
`chrome://extensions` → enable Developer mode → **Load unpacked** → select
|
|
73
|
-
`extension-dist/`.
|
|
156
|
+
Or from Claude Code: `claude mcp add chrome-mcp -- cmd /c npx -y @mehmoodqureshi/chrome-mcp --allow-domain example.com`
|
|
74
157
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
`token` from `~/.chrome-mcp/handshake.json`. (Without the extension, the CLI
|
|
78
|
-
falls back to a Playwright-driven Chromium automatically.)
|
|
158
|
+
Everything else is the same — load `extension-dist/` from `npm root -g` and pair
|
|
159
|
+
as above.
|
|
79
160
|
|
|
80
161
|
The tools cover tabs, navigation, interaction (`click`/`type`/`press`/`hover`/
|
|
81
162
|
`scroll`/`select_option`), reads (`get_text`/`get_html`/`screenshot`/`eval`/`wait_for`),
|
|
@@ -183,7 +264,11 @@ chrome-mcp --unsafe-all-domains # loud footgun
|
|
|
183
264
|
```
|
|
184
265
|
|
|
185
266
|
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.
|
|
267
|
+
only trust boundary; it is never written to stdout/stderr. On POSIX the mode is
|
|
268
|
+
re-verified after every write and the server **fails closed** if the file ends up
|
|
269
|
+
group/other-accessible. Windows has no such bits — `chmod` there only toggles the
|
|
270
|
+
read-only attribute — so the check is skipped and the token's confidentiality
|
|
271
|
+
rests on the per-user ACL of `%USERPROFILE%\.chrome-mcp`.
|
|
187
272
|
|
|
188
273
|
## Develop
|
|
189
274
|
|
|
@@ -27,6 +27,18 @@ export declare const BRIDGE_HOST: "127.0.0.1";
|
|
|
27
27
|
/** WebSocket close codes we use deliberately. */
|
|
28
28
|
export declare const CLOSE_UNAUTHORIZED: 4401;
|
|
29
29
|
export declare const CLOSE_SUPERSEDED: 4000;
|
|
30
|
+
/**
|
|
31
|
+
* Capabilities an extension advertises in `hello`. Additive and optional, so an
|
|
32
|
+
* older extension (which sends none) keeps the conservative behaviour — never
|
|
33
|
+
* gate a capability behind a version-string comparison.
|
|
34
|
+
*
|
|
35
|
+
* `tab-url`: this extension (a) enforces the policy mirror FAIL-CLOSED — it
|
|
36
|
+
* refuses every command until a policy has arrived — and (b) reports the target
|
|
37
|
+
* tab's post-command URL as `ResultFrame.tabUrl`. Together those let the server
|
|
38
|
+
* gate from the reported URL instead of paying a `tabs_list` round-trip before
|
|
39
|
+
* every call. Without it, the server falls back to fetching the URL itself.
|
|
40
|
+
*/
|
|
41
|
+
export declare const WIRE_CAP_TAB_URL: "tab-url";
|
|
30
42
|
/**
|
|
31
43
|
* Every method that may travel on the wire = the MCP primitives 1:1, plus
|
|
32
44
|
* `download_file` (privileged, executor-owned) and `ping_probe` (a short-deadline
|
|
@@ -52,6 +64,9 @@ export interface HelloFrame extends BaseFrame {
|
|
|
52
64
|
* NOT a security boundary (the token is) — it selects which connection slot the
|
|
53
65
|
* server routes commands to, so several browsers can stay paired at once. */
|
|
54
66
|
profile?: string;
|
|
67
|
+
/** Optional capability advertisements (see `WIRE_CAP_TAB_URL`). An extension
|
|
68
|
+
* that sends none gets the conservative path, so old builds stay correct. */
|
|
69
|
+
caps?: string[];
|
|
55
70
|
}
|
|
56
71
|
/**
|
|
57
72
|
* The wire-serializable subset of the server's policy, delivered in `welcome` so
|
|
@@ -92,6 +107,13 @@ export interface ResultFrame extends BaseFrame {
|
|
|
92
107
|
ok: true;
|
|
93
108
|
/** For `screenshot`: { dataBase64, mimeType, width, height, truncated }. */
|
|
94
109
|
data: unknown;
|
|
110
|
+
/**
|
|
111
|
+
* The target tab's URL as observed AFTER the command ran — the extension has
|
|
112
|
+
* it locally, so sending it costs nothing and saves the server a round-trip
|
|
113
|
+
* on the next policy gate. Omitted when it can't be resolved (e.g. the tab was
|
|
114
|
+
* closed). Only sent by extensions advertising `WIRE_CAP_TAB_URL`.
|
|
115
|
+
*/
|
|
116
|
+
tabUrl?: string;
|
|
95
117
|
}
|
|
96
118
|
export interface ErrorFrame extends BaseFrame {
|
|
97
119
|
type: 'error';
|
package/dist/shared/protocol.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* wire method beyond the primitives.
|
|
15
15
|
*/
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.WIRE_METHODS = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
|
|
17
|
+
exports.WIRE_METHODS = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
|
|
18
18
|
/** Bumped on any breaking change to the frames below. */
|
|
19
19
|
exports.PROTOCOL_VERSION = 1;
|
|
20
20
|
/**
|
|
@@ -29,6 +29,18 @@ exports.BRIDGE_HOST = '127.0.0.1';
|
|
|
29
29
|
/** WebSocket close codes we use deliberately. */
|
|
30
30
|
exports.CLOSE_UNAUTHORIZED = 4401;
|
|
31
31
|
exports.CLOSE_SUPERSEDED = 4000;
|
|
32
|
+
/**
|
|
33
|
+
* Capabilities an extension advertises in `hello`. Additive and optional, so an
|
|
34
|
+
* older extension (which sends none) keeps the conservative behaviour — never
|
|
35
|
+
* gate a capability behind a version-string comparison.
|
|
36
|
+
*
|
|
37
|
+
* `tab-url`: this extension (a) enforces the policy mirror FAIL-CLOSED — it
|
|
38
|
+
* refuses every command until a policy has arrived — and (b) reports the target
|
|
39
|
+
* tab's post-command URL as `ResultFrame.tabUrl`. Together those let the server
|
|
40
|
+
* gate from the reported URL instead of paying a `tabs_list` round-trip before
|
|
41
|
+
* every call. Without it, the server falls back to fetching the URL itself.
|
|
42
|
+
*/
|
|
43
|
+
exports.WIRE_CAP_TAB_URL = 'tab-url';
|
|
32
44
|
/** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
|
|
33
45
|
exports.WIRE_METHODS = [
|
|
34
46
|
'tabs_list',
|
|
@@ -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) {
|
|
@@ -17,6 +17,8 @@ export interface ConnectionDeps {
|
|
|
17
17
|
extId: string;
|
|
18
18
|
sessionId: string;
|
|
19
19
|
heartbeatMs: number;
|
|
20
|
+
/** Capabilities from `hello` (see WIRE_CAP_TAB_URL). Old builds send none. */
|
|
21
|
+
caps?: string[];
|
|
20
22
|
onEvent?: (event: WireEvent, data: Record<string, unknown>) => void;
|
|
21
23
|
onClose?: (code: number) => void;
|
|
22
24
|
onLog?: (message: string) => void;
|
|
@@ -30,6 +32,10 @@ export declare class ExtensionConnection {
|
|
|
30
32
|
private closed;
|
|
31
33
|
private heartbeat;
|
|
32
34
|
private missedPongs;
|
|
35
|
+
/** Whether this extension reports `tabUrl` and gates fail-closed. */
|
|
36
|
+
private readonly reportsTabUrl;
|
|
37
|
+
/** Last URL the ACTIVE tab reported, with the wall-clock it arrived. */
|
|
38
|
+
private activeUrl;
|
|
33
39
|
private readonly onEvent?;
|
|
34
40
|
private readonly onClose?;
|
|
35
41
|
private readonly onLog?;
|
|
@@ -43,6 +49,18 @@ export declare class ExtensionConnection {
|
|
|
43
49
|
isOpen(): boolean;
|
|
44
50
|
private handleMessage;
|
|
45
51
|
private settle;
|
|
52
|
+
/**
|
|
53
|
+
* Cache the URL a result rode home with — but ONLY when it describes the active
|
|
54
|
+
* tab (no explicit tabId) and actually resolved. A blank `tabUrl` means the
|
|
55
|
+
* extension couldn't read it (closed tab, restricted page), which is a reason
|
|
56
|
+
* to forget what we knew, never to keep believing it.
|
|
57
|
+
*/
|
|
58
|
+
private rememberActiveUrl;
|
|
59
|
+
/**
|
|
60
|
+
* The active tab's last reported URL if it is younger than `maxAgeMs`, else
|
|
61
|
+
* null — the caller then resolves it the slow way. Never returns a guess.
|
|
62
|
+
*/
|
|
63
|
+
lastActiveUrl(maxAgeMs: number): string | null;
|
|
46
64
|
private handleClose;
|
|
47
65
|
private startHeartbeat;
|
|
48
66
|
}
|
|
@@ -36,6 +36,8 @@ function mapWireErrorCode(code) {
|
|
|
36
36
|
};
|
|
37
37
|
return known[code] ?? 'TARGET_GONE';
|
|
38
38
|
}
|
|
39
|
+
/** Commands that can change WHICH tab is active, invalidating a cached URL. */
|
|
40
|
+
const ACTIVE_TAB_CHANGERS = new Set(['tab_select', 'tab_new', 'tab_close']);
|
|
39
41
|
class ExtensionConnection {
|
|
40
42
|
extId;
|
|
41
43
|
sessionId;
|
|
@@ -45,6 +47,10 @@ class ExtensionConnection {
|
|
|
45
47
|
closed = false;
|
|
46
48
|
heartbeat = null;
|
|
47
49
|
missedPongs = 0;
|
|
50
|
+
/** Whether this extension reports `tabUrl` and gates fail-closed. */
|
|
51
|
+
reportsTabUrl;
|
|
52
|
+
/** Last URL the ACTIVE tab reported, with the wall-clock it arrived. */
|
|
53
|
+
activeUrl = null;
|
|
48
54
|
onEvent;
|
|
49
55
|
onClose;
|
|
50
56
|
onLog;
|
|
@@ -52,6 +58,7 @@ class ExtensionConnection {
|
|
|
52
58
|
this.ws = deps.ws;
|
|
53
59
|
this.extId = deps.extId;
|
|
54
60
|
this.sessionId = deps.sessionId;
|
|
61
|
+
this.reportsTabUrl = deps.caps?.includes(protocol_1.WIRE_CAP_TAB_URL) ?? false;
|
|
55
62
|
this.onEvent = deps.onEvent;
|
|
56
63
|
this.onClose = deps.onClose;
|
|
57
64
|
this.onLog = deps.onLog;
|
|
@@ -71,6 +78,11 @@ class ExtensionConnection {
|
|
|
71
78
|
}
|
|
72
79
|
const id = String(++this.seq);
|
|
73
80
|
const timeoutMs = opts?.timeoutMs ?? defaultTimeoutFor(method);
|
|
81
|
+
// Anything that reshuffles tabs makes the cached URL a claim about a tab that
|
|
82
|
+
// may no longer be the active one. Drop it before the command, not after, so
|
|
83
|
+
// a failure mid-flight can't leave a stale entry behind.
|
|
84
|
+
if (ACTIVE_TAB_CHANGERS.has(method))
|
|
85
|
+
this.activeUrl = null;
|
|
74
86
|
const frame = {
|
|
75
87
|
type: 'command',
|
|
76
88
|
v: protocol_1.PROTOCOL_VERSION,
|
|
@@ -86,7 +98,7 @@ class ExtensionConnection {
|
|
|
86
98
|
reject(new types_1.ExecutorError('TIMEOUT', `"${method}" timed out after ${timeoutMs}ms`));
|
|
87
99
|
}, timeoutMs);
|
|
88
100
|
timer.unref?.();
|
|
89
|
-
this.pending.set(id, { resolve, reject, timer, method });
|
|
101
|
+
this.pending.set(id, { resolve, reject, timer, method, activeTab: opts?.tabId === undefined });
|
|
90
102
|
try {
|
|
91
103
|
this.ws.send(JSON.stringify(frame));
|
|
92
104
|
}
|
|
@@ -148,12 +160,38 @@ class ExtensionConnection {
|
|
|
148
160
|
clearTimeout(p.timer);
|
|
149
161
|
this.pending.delete(id);
|
|
150
162
|
if (frame.type === 'result') {
|
|
163
|
+
this.rememberActiveUrl(p, frame);
|
|
151
164
|
p.resolve(frame.data);
|
|
152
165
|
}
|
|
153
166
|
else {
|
|
167
|
+
// A failed command tells us nothing reliable about where the tab ended up.
|
|
168
|
+
if (p.activeTab)
|
|
169
|
+
this.activeUrl = null;
|
|
154
170
|
p.reject(new types_1.ExecutorError(mapWireErrorCode(frame.error.code), frame.error.message));
|
|
155
171
|
}
|
|
156
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Cache the URL a result rode home with — but ONLY when it describes the active
|
|
175
|
+
* tab (no explicit tabId) and actually resolved. A blank `tabUrl` means the
|
|
176
|
+
* extension couldn't read it (closed tab, restricted page), which is a reason
|
|
177
|
+
* to forget what we knew, never to keep believing it.
|
|
178
|
+
*/
|
|
179
|
+
rememberActiveUrl(p, frame) {
|
|
180
|
+
if (!this.reportsTabUrl || !p.activeTab)
|
|
181
|
+
return;
|
|
182
|
+
if (ACTIVE_TAB_CHANGERS.has(p.method))
|
|
183
|
+
return; // already invalidated; re-caching would race
|
|
184
|
+
this.activeUrl = frame.tabUrl ? { url: frame.tabUrl, at: Date.now() } : null;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The active tab's last reported URL if it is younger than `maxAgeMs`, else
|
|
188
|
+
* null — the caller then resolves it the slow way. Never returns a guess.
|
|
189
|
+
*/
|
|
190
|
+
lastActiveUrl(maxAgeMs) {
|
|
191
|
+
if (!this.activeUrl)
|
|
192
|
+
return null;
|
|
193
|
+
return Date.now() - this.activeUrl.at <= maxAgeMs ? this.activeUrl.url : null;
|
|
194
|
+
}
|
|
157
195
|
handleClose(code) {
|
|
158
196
|
if (this.closed)
|
|
159
197
|
return;
|
|
@@ -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;
|
|
@@ -62,6 +65,12 @@ export declare class BridgeServer {
|
|
|
62
65
|
timeoutMs?: number;
|
|
63
66
|
profile?: string;
|
|
64
67
|
}): Promise<unknown>;
|
|
68
|
+
/**
|
|
69
|
+
* The active tab's URL for `profile` as last reported by the extension, if it
|
|
70
|
+
* is younger than `maxAgeMs`. Null means "ask properly" — an extension too old
|
|
71
|
+
* to report URLs, a tab shuffle since, or simply nothing recent enough.
|
|
72
|
+
*/
|
|
73
|
+
lastActiveUrl(profile: string | undefined, maxAgeMs: number): string | null;
|
|
65
74
|
private noPairMessage;
|
|
66
75
|
status(): {
|
|
67
76
|
extensionConnected: boolean;
|
|
@@ -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);
|
|
@@ -166,6 +178,15 @@ class BridgeServer {
|
|
|
166
178
|
}
|
|
167
179
|
return conn.sendCommand(method, params, opts);
|
|
168
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* The active tab's URL for `profile` as last reported by the extension, if it
|
|
183
|
+
* is younger than `maxAgeMs`. Null means "ask properly" — an extension too old
|
|
184
|
+
* to report URLs, a tab shuffle since, or simply nothing recent enough.
|
|
185
|
+
*/
|
|
186
|
+
lastActiveUrl(profile, maxAgeMs) {
|
|
187
|
+
const conn = this.conns.get(routeKey(profile));
|
|
188
|
+
return conn?.isOpen() ? conn.lastActiveUrl(maxAgeMs) : null;
|
|
189
|
+
}
|
|
169
190
|
noPairMessage(profile) {
|
|
170
191
|
return (`No browser is paired for profile "${profile}". In that Chrome's chrome-mcp ` +
|
|
171
192
|
`extension Options, set Port ${this.boundPort}, paste the token, set Profile to ` +
|
|
@@ -223,7 +244,7 @@ class BridgeServer {
|
|
|
223
244
|
authed = true;
|
|
224
245
|
clearTimeout(helloTimer);
|
|
225
246
|
ws.off('message', onMessage);
|
|
226
|
-
this.promote(ws, frame.ext ?? { id: 'unknown', version: '0', chrome: '0' }, routeKey(frame.profile));
|
|
247
|
+
this.promote(ws, frame.ext ?? { id: 'unknown', version: '0', chrome: '0' }, routeKey(frame.profile), Array.isArray(frame.caps) ? frame.caps : undefined);
|
|
227
248
|
};
|
|
228
249
|
ws.on('message', onMessage);
|
|
229
250
|
ws.on('error', () => {
|
|
@@ -240,7 +261,7 @@ class BridgeServer {
|
|
|
240
261
|
/* ignore */
|
|
241
262
|
}
|
|
242
263
|
}
|
|
243
|
-
promote(ws, ext, profile) {
|
|
264
|
+
promote(ws, ext, profile, caps) {
|
|
244
265
|
const sessionId = (0, node_crypto_1.randomUUID)();
|
|
245
266
|
// Supersede only the SAME profile's connection (a re-pair). Other profiles
|
|
246
267
|
// keep their live connections, so several browsers stay paired at once.
|
|
@@ -263,6 +284,7 @@ class BridgeServer {
|
|
|
263
284
|
extId: ext.id,
|
|
264
285
|
sessionId,
|
|
265
286
|
heartbeatMs: this.heartbeatMs,
|
|
287
|
+
caps,
|
|
266
288
|
onEvent: this.opts.onEvent,
|
|
267
289
|
onLog: (m) => this.log(m),
|
|
268
290
|
onClose: () => {
|
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
|
});
|
|
@@ -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
|
});
|
|
@@ -21,6 +21,9 @@ export declare class ExtensionExecutor implements Executor {
|
|
|
21
21
|
ensureReady(): Promise<void>;
|
|
22
22
|
ping(deadlineMs?: number): Promise<boolean>;
|
|
23
23
|
dispose(): Promise<void>;
|
|
24
|
+
/** The active tab's URL as reported by the last command on this profile, if it
|
|
25
|
+
* is fresh enough to gate against. See `ACTIVE_URL_TTL_MS`. */
|
|
26
|
+
cachedActiveUrl(): string | null;
|
|
24
27
|
tabsList(): Promise<TabInfo[]>;
|
|
25
28
|
tabSelect(tabId: TabId): Promise<TabInfo>;
|
|
26
29
|
tabNew(url?: string, opts?: {
|
|
@@ -12,6 +12,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
12
12
|
exports.ExtensionExecutor = void 0;
|
|
13
13
|
const types_1 = require("./types");
|
|
14
14
|
const workspace_1 = require("../bridge/workspace");
|
|
15
|
+
/**
|
|
16
|
+
* How long a reported active-tab URL stays usable for the policy gate.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately short. It exists to cover back-to-back calls (a `batch`, or an
|
|
19
|
+
* agent's read → click → read), where the tab demonstrably has not changed
|
|
20
|
+
* between them. Past that, pay the round-trip. Note the extension re-gates every
|
|
21
|
+
* command against the tab's live URL regardless, so this window trades a little
|
|
22
|
+
* pre-check precision for half the traffic — never enforcement itself.
|
|
23
|
+
*/
|
|
24
|
+
const ACTIVE_URL_TTL_MS = 1_000;
|
|
15
25
|
/** Flatten a Target into the params a wire command carries. */
|
|
16
26
|
function targetParams(t) {
|
|
17
27
|
if (!t)
|
|
@@ -64,6 +74,11 @@ class ExtensionExecutor {
|
|
|
64
74
|
async dispose() {
|
|
65
75
|
// Never close the user's Chrome.
|
|
66
76
|
}
|
|
77
|
+
/** The active tab's URL as reported by the last command on this profile, if it
|
|
78
|
+
* is fresh enough to gate against. See `ACTIVE_URL_TTL_MS`. */
|
|
79
|
+
cachedActiveUrl() {
|
|
80
|
+
return this.bridge.lastActiveUrl(this.activeProfile(), ACTIVE_URL_TTL_MS);
|
|
81
|
+
}
|
|
67
82
|
// -- tabs ---------------------------------------------------------------
|
|
68
83
|
async tabsList() {
|
|
69
84
|
return (await this.send('tabs_list', {}));
|
|
@@ -13,14 +13,32 @@ export interface StubOptions {
|
|
|
13
13
|
activeUrl?: string;
|
|
14
14
|
/** When true, `eval` resolves `{ok:false}` to mimic a page-side throw. */
|
|
15
15
|
evalThrows?: boolean;
|
|
16
|
+
/** When true, `tabsList` rejects — mimics a transient bridge failure. */
|
|
17
|
+
tabsListThrows?: boolean;
|
|
18
|
+
/** When true, `tabsList` resolves empty — mimics a browser reporting no tabs. */
|
|
19
|
+
noTabs?: boolean;
|
|
20
|
+
/** When true, the (single) tab reports an empty URL — mimics a chrome:// page
|
|
21
|
+
* or a site the extension has no host access to. */
|
|
22
|
+
blankTabUrl?: boolean;
|
|
23
|
+
/** A URL the backend claims to already know, as the extension reports on every
|
|
24
|
+
* result frame. Set it to assert the gate uses it INSTEAD of calling tabsList. */
|
|
25
|
+
cachedUrl?: string;
|
|
16
26
|
}
|
|
17
27
|
export declare class StubExecutor implements Executor {
|
|
18
28
|
readonly backend: BackendKind;
|
|
19
29
|
private url;
|
|
20
30
|
private readonly evalThrows;
|
|
31
|
+
private readonly tabsListThrows;
|
|
32
|
+
private readonly noTabs;
|
|
33
|
+
private readonly blankTabUrl;
|
|
34
|
+
private readonly cached;
|
|
35
|
+
/** How many times the gate actually asked for the tab list — the round-trip
|
|
36
|
+
* counter the caching path exists to keep at zero. */
|
|
37
|
+
tabsListCalls: number;
|
|
21
38
|
private ready;
|
|
22
39
|
constructor(opts?: StubOptions);
|
|
23
40
|
private tab;
|
|
41
|
+
cachedActiveUrl(): string | null;
|
|
24
42
|
status(): ExecutorStatus;
|
|
25
43
|
ensureReady(): Promise<void>;
|
|
26
44
|
ping(): Promise<boolean>;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.StubExecutor = void 0;
|
|
13
|
+
const types_1 = require("./types");
|
|
13
14
|
/** 1×1 transparent PNG, base64 — a valid image block for screenshot tests. */
|
|
14
15
|
const TINY_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
|
15
16
|
const ok = { ok: true };
|
|
@@ -17,13 +18,33 @@ class StubExecutor {
|
|
|
17
18
|
backend = 'extension';
|
|
18
19
|
url;
|
|
19
20
|
evalThrows;
|
|
21
|
+
tabsListThrows;
|
|
22
|
+
noTabs;
|
|
23
|
+
blankTabUrl;
|
|
24
|
+
cached;
|
|
25
|
+
/** How many times the gate actually asked for the tab list — the round-trip
|
|
26
|
+
* counter the caching path exists to keep at zero. */
|
|
27
|
+
tabsListCalls = 0;
|
|
20
28
|
ready = false;
|
|
21
29
|
constructor(opts = {}) {
|
|
22
30
|
this.url = opts.activeUrl ?? 'about:blank';
|
|
23
31
|
this.evalThrows = opts.evalThrows ?? false;
|
|
32
|
+
this.tabsListThrows = opts.tabsListThrows ?? false;
|
|
33
|
+
this.noTabs = opts.noTabs ?? false;
|
|
34
|
+
this.blankTabUrl = opts.blankTabUrl ?? false;
|
|
35
|
+
this.cached = opts.cachedUrl ?? null;
|
|
24
36
|
}
|
|
25
37
|
tab() {
|
|
26
|
-
return {
|
|
38
|
+
return {
|
|
39
|
+
tabId: 'extension:stub:1',
|
|
40
|
+
url: this.blankTabUrl ? '' : this.url,
|
|
41
|
+
title: 'Stub Page',
|
|
42
|
+
active: true,
|
|
43
|
+
index: 0,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
cachedActiveUrl() {
|
|
47
|
+
return this.cached;
|
|
27
48
|
}
|
|
28
49
|
status() {
|
|
29
50
|
return {
|
|
@@ -44,7 +65,10 @@ class StubExecutor {
|
|
|
44
65
|
this.ready = false;
|
|
45
66
|
}
|
|
46
67
|
async tabsList() {
|
|
47
|
-
|
|
68
|
+
this.tabsListCalls++;
|
|
69
|
+
if (this.tabsListThrows)
|
|
70
|
+
throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', 'stub bridge is down');
|
|
71
|
+
return this.noTabs ? [] : [this.tab()];
|
|
48
72
|
}
|
|
49
73
|
async tabSelect(tabId) {
|
|
50
74
|
return { ...this.tab(), tabId };
|
|
@@ -155,6 +155,14 @@ export interface Executor {
|
|
|
155
155
|
ping(deadlineMs?: number): Promise<boolean>;
|
|
156
156
|
/** Close ONLY if we own the browser; never the user's Chrome. */
|
|
157
157
|
dispose(): Promise<void>;
|
|
158
|
+
/**
|
|
159
|
+
* The active tab's URL if the backend ALREADY knows it — the extension reports
|
|
160
|
+
* it on every result frame, so a gate that runs right after a command needs no
|
|
161
|
+
* round-trip. Null means "not known recently enough", and the caller must then
|
|
162
|
+
* resolve it properly; it never returns a guess. Optional: backends that can't
|
|
163
|
+
* report cheaply simply omit it.
|
|
164
|
+
*/
|
|
165
|
+
cachedActiveUrl?(): string | null;
|
|
158
166
|
tabsList(): Promise<TabInfo[]>;
|
|
159
167
|
tabSelect(tabId: TabId): Promise<TabInfo>;
|
|
160
168
|
/** Open a tab. `active` (default true) focuses it; pass false to open in the background. */
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -81,19 +81,60 @@ exports.TOOL_DEFINITIONS = [
|
|
|
81
81
|
},
|
|
82
82
|
},
|
|
83
83
|
];
|
|
84
|
-
|
|
84
|
+
const GATE_CONTEXT = 'cannot resolve the active tab URL for the policy gate';
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the URL the policy should be evaluated against (the active tab).
|
|
87
|
+
*
|
|
88
|
+
* NEVER substitutes a placeholder URL. If the tab list can't be read, the real
|
|
89
|
+
* origin is unknown, and evaluating the policy against a fabricated URL would
|
|
90
|
+
* silently allow or deny against the wrong origin with no signal to the caller.
|
|
91
|
+
* So a `tabsList` failure (or a browser reporting no tabs at all) propagates —
|
|
92
|
+
* the dispatch firewall renders it as a structured error carrying the code.
|
|
93
|
+
*
|
|
94
|
+
* Prefers a URL the backend already reported over asking again: the extension
|
|
95
|
+
* rides the tab's landing URL home on every result frame, which is what keeps a
|
|
96
|
+
* gated call to ONE round-trip instead of two.
|
|
97
|
+
*/
|
|
85
98
|
async function activeUrl(ex) {
|
|
99
|
+
const known = ex.cachedActiveUrl?.();
|
|
100
|
+
if (known)
|
|
101
|
+
return known;
|
|
102
|
+
let tabs;
|
|
86
103
|
try {
|
|
87
|
-
|
|
88
|
-
return tabs.find((t) => t.active)?.url ?? tabs[0]?.url ?? 'about:blank';
|
|
104
|
+
tabs = await ex.tabsList();
|
|
89
105
|
}
|
|
90
|
-
catch {
|
|
91
|
-
|
|
106
|
+
catch (err) {
|
|
107
|
+
// Keep the underlying code (TIMEOUT / EXTENSION_DISCONNECTED / …) so the
|
|
108
|
+
// caller can tell a transient bridge failure from a policy decision — the
|
|
109
|
+
// rendered message is prefixed with it, since only the text crosses MCP.
|
|
110
|
+
if (err instanceof types_1.ExecutorError)
|
|
111
|
+
throw new types_1.ExecutorError(err.code, `${GATE_CONTEXT}: ${err.message}`);
|
|
112
|
+
throw err; // an internal fault, not a browser one — don't relabel it
|
|
92
113
|
}
|
|
114
|
+
const active = tabs.find((t) => t.active) ?? tabs[0];
|
|
115
|
+
if (!active)
|
|
116
|
+
throw new types_1.ExecutorError('TAB_NOT_FOUND', `${GATE_CONTEXT}: the browser reports no open tabs`);
|
|
117
|
+
// An empty URL is Chrome declining to reveal one (a chrome:// page, or a tab
|
|
118
|
+
// the extension has no host access to) — NOT an origin. Gating on '' would
|
|
119
|
+
// produce a baffling "blocked on " denial that reads like a policy decision.
|
|
120
|
+
if (!active.url) {
|
|
121
|
+
throw new types_1.ExecutorError('TAB_NOT_FOUND', `${GATE_CONTEXT}: the active tab (id ${active.tabId}) reports no URL. Chrome hides it for ` +
|
|
122
|
+
`internal pages (chrome://, the Web Store) and until the extension has access to that site — ` +
|
|
123
|
+
`switch to a normal page, or open the target site in a new tab.`);
|
|
124
|
+
}
|
|
125
|
+
return active.url;
|
|
93
126
|
}
|
|
94
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* Policy chokepoint. `urlOverride` is the destination for navigation.
|
|
129
|
+
*
|
|
130
|
+
* Only resolves the active URL for methods whose verdict actually depends on one
|
|
131
|
+
* (`isUrlGated`). Tab management and the capability gates — eval, downloads,
|
|
132
|
+
* uploads, mutations — are decided without any URL, so making them wait on the
|
|
133
|
+
* tab list bought nothing and, worse, made `tab_new` fail exactly when the tab
|
|
134
|
+
* list was unreadable: the one call that could dig you out.
|
|
135
|
+
*/
|
|
95
136
|
async function gate(ctx, method, urlOverride) {
|
|
96
|
-
const url = urlOverride ?? (await activeUrl(ctx.ex));
|
|
137
|
+
const url = urlOverride ?? ((0, policy_1.isUrlGated)(method) ? await activeUrl(ctx.ex) : '');
|
|
97
138
|
(0, policy_1.assertUrlAllowed)(url, method, ctx.policy);
|
|
98
139
|
}
|
|
99
140
|
const tabId = (args) => (0, validators_1.optionalString)(args, 'tabId');
|
|
@@ -349,7 +390,12 @@ exports.TOOL_HANDLERS = {
|
|
|
349
390
|
// Dispatch (never-throw firewall)
|
|
350
391
|
// ---------------------------------------------------------------------------
|
|
351
392
|
function errMessage(err) {
|
|
352
|
-
|
|
393
|
+
// Only the text crosses the MCP boundary, so the code has to travel inside it —
|
|
394
|
+
// otherwise a caller cannot tell EXTENSION_DISCONNECTED (retry in a moment)
|
|
395
|
+
// from POLICY_DENIED (retrying will never help).
|
|
396
|
+
if (err instanceof types_1.ExecutorError)
|
|
397
|
+
return `[${err.code}] ${err.message}`;
|
|
398
|
+
if (err instanceof validators_1.McpToolError)
|
|
353
399
|
return err.message;
|
|
354
400
|
if (err instanceof Error)
|
|
355
401
|
return `internal error: ${err.message}`;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
(() => {
|
|
3
3
|
// shared/protocol.ts
|
|
4
4
|
var PROTOCOL_VERSION = 1;
|
|
5
|
+
var WIRE_CAP_TAB_URL = "tab-url";
|
|
5
6
|
var WIRE_METHODS = [
|
|
6
7
|
"tabs_list",
|
|
7
8
|
"tab_select",
|
|
@@ -63,7 +64,11 @@
|
|
|
63
64
|
v: PROTOCOL_VERSION,
|
|
64
65
|
token,
|
|
65
66
|
ext: { id: chrome.runtime.id, version: chrome.runtime.getManifest().version, chrome: chromeVersion() },
|
|
66
|
-
profile: profile && profile.trim() ? profile.trim() : void 0
|
|
67
|
+
profile: profile && profile.trim() ? profile.trim() : void 0,
|
|
68
|
+
// This build gates fail-closed and reports tab URLs on results, so the
|
|
69
|
+
// server may skip its pre-flight tabs_list. An older build omits this and
|
|
70
|
+
// the server keeps fetching the URL itself.
|
|
71
|
+
caps: [WIRE_CAP_TAB_URL]
|
|
67
72
|
};
|
|
68
73
|
ws2.send(JSON.stringify(hello));
|
|
69
74
|
};
|
|
@@ -494,6 +499,9 @@
|
|
|
494
499
|
const u = cmd.params.url;
|
|
495
500
|
return typeof u === "string" ? u : "";
|
|
496
501
|
}
|
|
502
|
+
return observedTabUrl(cmd);
|
|
503
|
+
}
|
|
504
|
+
async function observedTabUrl(cmd) {
|
|
497
505
|
try {
|
|
498
506
|
const tabId = await targetTab(cmd);
|
|
499
507
|
const t = await chrome.tabs.get(tabId);
|
|
@@ -1124,6 +1132,12 @@
|
|
|
1124
1132
|
async dispatch(cmd) {
|
|
1125
1133
|
try {
|
|
1126
1134
|
const policy = this.deps.getPolicy();
|
|
1135
|
+
if (!policy && cmd.method !== "ping_probe") {
|
|
1136
|
+
throw new CmdError(
|
|
1137
|
+
"POLICY_DENIED",
|
|
1138
|
+
"no policy has arrived from the chrome-mcp server yet, so this extension is refusing every command"
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1127
1141
|
if (policy) {
|
|
1128
1142
|
const url = isUrlGated(cmd.method) ? await urlForCommand(cmd) : "";
|
|
1129
1143
|
const verdict = evaluatePolicy(url, cmd.method, policy);
|
|
@@ -1131,6 +1145,8 @@
|
|
|
1131
1145
|
}
|
|
1132
1146
|
const data = await this.deps.exec.run(cmd);
|
|
1133
1147
|
const frame = { type: "result", v: PROTOCOL_VERSION, id: cmd.id, ok: true, data };
|
|
1148
|
+
const tabUrl = await observedTabUrl(cmd);
|
|
1149
|
+
if (tabUrl) frame.tabUrl = tabUrl;
|
|
1134
1150
|
this.deps.send(frame);
|
|
1135
1151
|
} catch (err) {
|
|
1136
1152
|
const code = err instanceof CmdError ? err.code : "CDP_ERROR";
|
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.5",
|
|
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",
|
|
@@ -32,12 +32,20 @@
|
|
|
32
32
|
"keywords": [
|
|
33
33
|
"mcp",
|
|
34
34
|
"model-context-protocol",
|
|
35
|
+
"mcp-server",
|
|
35
36
|
"chrome",
|
|
36
37
|
"chrome-extension",
|
|
37
38
|
"browser-automation",
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
39
|
+
"browser-mcp",
|
|
40
|
+
"real-browser",
|
|
41
|
+
"logged-in",
|
|
42
|
+
"session",
|
|
43
|
+
"cookies",
|
|
44
|
+
"authenticated",
|
|
45
|
+
"web-scraping",
|
|
46
|
+
"ai-agent",
|
|
47
|
+
"claude",
|
|
48
|
+
"claude-code"
|
|
41
49
|
],
|
|
42
50
|
"scripts": {
|
|
43
51
|
"build": "tsc -p tsconfig.json",
|
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`,
|