@mehmoodqureshi/chrome-mcp 0.3.0 → 0.4.0
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/README.md +11 -4
- package/dist/shared/protocol.d.ts +2 -2
- package/dist/shared/protocol.js +1 -0
- package/dist/shared/snapshot.js +64 -2
- package/dist/src/bridge/server.js +17 -1
- package/dist/src/cli.js +24 -3
- package/dist/src/config.js +8 -0
- package/dist/src/executor/cdp-executor.d.ts +10 -0
- package/dist/src/executor/cdp-executor.js +230 -129
- package/dist/src/executor/extension-executor.d.ts +3 -0
- package/dist/src/executor/extension-executor.js +6 -1
- package/dist/src/executor/stub-executor.d.ts +1 -0
- package/dist/src/executor/stub-executor.js +3 -0
- package/dist/src/executor/types.d.ts +22 -1
- package/dist/src/executor/types.js +29 -1
- package/dist/src/mcp/server.d.ts +2 -2
- package/dist/src/mcp/server.js +7 -5
- package/dist/src/mcp/tools.d.ts +2 -0
- package/dist/src/mcp/tools.js +58 -1
- package/dist/src/mcp/validators.d.ts +6 -0
- package/dist/src/mcp/validators.js +20 -2
- package/dist/src/security/policy.d.ts +4 -0
- package/dist/src/security/policy.js +13 -1
- package/extension-dist/background.js +77 -3
- package/extension-dist/manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,7 +32,13 @@ Distributed as an `npx` CLI (the MCP server) plus a load-unpacked extension.
|
|
|
32
32
|
|
|
33
33
|
By default everything is **deny-all** (no domains, no eval, no mutations). Grant
|
|
34
34
|
exactly what you need with `--allow-domain <glob>` (repeatable), `--enable-mutations`,
|
|
35
|
-
`--enable-downloads`, `--unsafe-enable-eval`, or `--unsafe-all-domains`.
|
|
35
|
+
`--enable-downloads`, `--enable-uploads`, `--unsafe-enable-eval`, or `--unsafe-all-domains`.
|
|
36
|
+
|
|
37
|
+
> `--enable-uploads` permits `upload_file` (setting local file(s) on a page's file
|
|
38
|
+
> `<input>`). It is **off by default** because sending local files to a page is an
|
|
39
|
+
> exfiltration risk; it is also gated by the destination-domain allowlist. Pair it
|
|
40
|
+
> with `--uploads-dir <path>` to restrict uploads to files inside that directory
|
|
41
|
+
> (`..` traversal is blocked) — strongly recommended for unattended use.
|
|
36
42
|
|
|
37
43
|
**Drive only your real Chrome (recommended for the extension).** Add
|
|
38
44
|
`--no-cdp-fallback` so the server never launches a separate Chromium, and
|
|
@@ -71,8 +77,9 @@ The tools cover tabs, navigation, interaction (`click`/`type`/`press`/`hover`/
|
|
|
71
77
|
`scroll`/`select_option`), reads (`get_text`/`get_html`/`screenshot`/`eval`/`wait_for`),
|
|
72
78
|
an accessibility `snapshot` (interactive elements with stable `ref`s the model can
|
|
73
79
|
target instead of guessing CSS selectors), session access (`get_cookies`/`storage`),
|
|
74
|
-
helpers (`extract_links`/`read_as_markdown`/`fill_form`/`download_file`),
|
|
75
|
-
`chrome_status`.
|
|
80
|
+
helpers (`extract_links`/`read_as_markdown`/`fill_form`/`download_file`/`upload_file`),
|
|
81
|
+
and `chrome_status`. `upload_file` sets local file(s) on a file `<input>` without the
|
|
82
|
+
OS dialog (requires `--enable-uploads`).
|
|
76
83
|
|
|
77
84
|
`click`/`type` accept `trusted: true` for real OS-level input (works on
|
|
78
85
|
React/Vue controlled inputs); interactions auto-wait for the target to appear.
|
|
@@ -91,7 +98,7 @@ token (`--persist-token`).
|
|
|
91
98
|
(default-deny policy + capability gates), `src/config.ts` (CLI/env/policy
|
|
92
99
|
resolution), build + test harness.
|
|
93
100
|
- [x] **Phase 1 — MCP server + StubExecutor:** `mcp/server.ts` (clean-stdout
|
|
94
|
-
stdio), `mcp/tools.ts` (
|
|
101
|
+
stdio), `mcp/tools.ts` (28-tool catalog + never-throw dispatch +
|
|
95
102
|
drift-check), validators/envelopes/helpers, `ExecutorManager` +
|
|
96
103
|
`StubExecutor`, `cli.ts`. Point an MCP host at `node dist/src/cli.js` today.
|
|
97
104
|
- [x] **Phase 2 — WebSocket bridge + auth:** `bridge/server.ts` (loopback WS,
|
|
@@ -32,10 +32,10 @@ export declare const CLOSE_SUPERSEDED: 4000;
|
|
|
32
32
|
* `download_file` (privileged, executor-owned) and `ping_probe` (a short-deadline
|
|
33
33
|
* responsiveness check used to detect a dead-but-not-yet-reconnected worker).
|
|
34
34
|
*/
|
|
35
|
-
export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'snapshot' | 'select_option' | 'get_cookies' | 'storage' | 'eval' | 'wait_for' | 'download_file' | 'ping_probe';
|
|
35
|
+
export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'snapshot' | 'select_option' | 'get_cookies' | 'storage' | 'eval' | 'wait_for' | 'download_file' | 'upload_file' | 'ping_probe';
|
|
36
36
|
/** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
|
|
37
37
|
export declare const WIRE_METHODS: readonly WireMethod[];
|
|
38
|
-
export type ExecutorErrorCode = 'NO_TARGET' | 'TARGET_GONE' | 'DETACHED' | 'DEVTOOLS_OPEN' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_THREW' | 'TIMEOUT' | 'BAD_ARGS' | 'CDP_ERROR' | 'POLICY_DENIED' | 'DOWNLOAD_FAILED' | 'UNKNOWN_METHOD';
|
|
38
|
+
export type ExecutorErrorCode = 'NO_TARGET' | 'TARGET_GONE' | 'DETACHED' | 'DEVTOOLS_OPEN' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_THREW' | 'TIMEOUT' | 'BAD_ARGS' | 'CDP_ERROR' | 'POLICY_DENIED' | 'DOWNLOAD_FAILED' | 'UPLOAD_FAILED' | 'UNKNOWN_METHOD';
|
|
39
39
|
export interface BaseFrame {
|
|
40
40
|
type: string;
|
|
41
41
|
v: ProtocolVersion;
|
package/dist/shared/protocol.js
CHANGED
package/dist/shared/snapshot.js
CHANGED
|
@@ -20,7 +20,30 @@ function collectSnapshot(interactiveOnly = true, max = 200) {
|
|
|
20
20
|
if (r.width === 0 && r.height === 0)
|
|
21
21
|
return false;
|
|
22
22
|
const s = window.getComputedStyle(el);
|
|
23
|
-
|
|
23
|
+
if (s.visibility === 'hidden' || s.display === 'none')
|
|
24
|
+
return false;
|
|
25
|
+
// Prefer the native check (accounts for ancestors, content-visibility, etc.).
|
|
26
|
+
const cv = el.checkVisibility;
|
|
27
|
+
if (typeof cv === 'function') {
|
|
28
|
+
try {
|
|
29
|
+
return cv.call(el, { checkOpacity: false, checkVisibilityCSS: true });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* fall through to manual ancestor walk */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Fallback: walk ancestors for display:none / visibility:hidden. An element
|
|
36
|
+
// with no offsetParent (and not position:fixed) is detached/hidden.
|
|
37
|
+
let p = el.parentElement;
|
|
38
|
+
while (p) {
|
|
39
|
+
const ps = window.getComputedStyle(p);
|
|
40
|
+
if (ps.display === 'none' || ps.visibility === 'hidden')
|
|
41
|
+
return false;
|
|
42
|
+
p = p.parentElement;
|
|
43
|
+
}
|
|
44
|
+
if (el.offsetParent === null && s.position !== 'fixed')
|
|
45
|
+
return false;
|
|
46
|
+
return true;
|
|
24
47
|
};
|
|
25
48
|
const accName = (el) => {
|
|
26
49
|
const aria = el.getAttribute('aria-label');
|
|
@@ -69,7 +92,46 @@ function collectSnapshot(interactiveOnly = true, max = 200) {
|
|
|
69
92
|
}
|
|
70
93
|
return tag;
|
|
71
94
|
};
|
|
72
|
-
|
|
95
|
+
// Collect candidates across the light DOM *and* open shadow roots, descending
|
|
96
|
+
// recursively. Defensive against null/closed shadow roots and re-visits.
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
const candidates = [];
|
|
99
|
+
const collect = (root) => {
|
|
100
|
+
if (candidates.length >= max)
|
|
101
|
+
return;
|
|
102
|
+
let matched;
|
|
103
|
+
try {
|
|
104
|
+
matched = Array.from(root.querySelectorAll(sel));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
matched = [];
|
|
108
|
+
}
|
|
109
|
+
for (const el of matched) {
|
|
110
|
+
if (candidates.length >= max)
|
|
111
|
+
break;
|
|
112
|
+
if (seen.has(el))
|
|
113
|
+
continue;
|
|
114
|
+
seen.add(el);
|
|
115
|
+
candidates.push(el);
|
|
116
|
+
}
|
|
117
|
+
// Descend into any open shadow roots hosted under this root.
|
|
118
|
+
let hosts;
|
|
119
|
+
try {
|
|
120
|
+
hosts = Array.from(root.querySelectorAll('*'));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
hosts = [];
|
|
124
|
+
}
|
|
125
|
+
for (const host of hosts) {
|
|
126
|
+
if (candidates.length >= max)
|
|
127
|
+
break;
|
|
128
|
+
const sr = host.shadowRoot;
|
|
129
|
+
if (sr)
|
|
130
|
+
collect(sr);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
collect(document);
|
|
134
|
+
const els = candidates.filter(visible);
|
|
73
135
|
const nodes = [];
|
|
74
136
|
let n = 0;
|
|
75
137
|
for (const el of els) {
|
|
@@ -21,6 +21,8 @@ const connection_1 = require("./connection");
|
|
|
21
21
|
const auth_1 = require("./auth");
|
|
22
22
|
const HELLO_TIMEOUT_MS = 5_000;
|
|
23
23
|
const DEFAULT_HEARTBEAT_MS = 15_000;
|
|
24
|
+
/** Max pre-auth frames a socket may send before a valid hello (anti-idle-hold). */
|
|
25
|
+
const MAX_PREAUTH_FRAMES = 10;
|
|
24
26
|
class BridgeServer {
|
|
25
27
|
opts;
|
|
26
28
|
wss = null;
|
|
@@ -78,6 +80,9 @@ class BridgeServer {
|
|
|
78
80
|
// -- internals ----------------------------------------------------------
|
|
79
81
|
handleConnection(ws) {
|
|
80
82
|
let authed = false;
|
|
83
|
+
// Cap pre-auth frames so a peer can't hold a socket idle by streaming
|
|
84
|
+
// non-hello noise until HELLO_TIMEOUT_MS.
|
|
85
|
+
let preAuthFrames = 0;
|
|
81
86
|
const helloTimer = setTimeout(() => {
|
|
82
87
|
if (authed)
|
|
83
88
|
return;
|
|
@@ -87,6 +92,11 @@ class BridgeServer {
|
|
|
87
92
|
const onMessage = (raw) => {
|
|
88
93
|
if (authed)
|
|
89
94
|
return;
|
|
95
|
+
if (++preAuthFrames > MAX_PREAUTH_FRAMES) {
|
|
96
|
+
clearTimeout(helloTimer);
|
|
97
|
+
this.reject(ws, 'bad_token');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
90
100
|
let frame;
|
|
91
101
|
try {
|
|
92
102
|
frame = JSON.parse(raw.toString());
|
|
@@ -136,7 +146,13 @@ class BridgeServer {
|
|
|
136
146
|
const differentId = prev.extId !== ext.id;
|
|
137
147
|
this.log(`extension "${ext.id}" superseded active connection "${prev.extId}"` +
|
|
138
148
|
(differentId ? ' (DIFFERENT id — possible hijack; surfaced to status)' : ''));
|
|
139
|
-
|
|
149
|
+
try {
|
|
150
|
+
this.opts.onDisplacement?.({ oldExtId: prev.extId, newExtId: ext.id, differentId });
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// A throwing displacement callback must not take down the bridge.
|
|
154
|
+
this.log('onDisplacement callback threw; ignored');
|
|
155
|
+
}
|
|
140
156
|
prev.close(protocol_1.CLOSE_SUPERSEDED, 'superseded');
|
|
141
157
|
}
|
|
142
158
|
const conn = new connection_1.ExtensionConnection({
|
package/dist/src/cli.js
CHANGED
|
@@ -19,6 +19,27 @@ const server_1 = require("./bridge/server");
|
|
|
19
19
|
const datadir_1 = require("./bridge/datadir");
|
|
20
20
|
const auth_1 = require("./bridge/auth");
|
|
21
21
|
const server_2 = require("./mcp/server");
|
|
22
|
+
/** Hard deadline for clean shutdown before we force-exit (a stuck socket must not hang us). */
|
|
23
|
+
const SHUTDOWN_DEADLINE_MS = 3000;
|
|
24
|
+
/**
|
|
25
|
+
* Race a best-effort shutdown against a hard deadline, then exit. The timer is
|
|
26
|
+
* unref'd so it never itself keeps the process alive; if it fires first we log a
|
|
27
|
+
* brief note that clean shutdown did not complete in time.
|
|
28
|
+
*/
|
|
29
|
+
function exitWithDeadline(work) {
|
|
30
|
+
let timer;
|
|
31
|
+
const deadline = new Promise((resolve) => {
|
|
32
|
+
timer = setTimeout(() => resolve('timeout'), SHUTDOWN_DEADLINE_MS);
|
|
33
|
+
timer.unref();
|
|
34
|
+
});
|
|
35
|
+
void Promise.race([work.then(() => 'clean'), deadline]).then((outcome) => {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
if (outcome === 'timeout') {
|
|
38
|
+
(0, server_2.logErr)(`shutdown deadline (${SHUTDOWN_DEADLINE_MS}ms) hit before clean shutdown; forcing exit.`);
|
|
39
|
+
}
|
|
40
|
+
process.exit(0);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
22
43
|
function version() {
|
|
23
44
|
try {
|
|
24
45
|
const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, '..', '..', 'package.json'), 'utf8'));
|
|
@@ -65,7 +86,7 @@ async function main() {
|
|
|
65
86
|
(0, server_2.logErr)('pairing mode — bridge is up; open the extension and pair, then Ctrl-C.');
|
|
66
87
|
process.on('SIGINT', () => {
|
|
67
88
|
cleanup();
|
|
68
|
-
|
|
89
|
+
exitWithDeadline(bridge.stop());
|
|
69
90
|
});
|
|
70
91
|
return;
|
|
71
92
|
}
|
|
@@ -86,11 +107,11 @@ async function main() {
|
|
|
86
107
|
(0, server_2.logErr)(`backend: extension-if-paired else ${cfg.cdpFallback ? 'CDP fallback' : 'none'} (prefer: ${cfg.prefer})`);
|
|
87
108
|
const shutdown = () => {
|
|
88
109
|
cleanup();
|
|
89
|
-
|
|
110
|
+
exitWithDeadline(Promise.allSettled([(0, server_2.stopMcpServer)(), bridge.stop()]));
|
|
90
111
|
};
|
|
91
112
|
process.on('SIGINT', shutdown);
|
|
92
113
|
process.on('SIGTERM', shutdown);
|
|
93
|
-
await (0, server_2.startMcpServer)();
|
|
114
|
+
await (0, server_2.startMcpServer)(version());
|
|
94
115
|
}
|
|
95
116
|
main().catch((err) => {
|
|
96
117
|
(0, server_2.logErr)(`fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
package/dist/src/config.js
CHANGED
|
@@ -84,6 +84,12 @@ function parseArgs(argv) {
|
|
|
84
84
|
case '--enable-downloads':
|
|
85
85
|
policyFlags.allowDownloads = true;
|
|
86
86
|
break;
|
|
87
|
+
case '--enable-uploads':
|
|
88
|
+
policyFlags.allowUploads = true;
|
|
89
|
+
break;
|
|
90
|
+
case '--uploads-dir':
|
|
91
|
+
policyFlags.uploadsDir = (0, node_path_1.resolve)(requireValue(argv[++i], '--uploads-dir'));
|
|
92
|
+
break;
|
|
87
93
|
case '--allow-all-tabs':
|
|
88
94
|
policyFlags.allowAllTabs = true;
|
|
89
95
|
break;
|
|
@@ -194,6 +200,8 @@ Security (default: deny-all safe mode):
|
|
|
194
200
|
--enable-mutations Enable click/type/navigate/… (off by default)
|
|
195
201
|
--unsafe-enable-eval Enable the eval primitive (off by default)
|
|
196
202
|
--enable-downloads Enable download_file (off by default)
|
|
203
|
+
--enable-uploads Enable upload_file — sends local files to a page (off by default)
|
|
204
|
+
--uploads-dir <path> Restrict upload_file to files inside <path> (recommended with --enable-uploads)
|
|
197
205
|
--allow-all-tabs Relax tab list/select to all tabs
|
|
198
206
|
|
|
199
207
|
Misc:
|
|
@@ -45,6 +45,13 @@ export declare class CdpExecutor implements Executor {
|
|
|
45
45
|
private contentPages;
|
|
46
46
|
private resolveTab;
|
|
47
47
|
private locator;
|
|
48
|
+
/**
|
|
49
|
+
* Run a live-page operation, converting an opaque "target closed"/crashed
|
|
50
|
+
* error from Playwright into a clean DETACHED. On such a failure we also drop
|
|
51
|
+
* the cached context/tabs so the next call relaunches or reconnects cleanly.
|
|
52
|
+
* Any other error is rethrown unchanged.
|
|
53
|
+
*/
|
|
54
|
+
private guard;
|
|
48
55
|
tabsList(): Promise<TabInfo[]>;
|
|
49
56
|
tabSelect(tabId: TabId): Promise<TabInfo>;
|
|
50
57
|
tabNew(url?: string): Promise<TabInfo>;
|
|
@@ -151,4 +158,7 @@ export declare class CdpExecutor implements Executor {
|
|
|
151
158
|
tabId?: TabId;
|
|
152
159
|
suggestedName?: string;
|
|
153
160
|
}): Promise<DownloadResult>;
|
|
161
|
+
uploadFile(t: Target, files: string[], opts?: {
|
|
162
|
+
tabId?: TabId;
|
|
163
|
+
}): Promise<ActionOk>;
|
|
154
164
|
}
|