@agent360/browser-mcp 1.23.0 → 1.25.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
 
@@ -42,7 +48,7 @@ That's it. The Browser MCP icon will appear in your toolbar, and 34 browser tool
42
48
 
43
49
  If you don't want to use npm, download the extension directly:
44
50
 
45
- 1. [Download `browser-mcp-v1.23.0.zip`](https://github.com/Agent360dk/browser-mcp/releases/latest) from the latest GitHub release
51
+ 1. [Download `browser-mcp-v1.25.0.zip`](https://github.com/Agent360dk/browser-mcp/releases/latest) from the latest GitHub release
46
52
  2. Unzip the file (anywhere — e.g. `~/Downloads/browser-mcp-extension/`)
47
53
  3. Follow Step 2 above, but select the unzipped folder instead of `~/.browser-mcp/extension/`
48
54
  4. Configure Claude Code manually by adding this to your `~/.claude.json` (or run `npx @agent360/browser-mcp install --skip-extension`):
@@ -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) {
@@ -361,29 +399,62 @@ chrome.tabs.onRemoved.addListener((tabId) => {
361
399
  }
362
400
  });
363
401
 
402
+ // Physical-key `code` for a character, US layout. We used to build this as
403
+ // `Key${char.toUpperCase()}`, which is only correct for letters: "1" became "Key1",
404
+ // "@" became "Key@", " " became "Key ". Frameworks that branch on event.code —
405
+ // masked inputs, shortcut handlers, several React form libraries — see an unknown
406
+ // code and drop the keystroke, so typing an email or URL misbehaved on strict SPAs.
407
+ // Shifted symbols report the code of the physical key they sit on ("@" is Digit2).
408
+ const CDP_CHAR_CODES = {
409
+ ' ': 'Space', '\n': 'Enter', '\t': 'Tab',
410
+ '-': 'Minus', '_': 'Minus', '=': 'Equal', '+': 'Equal',
411
+ '[': 'BracketLeft', '{': 'BracketLeft', ']': 'BracketRight', '}': 'BracketRight',
412
+ '\\': 'Backslash', '|': 'Backslash', ';': 'Semicolon', ':': 'Semicolon',
413
+ "'": 'Quote', '"': 'Quote', ',': 'Comma', '<': 'Comma',
414
+ '.': 'Period', '>': 'Period', '/': 'Slash', '?': 'Slash',
415
+ '`': 'Backquote', '~': 'Backquote',
416
+ '!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4', '%': 'Digit5',
417
+ '^': 'Digit6', '&': 'Digit7', '*': 'Digit8', '(': 'Digit9', ')': 'Digit0',
418
+ };
419
+ function cdpCodeForChar(ch) {
420
+ if (ch >= 'a' && ch <= 'z') return `Key${ch.toUpperCase()}`;
421
+ if (ch >= 'A' && ch <= 'Z') return `Key${ch}`;
422
+ if (ch >= '0' && ch <= '9') return `Digit${ch}`;
423
+ // Unknown (accented letters, CJK, emoji): omit it. CDP accepts a missing code,
424
+ // and an omitted code is honest where a fabricated one is actively misleading.
425
+ return CDP_CHAR_CODES[ch] || '';
426
+ }
427
+
428
+ // Types text as individual key events. Assumes the debugger is already attached —
429
+ // debuggerType() is the public wrapper that manages attach/detach.
430
+ async function typeCharsAttached(tabId, text) {
431
+ for (let i = 0; i < text.length; i++) {
432
+ const char = text[i];
433
+ const code = cdpCodeForChar(char);
434
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
435
+ type: 'keyDown',
436
+ text: char,
437
+ key: char,
438
+ ...(code ? { code } : {}),
439
+ unmodifiedText: char,
440
+ });
441
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
442
+ type: 'keyUp',
443
+ key: char,
444
+ ...(code ? { code } : {}),
445
+ });
446
+ // Human-like typing: random 30-120ms, occasional longer pause
447
+ const pause = (i > 0 && i % (7 + Math.floor(Math.random() * 5)) === 0)
448
+ ? 150 + Math.random() * 200 // thinking pause every ~10 chars
449
+ : 30 + Math.random() * 90; // normal keystroke
450
+ await new Promise(r => setTimeout(r, pause));
451
+ }
452
+ }
453
+
364
454
  async function debuggerType(tabId, text) {
365
455
  await debuggerAttach(tabId);
366
456
  try {
367
- for (let i = 0; i < text.length; i++) {
368
- const char = text[i];
369
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
370
- type: 'keyDown',
371
- text: char,
372
- key: char,
373
- code: `Key${char.toUpperCase()}`,
374
- unmodifiedText: char,
375
- });
376
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
377
- type: 'keyUp',
378
- key: char,
379
- code: `Key${char.toUpperCase()}`,
380
- });
381
- // Human-like typing: random 30-120ms, occasional longer pause
382
- const pause = (i > 0 && i % (7 + Math.floor(Math.random() * 5)) === 0)
383
- ? 150 + Math.random() * 200 // thinking pause every ~10 chars
384
- : 30 + Math.random() * 90; // normal keystroke
385
- await new Promise(r => setTimeout(r, pause));
386
- }
457
+ await typeCharsAttached(tabId, text);
387
458
  } finally {
388
459
  await debuggerDetach(tabId);
389
460
  }
@@ -409,6 +480,20 @@ async function debuggerClick(tabId, x, y) {
409
480
  el = inner; host = inner;
410
481
  }
411
482
  window.__bmcpClickTarget = el || null;
483
+ // FIX-13: watch whether the trusted click (step 2) actually lands on the target,
484
+ // so step 3's framework-fallback does NOT double-fire on elements that stay
485
+ // connected (toggles, checkboxes, add-to-cart, form fields).
486
+ window.__bmcpClicked = false;
487
+ try { window.__bmcpClickListener && document.removeEventListener('click', window.__bmcpClickListener, true); } catch (e) {}
488
+ window.__bmcpClickListener = (ev) => {
489
+ try {
490
+ const t = ev.target;
491
+ if (el && (t === el || el.contains(t) || (ev.composedPath && ev.composedPath().includes(el)))) {
492
+ window.__bmcpClicked = true;
493
+ }
494
+ } catch (e) {}
495
+ };
496
+ document.addEventListener('click', window.__bmcpClickListener, true);
412
497
  })()`,
413
498
  });
414
499
  // 1. mouseMoved first (triggers hover state, required by some frameworks)
@@ -435,7 +520,10 @@ async function debuggerClick(tabId, x, y) {
435
520
  await cdpSend(tabId, 'Runtime.evaluate', {
436
521
  expression: `(() => {
437
522
  const el = window.__bmcpClickTarget;
438
- try { delete window.__bmcpClickTarget; } catch (e) {}
523
+ const landed = window.__bmcpClicked === true;
524
+ try { window.__bmcpClickListener && document.removeEventListener('click', window.__bmcpClickListener, true); } catch (e) {}
525
+ try { delete window.__bmcpClickTarget; delete window.__bmcpClicked; delete window.__bmcpClickListener; } catch (e) {}
526
+ if (landed) return; // FIX-13: trusted click already landed — do NOT double-fire
439
527
  if (!el || !el.isConnected) return; // already navigated/handled — don't double-fire
440
528
  const opts = { bubbles: true, cancelable: true, composed: true, view: window, clientX: ${x}, clientY: ${y} };
441
529
  try { el.dispatchEvent(new PointerEvent('pointerdown', opts)); } catch (e) {}
@@ -484,6 +572,32 @@ async function debuggerFocus(tabId, selector) {
484
572
  }
485
573
  }
486
574
 
575
+ // Runtime.evaluate that leaves attach state alone. debuggerEval() detaches in its
576
+ // finally, which would pull the debugger out from under a fill that is mid-flight.
577
+ async function evalAttached(tabId, expression) {
578
+ const result = await cdpSend(tabId, 'Runtime.evaluate', { expression, returnByValue: true });
579
+ if (result.exceptionDetails) {
580
+ throw new Error(result.exceptionDetails.text || 'Script execution failed');
581
+ }
582
+ return result.result?.value;
583
+ }
584
+
585
+ // Select-all + Backspace. Assumes the debugger is already attached.
586
+ async function clearFieldAttached(tabId) {
587
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
588
+ type: 'keyDown', key: 'a', code: 'KeyA', modifiers: SELECT_ALL_MODS,
589
+ });
590
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
591
+ type: 'keyUp', key: 'a', code: 'KeyA',
592
+ });
593
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
594
+ type: 'keyDown', key: 'Backspace', code: 'Backspace',
595
+ });
596
+ await cdpSend(tabId, 'Input.dispatchKeyEvent', {
597
+ type: 'keyUp', key: 'Backspace', code: 'Backspace',
598
+ });
599
+ }
600
+
487
601
  async function debuggerFill(tabId, selector, value) {
488
602
  // Check if element is contenteditable (rich text editors: LinkedIn, Slack)
489
603
  const isContentEditable = await debuggerEval(tabId, `
@@ -511,27 +625,39 @@ async function debuggerFill(tabId, selector, value) {
511
625
  return;
512
626
  }
513
627
 
514
- // Standard input/textarea — focus, clear, type
628
+ // Standard input/textarea — focus, clear, fill
515
629
  await debuggerFocus(tabId, selector);
516
630
  await debuggerAttach(tabId);
517
631
  try {
518
- // Ctrl+A to select all, then Backspace to clear
519
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
520
- type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 2,
521
- });
522
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
523
- type: 'keyUp', key: 'a', code: 'KeyA',
524
- });
525
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
526
- type: 'keyDown', key: 'Backspace', code: 'Backspace',
527
- });
528
- await cdpSend(tabId, 'Input.dispatchKeyEvent', {
529
- type: 'keyUp', key: 'Backspace', code: 'Backspace',
530
- });
632
+ await clearFieldAttached(tabId);
633
+
634
+ // Fast path: one trusted InputEvent instead of N key events. This is the same
635
+ // primitive set_combobox and set_date already rely on, it avoids per-key `code`
636
+ // mapping entirely, and it turns a 40-character value from ~3 seconds of
637
+ // keystrokes into a single call — which also shrinks the window in which the
638
+ // debugger can detach mid-fill.
639
+ await cdpSend(tabId, 'Input.insertText', { text: value });
640
+
641
+ // Verify something actually landed. Masked inputs, maxlength enforcement and
642
+ // autocompletes that filter per keydown can swallow an inserted string, and
643
+ // until now that failed silently: the caller got "ok" and the field stayed
644
+ // empty. Only an EMPTY field triggers the fallback — a field that transformed
645
+ // the text (phone/date masks reformatting it) did accept the input, and
646
+ // retyping it per character would produce the same transform for no gain.
647
+ const landed = await evalAttached(tabId, `
648
+ (function() {
649
+ const el = document.querySelector(${JSON.stringify(selector)});
650
+ if (!el) return null;
651
+ return ('value' in el) ? el.value : el.textContent;
652
+ })()
653
+ `);
654
+ if (!landed) {
655
+ await clearFieldAttached(tabId);
656
+ await typeCharsAttached(tabId, value);
657
+ }
531
658
  } finally {
532
659
  await debuggerDetach(tabId);
533
660
  }
534
- await debuggerType(tabId, value);
535
661
  }
536
662
 
537
663
  async function debuggerEval(tabId, expression) {
@@ -617,10 +743,20 @@ async function safeExecuteScript(tabId, func, args = [], world = 'MAIN') {
617
743
 
618
744
  function buildTextFinderJS(textPattern, tagFilter) {
619
745
  const escaped = JSON.stringify(textPattern);
620
- const tagCheck = tagFilter ? `&& el.tagName === ${JSON.stringify(tagFilter.toUpperCase())}` : '';
746
+ const wantTag = tagFilter ? JSON.stringify(tagFilter.toUpperCase()) : 'null';
621
747
  return `(function() {
622
748
  const text = ${escaped};
623
- // Collect all elements including inside shadow DOM
749
+ const wantTag = ${wantTag};
750
+ // Interactive controls we prefer to actually click. Fixes the class of bug where a
751
+ // text match lands on a large CONTAINER (e.g. Angular Material <mat-nav-list>,
752
+ // toolbar, list-item) whose center is NOT over the real <button> — so the trusted
753
+ // click misses and menus/dropdowns never open.
754
+ const CLICKABLE = 'a,button,summary,label,[role="button"],[role="menuitem"],' +
755
+ '[role="menuitemcheckbox"],[role="menuitemradio"],[role="option"],[role="tab"],' +
756
+ '[role="link"],[role="checkbox"],[role="radio"],[role="switch"],[onclick],' +
757
+ '[mat-button],[mat-raised-button],[mat-stroked-button],[mat-flat-button],' +
758
+ '[mat-icon-button],[mat-fab],[mat-mini-fab],[mat-menu-item],[mat-list-item],' +
759
+ 'mat-checkbox,mat-slide-toggle,mat-radio-button';
624
760
  function collectAll(root, results) {
625
761
  for (const el of root.querySelectorAll('*')) {
626
762
  results.push(el);
@@ -629,23 +765,34 @@ function buildTextFinderJS(textPattern, tagFilter) {
629
765
  return results;
630
766
  }
631
767
  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;
768
+ const tagOk = (el) => !wantTag || el.tagName === wantTag;
769
+ // Map a matched element to the ACTIONABLE control: itself if clickable, else the
770
+ // nearest clickable ancestor (only if its own text isn't much larger than the match,
771
+ // so we don't grab a whole toolbar), else a clickable descendant.
772
+ function toClickable(el) {
773
+ if (el.matches && el.matches(CLICKABLE)) return el;
774
+ const anc = el.closest && el.closest(CLICKABLE);
775
+ if (anc && (anc.textContent || '').trim().length <= text.length + 40) return anc;
776
+ const desc = el.querySelector && el.querySelector(CLICKABLE);
777
+ if (desc) return desc;
778
+ return el;
779
+ }
780
+ function pick(test) {
781
+ const matches = all.filter(el => tagOk(el) && test((el.textContent || '').trim()));
782
+ if (!matches.length) return null;
783
+ // Prefer the INNERMOST matches (an element that is not an ancestor of another
784
+ // match) — this is what "prefer leaf nodes" was supposed to do.
785
+ const inner = matches.filter(el => !matches.some(o => o !== el && el.contains && el.contains(o)));
786
+ const pool = inner.length ? inner : matches;
787
+ // Prefer a match that resolves to a real interactive control.
788
+ for (const el of pool) {
789
+ const c = toClickable(el);
790
+ if (c && c.matches && c.matches(CLICKABLE)) return c;
646
791
  }
792
+ return toClickable(pool[0]);
647
793
  }
648
- return null;
794
+ // Exact match first, then partial fallback.
795
+ return pick(t => t === text) || pick(t => t && t.includes(text));
649
796
  })()`;
650
797
  }
651
798
 
@@ -771,7 +918,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
771
918
  dispatch(port, msg.method, msg.params)
772
919
  .then(result => sendResponse(result))
773
920
  .catch(err => sendResponse({ __error: err.message || String(err) }));
774
- });
921
+ }).catch(err => sendResponse({ __error: err.message || String(err) })); // else a storage-restore reject hangs the caller
775
922
  return true; // async response
776
923
  }
777
924
 
@@ -829,22 +976,6 @@ chrome.tabs.onCreated.addListener(async (tab) => {
829
976
  }
830
977
  });
831
978
 
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
979
  // ── Deep Shadow DOM Query ────────────────────────────────────────────────────
849
980
  // querySelectorDeep: finds elements inside shadow DOMs (Shopify, Salesforce, etc.)
850
981
 
@@ -995,7 +1126,7 @@ async function setDateMaskedTyping(tabId, selector, iso, format) {
995
1126
  await debuggerAttach(tabId);
996
1127
  try {
997
1128
  await cdpSend(tabId, 'Input.dispatchKeyEvent', {
998
- type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 2,
1129
+ type: 'keyDown', key: 'a', code: 'KeyA', modifiers: SELECT_ALL_MODS,
999
1130
  });
1000
1131
  await cdpSend(tabId, 'Input.dispatchKeyEvent', {
1001
1132
  type: 'keyUp', key: 'a', code: 'KeyA',
@@ -1039,23 +1170,6 @@ async function isPickerOpen(tabId) {
1039
1170
  })()`);
1040
1171
  }
1041
1172
 
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
1173
  async function setDatePicker(tabId, selector, iso) {
1060
1174
  const [yStr, mStr, dStr] = iso.split('-');
1061
1175
  const targetYear = parseInt(yStr, 10);
@@ -1712,26 +1826,61 @@ async function dispatch(port, method, params) {
1712
1826
  }
1713
1827
 
1714
1828
  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)
1829
+ // getSessionTab(…, true) is focus-NEUTRAL now: it un-minimizes + activates the tab
1830
+ // but does NOT steal window focus (FIX-1). Screenshots run constantly, so the common
1831
+ // path must never yank Chrome to the foreground.
1832
+ const tab = await getSessionTab(port, true);
1833
+ if (tab.url.startsWith('chrome://') || tab.url.startsWith('about:')) {
1834
+ throw new Error(`Cannot screenshot ${tab.url.split(':')[0]}: pages — navigate to a real page first`);
1835
+ }
1836
+ // Capture without stealing focus: CDP Page.captureScreenshot (default → fromSurface:false
1837
+ // retry) works for background/visible tabs; captureVisibleTab is the secondary.
1838
+ const tryCapture = async () => {
1728
1839
  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' });
1840
+ await debuggerAttach(tab.id);
1841
+ try {
1842
+ const shot = await cdpSend(tab.id, 'Page.captureScreenshot', { format: 'png' });
1843
+ return { image: 'data:image/png;base64,' + shot.data };
1844
+ } catch {
1845
+ const shot = await cdpSend(tab.id, 'Page.captureScreenshot', {
1846
+ format: 'png', fromSurface: false, captureBeyondViewport: false,
1847
+ });
1848
+ return { image: 'data:image/png;base64,' + shot.data };
1849
+ }
1850
+ } catch {
1851
+ // CDP failed entirely — native tabs API (needs the tab visible in its window).
1852
+ const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
1732
1853
  return { image: dataUrl };
1733
- } catch (e) {
1734
- throw new Error('Screenshot failed: ' + e.message);
1854
+ }
1855
+ };
1856
+
1857
+ // Attempt 1 — focus-neutral. Handles the vast majority (background-but-visible window).
1858
+ try {
1859
+ return await tryCapture();
1860
+ } catch (firstErr) {
1861
+ // Both methods failed → the window is genuinely OCCLUDED (covered by other windows),
1862
+ // so Chrome's compositor produced no frames. LAST RESORT ONLY: raise the window to
1863
+ // de-occlude it, capture, then RESTORE the user's previously-focused window. This
1864
+ // focus-steal happens ONLY in the rare covered case — never on a normal screenshot.
1865
+ const prev = await chrome.windows.getLastFocused().catch(() => null);
1866
+ try {
1867
+ await chrome.windows.update(tab.windowId, { focused: true, state: 'normal' });
1868
+ await chrome.tabs.update(tab.id, { active: true }).catch(() => {});
1869
+ await new Promise(r => setTimeout(r, 250)); // let it composite
1870
+ return await tryCapture();
1871
+ } catch (secondErr) {
1872
+ throw new Error(
1873
+ `Screenshot failed after focus-neutral AND raised attempts. ` +
1874
+ `First: ${firstErr?.message || firstErr}. Raised: ${secondErr?.message || secondErr}. ` +
1875
+ `If both say "image readback failed" the GPU compositor is not producing frames — ` +
1876
+ `disable Chrome hardware acceleration (chrome://settings/system) as a last resort.`
1877
+ );
1878
+ } finally {
1879
+ // Give focus back to the user's previous Chrome window (best-effort; getLastFocused
1880
+ // only sees Chrome windows, so a non-Chrome IDE can't be re-focused programmatically).
1881
+ if (prev && prev.id != null && prev.id !== tab.windowId) {
1882
+ await chrome.windows.update(prev.id, { focused: true }).catch(() => {});
1883
+ }
1735
1884
  }
1736
1885
  }
1737
1886
  }
@@ -1797,13 +1946,50 @@ async function dispatch(port, method, params) {
1797
1946
  diag.main_throw = String(e?.message || e);
1798
1947
  }
1799
1948
 
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
- };
1949
+ // Step 3: debugger fallbackthe ONLY universal path for arbitrary STRING code
1950
+ // (both scripting worlds block `new Function`: ISOLATED via MV3 extension-CSP,
1951
+ // MAIN via the page's own unsafe-eval CSP). CDP Runtime.evaluate bypasses CSP.
1952
+ // FIX (2026-07-16): retry on an EMPTY/undefined CDP response. On some pages the
1953
+ // debugger auto-detaches mid-command and `chrome.debugger.sendCommand` RESOLVES
1954
+ // with `undefined` instead of rejecting, so cdpSend's throw-based retry never
1955
+ // fires and debuggerEval silently returned undefined → the caller saw a bare
1956
+ // `{method:"debugger"}` with no result. Also surface script exceptions + raw
1957
+ // diagnostics so a genuine failure is never mistaken for an empty success.
1958
+ let rawDbg, dbgErr = '';
1959
+ for (let attempt = 0; attempt < 4; attempt++) {
1960
+ try {
1961
+ await debuggerAttach(tab.id);
1962
+ rawDbg = await cdpSend(tab.id, 'Runtime.evaluate', {
1963
+ expression: '(' + params.code + '\n)',
1964
+ returnByValue: true,
1965
+ awaitPromise: true,
1966
+ });
1967
+ if (rawDbg && rawDbg.exceptionDetails) {
1968
+ const ex = rawDbg.exceptionDetails;
1969
+ await debuggerDetach(tab.id).catch(() => {});
1970
+ throw new Error('__SCRIPT_EX__' + (ex.exception?.description || ex.text || 'Script exception'));
1971
+ }
1972
+ if (rawDbg && rawDbg.result && rawDbg.result.type !== 'undefined') {
1973
+ await debuggerDetach(tab.id).catch(() => {});
1974
+ return { result: rawDbg.result.value, method: 'debugger' };
1975
+ }
1976
+ dbgErr = 'empty/undefined CDP response: ' + JSON.stringify(rawDbg);
1977
+ } catch (e) {
1978
+ const m = String(e?.message || e);
1979
+ if (m.startsWith('__SCRIPT_EX__')) {
1980
+ throw new Error(m.slice('__SCRIPT_EX__'.length) + ' | scripting-diag: ' + JSON.stringify(diag));
1981
+ }
1982
+ dbgErr = m;
1983
+ if (!/detach|attach|empty|gone|given id|not attached/i.test(m)) break;
1984
+ }
1985
+ await debuggerDetach(tab.id).catch(() => {});
1986
+ await new Promise(r => setTimeout(r, 200 + attempt * 200));
1987
+ }
1988
+ throw new Error(
1989
+ 'execute_script failed on all paths. debugger: ' + dbgErr +
1990
+ ' | raw: ' + JSON.stringify(rawDbg) +
1991
+ ' | scripting-diag: ' + JSON.stringify(diag)
1992
+ );
1807
1993
  }
1808
1994
 
1809
1995
  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.25.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.25.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: {