@mehmoodqureshi/chrome-mcp 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -9
- package/SETUP.md +189 -0
- package/dist/src/bridge/auth.d.ts +14 -0
- package/dist/src/bridge/auth.js +36 -0
- package/dist/src/cli.js +22 -0
- package/dist/src/config.d.ts +3 -1
- package/dist/src/config.js +9 -1
- package/dist/src/executor/select.d.ts +1 -1
- package/dist/src/executor/select.js +16 -2
- package/extension-dist/background.js +27 -2
- package/extension-dist/manifest.json +1 -1
- package/extension-dist/options.html +3 -2
- package/extension-dist/options.js +9 -2
- package/package.json +5 -5
- package/scripts/postinstall.js +0 -56
package/README.md
CHANGED
|
@@ -29,6 +29,21 @@ Distributed as an `npx` CLI (the MCP server) plus a load-unpacked extension.
|
|
|
29
29
|
|
|
30
30
|
## Quickstart
|
|
31
31
|
|
|
32
|
+
### Up and running in one paste
|
|
33
|
+
|
|
34
|
+
Hand this to your AI agent (Claude Code, Cursor, Windsurf, anything MCP) and it
|
|
35
|
+
installs the server, wires it into the client, and walks you through the two
|
|
36
|
+
steps that must happen inside Chrome:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
Set up chrome-mcp on this machine by fetching and following
|
|
40
|
+
https://raw.githubusercontent.com/Mehmoodqureshi/chrome-mcp/main/SETUP.md
|
|
41
|
+
exactly, step by step. Work autonomously and verify each step.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Prefer to read before you run an agent on your machine? [`SETUP.md`](SETUP.md)
|
|
45
|
+
is the exact file the agent follows. The manual steps are below.
|
|
46
|
+
|
|
32
47
|
**1. Register the MCP server** with your host.
|
|
33
48
|
|
|
34
49
|
<details open>
|
|
@@ -98,16 +113,24 @@ compile. Install globally to get a stable path to it:
|
|
|
98
113
|
|
|
99
114
|
```bash
|
|
100
115
|
npm install -g @mehmoodqureshi/chrome-mcp
|
|
101
|
-
|
|
116
|
+
chrome-mcp --extension-path # prints the absolute path of extension-dist
|
|
102
117
|
```
|
|
103
118
|
|
|
104
119
|
Then `chrome://extensions` → enable **Developer mode** → **Load unpacked** →
|
|
105
120
|
select that `extension-dist/` directory. (Working from a git clone instead? Run
|
|
106
121
|
`npm install && npm run build:ext` first — `extension-dist/` is gitignored.)
|
|
107
122
|
|
|
108
|
-
**3. Pair it
|
|
109
|
-
|
|
110
|
-
`
|
|
123
|
+
**3. Pair it — usually nothing to do.** Every time the server boots it writes
|
|
124
|
+
`pairing.json` (mode 0600, never shipped in the tarball) into the very
|
|
125
|
+
`extension-dist/` folder you just loaded. The extension reads that file from its
|
|
126
|
+
own folder on startup and pairs itself, so the toolbar badge turns green with no
|
|
127
|
+
token to paste. Load the extension before the server has ever run? It re-checks
|
|
128
|
+
every 30 seconds and pairs as soon as the file appears.
|
|
129
|
+
|
|
130
|
+
Manual fallback (a copied folder, a read-only install): run
|
|
131
|
+
`npx chrome-mcp --print-pairing`, open the extension's **Options** page, and
|
|
132
|
+
paste the `port` + `token` from `~/.chrome-mcp/handshake.json`. Values saved
|
|
133
|
+
there take precedence over the bundled file.
|
|
111
134
|
|
|
112
135
|
### Running more than one session
|
|
113
136
|
|
|
@@ -155,7 +178,7 @@ start. Wrap it in `cmd /c`:
|
|
|
155
178
|
|
|
156
179
|
Or from Claude Code: `claude mcp add chrome-mcp -- cmd /c npx -y @mehmoodqureshi/chrome-mcp --allow-domain example.com`
|
|
157
180
|
|
|
158
|
-
Everything else is the same — load
|
|
181
|
+
Everything else is the same — load the folder `chrome-mcp --extension-path` prints and pair
|
|
159
182
|
as above.
|
|
160
183
|
|
|
161
184
|
The tools cover tabs, navigation, interaction (`click`/`type`/`press`/`hover`/
|
|
@@ -400,10 +423,10 @@ RUN_EXT_SMOKE=1 node --test dist/test/extension-smoke.test.js # live, headed
|
|
|
400
423
|
## The extension
|
|
401
424
|
|
|
402
425
|
`extension/` builds (esbuild) to `extension-dist/`, loaded via
|
|
403
|
-
`chrome://extensions` → **Load unpacked** → select `extension-dist/`.
|
|
404
|
-
from the
|
|
405
|
-
`~/.chrome-mcp/handshake.json`
|
|
406
|
-
path).
|
|
426
|
+
`chrome://extensions` → **Load unpacked** → select `extension-dist/`. It pairs
|
|
427
|
+
itself from the `pairing.json` the server writes into that folder on boot; the
|
|
428
|
+
**Options** page paste of `port` + `token` from `~/.chrome-mcp/handshake.json`
|
|
429
|
+
(run `npx chrome-mcp --print-pairing` to get the path) is only the fallback.
|
|
407
430
|
|
|
408
431
|
> **Reads/interaction use `chrome.scripting`/`chrome.tabs`** — no "is being
|
|
409
432
|
> debugged" banner, CSP-safe reads (isolated world), testable under Playwright.
|
package/SETUP.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# chrome-mcp — agent setup instructions
|
|
2
|
+
|
|
3
|
+
**You are an AI agent. Follow these steps to set up chrome-mcp end-to-end on this
|
|
4
|
+
machine and prove it works.** chrome-mcp lets an MCP client drive the human's
|
|
5
|
+
real Chrome browser: real logins, real cookies, no separate Chromium. It has two
|
|
6
|
+
halves that must be paired: a stdio MCP server (npm package
|
|
7
|
+
`@mehmoodqureshi/chrome-mcp`) and a Manifest V3 extension that ships inside the
|
|
8
|
+
same package. Work autonomously; pause only for the two actions that must happen
|
|
9
|
+
inside Chrome's own UI (loading the extension, pasting the pairing token).
|
|
10
|
+
|
|
11
|
+
## Ground rules
|
|
12
|
+
|
|
13
|
+
- Verify each step's result before moving on. If a command fails, diagnose and
|
|
14
|
+
fix it. Do not proceed on a broken step.
|
|
15
|
+
- The server is **deny-all by default**: no domains, no clicks, no eval, no
|
|
16
|
+
downloads, no uploads. Ask the human which domains they want to automate and
|
|
17
|
+
grant only those with `--allow-domain`. Never reach for `--unsafe-all-domains`
|
|
18
|
+
or `--unsafe-enable-eval` unless the human asks for them by name.
|
|
19
|
+
- Never print or log the pairing token. It lives in a 0600 file; show the human
|
|
20
|
+
its path, not its contents.
|
|
21
|
+
- Requires Node 18 or newer (`node --version`) and Chrome 116 or newer.
|
|
22
|
+
- On Windows, MCP hosts must launch the server through `cmd /c npx ...`. See
|
|
23
|
+
step 3.
|
|
24
|
+
|
|
25
|
+
## Step 1 — Confirm the environment
|
|
26
|
+
|
|
27
|
+
Run and record the output:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
node --version
|
|
31
|
+
npm --version
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
If Node is below 18, stop and tell the human to upgrade before anything else.
|
|
35
|
+
|
|
36
|
+
## Step 2 — Install the package globally
|
|
37
|
+
|
|
38
|
+
A global install gives the extension a stable path that Chrome can load from.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
npm install -g @mehmoodqureshi/chrome-mcp
|
|
42
|
+
chrome-mcp --version
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Verify: the second command prints a version. Then locate the extension folder:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
chrome-mcp --extension-path
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
It prints the absolute path of the bundled `extension-dist` folder. Verify with
|
|
52
|
+
`ls` (or `dir`) that the folder holds `manifest.json` and `background.js`.
|
|
53
|
+
Record the absolute path; the human needs it in step 4. The install is small
|
|
54
|
+
and downloads no browser: the server drives the Chrome the human already has.
|
|
55
|
+
|
|
56
|
+
## Step 3 — Wire the server into this MCP client
|
|
57
|
+
|
|
58
|
+
Ask the human which domains they want to automate. Build the argument list from
|
|
59
|
+
their answer: one `--allow-domain <glob>` per domain, plus `--enable-mutations`
|
|
60
|
+
if they want the agent to click, type, and navigate (almost always yes). Add
|
|
61
|
+
`--persist-token` so the pairing survives restarts. Leave every other gate off
|
|
62
|
+
unless they ask.
|
|
63
|
+
|
|
64
|
+
**Claude Code** (registers for every project on this machine):
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
claude mcp add chrome-mcp -s user -- \
|
|
68
|
+
npx -y @mehmoodqureshi/chrome-mcp \
|
|
69
|
+
--allow-domain example.com --enable-mutations --persist-token
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Everything before `--` belongs to Claude Code. Everything after it is the
|
|
73
|
+
server's own command line. Keep the `--` or `--allow-domain` gets read as a
|
|
74
|
+
Claude Code option. Verify with `claude mcp list`: chrome-mcp should be listed.
|
|
75
|
+
It will show as connected once the server boots, even before the extension has
|
|
76
|
+
paired.
|
|
77
|
+
|
|
78
|
+
**Claude Desktop, Cursor, Windsurf, VS Code, and other JSON-configured hosts:**
|
|
79
|
+
add this to the host's MCP config file (the exact path differs per client; VS
|
|
80
|
+
Code uses a top-level `servers` key instead of `mcpServers`).
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"mcpServers": {
|
|
85
|
+
"chrome-mcp": {
|
|
86
|
+
"command": "npx",
|
|
87
|
+
"args": ["-y", "@mehmoodqureshi/chrome-mcp",
|
|
88
|
+
"--allow-domain", "example.com",
|
|
89
|
+
"--enable-mutations",
|
|
90
|
+
"--persist-token"]
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Windows:** the command must be `cmd` with args `["/c", "npx", "-y",
|
|
97
|
+
"@mehmoodqureshi/chrome-mcp", ...]`, or for Claude Code:
|
|
98
|
+
`claude mcp add chrome-mcp -s user -- cmd /c npx -y @mehmoodqureshi/chrome-mcp --allow-domain example.com --enable-mutations --persist-token`.
|
|
99
|
+
|
|
100
|
+
Tell the human to restart the client (or run `/mcp` in Claude Code) so the
|
|
101
|
+
server loads. The first boot writes `~/.chrome-mcp/handshake.json` (mode 0600)
|
|
102
|
+
holding the bridge port and the pairing token, and with `--persist-token` it
|
|
103
|
+
also stores the token at `~/.chrome-mcp/token` for reuse.
|
|
104
|
+
|
|
105
|
+
## Step 4 — Load the extension (human action inside Chrome)
|
|
106
|
+
|
|
107
|
+
You cannot do this step yourself. Give the human these exact instructions:
|
|
108
|
+
|
|
109
|
+
1. Open `chrome://extensions` in Chrome.
|
|
110
|
+
2. Turn on **Developer mode** (top right).
|
|
111
|
+
3. Click **Load unpacked** and choose the `extension-dist` folder from step 2.
|
|
112
|
+
4. Confirm an extension named **Chrome MCP Bridge** now appears in the list.
|
|
113
|
+
|
|
114
|
+
Wait for the human to confirm before continuing.
|
|
115
|
+
|
|
116
|
+
## Step 5 — Pairing (automatic; verify it)
|
|
117
|
+
|
|
118
|
+
Pairing needs no paste. Every time the server boots it writes `pairing.json`
|
|
119
|
+
(mode 0600) into the same `extension-dist` folder the human loaded in step 4.
|
|
120
|
+
The extension reads that file from its own folder and pairs itself. So make
|
|
121
|
+
sure a server has booted at least once since step 2, in either of these ways:
|
|
122
|
+
|
|
123
|
+
- the client started one in step 3 (a restart or `/mcp` reconnect is enough), or
|
|
124
|
+
- start one yourself in pairing mode and leave it running in the background:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
chrome-mcp --print-pairing --persist-token
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Verify the file exists (do not print it):
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
ls "$(chrome-mcp --extension-path)/pairing.json"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Then ask the human to look at the extension's toolbar icon on
|
|
137
|
+
`chrome://extensions` or in the toolbar: a green dot badge means paired and
|
|
138
|
+
connected. It can take up to 30 seconds if the extension was loaded before the
|
|
139
|
+
server first ran. Wait for the human to confirm.
|
|
140
|
+
|
|
141
|
+
**Manual fallback**, only if the badge stays grey after a minute (a copied
|
|
142
|
+
extension folder or a read-only global install): read the port without
|
|
143
|
+
printing the token,
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
node -e "console.log(require(require('os').homedir()+'/.chrome-mcp/handshake.json').port)"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
then have the human open **Details** on Chrome MCP Bridge, then **Extension
|
|
150
|
+
options**, enter that **Port**, open `~/.chrome-mcp/handshake.json` in a text
|
|
151
|
+
editor and paste its `token` into the **Token** field, leave **Profile** as
|
|
152
|
+
`default`, and click **Save**. The status line should read **connected** within
|
|
153
|
+
a few seconds. Values saved by hand take precedence over the bundled file.
|
|
154
|
+
|
|
155
|
+
If you started a pairing-mode server in this step, stop it now with Ctrl-C. The
|
|
156
|
+
client's own server takes the port over on its next boot, and with
|
|
157
|
+
`--persist-token` the extension reconnects to it with no further pairing.
|
|
158
|
+
|
|
159
|
+
## Step 6 — Prove the chain end-to-end
|
|
160
|
+
|
|
161
|
+
From the restarted client session, call the MCP tools in this order and check
|
|
162
|
+
each result:
|
|
163
|
+
|
|
164
|
+
1. `chrome_status` — reports the backend as the extension and the session as
|
|
165
|
+
connected.
|
|
166
|
+
2. `tabs_list` — returns at least one tab from the human's real Chrome.
|
|
167
|
+
3. `navigate` to a URL on one of the allowlisted domains, then `snapshot` —
|
|
168
|
+
returns interactive elements with `ref` ids.
|
|
169
|
+
4. `navigate` to a domain that is **not** allowlisted — must be refused with a
|
|
170
|
+
policy error. This confirms deny-all is working, which matters more than the
|
|
171
|
+
happy path.
|
|
172
|
+
|
|
173
|
+
If `chrome_status` shows the extension disconnected, the pairing in step 5 did
|
|
174
|
+
not stick. Re-check the port and token before anything else.
|
|
175
|
+
|
|
176
|
+
## Step 7 — Report
|
|
177
|
+
|
|
178
|
+
Summarize for the human:
|
|
179
|
+
|
|
180
|
+
- Node and package versions installed.
|
|
181
|
+
- The absolute `extension-dist` path they loaded.
|
|
182
|
+
- Which MCP client was configured, at which scope, with which domains and gates.
|
|
183
|
+
- Whether pairing, `tabs_list`, an allowed navigation, and a refused navigation
|
|
184
|
+
each verified green.
|
|
185
|
+
- Anything still open on their side, such as restarting the client.
|
|
186
|
+
|
|
187
|
+
For any failure, name the exact symptom, what you tried, and the matching
|
|
188
|
+
section of the README's troubleshooting notes at
|
|
189
|
+
`https://github.com/Mehmoodqureshi/chrome-mcp#readme`.
|
|
@@ -42,6 +42,20 @@ export interface WriteHandshakeFields {
|
|
|
42
42
|
token: string;
|
|
43
43
|
expectedExtensionId?: string;
|
|
44
44
|
}
|
|
45
|
+
/** Name of the auto-pairing file the server drops into its bundled extension folder. */
|
|
46
|
+
export declare const BUNDLED_PAIRING_FILE = "pairing.json";
|
|
47
|
+
/**
|
|
48
|
+
* Write `<extDir>/pairing.json` so an extension loaded unpacked from that very
|
|
49
|
+
* folder can pair itself: its service worker fetches the file from its own
|
|
50
|
+
* package and adopts the port + token with no Options-page paste. Same secret,
|
|
51
|
+
* same 0600 mode, same trust boundary as the handshake (only this user can read
|
|
52
|
+
* it, and the file is neither web-accessible nor shipped in the npm tarball).
|
|
53
|
+
*
|
|
54
|
+
* Best-effort: returns the path on success, or null when the folder is missing
|
|
55
|
+
* or read-only (a locked-down global install). Never throws — manual pairing
|
|
56
|
+
* still works without it.
|
|
57
|
+
*/
|
|
58
|
+
export declare function writeBundledPairing(extDir: string, fields: WriteHandshakeFields): string | null;
|
|
45
59
|
/**
|
|
46
60
|
* Atomically write the handshake at 0600 and verify the mode. Throws (fail
|
|
47
61
|
* closed) if the file ends up group/other-readable — the token is the entire
|
package/dist/src/bridge/auth.js
CHANGED
|
@@ -14,12 +14,14 @@
|
|
|
14
14
|
* - The token is NEVER written to stdout/stderr or any log (a test asserts it).
|
|
15
15
|
*/
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.BUNDLED_PAIRING_FILE = void 0;
|
|
17
18
|
exports.generateToken = generateToken;
|
|
18
19
|
exports.tokenPath = tokenPath;
|
|
19
20
|
exports.readPersistedToken = readPersistedToken;
|
|
20
21
|
exports.writePersistedToken = writePersistedToken;
|
|
21
22
|
exports.resolveToken = resolveToken;
|
|
22
23
|
exports.tokensMatch = tokensMatch;
|
|
24
|
+
exports.writeBundledPairing = writeBundledPairing;
|
|
23
25
|
exports.writeHandshake = writeHandshake;
|
|
24
26
|
exports.readHandshake = readHandshake;
|
|
25
27
|
exports.removeHandshake = removeHandshake;
|
|
@@ -110,6 +112,40 @@ function tokensMatch(a, b) {
|
|
|
110
112
|
const hb = (0, node_crypto_1.createHash)('sha256').update(b, 'utf8').digest();
|
|
111
113
|
return (0, node_crypto_1.timingSafeEqual)(ha, hb);
|
|
112
114
|
}
|
|
115
|
+
/** Name of the auto-pairing file the server drops into its bundled extension folder. */
|
|
116
|
+
exports.BUNDLED_PAIRING_FILE = 'pairing.json';
|
|
117
|
+
/**
|
|
118
|
+
* Write `<extDir>/pairing.json` so an extension loaded unpacked from that very
|
|
119
|
+
* folder can pair itself: its service worker fetches the file from its own
|
|
120
|
+
* package and adopts the port + token with no Options-page paste. Same secret,
|
|
121
|
+
* same 0600 mode, same trust boundary as the handshake (only this user can read
|
|
122
|
+
* it, and the file is neither web-accessible nor shipped in the npm tarball).
|
|
123
|
+
*
|
|
124
|
+
* Best-effort: returns the path on success, or null when the folder is missing
|
|
125
|
+
* or read-only (a locked-down global install). Never throws — manual pairing
|
|
126
|
+
* still works without it.
|
|
127
|
+
*/
|
|
128
|
+
function writeBundledPairing(extDir, fields) {
|
|
129
|
+
const path = (0, node_path_1.join)(extDir, exports.BUNDLED_PAIRING_FILE);
|
|
130
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
131
|
+
const payload = { v: protocol_1.PROTOCOL_VERSION, port: fields.port, token: fields.token, ts: Date.now() };
|
|
132
|
+
try {
|
|
133
|
+
(0, node_fs_1.writeFileSync)(tmp, JSON.stringify(payload), { mode: 0o600 });
|
|
134
|
+
(0, node_fs_1.chmodSync)(tmp, 0o600);
|
|
135
|
+
(0, node_fs_1.renameSync)(tmp, path);
|
|
136
|
+
(0, node_fs_1.chmodSync)(path, 0o600);
|
|
137
|
+
return path;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
try {
|
|
141
|
+
(0, node_fs_1.unlinkSync)(tmp);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* nothing to clean */
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
113
149
|
/**
|
|
114
150
|
* Atomically write the handshake at 0600 and verify the mode. Throws (fail
|
|
115
151
|
* closed) if the file ends up group/other-readable — the token is the entire
|
package/dist/src/cli.js
CHANGED
|
@@ -51,6 +51,14 @@ function version() {
|
|
|
51
51
|
return '0.0.0';
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Absolute path of the bundled extension. `dist/src/cli.js` -> `<pkg>/extension-dist`,
|
|
56
|
+
* which `files` in package.json ships in the tarball, so this is right for a global
|
|
57
|
+
* install, an npx cache entry, and a git checkout alike.
|
|
58
|
+
*/
|
|
59
|
+
function extensionPath() {
|
|
60
|
+
return (0, node_path_1.resolve)(__dirname, '..', '..', 'extension-dist');
|
|
61
|
+
}
|
|
54
62
|
/** Render a byte count as a short human string (1.2 MB, 904 KB, …). */
|
|
55
63
|
function humanBytes(n) {
|
|
56
64
|
if (n < 1024)
|
|
@@ -154,6 +162,10 @@ async function main() {
|
|
|
154
162
|
process.stdout.write(`${version()}\n`);
|
|
155
163
|
return;
|
|
156
164
|
}
|
|
165
|
+
if (cfg.showExtensionPath) {
|
|
166
|
+
process.stdout.write(`${extensionPath()}\n`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
157
169
|
const dataDir = (0, datadir_1.ensureDataDir)(cfg.dataDir);
|
|
158
170
|
const token = (0, auth_1.resolveToken)(dataDir, { persist: cfg.persistToken });
|
|
159
171
|
const { allowDomains, allowEval, allowDownloads, allowUploads, allowAllTabs, enableMutations } = cfg.policy;
|
|
@@ -179,6 +191,16 @@ async function main() {
|
|
|
179
191
|
})}`);
|
|
180
192
|
const handshakePath = (0, auth_1.writeHandshake)(dataDir, { port, token });
|
|
181
193
|
(0, server_2.logErr)(`pairing handshake written to ${handshakePath} (mode 0600; token not logged)`);
|
|
194
|
+
// Drop the same port + token into the bundled extension folder so a Load
|
|
195
|
+
// unpacked from there pairs itself. Best-effort: a read-only install just
|
|
196
|
+
// falls back to the Options-page paste.
|
|
197
|
+
const bundled = (0, auth_1.writeBundledPairing)(extensionPath(), { port, token });
|
|
198
|
+
if (bundled) {
|
|
199
|
+
(0, server_2.logErr)(`auto-pairing file written to ${bundled} — Load unpacked from that folder needs no token paste`);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
(0, server_2.logDebug)(`auto-pairing file not written (extension folder missing or read-only at ${extensionPath()})`);
|
|
203
|
+
}
|
|
182
204
|
if (process.env.CHROME_MCP_TOKEN) {
|
|
183
205
|
(0, server_2.logErr)('token: pinned from CHROME_MCP_TOKEN (stable; pair once, never again).');
|
|
184
206
|
}
|
package/dist/src/config.d.ts
CHANGED
|
@@ -28,8 +28,10 @@ export interface CliConfig {
|
|
|
28
28
|
prefer: BackendPreference;
|
|
29
29
|
/** Run the CDP-fallback Chromium headless. */
|
|
30
30
|
headless: boolean;
|
|
31
|
-
/** `--print-pairing`: write the handshake
|
|
31
|
+
/** `--print-pairing`: write the handshake, print its path (never the token), and keep the bridge up until Ctrl-C. */
|
|
32
32
|
printPairing: boolean;
|
|
33
|
+
/** `--extension-path`: print the absolute path of the bundled extension (for Load unpacked) and exit. */
|
|
34
|
+
showExtensionPath: boolean;
|
|
33
35
|
/** `--persist-token`: reuse a stable on-disk token so the extension never re-pairs. */
|
|
34
36
|
persistToken: boolean;
|
|
35
37
|
showHelp: boolean;
|
package/dist/src/config.js
CHANGED
|
@@ -80,6 +80,7 @@ function parseArgs(argv) {
|
|
|
80
80
|
let persistToken = false;
|
|
81
81
|
let showHelp = false;
|
|
82
82
|
let showVersion = false;
|
|
83
|
+
let showExtensionPath = false;
|
|
83
84
|
let logLevel = 'info';
|
|
84
85
|
// Policy assembled from flags, layered over an optional file.
|
|
85
86
|
let policyFile;
|
|
@@ -95,6 +96,9 @@ function parseArgs(argv) {
|
|
|
95
96
|
case '--version':
|
|
96
97
|
showVersion = true;
|
|
97
98
|
break;
|
|
99
|
+
case '--extension-path':
|
|
100
|
+
showExtensionPath = true;
|
|
101
|
+
break;
|
|
98
102
|
case '--port':
|
|
99
103
|
wsPort = requireInt(argv[++i], '--port');
|
|
100
104
|
break;
|
|
@@ -214,6 +218,7 @@ function parseArgs(argv) {
|
|
|
214
218
|
persistToken,
|
|
215
219
|
showHelp,
|
|
216
220
|
showVersion,
|
|
221
|
+
showExtensionPath,
|
|
217
222
|
logLevel,
|
|
218
223
|
};
|
|
219
224
|
}
|
|
@@ -267,7 +272,10 @@ Connection:
|
|
|
267
272
|
the extension Options; tools route to the active profile.
|
|
268
273
|
--task <name> Task label (default "default"). Downloads and a meta.json
|
|
269
274
|
land in profiles/<profile>/tasks/<task>/.
|
|
270
|
-
--print-pairing Write the handshake
|
|
275
|
+
--print-pairing Write the handshake, print its path, and keep the bridge
|
|
276
|
+
up until Ctrl-C (manual pairing; never serves MCP)
|
|
277
|
+
--extension-path Print the absolute path of the bundled extension folder
|
|
278
|
+
(what to pick in chrome://extensions -> Load unpacked)
|
|
271
279
|
--persist-token Reuse a stable on-disk token across restarts so the
|
|
272
280
|
extension never has to re-pair (default: fresh per boot).
|
|
273
281
|
CHROME_MCP_TOKEN env, if set, pins the token explicitly.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* extension and CDP executors are cached across calls; only the choice is live.
|
|
8
8
|
*/
|
|
9
9
|
import { type Executor } from './types';
|
|
10
|
-
import {
|
|
10
|
+
import type { CdpOptions } from './cdp-executor';
|
|
11
11
|
import type { BridgeServer } from '../bridge/server';
|
|
12
12
|
import type { BackendPreference } from '../config';
|
|
13
13
|
export interface SelectorDeps {
|
|
@@ -11,10 +11,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
11
11
|
exports.createSelector = createSelector;
|
|
12
12
|
const types_1 = require("./types");
|
|
13
13
|
const extension_executor_1 = require("./extension-executor");
|
|
14
|
-
const cdp_executor_1 = require("./cdp-executor");
|
|
15
14
|
function createSelector(deps) {
|
|
16
15
|
const makeExt = deps.makeExtension ?? ((b) => new extension_executor_1.ExtensionExecutor(b));
|
|
17
|
-
const makeCdp = deps.makeCdp ??
|
|
16
|
+
const makeCdp = deps.makeCdp ??
|
|
17
|
+
((o) => {
|
|
18
|
+
// Loaded lazily: playwright is a devDependency (tests + HITL only). The
|
|
19
|
+
// published CLI is extension-only, so a fresh install never pulls a
|
|
20
|
+
// browser. Reaching this without playwright present is a config error.
|
|
21
|
+
let mod;
|
|
22
|
+
try {
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
24
|
+
mod = require('./cdp-executor');
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
const why = e instanceof Error ? e.message : String(e);
|
|
28
|
+
throw new types_1.ExecutorError('NO_BACKEND', `CDP backend requested but this build is extension-only (playwright is not installed: ${why}). Pair the extension instead.`);
|
|
29
|
+
}
|
|
30
|
+
return new mod.CdpExecutor(o);
|
|
31
|
+
});
|
|
18
32
|
const pingMs = deps.pingDeadlineMs ?? 800;
|
|
19
33
|
const pingCacheMs = deps.pingCacheMs ?? 2_000;
|
|
20
34
|
const ext = makeExt(deps.bridge);
|
|
@@ -1572,7 +1572,10 @@
|
|
|
1572
1572
|
var executor = new ChromeExecutor(() => currentPolicy);
|
|
1573
1573
|
var ws = new WsClient({
|
|
1574
1574
|
onCommand: (cmd) => void router.dispatch(cmd),
|
|
1575
|
-
onState: (state) =>
|
|
1575
|
+
onState: (state) => {
|
|
1576
|
+
void persistState(state);
|
|
1577
|
+
if (state === "unauthorized") void adoptBundledPairing();
|
|
1578
|
+
},
|
|
1576
1579
|
onPolicy: (policy) => {
|
|
1577
1580
|
currentPolicy = policy;
|
|
1578
1581
|
void syncObserverScript(policy, (m) => console.debug("[chrome-mcp]", m));
|
|
@@ -1585,6 +1588,27 @@
|
|
|
1585
1588
|
getPolicy: () => currentPolicy,
|
|
1586
1589
|
log: (m) => console.debug("[chrome-mcp]", m)
|
|
1587
1590
|
});
|
|
1591
|
+
async function readBundledPairing() {
|
|
1592
|
+
try {
|
|
1593
|
+
const res = await fetch(chrome.runtime.getURL("pairing.json"), { cache: "no-store" });
|
|
1594
|
+
if (!res.ok) return null;
|
|
1595
|
+
const j = await res.json();
|
|
1596
|
+
if (typeof j.port === "number" && j.port > 0 && typeof j.token === "string" && j.token.length > 0) {
|
|
1597
|
+
return { wsPort: j.port, token: j.token };
|
|
1598
|
+
}
|
|
1599
|
+
} catch {
|
|
1600
|
+
}
|
|
1601
|
+
return null;
|
|
1602
|
+
}
|
|
1603
|
+
async function adoptBundledPairing() {
|
|
1604
|
+
const { wsPort, token, pairingSource } = await chrome.storage.local.get(["wsPort", "token", "pairingSource"]);
|
|
1605
|
+
if (pairingSource === "manual") return false;
|
|
1606
|
+
const bundled = await readBundledPairing();
|
|
1607
|
+
if (!bundled) return false;
|
|
1608
|
+
if (bundled.wsPort === wsPort && bundled.token === token) return false;
|
|
1609
|
+
await chrome.storage.local.set({ wsPort: bundled.wsPort, token: bundled.token, pairingSource: "auto" });
|
|
1610
|
+
return true;
|
|
1611
|
+
}
|
|
1588
1612
|
async function getConfig() {
|
|
1589
1613
|
const { wsPort, token, profile } = await chrome.storage.local.get(["wsPort", "token", "profile"]);
|
|
1590
1614
|
if (typeof wsPort === "number" && wsPort > 0 && typeof token === "string" && token.length > 0) {
|
|
@@ -1620,6 +1644,7 @@
|
|
|
1620
1644
|
}
|
|
1621
1645
|
async function keepalivePulse() {
|
|
1622
1646
|
await chrome.storage.local.get("connState");
|
|
1647
|
+
if (!ws.isConnected() && !await getConfig()) await adoptBundledPairing();
|
|
1623
1648
|
await ensureConnected();
|
|
1624
1649
|
}
|
|
1625
1650
|
chrome.runtime.onInstalled.addListener(() => void bootstrap());
|
|
@@ -1643,7 +1668,7 @@
|
|
|
1643
1668
|
});
|
|
1644
1669
|
async function bootstrap() {
|
|
1645
1670
|
await chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.5 });
|
|
1646
|
-
await ensureConnected();
|
|
1671
|
+
if (!await adoptBundledPairing()) await ensureConnected();
|
|
1647
1672
|
}
|
|
1648
1673
|
void bootstrap();
|
|
1649
1674
|
})();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Chrome MCP Bridge",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"description": "Lets a local chrome-mcp server drive this browser. Pair it with the server's handshake token.",
|
|
6
6
|
"minimum_chrome_version": "116",
|
|
7
7
|
"background": {
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<h1>Pair with chrome-mcp</h1>
|
|
19
|
+
<p class="hint" id="source">Checking pairing source…</p>
|
|
19
20
|
<p class="hint">
|
|
20
|
-
|
|
21
|
-
<code>handshake.json</code>, and paste its <code>port</code> and
|
|
21
|
+
Manual pairing: run <code>npx chrome-mcp --print-pairing</code>, open the
|
|
22
|
+
printed <code>handshake.json</code>, and paste its <code>port</code> and
|
|
22
23
|
<code>token</code> below.
|
|
23
24
|
</p>
|
|
24
25
|
<label for="port">Port</label>
|
|
@@ -9,8 +9,15 @@
|
|
|
9
9
|
var profileEl = document.getElementById("profile");
|
|
10
10
|
var saveEl = document.getElementById("save");
|
|
11
11
|
var statusEl = document.getElementById("status");
|
|
12
|
+
var sourceEl = document.getElementById("source");
|
|
12
13
|
async function loadExisting() {
|
|
13
|
-
const { wsPort, profile, connState } = await chrome.storage.local.get([
|
|
14
|
+
const { wsPort, profile, connState, pairingSource } = await chrome.storage.local.get([
|
|
15
|
+
"wsPort",
|
|
16
|
+
"profile",
|
|
17
|
+
"connState",
|
|
18
|
+
"pairingSource"
|
|
19
|
+
]);
|
|
20
|
+
sourceEl.textContent = pairingSource === "auto" ? "Paired automatically from the pairing.json the server wrote into this extension folder. Saving here overrides it." : pairingSource === "manual" ? "Paired by hand. Saved values take precedence over the bundled pairing.json." : "Not paired yet. If you loaded this extension from the chrome-mcp package folder, start the server once and it pairs itself; otherwise paste the values below.";
|
|
14
21
|
portEl.value = typeof wsPort === "number" && wsPort > 0 ? String(wsPort) : String(DEFAULT_WS_PORT);
|
|
15
22
|
profileEl.value = typeof profile === "string" ? profile : "";
|
|
16
23
|
render(typeof connState === "string" ? connState : "idle");
|
|
@@ -32,7 +39,7 @@
|
|
|
32
39
|
statusEl.textContent = "Status: enter a valid port (> 0) and token";
|
|
33
40
|
return;
|
|
34
41
|
}
|
|
35
|
-
await chrome.storage.local.set({ wsPort, token, profile });
|
|
42
|
+
await chrome.storage.local.set({ wsPort, token, profile, pairingSource: "manual" });
|
|
36
43
|
await chrome.runtime.sendMessage({ type: "reconnect" }).catch(() => void 0);
|
|
37
44
|
statusEl.textContent = "Status: \u2026 connecting";
|
|
38
45
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehmoodqureshi/chrome-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
"dist/shared/**/*.js",
|
|
25
25
|
"dist/shared/**/*.d.ts",
|
|
26
26
|
"extension-dist",
|
|
27
|
-
"
|
|
27
|
+
"!extension-dist/pairing.json",
|
|
28
28
|
"README.md",
|
|
29
|
+
"SETUP.md",
|
|
29
30
|
"LICENSE",
|
|
30
31
|
"docs/BLUEPRINT.md"
|
|
31
32
|
],
|
|
@@ -55,12 +56,10 @@
|
|
|
55
56
|
"clean": "rimraf dist extension-dist",
|
|
56
57
|
"test": "npm run clean && npm run build && npm run build:ext && node --test dist/test/*.test.js",
|
|
57
58
|
"test:hitl": "npm run build && node dist/hitl/index.js",
|
|
58
|
-
"prepack": "npm run clean && npm run build && npm run build:ext"
|
|
59
|
-
"postinstall": "node scripts/postinstall.js"
|
|
59
|
+
"prepack": "npm run clean && npm run build && npm run build:ext"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
63
|
-
"playwright": "^1.49.1",
|
|
64
63
|
"ws": "^8.18.0",
|
|
65
64
|
"zod": "^4.4.3"
|
|
66
65
|
},
|
|
@@ -69,6 +68,7 @@
|
|
|
69
68
|
"@types/node": "^20.17.10",
|
|
70
69
|
"@types/ws": "^8.5.13",
|
|
71
70
|
"esbuild": "^0.24.0",
|
|
71
|
+
"playwright": "^1.49.1",
|
|
72
72
|
"rimraf": "^5.0.10",
|
|
73
73
|
"typescript": "^5.7.2"
|
|
74
74
|
},
|
package/scripts/postinstall.js
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* postinstall — install the Chromium that the CDP fallback needs.
|
|
3
|
-
*
|
|
4
|
-
* Skip-guarded and never fatal: a failure here must not break `npm install`
|
|
5
|
-
* (the extension path needs no browser, and CI rarely wants the download).
|
|
6
|
-
* Skips when:
|
|
7
|
-
* - CHROME_MCP_SKIP_BROWSER_DOWNLOAD / PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD is set,
|
|
8
|
-
* - CI is set,
|
|
9
|
-
* - playwright is not installed yet (it is added in phase 3).
|
|
10
|
-
*/
|
|
11
|
-
'use strict';
|
|
12
|
-
|
|
13
|
-
function shouldSkip() {
|
|
14
|
-
return (
|
|
15
|
-
process.env.CHROME_MCP_SKIP_BROWSER_DOWNLOAD === '1' ||
|
|
16
|
-
process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === '1' ||
|
|
17
|
-
process.env.CI === 'true' ||
|
|
18
|
-
process.env.CI === '1'
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function hasPlaywright() {
|
|
23
|
-
try {
|
|
24
|
-
require.resolve('playwright');
|
|
25
|
-
return true;
|
|
26
|
-
} catch {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function main() {
|
|
32
|
-
if (shouldSkip()) {
|
|
33
|
-
process.stdout.write('[postinstall] skipping Chromium download (guard set).\n');
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
if (!hasPlaywright()) {
|
|
37
|
-
process.stdout.write('[postinstall] playwright not installed; CDP fallback unavailable until phase 3.\n');
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
try {
|
|
41
|
-
const { execFileSync } = require('node:child_process');
|
|
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' });
|
|
49
|
-
} catch (err) {
|
|
50
|
-
process.stdout.write(
|
|
51
|
-
`[postinstall] Chromium install skipped (non-fatal): ${err && err.message ? err.message : err}\n`,
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
main();
|