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.
Files changed (58) hide show
  1. package/README.md +33 -15
  2. package/dist/shared/client-auth.d.ts +2 -0
  3. package/dist/shared/phone-pairing.d.ts +35 -0
  4. package/dist/shared/phone-pairing.js +10 -0
  5. package/dist/shared/policy.js +7 -0
  6. package/dist/shared/protocol.d.ts +13 -2
  7. package/dist/shared/protocol.js +13 -1
  8. package/dist/shared/site-risk.d.ts +31 -0
  9. package/dist/shared/site-risk.js +190 -0
  10. package/dist/src/bridge/client-identity.d.ts +11 -5
  11. package/dist/src/bridge/client-identity.js +7 -4
  12. package/dist/src/bridge/connection.js +1 -0
  13. package/dist/src/bridge/server.d.ts +1 -0
  14. package/dist/src/bridge/server.js +38 -19
  15. package/dist/src/bridge/workspace.d.ts +1 -0
  16. package/dist/src/bridge/workspace.js +11 -3
  17. package/dist/src/cli.js +61 -18
  18. package/dist/src/config.d.ts +2 -0
  19. package/dist/src/config.js +26 -1
  20. package/dist/src/executor/extension-executor.d.ts +1 -0
  21. package/dist/src/executor/extension-executor.js +10 -1
  22. package/dist/src/executor/types.d.ts +3 -1
  23. package/dist/src/mcp/helpers.js +18 -6
  24. package/dist/src/mcp/person-wait.js +2 -1
  25. package/dist/src/mcp/server.d.ts +2 -1
  26. package/dist/src/mcp/server.js +4 -4
  27. package/dist/src/mcp/tools.d.ts +2 -2
  28. package/dist/src/mcp/tools.js +46 -9
  29. package/dist/src/native-host.js +11 -1
  30. package/dist/src/remote/claude-chat.d.ts +21 -0
  31. package/dist/src/remote/claude-chat.js +209 -0
  32. package/dist/src/remote/codex-rpc.d.ts +19 -0
  33. package/dist/src/remote/codex-rpc.js +94 -0
  34. package/dist/src/remote/http.d.ts +5 -0
  35. package/dist/src/remote/http.js +99 -7
  36. package/dist/src/remote/key.d.ts +4 -0
  37. package/dist/src/remote/key.js +6 -2
  38. package/dist/src/remote/phone-chat.d.ts +57 -0
  39. package/dist/src/remote/phone-chat.js +366 -0
  40. package/dist/src/remote/phone-control.d.ts +6 -0
  41. package/dist/src/remote/phone-control.js +132 -0
  42. package/dist/src/remote/phone-pairing.d.ts +41 -0
  43. package/dist/src/remote/phone-pairing.js +180 -0
  44. package/dist/src/remote/relay.d.ts +37 -0
  45. package/dist/src/remote/relay.js +207 -0
  46. package/docs/CLIENT-APPROVAL.md +44 -9
  47. package/docs/PHONE-PAIRING.md +97 -0
  48. package/extension-dist/THIRD-PARTY-NOTICES.txt +35 -0
  49. package/extension-dist/activity-dashboard.css +4 -9
  50. package/extension-dist/background.js +725 -127
  51. package/extension-dist/brand.css +63 -0
  52. package/extension-dist/manifest.json +1 -1
  53. package/extension-dist/options.html +25 -22
  54. package/extension-dist/options.js +179 -42
  55. package/extension-dist/popup.html +33 -41
  56. package/extension-dist/popup.js +2529 -126
  57. package/extension-dist/site-protection.css +11 -0
  58. 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 }) && (!conn.hasCap(client_auth_1.CLIENT_APPROVAL_CAP) || !opts?.authorization)) {
438
- throw new types_1.ExecutorError('POLICY_DENIED', 'Signed client authorization and an updated extension are required.');
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 false;
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
- // That exact name is a suggestion; any other label is the person's choice.
716
- if (!installId || helperProfile === DEFAULT_PROFILE || label !== helperProfile) {
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 && suggested !== ownKey && chrome?.keys().includes(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
- if (!active)
48
+ const workspace = peekActiveWorkspace();
49
+ if (!workspace)
44
50
  throw new Error('no active workspace (server not fully booted)');
45
- return active;
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
- exitWithDeadline(Promise.allSettled([http.close(), stopBridge()]));
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 base = tunnel ? tunnel.url : `http://127.0.0.1:${http.port}`;
517
- const lines = [
518
- '',
519
- tunnel ? 'MCP Browser Extension is reachable from anywhere.' : `MCP Browser Extension is serving MCP over HTTP on 127.0.0.1:${http.port}.`,
520
- '',
521
- 'Connector URL (paste into claude.ai > Settings > Connectors, ChatGPT, or any MCP client):',
522
- ` ${base}/mcp/${key}`,
523
- '',
524
- 'Or send the key as a header, e.g. for Claude Code on another computer:',
525
- ` claude mcp add --transport http browser ${base}/mcp --header "Authorization: Bearer ${key}"`,
526
- '',
527
- 'Anyone with this URL can drive this Chrome within the allowed sites. Keep it private.',
528
- 'Cut everyone off: restart with --rotate-remote-key.',
529
- ...(tunnel ? ['The tunnel URL changes every time this starts.'] : []),
530
- '',
531
- ];
532
- process.stdout.write(`${lines.join('\n')}\n`);
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
@@ -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
  /**
@@ -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
- return (await this.send('draw', { strokes }, { tabId: opts?.tabId }));
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 {
@@ -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 { html } = await ex.getHtml(args.selector ? { selector: args.selector } : undefined, {
148
- tabId: args.tabId,
149
- frameId: args.frameId,
150
- allFrames: args.allFrames,
151
- });
152
- return (0, markdown_extract_1.htmlToMarkdown)(html);
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.';
@@ -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. */
@@ -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. You cannot approve a site yourself or enable all-site access. ' +
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. Never approve it yourself or enable all-site access. ' +
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) => {
@@ -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
- /** Register the tool surface; returns the names actually advertised. */
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 {};