mcp-browser-extension 0.9.33 → 0.9.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/SETUP.md +1 -1
- 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 +68 -8
- package/dist/src/mcp/person-wait.d.ts +37 -0
- package/dist/src/mcp/person-wait.js +87 -0
- package/dist/src/mcp/server.js +4 -0
- package/dist/src/mcp/tools.d.ts +3 -1
- package/dist/src/mcp/tools.js +33 -16
- package/docs/CLIENT-APPROVAL.md +6 -0
- package/extension-dist/background.js +123 -17
- package/extension-dist/manifest.json +1 -1
- package/extension-dist/options.js +6 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -630,7 +630,7 @@ PDF is megabytes of base64 no model can read.
|
|
|
630
630
|
### Paying less per turn — `--tools`
|
|
631
631
|
|
|
632
632
|
Every MCP server you connect costs context before you ask it anything: the host
|
|
633
|
-
sends the whole tool catalog to the model on **every** turn. MCP Browser Extension's
|
|
633
|
+
sends the whole tool catalog to the model on **every** turn. MCP Browser Extension's 43
|
|
634
634
|
tools are 28 KB of JSON Schema, about 7.1k tokens, on each one.
|
|
635
635
|
|
|
636
636
|
Most runs need a handful of them. `--tools` advertises only those:
|
|
@@ -652,7 +652,7 @@ for a run that was never going to print a PDF or upload a file.
|
|
|
652
652
|
`--unsafe-enable-eval`.
|
|
653
653
|
- An unknown name fails at startup and prints the catalog, so a typo can never
|
|
654
654
|
quietly drop `click` from the surface.
|
|
655
|
-
- `mcp-browser-extension --help` prints the full catalog of
|
|
655
|
+
- `mcp-browser-extension --help` prints the full catalog of 43 names to pick from.
|
|
656
656
|
|
|
657
657
|
## Status
|
|
658
658
|
|
|
@@ -822,6 +822,11 @@ live on the website: **[MCP Browser Extension docs](https://mcp-browser-extensio
|
|
|
822
822
|
- [Use Claude in your signed-in Chrome](https://mcp-browser-extension.com/logged-in-chrome)
|
|
823
823
|
- [Give Claude Code browser access](https://mcp-browser-extension.com/blog/claude-code-browser)
|
|
824
824
|
- [Give Cursor a real browser with MCP](https://mcp-browser-extension.com/blog/cursor-browser-mcp)
|
|
825
|
+
- [Let VS Code Copilot use your real Chrome](https://mcp-browser-extension.com/blog/vscode-copilot-browser-mcp)
|
|
826
|
+
- [Test a login flow end to end with an AI agent](https://mcp-browser-extension.com/blog/test-login-flow-ai-agent)
|
|
827
|
+
- [Check a release page through a browser MCP tunnel](https://mcp-browser-extension.com/blog/check-release-page-remote-browser)
|
|
828
|
+
- [Chrome DevTools MCP vs Playwright MCP vs an MCP extension](https://mcp-browser-extension.com/blog/chrome-devtools-mcp-vs-playwright-mcp)
|
|
829
|
+
- [Best browser MCP servers in 2026](https://mcp-browser-extension.com/blog/best-browser-mcp-servers)
|
|
825
830
|
- [How it compares with other browser MCP servers](https://mcp-browser-extension.com/docs/compare)
|
|
826
831
|
|
|
827
832
|
## Develop
|
package/SETUP.md
CHANGED
|
@@ -20,7 +20,7 @@ inside Chrome's own UI (loading the extension, pasting the pairing token).
|
|
|
20
20
|
catch-all pattern. Only enable `--unsafe-enable-eval` if the human asks by name.
|
|
21
21
|
- Never print or log the pairing token. It lives in a 0600 file; show the human
|
|
22
22
|
its path, not its contents.
|
|
23
|
-
- Requires Node
|
|
23
|
+
- Requires Node 20 or newer (`node --version`) and Chrome 116 or newer.
|
|
24
24
|
- On Windows, MCP hosts must launch the server through `cmd /c npx ...`. See
|
|
25
25
|
step 3.
|
|
26
26
|
|
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,39 @@ 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
|
+
let suggested;
|
|
705
|
+
if (typeof frame.suggestedProfile === 'string' && frame.suggestedProfile.trim()) {
|
|
706
|
+
try {
|
|
707
|
+
suggested = (0, config_1.sanitizeName)(frame.suggestedProfile, 'profile');
|
|
708
|
+
}
|
|
709
|
+
catch { /* ignore an unusable hint */ }
|
|
710
|
+
}
|
|
711
|
+
if (frame.profile && frame.profile.trim()) {
|
|
712
|
+
const label = routeKey(frame.profile);
|
|
713
|
+
// Extensions before 0.9.35 saved the helper's name as if it was typed, so
|
|
714
|
+
// every browser paired with Connect claimed it and knocked the others off.
|
|
715
|
+
// That exact name is a suggestion; any other label is the person's choice.
|
|
716
|
+
if (!installId || helperProfile === DEFAULT_PROFILE || label !== helperProfile) {
|
|
717
|
+
return { profile: label, naming: 'options', installId };
|
|
718
|
+
}
|
|
719
|
+
suggested = label;
|
|
720
|
+
}
|
|
670
721
|
if (!installId)
|
|
671
722
|
return { profile: DEFAULT_PROFILE, naming: 'legacy' };
|
|
672
723
|
const heldByOther = (name) => {
|
|
673
724
|
const conn = this.conns.get(name);
|
|
674
725
|
return !!conn && conn.isOpen() && this.origins.get(name)?.installId !== installId;
|
|
675
726
|
};
|
|
676
|
-
|
|
727
|
+
// Chrome won't tell an extension its profile, but we can look it up on disk.
|
|
728
|
+
const chrome = this.opts.chromeProfiles === null ? null
|
|
729
|
+
: this.opts.chromeProfiles ?? { nameOf: chrome_profile_name_1.chromeProfileName, keys: () => (0, chrome_profile_name_1.chromeProfileKeys)() };
|
|
730
|
+
const own = chrome?.nameOf(frame.ext?.id, installId);
|
|
731
|
+
const ownKey = own ? (0, chrome_profile_name_1.profileKeyFromChromeName)(own) : undefined;
|
|
732
|
+
// Never hand a browser the name of a different Chrome profile on this computer.
|
|
733
|
+
if (suggested && suggested !== ownKey && chrome?.keys().includes(suggested))
|
|
734
|
+
suggested = undefined;
|
|
735
|
+
return { profile: this.registry.assign(installId, heldByOther, [suggested], ownKey), naming: 'auto', installId };
|
|
677
736
|
}
|
|
678
737
|
/** Hub side: serve a joined peer's relayed commands against our browsers. */
|
|
679
738
|
acceptPeer(ws, pid) {
|
|
@@ -758,6 +817,7 @@ class BridgeServer {
|
|
|
758
817
|
approvalChallenge: origin.approvalChallenge,
|
|
759
818
|
onEvent: this.opts.onEvent,
|
|
760
819
|
onSiteGrant: (host, allow) => this.applySiteGrant(host, allow),
|
|
820
|
+
onApprovalChanged: () => this.approvalChanged(),
|
|
761
821
|
onLog: (m) => this.log(m),
|
|
762
822
|
onClose: () => {
|
|
763
823
|
// Only clear if a newer re-pair hasn't already replaced this slot. Look it
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/mcp/person-wait.ts — hold a tool call open while the PERSON approves the
|
|
3
|
+
* app or allows a site in Chrome, then continue on its own.
|
|
4
|
+
*
|
|
5
|
+
* Without this the agent gets an error, tells the person, and has to invent its
|
|
6
|
+
* own polling (shell sleeps, "I'll check again in a minute"). Waiting here means
|
|
7
|
+
* one tool call simply completes once the person clicks Approve/Allow.
|
|
8
|
+
*/
|
|
9
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
export interface PersonWaitRequest {
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
progressToken?: string | number;
|
|
13
|
+
sendNotification?: (n: {
|
|
14
|
+
method: 'notifications/progress';
|
|
15
|
+
params: {
|
|
16
|
+
progressToken: string | number;
|
|
17
|
+
progress: number;
|
|
18
|
+
message: string;
|
|
19
|
+
};
|
|
20
|
+
}) => Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export declare function setPersonWaitForTesting(poll: number, wait: number): void;
|
|
23
|
+
/**
|
|
24
|
+
* The person just approved an app or allowed a site: retry every waiting call
|
|
25
|
+
* now instead of on the next fallback poll.
|
|
26
|
+
*/
|
|
27
|
+
export declare function notifyPersonStep(): void;
|
|
28
|
+
/** The person-only step a result is blocked on, or undefined when it is not. */
|
|
29
|
+
export declare function personStep(result: CallToolResult): string | undefined;
|
|
30
|
+
export declare const STILL_WAITING: string;
|
|
31
|
+
/**
|
|
32
|
+
* Run `attempt` until it is no longer blocked on the person, the wait expires,
|
|
33
|
+
* or the call is cancelled. Only results that ran nothing (approval / site
|
|
34
|
+
* gate refusals) are retried. `retry` is true on every attempt after the
|
|
35
|
+
* first, so the caller can count one refusal per wait instead of one per poll.
|
|
36
|
+
*/
|
|
37
|
+
export declare function waitForPerson(attempt: (retry: boolean) => Promise<CallToolResult>, request: PersonWaitRequest): Promise<CallToolResult>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/mcp/person-wait.ts — hold a tool call open while the PERSON approves the
|
|
4
|
+
* app or allows a site in Chrome, then continue on its own.
|
|
5
|
+
*
|
|
6
|
+
* Without this the agent gets an error, tells the person, and has to invent its
|
|
7
|
+
* own polling (shell sleeps, "I'll check again in a minute"). Waiting here means
|
|
8
|
+
* one tool call simply completes once the person clicks Approve/Allow.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.STILL_WAITING = void 0;
|
|
12
|
+
exports.setPersonWaitForTesting = setPersonWaitForTesting;
|
|
13
|
+
exports.notifyPersonStep = notifyPersonStep;
|
|
14
|
+
exports.personStep = personStep;
|
|
15
|
+
exports.waitForPerson = waitForPerson;
|
|
16
|
+
// A fallback only: an approval or site grant wakes the wait at once (below).
|
|
17
|
+
let pollMs = 10_000;
|
|
18
|
+
// Stay under the 60 s default tool timeout some MCP clients enforce.
|
|
19
|
+
let waitMs = 45_000;
|
|
20
|
+
function setPersonWaitForTesting(poll, wait) {
|
|
21
|
+
pollMs = Math.max(1, poll);
|
|
22
|
+
waitMs = Math.max(1, wait);
|
|
23
|
+
}
|
|
24
|
+
const wakers = new Set();
|
|
25
|
+
/**
|
|
26
|
+
* The person just approved an app or allowed a site: retry every waiting call
|
|
27
|
+
* now instead of on the next fallback poll.
|
|
28
|
+
*/
|
|
29
|
+
function notifyPersonStep() {
|
|
30
|
+
for (const wake of [...wakers])
|
|
31
|
+
wake();
|
|
32
|
+
}
|
|
33
|
+
const APPROVAL = /Please approve "/;
|
|
34
|
+
const SITE = /^\[POLICY_DENIED\] Please allow access to /;
|
|
35
|
+
/** The person-only step a result is blocked on, or undefined when it is not. */
|
|
36
|
+
function personStep(result) {
|
|
37
|
+
if (!result.isError)
|
|
38
|
+
return undefined;
|
|
39
|
+
const first = result.content[0];
|
|
40
|
+
const text = first?.type === 'text' ? first.text : '';
|
|
41
|
+
return APPROVAL.test(text) || SITE.test(text) ? text : undefined;
|
|
42
|
+
}
|
|
43
|
+
exports.STILL_WAITING = 'Still waiting for the person. Call the same tool again to keep waiting; it continues on its own once they approve. ' +
|
|
44
|
+
'Do not use shell sleeps or ask them to confirm in chat.';
|
|
45
|
+
/**
|
|
46
|
+
* Run `attempt` until it is no longer blocked on the person, the wait expires,
|
|
47
|
+
* or the call is cancelled. Only results that ran nothing (approval / site
|
|
48
|
+
* gate refusals) are retried. `retry` is true on every attempt after the
|
|
49
|
+
* first, so the caller can count one refusal per wait instead of one per poll.
|
|
50
|
+
*/
|
|
51
|
+
async function waitForPerson(attempt, request) {
|
|
52
|
+
let result = await attempt(false);
|
|
53
|
+
let step = personStep(result);
|
|
54
|
+
if (!step)
|
|
55
|
+
return result;
|
|
56
|
+
const deadline = Date.now() + waitMs;
|
|
57
|
+
let progress = 0;
|
|
58
|
+
const report = async (message) => {
|
|
59
|
+
if (request.progressToken === undefined || !request.sendNotification || request.signal?.aborted)
|
|
60
|
+
return;
|
|
61
|
+
try {
|
|
62
|
+
await request.sendNotification({ method: 'notifications/progress', params: { progressToken: request.progressToken, progress: ++progress, message } });
|
|
63
|
+
}
|
|
64
|
+
catch { /* Progress is optional; the tool result still carries the outcome. */ }
|
|
65
|
+
};
|
|
66
|
+
await report(`${step} Waiting for this automatically.`);
|
|
67
|
+
while (Date.now() < deadline && !request.signal?.aborted) {
|
|
68
|
+
await new Promise((resolve) => {
|
|
69
|
+
const timer = setTimeout(done, Math.min(pollMs, Math.max(1, deadline - Date.now())));
|
|
70
|
+
function done() { clearTimeout(timer); wakers.delete(done); request.signal?.removeEventListener('abort', done); resolve(); }
|
|
71
|
+
wakers.add(done);
|
|
72
|
+
request.signal?.addEventListener('abort', done, { once: true });
|
|
73
|
+
});
|
|
74
|
+
if (request.signal?.aborted)
|
|
75
|
+
break;
|
|
76
|
+
result = await attempt(true);
|
|
77
|
+
const next = personStep(result);
|
|
78
|
+
if (!next)
|
|
79
|
+
return result;
|
|
80
|
+
// Approving the app can uncover the site gate; say what is awaited now.
|
|
81
|
+
if (next !== step)
|
|
82
|
+
await report(`${next} Waiting for this automatically.`);
|
|
83
|
+
step = next;
|
|
84
|
+
}
|
|
85
|
+
return { ...result, content: [...result.content, { type: 'text', text: exports.STILL_WAITING }] };
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=person-wait.js.map
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -38,6 +38,8 @@ const SERVER_VERSION = '0.1.0';
|
|
|
38
38
|
exports.SERVER_INSTRUCTIONS = "MCP Browser Extension drives the user's real, signed-in Chrome. " +
|
|
39
39
|
'On the first browser tool call, enter your email in the MCP input prompt and open the sign-in link sent to you, then click Verify this device on the website. If it returns ACCESS_INPUT_REQUIRED, the local verification form opens automatically in the browser; also show its URL as a backup so the user can enter their email. Do not open the backup URL again or add another chat email prompt while that form is active. Do not fill it or read their email for them. The tool waits for verification, reports the result, and continues automatically on success. If it returns ACCESS_PENDING, call chrome_status again to keep waiting; do not ask for a chat reply or another email. Only ask the user to click Connect if the result says Chrome is not paired. ' +
|
|
40
40
|
'When Chrome approval is required, show the app name and key fingerprint and ask the person to review App connections in the extension popup or Settings. Approval lasts eight hours. Never approve a client yourself; YOLO and email-verification bypass do not bypass Chrome approval. ' +
|
|
41
|
+
'Approval and site refusals wait automatically: the tool call stays open (about 45 seconds) and continues on its own as soon as the person clicks Approve or Allow. Tell the person once. If it returns "Still waiting", call the same tool again; never use shell sleeps, timers or "I will check again later" messages, and never ask them to confirm in chat. ' +
|
|
42
|
+
'Chrome blocks every extension from Chrome Web Store pages (chromewebstore.google.com and chrome.google.com/webstore, including the developer dashboard): they fail with TARGET_GONE "The extensions gallery cannot be scripted". Do not ask the person to allow those sites; it cannot help. ' +
|
|
41
43
|
'Start with chrome_status. If it reports no paired browser (or any tool fails with NO_BACKEND), do not retry: ' +
|
|
42
44
|
'give the user the steps in its `setup` field, which name what went wrong and how to fix it. ' +
|
|
43
45
|
'If a tool fails with POLICY_DENIED because a site is not allowed, ask the user to allow it from the extension\'s ' +
|
|
@@ -46,6 +48,8 @@ exports.SERVER_INSTRUCTIONS = "MCP Browser Extension drives the user's real, sig
|
|
|
46
48
|
"To act on the user's current page, pass its tabId from tabs_list.";
|
|
47
49
|
const LOCAL_BYPASS_INSTRUCTIONS = 'Email verification is disabled by the local operator for this server. Only trusted local MCP clients should connect; the browser-domain allowlist and other capability safety gates remain active. ' +
|
|
48
50
|
'When Chrome approval is required, show the app name and key fingerprint and ask the person to review App connections in the extension popup or Settings. Approval lasts eight hours. Never approve a client yourself; YOLO and email-verification bypass do not bypass Chrome approval. ' +
|
|
51
|
+
'Approval and site refusals wait automatically: the tool call stays open (about 45 seconds) and continues on its own as soon as the person clicks Approve or Allow. Tell the person once. If it returns "Still waiting", call the same tool again; never use shell sleeps, timers or "I will check again later" messages, and never ask them to confirm in chat. ' +
|
|
52
|
+
'Chrome blocks every extension from Chrome Web Store pages (chromewebstore.google.com and chrome.google.com/webstore, including the developer dashboard): they fail with TARGET_GONE "The extensions gallery cannot be scripted". Do not ask the person to allow those sites; it cannot help. ' +
|
|
49
53
|
'Start with chrome_status. If it reports no paired browser (or any tool fails with NO_BACKEND), give the user the steps in its `setup` field. ' +
|
|
50
54
|
'If a tool fails with POLICY_DENIED because a site is not allowed, relay its friendly one-line approval steps and wait for the person to allow it from the extension\'s toolbar icon. Never approve it yourself or enable all-site access. ' +
|
|
51
55
|
"Work in your own tab: tab_new opens one in the background. To act on the user's current page, pass its tabId from tabs_list.";
|
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
|
@@ -35,6 +35,7 @@ const policy_2 = require("../../shared/policy");
|
|
|
35
35
|
const envelopes_1 = require("./envelopes");
|
|
36
36
|
const limits_1 = require("./limits");
|
|
37
37
|
const batch_1 = require("./batch");
|
|
38
|
+
const person_wait_1 = require("./person-wait");
|
|
38
39
|
const helpers_1 = require("./helpers");
|
|
39
40
|
const redact_1 = require("./redact");
|
|
40
41
|
const locate_1 = require("./locate");
|
|
@@ -1429,7 +1430,7 @@ const RETRY_SAFE_TOOLS = new Set([
|
|
|
1429
1430
|
function isRetryableFault(name, err) {
|
|
1430
1431
|
return err instanceof types_1.ExecutorError && err.code === 'EXTENSION_DISCONNECTED' && RETRY_SAFE_TOOLS.has(name);
|
|
1431
1432
|
}
|
|
1432
|
-
async function dispatchToolCall(name, rawArgs) {
|
|
1433
|
+
async function dispatchToolCall(name, rawArgs, opts = {}) {
|
|
1433
1434
|
// The allowlist is checked here, not only at registration, so a `batch` op
|
|
1434
1435
|
// cannot reach a tool the operator kept off the surface.
|
|
1435
1436
|
if (!isToolEnabled(name))
|
|
@@ -1442,10 +1443,17 @@ async function dispatchToolCall(name, rawArgs) {
|
|
|
1442
1443
|
const started = Date.now();
|
|
1443
1444
|
// One audit record per call, carried through async hops so a parallel `batch`
|
|
1444
1445
|
// cannot cross-attribute one op's target URL to another's log line.
|
|
1445
|
-
const { result: outcome } = await (0, audit_1.withAudit)(async (audit) => dispatchInner(name, handler, rawArgs, audit, started));
|
|
1446
|
+
const { result: outcome } = await (0, audit_1.withAudit)(async (audit) => dispatchInner(name, handler, rawArgs, audit, started, opts.quietRefusal === true));
|
|
1446
1447
|
return outcome;
|
|
1447
1448
|
}
|
|
1448
|
-
async function dispatchInner(name, handler, rawArgs, audit, started) {
|
|
1449
|
+
async function dispatchInner(name, handler, rawArgs, audit, started, quietRefusal) {
|
|
1450
|
+
// A person-wait poll that is still refused ran nothing; the first refusal
|
|
1451
|
+
// was already counted, so recording every poll would log ~30 per wait.
|
|
1452
|
+
const record = (result, extra) => {
|
|
1453
|
+
if (quietRefusal && (0, person_wait_1.personStep)(result))
|
|
1454
|
+
return;
|
|
1455
|
+
recordHistory(name, rawArgs, !result.isError, extra);
|
|
1456
|
+
};
|
|
1449
1457
|
try {
|
|
1450
1458
|
const mgr = (0, manager_1.getManager)();
|
|
1451
1459
|
// Workspace-management tools run server-side and must work even with no
|
|
@@ -1470,13 +1478,14 @@ async function dispatchInner(name, handler, rawArgs, audit, started) {
|
|
|
1470
1478
|
result = await handler((0, validators_1.asArgs)(rawArgs), { ex: reconnected, policy: mgr.policy });
|
|
1471
1479
|
(0, log_1.logDebug)(`${name}: retry after reconnect succeeded`);
|
|
1472
1480
|
}
|
|
1473
|
-
|
|
1481
|
+
record(result, { ms: Date.now() - started, audit });
|
|
1474
1482
|
return result;
|
|
1475
1483
|
}
|
|
1476
1484
|
catch (err) {
|
|
1477
1485
|
const message = errMessage(err);
|
|
1478
|
-
|
|
1479
|
-
|
|
1486
|
+
const result = (0, envelopes_1.errorResult)(message);
|
|
1487
|
+
record(result, { error: message, ms: Date.now() - started, audit });
|
|
1488
|
+
return result;
|
|
1480
1489
|
}
|
|
1481
1490
|
}
|
|
1482
1491
|
/** Assert the catalog and the dispatch table describe the same tool set. */
|
|
@@ -1544,17 +1553,25 @@ function registerTools(server, policy, accessGuard = access_1.requireAccess) {
|
|
|
1544
1553
|
});
|
|
1545
1554
|
if (accessError)
|
|
1546
1555
|
return accessError;
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1556
|
+
// Approval and site refusals run nothing, so the call waits for the
|
|
1557
|
+
// person here and continues once they click Approve or Allow.
|
|
1558
|
+
const result = await (0, person_wait_1.waitForPerson)(async (retry) => {
|
|
1559
|
+
if (profileBridge?.hasActiveExtension()) {
|
|
1560
|
+
const active = (0, workspace_1.peekActiveWorkspace)()?.profile ?? 'default';
|
|
1561
|
+
const profile = profileBridge.hasConnection(active) ? active : profileBridge.pairedProfiles()[0]?.name;
|
|
1562
|
+
try {
|
|
1563
|
+
await profileBridge.sendCommand('ping_probe', { requireClientApproval: true }, { profile });
|
|
1564
|
+
}
|
|
1565
|
+
catch (err) {
|
|
1566
|
+
return (0, envelopes_1.errorResult)(err instanceof Error ? err.message : 'Chrome client approval is required.');
|
|
1567
|
+
}
|
|
1552
1568
|
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1569
|
+
return dispatchToolCall(d.name, args, { quietRefusal: retry });
|
|
1570
|
+
}, {
|
|
1571
|
+
signal: extra.signal,
|
|
1572
|
+
progressToken: extra._meta?.progressToken,
|
|
1573
|
+
sendNotification: extra.sendNotification,
|
|
1574
|
+
});
|
|
1558
1575
|
if (verifiedNow)
|
|
1559
1576
|
result.content.unshift({ type: 'text', text: 'Email verified. MCP Browser Extension access is active.' });
|
|
1560
1577
|
return result;
|
package/docs/CLIENT-APPROVAL.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Client approval
|
|
2
2
|
|
|
3
|
+
Related security steps: [Step 1: App and Website Approval](SECURITY-STEP-1.md),
|
|
4
|
+
[Step 2: Optional Browser Permissions](SECURITY-STEP-2.md),
|
|
5
|
+
[Step 3: Per-Action Approval Decision](SECURITY-STEP-3.md),
|
|
6
|
+
[Step 4: Activity and Stop Access](SECURITY-STEP-4.md), and
|
|
7
|
+
[Step 5: Download and Bridge Hardening](SECURITY-STEP-5.md).
|
|
8
|
+
|
|
3
9
|
The extension requires a person to approve each new MCP app session in its Chrome
|
|
4
10
|
popup or Settings before it can read or act on browser pages. This is independent of
|
|
5
11
|
email verification, transport pairing, domain restrictions and client permission
|
|
@@ -424,7 +424,7 @@
|
|
|
424
424
|
isConnected() {
|
|
425
425
|
return this.state === "connected" && this.ws?.readyState === WebSocket.OPEN;
|
|
426
426
|
}
|
|
427
|
-
connect(port, token, profile, installId, install) {
|
|
427
|
+
connect(port, token, profile, installId, install, suggestedProfile) {
|
|
428
428
|
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
|
429
429
|
return;
|
|
430
430
|
}
|
|
@@ -448,6 +448,7 @@
|
|
|
448
448
|
ext: { id: chrome.runtime.id, version: chrome.runtime.getManifest().version, chrome: chromeVersion() },
|
|
449
449
|
profile: profile && profile.trim() ? profile.trim() : void 0,
|
|
450
450
|
installId,
|
|
451
|
+
...suggestedProfile ? { suggestedProfile } : {},
|
|
451
452
|
...install ? { install } : {},
|
|
452
453
|
// This build gates fail-closed and reports tab URLs on results, so the
|
|
453
454
|
// server may skip its pre-flight tabs_list. An older build omits this and
|
|
@@ -645,7 +646,7 @@
|
|
|
645
646
|
return { ok: true };
|
|
646
647
|
}
|
|
647
648
|
function blockedDomainMessage(host) {
|
|
648
|
-
return `Please allow access to ${host}: Chrome
|
|
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;
|
|
@@ -3683,7 +3777,9 @@
|
|
|
3683
3777
|
void operation.then((changed) => reply({
|
|
3684
3778
|
ok: changed || !msg.allow,
|
|
3685
3779
|
...!changed && msg.allow ? { error: "No access was added. You can allow it whenever you are ready." } : {}
|
|
3686
|
-
}), () => reply({ ok: false, error: "Could not change this permission. Please try again." }))
|
|
3780
|
+
}), () => reply({ ok: false, error: "Could not change this permission. Please try again." })).finally(() => {
|
|
3781
|
+
if (msg.allow) void reopenPopup(sender);
|
|
3782
|
+
});
|
|
3687
3783
|
return true;
|
|
3688
3784
|
}
|
|
3689
3785
|
if (["access_stop", "access_resume", "activity_clear"].includes(msg?.type)) {
|
|
@@ -3761,7 +3857,9 @@
|
|
|
3761
3857
|
ws.send(frame);
|
|
3762
3858
|
if (currentPolicy) await onAllowlist(currentPolicy);
|
|
3763
3859
|
reply({ ok: true });
|
|
3764
|
-
})().catch(() => reply({ ok: false, error: "Could not update this website permission. Please try again." }))
|
|
3860
|
+
})().catch(() => reply({ ok: false, error: "Could not update this website permission. Please try again." })).finally(() => {
|
|
3861
|
+
if (msg.type === "site_access" && msg.allow) void reopenPopup(sender);
|
|
3862
|
+
});
|
|
3765
3863
|
return true;
|
|
3766
3864
|
}
|
|
3767
3865
|
const fromOwnPage = sender.id === chrome.runtime.id && sender.url?.startsWith(chrome.runtime.getURL(""));
|
|
@@ -3791,6 +3889,10 @@
|
|
|
3791
3889
|
return true;
|
|
3792
3890
|
}
|
|
3793
3891
|
});
|
|
3892
|
+
async function reopenPopup(sender) {
|
|
3893
|
+
if (sender.url !== chrome.runtime.getURL("popup.html")) return;
|
|
3894
|
+
await showPopup();
|
|
3895
|
+
}
|
|
3794
3896
|
async function healFromHelper() {
|
|
3795
3897
|
try {
|
|
3796
3898
|
if (!await chrome.permissions.contains({ permissions: ["nativeMessaging"] })) return false;
|
|
@@ -3828,6 +3930,10 @@
|
|
|
3828
3930
|
await chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.5 });
|
|
3829
3931
|
await syncProxy();
|
|
3830
3932
|
await reloadIfFilesChanged();
|
|
3933
|
+
if ((await chrome.storage.local.get(PAIR_AFTER_RESTART_KEY))[PAIR_AFTER_RESTART_KEY]) {
|
|
3934
|
+
await chrome.storage.local.remove(PAIR_AFTER_RESTART_KEY);
|
|
3935
|
+
if (await healFromHelper()) return;
|
|
3936
|
+
}
|
|
3831
3937
|
if (await adoptBundledPairing()) return;
|
|
3832
3938
|
if (!await getConfig() && await healFromHelper()) return;
|
|
3833
3939
|
await ensureConnected();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "MCP Browser Extension – AI agent in your logged-in Chrome",
|
|
4
|
-
"version": "0.9.
|
|
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.35",
|
|
5
5
|
"description": "Drive your real Chrome browser over MCP — real logins, real cookies. A stdio MCP server (CLI) plus an MV3 extension, driving Chrome via chrome.scripting/chrome.tabs. Multi-tab batch automation, accessibility snapshots, deny-all security by default.",
|
|
6
6
|
"author": "Mehmood Ur Rehman Qureshi",
|
|
7
7
|
"license": "MIT",
|
|
@@ -78,6 +78,6 @@
|
|
|
78
78
|
"typescript": "^5.7.2"
|
|
79
79
|
},
|
|
80
80
|
"engines": {
|
|
81
|
-
"node": ">=
|
|
81
|
+
"node": ">=20.0.0"
|
|
82
82
|
}
|
|
83
83
|
}
|