@agent360/browser-mcp 1.21.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 +87 -19
- package/bin/cli.js +6 -1
- package/extension/background.js +463 -165
- package/extension/manifest.json +2 -2
- package/index.js +69 -17
- package/package.json +7 -3
- package/tools.js +31 -5
package/README.md
CHANGED
|
@@ -1,32 +1,73 @@
|
|
|
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)
|
|
5
5
|
[](https://github.com/Agent360dk/browser-mcp)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
[](https://modelcontextprotocol.io)
|
|
8
|
+
[](https://chromewebstore.google.com/detail/agent360-browser-mcp/jdehgalffmffhfhmmhaokfbfnafnmgcl)
|
|
8
9
|
|
|
9
|
-
**
|
|
10
|
+
**Your AI agent drives your real, logged-in Chrome — and works where headless tools die.**
|
|
10
11
|
|
|
11
|
-

|
|
12
|
+
[](https://browsermcp.dev)
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
▶ **[Watch the 37-second demo with sound →](https://browsermcp.dev)**
|
|
14
15
|
|
|
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.**
|
|
21
|
+
|
|
22
|
+
## Install — 2 steps (~60 seconds)
|
|
23
|
+
|
|
24
|
+
### Step 1: Configure the MCP server
|
|
16
25
|
|
|
17
26
|
```bash
|
|
18
27
|
npx @agent360/browser-mcp install
|
|
19
28
|
```
|
|
20
29
|
|
|
21
|
-
This copies the Chrome extension to `~/.browser-mcp/extension/` and
|
|
30
|
+
This copies the Chrome extension files to `~/.browser-mcp/extension/` and adds the MCP server to your Claude Code config. **You'll see the path to the extension folder printed in the terminal — copy it.**
|
|
31
|
+
|
|
32
|
+
### Step 2: Load the extension in Chrome
|
|
33
|
+
|
|
34
|
+
> Chrome won't let extensions install themselves from npm — you load it manually one time. To **update** later, re-run the install command and reload it (see [Keeping it updated](#keeping-it-updated)). Prefer the [Chrome Web Store](#chrome-web-store-one-click-install) install if you'd rather have the extension auto-update.
|
|
35
|
+
|
|
36
|
+
1. **Open Chrome** and type `chrome://extensions` in the address bar
|
|
37
|
+
2. **Toggle "Developer mode"** ON (top right corner)
|
|
38
|
+
3. **Click "Load unpacked"** (top left, next to "Pack extension")
|
|
39
|
+
4. **Navigate to `~/.browser-mcp/extension/`** and click "Select"
|
|
40
|
+
- On Mac: Press `Cmd+Shift+G` in the file picker, paste `~/.browser-mcp/extension/`, press Enter
|
|
41
|
+
- On Windows: Paste `%USERPROFILE%\.browser-mcp\extension\` in the address bar
|
|
42
|
+
- On Linux: Type `~/.browser-mcp/extension/` in the path field
|
|
43
|
+
5. **Restart Claude Code** so it picks up the new MCP server
|
|
44
|
+
|
|
45
|
+
That's it. The Browser MCP icon will appear in your toolbar, and 34 browser tools are now available in Claude Code.
|
|
46
|
+
|
|
47
|
+
### Alternative: Manual zip download (no npm)
|
|
48
|
+
|
|
49
|
+
If you don't want to use npm, download the extension directly:
|
|
22
50
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
51
|
+
1. [Download `browser-mcp-v1.23.0.zip`](https://github.com/Agent360dk/browser-mcp/releases/latest) from the latest GitHub release
|
|
52
|
+
2. Unzip the file (anywhere — e.g. `~/Downloads/browser-mcp-extension/`)
|
|
53
|
+
3. Follow Step 2 above, but select the unzipped folder instead of `~/.browser-mcp/extension/`
|
|
54
|
+
4. Configure Claude Code manually by adding this to your `~/.claude.json` (or run `npx @agent360/browser-mcp install --skip-extension`):
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"browser-mcp": {
|
|
59
|
+
"command": "npx",
|
|
60
|
+
"args": ["@agent360/browser-mcp"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
28
65
|
|
|
29
|
-
|
|
66
|
+
### Chrome Web Store (one-click install)
|
|
67
|
+
|
|
68
|
+
[**Install from Chrome Web Store →**](https://chromewebstore.google.com/detail/agent360-browser-mcp/jdehgalffmffhfhmmhaokfbfnafnmgcl)
|
|
69
|
+
|
|
70
|
+
No Developer mode needed. Then run `npx @agent360/browser-mcp install --skip-extension` to configure Claude Code.
|
|
30
71
|
|
|
31
72
|
## Why This Over Playwright MCP / BrowserMCP?
|
|
32
73
|
|
|
@@ -43,7 +84,7 @@ That's it. 29 browser tools are now available in Claude Code.
|
|
|
43
84
|
| **Custom dropdowns** | Angular Material, React Select support | Works (headless) | Limited |
|
|
44
85
|
| **Install** | `npx @agent360/browser-mcp install` | `npx @anthropic-ai/mcp-playwright` | Manual clone |
|
|
45
86
|
|
|
46
|
-
##
|
|
87
|
+
## 34 Tools
|
|
47
88
|
|
|
48
89
|
### Navigation & Content
|
|
49
90
|
| Tool | Description |
|
|
@@ -63,7 +104,10 @@ That's it. 29 browser tools are now available in Claude Code.
|
|
|
63
104
|
| `browser_wait` | Wait for element to appear |
|
|
64
105
|
| `browser_hover` | Hover for tooltips/dropdowns |
|
|
65
106
|
| `browser_select_option` | Native `<select>` + custom dropdowns (Angular Material, React Select) |
|
|
66
|
-
| `
|
|
107
|
+
| `browser_set_combobox` | Autocomplete/combobox: type query → wait for filtered listbox → click option (multi-value chip support). Use when `browser_select_option` fails on lazy-rendered options |
|
|
108
|
+
| `browser_set_date` | Robust date inputs: tries native value-set → masked typing → calendar-picker navigation (MUI/AntD/react-datepicker/Lexical). Use when `browser_fill` fails on date fields |
|
|
109
|
+
| `browser_dismiss_overlays` | Bulk-dismiss popups/modals/tooltips/banners via aria-label/text/×-char heuristics. `non_critical` mode preserves dialogs with form data |
|
|
110
|
+
| `browser_handle_dialog` | Accept/dismiss native alert/confirm/prompt dialogs |
|
|
67
111
|
|
|
68
112
|
### Tabs & Frames
|
|
69
113
|
| Tool | Description |
|
|
@@ -87,7 +131,7 @@ That's it. 29 browser tools are now available in Claude Code.
|
|
|
87
131
|
### CAPTCHA Solving
|
|
88
132
|
| Tool | Description |
|
|
89
133
|
|------|-------------|
|
|
90
|
-
| `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) |
|
|
91
135
|
|
|
92
136
|
### Human-in-the-Loop
|
|
93
137
|
| Tool | Description |
|
|
@@ -103,6 +147,7 @@ That's it. 29 browser tools are now available in Claude Code.
|
|
|
103
147
|
| `browser_set_local_storage` | Write localStorage values |
|
|
104
148
|
| `browser_console_logs` | Capture console.log/warn/error messages from page |
|
|
105
149
|
| `browser_upload_file` | Upload files to `<input type="file">` via Chrome Debugger API (no dialog) |
|
|
150
|
+
| `browser_drop_file` | Upload via drop-zones: finds hidden `<input type="file">` in target subtree/parent (up to 2 levels). Use when `browser_upload_file` fails because the zone has no visible input |
|
|
106
151
|
|
|
107
152
|
## Multi-Session Support
|
|
108
153
|
|
|
@@ -145,7 +190,7 @@ extension/
|
|
|
145
190
|
|
|
146
191
|
mcp-server/
|
|
147
192
|
index.js # MCP server (stdio) + WebSocket client
|
|
148
|
-
tools.js #
|
|
193
|
+
tools.js # 34 tool definitions
|
|
149
194
|
bin/cli.js # Install CLI
|
|
150
195
|
```
|
|
151
196
|
|
|
@@ -157,11 +202,19 @@ mcp-server/
|
|
|
157
202
|
5. Commands flow: Claude Code → MCP → Extension → Chrome APIs
|
|
158
203
|
6. Process auto-exits when Claude Code closes (stdin detection)
|
|
159
204
|
|
|
160
|
-
##
|
|
205
|
+
## Keeping it updated
|
|
206
|
+
|
|
207
|
+
Browser MCP has two parts, and they update independently — how the **extension** updates depends on how you installed it:
|
|
161
208
|
|
|
162
|
-
|
|
209
|
+
| Part | Install method | How it updates |
|
|
210
|
+
|------|----------------|----------------|
|
|
211
|
+
| **MCP server** | any | **Automatic.** Runs via `npx @agent360/browser-mcp@latest`, so every Claude Code session pulls the newest from npm. Nothing to do. |
|
|
212
|
+
| **Extension** | **Chrome Web Store** | **Automatic.** Chrome updates it in the background (usually within a few hours). Nothing to do. |
|
|
213
|
+
| **Extension** | **Unpacked** (`npx … install` or manual zip) | **Manual.** Chrome never auto-updates a load-unpacked extension. Re-run `npx @agent360/browser-mcp install`, then open `chrome://extensions` → Browser MCP → **↻ reload**. |
|
|
163
214
|
|
|
164
|
-
|
|
215
|
+
**Not sure which you have?** Open `chrome://extensions` and find Browser MCP. If it shows a **"Loaded from /path/…"** line, it's unpacked (manual updates). If there's no such line, it came from the Chrome Web Store (auto-updates).
|
|
216
|
+
|
|
217
|
+
**Want zero-maintenance updates?** Install the extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/agent360-browser-mcp/jdehgalffmffhfhmmhaokfbfnafnmgcl), then run `npx @agent360/browser-mcp install --skip-extension` to wire up just the MCP server. After that, both parts stay current on their own.
|
|
165
218
|
|
|
166
219
|
## Troubleshooting
|
|
167
220
|
|
|
@@ -183,6 +236,21 @@ To update the extension: `npx @agent360/browser-mcp install` (re-copies files),
|
|
|
183
236
|
- Idle timeout: 4 hours without commands → auto-exit
|
|
184
237
|
- Manual cleanup: `lsof -i :9876-9885 | grep LISTEN`
|
|
185
238
|
|
|
239
|
+
## 💡 Help Shape Browser MCP
|
|
240
|
+
|
|
241
|
+
Browser MCP is built in the open and shaped by the people using it.
|
|
242
|
+
|
|
243
|
+
### Browse what others want / built
|
|
244
|
+
- 💡 **[Wishlist →](WISHLIST.md)** — features people are asking for
|
|
245
|
+
- 🎯 **[Use-cases →](USE_CASES.md)** — what others have built (LinkedIn ICP scraping, vendor research, daily ops, …)
|
|
246
|
+
|
|
247
|
+
### Contribute in 30 seconds
|
|
248
|
+
- 💡 [Wish for a feature](https://github.com/Agent360dk/browser-mcp/issues/new?template=wish.yml)
|
|
249
|
+
- 🎯 [Share a use-case](https://github.com/Agent360dk/browser-mcp/issues/new?template=use-case.yml)
|
|
250
|
+
- 🐛 [Report a bug](https://github.com/Agent360dk/browser-mcp/issues/new?template=bug.yml)
|
|
251
|
+
|
|
252
|
+
Or just **ask Claude** — it knows about the `browser_about` tool and will draft + submit on your behalf when you say things like *"I wish browser-mcp could …"* or *"share my browser-mcp use-case"*.
|
|
253
|
+
|
|
186
254
|
## License
|
|
187
255
|
|
|
188
256
|
MIT — [Agent360](https://agent360.dk)
|
package/bin/cli.js
CHANGED
|
@@ -85,7 +85,7 @@ function install() {
|
|
|
85
85
|
5. Navigate to and select this folder:
|
|
86
86
|
${extensionDir}
|
|
87
87
|
6. The extension "Agent360 Browser MCP" appears with a puzzle icon
|
|
88
|
-
7. Restart Claude Code —
|
|
88
|
+
7. Restart Claude Code — 34 browser tools are now available!
|
|
89
89
|
|
|
90
90
|
🔄 Auto-updates (fully automatic):
|
|
91
91
|
- MCP server: always fetches latest from npm (npx @latest)
|
|
@@ -93,6 +93,11 @@ function install() {
|
|
|
93
93
|
- Extension reload: auto-triggered via WebSocket
|
|
94
94
|
- You don't need to do anything — updates happen on every Claude Code session start
|
|
95
95
|
|
|
96
|
+
💡 Help shape Browser MCP:
|
|
97
|
+
- Public wishlist: https://github.com/Agent360dk/browser-mcp/blob/main/WISHLIST.md
|
|
98
|
+
- Use-case gallery: https://github.com/Agent360dk/browser-mcp/blob/main/USE_CASES.md
|
|
99
|
+
- Got an idea, bug, or cool thing you built? Just ask Claude — it can draft + submit for you.
|
|
100
|
+
|
|
96
101
|
📖 Docs: https://browsermcp.dev
|
|
97
102
|
`);
|
|
98
103
|
}
|
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) {
|
|
@@ -53,10 +63,45 @@ function getSession(port) {
|
|
|
53
63
|
return sessions.get(port);
|
|
54
64
|
}
|
|
55
65
|
|
|
66
|
+
// LRU eviction cap: hver session må højst have N åbne tabs samtidigt.
|
|
67
|
+
// Når en ny tab tilføjes ud over cap'en, lukkes den ÆLDSTE tab i sessionen
|
|
68
|
+
// (insertion-order via Set) — bortset fra session.activeTabId (current tab).
|
|
69
|
+
// Begrundelse: Claude Code-flows kan åbne 20+ navigate(new_tab=true) per session
|
|
70
|
+
// over en længere conversation. Uden eviction akkumulerer disse i Chrome som
|
|
71
|
+
// orphan-tabs der spiser RAM + giver "extension localhost 19+" tab-noise.
|
|
72
|
+
const MAX_TABS_PER_SESSION = 10;
|
|
73
|
+
|
|
74
|
+
async function evictOldestTabs(session, justAddedTabId) {
|
|
75
|
+
// Drop dead tab-ids først (user manually closed dem)
|
|
76
|
+
for (const id of [...session.tabIds]) {
|
|
77
|
+
try {
|
|
78
|
+
await chrome.tabs.get(id);
|
|
79
|
+
} catch {
|
|
80
|
+
session.tabIds.delete(id);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Evict oldest indtil ≤ cap. Skip activeTabId og just-added tab.
|
|
84
|
+
const ordered = [...session.tabIds];
|
|
85
|
+
for (const oldId of ordered) {
|
|
86
|
+
if (session.tabIds.size <= MAX_TABS_PER_SESSION) break;
|
|
87
|
+
if (oldId === session.activeTabId) continue;
|
|
88
|
+
if (oldId === justAddedTabId) continue;
|
|
89
|
+
try {
|
|
90
|
+
await chrome.tabs.remove(oldId);
|
|
91
|
+
} catch {} // tab may already be closed
|
|
92
|
+
session.tabIds.delete(oldId);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
56
96
|
async function addTabToSession(port, tabId) {
|
|
57
97
|
const session = getSession(port);
|
|
58
98
|
session.tabIds.add(tabId);
|
|
59
99
|
|
|
100
|
+
// LRU eviction: når sessionen overstiger cap, luk de ældste tabs.
|
|
101
|
+
if (session.tabIds.size > MAX_TABS_PER_SESSION) {
|
|
102
|
+
await evictOldestTabs(session, tabId);
|
|
103
|
+
}
|
|
104
|
+
|
|
60
105
|
try {
|
|
61
106
|
if (session.groupId !== null) {
|
|
62
107
|
try {
|
|
@@ -68,10 +113,7 @@ async function addTabToSession(port, tabId) {
|
|
|
68
113
|
}
|
|
69
114
|
|
|
70
115
|
if (session.groupId === null) {
|
|
71
|
-
const
|
|
72
|
-
try { return id; } catch { return false; }
|
|
73
|
-
});
|
|
74
|
-
const groupId = await chrome.tabs.group({ tabIds: validTabIds });
|
|
116
|
+
const groupId = await chrome.tabs.group({ tabIds: [...session.tabIds] });
|
|
75
117
|
session.groupId = groupId;
|
|
76
118
|
await chrome.tabGroups.update(groupId, {
|
|
77
119
|
title: session.label,
|
|
@@ -123,17 +165,25 @@ function persistSessions() {
|
|
|
123
165
|
async function getSessionTab(port, activate = false) {
|
|
124
166
|
const session = getSession(port);
|
|
125
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
|
+
};
|
|
126
177
|
|
|
127
178
|
// Prefer the active (last navigated) tab
|
|
128
179
|
if (session.activeTabId) {
|
|
129
180
|
try {
|
|
130
181
|
const tab = await chrome.tabs.get(session.activeTabId);
|
|
131
|
-
if (tab
|
|
132
|
-
target = tab;
|
|
133
|
-
}
|
|
182
|
+
if (consider(tab)) target = tab;
|
|
134
183
|
} catch {
|
|
184
|
+
const dead = session.activeTabId; // FIX-17: capture id BEFORE nulling (was deleting null)
|
|
135
185
|
session.activeTabId = null;
|
|
136
|
-
session.tabIds.delete(
|
|
186
|
+
session.tabIds.delete(dead);
|
|
137
187
|
}
|
|
138
188
|
}
|
|
139
189
|
|
|
@@ -142,30 +192,48 @@ async function getSessionTab(port, activate = false) {
|
|
|
142
192
|
for (const tabId of session.tabIds) {
|
|
143
193
|
try {
|
|
144
194
|
const tab = await chrome.tabs.get(tabId);
|
|
145
|
-
if (tab
|
|
146
|
-
session.activeTabId = tabId;
|
|
147
|
-
target = tab;
|
|
148
|
-
break;
|
|
149
|
-
}
|
|
195
|
+
if (consider(tab)) { session.activeTabId = tabId; target = tab; break; }
|
|
150
196
|
} catch {
|
|
151
197
|
session.tabIds.delete(tabId);
|
|
152
198
|
}
|
|
153
199
|
}
|
|
154
200
|
}
|
|
155
201
|
|
|
156
|
-
//
|
|
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.
|
|
157
211
|
if (!target) {
|
|
158
212
|
target = await chrome.tabs.create({ url: 'about:blank', active: false });
|
|
159
213
|
await addTabToSession(port, target.id);
|
|
160
|
-
|
|
214
|
+
session.activeTabId = target.id;
|
|
215
|
+
persistSessions();
|
|
216
|
+
// fall through to the activate branch (SC-3: previously returned early, skipping it)
|
|
161
217
|
}
|
|
162
218
|
|
|
163
|
-
// Activate the tab
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 */ }
|
|
169
237
|
}
|
|
170
238
|
|
|
171
239
|
return target;
|
|
@@ -176,18 +244,61 @@ async function getSessionTab(port, activate = false) {
|
|
|
176
244
|
// Track which tabs have debugger attached to avoid repeated attach/detach
|
|
177
245
|
const debuggerAttached = new Set();
|
|
178
246
|
|
|
179
|
-
|
|
180
|
-
|
|
247
|
+
// Verify Chrome's actual debugger-truth before trusting local cache.
|
|
248
|
+
// Fixes "ghost-attached" state where Set says attached but Chrome side is gone
|
|
249
|
+
// (happens on SW lifecycle events, user-canceled banners, anti-automation evictions).
|
|
250
|
+
async function verifyAttachedWithChrome(tabId) {
|
|
181
251
|
try {
|
|
182
|
-
await chrome.debugger.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
252
|
+
const targets = await chrome.debugger.getTargets();
|
|
253
|
+
const t = targets.find(x => x.tabId === tabId);
|
|
254
|
+
return !!t?.attached;
|
|
255
|
+
} catch {
|
|
256
|
+
return false; // assume not-attached on API error
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function debuggerAttach(tabId) {
|
|
261
|
+
// First check local cache — fast path
|
|
262
|
+
if (debuggerAttached.has(tabId)) {
|
|
263
|
+
// Verify with Chrome before trusting cache (cheap, ~1ms)
|
|
264
|
+
if (await verifyAttachedWithChrome(tabId)) return;
|
|
265
|
+
// Cache was stale — Chrome doesn't actually have us attached
|
|
266
|
+
debuggerAttached.delete(tabId);
|
|
267
|
+
}
|
|
268
|
+
|
|
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);
|
|
189
294
|
}
|
|
295
|
+
if (attempt < 2) await new Promise(r => setTimeout(r, 250 + attempt * 250));
|
|
190
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
|
+
);
|
|
191
302
|
}
|
|
192
303
|
|
|
193
304
|
async function debuggerDetach(tabId) {
|
|
@@ -319,25 +430,75 @@ async function debuggerType(tabId, text) {
|
|
|
319
430
|
async function debuggerClick(tabId, x, y) {
|
|
320
431
|
await debuggerAttach(tabId);
|
|
321
432
|
try {
|
|
433
|
+
// 0. Capture the DEEPEST target element under the point BEFORE dispatching.
|
|
434
|
+
// Web-components (Google Ads <button-panel>, Material Web) keep their real
|
|
435
|
+
// <button> inside an (open) shadow root, so we pierce shadow roots to reach
|
|
436
|
+
// it. We stash it on window so the framework fallback (step 3) can verify it
|
|
437
|
+
// is still connected — if the trusted click already navigated/re-rendered,
|
|
438
|
+
// the ref is detached and we must NOT re-fire (avoids mis-clicks on the new
|
|
439
|
+
// view / double-submits).
|
|
440
|
+
await cdpSend(tabId, 'Runtime.evaluate', {
|
|
441
|
+
expression: `(() => {
|
|
442
|
+
let el = document.elementFromPoint(${x}, ${y});
|
|
443
|
+
let host = el;
|
|
444
|
+
for (let i = 0; i < 20 && host && host.shadowRoot; i++) {
|
|
445
|
+
const inner = host.shadowRoot.elementFromPoint(${x}, ${y});
|
|
446
|
+
if (!inner || inner === host) break;
|
|
447
|
+
el = inner; host = inner;
|
|
448
|
+
}
|
|
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);
|
|
464
|
+
})()`,
|
|
465
|
+
});
|
|
322
466
|
// 1. mouseMoved first (triggers hover state, required by some frameworks)
|
|
323
467
|
await cdpSend(tabId, 'Input.dispatchMouseEvent', {
|
|
324
468
|
type: 'mouseMoved', x, y,
|
|
325
469
|
});
|
|
326
470
|
await new Promise(r => setTimeout(r, 30));
|
|
327
|
-
// 2. mousePressed + mouseReleased (
|
|
471
|
+
// 2. mousePressed + mouseReleased. The `buttons` bitmask (1 while pressed,
|
|
472
|
+
// 0 on release) plus a small press→release gap are REQUIRED for Chrome to
|
|
473
|
+
// synthesize a *trusted* 'click' from the pair. Without them, web-components
|
|
474
|
+
// that gate on the trusted click event (Google Ads, Material Web) never fire.
|
|
328
475
|
await cdpSend(tabId, 'Input.dispatchMouseEvent', {
|
|
329
|
-
type: 'mousePressed', x, y, button: 'left', clickCount: 1,
|
|
476
|
+
type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1,
|
|
330
477
|
});
|
|
478
|
+
await new Promise(r => setTimeout(r, 30));
|
|
331
479
|
await cdpSend(tabId, 'Input.dispatchMouseEvent', {
|
|
332
|
-
type: 'mouseReleased', x, y, button: 'left', clickCount: 1,
|
|
480
|
+
type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1,
|
|
333
481
|
});
|
|
334
|
-
// 3.
|
|
335
|
-
//
|
|
482
|
+
// 3. Framework fallback — only if the captured target is STILL connected (i.e.
|
|
483
|
+
// the trusted click in step 2 did not already handle it). Settle delay lets
|
|
484
|
+
// SPA re-renders (Google Ads) detach the element first. Fires a full pointer
|
|
485
|
+
// + mouse sequence on the shadow-pierced target, then React/Angular handlers.
|
|
486
|
+
await new Promise(r => setTimeout(r, 120));
|
|
336
487
|
await cdpSend(tabId, 'Runtime.evaluate', {
|
|
337
488
|
expression: `(() => {
|
|
338
|
-
const el =
|
|
339
|
-
|
|
340
|
-
|
|
489
|
+
const el = window.__bmcpClickTarget;
|
|
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
|
|
494
|
+
if (!el || !el.isConnected) return; // already navigated/handled — don't double-fire
|
|
495
|
+
const opts = { bubbles: true, cancelable: true, composed: true, view: window, clientX: ${x}, clientY: ${y} };
|
|
496
|
+
try { el.dispatchEvent(new PointerEvent('pointerdown', opts)); } catch (e) {}
|
|
497
|
+
el.dispatchEvent(new MouseEvent('mousedown', opts));
|
|
498
|
+
try { el.dispatchEvent(new PointerEvent('pointerup', opts)); } catch (e) {}
|
|
499
|
+
el.dispatchEvent(new MouseEvent('mouseup', opts));
|
|
500
|
+
el.dispatchEvent(new MouseEvent('click', opts));
|
|
501
|
+
if (typeof el.click === 'function') el.click();
|
|
341
502
|
|
|
342
503
|
// React fiber fallback — find and call onClick handler directly
|
|
343
504
|
const fiberKey = Object.keys(el).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
|
|
@@ -349,16 +510,11 @@ async function debuggerClick(tabId, x, y) {
|
|
|
349
510
|
}
|
|
350
511
|
}
|
|
351
512
|
|
|
352
|
-
// Angular fallback —
|
|
513
|
+
// Angular Material fallback — ripple + internal handlers
|
|
353
514
|
const ngKey = Object.keys(el).find(k => k.startsWith('__ng'));
|
|
354
515
|
if (ngKey || el.getAttribute('ng-click') || el.getAttribute('(click)')) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true}));
|
|
358
|
-
el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, view:window}));
|
|
359
|
-
// Angular Material components (mat-button, mat-checkbox, etc.) use ripple + internal handlers
|
|
360
|
-
const matRipple = el.closest('[mat-button], [mat-raised-button], [mat-icon-button], [mat-fab], mat-checkbox, mat-slide-toggle, mat-radio-button');
|
|
361
|
-
if (matRipple) matRipple.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, view:window}));
|
|
516
|
+
const matRipple = el.closest && el.closest('[mat-button], [mat-raised-button], [mat-icon-button], [mat-fab], mat-checkbox, mat-slide-toggle, mat-radio-button');
|
|
517
|
+
if (matRipple) matRipple.dispatchEvent(new MouseEvent('click', opts));
|
|
362
518
|
}
|
|
363
519
|
})()`,
|
|
364
520
|
});
|
|
@@ -414,9 +570,9 @@ async function debuggerFill(tabId, selector, value) {
|
|
|
414
570
|
await debuggerFocus(tabId, selector);
|
|
415
571
|
await debuggerAttach(tabId);
|
|
416
572
|
try {
|
|
417
|
-
//
|
|
573
|
+
// Select-all (Cmd+A on macOS, Ctrl+A elsewhere), then Backspace to clear
|
|
418
574
|
await cdpSend(tabId, 'Input.dispatchKeyEvent', {
|
|
419
|
-
type: 'keyDown', key: 'a', code: 'KeyA', modifiers:
|
|
575
|
+
type: 'keyDown', key: 'a', code: 'KeyA', modifiers: SELECT_ALL_MODS,
|
|
420
576
|
});
|
|
421
577
|
await cdpSend(tabId, 'Input.dispatchKeyEvent', {
|
|
422
578
|
type: 'keyUp', key: 'a', code: 'KeyA',
|
|
@@ -516,10 +672,20 @@ async function safeExecuteScript(tabId, func, args = [], world = 'MAIN') {
|
|
|
516
672
|
|
|
517
673
|
function buildTextFinderJS(textPattern, tagFilter) {
|
|
518
674
|
const escaped = JSON.stringify(textPattern);
|
|
519
|
-
const
|
|
675
|
+
const wantTag = tagFilter ? JSON.stringify(tagFilter.toUpperCase()) : 'null';
|
|
520
676
|
return `(function() {
|
|
521
677
|
const text = ${escaped};
|
|
522
|
-
|
|
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';
|
|
523
689
|
function collectAll(root, results) {
|
|
524
690
|
for (const el of root.querySelectorAll('*')) {
|
|
525
691
|
results.push(el);
|
|
@@ -528,23 +694,34 @@ function buildTextFinderJS(textPattern, tagFilter) {
|
|
|
528
694
|
return results;
|
|
529
695
|
}
|
|
530
696
|
const all = collectAll(document, []);
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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;
|
|
545
720
|
}
|
|
721
|
+
return toClickable(pool[0]);
|
|
546
722
|
}
|
|
547
|
-
|
|
723
|
+
// Exact match first, then partial fallback.
|
|
724
|
+
return pick(t => t === text) || pick(t => t && t.includes(text));
|
|
548
725
|
})()`;
|
|
549
726
|
}
|
|
550
727
|
|
|
@@ -670,7 +847,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
|
670
847
|
dispatch(port, msg.method, msg.params)
|
|
671
848
|
.then(result => sendResponse(result))
|
|
672
849
|
.catch(err => sendResponse({ __error: err.message || String(err) }));
|
|
673
|
-
});
|
|
850
|
+
}).catch(err => sendResponse({ __error: err.message || String(err) })); // else a storage-restore reject hangs the caller
|
|
674
851
|
return true; // async response
|
|
675
852
|
}
|
|
676
853
|
|
|
@@ -728,22 +905,6 @@ chrome.tabs.onCreated.addListener(async (tab) => {
|
|
|
728
905
|
}
|
|
729
906
|
});
|
|
730
907
|
|
|
731
|
-
// ── CAPTCHA Detection ────────────────────────────────────────────────────────
|
|
732
|
-
|
|
733
|
-
async function detectCaptcha(tabId) {
|
|
734
|
-
try {
|
|
735
|
-
return await debuggerEval(tabId, `
|
|
736
|
-
(function() {
|
|
737
|
-
if (document.querySelector('iframe[src*="hcaptcha.com"]') || document.querySelector('.h-captcha')) return 'hcaptcha';
|
|
738
|
-
if (document.querySelector('iframe[src*="recaptcha"]') || document.querySelector('.g-recaptcha')) return 'recaptcha';
|
|
739
|
-
if (document.querySelector('iframe[src*="challenges.cloudflare.com"]') || document.querySelector('.cf-turnstile')) return 'turnstile';
|
|
740
|
-
if (document.documentElement.innerHTML.includes('challenge-platform')) return 'challenge';
|
|
741
|
-
return null;
|
|
742
|
-
})()
|
|
743
|
-
`);
|
|
744
|
-
} catch { return null; }
|
|
745
|
-
}
|
|
746
|
-
|
|
747
908
|
// ── Deep Shadow DOM Query ────────────────────────────────────────────────────
|
|
748
909
|
// querySelectorDeep: finds elements inside shadow DOMs (Shopify, Salesforce, etc.)
|
|
749
910
|
|
|
@@ -894,7 +1055,7 @@ async function setDateMaskedTyping(tabId, selector, iso, format) {
|
|
|
894
1055
|
await debuggerAttach(tabId);
|
|
895
1056
|
try {
|
|
896
1057
|
await cdpSend(tabId, 'Input.dispatchKeyEvent', {
|
|
897
|
-
type: 'keyDown', key: 'a', code: 'KeyA', modifiers:
|
|
1058
|
+
type: 'keyDown', key: 'a', code: 'KeyA', modifiers: SELECT_ALL_MODS,
|
|
898
1059
|
});
|
|
899
1060
|
await cdpSend(tabId, 'Input.dispatchKeyEvent', {
|
|
900
1061
|
type: 'keyUp', key: 'a', code: 'KeyA',
|
|
@@ -938,23 +1099,6 @@ async function isPickerOpen(tabId) {
|
|
|
938
1099
|
})()`);
|
|
939
1100
|
}
|
|
940
1101
|
|
|
941
|
-
async function getPickerRoot(tabId) {
|
|
942
|
-
return await debuggerEval(tabId, `(() => {
|
|
943
|
-
const sels = ${JSON.stringify(PICKER_OPEN_SELECTORS)};
|
|
944
|
-
for (const s of sels) {
|
|
945
|
-
try {
|
|
946
|
-
const el = document.querySelector(s);
|
|
947
|
-
if (el) {
|
|
948
|
-
const root = el.closest('[role="dialog"], .react-datepicker, .MuiPickersPopper-root, .ant-picker-dropdown') || el;
|
|
949
|
-
// Return a stable selector path — for runtime use we re-query each time
|
|
950
|
-
return true;
|
|
951
|
-
}
|
|
952
|
-
} catch {}
|
|
953
|
-
}
|
|
954
|
-
return false;
|
|
955
|
-
})()`);
|
|
956
|
-
}
|
|
957
|
-
|
|
958
1102
|
async function setDatePicker(tabId, selector, iso) {
|
|
959
1103
|
const [yStr, mStr, dStr] = iso.split('-');
|
|
960
1104
|
const targetYear = parseInt(yStr, 10);
|
|
@@ -1611,68 +1755,193 @@ async function dispatch(port, method, params) {
|
|
|
1611
1755
|
}
|
|
1612
1756
|
|
|
1613
1757
|
case 'screenshot': {
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
//
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
return { image: 'data:image/png;base64,' + data };
|
|
1625
|
-
} catch {
|
|
1626
|
-
// 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 () => {
|
|
1627
1768
|
try {
|
|
1628
|
-
await
|
|
1629
|
-
|
|
1630
|
-
|
|
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' });
|
|
1631
1782
|
return { image: dataUrl };
|
|
1632
|
-
}
|
|
1633
|
-
|
|
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
|
+
}
|
|
1634
1813
|
}
|
|
1635
1814
|
}
|
|
1636
1815
|
}
|
|
1637
1816
|
|
|
1638
1817
|
case 'execute_script': {
|
|
1818
|
+
// v1.22.2 (DIAGNOSTIC): Try scripting paths but log all errors so we can see WHY they fail
|
|
1639
1819
|
const tab = await getSessionTab(port);
|
|
1640
1820
|
if (tab.url.startsWith('chrome://')) throw new Error('Cannot execute scripts on chrome:// pages');
|
|
1821
|
+
|
|
1822
|
+
const diag = { tried: [] };
|
|
1823
|
+
|
|
1824
|
+
// Step 1: try ISOLATED world
|
|
1825
|
+
try {
|
|
1826
|
+
const [result] = await chrome.scripting.executeScript({
|
|
1827
|
+
target: { tabId: tab.id },
|
|
1828
|
+
world: 'ISOLATED',
|
|
1829
|
+
args: [params.code],
|
|
1830
|
+
func: (codeStr) => {
|
|
1831
|
+
try {
|
|
1832
|
+
const fn = new Function('return (' + codeStr + ')');
|
|
1833
|
+
return { __ok: true, value: fn() };
|
|
1834
|
+
} catch (e) {
|
|
1835
|
+
return { __scriptingError: true, message: String(e?.message || e), name: e?.name, world: 'ISOLATED' };
|
|
1836
|
+
}
|
|
1837
|
+
},
|
|
1838
|
+
});
|
|
1839
|
+
const r = result?.result;
|
|
1840
|
+
diag.tried.push({ world: 'ISOLATED', result_keys: r ? Object.keys(r) : null, r_type: typeof r });
|
|
1841
|
+
if (r && typeof r === 'object' && r.__ok) {
|
|
1842
|
+
return { result: r.value, method: 'scripting-isolated' };
|
|
1843
|
+
}
|
|
1844
|
+
if (r && typeof r === 'object' && r.__scriptingError) {
|
|
1845
|
+
diag.isolated_error = r.message;
|
|
1846
|
+
}
|
|
1847
|
+
} catch (e) {
|
|
1848
|
+
diag.isolated_throw = String(e?.message || e);
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// Step 2: try MAIN world
|
|
1641
1852
|
try {
|
|
1642
1853
|
const [result] = await chrome.scripting.executeScript({
|
|
1643
1854
|
target: { tabId: tab.id },
|
|
1644
|
-
func: new Function('return (' + params.code + ')'),
|
|
1645
1855
|
world: 'MAIN',
|
|
1856
|
+
args: [params.code],
|
|
1857
|
+
func: (codeStr) => {
|
|
1858
|
+
try {
|
|
1859
|
+
const fn = new Function('return (' + codeStr + ')');
|
|
1860
|
+
return { __ok: true, value: fn() };
|
|
1861
|
+
} catch (e) {
|
|
1862
|
+
return { __scriptingError: true, message: String(e?.message || e), name: e?.name, world: 'MAIN' };
|
|
1863
|
+
}
|
|
1864
|
+
},
|
|
1646
1865
|
});
|
|
1647
|
-
|
|
1866
|
+
const r = result?.result;
|
|
1867
|
+
diag.tried.push({ world: 'MAIN', result_keys: r ? Object.keys(r) : null, r_type: typeof r });
|
|
1868
|
+
if (r && typeof r === 'object' && r.__ok) {
|
|
1869
|
+
return { result: r.value, method: 'scripting-main' };
|
|
1870
|
+
}
|
|
1871
|
+
if (r && typeof r === 'object' && r.__scriptingError) {
|
|
1872
|
+
diag.main_error = r.message;
|
|
1873
|
+
}
|
|
1648
1874
|
} catch (e) {
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1875
|
+
diag.main_throw = String(e?.message || e);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
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;
|
|
1653
1913
|
}
|
|
1654
|
-
|
|
1914
|
+
await debuggerDetach(tab.id).catch(() => {});
|
|
1915
|
+
await new Promise(r => setTimeout(r, 200 + attempt * 200));
|
|
1655
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
|
+
);
|
|
1656
1922
|
}
|
|
1657
1923
|
|
|
1658
1924
|
case 'click': {
|
|
1659
1925
|
const tab = await getSessionTab(port);
|
|
1660
1926
|
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
|
1661
1927
|
|
|
1662
|
-
//
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
// Primary path: debugger mouse events (isTrusted=true, works on React/Angular SPAs)
|
|
1928
|
+
// Wrap full click flow (incl. resolveElement) so debugger failures in EITHER
|
|
1929
|
+
// resolveElement (text-selectors use debuggerEval) OR debuggerClick trigger
|
|
1930
|
+
// the scripting-fallback. v1.21.2: previously only debuggerClick was wrapped,
|
|
1931
|
+
// leaving text-selector clicks unrecoverable when debugger was user-blocked.
|
|
1667
1932
|
try {
|
|
1933
|
+
const el = await resolveElement(tab.id, params.selector);
|
|
1934
|
+
if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
|
|
1935
|
+
|
|
1936
|
+
// Primary path: debugger mouse events (isTrusted=true, works on React/Angular SPAs)
|
|
1668
1937
|
await debuggerClick(tab.id, el.x, el.y);
|
|
1669
1938
|
return { ok: true, method: el.method || 'debugger', tag: el.tag, text: el.text };
|
|
1670
1939
|
} catch (e) {
|
|
1671
1940
|
// Fallback: synthetic click via chrome.scripting for anti-automation sites
|
|
1672
|
-
// (Apple ASC etc.)
|
|
1941
|
+
// (Apple ASC etc.) OR user-blocked-debugger scenarios.
|
|
1673
1942
|
if (/Debugger detached/.test(e?.message || '')) {
|
|
1674
1943
|
const r = await scriptingClick(tab.id, params.selector);
|
|
1675
|
-
if (r.ok) return { ok: true, method: 'scripting-fallback', tag: r.tag
|
|
1944
|
+
if (r.ok) return { ok: true, method: 'scripting-fallback', tag: r.tag };
|
|
1676
1945
|
}
|
|
1677
1946
|
throw e;
|
|
1678
1947
|
}
|
|
@@ -1847,11 +2116,24 @@ async function dispatch(port, method, params) {
|
|
|
1847
2116
|
}
|
|
1848
2117
|
|
|
1849
2118
|
case 'press_key': {
|
|
1850
|
-
|
|
2119
|
+
// v1.22: activate tab so key-event lands in foreground (otherwise Chrome routes to active tab)
|
|
2120
|
+
const tab = await getSessionTab(port, true);
|
|
1851
2121
|
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
|
1852
2122
|
const key = params.key; // e.g. "Enter", "Tab", "Escape", "ArrowDown"
|
|
1853
2123
|
const modifiers = (params.ctrl ? 2 : 0) | (params.alt ? 1 : 0) | (params.shift ? 8 : 0) | (params.meta ? 4 : 0);
|
|
1854
2124
|
|
|
2125
|
+
// v1.22: Chrome requires windowsVirtualKeyCode for navigation/system keys to trigger
|
|
2126
|
+
// scroll/form-submit behavior. Without these, key-event is dispatched but page doesn't react.
|
|
2127
|
+
const VK_CODES = {
|
|
2128
|
+
'Backspace': 8, 'Tab': 9, 'Enter': 13, 'Shift': 16, 'Control': 17, 'Alt': 18,
|
|
2129
|
+
'Escape': 27, 'Space': 32, ' ': 32,
|
|
2130
|
+
'PageUp': 33, 'PageDown': 34, 'End': 35, 'Home': 36,
|
|
2131
|
+
'ArrowLeft': 37, 'ArrowUp': 38, 'ArrowRight': 39, 'ArrowDown': 40,
|
|
2132
|
+
'Delete': 46,
|
|
2133
|
+
};
|
|
2134
|
+
const vkCode = VK_CODES[key];
|
|
2135
|
+
const vkParams = vkCode ? { windowsVirtualKeyCode: vkCode, nativeVirtualKeyCode: vkCode } : {};
|
|
2136
|
+
|
|
1855
2137
|
await debuggerAttach(tab.id);
|
|
1856
2138
|
try {
|
|
1857
2139
|
await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
|
|
@@ -1860,12 +2142,14 @@ async function dispatch(port, method, params) {
|
|
|
1860
2142
|
code: params.code || key,
|
|
1861
2143
|
modifiers,
|
|
1862
2144
|
text: key.length === 1 ? key : '',
|
|
2145
|
+
...vkParams,
|
|
1863
2146
|
});
|
|
1864
2147
|
await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
|
|
1865
2148
|
type: 'keyUp',
|
|
1866
2149
|
key,
|
|
1867
2150
|
code: params.code || key,
|
|
1868
2151
|
modifiers,
|
|
2152
|
+
...vkParams,
|
|
1869
2153
|
});
|
|
1870
2154
|
} finally {
|
|
1871
2155
|
await debuggerDetach(tab.id);
|
|
@@ -1874,6 +2158,8 @@ async function dispatch(port, method, params) {
|
|
|
1874
2158
|
}
|
|
1875
2159
|
|
|
1876
2160
|
case 'scroll': {
|
|
2161
|
+
// v1.22: NO activate — CDP Input.dispatchMouseEvent goes via debugger directly to target,
|
|
2162
|
+
// doesn't need active tab. Re-activating on every scroll-call destabilizes debugger.
|
|
1877
2163
|
const tab = await getSessionTab(port);
|
|
1878
2164
|
if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
|
|
1879
2165
|
// Scroll to element
|
|
@@ -1882,20 +2168,32 @@ async function dispatch(port, method, params) {
|
|
|
1882
2168
|
if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
|
|
1883
2169
|
return { ok: true, scrolled_to: params.selector };
|
|
1884
2170
|
}
|
|
1885
|
-
// Scroll by pixels using CDP mouseWheel —
|
|
2171
|
+
// Scroll by pixels using CDP mouseWheel — split into smaller steps so IntersectionObservers fire.
|
|
2172
|
+
// FB/Twitter/IG only trigger lazy-load on continuous wheel events, not a single large delta.
|
|
1886
2173
|
const dx = params.x || 0;
|
|
1887
2174
|
const dy = params.y || 0;
|
|
1888
2175
|
try {
|
|
1889
2176
|
await debuggerAttach(tab.id);
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
2177
|
+
const STEP_SIZE = 300; // pixels per wheel-event (matches a typical mouse-wheel notch)
|
|
2178
|
+
const totalSteps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)) / STEP_SIZE));
|
|
2179
|
+
const stepX = dx / totalSteps;
|
|
2180
|
+
const stepY = dy / totalSteps;
|
|
2181
|
+
for (let i = 0; i < totalSteps; i++) {
|
|
2182
|
+
await cdpSend(tab.id, 'Input.dispatchMouseEvent', {
|
|
2183
|
+
type: 'mouseWheel', x: 400, y: 300, deltaX: stepX, deltaY: stepY,
|
|
2184
|
+
});
|
|
2185
|
+
// Small delay between wheel-events so IntersectionObserver + lazy-load XHRs can fire
|
|
2186
|
+
if (i < totalSteps - 1) await new Promise(r => setTimeout(r, 80));
|
|
2187
|
+
}
|
|
2188
|
+
// After last wheel-event, give FB/Twitter/IG ~600ms to start lazy-load XHRs
|
|
2189
|
+
// before any subsequent commands run (caller often scrapes immediately after)
|
|
2190
|
+
await new Promise(r => setTimeout(r, 600));
|
|
2191
|
+
} catch (e) {
|
|
2192
|
+
// Fallback to window.scrollBy for simple pages (synthetic but works on non-anti-scrape sites)
|
|
1896
2193
|
await debuggerEval(tab.id, `window.scrollBy(${dx}, ${dy})`);
|
|
2194
|
+
return { ok: true, scrolled: { x: dx, y: dy }, method: 'fallback', fallback_reason: e.message };
|
|
1897
2195
|
}
|
|
1898
|
-
return { ok: true, scrolled: { x: dx, y: dy } };
|
|
2196
|
+
return { ok: true, scrolled: { x: dx, y: dy }, method: 'mouseWheel-stepped' };
|
|
1899
2197
|
}
|
|
1900
2198
|
|
|
1901
2199
|
case 'hover': {
|
package/extension/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Agent360 Browser MCP",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "Control your real Chrome from Claude Code — navigate, click, fill, screenshot, solve CAPTCHAs.
|
|
4
|
+
"version": "1.24.0",
|
|
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",
|
|
8
8
|
"tabGroups",
|
package/index.js
CHANGED
|
@@ -14,9 +14,9 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
14
14
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
15
15
|
import { WebSocketServer } from 'ws';
|
|
16
16
|
import { execSync } from 'child_process';
|
|
17
|
-
import { dirname, join } from 'path';
|
|
17
|
+
import { dirname, join, resolve } from 'path';
|
|
18
18
|
import { fileURLToPath } from 'url';
|
|
19
|
-
import { readFileSync } from 'fs';
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
20
20
|
import { TOOLS, PROVIDER_PAGES } from './tools.js';
|
|
21
21
|
|
|
22
22
|
// Read version from package.json — single source of truth, never drifts
|
|
@@ -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
|
|
@@ -242,7 +242,10 @@ For image grid challenges: cells are 0-indexed, left-to-right, top-to-bottom. A
|
|
|
242
242
|
The MCP server auto-pulls the latest code from git on every new session startup.
|
|
243
243
|
If the extension files were updated, ask the user to reload it:
|
|
244
244
|
"The Browser MCP extension was updated. Please go to chrome://extensions, find 'Agent360 Browser MCP', and click the reload icon (🔄) to apply the update."
|
|
245
|
-
You cannot navigate to chrome:// pages — the user must do this manually
|
|
245
|
+
You cannot navigate to chrome:// pages — the user must do this manually.
|
|
246
|
+
|
|
247
|
+
## Sharing wishes / use-cases / bugs
|
|
248
|
+
Browser MCP is community-shaped. Whenever the user (a) says "I wish browser-mcp could…" or expresses a missing feature, (b) hits something broken or unexpected that looks like a bug in browser-mcp itself, or (c) describes something cool they built with it — call **browser_about** with the matching intent ("wish" / "use_case" / "bug") and a short title + body, then offer the returned submit_url to the user as a clickable link. Don't ask permission, just draft + offer the link. This is the user's path to contribute back, and they usually don't know it exists.`;
|
|
246
249
|
|
|
247
250
|
const mcpServer = new Server(
|
|
248
251
|
{ name: 'agent360-browser', version: PKG_VERSION },
|
|
@@ -294,6 +297,10 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
294
297
|
browser_drop_file: 'drop_file',
|
|
295
298
|
};
|
|
296
299
|
|
|
300
|
+
if (name === 'browser_about') {
|
|
301
|
+
return handleAbout(args);
|
|
302
|
+
}
|
|
303
|
+
|
|
297
304
|
if (name === 'browser_extract_token') {
|
|
298
305
|
return await handleExtractToken(args);
|
|
299
306
|
}
|
|
@@ -312,6 +319,19 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
312
319
|
const prefix = isJpeg ? /^data:image\/jpeg;base64,/ : /^data:image\/png;base64,/;
|
|
313
320
|
const mimeType = isJpeg ? 'image/jpeg' : 'image/png';
|
|
314
321
|
const base64 = result.image.replace(prefix, '');
|
|
322
|
+
|
|
323
|
+
if (args && args.path) {
|
|
324
|
+
const targetPath = resolve(process.cwd(), args.path);
|
|
325
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
326
|
+
writeFileSync(targetPath, Buffer.from(base64, 'base64'));
|
|
327
|
+
return {
|
|
328
|
+
content: [
|
|
329
|
+
{ type: 'text', text: `Screenshot successfully saved to: ${targetPath}` },
|
|
330
|
+
{ type: 'image', data: base64, mimeType }
|
|
331
|
+
]
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
315
335
|
return { content: [{ type: 'image', data: base64, mimeType }] };
|
|
316
336
|
}
|
|
317
337
|
|
|
@@ -337,8 +357,47 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
337
357
|
}
|
|
338
358
|
});
|
|
339
359
|
|
|
360
|
+
const REPO_URL = 'https://github.com/Agent360dk/browser-mcp';
|
|
361
|
+
const ISSUE_TEMPLATES = { wish: 'wish.yml', use_case: 'use-case.yml', bug: 'bug.yml' };
|
|
362
|
+
|
|
363
|
+
function handleAbout(args) {
|
|
364
|
+
const intent = args?.intent || 'info';
|
|
365
|
+
const title = args?.title || '';
|
|
366
|
+
const body = args?.body || '';
|
|
367
|
+
|
|
368
|
+
const submit_url = intent === 'info' || !ISSUE_TEMPLATES[intent]
|
|
369
|
+
? `${REPO_URL}/issues/new/choose`
|
|
370
|
+
: `${REPO_URL}/issues/new?template=${ISSUE_TEMPLATES[intent]}` +
|
|
371
|
+
(title ? `&title=${encodeURIComponent(title)}` : '') +
|
|
372
|
+
(body ? `&body=${encodeURIComponent(body)}` : '');
|
|
373
|
+
|
|
374
|
+
const instruction =
|
|
375
|
+
intent === 'wish'
|
|
376
|
+
? `Share this exact submission link with the user as a clickable link, with a short note like "Click to submit your wish — it'll open a pre-filled GitHub issue you can review before submitting": ${submit_url}`
|
|
377
|
+
: intent === 'use_case'
|
|
378
|
+
? `Share this exact submission link with the user as a clickable link, with a short note like "Click to share your use-case — pre-filled, you can edit before submitting": ${submit_url}`
|
|
379
|
+
: intent === 'bug'
|
|
380
|
+
? `Share this exact bug-report link with the user as a clickable link, with a short note like "Click to report — pre-filled, please add reproduction steps before submitting": ${submit_url}`
|
|
381
|
+
: `Browser MCP is community-shaped. Open wishlist: ${REPO_URL}/blob/main/WISHLIST.md · Use-cases: ${REPO_URL}/blob/main/USE_CASES.md · Submit anything: ${REPO_URL}/issues/new/choose`;
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
content: [{
|
|
385
|
+
type: 'text',
|
|
386
|
+
text: JSON.stringify({
|
|
387
|
+
name: 'Browser MCP by Agent360',
|
|
388
|
+
version: PKG_VERSION,
|
|
389
|
+
repo: REPO_URL,
|
|
390
|
+
wishlist: `${REPO_URL}/blob/main/WISHLIST.md`,
|
|
391
|
+
use_cases: `${REPO_URL}/blob/main/USE_CASES.md`,
|
|
392
|
+
submit_url,
|
|
393
|
+
instruction,
|
|
394
|
+
}, null, 2),
|
|
395
|
+
}],
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
340
399
|
async function handleExtractToken(args) {
|
|
341
|
-
const { provider
|
|
400
|
+
const { provider } = args;
|
|
342
401
|
const info = PROVIDER_PAGES[provider];
|
|
343
402
|
|
|
344
403
|
if (!info) {
|
|
@@ -351,18 +410,11 @@ async function handleExtractToken(args) {
|
|
|
351
410
|
}
|
|
352
411
|
|
|
353
412
|
const nav = await sendToExtension('navigate', { url: info.url });
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
content.push({
|
|
360
|
-
type: 'text',
|
|
361
|
-
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}"}'`,
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
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
|
+
};
|
|
366
418
|
}
|
|
367
419
|
|
|
368
420
|
// ── Graceful shutdown ──────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent360/browser-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Browser MCP — control your real Chrome from Claude Code.
|
|
3
|
+
"version": "1.24.0",
|
|
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",
|
|
7
7
|
"main": "index.js",
|
|
@@ -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
|
@@ -29,8 +29,13 @@ export const TOOLS = [
|
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
name: 'browser_screenshot',
|
|
32
|
-
description: 'Take a screenshot of the visible area of the current tab. Returns base64 PNG.',
|
|
33
|
-
inputSchema: {
|
|
32
|
+
description: 'Take a screenshot of the visible area of the current tab. Returns base64 PNG, or saves to disk if path is provided.',
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
path: { type: 'string', description: 'File path to save the screenshot to (e.g. /path/to/screenshot.png)' },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
34
39
|
},
|
|
35
40
|
{
|
|
36
41
|
name: 'browser_execute_script',
|
|
@@ -373,19 +378,18 @@ export const TOOLS = [
|
|
|
373
378
|
},
|
|
374
379
|
{
|
|
375
380
|
name: 'browser_extract_token',
|
|
376
|
-
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.',
|
|
377
382
|
inputSchema: {
|
|
378
383
|
type: 'object',
|
|
379
384
|
properties: {
|
|
380
385
|
provider: { type: 'string', description: 'Provider slug (stripe, hubspot, slack, etc.)' },
|
|
381
|
-
store_in_vault: { type: 'boolean', description: 'If true, POST token to Agent360 vault API' },
|
|
382
386
|
},
|
|
383
387
|
required: ['provider'],
|
|
384
388
|
},
|
|
385
389
|
},
|
|
386
390
|
{
|
|
387
391
|
name: 'browser_solve_captcha',
|
|
388
|
-
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.',
|
|
389
393
|
inputSchema: {
|
|
390
394
|
type: 'object',
|
|
391
395
|
properties: {
|
|
@@ -398,6 +402,28 @@ export const TOOLS = [
|
|
|
398
402
|
},
|
|
399
403
|
},
|
|
400
404
|
},
|
|
405
|
+
{
|
|
406
|
+
name: 'browser_about',
|
|
407
|
+
description: 'Returns Browser MCP info and pre-filled URLs the user can click to submit feature wishes, share use-cases, or report bugs. Call this PROACTIVELY whenever the user (a) mentions a feature they wish existed ("I wish browser-mcp could...", "it would be nice if..."), (b) says something is missing, broken, or unexpected, (c) asks how Browser MCP works or who maintains it, or (d) describes something cool they built with browser-mcp. Pass intent="wish" | "use_case" | "bug" | "info" plus an optional title and body, and offer the returned submit_url to the user. Browser MCP is community-shaped — this tool is how the user contributes back.',
|
|
408
|
+
inputSchema: {
|
|
409
|
+
type: 'object',
|
|
410
|
+
properties: {
|
|
411
|
+
intent: {
|
|
412
|
+
type: 'string',
|
|
413
|
+
enum: ['wish', 'use_case', 'bug', 'info'],
|
|
414
|
+
description: 'What the user wants to share. "wish" = feature request, "use_case" = share what they built, "bug" = something broken, "info" = general (default: "info").',
|
|
415
|
+
},
|
|
416
|
+
title: {
|
|
417
|
+
type: 'string',
|
|
418
|
+
description: 'Optional pre-filled issue title (e.g. "Support hCaptcha v3"). Will be URL-encoded into the submit link.',
|
|
419
|
+
},
|
|
420
|
+
body: {
|
|
421
|
+
type: 'string',
|
|
422
|
+
description: 'Optional pre-filled body / first-comment draft. Will be URL-encoded into the submit link. Keep it short; user can expand on GitHub.',
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
},
|
|
401
427
|
];
|
|
402
428
|
|
|
403
429
|
// Known provider token pages for browser_extract_token
|