@mehmoodqureshi/chrome-mcp 0.1.0 → 0.3.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.
@@ -19,6 +19,10 @@
19
19
  "screenshot",
20
20
  "get_text",
21
21
  "get_html",
22
+ "snapshot",
23
+ "select_option",
24
+ "get_cookies",
25
+ "storage",
22
26
  "eval",
23
27
  "wait_for",
24
28
  "download_file",
@@ -161,6 +165,68 @@
161
165
  return n;
162
166
  }
163
167
 
168
+ // shared/snapshot.ts
169
+ function collectSnapshot(interactiveOnly = true, max = 200) {
170
+ const INTERACTIVE = "a[href],button,input,select,textarea,[role=button],[role=link],[role=tab],[role=checkbox],[role=radio],[role=menuitem],[role=option],[role=switch],[contenteditable=true],[onclick]";
171
+ const LANDMARK = "h1,h2,h3,[role=heading],nav,main,header,footer,[role=navigation]";
172
+ const sel = interactiveOnly ? INTERACTIVE : `${INTERACTIVE},${LANDMARK}`;
173
+ const visible = (el) => {
174
+ const r = el.getBoundingClientRect();
175
+ if (r.width === 0 && r.height === 0) return false;
176
+ const s = window.getComputedStyle(el);
177
+ return s.visibility !== "hidden" && s.display !== "none";
178
+ };
179
+ const accName = (el) => {
180
+ const aria = el.getAttribute("aria-label");
181
+ if (aria) return aria.trim();
182
+ const labelledby = el.getAttribute("aria-labelledby");
183
+ if (labelledby) {
184
+ const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.innerText ?? "").join(" ").trim();
185
+ if (t) return t;
186
+ }
187
+ const ph = el.getAttribute("placeholder");
188
+ if (ph) return ph.trim();
189
+ const title = el.getAttribute("title");
190
+ if (title) return title.trim();
191
+ const text = el.innerText ?? "";
192
+ if (text) return text.replace(/\s+/g, " ").trim().slice(0, 120);
193
+ const alt = el.querySelector("img[alt]")?.getAttribute("alt");
194
+ return (alt ?? "").trim();
195
+ };
196
+ const roleOf = (el) => {
197
+ const explicit = el.getAttribute("role");
198
+ if (explicit) return explicit;
199
+ const tag = el.tagName.toLowerCase();
200
+ if (tag === "a") return "link";
201
+ if (tag === "button") return "button";
202
+ if (tag === "select") return "combobox";
203
+ if (tag === "textarea") return "textbox";
204
+ if (tag === "input") {
205
+ const t = el.type;
206
+ if (t === "checkbox") return "checkbox";
207
+ if (t === "radio") return "radio";
208
+ if (t === "button" || t === "submit") return "button";
209
+ return "textbox";
210
+ }
211
+ return tag;
212
+ };
213
+ const els = Array.from(document.querySelectorAll(sel)).filter(visible);
214
+ const nodes = [];
215
+ let n = 0;
216
+ for (const el of els) {
217
+ if (nodes.length >= max) break;
218
+ const ref = `e${++n}`;
219
+ el.setAttribute("data-mcp-ref", ref);
220
+ const node = { ref, role: roleOf(el), name: accName(el), tag: el.tagName.toLowerCase() };
221
+ const v = el.value;
222
+ if (typeof v === "string" && v) node.value = v.slice(0, 200);
223
+ if (el.disabled) node.disabled = true;
224
+ if (el.checked) node.checked = true;
225
+ nodes.push(node);
226
+ }
227
+ return { url: location.href, title: document.title, nodes, truncated: els.length > nodes.length };
228
+ }
229
+
164
230
  // extension/src/sw/executor.ts
165
231
  var CmdError = class extends Error {
166
232
  constructor(code, message) {
@@ -210,9 +276,73 @@
210
276
  await delay(100);
211
277
  }
212
278
  }
213
- function selectorOf(cmd) {
279
+ function resolveSelector(cmd) {
214
280
  const s = cmd.params.selector;
215
- return typeof s === "string" ? s : void 0;
281
+ if (typeof s === "string" && s.length > 0) return s;
282
+ const ref = cmd.params.ref;
283
+ if (typeof ref === "string" && ref.length > 0) return `[data-mcp-ref="${ref.replace(/["\\]/g, "\\$&")}"]`;
284
+ return void 0;
285
+ }
286
+ function selectorOf(cmd) {
287
+ return resolveSelector(cmd);
288
+ }
289
+ async function waitForSelector(tabId, selector, timeoutMs = 5e3) {
290
+ const start = Date.now();
291
+ for (; ; ) {
292
+ const present = await execInTab(tabId, (s) => !!document.querySelector(s), [selector]);
293
+ if (present) return true;
294
+ if (Date.now() - start > timeoutMs) return false;
295
+ await delay(120);
296
+ }
297
+ }
298
+ async function withDebugger(tabId, fn) {
299
+ const target = { tabId };
300
+ await chrome.debugger.attach(target, "1.3");
301
+ try {
302
+ return await fn(target);
303
+ } finally {
304
+ await chrome.debugger.detach(target).catch(() => void 0);
305
+ }
306
+ }
307
+ async function trustedType(tabId, selector, text, clear) {
308
+ const focused = await execInTab(
309
+ tabId,
310
+ (s, doClear) => {
311
+ const el = document.querySelector(s);
312
+ if (!el) return false;
313
+ el.focus();
314
+ if (doClear) {
315
+ const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value")?.set;
316
+ setter ? setter.call(el, "") : el.value = "";
317
+ el.dispatchEvent(new Event("input", { bubbles: true }));
318
+ }
319
+ return true;
320
+ },
321
+ [selector, clear]
322
+ );
323
+ if (!focused) return false;
324
+ await withDebugger(tabId, (t) => chrome.debugger.sendCommand(t, "Input.insertText", { text }));
325
+ return true;
326
+ }
327
+ async function trustedClick(tabId, selector) {
328
+ const pt = await execInTab(
329
+ tabId,
330
+ (s) => {
331
+ const el = document.querySelector(s);
332
+ if (!el) return null;
333
+ el.scrollIntoView({ block: "center", inline: "center" });
334
+ const r = el.getBoundingClientRect();
335
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
336
+ },
337
+ [selector]
338
+ );
339
+ if (!pt) return false;
340
+ await withDebugger(tabId, async (t) => {
341
+ const base = { x: pt.x, y: pt.y, button: "left", clickCount: 1 };
342
+ await chrome.debugger.sendCommand(t, "Input.dispatchMouseEvent", { type: "mousePressed", buttons: 1, ...base });
343
+ await chrome.debugger.sendCommand(t, "Input.dispatchMouseEvent", { type: "mouseReleased", buttons: 0, ...base });
344
+ });
345
+ return true;
216
346
  }
217
347
  async function tabInfo(tab, index = 0) {
218
348
  return {
@@ -240,6 +370,10 @@
240
370
  "screenshot",
241
371
  "get_text",
242
372
  "get_html",
373
+ "snapshot",
374
+ "select_option",
375
+ "get_cookies",
376
+ "storage",
243
377
  "eval",
244
378
  "wait_for",
245
379
  "download_file",
@@ -325,42 +459,155 @@
325
459
  );
326
460
  return { html: html ?? "" };
327
461
  }
462
+ // -- accessibility snapshot (tags elements with data-mcp-ref so refs work) --
463
+ case "snapshot": {
464
+ const id = await targetTab(cmd);
465
+ const interactiveOnly = cmd.params.interactiveOnly !== false;
466
+ const max = typeof cmd.params.max === "number" ? cmd.params.max : 200;
467
+ const raw = await execInTab(
468
+ id,
469
+ collectSnapshot,
470
+ [interactiveOnly, max]
471
+ );
472
+ return raw ?? { url: "", title: "", nodes: [], truncated: false };
473
+ }
474
+ // -- <select> option(s) by value or visible label --
475
+ case "select_option": {
476
+ const id = await targetTab(cmd);
477
+ const sel = requireSelector(cmd);
478
+ const values = Array.isArray(cmd.params.values) ? cmd.params.values.map(String) : [];
479
+ await waitForSelector(id, sel);
480
+ const matched = await execInTab(
481
+ id,
482
+ (s, vals) => {
483
+ const el = document.querySelector(s);
484
+ if (!el || !el.options) return false;
485
+ const set = new Set(vals);
486
+ let hit = false;
487
+ for (const opt of Array.from(el.options)) {
488
+ const on = set.has(opt.value) || set.has(opt.label) || set.has(opt.text);
489
+ opt.selected = on;
490
+ if (on) hit = true;
491
+ }
492
+ el.dispatchEvent(new Event("input", { bubbles: true }));
493
+ el.dispatchEvent(new Event("change", { bubbles: true }));
494
+ return hit;
495
+ },
496
+ [sel, values]
497
+ );
498
+ if (!matched) throw new CmdError("SELECTOR_NOT_FOUND", `no <select> option matched for ${sel}`);
499
+ return { ok: true };
500
+ }
501
+ // -- cookies for the tab's URL (chrome.cookies; needs "cookies" permission) --
502
+ case "get_cookies": {
503
+ const id = await targetTab(cmd);
504
+ const t = await chrome.tabs.get(id);
505
+ const url = typeof cmd.params.url === "string" ? cmd.params.url : t.url;
506
+ if (!url) throw new CmdError("BAD_ARGS", "no url to read cookies for");
507
+ const cookies = await chrome.cookies.getAll({ url });
508
+ return {
509
+ cookies: cookies.map((c) => ({
510
+ name: c.name,
511
+ value: c.value,
512
+ domain: c.domain,
513
+ path: c.path,
514
+ secure: c.secure,
515
+ httpOnly: c.httpOnly,
516
+ expires: c.expirationDate
517
+ }))
518
+ };
519
+ }
520
+ // -- localStorage / sessionStorage (isolated world) --
521
+ case "storage": {
522
+ const id = await targetTab(cmd);
523
+ const op = String(cmd.params.op);
524
+ const key = typeof cmd.params.key === "string" ? cmd.params.key : null;
525
+ const value = typeof cmd.params.value === "string" ? cmd.params.value : null;
526
+ const session = cmd.params.session === true;
527
+ const res = await execInTab(
528
+ id,
529
+ (o, k, v, s) => {
530
+ const store = s ? window.sessionStorage : window.localStorage;
531
+ if (o === "set") {
532
+ store.setItem(String(k), String(v ?? ""));
533
+ return { ok: true };
534
+ }
535
+ if (o === "remove") {
536
+ store.removeItem(String(k));
537
+ return { ok: true };
538
+ }
539
+ if (o === "clear") {
540
+ store.clear();
541
+ return { ok: true };
542
+ }
543
+ if (k) return { ok: true, value: store.getItem(k) };
544
+ const entries = {};
545
+ for (let i = 0; i < store.length; i++) {
546
+ const kk = store.key(i);
547
+ if (kk) entries[kk] = store.getItem(kk) ?? "";
548
+ }
549
+ return { ok: true, entries };
550
+ },
551
+ [op, key, value, session]
552
+ );
553
+ return res ?? { ok: false };
554
+ }
328
555
  // -- interaction (synthetic events in the isolated world) --
329
556
  case "click": {
330
557
  const id = await targetTab(cmd);
558
+ const sel = requireSelector(cmd);
559
+ if (!await waitForSelector(id, sel)) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
560
+ if (cmd.params.trusted === true) {
561
+ if (!await trustedClick(id, sel)) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
562
+ return { ok: true };
563
+ }
331
564
  const found = await execInTab(
332
565
  id,
333
- (sel) => {
334
- const el = document.querySelector(sel);
566
+ (s) => {
567
+ const el = document.querySelector(s);
335
568
  if (!el) return false;
336
569
  el.scrollIntoView({ block: "center" });
337
570
  el.click();
338
571
  return true;
339
572
  },
340
- [requireSelector(cmd)]
573
+ [sel]
341
574
  );
342
- if (!found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${requireSelector(cmd)}`);
575
+ if (!found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
343
576
  return { ok: true };
344
577
  }
345
578
  case "type": {
346
579
  const id = await targetTab(cmd);
580
+ const sel = requireSelector(cmd);
347
581
  const text = String(cmd.params.text ?? "");
348
582
  const clear = cmd.params.clear === true;
583
+ if (!await waitForSelector(id, sel)) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
584
+ if (cmd.params.trusted === true) {
585
+ if (!await trustedType(id, sel, text, clear)) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
586
+ if (cmd.params.pressEnter === true) {
587
+ await withDebugger(id, async (t) => {
588
+ for (const type of ["keyDown", "keyUp"]) {
589
+ await chrome.debugger.sendCommand(t, "Input.dispatchKeyEvent", { type, key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 });
590
+ }
591
+ });
592
+ }
593
+ return { ok: true };
594
+ }
349
595
  const found = await execInTab(
350
596
  id,
351
- (sel, value, doClear) => {
352
- const el = document.querySelector(sel);
597
+ (s, value, doClear) => {
598
+ const el = document.querySelector(s);
353
599
  if (!el) return false;
354
600
  el.focus();
355
- if (doClear) el.value = "";
356
- el.value = (el.value ?? "") + value;
601
+ const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value")?.set;
602
+ const next = (doClear ? "" : el.value ?? "") + value;
603
+ setter ? setter.call(el, next) : el.value = next;
357
604
  el.dispatchEvent(new Event("input", { bubbles: true }));
358
605
  el.dispatchEvent(new Event("change", { bubbles: true }));
359
606
  return true;
360
607
  },
361
- [requireSelector(cmd), text, clear]
608
+ [sel, text, clear]
362
609
  );
363
- if (!found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${requireSelector(cmd)}`);
610
+ if (!found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
364
611
  return { ok: true };
365
612
  }
366
613
  case "press": {
@@ -380,13 +627,16 @@
380
627
  }
381
628
  case "hover": {
382
629
  const id = await targetTab(cmd);
630
+ const sel = requireSelector(cmd);
631
+ await waitForSelector(id, sel);
383
632
  await execInTab(
384
633
  id,
385
- (sel) => {
386
- const el = document.querySelector(sel);
634
+ (s) => {
635
+ const el = document.querySelector(s);
387
636
  el?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
637
+ el?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
388
638
  },
389
- [requireSelector(cmd)]
639
+ [sel]
390
640
  );
391
641
  return { ok: true };
392
642
  }
@@ -402,17 +652,33 @@
402
652
  );
403
653
  return { ok: true };
404
654
  }
405
- // -- screenshot (visible tab) --
655
+ // -- screenshot (captureVisibleTab grabs the ACTIVE visible tab, so activate the target first) --
406
656
  case "screenshot": {
407
657
  const id = await targetTab(cmd);
408
- const t = await chrome.tabs.get(id);
658
+ let t = await chrome.tabs.get(id);
659
+ if (!t.active) {
660
+ await chrome.tabs.update(id, { active: true });
661
+ await chrome.windows.update(t.windowId, { focused: true }).catch(() => void 0);
662
+ await delay(150);
663
+ t = await chrome.tabs.get(id);
664
+ }
665
+ const dims = await execInTab(
666
+ id,
667
+ () => ({ w: window.innerWidth, h: window.innerHeight, full: document.documentElement.scrollHeight }),
668
+ []
669
+ );
409
670
  const dataUrl = await chrome.tabs.captureVisibleTab(t.windowId, { format: "png" });
671
+ const fullPage = cmd.params.fullPage === true;
672
+ const viewportH = dims?.h ?? 0;
673
+ const fullH = dims?.full ?? viewportH;
410
674
  return {
411
675
  dataBase64: dataUrl.split(",")[1] ?? "",
412
676
  mimeType: "image/png",
413
- width: 0,
414
- height: 0,
415
- truncated: false
677
+ width: dims?.w ?? 0,
678
+ height: viewportH,
679
+ // The scripting backend can only capture the viewport; flag when a fullPage was asked but clipped.
680
+ truncated: fullPage && fullH > viewportH,
681
+ fullHeight: fullPage ? fullH : void 0
416
682
  };
417
683
  }
418
684
  // -- eval (MAIN world; may be blocked by strict page CSP) --
@@ -472,11 +738,11 @@
472
738
  }
473
739
  };
474
740
  function requireSelector(cmd) {
475
- const s = cmd.params.selector;
476
- if (typeof s !== "string" || s.length === 0) {
477
- throw new CmdError("BAD_ARGS", "this command needs a CSS selector (the scripting backend does not support refs)");
741
+ const sel = resolveSelector(cmd);
742
+ if (!sel) {
743
+ throw new CmdError("BAD_ARGS", 'this command needs a "selector" or a "ref" from snapshot()');
478
744
  }
479
- return s;
745
+ return sel;
480
746
  }
481
747
 
482
748
  // extension/src/sw/router.ts
@@ -523,12 +789,28 @@
523
789
  });
524
790
  async function getConfig() {
525
791
  const { wsPort, token } = await chrome.storage.local.get(["wsPort", "token"]);
526
- if (typeof wsPort === "number" && typeof token === "string" && token.length > 0) {
792
+ if (typeof wsPort === "number" && wsPort > 0 && typeof token === "string" && token.length > 0) {
527
793
  return { wsPort, token };
528
794
  }
529
795
  return null;
530
796
  }
797
+ var BADGE = {
798
+ connected: { text: "\u25CF", color: "#16a34a", title: "Chrome MCP \u2014 connected" },
799
+ connecting: { text: "\u2026", color: "#ca8a04", title: "Chrome MCP \u2014 connecting" },
800
+ unauthorized: { text: "!", color: "#dc2626", title: "Chrome MCP \u2014 rejected (bad/stale token; re-pair)" },
801
+ idle: { text: "\u25CB", color: "#6b7280", title: "Chrome MCP \u2014 not connected (open options to pair)" }
802
+ };
803
+ function reflectBadge(state) {
804
+ const b = BADGE[state] ?? BADGE.idle;
805
+ try {
806
+ void chrome.action.setBadgeText({ text: b.text });
807
+ void chrome.action.setBadgeBackgroundColor({ color: b.color });
808
+ void chrome.action.setTitle({ title: b.title });
809
+ } catch {
810
+ }
811
+ }
531
812
  async function persistState(state) {
813
+ reflectBadge(state);
532
814
  await chrome.storage.local.set({ connState: state });
533
815
  }
534
816
  async function ensureConnected() {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome MCP Bridge",
4
- "version": "0.1.0",
4
+ "version": "0.2.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": { "service_worker": "background.js" },
8
- "permissions": ["tabs", "scripting", "activeTab", "downloads", "storage", "alarms"],
9
- "host_permissions": ["http://*/*", "https://*/*"],
8
+ "permissions": ["tabs", "scripting", "activeTab", "downloads", "storage", "alarms", "cookies", "debugger"],
9
+ "host_permissions": ["<all_urls>"],
10
10
  "options_page": "options.html",
11
11
  "action": { "default_title": "Chrome MCP — open options to pair" }
12
12
  }
@@ -1,5 +1,8 @@
1
1
  "use strict";
2
2
  (() => {
3
+ // shared/protocol.ts
4
+ var DEFAULT_WS_PORT = 38017;
5
+
3
6
  // extension/src/options/options.ts
4
7
  var portEl = document.getElementById("port");
5
8
  var tokenEl = document.getElementById("token");
@@ -7,7 +10,7 @@
7
10
  var statusEl = document.getElementById("status");
8
11
  async function loadExisting() {
9
12
  const { wsPort, connState } = await chrome.storage.local.get(["wsPort", "connState"]);
10
- if (typeof wsPort === "number") portEl.value = String(wsPort);
13
+ portEl.value = typeof wsPort === "number" && wsPort > 0 ? String(wsPort) : String(DEFAULT_WS_PORT);
11
14
  render(typeof connState === "string" ? connState : "idle");
12
15
  }
13
16
  function render(state) {
@@ -22,8 +25,8 @@
22
25
  saveEl.addEventListener("click", async () => {
23
26
  const wsPort = Number(portEl.value);
24
27
  const token = tokenEl.value.trim();
25
- if (!Number.isInteger(wsPort) || wsPort < 0 || !token) {
26
- statusEl.textContent = "Status: enter a valid port and token";
28
+ if (!Number.isInteger(wsPort) || wsPort <= 0 || !token) {
29
+ statusEl.textContent = "Status: enter a valid port (> 0) and token";
27
30
  return;
28
31
  }
29
32
  await chrome.storage.local.set({ wsPort, token });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehmoodqureshi/chrome-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",
@@ -64,6 +64,6 @@
64
64
  "typescript": "^5.7.2"
65
65
  },
66
66
  "engines": {
67
- "node": ">=20.0.0"
67
+ "node": ">=18.0.0"
68
68
  }
69
69
  }