@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 +11 -5
- package/extension/background.js +266 -151
- package/extension/manifest.json +1 -1
- package/index.js +7 -14
- package/package.json +6 -2
- package/tools.js +2 -3
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
|
[](https://www.npmjs.com/package/@agent360/browser-mcp)
|
|
4
4
|
[](https://www.npmjs.com/package/@agent360/browser-mcp)
|
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
[](https://modelcontextprotocol.io)
|
|
8
8
|
[](https://chromewebstore.google.com/detail/agent360-browser-mcp/jdehgalffmffhfhmmhaokfbfnafnmgcl)
|
|
9
9
|
|
|
10
|
-
**
|
|
10
|
+
**Your AI agent drives your real, logged-in Chrome — and works where headless tools die.**
|
|
11
11
|
|
|
12
|
-

|
|
12
|
+
[](https://browsermcp.dev)
|
|
13
13
|
|
|
14
|
-
|
|
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,
|
|
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 |
|
package/extension/background.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
675
|
+
const wantTag = tagFilter ? JSON.stringify(tagFilter.toUpperCase()) : 'null';
|
|
621
676
|
return `(function() {
|
|
622
677
|
const text = ${escaped};
|
|
623
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1716
|
-
|
|
1717
|
-
//
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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
|
|
1730
|
-
|
|
1731
|
-
|
|
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
|
-
}
|
|
1734
|
-
|
|
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:
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1878
|
+
// Step 3: debugger fallback — the 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': {
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Agent360 Browser MCP",
|
|
4
|
-
"version": "1.
|
|
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
|
|
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
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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.
|
|
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":
|
|
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
|
|
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 (
|
|
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: {
|