@mehmoodqureshi/chrome-mcp 0.7.1 → 0.9.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>
@@ -93,21 +108,55 @@ explicitly (and is never written to disk).
93
108
 
94
109
  **2. Load the extension** — **required**; the server can drive nothing without it.
95
110
 
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:
111
+ The extension ships prebuilt inside the npm package, and every time the server
112
+ boots it copies it to a plain folder right under your home directory:
113
+
114
+ ```
115
+ ~/chrome-mcp-extension (macOS / Linux)
116
+ %USERPROFILE%\chrome-mcp-extension (Windows)
117
+ ```
118
+
119
+ So after step 1 has started the server once (restart your client, or `/mcp` in
120
+ Claude Code), the folder is already there. To create it without a client, or
121
+ to print the exact path:
98
122
 
99
123
  ```bash
100
- npm install -g @mehmoodqureshi/chrome-mcp
101
- npm root -g # → <root>; the extension is at <root>/@mehmoodqureshi/chrome-mcp/extension-dist
124
+ npx -y @mehmoodqureshi/chrome-mcp --extension-path
102
125
  ```
103
126
 
104
127
  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`.
128
+ pick `chrome-mcp-extension` in your home folder. After upgrading the package the
129
+ server refreshes the files on its next boot; click **Reload** on
130
+ `chrome://extensions` to pick them up. `CHROME_MCP_EXTENSION_DIR` moves the
131
+ folder somewhere else. (Working from a git clone instead? Run
132
+ `npm install && npm run build:ext` first — `extension-dist/` is gitignored, and
133
+ the server mirrors it to the same home folder.)
134
+
135
+ **3. Pair it — usually nothing to do.** Every time the server boots it writes
136
+ `pairing.json` (mode 0600, never shipped in the tarball) into the very
137
+ `chrome-mcp-extension` folder you just loaded. The extension reads that file
138
+ from its own folder on startup and pairs itself, so the toolbar badge turns
139
+ green with no token to paste. Load the extension before the server has ever
140
+ run? It re-checks every 30 seconds and pairs as soon as the file appears.
141
+
142
+ **Where to see the badge:** it sits on the extension's icon in Chrome's
143
+ toolbar, not on the `chrome://extensions` page. Chrome hides new extensions
144
+ behind the puzzle-piece button at the right of the address bar, so click that,
145
+ find **Chrome MCP Bridge**, and click the pin next to it once; the icon then
146
+ stays in the toolbar. Hover it for the status in words.
147
+
148
+ | Badge | Meaning |
149
+ |---|---|
150
+ | green dot | paired and connected |
151
+ | yellow dots | connecting |
152
+ | grey circle | not paired yet (no server has run, or no pairing file) |
153
+ | red exclamation mark | token rejected; the server rotated it, re-pairs by itself in a moment |
154
+
155
+ Manual fallback (a copied folder, a read-only home): run
156
+ `npx -y @mehmoodqureshi/chrome-mcp --print-pairing`, open the extension's
157
+ **Options** page, and paste the `port` + `token` from
158
+ `~/.chrome-mcp/handshake.json`. Values saved there take precedence over the
159
+ bundled file.
111
160
 
112
161
  ### Running more than one session
113
162
 
@@ -155,7 +204,7 @@ start. Wrap it in `cmd /c`:
155
204
 
156
205
  Or from Claude Code: `claude mcp add chrome-mcp -- cmd /c npx -y @mehmoodqureshi/chrome-mcp --allow-domain example.com`
157
206
 
158
- Everything else is the same — load `extension-dist/` from `npm root -g` and pair
207
+ Everything else is the same — load `%USERPROFILE%\chrome-mcp-extension` and pair
159
208
  as above.
160
209
 
161
210
  The tools cover tabs, navigation, interaction (`click`/`type`/`press`/`hover`/
@@ -400,10 +449,13 @@ RUN_EXT_SMOKE=1 node --test dist/test/extension-smoke.test.js # live, headed
400
449
  ## The extension
401
450
 
402
451
  `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).
452
+ `chrome://extensions` → **Load unpacked** → select `~/chrome-mcp-extension`, the
453
+ mirror the server refreshes from `extension-dist/` on every boot (loading
454
+ `extension-dist/` directly also works). It pairs itself from the `pairing.json`
455
+ the server writes into that folder; the **Options** page paste of `port` +
456
+ `token` from `~/.chrome-mcp/handshake.json` (run
457
+ `npx -y @mehmoodqureshi/chrome-mcp --print-pairing` to get the path) is only
458
+ the fallback.
407
459
 
408
460
  > **Reads/interaction use `chrome.scripting`/`chrome.tabs`** — no "is being
409
461
  > debugged" banner, CSP-safe reads (isolated world), testable under Playwright.
package/SETUP.md ADDED
@@ -0,0 +1,201 @@
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 extension folder
37
+
38
+ No global install is needed. This one command fetches the package, copies the
39
+ extension to a plain folder directly under the home directory, and prints its
40
+ path:
41
+
42
+ ```
43
+ npx -y @mehmoodqureshi/chrome-mcp --extension-path
44
+ ```
45
+
46
+ It prints `~/chrome-mcp-extension` (on Windows `%USERPROFILE%\chrome-mcp-extension`).
47
+ Verify with `ls` (or `dir`) that the folder holds `manifest.json` and
48
+ `background.js`. Record the path; the human needs it in step 4. The install is
49
+ small and downloads no browser: the server drives the Chrome the human already
50
+ has. The server refreshes this folder on every boot, so upgrades need no
51
+ re-copy, only a Reload on `chrome://extensions`.
52
+
53
+ ## Step 3 — Wire the server into this MCP client
54
+
55
+ Ask the human which domains they want to automate. Build the argument list from
56
+ their answer: one `--allow-domain <glob>` per domain, plus `--enable-mutations`
57
+ if they want the agent to click, type, and navigate (almost always yes). Add
58
+ `--persist-token` so the pairing survives restarts. Leave every other gate off
59
+ unless they ask.
60
+
61
+ **Claude Code** (registers for every project on this machine):
62
+
63
+ ```
64
+ claude mcp add chrome-mcp -s user -- \
65
+ npx -y @mehmoodqureshi/chrome-mcp \
66
+ --allow-domain example.com --enable-mutations --persist-token
67
+ ```
68
+
69
+ Everything before `--` belongs to Claude Code. Everything after it is the
70
+ server's own command line. Keep the `--` or `--allow-domain` gets read as a
71
+ Claude Code option. Verify with `claude mcp list`: chrome-mcp should be listed.
72
+ It will show as connected once the server boots, even before the extension has
73
+ paired.
74
+
75
+ **Claude Desktop, Cursor, Windsurf, VS Code, and other JSON-configured hosts:**
76
+ add this to the host's MCP config file (the exact path differs per client; VS
77
+ Code uses a top-level `servers` key instead of `mcpServers`).
78
+
79
+ ```json
80
+ {
81
+ "mcpServers": {
82
+ "chrome-mcp": {
83
+ "command": "npx",
84
+ "args": ["-y", "@mehmoodqureshi/chrome-mcp",
85
+ "--allow-domain", "example.com",
86
+ "--enable-mutations",
87
+ "--persist-token"]
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ **Windows:** the command must be `cmd` with args `["/c", "npx", "-y",
94
+ "@mehmoodqureshi/chrome-mcp", ...]`, or for Claude Code:
95
+ `claude mcp add chrome-mcp -s user -- cmd /c npx -y @mehmoodqureshi/chrome-mcp --allow-domain example.com --enable-mutations --persist-token`.
96
+
97
+ Tell the human to restart the client (or run `/mcp` in Claude Code) so the
98
+ server loads. The first boot writes `~/.chrome-mcp/handshake.json` (mode 0600)
99
+ holding the bridge port and the pairing token, and with `--persist-token` it
100
+ also stores the token at `~/.chrome-mcp/token` for reuse.
101
+
102
+ ## Step 4 — Load the extension (human action inside Chrome)
103
+
104
+ You cannot do this step yourself. Give the human these exact instructions:
105
+
106
+ 1. Open `chrome://extensions` in Chrome.
107
+ 2. Turn on **Developer mode** (top right).
108
+ 3. Click **Load unpacked** and choose the `chrome-mcp-extension` folder in
109
+ their home directory (the path from step 2).
110
+ 4. Confirm an extension named **Chrome MCP Bridge** now appears in the list.
111
+
112
+ Wait for the human to confirm before continuing.
113
+
114
+ ## Step 5 — Pairing (automatic; verify it)
115
+
116
+ Pairing needs no paste. Every time the server boots it writes `pairing.json`
117
+ (mode 0600) into the same `chrome-mcp-extension` folder the human loaded in step 4.
118
+ The extension reads that file from its own folder and pairs itself. So make
119
+ sure a server has booted at least once since step 2, in either of these ways:
120
+
121
+ - the client started one in step 3 (a restart or `/mcp` reconnect is enough), or
122
+ - start one yourself in pairing mode and leave it running in the background:
123
+
124
+ ```
125
+ npx -y @mehmoodqureshi/chrome-mcp --print-pairing --persist-token
126
+ ```
127
+
128
+ Verify the file exists (do not print it):
129
+
130
+ ```
131
+ ls "$(npx -y @mehmoodqureshi/chrome-mcp --extension-path)/pairing.json"
132
+ ```
133
+
134
+ Then tell the human where to look. The status badge is on the extension's
135
+ icon in Chrome's toolbar, not on the `chrome://extensions` page. Chrome hides
136
+ new extensions behind the puzzle-piece button at the right of the address bar,
137
+ so give them these exact steps:
138
+
139
+ 1. Click the puzzle-piece button at the right of the address bar.
140
+ 2. Find **Chrome MCP Bridge** in the list and click the pin icon next to it.
141
+ The extension icon now stays in the toolbar.
142
+ 3. Look at the small badge on that icon. Hovering it shows the status in words.
143
+
144
+ Badge meanings: a green dot means paired and connected. Yellow dots mean
145
+ connecting. A grey circle means not paired yet, usually because no server has
146
+ run since the extension was loaded. A red exclamation mark means the token was
147
+ rejected, which the extension fixes by itself within a few seconds by re-reading
148
+ the pairing file.
149
+
150
+ A green dot can take up to 30 seconds if the extension was loaded before the
151
+ server first ran. Wait for the human to confirm the green dot.
152
+
153
+ **Manual fallback**, only if the badge stays grey after a minute (a copied
154
+ extension folder or a read-only global install): read the port without
155
+ printing the token,
156
+
157
+ ```
158
+ node -e "console.log(require(require('os').homedir()+'/.chrome-mcp/handshake.json').port)"
159
+ ```
160
+
161
+ then have the human open **Details** on Chrome MCP Bridge, then **Extension
162
+ options**, enter that **Port**, open `~/.chrome-mcp/handshake.json` in a text
163
+ editor and paste its `token` into the **Token** field, leave **Profile** as
164
+ `default`, and click **Save**. The status line should read **connected** within
165
+ a few seconds. Values saved by hand take precedence over the bundled file.
166
+
167
+ If you started a pairing-mode server in this step, stop it now with Ctrl-C. The
168
+ client's own server takes the port over on its next boot, and with
169
+ `--persist-token` the extension reconnects to it with no further pairing.
170
+
171
+ ## Step 6 — Prove the chain end-to-end
172
+
173
+ From the restarted client session, call the MCP tools in this order and check
174
+ each result:
175
+
176
+ 1. `chrome_status` — reports the backend as the extension and the session as
177
+ connected.
178
+ 2. `tabs_list` — returns at least one tab from the human's real Chrome.
179
+ 3. `navigate` to a URL on one of the allowlisted domains, then `snapshot` —
180
+ returns interactive elements with `ref` ids.
181
+ 4. `navigate` to a domain that is **not** allowlisted — must be refused with a
182
+ policy error. This confirms deny-all is working, which matters more than the
183
+ happy path.
184
+
185
+ If `chrome_status` shows the extension disconnected, the pairing in step 5 did
186
+ not stick. Re-check the port and token before anything else.
187
+
188
+ ## Step 7 — Report
189
+
190
+ Summarize for the human:
191
+
192
+ - Node and package versions installed.
193
+ - The absolute `chrome-mcp-extension` path they loaded.
194
+ - Which MCP client was configured, at which scope, with which domains and gates.
195
+ - Whether the badge went green, and whether `tabs_list`, an allowed
196
+ navigation, and a refused navigation each verified.
197
+ - Anything still open on their side, such as restarting the client.
198
+
199
+ For any failure, name the exact symptom, what you tried, and the matching
200
+ section of the README's troubleshooting notes at
201
+ `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
package/dist/src/cli.js CHANGED
@@ -21,6 +21,7 @@ const datadir_1 = require("./bridge/datadir");
21
21
  const workspace_1 = require("./bridge/workspace");
22
22
  const auth_1 = require("./bridge/auth");
23
23
  const server_2 = require("./mcp/server");
24
+ const extension_install_1 = require("./extension-install");
24
25
  /** Hard deadline for clean shutdown before we force-exit (a stuck socket must not hang us). */
25
26
  const SHUTDOWN_DEADLINE_MS = 3000;
26
27
  /**
@@ -51,6 +52,25 @@ function version() {
51
52
  return '0.0.0';
52
53
  }
53
54
  }
55
+ /**
56
+ * Mirror the bundled extension into the visible install folder
57
+ * (`~/chrome-mcp-extension`) and report where Chrome should load it from. On a
58
+ * read-only home the bundled folder is returned instead; both are loadable.
59
+ */
60
+ function installExtension() {
61
+ const r = (0, extension_install_1.syncExtension)();
62
+ if (!r.ok) {
63
+ (0, server_2.logDebug)(`extension not mirrored to the home folder (${r.error}); using the bundled copy at ${r.dir}`);
64
+ return r.dir;
65
+ }
66
+ if (r.created) {
67
+ (0, server_2.logErr)(`extension installed at ${r.dir} — chrome://extensions -> Load unpacked -> pick that folder`);
68
+ }
69
+ else if (r.copied.length > 0) {
70
+ (0, server_2.logErr)(`extension files updated at ${r.dir} — click Reload on chrome://extensions to pick up the new version`);
71
+ }
72
+ return r.dir;
73
+ }
54
74
  /** Render a byte count as a short human string (1.2 MB, 904 KB, …). */
55
75
  function humanBytes(n) {
56
76
  if (n < 1024)
@@ -154,6 +174,10 @@ async function main() {
154
174
  process.stdout.write(`${version()}\n`);
155
175
  return;
156
176
  }
177
+ if (cfg.showExtensionPath) {
178
+ process.stdout.write(`${installExtension()}\n`);
179
+ return;
180
+ }
157
181
  const dataDir = (0, datadir_1.ensureDataDir)(cfg.dataDir);
158
182
  const token = (0, auth_1.resolveToken)(dataDir, { persist: cfg.persistToken });
159
183
  const { allowDomains, allowEval, allowDownloads, allowUploads, allowAllTabs, enableMutations } = cfg.policy;
@@ -179,6 +203,21 @@ async function main() {
179
203
  })}`);
180
204
  const handshakePath = (0, auth_1.writeHandshake)(dataDir, { port, token });
181
205
  (0, server_2.logErr)(`pairing handshake written to ${handshakePath} (mode 0600; token not logged)`);
206
+ // Drop the same port + token into the bundled extension folder so a Load
207
+ // unpacked from there pairs itself. Best-effort: a read-only install just
208
+ // falls back to the Options-page paste.
209
+ const extDir = installExtension();
210
+ const bundled = (0, auth_1.writeBundledPairing)(extDir, { port, token });
211
+ if (bundled) {
212
+ (0, server_2.logErr)(`auto-pairing file written to ${bundled} — Load unpacked from ${extDir} needs no token paste`);
213
+ }
214
+ else {
215
+ (0, server_2.logDebug)(`auto-pairing file not written (extension folder missing or read-only at ${extDir})`);
216
+ }
217
+ // Anyone who loaded the extension straight from the package folder (0.8.0
218
+ // docs) keeps pairing too.
219
+ if (extDir !== (0, extension_install_1.bundledExtensionDir)())
220
+ (0, auth_1.writeBundledPairing)((0, extension_install_1.bundledExtensionDir)(), { port, token });
182
221
  if (process.env.CHROME_MCP_TOKEN) {
183
222
  (0, server_2.logErr)('token: pinned from CHROME_MCP_TOKEN (stable; pair once, never again).');
184
223
  }
@@ -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,12 @@ 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 Install the extension to ~/chrome-mcp-extension (or
278
+ CHROME_MCP_EXTENSION_DIR) and print that path — the folder
279
+ to pick in chrome://extensions -> Load unpacked. The server
280
+ also refreshes it on every boot.
271
281
  --persist-token Reuse a stable on-disk token across restarts so the
272
282
  extension never has to re-pair (default: fresh per boot).
273
283
  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);
@@ -0,0 +1,42 @@
1
+ /**
2
+ * src/extension-install.ts — put the extension somewhere a human can find.
3
+ *
4
+ * The extension ships inside the npm package, which is the wrong place to send
5
+ * someone with a "Load unpacked" file dialog: a global install buries it five
6
+ * folders deep under `npm root -g`, and an npx run hides it in a cache. So on
7
+ * every boot (and on `--extension-path`) the server mirrors the bundled
8
+ * `extension-dist/` into a plain, visible folder directly under the home
9
+ * directory — `~/chrome-mcp-extension` — and that is the folder the docs, the
10
+ * pairing file, and `--extension-path` all point at.
11
+ *
12
+ * The copy is a one-way mirror of top-level regular files, written only when
13
+ * content differs, so an unchanged boot touches nothing. It never deletes: the
14
+ * target may hold `pairing.json` (ours) and, if the operator pointed
15
+ * CHROME_MCP_EXTENSION_DIR somewhere of their own, files that are not ours to
16
+ * remove. It never throws — a read-only home just falls back to the bundled
17
+ * folder, which still works for Load unpacked.
18
+ */
19
+ /** Folder name under the home directory. */
20
+ export declare const EXTENSION_DIR_NAME = "chrome-mcp-extension";
21
+ /** The auto-pairing file the server writes; never copied, never overwritten by the mirror. */
22
+ export declare const PAIRING_FILE = "pairing.json";
23
+ /** `dist/src/extension-install.js` -> `<pkg>/extension-dist`, which the tarball ships. */
24
+ export declare function bundledExtensionDir(): string;
25
+ /** Where the extension is mirrored for Load unpacked. `CHROME_MCP_EXTENSION_DIR` overrides. */
26
+ export declare function extensionInstallDir(): string;
27
+ export interface SyncResult {
28
+ /** The folder that now holds the extension (the target on success, the source on failure). */
29
+ dir: string;
30
+ /** Files written this run (new or changed). Empty means the target was already current. */
31
+ copied: string[];
32
+ /** True when the target folder did not exist before this run (first install). */
33
+ created: boolean;
34
+ ok: boolean;
35
+ error?: string;
36
+ }
37
+ /**
38
+ * Mirror the bundled extension into the install dir. Returns the folder to
39
+ * point Chrome at either way: the target when the mirror succeeded, the source
40
+ * when it did not (so callers always have a loadable folder).
41
+ */
42
+ export declare function syncExtension(from?: string, to?: string): SyncResult;
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ /**
3
+ * src/extension-install.ts — put the extension somewhere a human can find.
4
+ *
5
+ * The extension ships inside the npm package, which is the wrong place to send
6
+ * someone with a "Load unpacked" file dialog: a global install buries it five
7
+ * folders deep under `npm root -g`, and an npx run hides it in a cache. So on
8
+ * every boot (and on `--extension-path`) the server mirrors the bundled
9
+ * `extension-dist/` into a plain, visible folder directly under the home
10
+ * directory — `~/chrome-mcp-extension` — and that is the folder the docs, the
11
+ * pairing file, and `--extension-path` all point at.
12
+ *
13
+ * The copy is a one-way mirror of top-level regular files, written only when
14
+ * content differs, so an unchanged boot touches nothing. It never deletes: the
15
+ * target may hold `pairing.json` (ours) and, if the operator pointed
16
+ * CHROME_MCP_EXTENSION_DIR somewhere of their own, files that are not ours to
17
+ * remove. It never throws — a read-only home just falls back to the bundled
18
+ * folder, which still works for Load unpacked.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.PAIRING_FILE = exports.EXTENSION_DIR_NAME = void 0;
22
+ exports.bundledExtensionDir = bundledExtensionDir;
23
+ exports.extensionInstallDir = extensionInstallDir;
24
+ exports.syncExtension = syncExtension;
25
+ const node_os_1 = require("node:os");
26
+ const node_path_1 = require("node:path");
27
+ const node_fs_1 = require("node:fs");
28
+ /** Folder name under the home directory. */
29
+ exports.EXTENSION_DIR_NAME = 'chrome-mcp-extension';
30
+ /** The auto-pairing file the server writes; never copied, never overwritten by the mirror. */
31
+ exports.PAIRING_FILE = 'pairing.json';
32
+ /** `dist/src/extension-install.js` -> `<pkg>/extension-dist`, which the tarball ships. */
33
+ function bundledExtensionDir() {
34
+ return (0, node_path_1.resolve)(__dirname, '..', '..', 'extension-dist');
35
+ }
36
+ /** Where the extension is mirrored for Load unpacked. `CHROME_MCP_EXTENSION_DIR` overrides. */
37
+ function extensionInstallDir() {
38
+ const override = process.env.CHROME_MCP_EXTENSION_DIR;
39
+ return override && override.trim() ? (0, node_path_1.resolve)(override.trim()) : (0, node_path_1.join)((0, node_os_1.homedir)(), exports.EXTENSION_DIR_NAME);
40
+ }
41
+ function sameBytes(a, b) {
42
+ return a.length === b.length && a.equals(b);
43
+ }
44
+ /**
45
+ * Mirror the bundled extension into the install dir. Returns the folder to
46
+ * point Chrome at either way: the target when the mirror succeeded, the source
47
+ * when it did not (so callers always have a loadable folder).
48
+ */
49
+ function syncExtension(from = bundledExtensionDir(), to = extensionInstallDir()) {
50
+ const copied = [];
51
+ let created = false;
52
+ try {
53
+ const entries = (0, node_fs_1.readdirSync)(from, { withFileTypes: true }).filter((e) => e.isFile() && e.name !== exports.PAIRING_FILE);
54
+ if (!entries.some((e) => e.name === 'manifest.json')) {
55
+ return { dir: from, copied, created, ok: false, error: `no manifest.json in ${from}` };
56
+ }
57
+ try {
58
+ (0, node_fs_1.statSync)(to);
59
+ }
60
+ catch {
61
+ created = true;
62
+ }
63
+ (0, node_fs_1.mkdirSync)(to, { recursive: true });
64
+ for (const e of entries) {
65
+ const src = (0, node_fs_1.readFileSync)((0, node_path_1.join)(from, e.name));
66
+ const dst = (0, node_path_1.join)(to, e.name);
67
+ let current = null;
68
+ try {
69
+ if ((0, node_fs_1.statSync)(dst).isFile())
70
+ current = (0, node_fs_1.readFileSync)(dst);
71
+ }
72
+ catch {
73
+ /* absent */
74
+ }
75
+ if (current && sameBytes(current, src))
76
+ continue;
77
+ (0, node_fs_1.writeFileSync)(dst, src);
78
+ copied.push(e.name);
79
+ }
80
+ return { dir: to, copied, created, ok: true };
81
+ }
82
+ catch (e) {
83
+ return { dir: from, copied, created, ok: false, error: e instanceof Error ? e.message : String(e) };
84
+ }
85
+ }
86
+ //# sourceMappingURL=extension-install.js.map
@@ -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.9.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,10 +16,11 @@
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
22
- <code>token</code> below.
21
+ Manual pairing: run <code>npx -y @mehmoodqureshi/chrome-mcp --print-pairing</code>,
22
+ open the printed <code>handshake.json</code>, and paste its <code>port</code>
23
+ and <code>token</code> below.
23
24
  </p>
24
25
  <label for="port">Port</label>
25
26
  <input id="port" type="number" inputmode="numeric" placeholder="38017" />
@@ -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.1",
3
+ "version": "0.9.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();