@mehmoodqureshi/chrome-mcp 0.9.10 → 0.9.12

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.
@@ -29,6 +29,14 @@ export interface FillFormHooks {
29
29
  code: FillFormError['code'];
30
30
  message: string;
31
31
  };
32
+ /**
33
+ * Epoch ms after which no further field is started — the moment the server
34
+ * stops waiting for this command. Without it a timed-out batch kept typing
35
+ * into the page after the caller had already been told it failed.
36
+ */
37
+ deadline?: number;
38
+ /** Clock, injectable for tests. */
39
+ now?: () => number;
32
40
  }
33
41
  type FillFormError = NonNullable<FillFormWireResult['error']>;
34
42
  /**
@@ -27,7 +27,16 @@ const policy_1 = require("./policy");
27
27
  */
28
28
  async function runFillFields(ops, hooks) {
29
29
  const res = { filled: 0 };
30
+ const now = hooks.now ?? Date.now;
30
31
  for (const op of ops) {
32
+ if (hooks.deadline !== undefined && now() >= hooks.deadline) {
33
+ res.error = {
34
+ selector: op.selector,
35
+ code: 'TIMEOUT',
36
+ message: `fill_form ran out of time after ${res.filled} of ${ops.length} fields; the rest were not written`,
37
+ };
38
+ return res;
39
+ }
31
40
  try {
32
41
  // Fails CLOSED, exactly as the router does: no policy means nothing runs.
33
42
  const policy = hooks.policy();
@@ -52,6 +52,19 @@ export interface FillFieldOp {
52
52
  /** A string is value-set (cleared first); a boolean toggles via a click. */
53
53
  value: string | boolean;
54
54
  }
55
+ /** Floor for a batched `fill_form` — the old flat budget, so small forms keep it. */
56
+ export declare const FILL_FORM_MIN_TIMEOUT_MS = 60000;
57
+ /** Per-field allowance: the extension's 5 s element wait plus headroom for the write. */
58
+ export declare const FILL_FORM_PER_FIELD_MS = 6000;
59
+ /** Ceiling, so a runaway field count can't hold the tab for longer than this. */
60
+ export declare const FILL_FORM_MAX_TIMEOUT_MS = 600000;
61
+ /**
62
+ * Wire timeout for a `fill_form` of `fields` fields. One flat 60 s used to cover
63
+ * the whole batch, so a long form whose fields each waited for their element
64
+ * timed out server-side while the extension was still typing. The budget now
65
+ * grows with the field count (10 s base + 6 s per field), clamped to 60 s..10 min.
66
+ */
67
+ export declare function fillFormTimeoutMs(fields: number): number;
55
68
  /**
56
69
  * `fill_form` result. Ops run in order and stop at the first failure, so
57
70
  * `filled` counts the fields that landed and `error` names the one that did not.
@@ -15,7 +15,8 @@
15
15
  * in one round-trip; capability-gated, see `WIRE_CAP_FILL_FORM`).
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.WIRE_METHODS = exports.WIRE_CAP_FILL_FORM = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
18
+ exports.WIRE_METHODS = exports.FILL_FORM_MAX_TIMEOUT_MS = exports.FILL_FORM_PER_FIELD_MS = exports.FILL_FORM_MIN_TIMEOUT_MS = exports.WIRE_CAP_FILL_FORM = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
19
+ exports.fillFormTimeoutMs = fillFormTimeoutMs;
19
20
  /** Bumped on any breaking change to the frames below. */
20
21
  exports.PROTOCOL_VERSION = 1;
21
22
  /**
@@ -48,6 +49,22 @@ exports.WIRE_CAP_TAB_URL = 'tab-url';
48
49
  * field over `type`/`click`, which every extension build understands.
49
50
  */
50
51
  exports.WIRE_CAP_FILL_FORM = 'fill-form';
52
+ /** Floor for a batched `fill_form` — the old flat budget, so small forms keep it. */
53
+ exports.FILL_FORM_MIN_TIMEOUT_MS = 60_000;
54
+ /** Per-field allowance: the extension's 5 s element wait plus headroom for the write. */
55
+ exports.FILL_FORM_PER_FIELD_MS = 6_000;
56
+ /** Ceiling, so a runaway field count can't hold the tab for longer than this. */
57
+ exports.FILL_FORM_MAX_TIMEOUT_MS = 600_000;
58
+ /**
59
+ * Wire timeout for a `fill_form` of `fields` fields. One flat 60 s used to cover
60
+ * the whole batch, so a long form whose fields each waited for their element
61
+ * timed out server-side while the extension was still typing. The budget now
62
+ * grows with the field count (10 s base + 6 s per field), clamped to 60 s..10 min.
63
+ */
64
+ function fillFormTimeoutMs(fields) {
65
+ const n = Number.isFinite(fields) && fields > 0 ? Math.floor(fields) : 0;
66
+ return Math.min(exports.FILL_FORM_MAX_TIMEOUT_MS, Math.max(exports.FILL_FORM_MIN_TIMEOUT_MS, 10_000 + n * exports.FILL_FORM_PER_FIELD_MS));
67
+ }
51
68
  /** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
52
69
  exports.WIRE_METHODS = [
53
70
  'tabs_list',
@@ -56,6 +56,18 @@ export declare const BUNDLED_PAIRING_FILE = "pairing.json";
56
56
  * still works without it.
57
57
  */
58
58
  export declare function writeBundledPairing(extDir: string, fields: WriteHandshakeFields): string | null;
59
+ /** The port an existing `<extDir>/pairing.json` points at, or null when absent/unreadable. */
60
+ export declare function readBundledPairingPort(extDir: string): number | null;
61
+ /** True when something is accepting TCP connections on 127.0.0.1:`port`. */
62
+ export declare function portIsListening(port: number, timeoutMs?: number): Promise<boolean>;
63
+ /**
64
+ * Should a hub on `port` leave `<extDir>/pairing.json` alone? Yes when the file
65
+ * already points the extension at a DIFFERENT port that is still live: another
66
+ * chrome-mcp (e.g. one started with its own CHROME_MCP_DATA / --ws-port) owns
67
+ * that pairing, and overwriting it would silently steal the user's extension.
68
+ * A file naming our own port, a dead port, or no file at all is fair game.
69
+ */
70
+ export declare function bundledPairingHeldByOther(extDir: string, port: number): Promise<boolean>;
59
71
  /**
60
72
  * Atomically write the handshake at 0600 and verify the mode. Throws (fail
61
73
  * closed) if the file ends up group/other-readable — the token is the entire
@@ -22,6 +22,9 @@ exports.writePersistedToken = writePersistedToken;
22
22
  exports.resolveToken = resolveToken;
23
23
  exports.tokensMatch = tokensMatch;
24
24
  exports.writeBundledPairing = writeBundledPairing;
25
+ exports.readBundledPairingPort = readBundledPairingPort;
26
+ exports.portIsListening = portIsListening;
27
+ exports.bundledPairingHeldByOther = bundledPairingHeldByOther;
25
28
  exports.writeHandshake = writeHandshake;
26
29
  exports.readHandshake = readHandshake;
27
30
  exports.removeHandshake = removeHandshake;
@@ -29,6 +32,7 @@ exports.redactToken = redactToken;
29
32
  const node_fs_1 = require("node:fs");
30
33
  const node_path_1 = require("node:path");
31
34
  const node_crypto_1 = require("node:crypto");
35
+ const node_net_1 = require("node:net");
32
36
  const protocol_1 = require("../../shared/protocol");
33
37
  const datadir_1 = require("./datadir");
34
38
  /** A fresh 256-bit token, base64url. Generated once per server boot. */
@@ -146,6 +150,42 @@ function writeBundledPairing(extDir, fields) {
146
150
  return null;
147
151
  }
148
152
  }
153
+ /** The port an existing `<extDir>/pairing.json` points at, or null when absent/unreadable. */
154
+ function readBundledPairingPort(extDir) {
155
+ try {
156
+ const raw = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(extDir, exports.BUNDLED_PAIRING_FILE), 'utf8'));
157
+ return typeof raw.port === 'number' && Number.isInteger(raw.port) && raw.port > 0 ? raw.port : null;
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ /** True when something is accepting TCP connections on 127.0.0.1:`port`. */
164
+ function portIsListening(port, timeoutMs = 500) {
165
+ return new Promise((resolve) => {
166
+ const sock = (0, node_net_1.connect)({ host: '127.0.0.1', port });
167
+ const done = (live) => {
168
+ sock.destroy();
169
+ resolve(live);
170
+ };
171
+ sock.setTimeout(timeoutMs, () => done(false));
172
+ sock.once('connect', () => done(true));
173
+ sock.once('error', () => done(false));
174
+ });
175
+ }
176
+ /**
177
+ * Should a hub on `port` leave `<extDir>/pairing.json` alone? Yes when the file
178
+ * already points the extension at a DIFFERENT port that is still live: another
179
+ * chrome-mcp (e.g. one started with its own CHROME_MCP_DATA / --ws-port) owns
180
+ * that pairing, and overwriting it would silently steal the user's extension.
181
+ * A file naming our own port, a dead port, or no file at all is fair game.
182
+ */
183
+ async function bundledPairingHeldByOther(extDir, port) {
184
+ const current = readBundledPairingPort(extDir);
185
+ if (current === null || current === port)
186
+ return false;
187
+ return portIsListening(current);
188
+ }
149
189
  /**
150
190
  * Atomically write the handshake at 0600 and verify the mode. Throws (fail
151
191
  * closed) if the file ends up group/other-readable — the token is the entire
package/dist/src/cli.js CHANGED
@@ -223,12 +223,22 @@ async function main() {
223
223
  },
224
224
  });
225
225
  const publishPairing = (port, pairToken) => {
226
+ // Synchronous so a handshake that can't be made private still fails closed.
226
227
  const path = (0, auth_1.writeHandshake)(dataDir, { port, token: pairToken });
227
228
  (0, server_2.logErr)(`pairing handshake written to ${path} (mode 0600; token not logged)`);
229
+ publishBundledPairing(port, pairToken).catch((err) => (0, server_2.logDebug)(`auto-pairing file not written: ${err instanceof Error ? err.message : String(err)}`));
230
+ };
231
+ const publishBundledPairing = async (port, pairToken) => {
228
232
  // Drop the same port + token into the bundled extension folder so a Load
229
233
  // unpacked from there pairs itself. Best-effort: a read-only install just
230
- // falls back to the Options-page paste.
234
+ // falls back to the Options-page paste. Never take over a pairing file that
235
+ // a different, still-running chrome-mcp published (a second server with its
236
+ // own data dir or port would otherwise re-point the user's extension).
231
237
  const extDir = installExtension();
238
+ if (await (0, auth_1.bundledPairingHeldByOther)(extDir, port)) {
239
+ (0, server_2.logErr)(`auto-pairing file at ${extDir} belongs to another running chrome-mcp — left as is; pair this server from the extension Options page if you want it`);
240
+ return;
241
+ }
232
242
  const bundled = (0, auth_1.writeBundledPairing)(extDir, { port, token: pairToken });
233
243
  if (bundled) {
234
244
  (0, server_2.logErr)(`auto-pairing file written to ${bundled} — Load unpacked from ${extDir} needs no token paste`);
@@ -238,8 +248,10 @@ async function main() {
238
248
  }
239
249
  // Anyone who loaded the extension straight from the package folder (0.8.0
240
250
  // docs) keeps pairing too.
241
- if (extDir !== (0, extension_install_1.bundledExtensionDir)())
242
- (0, auth_1.writeBundledPairing)((0, extension_install_1.bundledExtensionDir)(), { port, token: pairToken });
251
+ const pkgDir = (0, extension_install_1.bundledExtensionDir)();
252
+ if (extDir !== pkgDir && !(await (0, auth_1.bundledPairingHeldByOther)(pkgDir, port))) {
253
+ (0, auth_1.writeBundledPairing)(pkgDir, { port, token: pairToken });
254
+ }
243
255
  };
244
256
  const port = await bridge.start();
245
257
  (0, tools_1.setProfileBridge)(bridge);
@@ -149,7 +149,7 @@ class ExtensionExecutor {
149
149
  // An extension that predates the op never advertised it: let the caller go field by field.
150
150
  if (!this.bridge.hasCap(this.activeProfile(), protocol_1.WIRE_CAP_FILL_FORM))
151
151
  return null;
152
- const res = (await this.send('fill_form', { ops: fields, ...frameParams(opts) }, { tabId: opts?.tabId }));
152
+ const res = (await this.send('fill_form', { ops: fields, ...frameParams(opts) }, { tabId: opts?.tabId, timeoutMs: (0, protocol_1.fillFormTimeoutMs)(fields.length) }));
153
153
  if (res.error) {
154
154
  // Say how far the batch got: the fields before this one DID land, and a
155
155
  // blind retry of the whole form would write them a second time.
@@ -242,7 +242,16 @@
242
242
  // shared/fill-form.ts
243
243
  async function runFillFields(ops, hooks) {
244
244
  const res = { filled: 0 };
245
+ const now = hooks.now ?? Date.now;
245
246
  for (const op of ops) {
247
+ if (hooks.deadline !== void 0 && now() >= hooks.deadline) {
248
+ res.error = {
249
+ selector: op.selector,
250
+ code: "TIMEOUT",
251
+ message: `fill_form ran out of time after ${res.filled} of ${ops.length} fields; the rest were not written`
252
+ };
253
+ return res;
254
+ }
246
255
  try {
247
256
  const policy = hooks.policy();
248
257
  if (!policy) throw new PolicyStop("no policy is in force; refusing to fill");
@@ -1527,7 +1536,9 @@
1527
1536
  selector: String(f.selector ?? ""),
1528
1537
  value: typeof f.value === "boolean" ? f.value : String(f.value ?? "")
1529
1538
  }));
1539
+ const deadline = typeof cmd.timeoutMs === "number" && cmd.timeoutMs > 0 ? Date.now() + cmd.timeoutMs - 1e3 : void 0;
1530
1540
  return runFillFields(ops, {
1541
+ deadline,
1531
1542
  currentUrl: () => observedTabUrl(cmd, id),
1532
1543
  policy: () => this.getPolicy(),
1533
1544
  // Frame ids are re-probed per field too: an iframe that navigates
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "MCP Extension for Chrome",
4
- "version": "0.9.8",
4
+ "version": "0.9.12",
5
5
  "description": "Lets a local chrome-mcp server drive this browser. Pair it with the server's handshake token.",
6
6
  "homepage_url": "https://chrome-mcp-omega.vercel.app",
7
7
  "minimum_chrome_version": "116",
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@mehmoodqureshi/chrome-mcp",
3
- "version": "0.9.10",
3
+ "mcpName": "io.github.Mehmoodqureshi/chrome-mcp",
4
+ "version": "0.9.12",
4
5
  "description": "Drive your real Chrome browser over MCP — real logins, real cookies. A stdio MCP server (CLI) plus an MV3 extension, driving Chrome via chrome.scripting/chrome.tabs. Multi-tab batch automation, accessibility snapshots, deny-all security by default.",
5
6
  "author": "Mehmood Ur Rehman Qureshi",
6
7
  "license": "MIT",