@mehmoodqureshi/chrome-mcp 0.4.1 → 0.5.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.
@@ -75,6 +75,7 @@
75
75
  }
76
76
  switch (frame.type) {
77
77
  case "welcome":
78
+ this.deps.onPolicy(frame.policy);
78
79
  this.setState("connected");
79
80
  this.deps.log("paired with server");
80
81
  break;
@@ -116,6 +117,92 @@
116
117
  }
117
118
  };
118
119
 
120
+ // shared/policy.ts
121
+ var READ_CONTENT = /* @__PURE__ */ new Set([
122
+ "get_text",
123
+ "get_html",
124
+ "screenshot",
125
+ "wait_for"
126
+ ]);
127
+ var MUTATE_CONTENT = /* @__PURE__ */ new Set([
128
+ "click",
129
+ "type",
130
+ "press",
131
+ "hover",
132
+ "scroll"
133
+ ]);
134
+ var NAVIGATION = /* @__PURE__ */ new Set([
135
+ "navigate",
136
+ "back",
137
+ "forward",
138
+ "reload"
139
+ ]);
140
+ var TAB_MUTATE = /* @__PURE__ */ new Set([
141
+ "tab_select",
142
+ "tab_new",
143
+ "tab_close"
144
+ ]);
145
+ function isMutatingMethod(method) {
146
+ return MUTATE_CONTENT.has(method) || NAVIGATION.has(method) || TAB_MUTATE.has(method);
147
+ }
148
+ function isUrlGated(method) {
149
+ return READ_CONTENT.has(method) || MUTATE_CONTENT.has(method) || NAVIGATION.has(method) || method === "eval" || method === "upload_file";
150
+ }
151
+ function hostOf(url) {
152
+ try {
153
+ return new URL(url).hostname.toLowerCase();
154
+ } catch {
155
+ return "";
156
+ }
157
+ }
158
+ function isAboutBlank(url) {
159
+ return url === "about:blank" || url === "" || url.startsWith("about:");
160
+ }
161
+ function globMatches(host, pattern) {
162
+ const p = pattern.trim().toLowerCase();
163
+ if (p === "*" || p === "*://*/*") return true;
164
+ if (p.startsWith("*.")) {
165
+ const base = p.slice(2);
166
+ return host === base || host.endsWith("." + base);
167
+ }
168
+ return host === p;
169
+ }
170
+ function isDomainAllowed(url, policy) {
171
+ const host = hostOf(url);
172
+ if (!host) return false;
173
+ return policy.allowDomains.some((pat) => globMatches(host, pat));
174
+ }
175
+ function evaluatePolicy(url, method, policy) {
176
+ if (method === "eval" && !policy.allowEval) {
177
+ return { ok: false, reason: "eval is disabled (safe-mode). Pass --unsafe-enable-eval to allow it." };
178
+ }
179
+ if (method === "download_file" && !policy.allowDownloads) {
180
+ return { ok: false, reason: "downloads are disabled. Pass --enable-downloads or set allowDownloads." };
181
+ }
182
+ if (method === "upload_file" && !policy.allowUploads) {
183
+ return {
184
+ ok: false,
185
+ reason: "uploads are disabled (sending local files to a page is an exfiltration risk). Pass --enable-uploads or set allowUploads."
186
+ };
187
+ }
188
+ if (isMutatingMethod(method) && !policy.enableMutations) {
189
+ return {
190
+ ok: false,
191
+ reason: `mutating tool "${method}" is disabled (safe-mode). Pass --enable-mutations to allow it.`
192
+ };
193
+ }
194
+ if (!isUrlGated(method)) return { ok: true };
195
+ if (isAboutBlank(url) && NAVIGATION.has(method)) return { ok: true };
196
+ if (!isDomainAllowed(url, policy)) {
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
+ };
202
+ }
203
+ return { ok: true };
204
+ }
205
+
119
206
  // shared/download.ts
120
207
  var MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
121
208
  var DANGEROUS_EXTENSIONS = /* @__PURE__ */ new Set([
@@ -272,6 +359,62 @@
272
359
  return { url: location.href, title: document.title, nodes, truncated: els.length > nodes.length };
273
360
  }
274
361
 
362
+ // shared/screenshot.ts
363
+ var MAX_CAPTURE_PX = 16384;
364
+ function planScreenshot(dims, opts = {}) {
365
+ if (opts.element) {
366
+ const realH = Math.max(1, Math.round(opts.element.h));
367
+ const clipH = Math.min(opts.element.h, MAX_CAPTURE_PX);
368
+ return {
369
+ clip: { x: opts.element.x, y: opts.element.y, width: opts.element.w, height: clipH, scale: 1 },
370
+ captureBeyondViewport: true,
371
+ width: Math.max(1, Math.round(opts.element.w)),
372
+ height: Math.min(realH, MAX_CAPTURE_PX),
373
+ truncated: realH > MAX_CAPTURE_PX,
374
+ fullHeight: realH
375
+ };
376
+ }
377
+ if (opts.fullPage) {
378
+ const clipH = Math.min(dims.fullH, MAX_CAPTURE_PX);
379
+ return {
380
+ clip: { x: 0, y: 0, width: dims.fullW, height: clipH, scale: 1 },
381
+ captureBeyondViewport: true,
382
+ width: dims.fullW,
383
+ height: clipH,
384
+ truncated: dims.fullH > clipH,
385
+ fullHeight: dims.fullH
386
+ };
387
+ }
388
+ return {
389
+ captureBeyondViewport: false,
390
+ width: dims.w,
391
+ height: dims.h,
392
+ truncated: false
393
+ };
394
+ }
395
+
396
+ // shared/mutex.ts
397
+ function noop() {
398
+ }
399
+ var KeyedMutex = class {
400
+ tails = /* @__PURE__ */ new Map();
401
+ /** Run `fn` after all earlier holders of `key` settle. Resolves/rejects with fn's outcome. */
402
+ run(key, fn) {
403
+ const prev = this.tails.get(key) ?? Promise.resolve();
404
+ const result = prev.then(fn, fn);
405
+ const tail = result.then(noop, noop);
406
+ this.tails.set(key, tail);
407
+ void tail.then(() => {
408
+ if (this.tails.get(key) === tail) this.tails.delete(key);
409
+ });
410
+ return result;
411
+ }
412
+ /** Number of keys with an outstanding or queued holder (for tests/inspection). */
413
+ get size() {
414
+ return this.tails.size;
415
+ }
416
+ };
417
+
275
418
  // extension/src/sw/executor.ts
276
419
  var CmdError = class extends Error {
277
420
  constructor(code, message) {
@@ -282,6 +425,8 @@
282
425
  var SESSION = crypto.randomUUID();
283
426
  var CONTENT_SCHEME = /^(https?|file):/i;
284
427
  var delay = (ms) => new Promise((r) => setTimeout(r, ms));
428
+ var locks = new KeyedMutex();
429
+ var claimedTabs = /* @__PURE__ */ new Set();
285
430
  function mint(tabId) {
286
431
  return `ext:${SESSION}:${tabId}`;
287
432
  }
@@ -303,6 +448,19 @@
303
448
  async function targetTab(cmd) {
304
449
  return cmd.tabId ? parseTabId(cmd.tabId) : currentTabId();
305
450
  }
451
+ async function urlForCommand(cmd) {
452
+ if (cmd.method === "navigate") {
453
+ const u = cmd.params.url;
454
+ return typeof u === "string" ? u : "";
455
+ }
456
+ try {
457
+ const tabId = await targetTab(cmd);
458
+ const t = await chrome.tabs.get(tabId);
459
+ return t.url ?? "";
460
+ } catch {
461
+ return "";
462
+ }
463
+ }
306
464
  async function execInTab(tabId, func, args = [], world) {
307
465
  const [res] = await chrome.scripting.executeScript({
308
466
  target: { tabId },
@@ -341,13 +499,15 @@
341
499
  }
342
500
  }
343
501
  async function withDebugger(tabId, fn) {
344
- const target = { tabId };
345
- await chrome.debugger.attach(target, "1.3");
346
- try {
347
- return await fn(target);
348
- } finally {
349
- await chrome.debugger.detach(target).catch(() => void 0);
350
- }
502
+ return locks.run(`dbg:${tabId}`, async () => {
503
+ const target = { tabId };
504
+ await chrome.debugger.attach(target, "1.3");
505
+ try {
506
+ return await fn(target);
507
+ } finally {
508
+ await chrome.debugger.detach(target).catch(() => void 0);
509
+ }
510
+ });
351
511
  }
352
512
  async function trustedType(tabId, selector, text, clear) {
353
513
  const focused = await execInTab(
@@ -389,6 +549,72 @@
389
549
  });
390
550
  return true;
391
551
  }
552
+ async function measurePage(tabId, selector) {
553
+ return execInTab(
554
+ tabId,
555
+ (sel) => {
556
+ const d = document.documentElement;
557
+ const dims = {
558
+ w: window.innerWidth,
559
+ h: window.innerHeight,
560
+ fullW: Math.max(d.scrollWidth, d.clientWidth),
561
+ fullH: Math.max(d.scrollHeight, d.clientHeight)
562
+ };
563
+ if (!sel) return { dims, element: null, missing: false };
564
+ const el = document.querySelector(sel);
565
+ if (!el) return { dims, element: null, missing: true };
566
+ el.scrollIntoView({ block: "center", inline: "center" });
567
+ const r = el.getBoundingClientRect();
568
+ return { dims, element: { x: r.left + window.scrollX, y: r.top + window.scrollY, w: r.width, h: r.height }, missing: false };
569
+ },
570
+ [selector ?? null]
571
+ );
572
+ }
573
+ async function screenshotViaDebugger(tabId, fullPage, selector) {
574
+ const measured = await measurePage(tabId, selector);
575
+ if (!measured) throw new CmdError("CDP_ERROR", "could not read page dimensions");
576
+ if (selector && measured.missing) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${selector}`);
577
+ const plan = planScreenshot(measured.dims, { fullPage, element: measured.element });
578
+ const params = { format: "png", captureBeyondViewport: plan.captureBeyondViewport };
579
+ if (plan.clip) params.clip = plan.clip;
580
+ const data = await withDebugger(tabId, async (target) => {
581
+ const res = await chrome.debugger.sendCommand(target, "Page.captureScreenshot", params);
582
+ return res.data ?? "";
583
+ });
584
+ return {
585
+ dataBase64: data,
586
+ mimeType: "image/png",
587
+ width: plan.width,
588
+ height: plan.height,
589
+ truncated: plan.truncated,
590
+ fullHeight: plan.fullHeight
591
+ };
592
+ }
593
+ async function screenshotViaVisibleTab(tabId, fullPage) {
594
+ let t = await chrome.tabs.get(tabId);
595
+ if (!t.active) {
596
+ await chrome.tabs.update(tabId, { active: true });
597
+ await chrome.windows.update(t.windowId, { focused: true }).catch(() => void 0);
598
+ await delay(150);
599
+ t = await chrome.tabs.get(tabId);
600
+ }
601
+ const dims = await execInTab(
602
+ tabId,
603
+ () => ({ w: window.innerWidth, h: window.innerHeight, full: document.documentElement.scrollHeight }),
604
+ []
605
+ );
606
+ const dataUrl = await chrome.tabs.captureVisibleTab(t.windowId, { format: "png" });
607
+ const viewportH = dims?.h ?? 0;
608
+ const fullH = dims?.full ?? viewportH;
609
+ return {
610
+ dataBase64: dataUrl.split(",")[1] ?? "",
611
+ mimeType: "image/png",
612
+ width: dims?.w ?? 0,
613
+ height: viewportH,
614
+ truncated: fullPage && fullH > viewportH,
615
+ fullHeight: fullPage ? fullH : void 0
616
+ };
617
+ }
392
618
  async function tabInfo(tab, index = 0) {
393
619
  return {
394
620
  tabId: mint(tab.id ?? -1),
@@ -442,19 +668,34 @@
442
668
  }
443
669
  case "tab_new": {
444
670
  const url = typeof cmd.params.url === "string" ? cmd.params.url : void 0;
671
+ const active = cmd.params.active !== false;
445
672
  const BLANK = /^(about:blank|chrome:\/\/newtab|chrome:\/\/new-tab-page|edge:\/\/newtab)/i;
446
- const blank = (await chrome.tabs.query({})).find(
447
- (t2) => t2.id !== void 0 && (BLANK.test(t2.url ?? "") || (t2.url ?? "") === "" || t2.pendingUrl === "about:blank")
448
- );
449
- if (blank?.id !== void 0) {
450
- if (url) {
451
- await chrome.tabs.update(blank.id, { url });
452
- await waitComplete(blank.id);
673
+ const claim = await locks.run("tab_new", async () => {
674
+ const tabs = await chrome.tabs.query({});
675
+ const present = new Set(tabs.map((t) => t.id).filter((id) => id !== void 0));
676
+ for (const id of claimedTabs) if (!present.has(id)) claimedTabs.delete(id);
677
+ const blank = tabs.find(
678
+ (t) => t.id !== void 0 && !claimedTabs.has(t.id) && (BLANK.test(t.url ?? "") || (t.url ?? "") === "" || t.pendingUrl === "about:blank")
679
+ );
680
+ if (blank?.id !== void 0) {
681
+ claimedTabs.add(blank.id);
682
+ return { id: blank.id, reused: true, needsNav: url !== void 0 };
453
683
  }
454
- return { ...await tabInfo(await chrome.tabs.get(blank.id)), reused: true };
684
+ const created = await chrome.tabs.create({ url, active: false });
685
+ if (created.id === void 0) throw new CmdError("TARGET_GONE", "failed to create a tab");
686
+ claimedTabs.add(created.id);
687
+ return { id: created.id, reused: false, needsNav: false };
688
+ });
689
+ if (claim.needsNav) {
690
+ await chrome.tabs.update(claim.id, { url });
691
+ await waitComplete(claim.id);
455
692
  }
456
- const t = await chrome.tabs.create({ url, active: false });
457
- return { ...await tabInfo(t), reused: false };
693
+ if (active) {
694
+ const t = await chrome.tabs.get(claim.id);
695
+ await chrome.tabs.update(claim.id, { active: true }).catch(() => void 0);
696
+ await chrome.windows.update(t.windowId, { focused: true }).catch(() => void 0);
697
+ }
698
+ return { ...await tabInfo(await chrome.tabs.get(claim.id)), reused: claim.reused };
458
699
  }
459
700
  case "tab_close": {
460
701
  const id = parseTabId(String(cmd.tabId));
@@ -709,34 +950,21 @@
709
950
  );
710
951
  return { ok: true };
711
952
  }
712
- // -- screenshot (captureVisibleTab grabs the ACTIVE visible tab, so activate the target first) --
953
+ // -- screenshot --
954
+ // Primary path: chrome.debugger Page.captureScreenshot, which captures a
955
+ // SPECIFIC tab WITHOUT activating it (no focus-stealing → safe under
956
+ // concurrent batches) and supports true full-page + element capture.
957
+ // Falls back to captureVisibleTab only if the debugger can't attach.
713
958
  case "screenshot": {
714
959
  const id = await targetTab(cmd);
715
- let t = await chrome.tabs.get(id);
716
- if (!t.active) {
717
- await chrome.tabs.update(id, { active: true });
718
- await chrome.windows.update(t.windowId, { focused: true }).catch(() => void 0);
719
- await delay(150);
720
- t = await chrome.tabs.get(id);
721
- }
722
- const dims = await execInTab(
723
- id,
724
- () => ({ w: window.innerWidth, h: window.innerHeight, full: document.documentElement.scrollHeight }),
725
- []
726
- );
727
- const dataUrl = await chrome.tabs.captureVisibleTab(t.windowId, { format: "png" });
728
960
  const fullPage = cmd.params.fullPage === true;
729
- const viewportH = dims?.h ?? 0;
730
- const fullH = dims?.full ?? viewportH;
731
- return {
732
- dataBase64: dataUrl.split(",")[1] ?? "",
733
- mimeType: "image/png",
734
- width: dims?.w ?? 0,
735
- height: viewportH,
736
- // The scripting backend can only capture the viewport; flag when a fullPage was asked but clipped.
737
- truncated: fullPage && fullH > viewportH,
738
- fullHeight: fullPage ? fullH : void 0
739
- };
961
+ const selector = selectorOf(cmd);
962
+ try {
963
+ return await screenshotViaDebugger(id, fullPage, selector);
964
+ } catch (err) {
965
+ if (err instanceof CmdError && err.code === "SELECTOR_NOT_FOUND") throw err;
966
+ return await screenshotViaVisibleTab(id, fullPage);
967
+ }
740
968
  }
741
969
  // -- eval (MAIN world; may be blocked by strict page CSP) --
742
970
  case "eval": {
@@ -829,6 +1057,12 @@
829
1057
  }
830
1058
  async dispatch(cmd) {
831
1059
  try {
1060
+ const policy = this.deps.getPolicy();
1061
+ if (policy) {
1062
+ const url = isUrlGated(cmd.method) ? await urlForCommand(cmd) : "";
1063
+ const verdict = evaluatePolicy(url, cmd.method, policy);
1064
+ if (!verdict.ok) throw new CmdError("POLICY_DENIED", verdict.reason);
1065
+ }
832
1066
  const data = await this.deps.exec.run(cmd);
833
1067
  const frame = { type: "result", v: PROTOCOL_VERSION, id: cmd.id, ok: true, data };
834
1068
  this.deps.send(frame);
@@ -850,15 +1084,20 @@
850
1084
 
851
1085
  // extension/src/sw/background.ts
852
1086
  var KEEPALIVE_ALARM = "chrome-mcp-keepalive";
1087
+ var currentPolicy = null;
853
1088
  var executor = new ChromeExecutor();
854
1089
  var ws = new WsClient({
855
1090
  onCommand: (cmd) => void router.dispatch(cmd),
856
1091
  onState: (state) => void persistState(state),
1092
+ onPolicy: (policy) => {
1093
+ currentPolicy = policy;
1094
+ },
857
1095
  log: (m) => console.debug("[chrome-mcp]", m)
858
1096
  });
859
1097
  var router = new CommandRouter({
860
1098
  exec: executor,
861
1099
  send: (frame) => ws.send(frame),
1100
+ getPolicy: () => currentPolicy,
862
1101
  log: (m) => console.debug("[chrome-mcp]", m)
863
1102
  });
864
1103
  async function getConfig() {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome MCP Bridge",
4
- "version": "0.4.1",
4
+ "version": "0.5.0",
5
5
  "description": "Lets a local chrome-mcp server drive this browser. Pair it with the server's handshake token.",
6
6
  "minimum_chrome_version": "116",
7
7
  "background": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehmoodqureshi/chrome-mcp",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
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",