@mehmoodqureshi/chrome-mcp 0.6.1 → 0.6.3
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.js +25 -4
- package/dist/src/config.js +16 -7
- package/dist/src/mcp/helpers.d.ts +6 -0
- package/dist/src/mcp/helpers.js +40 -11
- package/dist/src/mcp/tools.js +3 -1
- package/extension-dist/background.js +15 -10
- package/package.json +1 -1
package/dist/shared/policy.js
CHANGED
|
@@ -80,17 +80,38 @@ function hostOf(url) {
|
|
|
80
80
|
function isAboutBlank(url) {
|
|
81
81
|
return url === 'about:blank' || url === '' || url.startsWith('about:');
|
|
82
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Reduce an allowlist entry to the bare host it constrains. Users routinely paste
|
|
85
|
+
* a full URL ("https://example.com/app") or a "host:port/path" instead of a bare
|
|
86
|
+
* host; those forms would never equal a hostname and so silently match nothing.
|
|
87
|
+
* We strip the scheme, userinfo, port, and path/query/fragment, preserving a
|
|
88
|
+
* leading "*." wildcard and the two catch-all forms. Returns '' for a pattern
|
|
89
|
+
* that carries no host (which then matches nothing).
|
|
90
|
+
*/
|
|
91
|
+
function normalizeDomainPattern(pattern) {
|
|
92
|
+
let p = pattern.trim().toLowerCase();
|
|
93
|
+
if (p === '*' || p === '*://*/*')
|
|
94
|
+
return '*';
|
|
95
|
+
// Strip a leading scheme ("https://", "http://", any "scheme://").
|
|
96
|
+
p = p.replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
|
|
97
|
+
// Drop everything from the first path/query/fragment separator onward.
|
|
98
|
+
p = p.replace(/[/?#].*$/, '');
|
|
99
|
+
// Strip userinfo ("user:pass@") then a trailing port (":8080").
|
|
100
|
+
p = p.replace(/^[^@]*@/, '').replace(/:\d+$/, '');
|
|
101
|
+
return p;
|
|
102
|
+
}
|
|
83
103
|
/** Convert a single domain glob to a predicate. '*' matches everything;
|
|
84
|
-
* '*.example.com' matches example.com and any subdomain; otherwise exact host.
|
|
104
|
+
* '*.example.com' matches example.com and any subdomain; otherwise exact host.
|
|
105
|
+
* URL/port/path forms are normalized to their bare host first. */
|
|
85
106
|
function globMatches(host, pattern) {
|
|
86
|
-
const p = pattern
|
|
87
|
-
if (p === '*'
|
|
107
|
+
const p = normalizeDomainPattern(pattern);
|
|
108
|
+
if (p === '*')
|
|
88
109
|
return true;
|
|
89
110
|
if (p.startsWith('*.')) {
|
|
90
111
|
const base = p.slice(2);
|
|
91
112
|
return host === base || host.endsWith('.' + base);
|
|
92
113
|
}
|
|
93
|
-
return host === p;
|
|
114
|
+
return p !== '' && host === p;
|
|
94
115
|
}
|
|
95
116
|
function isDomainAllowed(url, policy) {
|
|
96
117
|
const host = hostOf(url);
|
package/dist/src/config.js
CHANGED
|
@@ -162,6 +162,14 @@ function parseArgs(argv) {
|
|
|
162
162
|
throw new Error(`unknown argument: ${arg}`);
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
|
+
// Fallback permanently removed — this build is EXTENSION-ONLY. It NEVER
|
|
166
|
+
// launches or attaches a Chromium of its own; it only ever drives the user's
|
|
167
|
+
// real Chrome through the paired extension. Any CDP flags (--cdp-fallback,
|
|
168
|
+
// --cdp-endpoint, --prefer cdp) are still accepted for back-compat but are
|
|
169
|
+
// hard-overridden here so no separate/"fallback" browser can ever open.
|
|
170
|
+
cdpFallback = false;
|
|
171
|
+
cdpEndpoint = undefined;
|
|
172
|
+
prefer = 'extension';
|
|
165
173
|
// File first, then flags win.
|
|
166
174
|
const policy = (0, policy_1.resolvePolicy)({ ...policyFile, ...policyFlags });
|
|
167
175
|
// Uploads must be confined to a directory — refuse to start with uploads enabled
|
|
@@ -242,13 +250,14 @@ Connection:
|
|
|
242
250
|
CHROME_MCP_TOKEN env, if set, pins the token explicitly.
|
|
243
251
|
|
|
244
252
|
Backend:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
--
|
|
249
|
-
--cdp-
|
|
250
|
-
--
|
|
251
|
-
--
|
|
253
|
+
This build is EXTENSION-ONLY — it drives ONLY your real Chrome via the paired
|
|
254
|
+
extension and never launches or attaches a Chromium of its own. The CDP flags
|
|
255
|
+
below are accepted for back-compat but IGNORED (there is no fallback).
|
|
256
|
+
--cdp-fallback (ignored — fallback permanently removed)
|
|
257
|
+
--no-cdp-fallback (ignored — extension-only is always on)
|
|
258
|
+
--cdp-endpoint <url> (ignored — no CDP attach)
|
|
259
|
+
--prefer <which> (ignored — always "extension")
|
|
260
|
+
--headless (ignored — no CDP Chromium to run headless)
|
|
252
261
|
|
|
253
262
|
Security (default: deny-all safe mode):
|
|
254
263
|
--policy <file> Load a JSON policy file
|
|
@@ -13,10 +13,16 @@ export interface LinkOut {
|
|
|
13
13
|
/**
|
|
14
14
|
* Collect anchors from the page (or a subtree). Implemented as a single page
|
|
15
15
|
* eval so it is one round-trip; falls back to parsing getHtml if eval is denied.
|
|
16
|
+
*
|
|
17
|
+
* `dedupe` collapses anchors that share an href (nav/footer repetition is common
|
|
18
|
+
* noise when crawling); `limit` caps the number of links returned. Both are
|
|
19
|
+
* applied server-side after collection, so they work on either code path.
|
|
16
20
|
*/
|
|
17
21
|
export declare function extractLinks(ex: Executor, args: {
|
|
18
22
|
selector?: string;
|
|
19
23
|
sameOriginOnly?: boolean;
|
|
24
|
+
dedupe?: boolean;
|
|
25
|
+
limit?: number;
|
|
20
26
|
tabId?: string;
|
|
21
27
|
}): Promise<{
|
|
22
28
|
links: LinkOut[];
|
package/dist/src/mcp/helpers.js
CHANGED
|
@@ -13,6 +13,10 @@ const markdown_extract_1 = require("./markdown-extract");
|
|
|
13
13
|
/**
|
|
14
14
|
* Collect anchors from the page (or a subtree). Implemented as a single page
|
|
15
15
|
* eval so it is one round-trip; falls back to parsing getHtml if eval is denied.
|
|
16
|
+
*
|
|
17
|
+
* `dedupe` collapses anchors that share an href (nav/footer repetition is common
|
|
18
|
+
* noise when crawling); `limit` caps the number of links returned. Both are
|
|
19
|
+
* applied server-side after collection, so they work on either code path.
|
|
16
20
|
*/
|
|
17
21
|
async function extractLinks(ex, args) {
|
|
18
22
|
const root = args.selector ? JSON.stringify(args.selector) : 'null';
|
|
@@ -24,21 +28,46 @@ async function extractLinks(ex, args) {
|
|
|
24
28
|
href: a.href, text: (a.textContent || '').trim().slice(0, 200),
|
|
25
29
|
})).filter(l => l.href && (${args.sameOriginOnly ? 'l.href.startsWith(here)' : 'true'}));
|
|
26
30
|
})()`;
|
|
31
|
+
let links;
|
|
27
32
|
const res = await ex.eval(expr, { tabId: args.tabId });
|
|
28
33
|
if (res.ok && Array.isArray(res.value)) {
|
|
29
|
-
|
|
34
|
+
links = res.value;
|
|
30
35
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
else {
|
|
37
|
+
// Fallback: parse hrefs out of the HTML (e.g. when eval is policy-denied).
|
|
38
|
+
const { html } = await ex.getHtml(args.selector ? { selector: args.selector } : undefined, {
|
|
39
|
+
tabId: args.tabId,
|
|
40
|
+
});
|
|
41
|
+
links = [];
|
|
42
|
+
const re = /<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
|
43
|
+
let m;
|
|
44
|
+
while ((m = re.exec(html)) !== null) {
|
|
45
|
+
links.push({ href: m[1], text: m[2].replace(/<[^>]+>/g, '').trim().slice(0, 200) });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { links: refineLinks(links, args) };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Collapse links that share an href (keeping the first, but preferring a
|
|
52
|
+
* non-empty label) when `dedupe` is set, then cap to `limit`. Order is
|
|
53
|
+
* preserved so the first occurrence of each href wins.
|
|
54
|
+
*/
|
|
55
|
+
function refineLinks(links, opts) {
|
|
56
|
+
let out = links;
|
|
57
|
+
if (opts.dedupe) {
|
|
58
|
+
const byHref = new Map();
|
|
59
|
+
for (const l of links) {
|
|
60
|
+
const existing = byHref.get(l.href);
|
|
61
|
+
if (!existing)
|
|
62
|
+
byHref.set(l.href, { ...l });
|
|
63
|
+
else if (!existing.text && l.text)
|
|
64
|
+
existing.text = l.text;
|
|
65
|
+
}
|
|
66
|
+
out = [...byHref.values()];
|
|
40
67
|
}
|
|
41
|
-
|
|
68
|
+
if (opts.limit !== undefined && out.length > opts.limit)
|
|
69
|
+
out = out.slice(0, opts.limit);
|
|
70
|
+
return out;
|
|
42
71
|
}
|
|
43
72
|
/** Read a page (or subtree) as readable markdown. */
|
|
44
73
|
async function readAsMarkdown(ex, args) {
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -58,7 +58,7 @@ exports.TOOL_DEFINITIONS = [
|
|
|
58
58
|
{ name: 'storage', description: 'Read/write localStorage (or sessionStorage). op: get|set|remove|clear.', inputSchema: { op: zod_1.z.enum(['get', 'set', 'remove', 'clear']), key: zod_1.z.string().optional(), value: zod_1.z.string().optional(), session: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
59
59
|
{ name: 'eval', description: 'Evaluate JavaScript in the page (disabled in safe-mode).', inputSchema: { expression: zod_1.z.string(), awaitPromise: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
60
60
|
{ name: 'wait_for', description: 'Wait for a selector or text to appear/disappear.', inputSchema: { selector: zod_1.z.string().optional(), textContains: zod_1.z.string().optional(), gone: zod_1.z.boolean().optional(), timeoutMs: zod_1.z.number().optional(), tabId: tabIdField } },
|
|
61
|
-
{ name: 'extract_links', description: 'Extract anchors from the page or a subtree.', inputSchema: { selector: zod_1.z.string().optional(), sameOriginOnly: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
61
|
+
{ name: 'extract_links', description: 'Extract anchors from the page or a subtree. dedupe=true collapses links sharing an href (nav/footer noise); limit caps the count.', inputSchema: { selector: zod_1.z.string().optional(), sameOriginOnly: zod_1.z.boolean().optional(), dedupe: zod_1.z.boolean().optional(), limit: zod_1.z.number().optional(), tabId: tabIdField } },
|
|
62
62
|
{ name: 'read_as_markdown', description: 'Read the page (or subtree) as readable markdown.', inputSchema: { selector: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
63
63
|
{ name: 'fill_form', description: 'Fill multiple fields (keyed by selector) and optionally submit.', inputSchema: { fields: zod_1.z.record(zod_1.z.string(), zod_1.z.union([zod_1.z.string(), zod_1.z.boolean()])), submitSelector: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
64
64
|
{ name: 'download_file', description: 'Download a file by URL or from a link element.', inputSchema: { url: zod_1.z.string().optional(), ...TARGET_PROPS, suggestedName: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
@@ -276,6 +276,8 @@ exports.TOOL_HANDLERS = {
|
|
|
276
276
|
const res = await (0, helpers_1.extractLinks)(ctx.ex, {
|
|
277
277
|
selector: (0, validators_1.optionalString)(a, 'selector'),
|
|
278
278
|
sameOriginOnly: (0, validators_1.optionalBoolean)(a, 'sameOriginOnly'),
|
|
279
|
+
dedupe: (0, validators_1.optionalBoolean)(a, 'dedupe'),
|
|
280
|
+
limit: (0, validators_1.optionalNumber)(a, 'limit', { min: 1, max: 10_000 }),
|
|
279
281
|
tabId: tabId(a),
|
|
280
282
|
});
|
|
281
283
|
(0, workspace_1.saveResult)('extract_links', 'json', JSON.stringify(res, null, 2));
|
|
@@ -39,7 +39,6 @@
|
|
|
39
39
|
constructor(deps) {
|
|
40
40
|
this.deps = deps;
|
|
41
41
|
}
|
|
42
|
-
deps;
|
|
43
42
|
ws = null;
|
|
44
43
|
state = "idle";
|
|
45
44
|
isConnected() {
|
|
@@ -160,14 +159,22 @@
|
|
|
160
159
|
function isAboutBlank(url) {
|
|
161
160
|
return url === "about:blank" || url === "" || url.startsWith("about:");
|
|
162
161
|
}
|
|
162
|
+
function normalizeDomainPattern(pattern) {
|
|
163
|
+
let p = pattern.trim().toLowerCase();
|
|
164
|
+
if (p === "*" || p === "*://*/*") return "*";
|
|
165
|
+
p = p.replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
|
|
166
|
+
p = p.replace(/[/?#].*$/, "");
|
|
167
|
+
p = p.replace(/^[^@]*@/, "").replace(/:\d+$/, "");
|
|
168
|
+
return p;
|
|
169
|
+
}
|
|
163
170
|
function globMatches(host, pattern) {
|
|
164
|
-
const p = pattern
|
|
165
|
-
if (p === "*"
|
|
171
|
+
const p = normalizeDomainPattern(pattern);
|
|
172
|
+
if (p === "*") return true;
|
|
166
173
|
if (p.startsWith("*.")) {
|
|
167
174
|
const base = p.slice(2);
|
|
168
175
|
return host === base || host.endsWith("." + base);
|
|
169
176
|
}
|
|
170
|
-
return host === p;
|
|
177
|
+
return p !== "" && host === p;
|
|
171
178
|
}
|
|
172
179
|
function isDomainAllowed(url, policy) {
|
|
173
180
|
const host = hostOf(url);
|
|
@@ -425,7 +432,6 @@
|
|
|
425
432
|
super(message);
|
|
426
433
|
this.code = code;
|
|
427
434
|
}
|
|
428
|
-
code;
|
|
429
435
|
};
|
|
430
436
|
var DOWNLOAD_TIMEOUT_MS = 12e4;
|
|
431
437
|
function waitForDownloadComplete(id) {
|
|
@@ -527,7 +533,7 @@
|
|
|
527
533
|
async function waitForSelector(tabId, selector, timeoutMs = 5e3) {
|
|
528
534
|
const found = await execInTab(
|
|
529
535
|
tabId,
|
|
530
|
-
(
|
|
536
|
+
(s, timeout, interval) => new Promise((resolve) => {
|
|
531
537
|
const deadline = Date.now() + timeout;
|
|
532
538
|
const tick = () => {
|
|
533
539
|
if (document.querySelector(s)) return resolve(true);
|
|
@@ -535,7 +541,7 @@
|
|
|
535
541
|
setTimeout(tick, interval);
|
|
536
542
|
};
|
|
537
543
|
tick();
|
|
538
|
-
})
|
|
544
|
+
}),
|
|
539
545
|
[selector, timeoutMs, 120]
|
|
540
546
|
);
|
|
541
547
|
return found === true;
|
|
@@ -1036,7 +1042,7 @@
|
|
|
1036
1042
|
const start = Date.now();
|
|
1037
1043
|
const matched = await execInTab(
|
|
1038
1044
|
id,
|
|
1039
|
-
(
|
|
1045
|
+
(sel, text, gone, timeoutMs, interval) => new Promise((resolve) => {
|
|
1040
1046
|
const deadline = Date.now() + timeoutMs;
|
|
1041
1047
|
const hit = () => {
|
|
1042
1048
|
let present;
|
|
@@ -1051,7 +1057,7 @@
|
|
|
1051
1057
|
setTimeout(tick, interval);
|
|
1052
1058
|
};
|
|
1053
1059
|
tick();
|
|
1054
|
-
})
|
|
1060
|
+
}),
|
|
1055
1061
|
[cmd.params.selector ?? null, cmd.params.textContains ?? null, cmd.params.gone === true, timeout, 150]
|
|
1056
1062
|
);
|
|
1057
1063
|
return { matched: matched === true, waitedMs: Date.now() - start };
|
|
@@ -1115,7 +1121,6 @@
|
|
|
1115
1121
|
if (!HANDLED.has(m)) throw new Error(`router drift: no handler for wire method "${m}"`);
|
|
1116
1122
|
}
|
|
1117
1123
|
}
|
|
1118
|
-
deps;
|
|
1119
1124
|
async dispatch(cmd) {
|
|
1120
1125
|
try {
|
|
1121
1126
|
const policy = this.deps.getPolicy();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehmoodqureshi/chrome-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
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",
|