@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.
- package/dist/shared/fill-form.d.ts +8 -0
- package/dist/shared/fill-form.js +9 -0
- package/dist/shared/protocol.d.ts +13 -0
- package/dist/shared/protocol.js +18 -1
- package/dist/src/bridge/auth.d.ts +12 -0
- package/dist/src/bridge/auth.js +40 -0
- package/dist/src/cli.js +15 -3
- package/dist/src/executor/extension-executor.js +1 -1
- package/extension-dist/background.js +11 -0
- package/extension-dist/manifest.json +1 -1
- package/package.json +2 -1
|
@@ -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
|
/**
|
package/dist/shared/fill-form.js
CHANGED
|
@@ -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.
|
package/dist/shared/protocol.js
CHANGED
|
@@ -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
|
package/dist/src/bridge/auth.js
CHANGED
|
@@ -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
|
-
|
|
242
|
-
|
|
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.
|
|
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
|
-
"
|
|
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",
|