mcp-browser-extension 0.9.33 → 0.9.35

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
@@ -630,7 +630,7 @@ PDF is megabytes of base64 no model can read.
630
630
  ### Paying less per turn — `--tools`
631
631
 
632
632
  Every MCP server you connect costs context before you ask it anything: the host
633
- sends the whole tool catalog to the model on **every** turn. MCP Browser Extension's 40
633
+ sends the whole tool catalog to the model on **every** turn. MCP Browser Extension's 43
634
634
  tools are 28 KB of JSON Schema, about 7.1k tokens, on each one.
635
635
 
636
636
  Most runs need a handful of them. `--tools` advertises only those:
@@ -652,7 +652,7 @@ for a run that was never going to print a PDF or upload a file.
652
652
  `--unsafe-enable-eval`.
653
653
  - An unknown name fails at startup and prints the catalog, so a typo can never
654
654
  quietly drop `click` from the surface.
655
- - `mcp-browser-extension --help` prints the full catalog of 42 names to pick from.
655
+ - `mcp-browser-extension --help` prints the full catalog of 43 names to pick from.
656
656
 
657
657
  ## Status
658
658
 
@@ -822,6 +822,11 @@ live on the website: **[MCP Browser Extension docs](https://mcp-browser-extensio
822
822
  - [Use Claude in your signed-in Chrome](https://mcp-browser-extension.com/logged-in-chrome)
823
823
  - [Give Claude Code browser access](https://mcp-browser-extension.com/blog/claude-code-browser)
824
824
  - [Give Cursor a real browser with MCP](https://mcp-browser-extension.com/blog/cursor-browser-mcp)
825
+ - [Let VS Code Copilot use your real Chrome](https://mcp-browser-extension.com/blog/vscode-copilot-browser-mcp)
826
+ - [Test a login flow end to end with an AI agent](https://mcp-browser-extension.com/blog/test-login-flow-ai-agent)
827
+ - [Check a release page through a browser MCP tunnel](https://mcp-browser-extension.com/blog/check-release-page-remote-browser)
828
+ - [Chrome DevTools MCP vs Playwright MCP vs an MCP extension](https://mcp-browser-extension.com/blog/chrome-devtools-mcp-vs-playwright-mcp)
829
+ - [Best browser MCP servers in 2026](https://mcp-browser-extension.com/blog/best-browser-mcp-servers)
825
830
  - [How it compares with other browser MCP servers](https://mcp-browser-extension.com/docs/compare)
826
831
 
827
832
  ## Develop
package/SETUP.md CHANGED
@@ -20,7 +20,7 @@ inside Chrome's own UI (loading the extension, pasting the pairing token).
20
20
  catch-all pattern. Only enable `--unsafe-enable-eval` if the human asks by name.
21
21
  - Never print or log the pairing token. It lives in a 0600 file; show the human
22
22
  its path, not its contents.
23
- - Requires Node 18 or newer (`node --version`) and Chrome 116 or newer.
23
+ - Requires Node 20 or newer (`node --version`) and Chrome 116 or newer.
24
24
  - On Windows, MCP hosts must launch the server through `cmd /c npx ...`. See
25
25
  step 3.
26
26
 
@@ -186,7 +186,9 @@ function evaluatePolicy(url, method, policy) {
186
186
  * wait; only the person can approve the site in Chrome.
187
187
  */
188
188
  function blockedDomainMessage(host) {
189
- return `Please allow access to ${host}: Chrome → MCP Browser Extension icon → Allow next to ${host}, then try again.`;
189
+ // New installs sit inside Chrome's puzzle-piece menu, where the "?" badge is
190
+ // hidden, so the steps say where to find the icon.
191
+ return `Please allow access to ${host}: in Chrome, click the MCP Browser Extension icon (inside the puzzle-piece Extensions menu if it is not pinned), then click Allow next to ${host}.`;
190
192
  }
191
193
  /** A wire policy that allows nothing — the safe default when none was delivered. */
192
194
  exports.DENY_ALL_WIRE_POLICY = {
@@ -150,6 +150,9 @@ export interface HelloFrame extends BaseFrame {
150
150
  * Chrome profile. With no `profile` label, the server names the browser from it
151
151
  * ("default", "profile-2", ...) so two blank-profile browsers never collide. */
152
152
  installId?: string;
153
+ /** A name the pairing helper proposed (the server's --profile). Used only when
154
+ * no other browser has it; unlike `profile`, it never takes a name over. */
155
+ suggestedProfile?: string;
153
156
  /** How Chrome installed this copy (`chrome.management` installType):
154
157
  * "normal" for the store, "development" for unpacked. Unpacked builds carry
155
158
  * the store key, so the id alone cannot tell them apart. */
@@ -267,10 +270,14 @@ export interface SiteGrantFrame extends BaseFrame {
267
270
  host: string;
268
271
  allow: boolean;
269
272
  }
273
+ /** Extension → server: the person approved or revoked an app, so waiting calls can retry. */
274
+ export interface ApprovalChangedFrame extends BaseFrame {
275
+ type: 'approval_changed';
276
+ }
270
277
  /** Frames the SERVER sends to the extension. */
271
278
  export type ServerFrame = CommandFrame | WelcomeFrame | UnauthFrame | PingFrame | PolicyFrame | BlockedFrame;
272
279
  /** Frames the EXTENSION sends to the server. */
273
- export type ExtensionFrame = HelloFrame | ResultFrame | ErrorFrame | EventFrame | PongFrame | SiteGrantFrame;
280
+ export type ExtensionFrame = HelloFrame | ResultFrame | ErrorFrame | EventFrame | PongFrame | SiteGrantFrame | ApprovalChangedFrame;
274
281
  export type Frame = ServerFrame | ExtensionFrame;
275
282
  /** Shape of `$MCP_BROWSER_EXTENSION_DATA/handshake.json`. The token is a secret. */
276
283
  export interface HandshakeFile {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * src/bridge/chrome-profile-name.ts — the Chrome profile an extension install runs in.
3
+ *
4
+ * Chrome never tells an extension which profile it runs in, but this server runs
5
+ * on the same computer. Each profile keeps the extension's chrome.storage.local
6
+ * under `<profile>/Local Extension Settings/<extension id>/`, and that storage
7
+ * holds the install's random installId. Finding the id names the profile folder;
8
+ * `Local State` maps the folder to the name on Chrome's profile icon. Read-only
9
+ * and best effort: anything unclear returns undefined and naming falls back.
10
+ */
11
+ /**
12
+ * The display name of the Chrome profile holding this install, e.g. "Mehmood701".
13
+ * Only answers when exactly one profile matches.
14
+ */
15
+ export declare function chromeProfileName(extId: unknown, installId: string, roots?: string[]): string | undefined;
16
+ /** Routing keys for every Chrome profile on this computer, e.g. ["mehmood", "muhammad"]. */
17
+ export declare function chromeProfileKeys(roots?: string[]): string[];
18
+ /** A Chrome profile name as a routing key: "Mehmood 701" → "mehmood-701". */
19
+ export declare function profileKeyFromChromeName(name: string): string | undefined;
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ /**
3
+ * src/bridge/chrome-profile-name.ts — the Chrome profile an extension install runs in.
4
+ *
5
+ * Chrome never tells an extension which profile it runs in, but this server runs
6
+ * on the same computer. Each profile keeps the extension's chrome.storage.local
7
+ * under `<profile>/Local Extension Settings/<extension id>/`, and that storage
8
+ * holds the install's random installId. Finding the id names the profile folder;
9
+ * `Local State` maps the folder to the name on Chrome's profile icon. Read-only
10
+ * and best effort: anything unclear returns undefined and naming falls back.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.chromeProfileName = chromeProfileName;
14
+ exports.chromeProfileKeys = chromeProfileKeys;
15
+ exports.profileKeyFromChromeName = profileKeyFromChromeName;
16
+ const node_fs_1 = require("node:fs");
17
+ const node_path_1 = require("node:path");
18
+ const extension_repair_1 = require("../extension-repair");
19
+ /** Storage files bigger than this are skipped; ours are a few hundred KB at most. */
20
+ const MAX_FILE_BYTES = 8 * 1024 * 1024;
21
+ const found = new Map();
22
+ function profileNames(root) {
23
+ try {
24
+ const state = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(root, 'Local State'), 'utf8'));
25
+ const out = {};
26
+ for (const [dir, info] of Object.entries(state.profile?.info_cache ?? {})) {
27
+ if (typeof info?.name === 'string' && info.name.trim())
28
+ out[dir] = info.name.trim();
29
+ }
30
+ return out;
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
36
+ function storageHolds(folder, needle) {
37
+ let files;
38
+ try {
39
+ files = (0, node_fs_1.readdirSync)(folder).filter((f) => f.endsWith('.log') || f.endsWith('.ldb'));
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ for (const f of files) {
45
+ try {
46
+ const path = (0, node_path_1.join)(folder, f);
47
+ if ((0, node_fs_1.statSync)(path).size > MAX_FILE_BYTES)
48
+ continue;
49
+ if ((0, node_fs_1.readFileSync)(path).includes(needle))
50
+ return true;
51
+ }
52
+ catch {
53
+ /* locked or vanished: try the next file */
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+ /**
59
+ * The display name of the Chrome profile holding this install, e.g. "Mehmood701".
60
+ * Only answers when exactly one profile matches.
61
+ */
62
+ function chromeProfileName(extId, installId, roots = (0, extension_repair_1.browserDataDirs)()) {
63
+ if (typeof extId !== 'string' || !/^[a-p]{32}$/.test(extId))
64
+ return undefined;
65
+ const key = `${extId}/${installId}`;
66
+ if (found.has(key))
67
+ return found.get(key);
68
+ const needle = Buffer.from(installId, 'utf8');
69
+ const matches = [];
70
+ for (const root of roots) {
71
+ if (!(0, node_fs_1.existsSync)(root))
72
+ continue;
73
+ for (const [dir, name] of Object.entries(profileNames(root))) {
74
+ if (storageHolds((0, node_path_1.join)(root, dir, 'Local Extension Settings', extId), needle))
75
+ matches.push(name);
76
+ }
77
+ }
78
+ const name = matches.length === 1 ? matches[0] : undefined;
79
+ // Remember only a hit: a brand-new install may not have flushed its storage yet.
80
+ if (name)
81
+ found.set(key, name);
82
+ return name;
83
+ }
84
+ /** Routing keys for every Chrome profile on this computer, e.g. ["mehmood", "muhammad"]. */
85
+ function chromeProfileKeys(roots = (0, extension_repair_1.browserDataDirs)()) {
86
+ const keys = new Set();
87
+ for (const root of roots) {
88
+ if (!(0, node_fs_1.existsSync)(root))
89
+ continue;
90
+ for (const name of Object.values(profileNames(root))) {
91
+ const key = profileKeyFromChromeName(name);
92
+ if (key)
93
+ keys.add(key);
94
+ }
95
+ }
96
+ return [...keys];
97
+ }
98
+ /** A Chrome profile name as a routing key: "Mehmood 701" → "mehmood-701". */
99
+ function profileKeyFromChromeName(name) {
100
+ const key = name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|-+$/g, '');
101
+ return key || undefined;
102
+ }
103
+ //# sourceMappingURL=chrome-profile-name.js.map
@@ -29,6 +29,7 @@ export interface ConnectionDeps {
29
29
  onEvent?: (event: WireEvent, data: Record<string, unknown>) => void;
30
30
  /** The person allowed or removed a site on this browser's Options page. */
31
31
  onSiteGrant?: (host: unknown, allow: boolean) => void;
32
+ onApprovalChanged?: () => void;
32
33
  onClose?: (code: number) => void;
33
34
  onLog?: (message: string) => void;
34
35
  }
@@ -54,6 +55,7 @@ export declare class ExtensionConnection {
54
55
  private readonly tabUrls;
55
56
  private readonly onEvent?;
56
57
  private readonly onSiteGrant?;
58
+ private readonly onApprovalChanged?;
57
59
  private readonly onClose?;
58
60
  private readonly onLog?;
59
61
  constructor(deps: ConnectionDeps);
@@ -52,6 +52,7 @@ class ExtensionConnection {
52
52
  tabUrls = new Map();
53
53
  onEvent;
54
54
  onSiteGrant;
55
+ onApprovalChanged;
55
56
  onClose;
56
57
  onLog;
57
58
  constructor(deps) {
@@ -63,6 +64,7 @@ class ExtensionConnection {
63
64
  this.reportsTabUrl = this.caps.has(protocol_1.WIRE_CAP_TAB_URL);
64
65
  this.onEvent = deps.onEvent;
65
66
  this.onSiteGrant = deps.onSiteGrant;
67
+ this.onApprovalChanged = deps.onApprovalChanged;
66
68
  this.onClose = deps.onClose;
67
69
  this.onLog = deps.onLog;
68
70
  this.ws.on('message', (raw) => this.handleMessage(raw));
@@ -168,6 +170,9 @@ class ExtensionConnection {
168
170
  this.onSiteGrant?.(g.host, g.allow === true);
169
171
  break;
170
172
  }
173
+ case 'approval_changed':
174
+ this.onApprovalChanged?.();
175
+ break;
171
176
  default:
172
177
  // hello arrives only pre-auth (handled by the server); ignore here.
173
178
  break;
@@ -58,7 +58,11 @@ export interface PeerGrantFrame {
58
58
  host: string;
59
59
  allow: boolean;
60
60
  }
61
- export type HubToPeerFrame = PeerWelcomeFrame | PeerStateFrame | RelayResultFrame | PeerGrantFrame;
61
+ /** An app was approved in a browser: peers retry calls waiting on the person. */
62
+ export interface PeerWakeFrame {
63
+ type: 'peer_wake';
64
+ }
65
+ export type HubToPeerFrame = PeerWelcomeFrame | PeerStateFrame | RelayResultFrame | PeerGrantFrame | PeerWakeFrame;
62
66
  /** Encode a hub-side failure so the peer can rethrow it with its code intact. */
63
67
  export declare function relayError(id: string, err: unknown): RelayResultFrame;
64
68
  /**
@@ -18,11 +18,12 @@ exports.relayError = relayError;
18
18
  * file is 0600, so only the same OS user can read it — and speak a small
19
19
  * server-to-server protocol that the extension never sees:
20
20
  * peer → hub: peer_hello, relay (a wire command), peer_rename, peer_blocked
21
- * hub → peer: peer_welcome, peer_state (paired browsers), relay_result, peer_grant
21
+ * hub → peer: peer_welcome, peer_state (paired browsers), relay_result, peer_grant, peer_wake
22
22
  */
23
23
  const ws_1 = require("ws");
24
24
  const protocol_1 = require("../../shared/protocol");
25
25
  const types_1 = require("../executor/types");
26
+ const person_wait_1 = require("../mcp/person-wait");
26
27
  /** How long a joining server waits for the hub's welcome before giving up. */
27
28
  exports.JOIN_TIMEOUT_MS = 2_000;
28
29
  /** Slack on top of the hub's own command timeout, so the hub always answers first. */
@@ -182,6 +183,10 @@ class HubLink {
182
183
  this.onGrant?.(frame.host, frame.allow === true);
183
184
  return;
184
185
  }
186
+ if (frame.type === 'peer_wake') {
187
+ (0, person_wait_1.notifyPersonStep)();
188
+ return;
189
+ }
185
190
  if (frame.type !== 'relay_result')
186
191
  return;
187
192
  const p = this.pending.get(frame.id);
@@ -28,11 +28,18 @@ export declare class ProfileRegistry {
28
28
  /** The install that owns `name` in the registry, if any. */
29
29
  ownerOf(name: string): string | undefined;
30
30
  /**
31
- * Name for an install with no explicit label: its remembered name, else the
32
- * first of "default", "profile-2", ... that no other install owns and
33
- * `isLive(name)` doesn't report as held by someone else right now.
31
+ * Name for an install with no explicit label, first that fits:
32
+ * 1. a name given with profile_rename, kept for good;
33
+ * 2. each of `preferred` in order (the pairing helper's suggestion, then the
34
+ * Chrome profile's own name), but only while no other install owns it or
35
+ * holds it live: a preference never takes a name from another browser;
36
+ * 3. `own`, the Chrome profile's own name: unlike a preference it may be
37
+ * taken back from an install that is offline (an old copy that profile
38
+ * no longer runs), unless that one was named with profile_rename;
39
+ * 4. its remembered name, unless another browser holds it right now;
40
+ * 5. the first of "default", "profile-2", ... nobody owns or holds.
34
41
  */
35
- assign(installId: string, isLive: (name: string) => boolean): string;
42
+ assign(installId: string, isLive: (name: string) => boolean, preferred?: (string | undefined)[], own?: string): string;
36
43
  /**
37
44
  * Give the install currently named `from` the name `to`. Throws when `from`
38
45
  * isn't an auto-named install or `to` is already owned by another one; the
@@ -47,24 +47,49 @@ class ProfileRegistry {
47
47
  return undefined;
48
48
  }
49
49
  /**
50
- * Name for an install with no explicit label: its remembered name, else the
51
- * first of "default", "profile-2", ... that no other install owns and
52
- * `isLive(name)` doesn't report as held by someone else right now.
50
+ * Name for an install with no explicit label, first that fits:
51
+ * 1. a name given with profile_rename, kept for good;
52
+ * 2. each of `preferred` in order (the pairing helper's suggestion, then the
53
+ * Chrome profile's own name), but only while no other install owns it or
54
+ * holds it live: a preference never takes a name from another browser;
55
+ * 3. `own`, the Chrome profile's own name: unlike a preference it may be
56
+ * taken back from an install that is offline (an old copy that profile
57
+ * no longer runs), unless that one was named with profile_rename;
58
+ * 4. its remembered name, unless another browser holds it right now;
59
+ * 5. the first of "default", "profile-2", ... nobody owns or holds.
53
60
  */
54
- assign(installId, isLive) {
61
+ assign(installId, isLive, preferred = [], own) {
55
62
  const now = new Date().toISOString();
56
63
  const known = this.installs[installId];
57
- if (known) {
58
- known.lastSeen = now;
64
+ const keep = (name, renamed = false) => {
65
+ this.installs[installId] = { name, firstSeen: known?.firstSeen ?? now, lastSeen: now, ...(renamed ? { renamed } : {}) };
59
66
  this.save();
60
- return known.name;
67
+ return name;
68
+ };
69
+ if (known?.renamed && !isLive(known.name))
70
+ return keep(known.name, true);
71
+ for (const name of preferred) {
72
+ if (!name)
73
+ continue;
74
+ const owner = this.ownerOf(name);
75
+ if ((owner === undefined || owner === installId) && !isLive(name))
76
+ return keep(name);
61
77
  }
78
+ if (own && !isLive(own)) {
79
+ const owner = this.ownerOf(own);
80
+ if (owner === undefined || owner === installId || !this.installs[owner].renamed) {
81
+ // The old owner is offline; if it ever returns it is named afresh.
82
+ if (owner !== undefined && owner !== installId)
83
+ delete this.installs[owner];
84
+ return keep(own);
85
+ }
86
+ }
87
+ if (known && !isLive(known.name))
88
+ return keep(known.name);
62
89
  let name = exports.FIRST_AUTO_NAME;
63
- for (let n = 2; this.ownerOf(name) !== undefined || isLive(name); n++)
90
+ for (let n = 2; (this.ownerOf(name) ?? installId) !== installId || isLive(name); n++)
64
91
  name = `profile-${n}`;
65
- this.installs[installId] = { name, firstSeen: now, lastSeen: now };
66
- this.save();
67
- return name;
92
+ return keep(name);
68
93
  }
69
94
  /**
70
95
  * Give the install currently named `from` the name `to`. Throws when `from`
@@ -83,6 +108,7 @@ class ProfileRegistry {
83
108
  if (this.ownerOf(target) !== undefined)
84
109
  throw new Error(`profile "${target}" is already taken`);
85
110
  this.installs[id].name = target;
111
+ this.installs[id].renamed = true;
86
112
  this.save();
87
113
  return target;
88
114
  }
@@ -28,6 +28,8 @@ export interface PairedProfile {
28
28
  /** store = the Chrome Web Store copy (Chrome updates it); unpacked = any other id. */
29
29
  extSource?: 'store' | 'unpacked';
30
30
  }
31
+ /** Hub → peer: the proof was signed for an older connection of this browser. */
32
+ export declare const STALE_RELAY_MESSAGE = "The browser reconnected while this command was being sent. Retry it.";
31
33
  export interface DisplacementInfo {
32
34
  oldExtId: string;
33
35
  newExtId: string;
@@ -48,6 +50,13 @@ export interface BridgeOptions {
48
50
  siteGrants?: boolean;
49
51
  /** The extension version bundled with this server: what an up-to-date copy reports. */
50
52
  latestExtensionVersion?: string;
53
+ /** Reads Chrome's profiles on this computer for naming; null turns it off (tests). */
54
+ chromeProfiles?: {
55
+ nameOf(extId: unknown, installId: string): string | undefined;
56
+ keys(): string[];
57
+ } | null;
58
+ /** The name the pairing helper hands every browser (this server's --profile). */
59
+ helperProfile?: string;
51
60
  port?: number;
52
61
  host?: string;
53
62
  heartbeatMs?: number;
@@ -115,6 +124,8 @@ export declare class BridgeServer {
115
124
  * the live allowlist, saves it, and pushes it to every browser and peer.
116
125
  */
117
126
  private applySiteGrant;
127
+ /** The person approved or revoked an app in a browser: wake waiting calls here and on peers. */
128
+ private approvalChanged;
118
129
  /** Peer side: the hub reports a grant made in a browser; mirror it in our own gate. */
119
130
  private mirrorSiteGrant;
120
131
  /** 'hub' or 'peer' once started; null before start and between roles. */
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BridgeServer = exports.WEB_STORE_URL = void 0;
3
+ exports.BridgeServer = exports.STALE_RELAY_MESSAGE = exports.WEB_STORE_URL = void 0;
4
4
  const client_auth_1 = require("../../shared/client-auth");
5
5
  const client_identity_1 = require("./client-identity");
6
+ const chrome_profile_name_1 = require("./chrome-profile-name");
6
7
  /**
7
8
  * src/bridge/server.ts — the localhost WebSocket bridge.
8
9
  *
@@ -31,6 +32,7 @@ const profiles_1 = require("./profiles");
31
32
  const peer_1 = require("./peer");
32
33
  const site_grants_1 = require("./site-grants");
33
34
  const datadir_1 = require("./datadir");
35
+ const person_wait_1 = require("../mcp/person-wait");
34
36
  /** Where a person gets the extension. */
35
37
  exports.WEB_STORE_URL = protocol_1.STORE_EXTENSION_URL;
36
38
  /** The routing label for a hello with no/blank profile — the back-compat default. */
@@ -69,6 +71,8 @@ function routeKey(profile) {
69
71
  return DEFAULT_PROFILE;
70
72
  }
71
73
  }
74
+ /** Hub → peer: the proof was signed for an older connection of this browser. */
75
+ exports.STALE_RELAY_MESSAGE = 'The browser reconnected while this command was being sent. Retry it.';
72
76
  const HELLO_TIMEOUT_MS = 5_000;
73
77
  const DEFAULT_HEARTBEAT_MS = 15_000;
74
78
  /** Max pre-auth frames a socket may send before a valid hello (anti-idle-hold). */
@@ -167,12 +171,20 @@ class BridgeServer {
167
171
  conn.push(frame);
168
172
  for (const peer of this.peers)
169
173
  this.sendPeer(peer, { type: 'peer_grant', host: site, allow });
174
+ (0, person_wait_1.notifyPersonStep)();
175
+ }
176
+ /** The person approved or revoked an app in a browser: wake waiting calls here and on peers. */
177
+ approvalChanged() {
178
+ for (const peer of this.peers)
179
+ this.sendPeer(peer, { type: 'peer_wake' });
180
+ (0, person_wait_1.notifyPersonStep)();
170
181
  }
171
182
  /** Peer side: the hub reports a grant made in a browser; mirror it in our own gate. */
172
183
  mirrorSiteGrant(host, allow) {
173
184
  // The hub already saved it (to its data dir, normally the same one).
174
185
  if (this.grants?.apply(host, allow, false))
175
186
  this.log(`${allow ? 'allowed' : 'removed'} site "${host}" (shared by the hub)`);
187
+ (0, person_wait_1.notifyPersonStep)();
176
188
  }
177
189
  /** 'hub' or 'peer' once started; null before start and between roles. */
178
190
  get role() {
@@ -390,13 +402,30 @@ class BridgeServer {
390
402
  if (!(0, client_auth_1.isHealthProbe)(intent)) {
391
403
  if (!this.hasConnection(profile))
392
404
  throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', this.noPairMessage(profile));
393
- if (!challenge)
394
- throw new types_1.ExecutorError('POLICY_DENIED', 'Update the extension and MCP server together: this browser cannot enforce client approval yet.');
405
+ if (!challenge) {
406
+ throw new types_1.ExecutorError('POLICY_DENIED', `The browser paired as "${profile}" is running extension code too old to check app approvals. ` +
407
+ 'Chrome can keep running old code even after the files update: open chrome://extensions in that Chrome profile ' +
408
+ 'and click Reload on MCP Browser Extension (or update it from the Chrome Web Store), then retry.');
409
+ }
395
410
  authorization = ((0, client_identity_1.currentClientIdentity)() ?? this.clientIdentity).sign(intent, challenge);
396
411
  }
397
412
  const options = { ...opts, timeoutMs, authorization };
398
- if (this.hub)
399
- return this.hub.relay(method, params, options);
413
+ if (this.hub) {
414
+ try {
415
+ return await this.hub.relay(method, params, options);
416
+ }
417
+ catch (err) {
418
+ // The browser reconnected between our signature and the hub's send. The
419
+ // hub pushed its new challenge before refusing, so re-sign once.
420
+ if (!(err instanceof types_1.ExecutorError) || err.message !== exports.STALE_RELAY_MESSAGE || !authorization)
421
+ throw err;
422
+ const fresh = this.hub?.profiles.find((p) => p.name === profile)?.approvalChallenge;
423
+ if (!this.hub || !fresh || fresh === challenge)
424
+ throw err;
425
+ const resigned = ((0, client_identity_1.currentClientIdentity)() ?? this.clientIdentity).sign(intent, fresh);
426
+ return this.hub.relay(method, params, { ...options, authorization: resigned });
427
+ }
428
+ }
400
429
  return this.forwardCommand(method, params, options);
401
430
  }
402
431
  /** Never sign a peer's request using the hub's identity. Chrome verifies its original proof. */
@@ -408,6 +437,12 @@ class BridgeServer {
408
437
  if (!(0, client_auth_1.isHealthProbe)({ method, params }) && (!conn.hasCap(client_auth_1.CLIENT_APPROVAL_CAP) || !opts?.authorization)) {
409
438
  throw new types_1.ExecutorError('POLICY_DENIED', 'Signed client authorization and an updated extension are required.');
410
439
  }
440
+ // A peer signs with the challenge it last heard about. If this browser has
441
+ // reconnected since, Chrome would refuse the proof as stale: say so instead,
442
+ // so the peer re-signs (it already has the new challenge from broadcastState).
443
+ if (opts?.authorization && conn.approvalChallenge && opts.authorization.challenge !== conn.approvalChallenge) {
444
+ throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', exports.STALE_RELAY_MESSAGE);
445
+ }
411
446
  return conn.sendCommand(method, params, opts);
412
447
  }
413
448
  /** Whether the browser paired as `profile` advertised capability `cap`. A peer
@@ -665,15 +700,39 @@ class BridgeServer {
665
700
  */
666
701
  nameFor(frame) {
667
702
  const installId = (0, profiles_1.isValidInstallId)(frame.installId) ? frame.installId : undefined;
668
- if (frame.profile && frame.profile.trim())
669
- return { profile: routeKey(frame.profile), naming: 'options', installId };
703
+ const helperProfile = this.opts.helperProfile ?? (0, config_1.resolveProfile)();
704
+ let suggested;
705
+ if (typeof frame.suggestedProfile === 'string' && frame.suggestedProfile.trim()) {
706
+ try {
707
+ suggested = (0, config_1.sanitizeName)(frame.suggestedProfile, 'profile');
708
+ }
709
+ catch { /* ignore an unusable hint */ }
710
+ }
711
+ if (frame.profile && frame.profile.trim()) {
712
+ const label = routeKey(frame.profile);
713
+ // Extensions before 0.9.35 saved the helper's name as if it was typed, so
714
+ // every browser paired with Connect claimed it and knocked the others off.
715
+ // That exact name is a suggestion; any other label is the person's choice.
716
+ if (!installId || helperProfile === DEFAULT_PROFILE || label !== helperProfile) {
717
+ return { profile: label, naming: 'options', installId };
718
+ }
719
+ suggested = label;
720
+ }
670
721
  if (!installId)
671
722
  return { profile: DEFAULT_PROFILE, naming: 'legacy' };
672
723
  const heldByOther = (name) => {
673
724
  const conn = this.conns.get(name);
674
725
  return !!conn && conn.isOpen() && this.origins.get(name)?.installId !== installId;
675
726
  };
676
- return { profile: this.registry.assign(installId, heldByOther), naming: 'auto', installId };
727
+ // Chrome won't tell an extension its profile, but we can look it up on disk.
728
+ const chrome = this.opts.chromeProfiles === null ? null
729
+ : this.opts.chromeProfiles ?? { nameOf: chrome_profile_name_1.chromeProfileName, keys: () => (0, chrome_profile_name_1.chromeProfileKeys)() };
730
+ const own = chrome?.nameOf(frame.ext?.id, installId);
731
+ const ownKey = own ? (0, chrome_profile_name_1.profileKeyFromChromeName)(own) : undefined;
732
+ // Never hand a browser the name of a different Chrome profile on this computer.
733
+ if (suggested && suggested !== ownKey && chrome?.keys().includes(suggested))
734
+ suggested = undefined;
735
+ return { profile: this.registry.assign(installId, heldByOther, [suggested], ownKey), naming: 'auto', installId };
677
736
  }
678
737
  /** Hub side: serve a joined peer's relayed commands against our browsers. */
679
738
  acceptPeer(ws, pid) {
@@ -758,6 +817,7 @@ class BridgeServer {
758
817
  approvalChallenge: origin.approvalChallenge,
759
818
  onEvent: this.opts.onEvent,
760
819
  onSiteGrant: (host, allow) => this.applySiteGrant(host, allow),
820
+ onApprovalChanged: () => this.approvalChanged(),
761
821
  onLog: (m) => this.log(m),
762
822
  onClose: () => {
763
823
  // Only clear if a newer re-pair hasn't already replaced this slot. Look it
@@ -0,0 +1,37 @@
1
+ /**
2
+ * src/mcp/person-wait.ts — hold a tool call open while the PERSON approves the
3
+ * app or allows a site in Chrome, then continue on its own.
4
+ *
5
+ * Without this the agent gets an error, tells the person, and has to invent its
6
+ * own polling (shell sleeps, "I'll check again in a minute"). Waiting here means
7
+ * one tool call simply completes once the person clicks Approve/Allow.
8
+ */
9
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
10
+ export interface PersonWaitRequest {
11
+ signal?: AbortSignal;
12
+ progressToken?: string | number;
13
+ sendNotification?: (n: {
14
+ method: 'notifications/progress';
15
+ params: {
16
+ progressToken: string | number;
17
+ progress: number;
18
+ message: string;
19
+ };
20
+ }) => Promise<void>;
21
+ }
22
+ export declare function setPersonWaitForTesting(poll: number, wait: number): void;
23
+ /**
24
+ * The person just approved an app or allowed a site: retry every waiting call
25
+ * now instead of on the next fallback poll.
26
+ */
27
+ export declare function notifyPersonStep(): void;
28
+ /** The person-only step a result is blocked on, or undefined when it is not. */
29
+ export declare function personStep(result: CallToolResult): string | undefined;
30
+ export declare const STILL_WAITING: string;
31
+ /**
32
+ * Run `attempt` until it is no longer blocked on the person, the wait expires,
33
+ * or the call is cancelled. Only results that ran nothing (approval / site
34
+ * gate refusals) are retried. `retry` is true on every attempt after the
35
+ * first, so the caller can count one refusal per wait instead of one per poll.
36
+ */
37
+ export declare function waitForPerson(attempt: (retry: boolean) => Promise<CallToolResult>, request: PersonWaitRequest): Promise<CallToolResult>;
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ /**
3
+ * src/mcp/person-wait.ts — hold a tool call open while the PERSON approves the
4
+ * app or allows a site in Chrome, then continue on its own.
5
+ *
6
+ * Without this the agent gets an error, tells the person, and has to invent its
7
+ * own polling (shell sleeps, "I'll check again in a minute"). Waiting here means
8
+ * one tool call simply completes once the person clicks Approve/Allow.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.STILL_WAITING = void 0;
12
+ exports.setPersonWaitForTesting = setPersonWaitForTesting;
13
+ exports.notifyPersonStep = notifyPersonStep;
14
+ exports.personStep = personStep;
15
+ exports.waitForPerson = waitForPerson;
16
+ // A fallback only: an approval or site grant wakes the wait at once (below).
17
+ let pollMs = 10_000;
18
+ // Stay under the 60 s default tool timeout some MCP clients enforce.
19
+ let waitMs = 45_000;
20
+ function setPersonWaitForTesting(poll, wait) {
21
+ pollMs = Math.max(1, poll);
22
+ waitMs = Math.max(1, wait);
23
+ }
24
+ const wakers = new Set();
25
+ /**
26
+ * The person just approved an app or allowed a site: retry every waiting call
27
+ * now instead of on the next fallback poll.
28
+ */
29
+ function notifyPersonStep() {
30
+ for (const wake of [...wakers])
31
+ wake();
32
+ }
33
+ const APPROVAL = /Please approve "/;
34
+ const SITE = /^\[POLICY_DENIED\] Please allow access to /;
35
+ /** The person-only step a result is blocked on, or undefined when it is not. */
36
+ function personStep(result) {
37
+ if (!result.isError)
38
+ return undefined;
39
+ const first = result.content[0];
40
+ const text = first?.type === 'text' ? first.text : '';
41
+ return APPROVAL.test(text) || SITE.test(text) ? text : undefined;
42
+ }
43
+ exports.STILL_WAITING = 'Still waiting for the person. Call the same tool again to keep waiting; it continues on its own once they approve. ' +
44
+ 'Do not use shell sleeps or ask them to confirm in chat.';
45
+ /**
46
+ * Run `attempt` until it is no longer blocked on the person, the wait expires,
47
+ * or the call is cancelled. Only results that ran nothing (approval / site
48
+ * gate refusals) are retried. `retry` is true on every attempt after the
49
+ * first, so the caller can count one refusal per wait instead of one per poll.
50
+ */
51
+ async function waitForPerson(attempt, request) {
52
+ let result = await attempt(false);
53
+ let step = personStep(result);
54
+ if (!step)
55
+ return result;
56
+ const deadline = Date.now() + waitMs;
57
+ let progress = 0;
58
+ const report = async (message) => {
59
+ if (request.progressToken === undefined || !request.sendNotification || request.signal?.aborted)
60
+ return;
61
+ try {
62
+ await request.sendNotification({ method: 'notifications/progress', params: { progressToken: request.progressToken, progress: ++progress, message } });
63
+ }
64
+ catch { /* Progress is optional; the tool result still carries the outcome. */ }
65
+ };
66
+ await report(`${step} Waiting for this automatically.`);
67
+ while (Date.now() < deadline && !request.signal?.aborted) {
68
+ await new Promise((resolve) => {
69
+ const timer = setTimeout(done, Math.min(pollMs, Math.max(1, deadline - Date.now())));
70
+ function done() { clearTimeout(timer); wakers.delete(done); request.signal?.removeEventListener('abort', done); resolve(); }
71
+ wakers.add(done);
72
+ request.signal?.addEventListener('abort', done, { once: true });
73
+ });
74
+ if (request.signal?.aborted)
75
+ break;
76
+ result = await attempt(true);
77
+ const next = personStep(result);
78
+ if (!next)
79
+ return result;
80
+ // Approving the app can uncover the site gate; say what is awaited now.
81
+ if (next !== step)
82
+ await report(`${next} Waiting for this automatically.`);
83
+ step = next;
84
+ }
85
+ return { ...result, content: [...result.content, { type: 'text', text: exports.STILL_WAITING }] };
86
+ }
87
+ //# sourceMappingURL=person-wait.js.map
@@ -38,6 +38,8 @@ const SERVER_VERSION = '0.1.0';
38
38
  exports.SERVER_INSTRUCTIONS = "MCP Browser Extension drives the user's real, signed-in Chrome. " +
39
39
  'On the first browser tool call, enter your email in the MCP input prompt and open the sign-in link sent to you, then click Verify this device on the website. If it returns ACCESS_INPUT_REQUIRED, the local verification form opens automatically in the browser; also show its URL as a backup so the user can enter their email. Do not open the backup URL again or add another chat email prompt while that form is active. Do not fill it or read their email for them. The tool waits for verification, reports the result, and continues automatically on success. If it returns ACCESS_PENDING, call chrome_status again to keep waiting; do not ask for a chat reply or another email. Only ask the user to click Connect if the result says Chrome is not paired. ' +
40
40
  'When Chrome approval is required, show the app name and key fingerprint and ask the person to review App connections in the extension popup or Settings. Approval lasts eight hours. Never approve a client yourself; YOLO and email-verification bypass do not bypass Chrome approval. ' +
41
+ 'Approval and site refusals wait automatically: the tool call stays open (about 45 seconds) and continues on its own as soon as the person clicks Approve or Allow. Tell the person once. If it returns "Still waiting", call the same tool again; never use shell sleeps, timers or "I will check again later" messages, and never ask them to confirm in chat. ' +
42
+ 'Chrome blocks every extension from Chrome Web Store pages (chromewebstore.google.com and chrome.google.com/webstore, including the developer dashboard): they fail with TARGET_GONE "The extensions gallery cannot be scripted". Do not ask the person to allow those sites; it cannot help. ' +
41
43
  'Start with chrome_status. If it reports no paired browser (or any tool fails with NO_BACKEND), do not retry: ' +
42
44
  'give the user the steps in its `setup` field, which name what went wrong and how to fix it. ' +
43
45
  'If a tool fails with POLICY_DENIED because a site is not allowed, ask the user to allow it from the extension\'s ' +
@@ -46,6 +48,8 @@ exports.SERVER_INSTRUCTIONS = "MCP Browser Extension drives the user's real, sig
46
48
  "To act on the user's current page, pass its tabId from tabs_list.";
47
49
  const LOCAL_BYPASS_INSTRUCTIONS = 'Email verification is disabled by the local operator for this server. Only trusted local MCP clients should connect; the browser-domain allowlist and other capability safety gates remain active. ' +
48
50
  'When Chrome approval is required, show the app name and key fingerprint and ask the person to review App connections in the extension popup or Settings. Approval lasts eight hours. Never approve a client yourself; YOLO and email-verification bypass do not bypass Chrome approval. ' +
51
+ 'Approval and site refusals wait automatically: the tool call stays open (about 45 seconds) and continues on its own as soon as the person clicks Approve or Allow. Tell the person once. If it returns "Still waiting", call the same tool again; never use shell sleeps, timers or "I will check again later" messages, and never ask them to confirm in chat. ' +
52
+ 'Chrome blocks every extension from Chrome Web Store pages (chromewebstore.google.com and chrome.google.com/webstore, including the developer dashboard): they fail with TARGET_GONE "The extensions gallery cannot be scripted". Do not ask the person to allow those sites; it cannot help. ' +
49
53
  'Start with chrome_status. If it reports no paired browser (or any tool fails with NO_BACKEND), give the user the steps in its `setup` field. ' +
50
54
  'If a tool fails with POLICY_DENIED because a site is not allowed, relay its friendly one-line approval steps and wait for the person to allow it from the extension\'s toolbar icon. Never approve it yourself or enable all-site access. ' +
51
55
  "Work in your own tab: tab_new opens one in the background. To act on the user's current page, pass its tabId from tabs_list.";
@@ -63,7 +63,9 @@ export declare function setProfileBridge(bridge: BridgeServer | null): void;
63
63
  export declare const TOOL_HANDLERS: Record<string, ToolHandler>;
64
64
  /** Reset limiter state — for tests that exercise the ceiling. */
65
65
  export declare function resetRateLimiter(): void;
66
- export declare function dispatchToolCall(name: string, rawArgs: unknown): Promise<CallToolResult>;
66
+ export declare function dispatchToolCall(name: string, rawArgs: unknown, opts?: {
67
+ quietRefusal?: boolean;
68
+ }): Promise<CallToolResult>;
67
69
  /** Assert the catalog and the dispatch table describe the same tool set. */
68
70
  export declare function assertNoDrift(): void;
69
71
  /** Register the tool surface; returns the names actually advertised. */
@@ -35,6 +35,7 @@ const policy_2 = require("../../shared/policy");
35
35
  const envelopes_1 = require("./envelopes");
36
36
  const limits_1 = require("./limits");
37
37
  const batch_1 = require("./batch");
38
+ const person_wait_1 = require("./person-wait");
38
39
  const helpers_1 = require("./helpers");
39
40
  const redact_1 = require("./redact");
40
41
  const locate_1 = require("./locate");
@@ -1429,7 +1430,7 @@ const RETRY_SAFE_TOOLS = new Set([
1429
1430
  function isRetryableFault(name, err) {
1430
1431
  return err instanceof types_1.ExecutorError && err.code === 'EXTENSION_DISCONNECTED' && RETRY_SAFE_TOOLS.has(name);
1431
1432
  }
1432
- async function dispatchToolCall(name, rawArgs) {
1433
+ async function dispatchToolCall(name, rawArgs, opts = {}) {
1433
1434
  // The allowlist is checked here, not only at registration, so a `batch` op
1434
1435
  // cannot reach a tool the operator kept off the surface.
1435
1436
  if (!isToolEnabled(name))
@@ -1442,10 +1443,17 @@ async function dispatchToolCall(name, rawArgs) {
1442
1443
  const started = Date.now();
1443
1444
  // One audit record per call, carried through async hops so a parallel `batch`
1444
1445
  // cannot cross-attribute one op's target URL to another's log line.
1445
- const { result: outcome } = await (0, audit_1.withAudit)(async (audit) => dispatchInner(name, handler, rawArgs, audit, started));
1446
+ const { result: outcome } = await (0, audit_1.withAudit)(async (audit) => dispatchInner(name, handler, rawArgs, audit, started, opts.quietRefusal === true));
1446
1447
  return outcome;
1447
1448
  }
1448
- async function dispatchInner(name, handler, rawArgs, audit, started) {
1449
+ async function dispatchInner(name, handler, rawArgs, audit, started, quietRefusal) {
1450
+ // A person-wait poll that is still refused ran nothing; the first refusal
1451
+ // was already counted, so recording every poll would log ~30 per wait.
1452
+ const record = (result, extra) => {
1453
+ if (quietRefusal && (0, person_wait_1.personStep)(result))
1454
+ return;
1455
+ recordHistory(name, rawArgs, !result.isError, extra);
1456
+ };
1449
1457
  try {
1450
1458
  const mgr = (0, manager_1.getManager)();
1451
1459
  // Workspace-management tools run server-side and must work even with no
@@ -1470,13 +1478,14 @@ async function dispatchInner(name, handler, rawArgs, audit, started) {
1470
1478
  result = await handler((0, validators_1.asArgs)(rawArgs), { ex: reconnected, policy: mgr.policy });
1471
1479
  (0, log_1.logDebug)(`${name}: retry after reconnect succeeded`);
1472
1480
  }
1473
- recordHistory(name, rawArgs, !result.isError, { ms: Date.now() - started, audit });
1481
+ record(result, { ms: Date.now() - started, audit });
1474
1482
  return result;
1475
1483
  }
1476
1484
  catch (err) {
1477
1485
  const message = errMessage(err);
1478
- recordHistory(name, rawArgs, false, { error: message, ms: Date.now() - started, audit });
1479
- return (0, envelopes_1.errorResult)(message);
1486
+ const result = (0, envelopes_1.errorResult)(message);
1487
+ record(result, { error: message, ms: Date.now() - started, audit });
1488
+ return result;
1480
1489
  }
1481
1490
  }
1482
1491
  /** Assert the catalog and the dispatch table describe the same tool set. */
@@ -1544,17 +1553,25 @@ function registerTools(server, policy, accessGuard = access_1.requireAccess) {
1544
1553
  });
1545
1554
  if (accessError)
1546
1555
  return accessError;
1547
- if (profileBridge?.hasActiveExtension()) {
1548
- const active = (0, workspace_1.peekActiveWorkspace)()?.profile ?? 'default';
1549
- const profile = profileBridge.hasConnection(active) ? active : profileBridge.pairedProfiles()[0]?.name;
1550
- try {
1551
- await profileBridge.sendCommand('ping_probe', { requireClientApproval: true }, { profile });
1556
+ // Approval and site refusals run nothing, so the call waits for the
1557
+ // person here and continues once they click Approve or Allow.
1558
+ const result = await (0, person_wait_1.waitForPerson)(async (retry) => {
1559
+ if (profileBridge?.hasActiveExtension()) {
1560
+ const active = (0, workspace_1.peekActiveWorkspace)()?.profile ?? 'default';
1561
+ const profile = profileBridge.hasConnection(active) ? active : profileBridge.pairedProfiles()[0]?.name;
1562
+ try {
1563
+ await profileBridge.sendCommand('ping_probe', { requireClientApproval: true }, { profile });
1564
+ }
1565
+ catch (err) {
1566
+ return (0, envelopes_1.errorResult)(err instanceof Error ? err.message : 'Chrome client approval is required.');
1567
+ }
1552
1568
  }
1553
- catch (err) {
1554
- return (0, envelopes_1.errorResult)(err instanceof Error ? err.message : 'Chrome client approval is required.');
1555
- }
1556
- }
1557
- const result = await dispatchToolCall(d.name, args);
1569
+ return dispatchToolCall(d.name, args, { quietRefusal: retry });
1570
+ }, {
1571
+ signal: extra.signal,
1572
+ progressToken: extra._meta?.progressToken,
1573
+ sendNotification: extra.sendNotification,
1574
+ });
1558
1575
  if (verifiedNow)
1559
1576
  result.content.unshift({ type: 'text', text: 'Email verified. MCP Browser Extension access is active.' });
1560
1577
  return result;
@@ -1,5 +1,11 @@
1
1
  # Client approval
2
2
 
3
+ Related security steps: [Step 1: App and Website Approval](SECURITY-STEP-1.md),
4
+ [Step 2: Optional Browser Permissions](SECURITY-STEP-2.md),
5
+ [Step 3: Per-Action Approval Decision](SECURITY-STEP-3.md),
6
+ [Step 4: Activity and Stop Access](SECURITY-STEP-4.md), and
7
+ [Step 5: Download and Bridge Hardening](SECURITY-STEP-5.md).
8
+
3
9
  The extension requires a person to approve each new MCP app session in its Chrome
4
10
  popup or Settings before it can read or act on browser pages. This is independent of
5
11
  email verification, transport pairing, domain restrictions and client permission
@@ -424,7 +424,7 @@
424
424
  isConnected() {
425
425
  return this.state === "connected" && this.ws?.readyState === WebSocket.OPEN;
426
426
  }
427
- connect(port, token, profile, installId, install) {
427
+ connect(port, token, profile, installId, install, suggestedProfile) {
428
428
  if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
429
429
  return;
430
430
  }
@@ -448,6 +448,7 @@
448
448
  ext: { id: chrome.runtime.id, version: chrome.runtime.getManifest().version, chrome: chromeVersion() },
449
449
  profile: profile && profile.trim() ? profile.trim() : void 0,
450
450
  installId,
451
+ ...suggestedProfile ? { suggestedProfile } : {},
451
452
  ...install ? { install } : {},
452
453
  // This build gates fail-closed and reports tab URLs on results, so the
453
454
  // server may skip its pre-flight tabs_list. An older build omits this and
@@ -645,7 +646,7 @@
645
646
  return { ok: true };
646
647
  }
647
648
  function blockedDomainMessage(host) {
648
- return `Please allow access to ${host}: Chrome \u2192 MCP Browser Extension icon \u2192 Allow next to ${host}, then try again.`;
649
+ return `Please allow access to ${host}: in Chrome, click the MCP Browser Extension icon (inside the puzzle-piece Extensions menu if it is not pinned), then click Allow next to ${host}.`;
649
650
  }
650
651
 
651
652
  // shared/fill-form.ts
@@ -3302,6 +3303,9 @@
3302
3303
  // extension/src/connect.ts
3303
3304
  var NATIVE_HOST = "com.mehmoodqureshi.mcp_browser_extension";
3304
3305
  async function fetchPairing() {
3306
+ if (typeof chrome.runtime.sendNativeMessage !== "function") {
3307
+ return { ok: false, error: "restart_needed", message: "Finishing setup. Click Connect again if this stays." };
3308
+ }
3305
3309
  try {
3306
3310
  const reply = await chrome.runtime.sendNativeMessage(NATIVE_HOST, { type: "pairing" });
3307
3311
  return reply ?? { ok: false, message: "The helper gave no answer." };
@@ -3321,8 +3325,9 @@
3321
3325
  await chrome.storage.local.set({
3322
3326
  wsPort: reply.port,
3323
3327
  token: reply.token,
3324
- // The server's own --profile when it set one; otherwise let the server name us.
3325
- ...reply.profile ? { profile: reply.profile } : {},
3328
+ // The server's own --profile, as a suggestion only: another browser may
3329
+ // already use that name, and taking it over would disconnect that one.
3330
+ suggestedProfile: reply.profile ?? "",
3326
3331
  pairingSource: "native"
3327
3332
  });
3328
3333
  }
@@ -3338,8 +3343,43 @@
3338
3343
  }
3339
3344
 
3340
3345
  // extension/src/sw/background.ts
3346
+ var PAIR_AFTER_RESTART_KEY = "pairAfterRestart";
3347
+ var commandsRunning = 0;
3348
+ var updateReady = false;
3349
+ var updateCheckedFor = "";
3350
+ function runningVersion() {
3351
+ return true ? "0.9.35" : chrome.runtime.getManifest().version;
3352
+ }
3353
+ function isNewer(a, b) {
3354
+ const parse = (v) => (/^(\d+)\.(\d+)\.(\d+)/.exec(v) ?? []).slice(1).map(Number);
3355
+ const x = parse(a);
3356
+ const y = parse(b);
3357
+ if (x.length !== 3 || y.length !== 3) return false;
3358
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
3359
+ return false;
3360
+ }
3361
+ function checkForStoreUpdate(latest) {
3362
+ if (!latest || latest === updateCheckedFor || !isNewer(latest, runningVersion())) return;
3363
+ updateCheckedFor = latest;
3364
+ void chrome.runtime.requestUpdateCheck().catch(() => void 0);
3365
+ }
3366
+ function applyUpdateWhenIdle() {
3367
+ updateReady = true;
3368
+ if (commandsRunning > 0) return;
3369
+ console.info("[mcp-browser-extension] applying the downloaded update");
3370
+ chrome.runtime.reload();
3371
+ }
3372
+ chrome.runtime.onUpdateAvailable.addListener(() => applyUpdateWhenIdle());
3341
3373
  var pendingClients = 0;
3342
3374
  var pendingFeatures = 0;
3375
+ var seenPendingClients = {};
3376
+ var seenMissingFeatures = {};
3377
+ function newlyPending(seen, now) {
3378
+ const first = !seen.ids;
3379
+ const added = now.some((id) => !seen.ids?.has(id));
3380
+ seen.ids = new Set(now);
3381
+ return !first && added;
3382
+ }
3343
3383
  var KEEPALIVE_ALARM = "chrome-mcp-keepalive";
3344
3384
  var MAX_BLOCKED = 10;
3345
3385
  var currentPolicy = null;
@@ -3366,7 +3406,13 @@
3366
3406
  reconnectAttempt = 0;
3367
3407
  }
3368
3408
  var ws = new WsClient({
3369
- onCommand: (cmd, reply) => void router.dispatch(cmd, reply),
3409
+ onCommand: (cmd, reply) => {
3410
+ commandsRunning++;
3411
+ void router.dispatch(cmd, reply).finally(() => {
3412
+ commandsRunning--;
3413
+ if (updateReady) applyUpdateWhenIdle();
3414
+ });
3415
+ },
3370
3416
  onState: (state) => {
3371
3417
  void persistState(state);
3372
3418
  if (state === "connected") clearReconnect();
@@ -3391,7 +3437,10 @@
3391
3437
  onProfile: (profile) => void chrome.storage.local.set({ pairedProfile: profile }),
3392
3438
  onGranted: (granted) => void chrome.storage.local.set({ grantedSites: granted }),
3393
3439
  onBlocked: (host, method) => void rememberBlocked(host, method),
3394
- onLatestExtension: (version) => void chrome.storage.local.set({ latestExtension: version }),
3440
+ onLatestExtension: (version) => {
3441
+ void chrome.storage.local.set({ latestExtension: version });
3442
+ checkForStoreUpdate(version);
3443
+ },
3395
3444
  log: (m) => console.debug("[mcp-browser-extension]", m)
3396
3445
  });
3397
3446
  var approvals = new ClientApprovals({
@@ -3401,8 +3450,11 @@
3401
3450
  },
3402
3451
  challenge: () => ws.approvalChallenge,
3403
3452
  changed: (clients) => {
3404
- pendingClients = clients.filter((c) => c.state === "pending" && c.expiresAt > Date.now()).length;
3453
+ const pending = clients.filter((c) => c.state === "pending" && c.expiresAt > Date.now());
3454
+ pendingClients = pending.length;
3405
3455
  reflectBadge(ws.state);
3456
+ if (newlyPending(seenPendingClients, pending.map((c) => c.id))) void showPopup();
3457
+ if (ws.isConnected()) ws.send({ type: "approval_changed", v: PROTOCOL_VERSION });
3406
3458
  }
3407
3459
  });
3408
3460
  var activity = new ActivityLog({
@@ -3476,7 +3528,7 @@
3476
3528
  const res = await fetch(chrome.runtime.getURL("manifest.json"), { cache: "no-store" });
3477
3529
  if (!res.ok) return;
3478
3530
  const onDisk = await res.json();
3479
- const loaded = chrome.runtime.getManifest().version;
3531
+ const loaded = true ? "0.9.35" : chrome.runtime.getManifest().version;
3480
3532
  if (typeof onDisk.version !== "string" || onDisk.version === loaded) return;
3481
3533
  const { reloadedFor } = await chrome.storage.local.get("reloadedFor");
3482
3534
  if (reloadedFor === onDisk.version) return;
@@ -3493,15 +3545,25 @@
3493
3545
  return fresh;
3494
3546
  }
3495
3547
  async function getConfig() {
3496
- const { wsPort, token, profile, installId } = await chrome.storage.local.get([
3548
+ const { wsPort, token, profile, suggestedProfile, pairingSource, installId } = await chrome.storage.local.get([
3497
3549
  "wsPort",
3498
3550
  "token",
3499
3551
  "profile",
3552
+ "suggestedProfile",
3553
+ "pairingSource",
3500
3554
  "installId"
3501
3555
  ]);
3502
3556
  if (typeof wsPort === "number" && wsPort > 0 && typeof token === "string" && token.length > 0) {
3503
3557
  const label = typeof profile === "string" ? profile.trim() : "";
3504
- return { wsPort, token, profile: label, installId: await ensureInstallId(installId) };
3558
+ const suggested = typeof suggestedProfile === "string" ? suggestedProfile.trim() : "";
3559
+ const fromHelper = pairingSource === "native" && label !== "";
3560
+ return {
3561
+ wsPort,
3562
+ token,
3563
+ profile: fromHelper ? "" : label,
3564
+ suggestedProfile: suggested || (fromHelper ? label : ""),
3565
+ installId: await ensureInstallId(installId)
3566
+ };
3505
3567
  }
3506
3568
  return null;
3507
3569
  }
@@ -3518,7 +3580,8 @@
3518
3580
  }
3519
3581
  async function rememberBlocked(host, method) {
3520
3582
  const { blockedSites, dismissedSites } = await chrome.storage.local.get(["blockedSites", "dismissedSites"]);
3521
- const prior = (Array.isArray(blockedSites) ? blockedSites : []).filter((b) => b.host !== host);
3583
+ const listed = Array.isArray(blockedSites) ? blockedSites : [];
3584
+ const prior = listed.filter((b) => b.host !== host);
3522
3585
  const next = [{ host, method, at: Date.now() }, ...prior].slice(0, MAX_BLOCKED);
3523
3586
  await chrome.storage.local.set({
3524
3587
  blockedSites: next,
@@ -3526,7 +3589,30 @@
3526
3589
  });
3527
3590
  pendingBlocked = next.length;
3528
3591
  reflectBadge(ws.state);
3592
+ if (prior.length === listed.length) void showPopup();
3529
3593
  }
3594
+ var POPUP_OWED_KEY = "popupOwed";
3595
+ async function showPopup() {
3596
+ try {
3597
+ const open = await chrome.runtime.getContexts({ contextTypes: [chrome.runtime.ContextType.POPUP] });
3598
+ if (open.length) return;
3599
+ if (!(await chrome.windows.getLastFocused()).focused) {
3600
+ await chrome.storage.session.set({ [POPUP_OWED_KEY]: true });
3601
+ return;
3602
+ }
3603
+ await chrome.action.openPopup();
3604
+ } catch {
3605
+ }
3606
+ }
3607
+ chrome.windows.onFocusChanged.addListener((windowId) => {
3608
+ if (windowId === chrome.windows.WINDOW_ID_NONE) return;
3609
+ void (async () => {
3610
+ if (!(await chrome.storage.session.get(POPUP_OWED_KEY))[POPUP_OWED_KEY]) return;
3611
+ await chrome.storage.session.remove(POPUP_OWED_KEY);
3612
+ if (await chrome.action.getBadgeText({}) !== "?") return;
3613
+ await chrome.action.openPopup({ windowId });
3614
+ })().catch(() => void 0);
3615
+ });
3530
3616
  var pendingBlocked = 0;
3531
3617
  void chrome.storage.local.get("blockedSites").then(({ blockedSites }) => {
3532
3618
  pendingBlocked = Array.isArray(blockedSites) ? blockedSites.length : 0;
@@ -3582,7 +3668,7 @@
3582
3668
  const cfg = await getConfig();
3583
3669
  if (!cfg) return;
3584
3670
  const install = await installType();
3585
- if (!access.stopped) ws.connect(cfg.wsPort, cfg.token, cfg.profile, cfg.installId, install);
3671
+ if (!access.stopped) ws.connect(cfg.wsPort, cfg.token, cfg.profile, cfg.installId, install, cfg.suggestedProfile);
3586
3672
  }
3587
3673
  async function keepalivePulse() {
3588
3674
  await chrome.storage.local.get("connState");
@@ -3627,8 +3713,10 @@
3627
3713
  const requested = Array.isArray(saved) ? saved.filter((p) => typeof p === "string" && p in OPTIONAL_FEATURES) : [];
3628
3714
  const missing = await Promise.all(requested.map(async (permission) => !await chrome.permissions.contains({ permissions: [permission] })));
3629
3715
  if (generation !== featureBadgeGeneration) return;
3630
- pendingFeatures = missing.filter(Boolean).length;
3716
+ const stillMissing = requested.filter((_, i) => missing[i]);
3717
+ pendingFeatures = stillMissing.length;
3631
3718
  reflectBadge(ws.state);
3719
+ if (newlyPending(seenMissingFeatures, stillMissing)) void showPopup();
3632
3720
  }
3633
3721
  chrome.permissions.onAdded.addListener((permissions) => {
3634
3722
  void (async () => {
@@ -3643,9 +3731,9 @@
3643
3731
  if (area === "local" && changes[FEATURE_REQUESTS_KEY]) void refreshFeatureBadge();
3644
3732
  if (area === "local" && changes.tabBorder?.newValue === false) void border.clearAll();
3645
3733
  if (area === "local" && changes[PROXY_STORAGE_KEY]) void syncProxy();
3646
- if (area === "local" && (changes.wsPort || changes.token || changes.profile)) {
3734
+ if (area === "local" && (changes.wsPort || changes.token || changes.profile || changes.suggestedProfile)) {
3647
3735
  if (ws.state === "unauthorized" || ws.state === "superseded") ws.state = "idle";
3648
- if (changes.profile) ws.close();
3736
+ if (changes.profile || changes.suggestedProfile) ws.close();
3649
3737
  clearReconnect();
3650
3738
  void ensureConnected();
3651
3739
  }
@@ -3657,6 +3745,12 @@
3657
3745
  return;
3658
3746
  }
3659
3747
  void requestPairingFromClick().then(async (result) => {
3748
+ if (!result.ok && result.error === "restart_needed") {
3749
+ await chrome.storage.local.set({ [PAIR_AFTER_RESTART_KEY]: true });
3750
+ reply({ ok: true });
3751
+ setTimeout(() => chrome.runtime.reload(), 200);
3752
+ return;
3753
+ }
3660
3754
  if (!result.ok) {
3661
3755
  reply(result);
3662
3756
  return;
@@ -3683,7 +3777,9 @@
3683
3777
  void operation.then((changed) => reply({
3684
3778
  ok: changed || !msg.allow,
3685
3779
  ...!changed && msg.allow ? { error: "No access was added. You can allow it whenever you are ready." } : {}
3686
- }), () => reply({ ok: false, error: "Could not change this permission. Please try again." }));
3780
+ }), () => reply({ ok: false, error: "Could not change this permission. Please try again." })).finally(() => {
3781
+ if (msg.allow) void reopenPopup(sender);
3782
+ });
3687
3783
  return true;
3688
3784
  }
3689
3785
  if (["access_stop", "access_resume", "activity_clear"].includes(msg?.type)) {
@@ -3761,7 +3857,9 @@
3761
3857
  ws.send(frame);
3762
3858
  if (currentPolicy) await onAllowlist(currentPolicy);
3763
3859
  reply({ ok: true });
3764
- })().catch(() => reply({ ok: false, error: "Could not update this website permission. Please try again." }));
3860
+ })().catch(() => reply({ ok: false, error: "Could not update this website permission. Please try again." })).finally(() => {
3861
+ if (msg.type === "site_access" && msg.allow) void reopenPopup(sender);
3862
+ });
3765
3863
  return true;
3766
3864
  }
3767
3865
  const fromOwnPage = sender.id === chrome.runtime.id && sender.url?.startsWith(chrome.runtime.getURL(""));
@@ -3791,6 +3889,10 @@
3791
3889
  return true;
3792
3890
  }
3793
3891
  });
3892
+ async function reopenPopup(sender) {
3893
+ if (sender.url !== chrome.runtime.getURL("popup.html")) return;
3894
+ await showPopup();
3895
+ }
3794
3896
  async function healFromHelper() {
3795
3897
  try {
3796
3898
  if (!await chrome.permissions.contains({ permissions: ["nativeMessaging"] })) return false;
@@ -3828,6 +3930,10 @@
3828
3930
  await chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.5 });
3829
3931
  await syncProxy();
3830
3932
  await reloadIfFilesChanged();
3933
+ if ((await chrome.storage.local.get(PAIR_AFTER_RESTART_KEY))[PAIR_AFTER_RESTART_KEY]) {
3934
+ await chrome.storage.local.remove(PAIR_AFTER_RESTART_KEY);
3935
+ if (await healFromHelper()) return;
3936
+ }
3831
3937
  if (await adoptBundledPairing()) return;
3832
3938
  if (!await getConfig() && await healFromHelper()) return;
3833
3939
  await ensureConnected();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "MCP Browser Extension – AI agent in your logged-in Chrome",
4
- "version": "0.9.32",
4
+ "version": "0.9.35",
5
5
  "description": "Lets your AI agent use the Chrome you are signed into, through a local MCP server. Deny-all by default.",
6
6
  "homepage_url": "https://mcp-browser-extension.com",
7
7
  "minimum_chrome_version": "116",
@@ -563,13 +563,17 @@
563
563
  connectEl.textContent = "Connecting\u2026";
564
564
  const res = await connectWithOneClick();
565
565
  connectEl.disabled = false;
566
- connectEl.textContent = "Connect";
567
566
  connectMsgEl.className = res.ok ? "hint" : "hint error";
568
- connectMsgEl.textContent = res.ok ? "Connecting\u2026" : res.message ?? "Could not connect.";
567
+ connectMsgEl.textContent = res.ok ? "" : res.message ?? "Could not connect.";
568
+ const { connState } = await chrome.storage.local.get("connState");
569
+ render3(typeof connState === "string" ? connState : "connecting");
569
570
  });
570
571
  function render3(state) {
571
572
  quickEl.hidden = false;
572
573
  if (!connectEl.disabled) connectEl.textContent = state === "connected" ? "Connect again" : "Connect";
574
+ if (!connectMsgEl.classList.contains("error")) {
575
+ connectMsgEl.textContent = state === "connected" ? "Connected." : state === "connecting" ? "Connecting\u2026" : "";
576
+ }
573
577
  const labels = {
574
578
  connected: "connected",
575
579
  connecting: "\u2026 connecting",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-browser-extension",
3
3
  "mcpName": "io.github.Mehmoodqureshi/mcp-browser-extension",
4
- "version": "0.9.33",
4
+ "version": "0.9.35",
5
5
  "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.",
6
6
  "author": "Mehmood Ur Rehman Qureshi",
7
7
  "license": "MIT",
@@ -78,6 +78,6 @@
78
78
  "typescript": "^5.7.2"
79
79
  },
80
80
  "engines": {
81
- "node": ">=18.0.0"
81
+ "node": ">=20.0.0"
82
82
  }
83
83
  }