@mehmoodqureshi/chrome-mcp 0.5.1 → 0.5.2

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.
@@ -30,5 +30,12 @@ export type PolicyVerdict = {
30
30
  * verdict; the caller throws its own error type on `{ ok: false }`.
31
31
  */
32
32
  export declare function evaluatePolicy(url: string, method: WireMethod, policy: WirePolicy): PolicyVerdict;
33
+ /**
34
+ * A plain-English, actionable message for a blocked domain. Tells the user what
35
+ * happened, why it's blocked (safety, not a bug), which sites ARE allowed, and
36
+ * the exact one-line change to permit this one — so a non-technical user is never
37
+ * left at a dead end. Never includes the token or any other secret.
38
+ */
39
+ export declare function blockedDomainMessage(method: string, host: string, policy: WirePolicy): string;
33
40
  /** A wire policy that allows nothing — the safe default when none was delivered. */
34
41
  export declare const DENY_ALL_WIRE_POLICY: WirePolicy;
@@ -17,6 +17,7 @@ exports.isUrlGated = isUrlGated;
17
17
  exports.hostOf = hostOf;
18
18
  exports.isDomainAllowed = isDomainAllowed;
19
19
  exports.evaluatePolicy = evaluatePolicy;
20
+ exports.blockedDomainMessage = blockedDomainMessage;
20
21
  // ---------------------------------------------------------------------------
21
22
  // Method classification
22
23
  // ---------------------------------------------------------------------------
@@ -131,14 +132,27 @@ function evaluatePolicy(url, method, policy) {
131
132
  return { ok: true };
132
133
  if (!isDomainAllowed(url, policy)) {
133
134
  const host = hostOf(url) || url;
134
- return {
135
- ok: false,
136
- reason: `"${method}" denied: ${host} is not in the domain allowlist. ` +
137
- `Add it to allowDomains, or pass --unsafe-all-domains.`,
138
- };
135
+ return { ok: false, reason: blockedDomainMessage(method, host, policy) };
139
136
  }
140
137
  return { ok: true };
141
138
  }
139
+ /**
140
+ * A plain-English, actionable message for a blocked domain. Tells the user what
141
+ * happened, why it's blocked (safety, not a bug), which sites ARE allowed, and
142
+ * the exact one-line change to permit this one — so a non-technical user is never
143
+ * left at a dead end. Never includes the token or any other secret.
144
+ */
145
+ function blockedDomainMessage(method, host, policy) {
146
+ const allowed = policy.allowDomains.filter((d) => d !== '*');
147
+ const allowedLine = allowed.length
148
+ ? `Currently allowed: ${allowed.join(', ')}.`
149
+ : `Right now no sites are allowed.`;
150
+ return (`Blocked: "${method}" can't run on ${host} because it isn't on this browser tool's allowed-sites list. ` +
151
+ `This is a safety limit (the tool drives your real, logged-in browser, so it only touches sites you've approved) — not an error. ` +
152
+ `${allowedLine} ` +
153
+ `To allow ${host}, add it to the chrome-mcp settings as: --allow-domain "${host}" (or "*.${host}" to include subdomains), ` +
154
+ `then restart/reconnect. To allow every site (less safe), use --unsafe-all-domains.`);
155
+ }
142
156
  /** A wire policy that allows nothing — the safe default when none was delivered. */
143
157
  exports.DENY_ALL_WIRE_POLICY = {
144
158
  allowDomains: [],
@@ -40,6 +40,8 @@ export declare class BridgeServer {
40
40
  constructor(opts: BridgeOptions);
41
41
  /** Bind and start listening. Returns the actual port (useful with port 0). */
42
42
  start(): Promise<number>;
43
+ /** One bind attempt. Resolves with a listening server or rejects with the listen error. */
44
+ private listenOnce;
43
45
  stop(): Promise<void>;
44
46
  get port(): number;
45
47
  hasActiveExtension(): boolean;
@@ -24,6 +24,20 @@ const HELLO_TIMEOUT_MS = 5_000;
24
24
  const DEFAULT_HEARTBEAT_MS = 15_000;
25
25
  /** Max pre-auth frames a socket may send before a valid hello (anti-idle-hold). */
26
26
  const MAX_PREAUTH_FRAMES = 10;
27
+ /** How long to wait for a just-replaced instance to release a fixed port before giving up. */
28
+ const PORT_WAIT_MS = 4_000;
29
+ /** Pause between port-bind retries while waiting for the old listener to exit. */
30
+ const PORT_RETRY_MS = 250;
31
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
32
+ /** A friendly, actionable message for the rare case the port stays busy past PORT_WAIT_MS. */
33
+ function portBusyMessage(host, port) {
34
+ return (`Couldn't start: another program is already using ${host}:${port}.\n` +
35
+ `This is almost always a previous chrome-mcp that didn't shut down. To fix it:\n` +
36
+ ` 1. Fully quit/restart your MCP host (e.g. Claude Code), or\n` +
37
+ ` 2. Stop the leftover process, then reconnect:\n` +
38
+ ` macOS/Linux: lsof -nP -iTCP:${port} -sTCP:LISTEN then kill <PID>\n` +
39
+ ` 3. Or run chrome-mcp with a different port: --port <number>`);
40
+ }
27
41
  class BridgeServer {
28
42
  opts;
29
43
  wss = null;
@@ -38,17 +52,58 @@ class BridgeServer {
38
52
  async start() {
39
53
  if (this.wss)
40
54
  return this.boundPort;
41
- const wss = new ws_1.WebSocketServer({ host: this.opts.host ?? protocol_1.BRIDGE_HOST, port: this.opts.port ?? 0 });
42
- wss.on('connection', (ws) => this.handleConnection(ws));
43
- await new Promise((resolve, reject) => {
44
- wss.once('listening', resolve);
45
- wss.once('error', reject);
55
+ const host = this.opts.host ?? protocol_1.BRIDGE_HOST;
56
+ const port = this.opts.port ?? 0;
57
+ // A fixed port can be briefly held by a just-replaced instance of ourselves
58
+ // (e.g. on a host "Reconnect"). Rather than crash with a cryptic EADDRINUSE,
59
+ // wait-and-retry for a few seconds so the old process can release it; only if
60
+ // it never frees up do we surface a plain-English, actionable error.
61
+ const deadline = Date.now() + PORT_WAIT_MS;
62
+ for (let attempt = 1;; attempt++) {
63
+ try {
64
+ const wss = await this.listenOnce(host, port);
65
+ const addr = wss.address();
66
+ this.boundPort = typeof addr === 'object' && addr ? addr.port : port;
67
+ this.wss = wss;
68
+ this.log(`bridge listening on ${host}:${this.boundPort}`);
69
+ return this.boundPort;
70
+ }
71
+ catch (err) {
72
+ const inUse = err?.code === 'EADDRINUSE';
73
+ if (!inUse || port === 0 || Date.now() >= deadline) {
74
+ if (inUse)
75
+ throw new Error(portBusyMessage(host, port));
76
+ throw err;
77
+ }
78
+ if (attempt === 1)
79
+ this.log(`port ${host}:${port} busy — waiting for the previous instance to release it…`);
80
+ await delay(PORT_RETRY_MS);
81
+ }
82
+ }
83
+ }
84
+ /** One bind attempt. Resolves with a listening server or rejects with the listen error. */
85
+ listenOnce(host, port) {
86
+ return new Promise((resolve, reject) => {
87
+ const wss = new ws_1.WebSocketServer({ host, port });
88
+ const onError = (err) => {
89
+ wss.off('listening', onListening);
90
+ // Close so the failed server doesn't linger and leak a handle on retry.
91
+ try {
92
+ wss.close();
93
+ }
94
+ catch {
95
+ /* ignore */
96
+ }
97
+ reject(err);
98
+ };
99
+ const onListening = () => {
100
+ wss.off('error', onError);
101
+ wss.on('connection', (ws) => this.handleConnection(ws));
102
+ resolve(wss);
103
+ };
104
+ wss.once('error', onError);
105
+ wss.once('listening', onListening);
46
106
  });
47
- const addr = wss.address();
48
- this.boundPort = typeof addr === 'object' && addr ? addr.port : (this.opts.port ?? 0);
49
- this.wss = wss;
50
- this.log(`bridge listening on ${this.opts.host ?? protocol_1.BRIDGE_HOST}:${this.boundPort}`);
51
- return this.boundPort;
52
107
  }
53
108
  async stop() {
54
109
  this.active?.close(1001, 'server stopping');
package/dist/src/cli.js CHANGED
@@ -108,16 +108,29 @@ async function main() {
108
108
  }),
109
109
  });
110
110
  (0, server_2.logErr)(`backend: extension-if-paired else ${cfg.cdpFallback ? 'CDP fallback' : 'none'} (prefer: ${cfg.prefer})`);
111
+ let shuttingDown = false;
111
112
  const shutdown = () => {
113
+ if (shuttingDown)
114
+ return;
115
+ shuttingDown = true;
112
116
  cleanup();
113
117
  exitWithDeadline(Promise.allSettled([(0, server_2.stopMcpServer)(), bridge.stop()]));
114
118
  };
115
119
  process.on('SIGINT', shutdown);
116
120
  process.on('SIGTERM', shutdown);
121
+ // The MCP host owns our stdin. When it disconnects (host quit, or a
122
+ // "Reconnect" that spawns a fresh child), stdin reaches EOF — that's our cue
123
+ // to exit and release the port, so a stale process never lingers and blocks
124
+ // the next connection. This is what makes reconnects "just work".
125
+ process.stdin.on('end', shutdown);
126
+ process.stdin.on('close', shutdown);
117
127
  await (0, server_2.startMcpServer)(version());
118
128
  }
119
129
  main().catch((err) => {
120
- (0, server_2.logErr)(`fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
130
+ // Port-busy and similar startup failures carry a plain-English message already;
131
+ // show that to the user without a noisy stack trace.
132
+ const friendly = err instanceof Error && /Couldn't start:/.test(err.message);
133
+ (0, server_2.logErr)(friendly ? err.message : `fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
121
134
  process.exit(1);
122
135
  });
123
136
  //# sourceMappingURL=cli.js.map
@@ -16,6 +16,9 @@ export interface SelectorDeps {
16
16
  prefer: BackendPreference;
17
17
  cdp: CdpOptions;
18
18
  pingDeadlineMs?: number;
19
+ /** How long a successful ping is trusted before re-probing (ms). A burst of
20
+ * commands within this window skips the per-call ping round-trip. 0 disables. */
21
+ pingCacheMs?: number;
19
22
  /** Test seams — default to the real executors. */
20
23
  makeExtension?: (bridge: BridgeServer) => Executor;
21
24
  makeCdp?: (opts: CdpOptions) => Executor;
@@ -16,8 +16,10 @@ function createSelector(deps) {
16
16
  const makeExt = deps.makeExtension ?? ((b) => new extension_executor_1.ExtensionExecutor(b));
17
17
  const makeCdp = deps.makeCdp ?? ((o) => new cdp_executor_1.CdpExecutor(o));
18
18
  const pingMs = deps.pingDeadlineMs ?? 800;
19
+ const pingCacheMs = deps.pingCacheMs ?? 2_000;
19
20
  const ext = makeExt(deps.bridge);
20
21
  let cdp = null;
22
+ let lastPingOkAt = 0;
21
23
  const cdpAllowed = () => deps.cdpFallback || deps.prefer === 'cdp' || !!deps.cdp.cdpEndpoint;
22
24
  const getCdp = () => {
23
25
  if (!cdpAllowed())
@@ -28,7 +30,17 @@ function createSelector(deps) {
28
30
  const tryExt = async () => {
29
31
  if (!deps.bridge.hasActiveExtension())
30
32
  return null;
31
- return (await ext.ping(pingMs)) ? ext : null;
33
+ // A recent successful ping proves liveness; skip re-pinging within the TTL so
34
+ // a burst of commands doesn't each pay the ~ping round-trip. A truly dead
35
+ // worker closes its socket, flipping hasActiveExtension() false above before
36
+ // this matters — the ping only guards the narrow open-but-frozen window.
37
+ if (pingCacheMs > 0 && Date.now() - lastPingOkAt < pingCacheMs)
38
+ return ext;
39
+ if (await ext.ping(pingMs)) {
40
+ lastPingOkAt = Date.now();
41
+ return ext;
42
+ }
43
+ return null;
32
44
  };
33
45
  return async () => {
34
46
  if (deps.prefer === 'cdp') {
@@ -195,13 +195,15 @@
195
195
  if (isAboutBlank(url) && NAVIGATION.has(method)) return { ok: true };
196
196
  if (!isDomainAllowed(url, policy)) {
197
197
  const host = hostOf(url) || url;
198
- return {
199
- ok: false,
200
- reason: `"${method}" denied: ${host} is not in the domain allowlist. Add it to allowDomains, or pass --unsafe-all-domains.`
201
- };
198
+ return { ok: false, reason: blockedDomainMessage(method, host, policy) };
202
199
  }
203
200
  return { ok: true };
204
201
  }
202
+ function blockedDomainMessage(method, host, policy) {
203
+ const allowed = policy.allowDomains.filter((d) => d !== "*");
204
+ const allowedLine = allowed.length ? `Currently allowed: ${allowed.join(", ")}.` : `Right now no sites are allowed.`;
205
+ return `Blocked: "${method}" can't run on ${host} because it isn't on this browser tool's allowed-sites list. This is a safety limit (the tool drives your real, logged-in browser, so it only touches sites you've approved) \u2014 not an error. ${allowedLine} To allow ${host}, add it to the chrome-mcp settings as: --allow-domain "${host}" (or "*.${host}" to include subdomains), then restart/reconnect. To allow every site (less safe), use --unsafe-all-domains.`;
206
+ }
205
207
 
206
208
  // shared/download.ts
207
209
  var MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
@@ -490,13 +492,20 @@
490
492
  return resolveSelector(cmd);
491
493
  }
492
494
  async function waitForSelector(tabId, selector, timeoutMs = 5e3) {
493
- const start = Date.now();
494
- for (; ; ) {
495
- const present = await execInTab(tabId, (s) => !!document.querySelector(s), [selector]);
496
- if (present) return true;
497
- if (Date.now() - start > timeoutMs) return false;
498
- await delay(120);
499
- }
495
+ const found = await execInTab(
496
+ tabId,
497
+ (s, timeout, interval) => new Promise((resolve) => {
498
+ const deadline = Date.now() + timeout;
499
+ const tick = () => {
500
+ if (document.querySelector(s)) return resolve(true);
501
+ if (Date.now() > deadline) return resolve(false);
502
+ setTimeout(tick, interval);
503
+ };
504
+ tick();
505
+ }),
506
+ [selector, timeoutMs, 120]
507
+ );
508
+ return found === true;
500
509
  }
501
510
  async function withDebugger(tabId, fn) {
502
511
  return locks.run(`dbg:${tabId}`, async () => {
@@ -986,26 +995,33 @@
986
995
  return result ?? { ok: false, error: "no result" };
987
996
  }
988
997
  // -- wait_for (poll the isolated world) --
998
+ // One injection that polls IN-PAGE until the condition holds or the deadline
999
+ // passes, rather than one executeScript round-trip per tick.
989
1000
  case "wait_for": {
990
1001
  const id = await targetTab(cmd);
991
1002
  const timeout = typeof cmd.params.timeoutMs === "number" ? cmd.params.timeoutMs : 3e4;
992
1003
  const start = Date.now();
993
- for (; ; ) {
994
- const matched = await execInTab(
995
- id,
996
- (sel, text, gone) => {
1004
+ const matched = await execInTab(
1005
+ id,
1006
+ (sel, text, gone, timeoutMs, interval) => new Promise((resolve) => {
1007
+ const deadline = Date.now() + timeoutMs;
1008
+ const hit = () => {
997
1009
  let present;
998
1010
  if (sel) present = !!document.querySelector(sel);
999
1011
  else if (text) present = (document.body?.innerText ?? "").includes(text);
1000
1012
  else present = true;
1001
1013
  return gone ? !present : present;
1002
- },
1003
- [cmd.params.selector ?? null, cmd.params.textContains ?? null, cmd.params.gone === true]
1004
- );
1005
- if (matched) return { matched: true, waitedMs: Date.now() - start };
1006
- if (Date.now() - start > timeout) return { matched: false, waitedMs: Date.now() - start };
1007
- await delay(150);
1008
- }
1014
+ };
1015
+ const tick = () => {
1016
+ if (hit()) return resolve(true);
1017
+ if (Date.now() > deadline) return resolve(false);
1018
+ setTimeout(tick, interval);
1019
+ };
1020
+ tick();
1021
+ }),
1022
+ [cmd.params.selector ?? null, cmd.params.textContains ?? null, cmd.params.gone === true, timeout, 150]
1023
+ );
1024
+ return { matched: matched === true, waitedMs: Date.now() - start };
1009
1025
  }
1010
1026
  // -- download (user's Downloads dir) --
1011
1027
  case "download_file": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehmoodqureshi/chrome-mcp",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Drive a real Chrome browser over MCP. A stdio MCP server (CLI) plus an MV3 extension, behind one pluggable Executor (extension via chrome.scripting, or a Playwright CDP fallback).",
5
5
  "author": "Mehmood Ur Rehman Qureshi",
6
6
  "license": "MIT",