mcp-browser-extension 0.9.35 → 0.9.38
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 +33 -15
- package/dist/shared/client-auth.d.ts +2 -0
- package/dist/shared/phone-pairing.d.ts +35 -0
- package/dist/shared/phone-pairing.js +10 -0
- package/dist/shared/policy.js +7 -0
- package/dist/shared/protocol.d.ts +13 -2
- package/dist/shared/protocol.js +13 -1
- package/dist/shared/site-risk.d.ts +31 -0
- package/dist/shared/site-risk.js +190 -0
- package/dist/src/bridge/client-identity.d.ts +11 -5
- package/dist/src/bridge/client-identity.js +7 -4
- package/dist/src/bridge/connection.js +1 -0
- package/dist/src/bridge/server.d.ts +1 -0
- package/dist/src/bridge/server.js +38 -19
- package/dist/src/bridge/workspace.d.ts +1 -0
- package/dist/src/bridge/workspace.js +11 -3
- package/dist/src/cli.js +61 -18
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +26 -1
- package/dist/src/executor/extension-executor.d.ts +1 -0
- package/dist/src/executor/extension-executor.js +10 -1
- package/dist/src/executor/types.d.ts +3 -1
- package/dist/src/mcp/helpers.js +18 -6
- package/dist/src/mcp/person-wait.js +2 -1
- package/dist/src/mcp/server.d.ts +2 -1
- package/dist/src/mcp/server.js +4 -4
- package/dist/src/mcp/tools.d.ts +2 -2
- package/dist/src/mcp/tools.js +46 -9
- package/dist/src/native-host.js +11 -1
- package/dist/src/remote/claude-chat.d.ts +21 -0
- package/dist/src/remote/claude-chat.js +209 -0
- package/dist/src/remote/codex-rpc.d.ts +19 -0
- package/dist/src/remote/codex-rpc.js +94 -0
- package/dist/src/remote/http.d.ts +5 -0
- package/dist/src/remote/http.js +99 -7
- package/dist/src/remote/key.d.ts +4 -0
- package/dist/src/remote/key.js +6 -2
- package/dist/src/remote/phone-chat.d.ts +57 -0
- package/dist/src/remote/phone-chat.js +366 -0
- package/dist/src/remote/phone-control.d.ts +6 -0
- package/dist/src/remote/phone-control.js +132 -0
- package/dist/src/remote/phone-pairing.d.ts +41 -0
- package/dist/src/remote/phone-pairing.js +180 -0
- package/dist/src/remote/relay.d.ts +37 -0
- package/dist/src/remote/relay.js +207 -0
- package/docs/CLIENT-APPROVAL.md +44 -9
- package/docs/PHONE-PAIRING.md +97 -0
- package/extension-dist/THIRD-PARTY-NOTICES.txt +35 -0
- package/extension-dist/activity-dashboard.css +4 -9
- package/extension-dist/background.js +725 -127
- package/extension-dist/brand.css +63 -0
- package/extension-dist/manifest.json +1 -1
- package/extension-dist/options.html +25 -22
- package/extension-dist/options.js +179 -42
- package/extension-dist/popup.html +33 -41
- package/extension-dist/popup.js +2529 -126
- package/extension-dist/site-protection.css +11 -0
- package/package.json +13 -2
|
@@ -81,8 +81,6 @@ const MAX_PREAUTH_FRAMES = 10;
|
|
|
81
81
|
const PORT_WAIT_MS = 4_000;
|
|
82
82
|
/** Pause between port-bind retries while waiting for the old listener to exit. */
|
|
83
83
|
const PORT_RETRY_MS = 250;
|
|
84
|
-
/** Bridge frames are small JSON control messages; cap unauthenticated memory use. */
|
|
85
|
-
const MAX_BRIDGE_FRAME_BYTES = 4 * 1024 * 1024;
|
|
86
84
|
/** Native clients omit Origin; Chrome extensions use their own isolated origin. */
|
|
87
85
|
function allowBridgeOrigin(origin) {
|
|
88
86
|
return origin === undefined || /^chrome-extension:\/\/[a-p]{32}$/.test(origin);
|
|
@@ -319,7 +317,7 @@ class BridgeServer {
|
|
|
319
317
|
const wss = new ws_1.WebSocketServer({
|
|
320
318
|
host,
|
|
321
319
|
port,
|
|
322
|
-
maxPayload: MAX_BRIDGE_FRAME_BYTES,
|
|
320
|
+
maxPayload: protocol_1.MAX_BRIDGE_FRAME_BYTES,
|
|
323
321
|
perMessageDeflate: false,
|
|
324
322
|
verifyClient: ({ origin }) => allowBridgeOrigin(origin || undefined),
|
|
325
323
|
});
|
|
@@ -393,6 +391,11 @@ class BridgeServer {
|
|
|
393
391
|
*/
|
|
394
392
|
async sendCommand(method, params, opts) {
|
|
395
393
|
const profile = routeKey(opts?.profile);
|
|
394
|
+
const identity = (0, client_identity_1.currentClientIdentity)();
|
|
395
|
+
identity?.assertAccess?.();
|
|
396
|
+
if (identity?.profile && identity.profile !== profile) {
|
|
397
|
+
throw new types_1.ExecutorError('POLICY_DENIED', 'This phone is paired with a different browser profile.');
|
|
398
|
+
}
|
|
396
399
|
const timeoutMs = opts?.timeoutMs ?? (0, connection_1.defaultTimeoutFor)(method);
|
|
397
400
|
const intent = { v: protocol_1.PROTOCOL_VERSION, method, params, tabId: opts?.tabId, timeoutMs };
|
|
398
401
|
const challenge = this.hub
|
|
@@ -429,13 +432,23 @@ class BridgeServer {
|
|
|
429
432
|
return this.forwardCommand(method, params, options);
|
|
430
433
|
}
|
|
431
434
|
/** Never sign a peer's request using the hub's identity. Chrome verifies its original proof. */
|
|
432
|
-
async forwardCommand(method, params, opts) {
|
|
435
|
+
async forwardCommand(method, params, opts, peerPid) {
|
|
433
436
|
const profile = routeKey(opts?.profile);
|
|
434
437
|
const conn = this.conns.get(profile);
|
|
435
438
|
if (!conn?.isOpen())
|
|
436
439
|
throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', this.noPairMessage(profile));
|
|
437
|
-
if (!(0, client_auth_1.isHealthProbe)({ method, params })
|
|
438
|
-
|
|
440
|
+
if (!(0, client_auth_1.isHealthProbe)({ method, params })) {
|
|
441
|
+
if (!conn.hasCap(client_auth_1.CLIENT_APPROVAL_CAP)) {
|
|
442
|
+
throw new types_1.ExecutorError('POLICY_DENIED', `Signed client authorization and an updated extension are required. The browser paired as "${profile}" ` +
|
|
443
|
+
'runs an extension too old to check app approvals: update MCP Browser Extension from the Chrome Web Store ' +
|
|
444
|
+
'(or click Reload on it in chrome://extensions), then retry.');
|
|
445
|
+
}
|
|
446
|
+
if (!opts?.authorization) {
|
|
447
|
+
throw new types_1.ExecutorError('POLICY_DENIED', `Signed client authorization is missing: ${peerPid === undefined ? 'an AI app on this computer' : `an AI app on this computer (process ${peerPid})`} ` +
|
|
448
|
+
'is running an MCP Browser Extension server older than 0.9.32, which cannot sign browser commands. ' +
|
|
449
|
+
'In that app\'s MCP settings start "npx -y mcp-browser-extension@latest" (remove any pinned older version), ' +
|
|
450
|
+
'then fully quit and reopen the app.');
|
|
451
|
+
}
|
|
439
452
|
}
|
|
440
453
|
// A peer signs with the challenge it last heard about. If this browser has
|
|
441
454
|
// reconnected since, Chrome would refuse the proof as stale: say so instead,
|
|
@@ -449,7 +462,7 @@ class BridgeServer {
|
|
|
449
462
|
* cannot see the hub's handshake, so it answers false (the conservative path). */
|
|
450
463
|
hasCap(profile, cap) {
|
|
451
464
|
if (this.hub)
|
|
452
|
-
return
|
|
465
|
+
return cap === protocol_1.WIRE_CAP_SITE_CHECKS && this.hub.profiles.some((p) => p.name === routeKey(profile) && p.siteChecks === true);
|
|
453
466
|
const conn = this.conns.get(routeKey(profile));
|
|
454
467
|
return !!conn?.isOpen() && conn.hasCap(cap);
|
|
455
468
|
}
|
|
@@ -559,6 +572,7 @@ class BridgeServer {
|
|
|
559
572
|
const o = this.origins.get(name);
|
|
560
573
|
out.push({
|
|
561
574
|
name,
|
|
575
|
+
...(conn.hasCap(protocol_1.WIRE_CAP_SITE_CHECKS) ? { siteChecks: true } : {}),
|
|
562
576
|
naming: o?.naming ?? 'legacy',
|
|
563
577
|
...(includeChallenge && conn.approvalChallenge ? { approvalChallenge: conn.approvalChallenge } : {}),
|
|
564
578
|
...(o?.extVersion ? { extVersion: o.extVersion } : {}),
|
|
@@ -701,6 +715,13 @@ class BridgeServer {
|
|
|
701
715
|
nameFor(frame) {
|
|
702
716
|
const installId = (0, profiles_1.isValidInstallId)(frame.installId) ? frame.installId : undefined;
|
|
703
717
|
const helperProfile = this.opts.helperProfile ?? (0, config_1.resolveProfile)();
|
|
718
|
+
// Chrome won't tell an extension its profile, but we can look it up on disk.
|
|
719
|
+
const chrome = !installId || this.opts.chromeProfiles === null ? null
|
|
720
|
+
: this.opts.chromeProfiles ?? { nameOf: chrome_profile_name_1.chromeProfileName, keys: () => (0, chrome_profile_name_1.chromeProfileKeys)() };
|
|
721
|
+
const own = installId ? chrome?.nameOf(frame.ext?.id, installId) : undefined;
|
|
722
|
+
const ownKey = own ? (0, chrome_profile_name_1.profileKeyFromChromeName)(own) : undefined;
|
|
723
|
+
let otherProfiles;
|
|
724
|
+
const isOtherChromeProfile = (name) => name !== ownKey && (otherProfiles ??= chrome?.keys() ?? []).includes(name);
|
|
704
725
|
let suggested;
|
|
705
726
|
if (typeof frame.suggestedProfile === 'string' && frame.suggestedProfile.trim()) {
|
|
706
727
|
try {
|
|
@@ -712,8 +733,10 @@ class BridgeServer {
|
|
|
712
733
|
const label = routeKey(frame.profile);
|
|
713
734
|
// Extensions before 0.9.35 saved the helper's name as if it was typed, so
|
|
714
735
|
// every browser paired with Connect claimed it and knocked the others off.
|
|
715
|
-
//
|
|
716
|
-
|
|
736
|
+
// A label that is the helper's name, or another Chrome profile's name on
|
|
737
|
+
// this computer, is a suggestion; any other label is the person's choice.
|
|
738
|
+
const fromHelper = helperProfile !== DEFAULT_PROFILE && label === helperProfile;
|
|
739
|
+
if (!installId || !(fromHelper || isOtherChromeProfile(label))) {
|
|
717
740
|
return { profile: label, naming: 'options', installId };
|
|
718
741
|
}
|
|
719
742
|
suggested = label;
|
|
@@ -724,13 +747,8 @@ class BridgeServer {
|
|
|
724
747
|
const conn = this.conns.get(name);
|
|
725
748
|
return !!conn && conn.isOpen() && this.origins.get(name)?.installId !== installId;
|
|
726
749
|
};
|
|
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
750
|
// Never hand a browser the name of a different Chrome profile on this computer.
|
|
733
|
-
if (suggested &&
|
|
751
|
+
if (suggested && isOtherChromeProfile(suggested))
|
|
734
752
|
suggested = undefined;
|
|
735
753
|
return { profile: this.registry.assign(installId, heldByOther, [suggested], ownKey), naming: 'auto', installId };
|
|
736
754
|
}
|
|
@@ -740,13 +758,13 @@ class BridgeServer {
|
|
|
740
758
|
this.log(`mcp-browser-extension pid ${pid ?? '?'} joined as a peer (${this.peers.size} sharing these browsers)`);
|
|
741
759
|
this.sendPeer(ws, { type: 'peer_welcome', hubPid: process.pid });
|
|
742
760
|
this.sendPeer(ws, { type: 'peer_state', profiles: this.pairedProfiles(true) });
|
|
743
|
-
ws.on('message', (raw) => this.handlePeerFrame(ws, raw.toString()));
|
|
761
|
+
ws.on('message', (raw) => this.handlePeerFrame(ws, raw.toString(), pid));
|
|
744
762
|
ws.on('close', () => this.peers.delete(ws));
|
|
745
763
|
ws.on('error', () => {
|
|
746
764
|
/* the close that follows cleans up */
|
|
747
765
|
});
|
|
748
766
|
}
|
|
749
|
-
handlePeerFrame(ws, raw) {
|
|
767
|
+
handlePeerFrame(ws, raw, pid) {
|
|
750
768
|
let frame;
|
|
751
769
|
try {
|
|
752
770
|
frame = JSON.parse(raw);
|
|
@@ -758,9 +776,9 @@ class BridgeServer {
|
|
|
758
776
|
work.then((data) => this.sendPeer(ws, { type: 'relay_result', id, ok: true, data }), (err) => this.sendPeer(ws, (0, peer_1.relayError)(id, err)));
|
|
759
777
|
};
|
|
760
778
|
if (frame.type === 'relay')
|
|
761
|
-
answer(frame.id, this.forwardCommand(frame.method, frame.params ?? {}, frame.opts));
|
|
779
|
+
answer(frame.id, this.forwardCommand(frame.method, frame.params ?? {}, frame.opts, pid));
|
|
762
780
|
else if (frame.type === 'peer_rename')
|
|
763
|
-
answer(frame.id, this.forwardCommand('ping_probe', { requireClientApproval: true, renameProfile: { from: frame.from, to: frame.to } }, { profile: frame.from, timeoutMs: (0, connection_1.defaultTimeoutFor)('ping_probe'), authorization: frame.authorization }).then(() => this.renameApprovedProfile(frame.from, frame.to)));
|
|
781
|
+
answer(frame.id, this.forwardCommand('ping_probe', { requireClientApproval: true, renameProfile: { from: frame.from, to: frame.to } }, { profile: frame.from, timeoutMs: (0, connection_1.defaultTimeoutFor)('ping_probe'), authorization: frame.authorization }, pid).then(() => this.renameApprovedProfile(frame.from, frame.to)));
|
|
764
782
|
else if (frame.type === 'peer_blocked' && typeof frame.host === 'string')
|
|
765
783
|
this.noteBlocked(frame.host, String(frame.method ?? ''));
|
|
766
784
|
}
|
|
@@ -838,6 +856,7 @@ class BridgeServer {
|
|
|
838
856
|
type: 'welcome',
|
|
839
857
|
v: protocol_1.PROTOCOL_VERSION,
|
|
840
858
|
serverVersion: this.opts.serverVersion,
|
|
859
|
+
siteChecks: this.grants !== null,
|
|
841
860
|
sessionId,
|
|
842
861
|
heartbeatMs: this.heartbeatMs,
|
|
843
862
|
policy: this.policy,
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* switch) is logged to stderr and swallowed so it can never break a tool call.
|
|
13
13
|
*/
|
|
14
14
|
import { type Workspace } from './datadir';
|
|
15
|
+
export declare const withWorkspace: <T>(workspace: Workspace, fn: () => T) => T;
|
|
15
16
|
/** Install the initial workspace at boot (from cli.ts). */
|
|
16
17
|
export declare function setActiveWorkspace(w: Workspace): void;
|
|
17
18
|
/** The current workspace. Throws if the boot path never installed one. */
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* switch) is logged to stderr and swallowed so it can never break a tool call.
|
|
14
14
|
*/
|
|
15
15
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.withWorkspace = void 0;
|
|
16
17
|
exports.setActiveWorkspace = setActiveWorkspace;
|
|
17
18
|
exports.getActiveWorkspace = getActiveWorkspace;
|
|
18
19
|
exports.peekActiveWorkspace = peekActiveWorkspace;
|
|
@@ -25,6 +26,7 @@ exports.captureDownload = captureDownload;
|
|
|
25
26
|
exports.appendHistory = appendHistory;
|
|
26
27
|
const node_fs_1 = require("node:fs");
|
|
27
28
|
const node_path_1 = require("node:path");
|
|
29
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
28
30
|
const config_1 = require("../config");
|
|
29
31
|
// `mcp/log` (not `mcp/server`) so this stays free of the server import cycle
|
|
30
32
|
// while still honouring `--log-level silent`.
|
|
@@ -32,6 +34,9 @@ const log_1 = require("../mcp/log");
|
|
|
32
34
|
const download_1 = require("../../shared/download");
|
|
33
35
|
const datadir_1 = require("./datadir");
|
|
34
36
|
let active = null;
|
|
37
|
+
const scoped = new node_async_hooks_1.AsyncLocalStorage();
|
|
38
|
+
const withWorkspace = (workspace, fn) => scoped.run(workspace, fn);
|
|
39
|
+
exports.withWorkspace = withWorkspace;
|
|
35
40
|
/** Monotonic counter so saved result/screenshot filenames sort in capture order. */
|
|
36
41
|
let seq = 0;
|
|
37
42
|
/** Install the initial workspace at boot (from cli.ts). */
|
|
@@ -40,13 +45,14 @@ function setActiveWorkspace(w) {
|
|
|
40
45
|
}
|
|
41
46
|
/** The current workspace. Throws if the boot path never installed one. */
|
|
42
47
|
function getActiveWorkspace() {
|
|
43
|
-
|
|
48
|
+
const workspace = peekActiveWorkspace();
|
|
49
|
+
if (!workspace)
|
|
44
50
|
throw new Error('no active workspace (server not fully booted)');
|
|
45
|
-
return
|
|
51
|
+
return workspace;
|
|
46
52
|
}
|
|
47
53
|
/** The current workspace, or null before boot completes (for non-throwing callers). */
|
|
48
54
|
function peekActiveWorkspace() {
|
|
49
|
-
return active;
|
|
55
|
+
return scoped.getStore() ?? active;
|
|
50
56
|
}
|
|
51
57
|
/** Reset for tests. */
|
|
52
58
|
function resetActiveWorkspaceForTesting() {
|
|
@@ -60,6 +66,8 @@ function resetActiveWorkspaceForTesting() {
|
|
|
60
66
|
* the resolved workspace.
|
|
61
67
|
*/
|
|
62
68
|
function switchWorkspace(opts) {
|
|
69
|
+
if (scoped.getStore())
|
|
70
|
+
throw new Error('A paired phone cannot switch browser or task workspaces.');
|
|
63
71
|
const cur = getActiveWorkspace();
|
|
64
72
|
const profile = opts.profile === undefined ? cur.profile : (0, config_1.sanitizeName)(opts.profile, 'profile');
|
|
65
73
|
// A profile switch resets the task to "default" unless the caller names one.
|
package/dist/src/cli.js
CHANGED
|
@@ -28,6 +28,9 @@ const server_2 = require("./mcp/server");
|
|
|
28
28
|
const http_1 = require("./remote/http");
|
|
29
29
|
const key_1 = require("./remote/key");
|
|
30
30
|
const tunnel_1 = require("./remote/tunnel");
|
|
31
|
+
const relay_1 = require("./remote/relay");
|
|
32
|
+
const phone_pairing_1 = require("./remote/phone-pairing");
|
|
33
|
+
const phone_control_1 = require("./remote/phone-control");
|
|
31
34
|
const tools_1 = require("./mcp/tools");
|
|
32
35
|
const telemetry_1 = require("./telemetry");
|
|
33
36
|
const auto_update_1 = require("./auto-update");
|
|
@@ -475,6 +478,10 @@ async function main() {
|
|
|
475
478
|
*/
|
|
476
479
|
async function serveRemote(cfg, dataDir, stopBridge) {
|
|
477
480
|
const key = (0, key_1.resolveRemoteKey)(dataDir, { rotate: cfg.rotateRemoteKey });
|
|
481
|
+
const phonePairing = new phone_pairing_1.PhonePairing(dataDir, cfg.profile);
|
|
482
|
+
if (cfg.rotateRemoteKey)
|
|
483
|
+
for (const phone of phonePairing.state().devices)
|
|
484
|
+
phonePairing.remove(phone.id);
|
|
478
485
|
if (cfg.rotateRemoteKey)
|
|
479
486
|
(0, server_2.logErr)('remote key rotated: every earlier connector URL stops working.');
|
|
480
487
|
const http = await (0, http_1.startHttpMcp)({
|
|
@@ -484,15 +491,20 @@ async function serveRemote(cfg, dataDir, stopBridge) {
|
|
|
484
491
|
policy: cfg.policy,
|
|
485
492
|
log: (m) => (0, server_2.logErr)(m),
|
|
486
493
|
accessGuard: cfg.noEmailVerification ? access_1.bypassEmailVerification : undefined,
|
|
494
|
+
phonePairing,
|
|
495
|
+
phoneChat: process.env.MBE_PHONE_CODEX === '1' || process.env.MBE_PHONE_CHAT === '1',
|
|
487
496
|
});
|
|
497
|
+
const phoneControl = await (0, phone_control_1.startPhoneControl)(phonePairing).catch(async (err) => { await http.close(); throw err; });
|
|
488
498
|
let tunnel = null;
|
|
499
|
+
let relay = null;
|
|
489
500
|
let shuttingDown = false;
|
|
490
501
|
const shutdown = () => {
|
|
491
502
|
if (shuttingDown)
|
|
492
503
|
return;
|
|
493
504
|
shuttingDown = true;
|
|
494
505
|
tunnel?.stop();
|
|
495
|
-
|
|
506
|
+
relay?.stop();
|
|
507
|
+
exitWithDeadline(Promise.allSettled([phoneControl.close(), http.close(), stopBridge()]));
|
|
496
508
|
};
|
|
497
509
|
process.on('SIGINT', shutdown);
|
|
498
510
|
process.on('SIGTERM', shutdown);
|
|
@@ -502,34 +514,65 @@ async function serveRemote(cfg, dataDir, stopBridge) {
|
|
|
502
514
|
localPort: http.port,
|
|
503
515
|
log: (m) => (0, server_2.logErr)(m),
|
|
504
516
|
onExit: (code) => {
|
|
517
|
+
phonePairing.setPublicUrl();
|
|
505
518
|
if (shuttingDown)
|
|
506
519
|
return;
|
|
507
520
|
(0, server_2.logErr)(`the tunnel closed (cloudflared exit ${code}); remote clients are cut off. Restart to get a new URL.`);
|
|
508
521
|
},
|
|
509
522
|
});
|
|
523
|
+
phonePairing.setPublicUrl(tunnel.url);
|
|
510
524
|
}
|
|
511
525
|
catch (err) {
|
|
526
|
+
await phoneControl.close();
|
|
512
527
|
await http.close();
|
|
513
528
|
throw err;
|
|
514
529
|
}
|
|
515
530
|
}
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
531
|
+
const printConnector = (base, headline, notes = []) => {
|
|
532
|
+
const lines = [
|
|
533
|
+
'',
|
|
534
|
+
headline,
|
|
535
|
+
'',
|
|
536
|
+
'Connector URL (paste into claude.ai > Settings > Connectors, ChatGPT, or any MCP client):',
|
|
537
|
+
` ${base}/mcp/${key}`,
|
|
538
|
+
'',
|
|
539
|
+
'Or send the key as a header, e.g. for Claude Code on another computer:',
|
|
540
|
+
` claude mcp add --transport http browser ${base}/mcp --header "Authorization: Bearer ${key}"`,
|
|
541
|
+
'',
|
|
542
|
+
'Anyone with this URL can drive this Chrome within the allowed sites. Keep it private.',
|
|
543
|
+
'Cut everyone off: restart with --rotate-remote-key.',
|
|
544
|
+
...notes,
|
|
545
|
+
'',
|
|
546
|
+
];
|
|
547
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
548
|
+
};
|
|
549
|
+
if (cfg.relay) {
|
|
550
|
+
// The relay names the public address once it accepts this computer; print
|
|
551
|
+
// the connector then, and only once (the link reconnects after outages).
|
|
552
|
+
let announced = false;
|
|
553
|
+
(0, server_2.logErr)(`linking to the relay at ${cfg.relay} (relay token: ${(0, relay_1.relayTokenPath)(dataDir)})`);
|
|
554
|
+
relay = (0, relay_1.connectRelay)({
|
|
555
|
+
url: cfg.relay,
|
|
556
|
+
token: (0, relay_1.resolveRelayToken)(dataDir),
|
|
557
|
+
localPort: http.port,
|
|
558
|
+
log: (m) => (0, server_2.logErr)(m),
|
|
559
|
+
onOffline: () => phonePairing.setPublicUrl(),
|
|
560
|
+
onReady: (publicUrl) => {
|
|
561
|
+
try {
|
|
562
|
+
phonePairing.setPublicUrl(publicUrl);
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
(0, server_2.logErr)('phone pairing requires an HTTPS relay address');
|
|
566
|
+
}
|
|
567
|
+
if (announced)
|
|
568
|
+
return (0, server_2.logErr)('relay link restored');
|
|
569
|
+
announced = true;
|
|
570
|
+
printConnector(publicUrl.replace(/\/+$/, ''), 'MCP Browser Extension is reachable from anywhere through your relay.', ['The address stays the same; this computer reconnects to the relay on its own.']);
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
printConnector(tunnel ? tunnel.url : `http://127.0.0.1:${http.port}`, tunnel ? 'MCP Browser Extension is reachable from anywhere.' : `MCP Browser Extension is serving MCP over HTTP on 127.0.0.1:${http.port}.`, tunnel ? ['The tunnel URL changes every time this starts.'] : []);
|
|
533
576
|
}
|
|
534
577
|
main().catch((err) => {
|
|
535
578
|
// Port-busy and bad-flag failures carry a plain-English message already
|
package/dist/src/config.d.ts
CHANGED
|
@@ -53,6 +53,8 @@ export interface CliConfig {
|
|
|
53
53
|
httpPort: number;
|
|
54
54
|
/** `--tunnel`: also open a Cloudflare quick tunnel to the HTTP port. Implies `--http`. */
|
|
55
55
|
tunnel: boolean;
|
|
56
|
+
/** `--relay <url>`: also link to a relay server for a permanent public address. Implies `--http`. */
|
|
57
|
+
relay?: string;
|
|
56
58
|
/** `--rotate-remote-key`: replace the remote key, cutting off every remote client. */
|
|
57
59
|
rotateRemoteKey: boolean;
|
|
58
60
|
/**
|
package/dist/src/config.js
CHANGED
|
@@ -90,6 +90,7 @@ function parseArgs(argv) {
|
|
|
90
90
|
let http = false;
|
|
91
91
|
let httpPort = envInt('MCP_BROWSER_EXTENSION_HTTP_PORT') ?? exports.DEFAULT_HTTP_PORT;
|
|
92
92
|
let tunnel = false;
|
|
93
|
+
let relay;
|
|
93
94
|
let rotateRemoteKey = false;
|
|
94
95
|
let showHelp = false;
|
|
95
96
|
let showVersion = false;
|
|
@@ -218,6 +219,10 @@ function parseArgs(argv) {
|
|
|
218
219
|
tunnel = true;
|
|
219
220
|
http = true;
|
|
220
221
|
break;
|
|
222
|
+
case '--relay':
|
|
223
|
+
relay = requireValue(argv[++i], '--relay');
|
|
224
|
+
http = true;
|
|
225
|
+
break;
|
|
221
226
|
case '--rotate-remote-key':
|
|
222
227
|
rotateRemoteKey = true;
|
|
223
228
|
break;
|
|
@@ -246,6 +251,22 @@ function parseArgs(argv) {
|
|
|
246
251
|
if (noEmailVerification && tunnel) {
|
|
247
252
|
throw new Error('--no-email-verification cannot be used with --tunnel');
|
|
248
253
|
}
|
|
254
|
+
if (noEmailVerification && relay) {
|
|
255
|
+
throw new Error('--no-email-verification cannot be used with --relay');
|
|
256
|
+
}
|
|
257
|
+
if (relay && tunnel) {
|
|
258
|
+
throw new Error('--relay and --tunnel each give a public address; use one');
|
|
259
|
+
}
|
|
260
|
+
if (relay) {
|
|
261
|
+
let protocol = '';
|
|
262
|
+
try {
|
|
263
|
+
protocol = new URL(relay).protocol;
|
|
264
|
+
}
|
|
265
|
+
catch { /* reported below */ }
|
|
266
|
+
if (protocol !== 'https:' && !(protocol === 'http:' && /^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(relay))) {
|
|
267
|
+
throw new Error(`--relay needs an https URL (plain http only for a relay on this computer), got ${relay}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
249
270
|
// File first, then flags win.
|
|
250
271
|
for (const source of [policyFile, policyFlags]) {
|
|
251
272
|
if (source?.allowDomains !== undefined && (!Array.isArray(source.allowDomains) ||
|
|
@@ -296,6 +317,7 @@ function parseArgs(argv) {
|
|
|
296
317
|
http,
|
|
297
318
|
httpPort,
|
|
298
319
|
tunnel,
|
|
320
|
+
relay,
|
|
299
321
|
rotateRemoteKey,
|
|
300
322
|
showHelp,
|
|
301
323
|
showVersion,
|
|
@@ -384,6 +406,9 @@ Connection:
|
|
|
384
406
|
--tunnel --http plus a Cloudflare quick tunnel, and print a public
|
|
385
407
|
connector URL for claude.ai, ChatGPT or any MCP client.
|
|
386
408
|
Needs cloudflared. The URL changes on every start.
|
|
409
|
+
--relay <url> --http plus a link out to your own relay server (for example
|
|
410
|
+
on AWS), for a permanent public address. The relay checks
|
|
411
|
+
this computer's relay token, kept in <data-dir>/relay-token.
|
|
387
412
|
--rotate-remote-key Make a new remote key: every remote client is cut off and
|
|
388
413
|
needs the new URL.
|
|
389
414
|
--no-auto-update Run this copy even when a newer mcp-browser-extension is published.
|
|
@@ -397,7 +422,7 @@ Connection:
|
|
|
397
422
|
--no-email-verification Skip the hosted email/device access gate for trusted local use.
|
|
398
423
|
Email and device data are not sent to the access service.
|
|
399
424
|
Same as MCP_BROWSER_EXTENSION_EMAIL_VERIFICATION=0.
|
|
400
|
-
Cannot be combined with --tunnel; the domain allowlist,
|
|
425
|
+
Cannot be combined with --tunnel or --relay; the domain allowlist,
|
|
401
426
|
deny-all defaults, and other safety gates still apply.
|
|
402
427
|
|
|
403
428
|
Backend:
|
|
@@ -39,6 +39,7 @@ export declare class ExtensionExecutor implements Executor {
|
|
|
39
39
|
active?: boolean;
|
|
40
40
|
}): Promise<TabInfo>;
|
|
41
41
|
isOpeningTab(tabId: TabId): boolean;
|
|
42
|
+
checkSiteAccess(url: string): Promise<boolean>;
|
|
42
43
|
tabClose(tabId: TabId): Promise<{
|
|
43
44
|
closed: true;
|
|
44
45
|
tabId: TabId;
|
|
@@ -134,6 +134,14 @@ class ExtensionExecutor {
|
|
|
134
134
|
const until = this.openingTabs.get(tabId);
|
|
135
135
|
return until !== undefined && until >= Date.now();
|
|
136
136
|
}
|
|
137
|
+
async checkSiteAccess(url) {
|
|
138
|
+
if (!this.bridge.hasCap(this.activeProfile(), protocol_1.WIRE_CAP_SITE_CHECKS))
|
|
139
|
+
return false;
|
|
140
|
+
const result = await this.send('site_check', { url });
|
|
141
|
+
if (result?.checked !== true)
|
|
142
|
+
throw new types_1.ExecutorError('POLICY_DENIED', 'The extension did not confirm website access.');
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
137
145
|
async tabClose(tabId) {
|
|
138
146
|
return (await this.send('tab_close', {}, { tabId }));
|
|
139
147
|
}
|
|
@@ -155,7 +163,8 @@ class ExtensionExecutor {
|
|
|
155
163
|
return (await this.send('click', { ...targetParams(t), ...frameParams(opts), button: opts?.button, clickCount: opts?.clickCount, trusted: opts?.trusted }, { tabId: opts?.tabId }));
|
|
156
164
|
}
|
|
157
165
|
async draw(strokes, opts) {
|
|
158
|
-
|
|
166
|
+
const points = strokes.reduce((sum, stroke) => sum + stroke.points.length, 0);
|
|
167
|
+
return (await this.send('draw', { strokes }, { tabId: opts?.tabId, timeoutMs: (0, protocol_1.drawTimeoutMs)(points, strokes.length) }));
|
|
159
168
|
}
|
|
160
169
|
async type(t, text, opts) {
|
|
161
170
|
return (await this.send('type', { ...targetParams(t), ...frameParams(opts), text, clear: opts?.clear, pressEnter: opts?.pressEnter, keyEvents: opts?.keyEvents, trusted: opts?.trusted }, { tabId: opts?.tabId }));
|
|
@@ -234,6 +234,8 @@ export interface Executor {
|
|
|
234
234
|
* gate should wait for one rather than fail. Optional: false when absent.
|
|
235
235
|
*/
|
|
236
236
|
isOpeningTab?(tabId: TabId): boolean;
|
|
237
|
+
/** True only after the paired extension has authorised this URL; false for old extensions. */
|
|
238
|
+
checkSiteAccess?(url: string): Promise<boolean>;
|
|
237
239
|
tabsList(): Promise<TabInfo[]>;
|
|
238
240
|
tabSelect(tabId: TabId): Promise<TabInfo>;
|
|
239
241
|
/** Open a tab. `active` (default true) focuses it; pass false to open in the background. */
|
|
@@ -405,7 +407,7 @@ export interface Executor {
|
|
|
405
407
|
* (which only carries codes that originate inside the extension); these extra
|
|
406
408
|
* codes describe failures on the server half (no backend, launch failed, etc.).
|
|
407
409
|
*/
|
|
408
|
-
export type ExecutorErrorCodeLocal = 'NO_BACKEND' | 'EXTENSION_DISCONNECTED' | 'TIMEOUT' | 'TAB_NOT_FOUND' | 'STALE_TAB' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_FAILED' | 'LAUNCH_FAILED' | 'DETACHED' | 'TARGET_GONE' | 'POLICY_DENIED' | 'DEVTOOLS_OPEN' | 'DOWNLOAD_FAILED' | 'UPLOAD_FAILED' | 'FRAME_NOT_FOUND' | 'OBSERVERS_DISABLED' | 'UNSUPPORTED' | 'BACKPRESSURE'
|
|
410
|
+
export type ExecutorErrorCodeLocal = 'RESULT_TOO_LARGE' | 'NO_BACKEND' | 'EXTENSION_DISCONNECTED' | 'TIMEOUT' | 'TAB_NOT_FOUND' | 'STALE_TAB' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_FAILED' | 'LAUNCH_FAILED' | 'DETACHED' | 'TARGET_GONE' | 'POLICY_DENIED' | 'DEVTOOLS_OPEN' | 'DOWNLOAD_FAILED' | 'UPLOAD_FAILED' | 'FRAME_NOT_FOUND' | 'OBSERVERS_DISABLED' | 'UNSUPPORTED' | 'BACKPRESSURE'
|
|
409
411
|
/** The page is a sign-in wall (session expired mid-run). Raised only when the caller opts in via `failOnAuthWall`. */
|
|
410
412
|
| 'AUTH_REQUIRED';
|
|
411
413
|
export declare class ExecutorError extends Error {
|
package/dist/src/mcp/helpers.js
CHANGED
|
@@ -144,12 +144,24 @@ function refineLinks(links, opts) {
|
|
|
144
144
|
}
|
|
145
145
|
/** Read a page (or subtree) as readable markdown. */
|
|
146
146
|
async function readAsMarkdown(ex, args) {
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
147
|
+
const target = args.selector ? { selector: args.selector } : undefined;
|
|
148
|
+
const frames = { tabId: args.tabId, frameId: args.frameId, allFrames: args.allFrames };
|
|
149
|
+
try {
|
|
150
|
+
const { html } = await ex.getHtml(target, frames);
|
|
151
|
+
return (0, markdown_extract_1.htmlToMarkdown)(html);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
// An older shared bridge maps unknown wire codes to TARGET_GONE but
|
|
155
|
+
// preserves the message. Support that mixed-version installation too.
|
|
156
|
+
if (!(error instanceof types_1.ExecutorError) || !(error.code === 'RESULT_TOO_LARGE' ||
|
|
157
|
+
(error.code === 'TARGET_GONE' && error.message.startsWith('[RESULT_TOO_LARGE]'))))
|
|
158
|
+
throw error;
|
|
159
|
+
// Large embedded scripts/assets can dwarf a page's readable content.
|
|
160
|
+
// Keep the same target and frame, and let the extension recheck access.
|
|
161
|
+
// A text result that is itself too large still fails explicitly.
|
|
162
|
+
const { text } = await ex.getText(target, frames);
|
|
163
|
+
return `Text-only view of this page:\n\n${text}`;
|
|
164
|
+
}
|
|
153
165
|
}
|
|
154
166
|
/**
|
|
155
167
|
* Fill a set of fields (keyed by selector) and optionally submit. All writes go
|
|
@@ -32,13 +32,14 @@ function notifyPersonStep() {
|
|
|
32
32
|
}
|
|
33
33
|
const APPROVAL = /Please approve "/;
|
|
34
34
|
const SITE = /^\[POLICY_DENIED\] Please allow access to /;
|
|
35
|
+
const SITE_WARNING = /^\[POLICY_DENIED\] (Suspicious website |Automatic website access needs Chrome permission\.)/;
|
|
35
36
|
/** The person-only step a result is blocked on, or undefined when it is not. */
|
|
36
37
|
function personStep(result) {
|
|
37
38
|
if (!result.isError)
|
|
38
39
|
return undefined;
|
|
39
40
|
const first = result.content[0];
|
|
40
41
|
const text = first?.type === 'text' ? first.text : '';
|
|
41
|
-
return APPROVAL.test(text) || SITE.test(text) ? text : undefined;
|
|
42
|
+
return APPROVAL.test(text) || SITE.test(text) || SITE_WARNING.test(text) ? text : undefined;
|
|
42
43
|
}
|
|
43
44
|
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
45
|
'Do not use shell sleeps or ask them to confirm in chat.';
|
package/dist/src/mcp/server.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
10
|
import { requireAccess } from '../access';
|
|
11
11
|
import type { Policy } from '../security/policy';
|
|
12
|
+
import type { ClientIdentity } from '../bridge/client-identity';
|
|
12
13
|
export { getLogLevel, logDebug, logErr, setLogLevel } from './log';
|
|
13
14
|
/**
|
|
14
15
|
* Sent to the host once, at initialize. It covers the two walls new users hit
|
|
@@ -25,7 +26,7 @@ export declare const SERVER_INSTRUCTIONS: string;
|
|
|
25
26
|
* POLICY_DENIED, and the catalog is re-sent on every turn). Omit it and the
|
|
26
27
|
* full catalog is advertised, which is what the tests want.
|
|
27
28
|
*/
|
|
28
|
-
export declare function createServer(version?: string, policy?: Policy, accessGuard?: typeof requireAccess): McpServer;
|
|
29
|
+
export declare function createServer(version?: string, policy?: Policy, accessGuard?: typeof requireAccess, phoneIdentity?: ClientIdentity): McpServer;
|
|
29
30
|
/** Start over stdio. Idempotent. */
|
|
30
31
|
export declare function startMcpServer(version?: string, policy?: Policy, accessGuard?: typeof requireAccess): Promise<void>;
|
|
31
32
|
/** Stop and release the transport. Idempotent, best-effort. */
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -43,7 +43,7 @@ exports.SERVER_INSTRUCTIONS = "MCP Browser Extension drives the user's real, sig
|
|
|
43
43
|
'Start with chrome_status. If it reports no paired browser (or any tool fails with NO_BACKEND), do not retry: ' +
|
|
44
44
|
'give the user the steps in its `setup` field, which name what went wrong and how to fix it. ' +
|
|
45
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
|
-
'toolbar icon (its popup has an Allow button). Relay the friendly one-line approval steps from the error and wait for the person. It applies at once, with no restart.
|
|
46
|
+
'toolbar icon (its popup has an Allow button). Relay the friendly one-line approval steps from the error and wait for the person. It applies at once, with no restart. Only the person can approve a website warning or enable automatic website access. In automatic mode ordinary websites work without Allow; relay suspicious-site warning reasons and wait for the person. These local checks do not guarantee safety. ' +
|
|
47
47
|
"Work in your own tab: tab_new opens one in the background, and calls without a tabId go to it, never to the tab the user is on. " +
|
|
48
48
|
"To act on the user's current page, pass its tabId from tabs_list.";
|
|
49
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. ' +
|
|
@@ -51,7 +51,7 @@ const LOCAL_BYPASS_INSTRUCTIONS = 'Email verification is disabled by the local o
|
|
|
51
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
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. ' +
|
|
53
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. ' +
|
|
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.
|
|
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. Only the person can approve a website warning or enable automatic website access. Ordinary websites need no Allow in automatic mode; relay any suspicious-site warning and wait for the person. ' +
|
|
55
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.";
|
|
56
56
|
/** Default version reported when no explicit version is passed in (legacy callers/tests). */
|
|
57
57
|
const DEFAULT_VERSION = SERVER_VERSION;
|
|
@@ -65,12 +65,12 @@ let transport = null;
|
|
|
65
65
|
* POLICY_DENIED, and the catalog is re-sent on every turn). Omit it and the
|
|
66
66
|
* full catalog is advertised, which is what the tests want.
|
|
67
67
|
*/
|
|
68
|
-
function createServer(version = DEFAULT_VERSION, policy, accessGuard = access_1.requireAccess) {
|
|
68
|
+
function createServer(version = DEFAULT_VERSION, policy, accessGuard = access_1.requireAccess, phoneIdentity) {
|
|
69
69
|
const srv = new mcp_js_1.McpServer({ name: SERVER_NAME, version }, {
|
|
70
70
|
capabilities: { tools: {}, logging: {} },
|
|
71
71
|
instructions: accessGuard === access_1.bypassEmailVerification ? LOCAL_BYPASS_INSTRUCTIONS : exports.SERVER_INSTRUCTIONS,
|
|
72
72
|
});
|
|
73
|
-
const advertised = (0, tools_1.registerTools)(srv, policy, accessGuard);
|
|
73
|
+
const advertised = (0, tools_1.registerTools)(srv, policy, accessGuard, phoneIdentity);
|
|
74
74
|
watchInitialize(srv, advertised);
|
|
75
75
|
// `McpServer` wraps the low-level `Server`, which owns the `onerror` hook.
|
|
76
76
|
srv.server.onerror = (err) => {
|
package/dist/src/mcp/tools.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ClientIdentity } from '../bridge/client-identity';
|
|
1
2
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
3
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
3
4
|
import { z } from 'zod';
|
|
@@ -68,6 +69,5 @@ export declare function dispatchToolCall(name: string, rawArgs: unknown, opts?:
|
|
|
68
69
|
}): Promise<CallToolResult>;
|
|
69
70
|
/** Assert the catalog and the dispatch table describe the same tool set. */
|
|
70
71
|
export declare function assertNoDrift(): void;
|
|
71
|
-
|
|
72
|
-
export declare function registerTools(server: McpServer, policy?: Policy, accessGuard?: typeof requireAccess): string[];
|
|
72
|
+
export declare function registerTools(server: McpServer, policy?: Policy, accessGuard?: typeof requireAccess, phoneIdentity?: ClientIdentity): string[];
|
|
73
73
|
export {};
|