@mehmoodqureshi/chrome-mcp 0.7.0 → 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 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
- npm root -g # <root>; the extension is at <root>/@mehmoodqureshi/chrome-mcp/extension-dist
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:** 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`.
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 `extension-dist/` from `npm root -g` and pair
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/`. Pair it
404
- from the extension's **Options** page using the `port` + `token` from
405
- `~/.chrome-mcp/handshake.json` (run `npx chrome-mcp --print-pairing` to get the
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
@@ -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
@@ -26,12 +26,11 @@ exports.appendHistory = appendHistory;
26
26
  const node_fs_1 = require("node:fs");
27
27
  const node_path_1 = require("node:path");
28
28
  const config_1 = require("../config");
29
+ // `mcp/log` (not `mcp/server`) so this stays free of the server import cycle
30
+ // while still honouring `--log-level silent`.
31
+ const log_1 = require("../mcp/log");
29
32
  const download_1 = require("../../shared/download");
30
33
  const datadir_1 = require("./datadir");
31
- /** stderr only (never stdout in stdio mode); local to avoid an import cycle with mcp/server. */
32
- function logErr(message) {
33
- process.stderr.write(`[chrome-mcp] ${message}\n`);
34
- }
35
34
  let active = null;
36
35
  /** Monotonic counter so saved result/screenshot filenames sort in capture order. */
37
36
  let seq = 0;
@@ -100,7 +99,7 @@ function saveResult(tool, ext, body) {
100
99
  return path;
101
100
  }
102
101
  catch (err) {
103
- logErr(`results save failed: ${err instanceof Error ? err.message : String(err)}`);
102
+ (0, log_1.logErr)(`results save failed: ${err instanceof Error ? err.message : String(err)}`);
104
103
  return null;
105
104
  }
106
105
  }
@@ -119,7 +118,7 @@ function saveBinary(tool, ext, bytes) {
119
118
  return path;
120
119
  }
121
120
  catch (err) {
122
- logErr(`results save failed: ${err instanceof Error ? err.message : String(err)}`);
121
+ (0, log_1.logErr)(`results save failed: ${err instanceof Error ? err.message : String(err)}`);
123
122
  return null;
124
123
  }
125
124
  }
@@ -134,7 +133,7 @@ function saveScreenshot(dataBase64) {
134
133
  return path;
135
134
  }
136
135
  catch (err) {
137
- logErr(`screenshot save failed: ${err instanceof Error ? err.message : String(err)}`);
136
+ (0, log_1.logErr)(`screenshot save failed: ${err instanceof Error ? err.message : String(err)}`);
138
137
  return null;
139
138
  }
140
139
  }
@@ -176,7 +175,7 @@ function appendHistory(entry) {
176
175
  (0, node_fs_1.appendFileSync)(w.historyPath, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
177
176
  }
178
177
  catch (err) {
179
- logErr(`history append failed: ${err instanceof Error ? err.message : String(err)}`);
178
+ (0, log_1.logErr)(`history append failed: ${err instanceof Error ? err.message : String(err)}`);
180
179
  }
181
180
  }
182
181
  //# sourceMappingURL=workspace.js.map
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
  }
@@ -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 and print its path (never the token). */
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;
@@ -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 and print its path, then exit
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 { type CdpOptions } from './cdp-executor';
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 ?? ((o) => new cdp_executor_1.CdpExecutor(o));
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) => void persistState(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.5.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
- Run <code>npx chrome-mcp --print-pairing</code>, open the printed
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(["wsPort", "profile", "connState"]);
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.7.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
- "scripts/postinstall.js",
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
  },
@@ -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();