@agent360/browser-mcp 1.23.0 → 1.24.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 CHANGED
@@ -1,4 +1,4 @@
1
- # Browser MCP by Agent360
1
+ # Browser MCP by [Agent360](https://agent360.dk)
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@agent360/browser-mcp)](https://www.npmjs.com/package/@agent360/browser-mcp)
4
4
  [![npm downloads](https://img.shields.io/npm/dw/@agent360/browser-mcp)](https://www.npmjs.com/package/@agent360/browser-mcp)
@@ -7,11 +7,17 @@
7
7
  [![MCP](https://img.shields.io/badge/MCP-compatible-blue)](https://modelcontextprotocol.io)
8
8
  [![Chrome Web Store](https://img.shields.io/badge/Chrome_Web_Store-live-green)](https://chromewebstore.google.com/detail/agent360-browser-mcp/jdehgalffmffhfhmmhaokfbfnafnmgcl)
9
9
 
10
- **Control your real Chrome from Claude Code with your logins, cookies, and 2FA.**
10
+ **Your AI agent drives your real, logged-in Chrome — and works where headless tools die.**
11
11
 
12
- ![Browser MCP Demo](assets/demo.gif)
12
+ [![Browser MCP Demo](assets/demo.gif)](https://browsermcp.dev)
13
13
 
14
- The only browser MCP with **multi-session support** (10 concurrent AI sessions), **human-in-the-loop** (2FA, CAPTCHA, credentials), and **built-in provider integrations** (Stripe, HubSpot, Slack, and 6 more). 34 tools total.
14
+ **[Watch the 37-second demo with sound →](https://browsermcp.dev)**
15
+
16
+ Browser MCP gives Claude Code (and any MCP client — Cursor, VS Code agent mode) control of your actual Chrome: your cookies, your sessions, your 2FA. So it works on CAPTCHA, 2FA and anti-bot sites where Playwright and Puppeteer get blocked — because it's *you* browsing.
17
+
18
+ The killer move: it hits a login wall, reads the verification code from your own Gmail tab, and continues the sign-in. No API can do that. Operate platforms with no API, QA your own web app end-to-end, or work dashboards, LinkedIn and Reddit at human pace — with you approving the sensitive steps.
19
+
20
+ 34 tools. Auto-clicks the reCAPTCHA v2 checkbox, with a human fallback for the rest. Multi-session color-coded tab groups. **MIT, free, and 100% local — nothing leaves your machine.**
15
21
 
16
22
  ## Install — 2 steps (~60 seconds)
17
23
 
@@ -125,7 +131,7 @@ No Developer mode needed. Then run `npx @agent360/browser-mcp install --skip-ext
125
131
  ### CAPTCHA Solving
126
132
  | Tool | Description |
127
133
  |------|-------------|
128
- | `browser_solve_captcha` | Detect and solve CAPTCHAs. Auto-detects reCAPTCHA v2/v3, hCaptcha, Turnstile, FunCaptcha. Actions: `detect`, `click_checkbox` (auto-click, ~80% pass with Google login), `click_grid` (AI vision guided), `ask_human` (fallback) |
134
+ | `browser_solve_captcha` | Detect and solve CAPTCHAs. Auto-detects reCAPTCHA v2/v3, hCaptcha, Turnstile, FunCaptcha. Actions: `detect`, `click_checkbox` (auto-click, often passes when signed into Google), `click_grid` (AI vision guided), `ask_human` (fallback) |
129
135
 
130
136
  ### Human-in-the-Loop
131
137
  | Tool | Description |
@@ -9,34 +9,44 @@
9
9
  // ── Session Tab Management ─────────────────────────────────────────────────
10
10
 
11
11
  const SESSION_COLORS = ['blue', 'green', 'yellow', 'red', 'pink', 'purple', 'cyan', 'orange'];
12
+ // Select-all modifier is platform-dependent: Cmd (meta=4) on macOS, Ctrl (2) elsewhere.
13
+ // Get this wrong and the field isn't selected — Backspace no-ops and new text concatenates onto the old.
14
+ const SELECT_ALL_MODS = /Mac/i.test(navigator.userAgent) ? 4 : 2;
12
15
  const sessions = new Map(); // port → { tabIds: Set, groupId: number|null, color: string, label: string }
13
- let sessionsLoaded = false;
16
+ // FIX-2: promise-cache latch (not a boolean). The old `if(sessionsLoaded) return`
17
+ // flipped the flag BEFORE awaiting storage, so a second concurrent caller on a freshly
18
+ // woken service worker proceeded against an EMPTY sessions Map. Caching the promise makes
19
+ // every concurrent caller await the SAME populated completion. Resets to null on SW
20
+ // eviction (module re-init) and on error, so the next wake retries.
21
+ let restorePromise = null;
14
22
 
15
23
  // Restore sessions from storage (service workers lose in-memory state on suspend)
16
- async function restoreSessions() {
17
- if (sessionsLoaded) return;
18
- sessionsLoaded = true;
19
- const { sessions: saved } = await chrome.storage.local.get({ sessions: {} });
20
- for (const [port, data] of Object.entries(saved)) {
21
- // Verify tabs still exist
22
- const validTabIds = new Set();
23
- for (const tabId of (data.tabIds || [])) {
24
- try {
25
- await chrome.tabs.get(tabId);
26
- validTabIds.add(tabId);
27
- } catch {} // tab no longer exists
28
- }
29
- if (validTabIds.size > 0) {
30
- const activeTabId = data.activeTabId && validTabIds.has(data.activeTabId) ? data.activeTabId : null;
31
- sessions.set(Number(port), {
32
- tabIds: validTabIds,
33
- activeTabId,
34
- groupId: data.groupId || null,
35
- color: data.color || SESSION_COLORS[sessions.size % SESSION_COLORS.length],
36
- label: data.label || `Claude ${sessions.size + 1}`,
37
- });
24
+ function restoreSessions() {
25
+ if (restorePromise) return restorePromise;
26
+ restorePromise = (async () => {
27
+ const { sessions: saved } = await chrome.storage.local.get({ sessions: {} });
28
+ for (const [port, data] of Object.entries(saved)) {
29
+ // Verify tabs still exist
30
+ const validTabIds = new Set();
31
+ for (const tabId of (data.tabIds || [])) {
32
+ try {
33
+ await chrome.tabs.get(tabId);
34
+ validTabIds.add(tabId);
35
+ } catch {} // tab no longer exists
36
+ }
37
+ if (validTabIds.size > 0) {
38
+ const activeTabId = data.activeTabId && validTabIds.has(data.activeTabId) ? data.activeTabId : null;
39
+ sessions.set(Number(port), {
40
+ tabIds: validTabIds,
41
+ activeTabId,
42
+ groupId: data.groupId || null,
43
+ color: data.color || SESSION_COLORS[sessions.size % SESSION_COLORS.length],
44
+ label: data.label || `Claude ${sessions.size + 1}`,
45
+ });
46
+ }
38
47
  }
39
- }
48
+ })().catch(err => { restorePromise = null; throw err; });
49
+ return restorePromise;
40
50
  }
41
51
 
42
52
  function getSession(port) {
@@ -103,10 +113,7 @@ async function addTabToSession(port, tabId) {
103
113
  }
104
114
 
105
115
  if (session.groupId === null) {
106
- const validTabIds = [...session.tabIds].filter(id => {
107
- try { return id; } catch { return false; }
108
- });
109
- const groupId = await chrome.tabs.group({ tabIds: validTabIds });
116
+ const groupId = await chrome.tabs.group({ tabIds: [...session.tabIds] });
110
117
  session.groupId = groupId;
111
118
  await chrome.tabGroups.update(groupId, {
112
119
  title: session.label,
@@ -158,17 +165,25 @@ function persistSessions() {
158
165
  async function getSessionTab(port, activate = false) {
159
166
  const session = getSession(port);
160
167
  let target = null;
168
+ // Remember our OWN about:blank placeholder so we reuse it instead of spawning another
169
+ // on every read-only call before the first navigate (FIX-4: about:blank proliferation).
170
+ let blankFallback = null;
171
+ const consider = (tab) => {
172
+ if (!tab) return false;
173
+ if (tab.url.startsWith('chrome://')) return false;
174
+ if (tab.url.startsWith('about:')) { if (!blankFallback) blankFallback = tab; return false; }
175
+ return true;
176
+ };
161
177
 
162
178
  // Prefer the active (last navigated) tab
163
179
  if (session.activeTabId) {
164
180
  try {
165
181
  const tab = await chrome.tabs.get(session.activeTabId);
166
- if (tab && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:')) {
167
- target = tab;
168
- }
182
+ if (consider(tab)) target = tab;
169
183
  } catch {
184
+ const dead = session.activeTabId; // FIX-17: capture id BEFORE nulling (was deleting null)
170
185
  session.activeTabId = null;
171
- session.tabIds.delete(session.activeTabId);
186
+ session.tabIds.delete(dead);
172
187
  }
173
188
  }
174
189
 
@@ -177,30 +192,48 @@ async function getSessionTab(port, activate = false) {
177
192
  for (const tabId of session.tabIds) {
178
193
  try {
179
194
  const tab = await chrome.tabs.get(tabId);
180
- if (tab && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:')) {
181
- session.activeTabId = tabId;
182
- target = tab;
183
- break;
184
- }
195
+ if (consider(tab)) { session.activeTabId = tabId; target = tab; break; }
185
196
  } catch {
186
197
  session.tabIds.delete(tabId);
187
198
  }
188
199
  }
189
200
  }
190
201
 
191
- // No usable tab create one
202
+ // Reuse our own blank placeholder rather than spawning yet another one (FIX-4).
203
+ if (!target && blankFallback) {
204
+ target = blankFallback;
205
+ session.activeTabId = target.id;
206
+ persistSessions();
207
+ }
208
+
209
+ // No usable tab at all — create ONE placeholder and pin it as the active tab so the
210
+ // NEXT call reuses it (FIX-4) instead of creating a fresh about:blank every time.
192
211
  if (!target) {
193
212
  target = await chrome.tabs.create({ url: 'about:blank', active: false });
194
213
  await addTabToSession(port, target.id);
195
- return target;
214
+ session.activeTabId = target.id;
215
+ persistSessions();
216
+ // fall through to the activate branch (SC-3: previously returned early, skipping it)
196
217
  }
197
218
 
198
- // Activate the tab so Chrome APIs target it (not whatever user is viewing)
199
- if (activate && !target.active) {
200
- await chrome.tabs.update(target.id, { active: true });
201
- // Brief wait for Chrome to render the tab
202
- await new Promise(r => setTimeout(r, 150));
203
- target = await chrome.tabs.get(target.id);
219
+ // Activate the tab WITHOUT stealing the user's focus (FIX-1). This is a BACKGROUND tool:
220
+ // screenshot/press_key run constantly, so we must NOT chrome.windows.update({focused:true})
221
+ // here — that yanked Chrome to the foreground on every action. We only (a) un-minimize a
222
+ // minimized window (needed so it can composite) and (b) make the tab active within its
223
+ // window. The truly-occluded (covered) case is handled as a bounded last-resort
224
+ // raise-and-restore inside the screenshot handler only.
225
+ if (activate) {
226
+ try {
227
+ if (target.windowId != null) {
228
+ const win = await chrome.windows.get(target.windowId).catch(() => null);
229
+ if (win && win.state === 'minimized') {
230
+ await chrome.windows.update(target.windowId, { state: 'normal' }); // no focused:true
231
+ }
232
+ }
233
+ if (!target.active) await chrome.tabs.update(target.id, { active: true });
234
+ await new Promise(r => setTimeout(r, 150));
235
+ target = await chrome.tabs.get(target.id);
236
+ } catch { /* best-effort; capture path surfaces the real error */ }
204
237
  }
205
238
 
206
239
  return target;
@@ -233,34 +266,39 @@ async function debuggerAttach(tabId) {
233
266
  debuggerAttached.delete(tabId);
234
267
  }
235
268
 
236
- try {
237
- await chrome.debugger.attach({ tabId }, '1.3');
238
- // Verify attach actually took effect (Chrome can silently no-op after user-cancel)
239
- if (await verifyAttachedWithChrome(tabId)) {
240
- debuggerAttached.add(tabId);
241
- return;
242
- }
243
- throw new Error(
244
- `Debugger detached (GHOST_ATTACH): chrome.debugger.attach returned success but Chrome state shows tab ${tabId} not attached. ` +
245
- `This typically means the user clicked "Cancel" on Chrome's debugger banner. ` +
246
- `Fix: reload Browser MCP extension (chrome://extensions/ → ↻) or restart Chrome.`
247
- );
248
- } catch (e) {
249
- if (e.message?.includes('Already attached')) {
250
- // Chrome side has session sync local cache
251
- debuggerAttached.add(tabId);
252
- return;
253
- }
254
- if (e.message?.includes('Cannot attach') || e.message?.includes('canceled') || e.message?.includes('GHOST_ATTACH') || e.message?.includes('Debugger detached')) {
255
- throw new Error(
256
- `Debugger detached (BLOCKED_BY_USER): Cannot attach debugger to tab ${tabId}. ` +
257
- `Chrome blocks debugger attach — user likely clicked "Cancel" on debugger banner earlier this session. ` +
258
- `Fix: chrome://extensions/ → Browser MCP → reload (↻) icon. Or restart Chrome. ` +
259
- `Original error: ${e.message}`
260
- );
269
+ // Up to 3 attempts. A "ghost attach" (attach resolves but getTargets shows the tab
270
+ // NOT attached) is usually TRANSIENT: the page is mid-navigation/reload — e.g. the
271
+ // Metro dev-server rebuilding localhost:8081 auto-detaches the debugger. Retrying
272
+ // after a short delay lets the reload settle. Only a ghost that survives all retries
273
+ // is a real user-canceled banner. (Previously we threw on the first ghost, which made
274
+ // dev-server URLs unusable during their initial bundle.)
275
+ let lastMsg = '';
276
+ for (let attempt = 0; attempt < 3; attempt++) {
277
+ try {
278
+ await chrome.debugger.attach({ tabId }, '1.3');
279
+ if (await verifyAttachedWithChrome(tabId)) {
280
+ debuggerAttached.add(tabId);
281
+ return;
282
+ }
283
+ // Ghost detach cleanly so the next attempt starts fresh, then retry.
284
+ lastMsg = 'attach resolved but Chrome shows tab not attached (ghost — page likely mid-reload)';
285
+ try { await chrome.debugger.detach({ tabId }); } catch {}
286
+ } catch (e) {
287
+ if (e.message?.includes('Already attached')) {
288
+ // Chrome side has session — sync local cache
289
+ debuggerAttached.add(tabId);
290
+ return;
291
+ }
292
+ // "Cannot attach"/"canceled" can also be transient during navigation — retry too.
293
+ lastMsg = e.message || String(e);
261
294
  }
262
- throw e;
295
+ if (attempt < 2) await new Promise(r => setTimeout(r, 250 + attempt * 250));
263
296
  }
297
+ throw new Error(
298
+ `Debugger attach failed after 3 attempts (tab ${tabId}). Last: ${lastMsg}. ` +
299
+ `If persistent: the page may be continuously reloading (dev-server mid-build — wait, then retry), ` +
300
+ `or the user canceled Chrome's debugger banner — reload Browser MCP (chrome://extensions/ → ↻) or restart Chrome.`
301
+ );
264
302
  }
265
303
 
266
304
  async function debuggerDetach(tabId) {
@@ -409,6 +447,20 @@ async function debuggerClick(tabId, x, y) {
409
447
  el = inner; host = inner;
410
448
  }
411
449
  window.__bmcpClickTarget = el || null;
450
+ // FIX-13: watch whether the trusted click (step 2) actually lands on the target,
451
+ // so step 3's framework-fallback does NOT double-fire on elements that stay
452
+ // connected (toggles, checkboxes, add-to-cart, form fields).
453
+ window.__bmcpClicked = false;
454
+ try { window.__bmcpClickListener && document.removeEventListener('click', window.__bmcpClickListener, true); } catch (e) {}
455
+ window.__bmcpClickListener = (ev) => {
456
+ try {
457
+ const t = ev.target;
458
+ if (el && (t === el || el.contains(t) || (ev.composedPath && ev.composedPath().includes(el)))) {
459
+ window.__bmcpClicked = true;
460
+ }
461
+ } catch (e) {}
462
+ };
463
+ document.addEventListener('click', window.__bmcpClickListener, true);
412
464
  })()`,
413
465
  });
414
466
  // 1. mouseMoved first (triggers hover state, required by some frameworks)
@@ -435,7 +487,10 @@ async function debuggerClick(tabId, x, y) {
435
487
  await cdpSend(tabId, 'Runtime.evaluate', {
436
488
  expression: `(() => {
437
489
  const el = window.__bmcpClickTarget;
438
- try { delete window.__bmcpClickTarget; } catch (e) {}
490
+ const landed = window.__bmcpClicked === true;
491
+ try { window.__bmcpClickListener && document.removeEventListener('click', window.__bmcpClickListener, true); } catch (e) {}
492
+ try { delete window.__bmcpClickTarget; delete window.__bmcpClicked; delete window.__bmcpClickListener; } catch (e) {}
493
+ if (landed) return; // FIX-13: trusted click already landed — do NOT double-fire
439
494
  if (!el || !el.isConnected) return; // already navigated/handled — don't double-fire
440
495
  const opts = { bubbles: true, cancelable: true, composed: true, view: window, clientX: ${x}, clientY: ${y} };
441
496
  try { el.dispatchEvent(new PointerEvent('pointerdown', opts)); } catch (e) {}
@@ -515,9 +570,9 @@ async function debuggerFill(tabId, selector, value) {
515
570
  await debuggerFocus(tabId, selector);
516
571
  await debuggerAttach(tabId);
517
572
  try {
518
- // Ctrl+A to select all, then Backspace to clear
573
+ // Select-all (Cmd+A on macOS, Ctrl+A elsewhere), then Backspace to clear
519
574
  await cdpSend(tabId, 'Input.dispatchKeyEvent', {
520
- type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 2,
575
+ type: 'keyDown', key: 'a', code: 'KeyA', modifiers: SELECT_ALL_MODS,
521
576
  });
522
577
  await cdpSend(tabId, 'Input.dispatchKeyEvent', {
523
578
  type: 'keyUp', key: 'a', code: 'KeyA',
@@ -617,10 +672,20 @@ async function safeExecuteScript(tabId, func, args = [], world = 'MAIN') {
617
672
 
618
673
  function buildTextFinderJS(textPattern, tagFilter) {
619
674
  const escaped = JSON.stringify(textPattern);
620
- const tagCheck = tagFilter ? `&& el.tagName === ${JSON.stringify(tagFilter.toUpperCase())}` : '';
675
+ const wantTag = tagFilter ? JSON.stringify(tagFilter.toUpperCase()) : 'null';
621
676
  return `(function() {
622
677
  const text = ${escaped};
623
- // Collect all elements including inside shadow DOM
678
+ const wantTag = ${wantTag};
679
+ // Interactive controls we prefer to actually click. Fixes the class of bug where a
680
+ // text match lands on a large CONTAINER (e.g. Angular Material <mat-nav-list>,
681
+ // toolbar, list-item) whose center is NOT over the real <button> — so the trusted
682
+ // click misses and menus/dropdowns never open.
683
+ const CLICKABLE = 'a,button,summary,label,[role="button"],[role="menuitem"],' +
684
+ '[role="menuitemcheckbox"],[role="menuitemradio"],[role="option"],[role="tab"],' +
685
+ '[role="link"],[role="checkbox"],[role="radio"],[role="switch"],[onclick],' +
686
+ '[mat-button],[mat-raised-button],[mat-stroked-button],[mat-flat-button],' +
687
+ '[mat-icon-button],[mat-fab],[mat-mini-fab],[mat-menu-item],[mat-list-item],' +
688
+ 'mat-checkbox,mat-slide-toggle,mat-radio-button';
624
689
  function collectAll(root, results) {
625
690
  for (const el of root.querySelectorAll('*')) {
626
691
  results.push(el);
@@ -629,23 +694,34 @@ function buildTextFinderJS(textPattern, tagFilter) {
629
694
  return results;
630
695
  }
631
696
  const all = collectAll(document, []);
632
- // Exact match first (prefer leaf nodes)
633
- for (const el of all) {
634
- if (el.children.length > 3) continue;
635
- const t = el.textContent?.trim();
636
- if (t === text ${tagCheck}) {
637
- return el;
638
- }
639
- }
640
- // Partial match fallback
641
- for (const el of all) {
642
- if (el.children.length > 3) continue;
643
- const t = el.textContent?.trim();
644
- if (t && t.includes(text) ${tagCheck}) {
645
- return el;
697
+ const tagOk = (el) => !wantTag || el.tagName === wantTag;
698
+ // Map a matched element to the ACTIONABLE control: itself if clickable, else the
699
+ // nearest clickable ancestor (only if its own text isn't much larger than the match,
700
+ // so we don't grab a whole toolbar), else a clickable descendant.
701
+ function toClickable(el) {
702
+ if (el.matches && el.matches(CLICKABLE)) return el;
703
+ const anc = el.closest && el.closest(CLICKABLE);
704
+ if (anc && (anc.textContent || '').trim().length <= text.length + 40) return anc;
705
+ const desc = el.querySelector && el.querySelector(CLICKABLE);
706
+ if (desc) return desc;
707
+ return el;
708
+ }
709
+ function pick(test) {
710
+ const matches = all.filter(el => tagOk(el) && test((el.textContent || '').trim()));
711
+ if (!matches.length) return null;
712
+ // Prefer the INNERMOST matches (an element that is not an ancestor of another
713
+ // match) — this is what "prefer leaf nodes" was supposed to do.
714
+ const inner = matches.filter(el => !matches.some(o => o !== el && el.contains && el.contains(o)));
715
+ const pool = inner.length ? inner : matches;
716
+ // Prefer a match that resolves to a real interactive control.
717
+ for (const el of pool) {
718
+ const c = toClickable(el);
719
+ if (c && c.matches && c.matches(CLICKABLE)) return c;
646
720
  }
721
+ return toClickable(pool[0]);
647
722
  }
648
- return null;
723
+ // Exact match first, then partial fallback.
724
+ return pick(t => t === text) || pick(t => t && t.includes(text));
649
725
  })()`;
650
726
  }
651
727
 
@@ -771,7 +847,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
771
847
  dispatch(port, msg.method, msg.params)
772
848
  .then(result => sendResponse(result))
773
849
  .catch(err => sendResponse({ __error: err.message || String(err) }));
774
- });
850
+ }).catch(err => sendResponse({ __error: err.message || String(err) })); // else a storage-restore reject hangs the caller
775
851
  return true; // async response
776
852
  }
777
853
 
@@ -829,22 +905,6 @@ chrome.tabs.onCreated.addListener(async (tab) => {
829
905
  }
830
906
  });
831
907
 
832
- // ── CAPTCHA Detection ────────────────────────────────────────────────────────
833
-
834
- async function detectCaptcha(tabId) {
835
- try {
836
- return await debuggerEval(tabId, `
837
- (function() {
838
- if (document.querySelector('iframe[src*="hcaptcha.com"]') || document.querySelector('.h-captcha')) return 'hcaptcha';
839
- if (document.querySelector('iframe[src*="recaptcha"]') || document.querySelector('.g-recaptcha')) return 'recaptcha';
840
- if (document.querySelector('iframe[src*="challenges.cloudflare.com"]') || document.querySelector('.cf-turnstile')) return 'turnstile';
841
- if (document.documentElement.innerHTML.includes('challenge-platform')) return 'challenge';
842
- return null;
843
- })()
844
- `);
845
- } catch { return null; }
846
- }
847
-
848
908
  // ── Deep Shadow DOM Query ────────────────────────────────────────────────────
849
909
  // querySelectorDeep: finds elements inside shadow DOMs (Shopify, Salesforce, etc.)
850
910
 
@@ -995,7 +1055,7 @@ async function setDateMaskedTyping(tabId, selector, iso, format) {
995
1055
  await debuggerAttach(tabId);
996
1056
  try {
997
1057
  await cdpSend(tabId, 'Input.dispatchKeyEvent', {
998
- type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 2,
1058
+ type: 'keyDown', key: 'a', code: 'KeyA', modifiers: SELECT_ALL_MODS,
999
1059
  });
1000
1060
  await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1001
1061
  type: 'keyUp', key: 'a', code: 'KeyA',
@@ -1039,23 +1099,6 @@ async function isPickerOpen(tabId) {
1039
1099
  })()`);
1040
1100
  }
1041
1101
 
1042
- async function getPickerRoot(tabId) {
1043
- return await debuggerEval(tabId, `(() => {
1044
- const sels = ${JSON.stringify(PICKER_OPEN_SELECTORS)};
1045
- for (const s of sels) {
1046
- try {
1047
- const el = document.querySelector(s);
1048
- if (el) {
1049
- const root = el.closest('[role="dialog"], .react-datepicker, .MuiPickersPopper-root, .ant-picker-dropdown') || el;
1050
- // Return a stable selector path — for runtime use we re-query each time
1051
- return true;
1052
- }
1053
- } catch {}
1054
- }
1055
- return false;
1056
- })()`);
1057
- }
1058
-
1059
1102
  async function setDatePicker(tabId, selector, iso) {
1060
1103
  const [yStr, mStr, dStr] = iso.split('-');
1061
1104
  const targetYear = parseInt(yStr, 10);
@@ -1712,26 +1755,61 @@ async function dispatch(port, method, params) {
1712
1755
  }
1713
1756
 
1714
1757
  case 'screenshot': {
1715
- const tab = await getSessionTab(port);
1716
- if (tab.url.startsWith('chrome://')) throw new Error('Cannot screenshot chrome:// pages');
1717
- // Use debugger Page.captureScreenshot as PRIMARY method.
1718
- // captureVisibleTab requires active tab in active window — fails when
1719
- // user is in terminal. Debugger works regardless of tab focus.
1720
- try {
1721
- await debuggerAttach(tab.id);
1722
- const { data } = await cdpSend(tab.id, 'Page.captureScreenshot', {
1723
- format: 'png',
1724
- });
1725
- return { image: 'data:image/png;base64,' + data };
1726
- } catch {
1727
- // Debugger failed — fall back to captureVisibleTab (needs active tab)
1758
+ // getSessionTab(…, true) is focus-NEUTRAL now: it un-minimizes + activates the tab
1759
+ // but does NOT steal window focus (FIX-1). Screenshots run constantly, so the common
1760
+ // path must never yank Chrome to the foreground.
1761
+ const tab = await getSessionTab(port, true);
1762
+ if (tab.url.startsWith('chrome://') || tab.url.startsWith('about:')) {
1763
+ throw new Error(`Cannot screenshot ${tab.url.split(':')[0]}: pages — navigate to a real page first`);
1764
+ }
1765
+ // Capture without stealing focus: CDP Page.captureScreenshot (default → fromSurface:false
1766
+ // retry) works for background/visible tabs; captureVisibleTab is the secondary.
1767
+ const tryCapture = async () => {
1728
1768
  try {
1729
- await chrome.tabs.update(tab.id, { active: true });
1730
- await new Promise(r => setTimeout(r, 150));
1731
- const dataUrl = await chrome.tabs.captureVisibleTab(null, { format: 'png' });
1769
+ await debuggerAttach(tab.id);
1770
+ try {
1771
+ const shot = await cdpSend(tab.id, 'Page.captureScreenshot', { format: 'png' });
1772
+ return { image: 'data:image/png;base64,' + shot.data };
1773
+ } catch {
1774
+ const shot = await cdpSend(tab.id, 'Page.captureScreenshot', {
1775
+ format: 'png', fromSurface: false, captureBeyondViewport: false,
1776
+ });
1777
+ return { image: 'data:image/png;base64,' + shot.data };
1778
+ }
1779
+ } catch {
1780
+ // CDP failed entirely — native tabs API (needs the tab visible in its window).
1781
+ const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
1732
1782
  return { image: dataUrl };
1733
- } catch (e) {
1734
- throw new Error('Screenshot failed: ' + e.message);
1783
+ }
1784
+ };
1785
+
1786
+ // Attempt 1 — focus-neutral. Handles the vast majority (background-but-visible window).
1787
+ try {
1788
+ return await tryCapture();
1789
+ } catch (firstErr) {
1790
+ // Both methods failed → the window is genuinely OCCLUDED (covered by other windows),
1791
+ // so Chrome's compositor produced no frames. LAST RESORT ONLY: raise the window to
1792
+ // de-occlude it, capture, then RESTORE the user's previously-focused window. This
1793
+ // focus-steal happens ONLY in the rare covered case — never on a normal screenshot.
1794
+ const prev = await chrome.windows.getLastFocused().catch(() => null);
1795
+ try {
1796
+ await chrome.windows.update(tab.windowId, { focused: true, state: 'normal' });
1797
+ await chrome.tabs.update(tab.id, { active: true }).catch(() => {});
1798
+ await new Promise(r => setTimeout(r, 250)); // let it composite
1799
+ return await tryCapture();
1800
+ } catch (secondErr) {
1801
+ throw new Error(
1802
+ `Screenshot failed after focus-neutral AND raised attempts. ` +
1803
+ `First: ${firstErr?.message || firstErr}. Raised: ${secondErr?.message || secondErr}. ` +
1804
+ `If both say "image readback failed" the GPU compositor is not producing frames — ` +
1805
+ `disable Chrome hardware acceleration (chrome://settings/system) as a last resort.`
1806
+ );
1807
+ } finally {
1808
+ // Give focus back to the user's previous Chrome window (best-effort; getLastFocused
1809
+ // only sees Chrome windows, so a non-Chrome IDE can't be re-focused programmatically).
1810
+ if (prev && prev.id != null && prev.id !== tab.windowId) {
1811
+ await chrome.windows.update(prev.id, { focused: true }).catch(() => {});
1812
+ }
1735
1813
  }
1736
1814
  }
1737
1815
  }
@@ -1797,13 +1875,50 @@ async function dispatch(port, method, params) {
1797
1875
  diag.main_throw = String(e?.message || e);
1798
1876
  }
1799
1877
 
1800
- // Step 3: DIAG MODEdon't fallback to debugger, return scripting-state info instead
1801
- return {
1802
- result: '__SCRIPTING_FAILED__',
1803
- method: 'scripting-failed',
1804
- diag,
1805
- note: 'Both ISOLATED + MAIN scripting paths returned null/error. See diag for details.',
1806
- };
1878
+ // Step 3: debugger fallbackthe ONLY universal path for arbitrary STRING code
1879
+ // (both scripting worlds block `new Function`: ISOLATED via MV3 extension-CSP,
1880
+ // MAIN via the page's own unsafe-eval CSP). CDP Runtime.evaluate bypasses CSP.
1881
+ // FIX (2026-07-16): retry on an EMPTY/undefined CDP response. On some pages the
1882
+ // debugger auto-detaches mid-command and `chrome.debugger.sendCommand` RESOLVES
1883
+ // with `undefined` instead of rejecting, so cdpSend's throw-based retry never
1884
+ // fires and debuggerEval silently returned undefined → the caller saw a bare
1885
+ // `{method:"debugger"}` with no result. Also surface script exceptions + raw
1886
+ // diagnostics so a genuine failure is never mistaken for an empty success.
1887
+ let rawDbg, dbgErr = '';
1888
+ for (let attempt = 0; attempt < 4; attempt++) {
1889
+ try {
1890
+ await debuggerAttach(tab.id);
1891
+ rawDbg = await cdpSend(tab.id, 'Runtime.evaluate', {
1892
+ expression: '(' + params.code + '\n)',
1893
+ returnByValue: true,
1894
+ awaitPromise: true,
1895
+ });
1896
+ if (rawDbg && rawDbg.exceptionDetails) {
1897
+ const ex = rawDbg.exceptionDetails;
1898
+ await debuggerDetach(tab.id).catch(() => {});
1899
+ throw new Error('__SCRIPT_EX__' + (ex.exception?.description || ex.text || 'Script exception'));
1900
+ }
1901
+ if (rawDbg && rawDbg.result && rawDbg.result.type !== 'undefined') {
1902
+ await debuggerDetach(tab.id).catch(() => {});
1903
+ return { result: rawDbg.result.value, method: 'debugger' };
1904
+ }
1905
+ dbgErr = 'empty/undefined CDP response: ' + JSON.stringify(rawDbg);
1906
+ } catch (e) {
1907
+ const m = String(e?.message || e);
1908
+ if (m.startsWith('__SCRIPT_EX__')) {
1909
+ throw new Error(m.slice('__SCRIPT_EX__'.length) + ' | scripting-diag: ' + JSON.stringify(diag));
1910
+ }
1911
+ dbgErr = m;
1912
+ if (!/detach|attach|empty|gone|given id|not attached/i.test(m)) break;
1913
+ }
1914
+ await debuggerDetach(tab.id).catch(() => {});
1915
+ await new Promise(r => setTimeout(r, 200 + attempt * 200));
1916
+ }
1917
+ throw new Error(
1918
+ 'execute_script failed on all paths. debugger: ' + dbgErr +
1919
+ ' | raw: ' + JSON.stringify(rawDbg) +
1920
+ ' | scripting-diag: ' + JSON.stringify(diag)
1921
+ );
1807
1922
  }
1808
1923
 
1809
1924
  case 'click': {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Agent360 Browser MCP",
4
- "version": "1.23.0",
4
+ "version": "1.24.0",
5
5
  "description": "Control your real Chrome from Claude Code — navigate, click, fill, screenshot, solve CAPTCHAs. 34 tools, multi-session.",
6
6
  "permissions": [
7
7
  "tabs",
package/index.js CHANGED
@@ -209,7 +209,7 @@ const INSTRUCTIONS = `You control the user's real Chrome browser via this MCP se
209
209
  ## CAPTCHA handling
210
210
  Use browser_solve_captcha to detect and solve CAPTCHAs automatically:
211
211
  1. Call browser_solve_captcha() — detects CAPTCHA type on page
212
- 2. If reCAPTCHA v2 checkbox found → call browser_solve_captcha(action="click_checkbox") — auto-clicks, passes ~80% with logged-in Google
212
+ 2. If reCAPTCHA v2 checkbox found → call browser_solve_captcha(action="click_checkbox") — auto-clicks; often passes when signed into Google
213
213
  3. If image challenge appears → call browser_screenshot, analyze the grid visually, then call browser_solve_captcha(action="click_grid", cells=[2,5,7]) with the correct cell indices
214
214
  4. If all else fails → call browser_solve_captcha(action="ask_human") to show overlay to user
215
215
  5. After solving, retry the action that was blocked
@@ -397,7 +397,7 @@ function handleAbout(args) {
397
397
  }
398
398
 
399
399
  async function handleExtractToken(args) {
400
- const { provider, store_in_vault } = args;
400
+ const { provider } = args;
401
401
  const info = PROVIDER_PAGES[provider];
402
402
 
403
403
  if (!info) {
@@ -410,18 +410,11 @@ async function handleExtractToken(args) {
410
410
  }
411
411
 
412
412
  const nav = await sendToExtension('navigate', { url: info.url });
413
- const content = [
414
- { type: 'text', text: `Navigated to ${info.url} (${nav.title})\n\nInstructions: ${info.instructions}\n\nUse browser_get_page_content or browser_screenshot to find the token, then use browser_execute_script to extract it.` },
415
- ];
416
-
417
- if (store_in_vault) {
418
- content.push({
419
- type: 'text',
420
- text: `\nWhen you have the token, POST it to the vault:\ncurl -X POST http://localhost:8000/v1/vault/connect -H "Authorization: Bearer {jwt}" -d '{"provider":"${provider}","token":"{extracted_token}"}'`,
421
- });
422
- }
423
-
424
- return { content };
413
+ return {
414
+ content: [
415
+ { type: 'text', text: `Navigated to ${info.url} (${nav.title})\n\nInstructions: ${info.instructions}\n\nUse browser_get_page_content or browser_screenshot to find the token, then use browser_execute_script to extract it.` },
416
+ ],
417
+ };
425
418
  }
426
419
 
427
420
  // ── Graceful shutdown ──────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent360/browser-mcp",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "description": "Browser MCP — control your real Chrome from Claude Code. 34 tools, CAPTCHA solving, date pickers, autocomplete combobox, overlay dismissal, file upload, multi-session, human-in-the-loop.",
5
5
  "mcpName": "io.github.Agent360dk/browser-mcp",
6
6
  "type": "module",
@@ -42,7 +42,11 @@
42
42
  "engines": {
43
43
  "node": ">=18"
44
44
  },
45
- "author": "Agent360 <hello@agent360.dk>",
45
+ "author": {
46
+ "name": "Agent360",
47
+ "email": "hello@agent360.dk",
48
+ "url": "https://agent360.dk"
49
+ },
46
50
  "license": "MIT",
47
51
  "homepage": "https://browsermcp.dev",
48
52
  "repository": {
package/tools.js CHANGED
@@ -378,19 +378,18 @@ export const TOOLS = [
378
378
  },
379
379
  {
380
380
  name: 'browser_extract_token',
381
- description: 'Navigate to a provider\'s API settings page and extract the API token. Optionally store it in Agent360 vault.',
381
+ description: 'Navigate to a provider\'s API settings page so you can read its API token from the page.',
382
382
  inputSchema: {
383
383
  type: 'object',
384
384
  properties: {
385
385
  provider: { type: 'string', description: 'Provider slug (stripe, hubspot, slack, etc.)' },
386
- store_in_vault: { type: 'boolean', description: 'If true, POST token to Agent360 vault API' },
387
386
  },
388
387
  required: ['provider'],
389
388
  },
390
389
  },
391
390
  {
392
391
  name: 'browser_solve_captcha',
393
- description: 'Detect and solve CAPTCHAs on the current page. Auto-detects reCAPTCHA v2/v3, hCaptcha, Cloudflare Turnstile, and FunCaptcha. Tries auto-click first (works ~80% with logged-in Google), then returns a screenshot for AI vision analysis, then falls back to asking the user. Returns detection info and solving status.',
392
+ description: 'Detect and solve CAPTCHAs on the current page. Auto-detects reCAPTCHA v2/v3, hCaptcha, Cloudflare Turnstile, and FunCaptcha. Tries auto-click first (often clears reCAPTCHA v2 when signed into Google), then returns a screenshot for AI vision analysis, then falls back to asking the user. Returns detection info and solving status.',
394
393
  inputSchema: {
395
394
  type: 'object',
396
395
  properties: {