@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.
@@ -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.trim().toLowerCase();
87
- if (p === '*' || 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);
@@ -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
- --cdp-fallback Opt in to launching/attaching Chromium when no extension
246
- is paired. OFF by default extension-only, never opens
247
- a browser of its own.
248
- --no-cdp-fallback Explicitly disable the fallback (already the default).
249
- --cdp-endpoint <url> Attach to an existing Chrome (e.g. http://127.0.0.1:9222)
250
- --prefer <which> "extension" (default) or "cdp"
251
- --headless Run the CDP-fallback Chromium headless
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[];
@@ -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
- return { links: res.value };
34
+ links = res.value;
30
35
  }
31
- // Fallback: parse hrefs out of the HTML (e.g. when eval is policy-denied).
32
- const { html } = await ex.getHtml(args.selector ? { selector: args.selector } : undefined, {
33
- tabId: args.tabId,
34
- });
35
- const links = [];
36
- const re = /<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
37
- let m;
38
- while ((m = re.exec(html)) !== null) {
39
- links.push({ href: m[1], text: m[2].replace(/<[^>]+>/g, '').trim().slice(0, 200) });
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
- return { links };
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) {
@@ -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.trim().toLowerCase();
165
- if (p === "*" || p === "*://*/*") return true;
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
- ((s, timeout, interval) => new Promise((resolve) => {
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
- ((sel, text, gone, timeoutMs, interval) => new Promise((resolve) => {
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.1",
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",