@mehmoodqureshi/chrome-mcp 0.6.4 → 0.6.6

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
@@ -4,10 +4,17 @@
4
4
  [![npm](https://img.shields.io/npm/v/%40mehmoodqureshi%2Fchrome-mcp?label=npm)](https://www.npmjs.com/package/@mehmoodqureshi/chrome-mcp)
5
5
  [![license](https://img.shields.io/npm/l/%40mehmoodqureshi%2Fchrome-mcp?label=license)](LICENSE)
6
6
 
7
- Drive a **real Chrome browser** from Claude (or any MCP host). An MV3 extension
8
- drives your real Chrome real logins, real cookiesvia
9
- `chrome.scripting`/`chrome.tabs`. The CLI runs a localhost WebSocket server; the
10
- extension dials in.
7
+ **Let Claude use the Chrome you are already logged into.** Not a fresh
8
+ automated browser that greets every site as a stranger*your* Chrome, with
9
+ your sessions, your cookies, your 2FA already done. If you can see a page in
10
+ your browser, your agent can read it, without logging in again and without
11
+ pasting credentials anywhere.
12
+
13
+ Most browser MCP servers launch their own Chromium and hand your agent a
14
+ signed-out window. chrome-mcp does the opposite: an MV3 extension dials into a
15
+ localhost WebSocket server and drives the browser you already have open, through
16
+ `chrome.scripting`/`chrome.tabs`. Works with Claude Code, Claude Desktop, and any
17
+ other MCP host.
11
18
 
12
19
  Distributed as an `npx` CLI (the MCP server) plus a load-unpacked extension.
13
20
 
@@ -22,19 +29,48 @@ Distributed as an `npx` CLI (the MCP server) plus a load-unpacked extension.
22
29
 
23
30
  ## Quickstart
24
31
 
25
- **1. Register the MCP server** with your host (e.g. Claude Desktop / Code):
32
+ **1. Register the MCP server** with your host.
33
+
34
+ <details open>
35
+ <summary><b>Claude Code (terminal)</b> — one command, no config file to find</summary>
36
+
37
+ ```bash
38
+ claude mcp add chrome-mcp -s user -- \
39
+ npx -y @mehmoodqureshi/chrome-mcp \
40
+ --allow-domain example.com --enable-mutations --persist-token
41
+ ```
42
+
43
+ Everything **before** `--` belongs to Claude Code; everything **after** it is this
44
+ server's command and flags. Keep the `--` or `--allow-domain` gets read as a
45
+ Claude Code option.
46
+
47
+ `-s user` registers it for every project on your machine. Use `-s local` (the
48
+ default) for just the current project, or `-s project` to write a `.mcp.json`
49
+ your team can commit.
50
+
51
+ Check it came up with `claude mcp list`. After upgrading the server, reconnect it
52
+ with `/mcp` inside a session — no restart needed.
53
+
54
+ </details>
55
+
56
+ <details>
57
+ <summary><b>Claude Desktop</b> and other MCP hosts — JSON config</summary>
26
58
 
27
59
  ```jsonc
28
60
  {
29
61
  "mcpServers": {
30
62
  "chrome-mcp": {
31
63
  "command": "npx",
32
- "args": ["-y", "@mehmoodqureshi/chrome-mcp", "--allow-domain", "example.com", "--enable-mutations"]
64
+ "args": ["-y", "@mehmoodqureshi/chrome-mcp",
65
+ "--allow-domain", "example.com", "--enable-mutations",
66
+ "--persist-token"]
33
67
  }
34
68
  }
35
69
  }
36
70
  ```
37
71
 
72
+ </details>
73
+
38
74
  By default everything is **deny-all** (no domains, no eval, no mutations). Grant
39
75
  exactly what you need with `--allow-domain <glob>` (repeatable), `--enable-mutations`,
40
76
  `--enable-downloads`, `--enable-uploads`, `--unsafe-enable-eval`, or `--unsafe-all-domains`.
@@ -45,21 +81,9 @@ exactly what you need with `--allow-domain <glob>` (repeatable), `--enable-mutat
45
81
  > with `--uploads-dir <path>` to restrict uploads to files inside that directory
46
82
  > (`..` traversal is blocked) — strongly recommended for unattended use.
47
83
 
48
- **Pair once, never again.** Add `--persist-token` so the pairing token survives
49
- restarts:
50
-
51
- ```jsonc
52
- {
53
- "mcpServers": {
54
- "chrome-mcp": {
55
- "command": "npx",
56
- "args": ["-y", "@mehmoodqureshi/chrome-mcp",
57
- "--allow-domain", "example.com", "--enable-mutations",
58
- "--persist-token"]
59
- }
60
- }
61
- }
62
- ```
84
+ **Pair once, never again.** Both examples above include `--persist-token`, which
85
+ is what makes the pairing survive a restart — drop it if you'd rather have the
86
+ stricter default described next.
63
87
 
64
88
  Without `--persist-token` a fresh token is minted every boot (the secure
65
89
  default), which means re-pairing the extension on each restart. With it, the
@@ -27,6 +27,18 @@ export declare const BRIDGE_HOST: "127.0.0.1";
27
27
  /** WebSocket close codes we use deliberately. */
28
28
  export declare const CLOSE_UNAUTHORIZED: 4401;
29
29
  export declare const CLOSE_SUPERSEDED: 4000;
30
+ /**
31
+ * Capabilities an extension advertises in `hello`. Additive and optional, so an
32
+ * older extension (which sends none) keeps the conservative behaviour — never
33
+ * gate a capability behind a version-string comparison.
34
+ *
35
+ * `tab-url`: this extension (a) enforces the policy mirror FAIL-CLOSED — it
36
+ * refuses every command until a policy has arrived — and (b) reports the target
37
+ * tab's post-command URL as `ResultFrame.tabUrl`. Together those let the server
38
+ * gate from the reported URL instead of paying a `tabs_list` round-trip before
39
+ * every call. Without it, the server falls back to fetching the URL itself.
40
+ */
41
+ export declare const WIRE_CAP_TAB_URL: "tab-url";
30
42
  /**
31
43
  * Every method that may travel on the wire = the MCP primitives 1:1, plus
32
44
  * `download_file` (privileged, executor-owned) and `ping_probe` (a short-deadline
@@ -52,6 +64,9 @@ export interface HelloFrame extends BaseFrame {
52
64
  * NOT a security boundary (the token is) — it selects which connection slot the
53
65
  * server routes commands to, so several browsers can stay paired at once. */
54
66
  profile?: string;
67
+ /** Optional capability advertisements (see `WIRE_CAP_TAB_URL`). An extension
68
+ * that sends none gets the conservative path, so old builds stay correct. */
69
+ caps?: string[];
55
70
  }
56
71
  /**
57
72
  * The wire-serializable subset of the server's policy, delivered in `welcome` so
@@ -92,6 +107,13 @@ export interface ResultFrame extends BaseFrame {
92
107
  ok: true;
93
108
  /** For `screenshot`: { dataBase64, mimeType, width, height, truncated }. */
94
109
  data: unknown;
110
+ /**
111
+ * The target tab's URL as observed AFTER the command ran — the extension has
112
+ * it locally, so sending it costs nothing and saves the server a round-trip
113
+ * on the next policy gate. Omitted when it can't be resolved (e.g. the tab was
114
+ * closed). Only sent by extensions advertising `WIRE_CAP_TAB_URL`.
115
+ */
116
+ tabUrl?: string;
95
117
  }
96
118
  export interface ErrorFrame extends BaseFrame {
97
119
  type: 'error';
@@ -14,7 +14,7 @@
14
14
  * wire method beyond the primitives.
15
15
  */
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.WIRE_METHODS = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
17
+ exports.WIRE_METHODS = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
18
18
  /** Bumped on any breaking change to the frames below. */
19
19
  exports.PROTOCOL_VERSION = 1;
20
20
  /**
@@ -29,6 +29,18 @@ exports.BRIDGE_HOST = '127.0.0.1';
29
29
  /** WebSocket close codes we use deliberately. */
30
30
  exports.CLOSE_UNAUTHORIZED = 4401;
31
31
  exports.CLOSE_SUPERSEDED = 4000;
32
+ /**
33
+ * Capabilities an extension advertises in `hello`. Additive and optional, so an
34
+ * older extension (which sends none) keeps the conservative behaviour — never
35
+ * gate a capability behind a version-string comparison.
36
+ *
37
+ * `tab-url`: this extension (a) enforces the policy mirror FAIL-CLOSED — it
38
+ * refuses every command until a policy has arrived — and (b) reports the target
39
+ * tab's post-command URL as `ResultFrame.tabUrl`. Together those let the server
40
+ * gate from the reported URL instead of paying a `tabs_list` round-trip before
41
+ * every call. Without it, the server falls back to fetching the URL itself.
42
+ */
43
+ exports.WIRE_CAP_TAB_URL = 'tab-url';
32
44
  /** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
33
45
  exports.WIRE_METHODS = [
34
46
  'tabs_list',
@@ -17,6 +17,8 @@ export interface ConnectionDeps {
17
17
  extId: string;
18
18
  sessionId: string;
19
19
  heartbeatMs: number;
20
+ /** Capabilities from `hello` (see WIRE_CAP_TAB_URL). Old builds send none. */
21
+ caps?: string[];
20
22
  onEvent?: (event: WireEvent, data: Record<string, unknown>) => void;
21
23
  onClose?: (code: number) => void;
22
24
  onLog?: (message: string) => void;
@@ -30,6 +32,10 @@ export declare class ExtensionConnection {
30
32
  private closed;
31
33
  private heartbeat;
32
34
  private missedPongs;
35
+ /** Whether this extension reports `tabUrl` and gates fail-closed. */
36
+ private readonly reportsTabUrl;
37
+ /** Last URL the ACTIVE tab reported, with the wall-clock it arrived. */
38
+ private activeUrl;
33
39
  private readonly onEvent?;
34
40
  private readonly onClose?;
35
41
  private readonly onLog?;
@@ -43,6 +49,18 @@ export declare class ExtensionConnection {
43
49
  isOpen(): boolean;
44
50
  private handleMessage;
45
51
  private settle;
52
+ /**
53
+ * Cache the URL a result rode home with — but ONLY when it describes the active
54
+ * tab (no explicit tabId) and actually resolved. A blank `tabUrl` means the
55
+ * extension couldn't read it (closed tab, restricted page), which is a reason
56
+ * to forget what we knew, never to keep believing it.
57
+ */
58
+ private rememberActiveUrl;
59
+ /**
60
+ * The active tab's last reported URL if it is younger than `maxAgeMs`, else
61
+ * null — the caller then resolves it the slow way. Never returns a guess.
62
+ */
63
+ lastActiveUrl(maxAgeMs: number): string | null;
46
64
  private handleClose;
47
65
  private startHeartbeat;
48
66
  }
@@ -36,6 +36,8 @@ function mapWireErrorCode(code) {
36
36
  };
37
37
  return known[code] ?? 'TARGET_GONE';
38
38
  }
39
+ /** Commands that can change WHICH tab is active, invalidating a cached URL. */
40
+ const ACTIVE_TAB_CHANGERS = new Set(['tab_select', 'tab_new', 'tab_close']);
39
41
  class ExtensionConnection {
40
42
  extId;
41
43
  sessionId;
@@ -45,6 +47,10 @@ class ExtensionConnection {
45
47
  closed = false;
46
48
  heartbeat = null;
47
49
  missedPongs = 0;
50
+ /** Whether this extension reports `tabUrl` and gates fail-closed. */
51
+ reportsTabUrl;
52
+ /** Last URL the ACTIVE tab reported, with the wall-clock it arrived. */
53
+ activeUrl = null;
48
54
  onEvent;
49
55
  onClose;
50
56
  onLog;
@@ -52,6 +58,7 @@ class ExtensionConnection {
52
58
  this.ws = deps.ws;
53
59
  this.extId = deps.extId;
54
60
  this.sessionId = deps.sessionId;
61
+ this.reportsTabUrl = deps.caps?.includes(protocol_1.WIRE_CAP_TAB_URL) ?? false;
55
62
  this.onEvent = deps.onEvent;
56
63
  this.onClose = deps.onClose;
57
64
  this.onLog = deps.onLog;
@@ -71,6 +78,11 @@ class ExtensionConnection {
71
78
  }
72
79
  const id = String(++this.seq);
73
80
  const timeoutMs = opts?.timeoutMs ?? defaultTimeoutFor(method);
81
+ // Anything that reshuffles tabs makes the cached URL a claim about a tab that
82
+ // may no longer be the active one. Drop it before the command, not after, so
83
+ // a failure mid-flight can't leave a stale entry behind.
84
+ if (ACTIVE_TAB_CHANGERS.has(method))
85
+ this.activeUrl = null;
74
86
  const frame = {
75
87
  type: 'command',
76
88
  v: protocol_1.PROTOCOL_VERSION,
@@ -86,7 +98,7 @@ class ExtensionConnection {
86
98
  reject(new types_1.ExecutorError('TIMEOUT', `"${method}" timed out after ${timeoutMs}ms`));
87
99
  }, timeoutMs);
88
100
  timer.unref?.();
89
- this.pending.set(id, { resolve, reject, timer, method });
101
+ this.pending.set(id, { resolve, reject, timer, method, activeTab: opts?.tabId === undefined });
90
102
  try {
91
103
  this.ws.send(JSON.stringify(frame));
92
104
  }
@@ -148,12 +160,38 @@ class ExtensionConnection {
148
160
  clearTimeout(p.timer);
149
161
  this.pending.delete(id);
150
162
  if (frame.type === 'result') {
163
+ this.rememberActiveUrl(p, frame);
151
164
  p.resolve(frame.data);
152
165
  }
153
166
  else {
167
+ // A failed command tells us nothing reliable about where the tab ended up.
168
+ if (p.activeTab)
169
+ this.activeUrl = null;
154
170
  p.reject(new types_1.ExecutorError(mapWireErrorCode(frame.error.code), frame.error.message));
155
171
  }
156
172
  }
173
+ /**
174
+ * Cache the URL a result rode home with — but ONLY when it describes the active
175
+ * tab (no explicit tabId) and actually resolved. A blank `tabUrl` means the
176
+ * extension couldn't read it (closed tab, restricted page), which is a reason
177
+ * to forget what we knew, never to keep believing it.
178
+ */
179
+ rememberActiveUrl(p, frame) {
180
+ if (!this.reportsTabUrl || !p.activeTab)
181
+ return;
182
+ if (ACTIVE_TAB_CHANGERS.has(p.method))
183
+ return; // already invalidated; re-caching would race
184
+ this.activeUrl = frame.tabUrl ? { url: frame.tabUrl, at: Date.now() } : null;
185
+ }
186
+ /**
187
+ * The active tab's last reported URL if it is younger than `maxAgeMs`, else
188
+ * null — the caller then resolves it the slow way. Never returns a guess.
189
+ */
190
+ lastActiveUrl(maxAgeMs) {
191
+ if (!this.activeUrl)
192
+ return null;
193
+ return Date.now() - this.activeUrl.at <= maxAgeMs ? this.activeUrl.url : null;
194
+ }
157
195
  handleClose(code) {
158
196
  if (this.closed)
159
197
  return;
@@ -65,6 +65,12 @@ export declare class BridgeServer {
65
65
  timeoutMs?: number;
66
66
  profile?: string;
67
67
  }): Promise<unknown>;
68
+ /**
69
+ * The active tab's URL for `profile` as last reported by the extension, if it
70
+ * is younger than `maxAgeMs`. Null means "ask properly" — an extension too old
71
+ * to report URLs, a tab shuffle since, or simply nothing recent enough.
72
+ */
73
+ lastActiveUrl(profile: string | undefined, maxAgeMs: number): string | null;
68
74
  private noPairMessage;
69
75
  status(): {
70
76
  extensionConnected: boolean;
@@ -178,6 +178,15 @@ class BridgeServer {
178
178
  }
179
179
  return conn.sendCommand(method, params, opts);
180
180
  }
181
+ /**
182
+ * The active tab's URL for `profile` as last reported by the extension, if it
183
+ * is younger than `maxAgeMs`. Null means "ask properly" — an extension too old
184
+ * to report URLs, a tab shuffle since, or simply nothing recent enough.
185
+ */
186
+ lastActiveUrl(profile, maxAgeMs) {
187
+ const conn = this.conns.get(routeKey(profile));
188
+ return conn?.isOpen() ? conn.lastActiveUrl(maxAgeMs) : null;
189
+ }
181
190
  noPairMessage(profile) {
182
191
  return (`No browser is paired for profile "${profile}". In that Chrome's chrome-mcp ` +
183
192
  `extension Options, set Port ${this.boundPort}, paste the token, set Profile to ` +
@@ -235,7 +244,7 @@ class BridgeServer {
235
244
  authed = true;
236
245
  clearTimeout(helloTimer);
237
246
  ws.off('message', onMessage);
238
- this.promote(ws, frame.ext ?? { id: 'unknown', version: '0', chrome: '0' }, routeKey(frame.profile));
247
+ this.promote(ws, frame.ext ?? { id: 'unknown', version: '0', chrome: '0' }, routeKey(frame.profile), Array.isArray(frame.caps) ? frame.caps : undefined);
239
248
  };
240
249
  ws.on('message', onMessage);
241
250
  ws.on('error', () => {
@@ -252,7 +261,7 @@ class BridgeServer {
252
261
  /* ignore */
253
262
  }
254
263
  }
255
- promote(ws, ext, profile) {
264
+ promote(ws, ext, profile, caps) {
256
265
  const sessionId = (0, node_crypto_1.randomUUID)();
257
266
  // Supersede only the SAME profile's connection (a re-pair). Other profiles
258
267
  // keep their live connections, so several browsers stay paired at once.
@@ -275,6 +284,7 @@ class BridgeServer {
275
284
  extId: ext.id,
276
285
  sessionId,
277
286
  heartbeatMs: this.heartbeatMs,
287
+ caps,
278
288
  onEvent: this.opts.onEvent,
279
289
  onLog: (m) => this.log(m),
280
290
  onClose: () => {
@@ -21,6 +21,9 @@ export declare class ExtensionExecutor implements Executor {
21
21
  ensureReady(): Promise<void>;
22
22
  ping(deadlineMs?: number): Promise<boolean>;
23
23
  dispose(): Promise<void>;
24
+ /** The active tab's URL as reported by the last command on this profile, if it
25
+ * is fresh enough to gate against. See `ACTIVE_URL_TTL_MS`. */
26
+ cachedActiveUrl(): string | null;
24
27
  tabsList(): Promise<TabInfo[]>;
25
28
  tabSelect(tabId: TabId): Promise<TabInfo>;
26
29
  tabNew(url?: string, opts?: {
@@ -12,6 +12,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.ExtensionExecutor = void 0;
13
13
  const types_1 = require("./types");
14
14
  const workspace_1 = require("../bridge/workspace");
15
+ /**
16
+ * How long a reported active-tab URL stays usable for the policy gate.
17
+ *
18
+ * Deliberately short. It exists to cover back-to-back calls (a `batch`, or an
19
+ * agent's read → click → read), where the tab demonstrably has not changed
20
+ * between them. Past that, pay the round-trip. Note the extension re-gates every
21
+ * command against the tab's live URL regardless, so this window trades a little
22
+ * pre-check precision for half the traffic — never enforcement itself.
23
+ */
24
+ const ACTIVE_URL_TTL_MS = 1_000;
15
25
  /** Flatten a Target into the params a wire command carries. */
16
26
  function targetParams(t) {
17
27
  if (!t)
@@ -64,6 +74,11 @@ class ExtensionExecutor {
64
74
  async dispose() {
65
75
  // Never close the user's Chrome.
66
76
  }
77
+ /** The active tab's URL as reported by the last command on this profile, if it
78
+ * is fresh enough to gate against. See `ACTIVE_URL_TTL_MS`. */
79
+ cachedActiveUrl() {
80
+ return this.bridge.lastActiveUrl(this.activeProfile(), ACTIVE_URL_TTL_MS);
81
+ }
67
82
  // -- tabs ---------------------------------------------------------------
68
83
  async tabsList() {
69
84
  return (await this.send('tabs_list', {}));
@@ -13,14 +13,43 @@ export interface StubOptions {
13
13
  activeUrl?: string;
14
14
  /** When true, `eval` resolves `{ok:false}` to mimic a page-side throw. */
15
15
  evalThrows?: boolean;
16
+ /** When true, `tabsList` rejects — mimics a transient bridge failure. */
17
+ tabsListThrows?: boolean;
18
+ /** When true, `tabsList` resolves empty — mimics a browser reporting no tabs. */
19
+ noTabs?: boolean;
20
+ /** When true, the (single) tab reports an empty URL — mimics a chrome:// page
21
+ * or a site the extension has no host access to. */
22
+ blankTabUrl?: boolean;
23
+ /** A URL the backend claims to already know, as the extension reports on every
24
+ * result frame. Set it to assert the gate uses it INSTEAD of calling tabsList. */
25
+ cachedUrl?: string;
26
+ /** Background (non-active) tabs, so a test can target one by an explicit tabId
27
+ * and check the gate authorizes against THAT tab rather than the active one. */
28
+ backgroundTabs?: Array<{
29
+ tabId: TabId;
30
+ url: string;
31
+ }>;
32
+ /** When true, tabs exist but none is flagged active — the case the gate used to
33
+ * paper over by silently gating against `tabs[0]`. */
34
+ noActiveTab?: boolean;
16
35
  }
17
36
  export declare class StubExecutor implements Executor {
18
37
  readonly backend: BackendKind;
19
38
  private url;
20
39
  private readonly evalThrows;
40
+ private readonly tabsListThrows;
41
+ private readonly noTabs;
42
+ private readonly blankTabUrl;
43
+ private readonly cached;
44
+ private readonly backgroundTabs;
45
+ private readonly noActiveTab;
46
+ /** How many times the gate actually asked for the tab list — the round-trip
47
+ * counter the caching path exists to keep at zero. */
48
+ tabsListCalls: number;
21
49
  private ready;
22
50
  constructor(opts?: StubOptions);
23
51
  private tab;
52
+ cachedActiveUrl(): string | null;
24
53
  status(): ExecutorStatus;
25
54
  ensureReady(): Promise<void>;
26
55
  ping(): Promise<boolean>;
@@ -10,6 +10,7 @@
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.StubExecutor = void 0;
13
+ const types_1 = require("./types");
13
14
  /** 1×1 transparent PNG, base64 — a valid image block for screenshot tests. */
14
15
  const TINY_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
15
16
  const ok = { ok: true };
@@ -17,13 +18,37 @@ class StubExecutor {
17
18
  backend = 'extension';
18
19
  url;
19
20
  evalThrows;
21
+ tabsListThrows;
22
+ noTabs;
23
+ blankTabUrl;
24
+ cached;
25
+ backgroundTabs;
26
+ noActiveTab;
27
+ /** How many times the gate actually asked for the tab list — the round-trip
28
+ * counter the caching path exists to keep at zero. */
29
+ tabsListCalls = 0;
20
30
  ready = false;
21
31
  constructor(opts = {}) {
22
32
  this.url = opts.activeUrl ?? 'about:blank';
23
33
  this.evalThrows = opts.evalThrows ?? false;
34
+ this.tabsListThrows = opts.tabsListThrows ?? false;
35
+ this.noTabs = opts.noTabs ?? false;
36
+ this.blankTabUrl = opts.blankTabUrl ?? false;
37
+ this.cached = opts.cachedUrl ?? null;
38
+ this.backgroundTabs = opts.backgroundTabs ?? [];
39
+ this.noActiveTab = opts.noActiveTab ?? false;
24
40
  }
25
41
  tab() {
26
- return { tabId: 'extension:stub:1', url: this.url, title: 'Stub Page', active: true, index: 0 };
42
+ return {
43
+ tabId: 'extension:stub:1',
44
+ url: this.blankTabUrl ? '' : this.url,
45
+ title: 'Stub Page',
46
+ active: !this.noActiveTab,
47
+ index: 0,
48
+ };
49
+ }
50
+ cachedActiveUrl() {
51
+ return this.cached;
27
52
  }
28
53
  status() {
29
54
  return {
@@ -44,7 +69,21 @@ class StubExecutor {
44
69
  this.ready = false;
45
70
  }
46
71
  async tabsList() {
47
- return [this.tab()];
72
+ this.tabsListCalls++;
73
+ if (this.tabsListThrows)
74
+ throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', 'stub bridge is down');
75
+ if (this.noTabs)
76
+ return [];
77
+ return [
78
+ this.tab(),
79
+ ...this.backgroundTabs.map((t, i) => ({
80
+ tabId: t.tabId,
81
+ url: t.url,
82
+ title: 'Stub Background Page',
83
+ active: false,
84
+ index: i + 1,
85
+ })),
86
+ ];
48
87
  }
49
88
  async tabSelect(tabId) {
50
89
  return { ...this.tab(), tabId };
@@ -155,6 +155,14 @@ export interface Executor {
155
155
  ping(deadlineMs?: number): Promise<boolean>;
156
156
  /** Close ONLY if we own the browser; never the user's Chrome. */
157
157
  dispose(): Promise<void>;
158
+ /**
159
+ * The active tab's URL if the backend ALREADY knows it — the extension reports
160
+ * it on every result frame, so a gate that runs right after a command needs no
161
+ * round-trip. Null means "not known recently enough", and the caller must then
162
+ * resolve it properly; it never returns a guess. Optional: backends that can't
163
+ * report cheaply simply omit it.
164
+ */
165
+ cachedActiveUrl?(): string | null;
158
166
  tabsList(): Promise<TabInfo[]>;
159
167
  tabSelect(tabId: TabId): Promise<TabInfo>;
160
168
  /** Open a tab. `active` (default true) focuses it; pass false to open in the background. */
@@ -81,19 +81,79 @@ exports.TOOL_DEFINITIONS = [
81
81
  },
82
82
  },
83
83
  ];
84
- /** Resolve the URL the policy should be evaluated against (the active tab). */
85
- async function activeUrl(ex) {
84
+ const GATE_CONTEXT = 'cannot resolve the target tab URL for the policy gate';
85
+ /**
86
+ * Resolve the URL the policy should be evaluated against: the URL of the tab
87
+ * this very call will act on — the explicit `tabId` when the caller gave one,
88
+ * the active tab only when they didn't.
89
+ *
90
+ * Gating on the ACTIVE tab regardless of `tabId` is an authorization bypass:
91
+ * park an allowlisted page as active and every `tabId`-addressed read (get_text,
92
+ * get_html, screenshot, eval, …) sails through against a tab whose origin was
93
+ * never checked. The tab that gets touched is the tab that must be authorized.
94
+ *
95
+ * NEVER substitutes a placeholder URL, and never falls back to some *other*
96
+ * tab. If the real origin is unknown, evaluating the policy against a stand-in
97
+ * would silently allow or deny against the wrong origin with no signal to the
98
+ * caller. So a `tabsList` failure (or a browser reporting no tabs at all)
99
+ * propagates — the dispatch firewall renders it as an error carrying the code.
100
+ *
101
+ * Prefers a URL the backend already reported over asking again: the extension
102
+ * rides the tab's landing URL home on every result frame, which is what keeps a
103
+ * gated call to ONE round-trip instead of two. That cache only ever describes
104
+ * the active tab, so it is bypassed whenever an explicit `tabId` is in play.
105
+ */
106
+ async function gatedUrl(ex, tabId) {
107
+ if (!tabId) {
108
+ const known = ex.cachedActiveUrl?.();
109
+ if (known)
110
+ return known;
111
+ }
112
+ let tabs;
86
113
  try {
87
- const tabs = await ex.tabsList();
88
- return tabs.find((t) => t.active)?.url ?? tabs[0]?.url ?? 'about:blank';
114
+ tabs = await ex.tabsList();
89
115
  }
90
- catch {
91
- return 'about:blank';
116
+ catch (err) {
117
+ // Keep the underlying code (TIMEOUT / EXTENSION_DISCONNECTED / …) so the
118
+ // caller can tell a transient bridge failure from a policy decision — the
119
+ // rendered message is prefixed with it, since only the text crosses MCP.
120
+ if (err instanceof types_1.ExecutorError)
121
+ throw new types_1.ExecutorError(err.code, `${GATE_CONTEXT}: ${err.message}`);
122
+ throw err; // an internal fault, not a browser one — don't relabel it
92
123
  }
124
+ if (tabs.length === 0) {
125
+ throw new types_1.ExecutorError('TAB_NOT_FOUND', `${GATE_CONTEXT}: the browser reports no open tabs`);
126
+ }
127
+ const target = tabId ? tabs.find((t) => t.tabId === tabId) : tabs.find((t) => t.active);
128
+ if (!target) {
129
+ throw new types_1.ExecutorError('TAB_NOT_FOUND', tabId
130
+ ? `${GATE_CONTEXT}: no open tab has id ${tabId} — call tabs_list for the current ids`
131
+ : `${GATE_CONTEXT}: the browser reports open tabs but none active — pass an explicit tabId`);
132
+ }
133
+ // An empty URL is Chrome declining to reveal one (a chrome:// page, or a tab
134
+ // the extension has no host access to) — NOT an origin. Gating on '' would
135
+ // produce a baffling "blocked on " denial that reads like a policy decision.
136
+ if (!target.url) {
137
+ throw new types_1.ExecutorError('TAB_NOT_FOUND', `${GATE_CONTEXT}: the target tab (id ${target.tabId}) reports no URL. Chrome hides it for ` +
138
+ `internal pages (chrome://, the Web Store) and until the extension has access to that site — ` +
139
+ `switch to a normal page, or open the target site in a new tab.`);
140
+ }
141
+ return target.url;
93
142
  }
94
- /** Policy chokepoint. `urlOverride` is the destination for navigation. */
95
- async function gate(ctx, method, urlOverride) {
96
- const url = urlOverride ?? (await activeUrl(ctx.ex));
143
+ /**
144
+ * Policy chokepoint. `opts.url` is the destination for navigation (it governs
145
+ * instead of any current tab URL); `opts.tabId` is the tab the call will act on,
146
+ * and MUST be threaded through by every URL-gated handler that accepts one —
147
+ * omitting it silently authorizes the call against the active tab instead.
148
+ *
149
+ * Only resolves a tab URL for methods whose verdict actually depends on one
150
+ * (`isUrlGated`). Tab management and the capability gates — eval, downloads,
151
+ * uploads, mutations — are decided without any URL, so making them wait on the
152
+ * tab list bought nothing and, worse, made `tab_new` fail exactly when the tab
153
+ * list was unreadable: the one call that could dig you out.
154
+ */
155
+ async function gate(ctx, method, opts = {}) {
156
+ const url = opts.url ?? ((0, policy_1.isUrlGated)(method) ? await gatedUrl(ctx.ex, opts.tabId) : '');
97
157
  (0, policy_1.assertUrlAllowed)(url, method, ctx.policy);
98
158
  }
99
159
  const tabId = (args) => (0, validators_1.optionalString)(args, 'tabId');
@@ -139,24 +199,24 @@ exports.TOOL_HANDLERS = {
139
199
  },
140
200
  navigate: async (a, ctx) => {
141
201
  const url = (0, validators_1.requireString)(a, 'url');
142
- await gate(ctx, 'navigate', url);
202
+ await gate(ctx, 'navigate', { url });
143
203
  return (0, envelopes_1.jsonResult)(await ctx.ex.navigate({ url, tabId: tabId(a), waitUntil: waitUntil(a) }));
144
204
  },
145
205
  back: async (a, ctx) => {
146
- await gate(ctx, 'back');
206
+ await gate(ctx, 'back', { tabId: tabId(a) });
147
207
  return (0, envelopes_1.jsonResult)(await ctx.ex.back(tabId(a)));
148
208
  },
149
209
  forward: async (a, ctx) => {
150
- await gate(ctx, 'forward');
210
+ await gate(ctx, 'forward', { tabId: tabId(a) });
151
211
  return (0, envelopes_1.jsonResult)(await ctx.ex.forward(tabId(a)));
152
212
  },
153
213
  reload: async (a, ctx) => {
154
- await gate(ctx, 'reload');
214
+ await gate(ctx, 'reload', { tabId: tabId(a) });
155
215
  return (0, envelopes_1.jsonResult)(await ctx.ex.reload({ tabId: tabId(a), waitUntil: waitUntil(a) }));
156
216
  },
157
217
  click: async (a, ctx) => {
158
218
  const t = (0, validators_1.requireTarget)(a);
159
- await gate(ctx, 'click');
219
+ await gate(ctx, 'click', { tabId: tabId(a) });
160
220
  return (0, envelopes_1.jsonResult)(await ctx.ex.click(t, {
161
221
  tabId: tabId(a),
162
222
  button: (0, validators_1.optionalString)(a, 'button'),
@@ -166,7 +226,7 @@ exports.TOOL_HANDLERS = {
166
226
  },
167
227
  type: async (a, ctx) => {
168
228
  const t = (0, validators_1.requireTarget)(a);
169
- await gate(ctx, 'type');
229
+ await gate(ctx, 'type', { tabId: tabId(a) });
170
230
  return (0, envelopes_1.jsonResult)(await ctx.ex.type(t, (0, validators_1.requireWithinLength)((0, validators_1.requireString)(a, 'text'), 'text', validators_1.MAX_TEXT_LEN), {
171
231
  tabId: tabId(a),
172
232
  clear: (0, validators_1.optionalBoolean)(a, 'clear'),
@@ -177,14 +237,14 @@ exports.TOOL_HANDLERS = {
177
237
  },
178
238
  select_option: async (a, ctx) => {
179
239
  const t = (0, validators_1.requireTarget)(a);
180
- await gate(ctx, 'type'); // mutating
240
+ await gate(ctx, 'type', { tabId: tabId(a) }); // mutating
181
241
  const values = (0, validators_1.optionalStringArray)(a, 'values');
182
242
  if (!values || values.length === 0)
183
243
  throw new validators_1.McpToolError('"values" must be a non-empty array of strings');
184
244
  return (0, envelopes_1.jsonResult)(await ctx.ex.selectOption(t, values, { tabId: tabId(a) }));
185
245
  },
186
246
  press: async (a, ctx) => {
187
- await gate(ctx, 'press');
247
+ await gate(ctx, 'press', { tabId: tabId(a) });
188
248
  return (0, envelopes_1.jsonResult)(await ctx.ex.press((0, validators_1.requireString)(a, 'key'), {
189
249
  tabId: tabId(a),
190
250
  modifiers: (0, validators_1.optionalStringArray)(a, 'modifiers'),
@@ -192,11 +252,11 @@ exports.TOOL_HANDLERS = {
192
252
  },
193
253
  hover: async (a, ctx) => {
194
254
  const t = (0, validators_1.requireTarget)(a);
195
- await gate(ctx, 'hover');
255
+ await gate(ctx, 'hover', { tabId: tabId(a) });
196
256
  return (0, envelopes_1.jsonResult)(await ctx.ex.hover(t, { tabId: tabId(a) }));
197
257
  },
198
258
  scroll: async (a, ctx) => {
199
- await gate(ctx, 'scroll');
259
+ await gate(ctx, 'scroll', { tabId: tabId(a) });
200
260
  return (0, envelopes_1.jsonResult)(await ctx.ex.scroll({
201
261
  tabId: tabId(a),
202
262
  x: (0, validators_1.optionalNumber)(a, 'x'),
@@ -207,7 +267,7 @@ exports.TOOL_HANDLERS = {
207
267
  }));
208
268
  },
209
269
  screenshot: async (a, ctx) => {
210
- await gate(ctx, 'screenshot');
270
+ await gate(ctx, 'screenshot', { tabId: tabId(a) });
211
271
  const shot = await ctx.ex.screenshot({
212
272
  tabId: tabId(a),
213
273
  fullPage: (0, validators_1.optionalBoolean)(a, 'fullPage'),
@@ -218,17 +278,17 @@ exports.TOOL_HANDLERS = {
218
278
  return (0, envelopes_1.imageResult)(shot.dataBase64, shot.mimeType, caption);
219
279
  },
220
280
  get_text: async (a, ctx) => {
221
- await gate(ctx, 'get_text');
281
+ await gate(ctx, 'get_text', { tabId: tabId(a) });
222
282
  const res = await ctx.ex.getText((0, validators_1.optionalTarget)(a), { tabId: tabId(a) });
223
283
  (0, workspace_1.saveResult)('get_text', 'json', JSON.stringify(res, null, 2));
224
284
  return (0, envelopes_1.jsonResult)(res);
225
285
  },
226
286
  get_html: async (a, ctx) => {
227
- await gate(ctx, 'get_html');
287
+ await gate(ctx, 'get_html', { tabId: tabId(a) });
228
288
  return (0, envelopes_1.jsonResult)(await ctx.ex.getHtml((0, validators_1.optionalTarget)(a), { tabId: tabId(a), outer: (0, validators_1.optionalBoolean)(a, 'outer') }));
229
289
  },
230
290
  snapshot: async (a, ctx) => {
231
- await gate(ctx, 'get_text'); // read of page structure
291
+ await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page structure
232
292
  return (0, envelopes_1.jsonResult)(await ctx.ex.snapshot({
233
293
  tabId: tabId(a),
234
294
  interactiveOnly: (0, validators_1.optionalBoolean)(a, 'interactiveOnly'),
@@ -236,13 +296,13 @@ exports.TOOL_HANDLERS = {
236
296
  }));
237
297
  },
238
298
  get_cookies: async (a, ctx) => {
239
- await gate(ctx, 'get_text'); // reads tab-scoped secrets; same domain gate as content reads
299
+ await gate(ctx, 'get_text', { tabId: tabId(a) }); // reads tab-scoped secrets; same domain gate as content reads
240
300
  return (0, envelopes_1.jsonResult)(await ctx.ex.getCookies({ tabId: tabId(a), url: (0, validators_1.optionalString)(a, 'url') }));
241
301
  },
242
302
  storage: async (a, ctx) => {
243
303
  const op = (0, validators_1.requireString)(a, 'op');
244
304
  // get is a read; set/remove/clear mutate.
245
- await gate(ctx, op === 'get' ? 'get_text' : 'type');
305
+ await gate(ctx, op === 'get' ? 'get_text' : 'type', { tabId: tabId(a) });
246
306
  if ((op === 'set' || op === 'remove') && !(0, validators_1.optionalString)(a, 'key')) {
247
307
  throw new validators_1.McpToolError(`storage "${op}" requires a "key"`);
248
308
  }
@@ -255,14 +315,14 @@ exports.TOOL_HANDLERS = {
255
315
  }));
256
316
  },
257
317
  eval: async (a, ctx) => {
258
- await gate(ctx, 'eval');
318
+ await gate(ctx, 'eval', { tabId: tabId(a) });
259
319
  return (0, envelopes_1.jsonResult)(await ctx.ex.eval((0, validators_1.requireString)(a, 'expression'), {
260
320
  tabId: tabId(a),
261
321
  awaitPromise: (0, validators_1.optionalBoolean)(a, 'awaitPromise'),
262
322
  }));
263
323
  },
264
324
  wait_for: async (a, ctx) => {
265
- await gate(ctx, 'wait_for');
325
+ await gate(ctx, 'wait_for', { tabId: tabId(a) });
266
326
  return (0, envelopes_1.jsonResult)(await ctx.ex.waitFor({
267
327
  tabId: tabId(a),
268
328
  selector: (0, validators_1.optionalString)(a, 'selector'),
@@ -272,7 +332,7 @@ exports.TOOL_HANDLERS = {
272
332
  }));
273
333
  },
274
334
  extract_links: async (a, ctx) => {
275
- await gate(ctx, 'get_text'); // read of page content
335
+ await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page content
276
336
  const res = await (0, helpers_1.extractLinks)(ctx.ex, {
277
337
  selector: (0, validators_1.optionalString)(a, 'selector'),
278
338
  sameOriginOnly: (0, validators_1.optionalBoolean)(a, 'sameOriginOnly'),
@@ -284,13 +344,13 @@ exports.TOOL_HANDLERS = {
284
344
  return (0, envelopes_1.jsonResult)(res);
285
345
  },
286
346
  read_as_markdown: async (a, ctx) => {
287
- await gate(ctx, 'get_text');
347
+ await gate(ctx, 'get_text', { tabId: tabId(a) });
288
348
  const md = await (0, helpers_1.readAsMarkdown)(ctx.ex, { selector: (0, validators_1.optionalString)(a, 'selector'), tabId: tabId(a) });
289
349
  (0, workspace_1.saveResult)('read_as_markdown', 'md', md);
290
350
  return (0, envelopes_1.textResult)(md);
291
351
  },
292
352
  fill_form: async (a, ctx) => {
293
- await gate(ctx, 'type'); // mutating
353
+ await gate(ctx, 'type', { tabId: tabId(a) }); // mutating
294
354
  const fields = a.fields;
295
355
  if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
296
356
  throw new validators_1.McpToolError('"fields" must be an object mapping selector -> string|boolean');
@@ -315,7 +375,7 @@ exports.TOOL_HANDLERS = {
315
375
  },
316
376
  upload_file: async (a, ctx) => {
317
377
  const t = (0, validators_1.requireTarget)(a);
318
- await gate(ctx, 'upload_file');
378
+ await gate(ctx, 'upload_file', { tabId: tabId(a) });
319
379
  const files = (0, validators_1.optionalStringArray)(a, 'files');
320
380
  if (!files || files.length === 0)
321
381
  throw new validators_1.McpToolError('"files" must be a non-empty array of absolute local paths');
@@ -349,7 +409,12 @@ exports.TOOL_HANDLERS = {
349
409
  // Dispatch (never-throw firewall)
350
410
  // ---------------------------------------------------------------------------
351
411
  function errMessage(err) {
352
- if (err instanceof validators_1.McpToolError || err instanceof types_1.ExecutorError)
412
+ // Only the text crosses the MCP boundary, so the code has to travel inside it —
413
+ // otherwise a caller cannot tell EXTENSION_DISCONNECTED (retry in a moment)
414
+ // from POLICY_DENIED (retrying will never help).
415
+ if (err instanceof types_1.ExecutorError)
416
+ return `[${err.code}] ${err.message}`;
417
+ if (err instanceof validators_1.McpToolError)
353
418
  return err.message;
354
419
  if (err instanceof Error)
355
420
  return `internal error: ${err.message}`;
@@ -2,6 +2,7 @@
2
2
  (() => {
3
3
  // shared/protocol.ts
4
4
  var PROTOCOL_VERSION = 1;
5
+ var WIRE_CAP_TAB_URL = "tab-url";
5
6
  var WIRE_METHODS = [
6
7
  "tabs_list",
7
8
  "tab_select",
@@ -63,7 +64,11 @@
63
64
  v: PROTOCOL_VERSION,
64
65
  token,
65
66
  ext: { id: chrome.runtime.id, version: chrome.runtime.getManifest().version, chrome: chromeVersion() },
66
- profile: profile && profile.trim() ? profile.trim() : void 0
67
+ profile: profile && profile.trim() ? profile.trim() : void 0,
68
+ // This build gates fail-closed and reports tab URLs on results, so the
69
+ // server may skip its pre-flight tabs_list. An older build omits this and
70
+ // the server keeps fetching the URL itself.
71
+ caps: [WIRE_CAP_TAB_URL]
67
72
  };
68
73
  ws2.send(JSON.stringify(hello));
69
74
  };
@@ -494,6 +499,9 @@
494
499
  const u = cmd.params.url;
495
500
  return typeof u === "string" ? u : "";
496
501
  }
502
+ return observedTabUrl(cmd);
503
+ }
504
+ async function observedTabUrl(cmd) {
497
505
  try {
498
506
  const tabId = await targetTab(cmd);
499
507
  const t = await chrome.tabs.get(tabId);
@@ -1124,6 +1132,12 @@
1124
1132
  async dispatch(cmd) {
1125
1133
  try {
1126
1134
  const policy = this.deps.getPolicy();
1135
+ if (!policy && cmd.method !== "ping_probe") {
1136
+ throw new CmdError(
1137
+ "POLICY_DENIED",
1138
+ "no policy has arrived from the chrome-mcp server yet, so this extension is refusing every command"
1139
+ );
1140
+ }
1127
1141
  if (policy) {
1128
1142
  const url = isUrlGated(cmd.method) ? await urlForCommand(cmd) : "";
1129
1143
  const verdict = evaluatePolicy(url, cmd.method, policy);
@@ -1131,6 +1145,8 @@
1131
1145
  }
1132
1146
  const data = await this.deps.exec.run(cmd);
1133
1147
  const frame = { type: "result", v: PROTOCOL_VERSION, id: cmd.id, ok: true, data };
1148
+ const tabUrl = await observedTabUrl(cmd);
1149
+ if (tabUrl) frame.tabUrl = tabUrl;
1134
1150
  this.deps.send(frame);
1135
1151
  } catch (err) {
1136
1152
  const code = err instanceof CmdError ? err.code : "CDP_ERROR";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehmoodqureshi/chrome-mcp",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "Drive your real Chrome browser over MCP — real logins, real cookies. A stdio MCP server (CLI) plus an MV3 extension, driving Chrome via chrome.scripting/chrome.tabs. Multi-tab batch automation, accessibility snapshots, deny-all security by default.",
5
5
  "author": "Mehmood Ur Rehman Qureshi",
6
6
  "license": "MIT",
@@ -32,12 +32,20 @@
32
32
  "keywords": [
33
33
  "mcp",
34
34
  "model-context-protocol",
35
+ "mcp-server",
35
36
  "chrome",
36
37
  "chrome-extension",
37
38
  "browser-automation",
38
- "cdp",
39
- "playwright",
40
- "claude"
39
+ "browser-mcp",
40
+ "real-browser",
41
+ "logged-in",
42
+ "session",
43
+ "cookies",
44
+ "authenticated",
45
+ "web-scraping",
46
+ "ai-agent",
47
+ "claude",
48
+ "claude-code"
41
49
  ],
42
50
  "scripts": {
43
51
  "build": "tsc -p tsconfig.json",