mcp-browser-extension 0.9.34 → 0.9.36

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
@@ -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
@@ -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,43 @@ 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
+ // Chrome won't tell an extension its profile, but we can look it up on disk.
705
+ const chrome = !installId || this.opts.chromeProfiles === null ? null
706
+ : this.opts.chromeProfiles ?? { nameOf: chrome_profile_name_1.chromeProfileName, keys: () => (0, chrome_profile_name_1.chromeProfileKeys)() };
707
+ const own = installId ? chrome?.nameOf(frame.ext?.id, installId) : undefined;
708
+ const ownKey = own ? (0, chrome_profile_name_1.profileKeyFromChromeName)(own) : undefined;
709
+ let otherProfiles;
710
+ const isOtherChromeProfile = (name) => name !== ownKey && (otherProfiles ??= chrome?.keys() ?? []).includes(name);
711
+ let suggested;
712
+ if (typeof frame.suggestedProfile === 'string' && frame.suggestedProfile.trim()) {
713
+ try {
714
+ suggested = (0, config_1.sanitizeName)(frame.suggestedProfile, 'profile');
715
+ }
716
+ catch { /* ignore an unusable hint */ }
717
+ }
718
+ if (frame.profile && frame.profile.trim()) {
719
+ const label = routeKey(frame.profile);
720
+ // Extensions before 0.9.35 saved the helper's name as if it was typed, so
721
+ // every browser paired with Connect claimed it and knocked the others off.
722
+ // A label that is the helper's name, or another Chrome profile's name on
723
+ // this computer, is a suggestion; any other label is the person's choice.
724
+ const fromHelper = helperProfile !== DEFAULT_PROFILE && label === helperProfile;
725
+ if (!installId || !(fromHelper || isOtherChromeProfile(label))) {
726
+ return { profile: label, naming: 'options', installId };
727
+ }
728
+ suggested = label;
729
+ }
670
730
  if (!installId)
671
731
  return { profile: DEFAULT_PROFILE, naming: 'legacy' };
672
732
  const heldByOther = (name) => {
673
733
  const conn = this.conns.get(name);
674
734
  return !!conn && conn.isOpen() && this.origins.get(name)?.installId !== installId;
675
735
  };
676
- return { profile: this.registry.assign(installId, heldByOther), naming: 'auto', installId };
736
+ // Never hand a browser the name of a different Chrome profile on this computer.
737
+ if (suggested && isOtherChromeProfile(suggested))
738
+ suggested = undefined;
739
+ return { profile: this.registry.assign(installId, heldByOther, [suggested], ownKey), naming: 'auto', installId };
677
740
  }
678
741
  /** Hub side: serve a joined peer's relayed commands against our browsers. */
679
742
  acceptPeer(ws, pid) {
@@ -758,6 +821,7 @@ class BridgeServer {
758
821
  approvalChallenge: origin.approvalChallenge,
759
822
  onEvent: this.opts.onEvent,
760
823
  onSiteGrant: (host, allow) => this.applySiteGrant(host, allow),
824
+ onApprovalChanged: () => this.approvalChanged(),
761
825
  onLog: (m) => this.log(m),
762
826
  onClose: () => {
763
827
  // Only clear if a newer re-pair hasn't already replaced this slot. Look it
@@ -20,12 +20,18 @@ export interface PersonWaitRequest {
20
20
  }) => Promise<void>;
21
21
  }
22
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;
23
28
  /** The person-only step a result is blocked on, or undefined when it is not. */
24
29
  export declare function personStep(result: CallToolResult): string | undefined;
25
30
  export declare const STILL_WAITING: string;
26
31
  /**
27
32
  * Run `attempt` until it is no longer blocked on the person, the wait expires,
28
33
  * or the call is cancelled. Only results that ran nothing (approval / site
29
- * gate refusals) are retried.
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.
30
36
  */
31
- export declare function waitForPerson(attempt: () => Promise<CallToolResult>, request: PersonWaitRequest): Promise<CallToolResult>;
37
+ export declare function waitForPerson(attempt: (retry: boolean) => Promise<CallToolResult>, request: PersonWaitRequest): Promise<CallToolResult>;
@@ -10,15 +10,26 @@
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
11
  exports.STILL_WAITING = void 0;
12
12
  exports.setPersonWaitForTesting = setPersonWaitForTesting;
13
+ exports.notifyPersonStep = notifyPersonStep;
13
14
  exports.personStep = personStep;
14
15
  exports.waitForPerson = waitForPerson;
15
- let pollMs = 1_500;
16
+ // A fallback only: an approval or site grant wakes the wait at once (below).
17
+ let pollMs = 10_000;
16
18
  // Stay under the 60 s default tool timeout some MCP clients enforce.
17
19
  let waitMs = 45_000;
18
20
  function setPersonWaitForTesting(poll, wait) {
19
21
  pollMs = Math.max(1, poll);
20
22
  waitMs = Math.max(1, wait);
21
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
+ }
22
33
  const APPROVAL = /Please approve "/;
23
34
  const SITE = /^\[POLICY_DENIED\] Please allow access to /;
24
35
  /** The person-only step a result is blocked on, or undefined when it is not. */
@@ -34,10 +45,11 @@ exports.STILL_WAITING = 'Still waiting for the person. Call the same tool again
34
45
  /**
35
46
  * Run `attempt` until it is no longer blocked on the person, the wait expires,
36
47
  * or the call is cancelled. Only results that ran nothing (approval / site
37
- * gate refusals) are retried.
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.
38
50
  */
39
51
  async function waitForPerson(attempt, request) {
40
- let result = await attempt();
52
+ let result = await attempt(false);
41
53
  let step = personStep(result);
42
54
  if (!step)
43
55
  return result;
@@ -55,12 +67,13 @@ async function waitForPerson(attempt, request) {
55
67
  while (Date.now() < deadline && !request.signal?.aborted) {
56
68
  await new Promise((resolve) => {
57
69
  const timer = setTimeout(done, Math.min(pollMs, Math.max(1, deadline - Date.now())));
58
- function done() { clearTimeout(timer); request.signal?.removeEventListener('abort', done); resolve(); }
70
+ function done() { clearTimeout(timer); wakers.delete(done); request.signal?.removeEventListener('abort', done); resolve(); }
71
+ wakers.add(done);
59
72
  request.signal?.addEventListener('abort', done, { once: true });
60
73
  });
61
74
  if (request.signal?.aborted)
62
75
  break;
63
- result = await attempt();
76
+ result = await attempt(true);
64
77
  const next = personStep(result);
65
78
  if (!next)
66
79
  return result;
@@ -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. */
@@ -1430,7 +1430,7 @@ const RETRY_SAFE_TOOLS = new Set([
1430
1430
  function isRetryableFault(name, err) {
1431
1431
  return err instanceof types_1.ExecutorError && err.code === 'EXTENSION_DISCONNECTED' && RETRY_SAFE_TOOLS.has(name);
1432
1432
  }
1433
- async function dispatchToolCall(name, rawArgs) {
1433
+ async function dispatchToolCall(name, rawArgs, opts = {}) {
1434
1434
  // The allowlist is checked here, not only at registration, so a `batch` op
1435
1435
  // cannot reach a tool the operator kept off the surface.
1436
1436
  if (!isToolEnabled(name))
@@ -1443,10 +1443,17 @@ async function dispatchToolCall(name, rawArgs) {
1443
1443
  const started = Date.now();
1444
1444
  // One audit record per call, carried through async hops so a parallel `batch`
1445
1445
  // cannot cross-attribute one op's target URL to another's log line.
1446
- 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));
1447
1447
  return outcome;
1448
1448
  }
1449
- 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
+ };
1450
1457
  try {
1451
1458
  const mgr = (0, manager_1.getManager)();
1452
1459
  // Workspace-management tools run server-side and must work even with no
@@ -1471,13 +1478,14 @@ async function dispatchInner(name, handler, rawArgs, audit, started) {
1471
1478
  result = await handler((0, validators_1.asArgs)(rawArgs), { ex: reconnected, policy: mgr.policy });
1472
1479
  (0, log_1.logDebug)(`${name}: retry after reconnect succeeded`);
1473
1480
  }
1474
- recordHistory(name, rawArgs, !result.isError, { ms: Date.now() - started, audit });
1481
+ record(result, { ms: Date.now() - started, audit });
1475
1482
  return result;
1476
1483
  }
1477
1484
  catch (err) {
1478
1485
  const message = errMessage(err);
1479
- recordHistory(name, rawArgs, false, { error: message, ms: Date.now() - started, audit });
1480
- 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;
1481
1489
  }
1482
1490
  }
1483
1491
  /** Assert the catalog and the dispatch table describe the same tool set. */
@@ -1547,7 +1555,7 @@ function registerTools(server, policy, accessGuard = access_1.requireAccess) {
1547
1555
  return accessError;
1548
1556
  // Approval and site refusals run nothing, so the call waits for the
1549
1557
  // person here and continues once they click Approve or Allow.
1550
- const result = await (0, person_wait_1.waitForPerson)(async () => {
1558
+ const result = await (0, person_wait_1.waitForPerson)(async (retry) => {
1551
1559
  if (profileBridge?.hasActiveExtension()) {
1552
1560
  const active = (0, workspace_1.peekActiveWorkspace)()?.profile ?? 'default';
1553
1561
  const profile = profileBridge.hasConnection(active) ? active : profileBridge.pairedProfiles()[0]?.name;
@@ -1558,7 +1566,7 @@ function registerTools(server, policy, accessGuard = access_1.requireAccess) {
1558
1566
  return (0, envelopes_1.errorResult)(err instanceof Error ? err.message : 'Chrome client approval is required.');
1559
1567
  }
1560
1568
  }
1561
- return dispatchToolCall(d.name, args);
1569
+ return dispatchToolCall(d.name, args, { quietRefusal: retry });
1562
1570
  }, {
1563
1571
  signal: extra.signal,
1564
1572
  progressToken: extra._meta?.progressToken,
@@ -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;
@@ -3797,12 +3891,7 @@
3797
3891
  });
3798
3892
  async function reopenPopup(sender) {
3799
3893
  if (sender.url !== chrome.runtime.getURL("popup.html")) return;
3800
- try {
3801
- const open = await chrome.runtime.getContexts({ contextTypes: [chrome.runtime.ContextType.POPUP] });
3802
- if (open.length) return;
3803
- await chrome.action.openPopup();
3804
- } catch {
3805
- }
3894
+ await showPopup();
3806
3895
  }
3807
3896
  async function healFromHelper() {
3808
3897
  try {
@@ -3841,6 +3930,10 @@
3841
3930
  await chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.5 });
3842
3931
  await syncProxy();
3843
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
+ }
3844
3937
  if (await adoptBundledPairing()) return;
3845
3938
  if (!await getConfig() && await healFromHelper()) return;
3846
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.34",
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.34",
4
+ "version": "0.9.36",
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",