@mehmoodqureshi/chrome-mcp 0.5.0 → 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.
- package/dist/shared/policy.d.ts +7 -0
- package/dist/shared/policy.js +19 -5
- package/dist/src/bridge/server.d.ts +2 -0
- package/dist/src/bridge/server.js +65 -10
- package/dist/src/cli.js +14 -1
- package/dist/src/executor/select.d.ts +3 -0
- package/dist/src/executor/select.js +13 -1
- package/extension-dist/background.js +38 -22
- package/package.json +1 -1
package/dist/shared/policy.d.ts
CHANGED
|
@@ -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;
|
package/dist/shared/policy.js
CHANGED
|
@@ -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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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.
|
|
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",
|