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 +5 -0
- package/dist/shared/policy.js +3 -1
- package/dist/shared/protocol.d.ts +8 -1
- package/dist/src/bridge/chrome-profile-name.d.ts +19 -0
- package/dist/src/bridge/chrome-profile-name.js +103 -0
- package/dist/src/bridge/connection.d.ts +2 -0
- package/dist/src/bridge/connection.js +5 -0
- package/dist/src/bridge/peer.d.ts +5 -1
- package/dist/src/bridge/peer.js +6 -1
- package/dist/src/bridge/profiles.d.ts +11 -4
- package/dist/src/bridge/profiles.js +37 -11
- package/dist/src/bridge/server.d.ts +11 -0
- package/dist/src/bridge/server.js +72 -8
- package/dist/src/mcp/person-wait.d.ts +8 -2
- package/dist/src/mcp/person-wait.js +18 -5
- package/dist/src/mcp/tools.d.ts +3 -1
- package/dist/src/mcp/tools.js +16 -8
- package/extension-dist/background.js +114 -21
- package/extension-dist/manifest.json +1 -1
- package/extension-dist/options.js +6 -2
- package/package.json +1 -1
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
|
package/dist/shared/policy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/dist/src/bridge/peer.js
CHANGED
|
@@ -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
|
|
32
|
-
*
|
|
33
|
-
* `
|
|
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
|
|
51
|
-
*
|
|
52
|
-
* `
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
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) !==
|
|
90
|
+
for (let n = 2; (this.ownerOf(name) ?? installId) !== installId || isLive(name); n++)
|
|
64
91
|
name = `profile-${n}`;
|
|
65
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
669
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/src/mcp/tools.d.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1480
|
-
|
|
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
|
|
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
|
|
3325
|
-
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 ? "
|
|
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.
|
|
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",
|