@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 CHANGED
@@ -1,32 +1,73 @@
1
- # Browser MCP by Agent360
1
+ # Browser MCP by [Agent360](https://agent360.dk)
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@agent360/browser-mcp)](https://www.npmjs.com/package/@agent360/browser-mcp)
4
4
  [![npm downloads](https://img.shields.io/npm/dw/@agent360/browser-mcp)](https://www.npmjs.com/package/@agent360/browser-mcp)
5
5
  [![GitHub stars](https://img.shields.io/github/stars/Agent360dk/browser-mcp)](https://github.com/Agent360dk/browser-mcp)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
  [![MCP](https://img.shields.io/badge/MCP-compatible-blue)](https://modelcontextprotocol.io)
8
+ [![Chrome Web Store](https://img.shields.io/badge/Chrome_Web_Store-live-green)](https://chromewebstore.google.com/detail/agent360-browser-mcp/jdehgalffmffhfhmmhaokfbfnafnmgcl)
8
9
 
9
- **Control your real Chrome from Claude Code with your logins, cookies, and 2FA.**
10
+ **Your AI agent drives your real, logged-in Chrome — and works where headless tools die.**
10
11
 
11
- ![Browser MCP Demo](assets/demo.gif)
12
+ [![Browser MCP Demo](assets/demo.gif)](https://browsermcp.dev)
12
13
 
13
- The only browser MCP with **multi-session support** (10 concurrent AI sessions), **human-in-the-loop** (2FA, CAPTCHA, credentials), and **built-in provider integrations** (Stripe, HubSpot, Slack, and 6 more). 29 tools total.
14
+ **[Watch the 37-second demo with sound →](https://browsermcp.dev)**
14
15
 
15
- ## Install
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 configures Claude Code automatically.
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
- Then load the extension in Chrome:
24
- 1. Open `chrome://extensions`
25
- 2. Enable **Developer mode** (top right)
26
- 3. Click **Load unpacked** select `~/.browser-mcp/extension/`
27
- 4. Restart Claude Code
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
- That's it. 29 browser tools are now available in Claude Code.
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
- ## 29 Tools
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
- | `browser_handle_dialog` | Accept/dismiss alert/confirm/prompt dialogs |
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, ~80% pass with Google login), `click_grid` (AI vision guided), `ask_human` (fallback) |
134
+ | `browser_solve_captcha` | Detect and solve CAPTCHAs. Auto-detects reCAPTCHA v2/v3, hCaptcha, Turnstile, FunCaptcha. Actions: `detect`, `click_checkbox` (auto-click, often passes when signed into Google), `click_grid` (AI vision guided), `ask_human` (fallback) |
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 # 29 tool definitions
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
- ## Auto-Updates
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
- The MCP server runs via `npx @agent360/browser-mcp@latest` always the latest version from npm. No manual git pulls needed.
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
- To update the extension: `npx @agent360/browser-mcp install` (re-copies files), then reload in `chrome://extensions`.
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 — 28 browser tools are now available!
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
  }
@@ -9,34 +9,44 @@
9
9
  // ── Session Tab Management ─────────────────────────────────────────────────
10
10
 
11
11
  const SESSION_COLORS = ['blue', 'green', 'yellow', 'red', 'pink', 'purple', 'cyan', 'orange'];
12
+ // Select-all modifier is platform-dependent: Cmd (meta=4) on macOS, Ctrl (2) elsewhere.
13
+ // Get this wrong and the field isn't selected — Backspace no-ops and new text concatenates onto the old.
14
+ const SELECT_ALL_MODS = /Mac/i.test(navigator.userAgent) ? 4 : 2;
12
15
  const sessions = new Map(); // port → { tabIds: Set, groupId: number|null, color: string, label: string }
13
- let sessionsLoaded = false;
16
+ // FIX-2: promise-cache latch (not a boolean). The old `if(sessionsLoaded) return`
17
+ // flipped the flag BEFORE awaiting storage, so a second concurrent caller on a freshly
18
+ // woken service worker proceeded against an EMPTY sessions Map. Caching the promise makes
19
+ // every concurrent caller await the SAME populated completion. Resets to null on SW
20
+ // eviction (module re-init) and on error, so the next wake retries.
21
+ let restorePromise = null;
14
22
 
15
23
  // Restore sessions from storage (service workers lose in-memory state on suspend)
16
- async function restoreSessions() {
17
- if (sessionsLoaded) return;
18
- sessionsLoaded = true;
19
- const { sessions: saved } = await chrome.storage.local.get({ sessions: {} });
20
- for (const [port, data] of Object.entries(saved)) {
21
- // Verify tabs still exist
22
- const validTabIds = new Set();
23
- for (const tabId of (data.tabIds || [])) {
24
- try {
25
- await chrome.tabs.get(tabId);
26
- validTabIds.add(tabId);
27
- } catch {} // tab no longer exists
28
- }
29
- if (validTabIds.size > 0) {
30
- const activeTabId = data.activeTabId && validTabIds.has(data.activeTabId) ? data.activeTabId : null;
31
- sessions.set(Number(port), {
32
- tabIds: validTabIds,
33
- activeTabId,
34
- groupId: data.groupId || null,
35
- color: data.color || SESSION_COLORS[sessions.size % SESSION_COLORS.length],
36
- label: data.label || `Claude ${sessions.size + 1}`,
37
- });
24
+ function restoreSessions() {
25
+ if (restorePromise) return restorePromise;
26
+ restorePromise = (async () => {
27
+ const { sessions: saved } = await chrome.storage.local.get({ sessions: {} });
28
+ for (const [port, data] of Object.entries(saved)) {
29
+ // Verify tabs still exist
30
+ const validTabIds = new Set();
31
+ for (const tabId of (data.tabIds || [])) {
32
+ try {
33
+ await chrome.tabs.get(tabId);
34
+ validTabIds.add(tabId);
35
+ } catch {} // tab no longer exists
36
+ }
37
+ if (validTabIds.size > 0) {
38
+ const activeTabId = data.activeTabId && validTabIds.has(data.activeTabId) ? data.activeTabId : null;
39
+ sessions.set(Number(port), {
40
+ tabIds: validTabIds,
41
+ activeTabId,
42
+ groupId: data.groupId || null,
43
+ color: data.color || SESSION_COLORS[sessions.size % SESSION_COLORS.length],
44
+ label: data.label || `Claude ${sessions.size + 1}`,
45
+ });
46
+ }
38
47
  }
39
- }
48
+ })().catch(err => { restorePromise = null; throw err; });
49
+ return restorePromise;
40
50
  }
41
51
 
42
52
  function getSession(port) {
@@ -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 validTabIds = [...session.tabIds].filter(id => {
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 && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:')) {
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(session.activeTabId);
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 && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:')) {
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
- // No usable tab create one
202
+ // Reuse our own blank placeholder rather than spawning yet another one (FIX-4).
203
+ if (!target && blankFallback) {
204
+ target = blankFallback;
205
+ session.activeTabId = target.id;
206
+ persistSessions();
207
+ }
208
+
209
+ // No usable tab at all — create ONE placeholder and pin it as the active tab so the
210
+ // NEXT call reuses it (FIX-4) instead of creating a fresh about:blank every time.
157
211
  if (!target) {
158
212
  target = await chrome.tabs.create({ url: 'about:blank', active: false });
159
213
  await addTabToSession(port, target.id);
160
- return target;
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 so Chrome APIs target it (not whatever user is viewing)
164
- if (activate && !target.active) {
165
- await chrome.tabs.update(target.id, { active: true });
166
- // Brief wait for Chrome to render the tab
167
- await new Promise(r => setTimeout(r, 150));
168
- target = await chrome.tabs.get(target.id);
219
+ // Activate the tab WITHOUT stealing the user's focus (FIX-1). This is a BACKGROUND tool:
220
+ // screenshot/press_key run constantly, so we must NOT chrome.windows.update({focused:true})
221
+ // here — that yanked Chrome to the foreground on every action. We only (a) un-minimize a
222
+ // minimized window (needed so it can composite) and (b) make the tab active within its
223
+ // window. The truly-occluded (covered) case is handled as a bounded last-resort
224
+ // raise-and-restore inside the screenshot handler only.
225
+ if (activate) {
226
+ try {
227
+ if (target.windowId != null) {
228
+ const win = await chrome.windows.get(target.windowId).catch(() => null);
229
+ if (win && win.state === 'minimized') {
230
+ await chrome.windows.update(target.windowId, { state: 'normal' }); // no focused:true
231
+ }
232
+ }
233
+ if (!target.active) await chrome.tabs.update(target.id, { active: true });
234
+ await new Promise(r => setTimeout(r, 150));
235
+ target = await chrome.tabs.get(target.id);
236
+ } catch { /* best-effort; capture path surfaces the real error */ }
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
- async function debuggerAttach(tabId) {
180
- if (debuggerAttached.has(tabId)) return;
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.attach({ tabId }, '1.3');
183
- debuggerAttached.add(tabId);
184
- } catch (e) {
185
- if (e.message?.includes('Already attached')) {
186
- debuggerAttached.add(tabId);
187
- } else {
188
- throw e;
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 (fires trusted mousedown/mouseup)
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. CDP doesn't synthesize 'click' event from mousePressed/mouseReleased.
335
- // Fire JS click + React/Angular framework fallbacks.
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 = document.elementFromPoint(${x}, ${y});
339
- if (!el) return;
340
- el.click();
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 — trigger via Zone.js patched events + ngClick
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
- // Angular uses Zone.js which patches addEventListener dispatch real events through it
356
- el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true}));
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
- // Ctrl+A to select all, then Backspace to clear
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: 2,
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 tagCheck = tagFilter ? `&& el.tagName === ${JSON.stringify(tagFilter.toUpperCase())}` : '';
675
+ const wantTag = tagFilter ? JSON.stringify(tagFilter.toUpperCase()) : 'null';
520
676
  return `(function() {
521
677
  const text = ${escaped};
522
- // Collect all elements including inside shadow DOM
678
+ const wantTag = ${wantTag};
679
+ // Interactive controls we prefer to actually click. Fixes the class of bug where a
680
+ // text match lands on a large CONTAINER (e.g. Angular Material <mat-nav-list>,
681
+ // toolbar, list-item) whose center is NOT over the real <button> — so the trusted
682
+ // click misses and menus/dropdowns never open.
683
+ const CLICKABLE = 'a,button,summary,label,[role="button"],[role="menuitem"],' +
684
+ '[role="menuitemcheckbox"],[role="menuitemradio"],[role="option"],[role="tab"],' +
685
+ '[role="link"],[role="checkbox"],[role="radio"],[role="switch"],[onclick],' +
686
+ '[mat-button],[mat-raised-button],[mat-stroked-button],[mat-flat-button],' +
687
+ '[mat-icon-button],[mat-fab],[mat-mini-fab],[mat-menu-item],[mat-list-item],' +
688
+ 'mat-checkbox,mat-slide-toggle,mat-radio-button';
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
- // Exact match first (prefer leaf nodes)
532
- for (const el of all) {
533
- if (el.children.length > 3) continue;
534
- const t = el.textContent?.trim();
535
- if (t === text ${tagCheck}) {
536
- return el;
537
- }
538
- }
539
- // Partial match fallback
540
- for (const el of all) {
541
- if (el.children.length > 3) continue;
542
- const t = el.textContent?.trim();
543
- if (t && t.includes(text) ${tagCheck}) {
544
- return el;
697
+ const tagOk = (el) => !wantTag || el.tagName === wantTag;
698
+ // Map a matched element to the ACTIONABLE control: itself if clickable, else the
699
+ // nearest clickable ancestor (only if its own text isn't much larger than the match,
700
+ // so we don't grab a whole toolbar), else a clickable descendant.
701
+ function toClickable(el) {
702
+ if (el.matches && el.matches(CLICKABLE)) return el;
703
+ const anc = el.closest && el.closest(CLICKABLE);
704
+ if (anc && (anc.textContent || '').trim().length <= text.length + 40) return anc;
705
+ const desc = el.querySelector && el.querySelector(CLICKABLE);
706
+ if (desc) return desc;
707
+ return el;
708
+ }
709
+ function pick(test) {
710
+ const matches = all.filter(el => tagOk(el) && test((el.textContent || '').trim()));
711
+ if (!matches.length) return null;
712
+ // Prefer the INNERMOST matches (an element that is not an ancestor of another
713
+ // match) — this is what "prefer leaf nodes" was supposed to do.
714
+ const inner = matches.filter(el => !matches.some(o => o !== el && el.contains && el.contains(o)));
715
+ const pool = inner.length ? inner : matches;
716
+ // Prefer a match that resolves to a real interactive control.
717
+ for (const el of pool) {
718
+ const c = toClickable(el);
719
+ if (c && c.matches && c.matches(CLICKABLE)) return c;
545
720
  }
721
+ return toClickable(pool[0]);
546
722
  }
547
- return null;
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: 2,
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
- const tab = await getSessionTab(port);
1615
- if (tab.url.startsWith('chrome://')) throw new Error('Cannot screenshot chrome:// pages');
1616
- // Use debugger Page.captureScreenshot as PRIMARY method.
1617
- // captureVisibleTab requires active tab in active window — fails when
1618
- // user is in terminal. Debugger works regardless of tab focus.
1619
- try {
1620
- await debuggerAttach(tab.id);
1621
- const { data } = await cdpSend(tab.id, 'Page.captureScreenshot', {
1622
- format: 'png',
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 chrome.tabs.update(tab.id, { active: true });
1629
- await new Promise(r => setTimeout(r, 150));
1630
- const dataUrl = await chrome.tabs.captureVisibleTab(null, { format: 'png' });
1769
+ await debuggerAttach(tab.id);
1770
+ try {
1771
+ const shot = await cdpSend(tab.id, 'Page.captureScreenshot', { format: 'png' });
1772
+ return { image: 'data:image/png;base64,' + shot.data };
1773
+ } catch {
1774
+ const shot = await cdpSend(tab.id, 'Page.captureScreenshot', {
1775
+ format: 'png', fromSurface: false, captureBeyondViewport: false,
1776
+ });
1777
+ return { image: 'data:image/png;base64,' + shot.data };
1778
+ }
1779
+ } catch {
1780
+ // CDP failed entirely — native tabs API (needs the tab visible in its window).
1781
+ const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
1631
1782
  return { image: dataUrl };
1632
- } catch (e) {
1633
- throw new Error('Screenshot failed: ' + e.message);
1783
+ }
1784
+ };
1785
+
1786
+ // Attempt 1 — focus-neutral. Handles the vast majority (background-but-visible window).
1787
+ try {
1788
+ return await tryCapture();
1789
+ } catch (firstErr) {
1790
+ // Both methods failed → the window is genuinely OCCLUDED (covered by other windows),
1791
+ // so Chrome's compositor produced no frames. LAST RESORT ONLY: raise the window to
1792
+ // de-occlude it, capture, then RESTORE the user's previously-focused window. This
1793
+ // focus-steal happens ONLY in the rare covered case — never on a normal screenshot.
1794
+ const prev = await chrome.windows.getLastFocused().catch(() => null);
1795
+ try {
1796
+ await chrome.windows.update(tab.windowId, { focused: true, state: 'normal' });
1797
+ await chrome.tabs.update(tab.id, { active: true }).catch(() => {});
1798
+ await new Promise(r => setTimeout(r, 250)); // let it composite
1799
+ return await tryCapture();
1800
+ } catch (secondErr) {
1801
+ throw new Error(
1802
+ `Screenshot failed after focus-neutral AND raised attempts. ` +
1803
+ `First: ${firstErr?.message || firstErr}. Raised: ${secondErr?.message || secondErr}. ` +
1804
+ `If both say "image readback failed" the GPU compositor is not producing frames — ` +
1805
+ `disable Chrome hardware acceleration (chrome://settings/system) as a last resort.`
1806
+ );
1807
+ } finally {
1808
+ // Give focus back to the user's previous Chrome window (best-effort; getLastFocused
1809
+ // only sees Chrome windows, so a non-Chrome IDE can't be re-focused programmatically).
1810
+ if (prev && prev.id != null && prev.id !== tab.windowId) {
1811
+ await chrome.windows.update(prev.id, { focused: true }).catch(() => {});
1812
+ }
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
- return { result: result.result };
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
- if (e.message?.includes('Content Security Policy') || e.message?.includes('unsafe-eval')) {
1650
- // CSP blocked — fall back to debugger Runtime.evaluate
1651
- const value = await debuggerEval(tab.id, params.code);
1652
- return { result: value, method: 'debugger' };
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
- throw e;
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
- // Resolve element (supports CSS + text selectors, auto-scrolls)
1663
- const el = await resolveElement(tab.id, params.selector);
1664
- if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
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.) where Chrome auto-detaches debugger on every interaction.
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, text: el.text };
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
- const tab = await getSessionTab(port);
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 — works in React containers with overflow:scroll
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
- await cdpSend(tab.id, 'Input.dispatchMouseEvent', {
1891
- type: 'mouseWheel', x: 400, y: 300, deltaX: dx, deltaY: dy,
1892
- });
1893
- await debuggerDetach(tab.id);
1894
- } catch {
1895
- // Fallback to window.scrollBy for simple pages
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': {
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Agent360 Browser MCP",
4
- "version": "1.21.0",
5
- "description": "Control your real Chrome from Claude Code — navigate, click, fill, screenshot, solve CAPTCHAs. 33 tools, multi-session.",
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, passes ~80% with logged-in Google
212
+ 2. If reCAPTCHA v2 checkbox found → call browser_solve_captcha(action="click_checkbox") — auto-clicks; often passes when signed into Google
213
213
  3. If image challenge appears → call browser_screenshot, analyze the grid visually, then call browser_solve_captcha(action="click_grid", cells=[2,5,7]) with the correct cell indices
214
214
  4. If all else fails → call browser_solve_captcha(action="ask_human") to show overlay to user
215
215
  5. After solving, retry the action that was blocked
@@ -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, store_in_vault } = args;
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
- const content = [
355
- { 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.` },
356
- ];
357
-
358
- if (store_in_vault) {
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.21.0",
4
- "description": "Browser MCP — control your real Chrome from Claude Code. 33 tools, CAPTCHA solving, date pickers, autocomplete combobox, overlay dismissal, file upload, multi-session, human-in-the-loop.",
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": "Agent360 <hello@agent360.dk>",
45
+ "author": {
46
+ "name": "Agent360",
47
+ "email": "hello@agent360.dk",
48
+ "url": "https://agent360.dk"
49
+ },
46
50
  "license": "MIT",
47
51
  "homepage": "https://browsermcp.dev",
48
52
  "repository": {
package/tools.js CHANGED
@@ -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: { type: 'object', properties: {} },
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 and extract the API token. Optionally store it in Agent360 vault.',
381
+ description: 'Navigate to a provider\'s API settings page so you can read its API token from the page.',
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 (works ~80% with logged-in Google), then returns a screenshot for AI vision analysis, then falls back to asking the user. Returns detection info and solving status.',
392
+ description: 'Detect and solve CAPTCHAs on the current page. Auto-detects reCAPTCHA v2/v3, hCaptcha, Cloudflare Turnstile, and FunCaptcha. Tries auto-click first (often clears reCAPTCHA v2 when signed into Google), then returns a screenshot for AI vision analysis, then falls back to asking the user. Returns detection info and solving status.',
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