@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 +12 -6
- package/extension/background.js +370 -184
- 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
|
|
|
@@ -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.
|
|
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,
|
|
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) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
628
|
+
// Standard input/textarea — focus, clear, fill
|
|
515
629
|
await debuggerFocus(tabId, selector);
|
|
516
630
|
await debuggerAttach(tabId);
|
|
517
631
|
try {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
await cdpSend(tabId, 'Input.
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
|
746
|
+
const wantTag = tagFilter ? JSON.stringify(tagFilter.toUpperCase()) : 'null';
|
|
621
747
|
return `(function() {
|
|
622
748
|
const text = ${escaped};
|
|
623
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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)
|
|
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
|
|
1730
|
-
|
|
1731
|
-
|
|
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
|
-
}
|
|
1734
|
-
|
|
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:
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1949
|
+
// Step 3: debugger fallback — the 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': {
|
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.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
|
|
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.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":
|
|
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: {
|