@agent360/browser-mcp 1.20.1 → 1.23.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
@@ -5,28 +5,63 @@
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
10
  **Control your real Chrome from Claude Code — with your logins, cookies, and 2FA.**
10
11
 
11
12
  ![Browser MCP Demo](assets/demo.gif)
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
+ The only browser MCP with **multi-session support** (10 concurrent AI sessions), **human-in-the-loop** (2FA, CAPTCHA, credentials), and **built-in provider integrations** (Stripe, HubSpot, Slack, and 6 more). 34 tools total.
14
15
 
15
- ## Install
16
+ ## Install — 2 steps (~60 seconds)
17
+
18
+ ### Step 1: Configure the MCP server
16
19
 
17
20
  ```bash
18
21
  npx @agent360/browser-mcp install
19
22
  ```
20
23
 
21
- This copies the Chrome extension to `~/.browser-mcp/extension/` and configures Claude Code automatically.
24
+ 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.**
25
+
26
+ ### Step 2: Load the extension in Chrome
27
+
28
+ > 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.
29
+
30
+ 1. **Open Chrome** and type `chrome://extensions` in the address bar
31
+ 2. **Toggle "Developer mode"** ON (top right corner)
32
+ 3. **Click "Load unpacked"** (top left, next to "Pack extension")
33
+ 4. **Navigate to `~/.browser-mcp/extension/`** and click "Select"
34
+ - On Mac: Press `Cmd+Shift+G` in the file picker, paste `~/.browser-mcp/extension/`, press Enter
35
+ - On Windows: Paste `%USERPROFILE%\.browser-mcp\extension\` in the address bar
36
+ - On Linux: Type `~/.browser-mcp/extension/` in the path field
37
+ 5. **Restart Claude Code** so it picks up the new MCP server
38
+
39
+ That's it. The Browser MCP icon will appear in your toolbar, and 34 browser tools are now available in Claude Code.
40
+
41
+ ### Alternative: Manual zip download (no npm)
42
+
43
+ If you don't want to use npm, download the extension directly:
44
+
45
+ 1. [Download `browser-mcp-v1.23.0.zip`](https://github.com/Agent360dk/browser-mcp/releases/latest) from the latest GitHub release
46
+ 2. Unzip the file (anywhere — e.g. `~/Downloads/browser-mcp-extension/`)
47
+ 3. Follow Step 2 above, but select the unzipped folder instead of `~/.browser-mcp/extension/`
48
+ 4. Configure Claude Code manually by adding this to your `~/.claude.json` (or run `npx @agent360/browser-mcp install --skip-extension`):
49
+ ```json
50
+ {
51
+ "mcpServers": {
52
+ "browser-mcp": {
53
+ "command": "npx",
54
+ "args": ["@agent360/browser-mcp"]
55
+ }
56
+ }
57
+ }
58
+ ```
22
59
 
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
60
+ ### Chrome Web Store (one-click install)
28
61
 
29
- That's it. 29 browser tools are now available in Claude Code.
62
+ [**Install from Chrome Web Store →**](https://chromewebstore.google.com/detail/agent360-browser-mcp/jdehgalffmffhfhmmhaokfbfnafnmgcl)
63
+
64
+ No Developer mode needed. Then run `npx @agent360/browser-mcp install --skip-extension` to configure Claude Code.
30
65
 
31
66
  ## Why This Over Playwright MCP / BrowserMCP?
32
67
 
@@ -43,7 +78,7 @@ That's it. 29 browser tools are now available in Claude Code.
43
78
  | **Custom dropdowns** | Angular Material, React Select support | Works (headless) | Limited |
44
79
  | **Install** | `npx @agent360/browser-mcp install` | `npx @anthropic-ai/mcp-playwright` | Manual clone |
45
80
 
46
- ## 29 Tools
81
+ ## 34 Tools
47
82
 
48
83
  ### Navigation & Content
49
84
  | Tool | Description |
@@ -63,7 +98,10 @@ That's it. 29 browser tools are now available in Claude Code.
63
98
  | `browser_wait` | Wait for element to appear |
64
99
  | `browser_hover` | Hover for tooltips/dropdowns |
65
100
  | `browser_select_option` | Native `<select>` + custom dropdowns (Angular Material, React Select) |
66
- | `browser_handle_dialog` | Accept/dismiss alert/confirm/prompt dialogs |
101
+ | `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 |
102
+ | `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 |
103
+ | `browser_dismiss_overlays` | Bulk-dismiss popups/modals/tooltips/banners via aria-label/text/×-char heuristics. `non_critical` mode preserves dialogs with form data |
104
+ | `browser_handle_dialog` | Accept/dismiss native alert/confirm/prompt dialogs |
67
105
 
68
106
  ### Tabs & Frames
69
107
  | Tool | Description |
@@ -103,6 +141,7 @@ That's it. 29 browser tools are now available in Claude Code.
103
141
  | `browser_set_local_storage` | Write localStorage values |
104
142
  | `browser_console_logs` | Capture console.log/warn/error messages from page |
105
143
  | `browser_upload_file` | Upload files to `<input type="file">` via Chrome Debugger API (no dialog) |
144
+ | `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
145
 
107
146
  ## Multi-Session Support
108
147
 
@@ -145,7 +184,7 @@ extension/
145
184
 
146
185
  mcp-server/
147
186
  index.js # MCP server (stdio) + WebSocket client
148
- tools.js # 29 tool definitions
187
+ tools.js # 34 tool definitions
149
188
  bin/cli.js # Install CLI
150
189
  ```
151
190
 
@@ -157,11 +196,19 @@ mcp-server/
157
196
  5. Commands flow: Claude Code → MCP → Extension → Chrome APIs
158
197
  6. Process auto-exits when Claude Code closes (stdin detection)
159
198
 
160
- ## Auto-Updates
199
+ ## Keeping it updated
200
+
201
+ Browser MCP has two parts, and they update independently — how the **extension** updates depends on how you installed it:
161
202
 
162
- The MCP server runs via `npx @agent360/browser-mcp@latest` always the latest version from npm. No manual git pulls needed.
203
+ | Part | Install method | How it updates |
204
+ |------|----------------|----------------|
205
+ | **MCP server** | any | **Automatic.** Runs via `npx @agent360/browser-mcp@latest`, so every Claude Code session pulls the newest from npm. Nothing to do. |
206
+ | **Extension** | **Chrome Web Store** | **Automatic.** Chrome updates it in the background (usually within a few hours). Nothing to do. |
207
+ | **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
208
 
164
- To update the extension: `npx @agent360/browser-mcp install` (re-copies files), then reload in `chrome://extensions`.
209
+ **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).
210
+
211
+ **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
212
 
166
213
  ## Troubleshooting
167
214
 
@@ -183,6 +230,21 @@ To update the extension: `npx @agent360/browser-mcp install` (re-copies files),
183
230
  - Idle timeout: 4 hours without commands → auto-exit
184
231
  - Manual cleanup: `lsof -i :9876-9885 | grep LISTEN`
185
232
 
233
+ ## 💡 Help Shape Browser MCP
234
+
235
+ Browser MCP is built in the open and shaped by the people using it.
236
+
237
+ ### Browse what others want / built
238
+ - 💡 **[Wishlist →](WISHLIST.md)** — features people are asking for
239
+ - 🎯 **[Use-cases →](USE_CASES.md)** — what others have built (LinkedIn ICP scraping, vendor research, daily ops, …)
240
+
241
+ ### Contribute in 30 seconds
242
+ - 💡 [Wish for a feature](https://github.com/Agent360dk/browser-mcp/issues/new?template=wish.yml)
243
+ - 🎯 [Share a use-case](https://github.com/Agent360dk/browser-mcp/issues/new?template=use-case.yml)
244
+ - 🐛 [Report a bug](https://github.com/Agent360dk/browser-mcp/issues/new?template=bug.yml)
245
+
246
+ 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"*.
247
+
186
248
  ## License
187
249
 
188
250
  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
  }
@@ -53,10 +53,45 @@ function getSession(port) {
53
53
  return sessions.get(port);
54
54
  }
55
55
 
56
+ // LRU eviction cap: hver session må højst have N åbne tabs samtidigt.
57
+ // Når en ny tab tilføjes ud over cap'en, lukkes den ÆLDSTE tab i sessionen
58
+ // (insertion-order via Set) — bortset fra session.activeTabId (current tab).
59
+ // Begrundelse: Claude Code-flows kan åbne 20+ navigate(new_tab=true) per session
60
+ // over en længere conversation. Uden eviction akkumulerer disse i Chrome som
61
+ // orphan-tabs der spiser RAM + giver "extension localhost 19+" tab-noise.
62
+ const MAX_TABS_PER_SESSION = 10;
63
+
64
+ async function evictOldestTabs(session, justAddedTabId) {
65
+ // Drop dead tab-ids først (user manually closed dem)
66
+ for (const id of [...session.tabIds]) {
67
+ try {
68
+ await chrome.tabs.get(id);
69
+ } catch {
70
+ session.tabIds.delete(id);
71
+ }
72
+ }
73
+ // Evict oldest indtil ≤ cap. Skip activeTabId og just-added tab.
74
+ const ordered = [...session.tabIds];
75
+ for (const oldId of ordered) {
76
+ if (session.tabIds.size <= MAX_TABS_PER_SESSION) break;
77
+ if (oldId === session.activeTabId) continue;
78
+ if (oldId === justAddedTabId) continue;
79
+ try {
80
+ await chrome.tabs.remove(oldId);
81
+ } catch {} // tab may already be closed
82
+ session.tabIds.delete(oldId);
83
+ }
84
+ }
85
+
56
86
  async function addTabToSession(port, tabId) {
57
87
  const session = getSession(port);
58
88
  session.tabIds.add(tabId);
59
89
 
90
+ // LRU eviction: når sessionen overstiger cap, luk de ældste tabs.
91
+ if (session.tabIds.size > MAX_TABS_PER_SESSION) {
92
+ await evictOldestTabs(session, tabId);
93
+ }
94
+
60
95
  try {
61
96
  if (session.groupId !== null) {
62
97
  try {
@@ -176,17 +211,55 @@ async function getSessionTab(port, activate = false) {
176
211
  // Track which tabs have debugger attached to avoid repeated attach/detach
177
212
  const debuggerAttached = new Set();
178
213
 
214
+ // Verify Chrome's actual debugger-truth before trusting local cache.
215
+ // Fixes "ghost-attached" state where Set says attached but Chrome side is gone
216
+ // (happens on SW lifecycle events, user-canceled banners, anti-automation evictions).
217
+ async function verifyAttachedWithChrome(tabId) {
218
+ try {
219
+ const targets = await chrome.debugger.getTargets();
220
+ const t = targets.find(x => x.tabId === tabId);
221
+ return !!t?.attached;
222
+ } catch {
223
+ return false; // assume not-attached on API error
224
+ }
225
+ }
226
+
179
227
  async function debuggerAttach(tabId) {
180
- if (debuggerAttached.has(tabId)) return;
228
+ // First check local cache — fast path
229
+ if (debuggerAttached.has(tabId)) {
230
+ // Verify with Chrome before trusting cache (cheap, ~1ms)
231
+ if (await verifyAttachedWithChrome(tabId)) return;
232
+ // Cache was stale — Chrome doesn't actually have us attached
233
+ debuggerAttached.delete(tabId);
234
+ }
235
+
181
236
  try {
182
237
  await chrome.debugger.attach({ tabId }, '1.3');
183
- debuggerAttached.add(tabId);
238
+ // Verify attach actually took effect (Chrome can silently no-op after user-cancel)
239
+ if (await verifyAttachedWithChrome(tabId)) {
240
+ debuggerAttached.add(tabId);
241
+ return;
242
+ }
243
+ throw new Error(
244
+ `Debugger detached (GHOST_ATTACH): chrome.debugger.attach returned success but Chrome state shows tab ${tabId} not attached. ` +
245
+ `This typically means the user clicked "Cancel" on Chrome's debugger banner. ` +
246
+ `Fix: reload Browser MCP extension (chrome://extensions/ → ↻) or restart Chrome.`
247
+ );
184
248
  } catch (e) {
185
249
  if (e.message?.includes('Already attached')) {
250
+ // Chrome side has session — sync local cache
186
251
  debuggerAttached.add(tabId);
187
- } else {
188
- throw e;
252
+ return;
189
253
  }
254
+ if (e.message?.includes('Cannot attach') || e.message?.includes('canceled') || e.message?.includes('GHOST_ATTACH') || e.message?.includes('Debugger detached')) {
255
+ throw new Error(
256
+ `Debugger detached (BLOCKED_BY_USER): Cannot attach debugger to tab ${tabId}. ` +
257
+ `Chrome blocks debugger attach — user likely clicked "Cancel" on debugger banner earlier this session. ` +
258
+ `Fix: chrome://extensions/ → Browser MCP → reload (↻) icon. Or restart Chrome. ` +
259
+ `Original error: ${e.message}`
260
+ );
261
+ }
262
+ throw e;
190
263
  }
191
264
  }
192
265
 
@@ -237,27 +310,39 @@ const RETRYABLE_CDP_METHODS = new Set([
237
310
  // For side-effectful methods, only re-attaches and throws — caller must decide.
238
311
  async function cdpSend(tabId, method, params = {}) {
239
312
  await debuggerAttach(tabId);
240
- try {
241
- return await chrome.debugger.sendCommand({ tabId }, method, params);
242
- } catch (e) {
243
- const msg = e?.message || String(e);
244
- const isDetachError =
245
- msg.includes('not attached') ||
246
- msg.includes('Detached') ||
247
- msg.includes('detached') ||
248
- msg.includes('Debugger is gone') ||
249
- msg.includes('No tab with given id');
250
- if (!isDetachError) throw e;
251
- debuggerAttached.delete(tabId);
252
- if (!RETRYABLE_CDP_METHODS.has(method)) {
253
- // Re-attach for next caller, but don't auto-retry side-effectful command
254
- try { await debuggerAttach(tabId); } catch {}
255
- throw new Error(`Debugger detached during ${method} — not auto-retried (side-effect risk). Original: ${msg}`);
313
+ let lastMsg = '';
314
+ // 4 total attempts (initial + 3 retries) for read-only methods; backoff 100/300/500ms.
315
+ // Handles aggressive auto-detach on anti-automation sites (Apple ASC, Salesforce, etc.)
316
+ // where Chrome re-detaches between attach and command execution.
317
+ for (let attempt = 0; attempt < 4; attempt++) {
318
+ try {
319
+ return await chrome.debugger.sendCommand({ tabId }, method, params);
320
+ } catch (e) {
321
+ const msg = e?.message || String(e);
322
+ const isDetachError =
323
+ msg.includes('not attached') ||
324
+ msg.includes('Detached') ||
325
+ msg.includes('detached') ||
326
+ msg.includes('Debugger is gone') ||
327
+ msg.includes('No tab with given id');
328
+ if (!isDetachError) throw e;
329
+ lastMsg = msg;
330
+ debuggerAttached.delete(tabId);
331
+ if (!RETRYABLE_CDP_METHODS.has(method)) {
332
+ // Side-effectful methods (Input.*) — re-attach for next caller but signal
333
+ // to handler so it can fall back to chrome.scripting (e.g., synthetic click).
334
+ try { await debuggerAttach(tabId); } catch {}
335
+ throw new Error(`Debugger detached during ${method} — not auto-retried (side-effect risk). Original: ${msg}`);
336
+ }
337
+ if (attempt < 3) {
338
+ await new Promise(r => setTimeout(r, 100 + attempt * 200));
339
+ try { await debuggerAttach(tabId); } catch (attachErr) {
340
+ throw new Error(`Re-attach failed during ${method}: ${attachErr.message}`);
341
+ }
342
+ }
256
343
  }
257
- await new Promise(r => setTimeout(r, 100));
258
- await debuggerAttach(tabId);
259
- return await chrome.debugger.sendCommand({ tabId }, method, params);
260
344
  }
345
+ throw new Error(`Debugger detached repeatedly during ${method} (4 attempts). Last: ${lastMsg}`);
261
346
  }
262
347
 
263
348
  // Clean up debugger + session refs when tabs close
@@ -307,25 +392,58 @@ async function debuggerType(tabId, text) {
307
392
  async function debuggerClick(tabId, x, y) {
308
393
  await debuggerAttach(tabId);
309
394
  try {
395
+ // 0. Capture the DEEPEST target element under the point BEFORE dispatching.
396
+ // Web-components (Google Ads <button-panel>, Material Web) keep their real
397
+ // <button> inside an (open) shadow root, so we pierce shadow roots to reach
398
+ // it. We stash it on window so the framework fallback (step 3) can verify it
399
+ // is still connected — if the trusted click already navigated/re-rendered,
400
+ // the ref is detached and we must NOT re-fire (avoids mis-clicks on the new
401
+ // view / double-submits).
402
+ await cdpSend(tabId, 'Runtime.evaluate', {
403
+ expression: `(() => {
404
+ let el = document.elementFromPoint(${x}, ${y});
405
+ let host = el;
406
+ for (let i = 0; i < 20 && host && host.shadowRoot; i++) {
407
+ const inner = host.shadowRoot.elementFromPoint(${x}, ${y});
408
+ if (!inner || inner === host) break;
409
+ el = inner; host = inner;
410
+ }
411
+ window.__bmcpClickTarget = el || null;
412
+ })()`,
413
+ });
310
414
  // 1. mouseMoved first (triggers hover state, required by some frameworks)
311
415
  await cdpSend(tabId, 'Input.dispatchMouseEvent', {
312
416
  type: 'mouseMoved', x, y,
313
417
  });
314
418
  await new Promise(r => setTimeout(r, 30));
315
- // 2. mousePressed + mouseReleased (fires trusted mousedown/mouseup)
419
+ // 2. mousePressed + mouseReleased. The `buttons` bitmask (1 while pressed,
420
+ // 0 on release) plus a small press→release gap are REQUIRED for Chrome to
421
+ // synthesize a *trusted* 'click' from the pair. Without them, web-components
422
+ // that gate on the trusted click event (Google Ads, Material Web) never fire.
316
423
  await cdpSend(tabId, 'Input.dispatchMouseEvent', {
317
- type: 'mousePressed', x, y, button: 'left', clickCount: 1,
424
+ type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1,
318
425
  });
426
+ await new Promise(r => setTimeout(r, 30));
319
427
  await cdpSend(tabId, 'Input.dispatchMouseEvent', {
320
- type: 'mouseReleased', x, y, button: 'left', clickCount: 1,
428
+ type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1,
321
429
  });
322
- // 3. CDP doesn't synthesize 'click' event from mousePressed/mouseReleased.
323
- // Fire JS click + React/Angular framework fallbacks.
430
+ // 3. Framework fallback only if the captured target is STILL connected (i.e.
431
+ // the trusted click in step 2 did not already handle it). Settle delay lets
432
+ // SPA re-renders (Google Ads) detach the element first. Fires a full pointer
433
+ // + mouse sequence on the shadow-pierced target, then React/Angular handlers.
434
+ await new Promise(r => setTimeout(r, 120));
324
435
  await cdpSend(tabId, 'Runtime.evaluate', {
325
436
  expression: `(() => {
326
- const el = document.elementFromPoint(${x}, ${y});
327
- if (!el) return;
328
- el.click();
437
+ const el = window.__bmcpClickTarget;
438
+ try { delete window.__bmcpClickTarget; } catch (e) {}
439
+ if (!el || !el.isConnected) return; // already navigated/handled — don't double-fire
440
+ const opts = { bubbles: true, cancelable: true, composed: true, view: window, clientX: ${x}, clientY: ${y} };
441
+ try { el.dispatchEvent(new PointerEvent('pointerdown', opts)); } catch (e) {}
442
+ el.dispatchEvent(new MouseEvent('mousedown', opts));
443
+ try { el.dispatchEvent(new PointerEvent('pointerup', opts)); } catch (e) {}
444
+ el.dispatchEvent(new MouseEvent('mouseup', opts));
445
+ el.dispatchEvent(new MouseEvent('click', opts));
446
+ if (typeof el.click === 'function') el.click();
329
447
 
330
448
  // React fiber fallback — find and call onClick handler directly
331
449
  const fiberKey = Object.keys(el).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
@@ -337,16 +455,11 @@ async function debuggerClick(tabId, x, y) {
337
455
  }
338
456
  }
339
457
 
340
- // Angular fallback — trigger via Zone.js patched events + ngClick
458
+ // Angular Material fallback — ripple + internal handlers
341
459
  const ngKey = Object.keys(el).find(k => k.startsWith('__ng'));
342
460
  if (ngKey || el.getAttribute('ng-click') || el.getAttribute('(click)')) {
343
- // Angular uses Zone.js which patches addEventListener dispatch real events through it
344
- el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true}));
345
- el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true}));
346
- el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, view:window}));
347
- // Angular Material components (mat-button, mat-checkbox, etc.) use ripple + internal handlers
348
- const matRipple = el.closest('[mat-button], [mat-raised-button], [mat-icon-button], [mat-fab], mat-checkbox, mat-slide-toggle, mat-radio-button');
349
- if (matRipple) matRipple.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, view:window}));
461
+ const matRipple = el.closest && el.closest('[mat-button], [mat-raised-button], [mat-icon-button], [mat-fab], mat-checkbox, mat-slide-toggle, mat-radio-button');
462
+ if (matRipple) matRipple.dispatchEvent(new MouseEvent('click', opts));
350
463
  }
351
464
  })()`,
352
465
  });
@@ -437,6 +550,46 @@ async function debuggerEval(tabId, expression) {
437
550
  }
438
551
  }
439
552
 
553
+ // Synthetic click via chrome.scripting — fallback when debugger detaches on
554
+ // anti-automation sites (Apple ASC, etc.). Loses isTrusted=true but works for
555
+ // the ~95% of sites that don't check it. Handles text= and :text() selectors.
556
+ async function scriptingClick(tabId, selector) {
557
+ try {
558
+ const [result] = await chrome.scripting.executeScript({
559
+ target: { tabId },
560
+ world: 'MAIN',
561
+ func: (sel) => {
562
+ let el;
563
+ if (sel.startsWith('text=')) {
564
+ const text = sel.slice(5).trim();
565
+ el = Array.from(document.querySelectorAll('button, a, [role="button"], [role="menuitem"], [role="tab"], [role="option"], input, label, span, div, p, li, td'))
566
+ .find(e => (e.textContent || '').trim() === text);
567
+ } else {
568
+ const m = sel.match(/^([\w-]+):text\(([^)]+)\)$/);
569
+ if (m) {
570
+ const needle = m[2].trim();
571
+ el = Array.from(document.querySelectorAll(m[1]))
572
+ .find(e => (e.textContent || '').trim().includes(needle));
573
+ } else {
574
+ el = document.querySelector(sel);
575
+ }
576
+ }
577
+ if (!el) return { ok: false, reason: 'not_found' };
578
+ el.scrollIntoView({ block: 'center', behavior: 'instant' });
579
+ const opts = { bubbles: true, cancelable: true, view: window };
580
+ el.dispatchEvent(new MouseEvent('mousedown', opts));
581
+ el.dispatchEvent(new MouseEvent('mouseup', opts));
582
+ el.click();
583
+ return { ok: true, tag: el.tagName };
584
+ },
585
+ args: [selector],
586
+ });
587
+ return result?.result || { ok: false, reason: 'no_result' };
588
+ } catch (e) {
589
+ return { ok: false, reason: 'exception', error: e.message };
590
+ }
591
+ }
592
+
440
593
  // Try executeScript first, fall back to debugger on CSP error
441
594
  async function safeExecuteScript(tabId, func, args = [], world = 'MAIN') {
442
595
  try {
@@ -1584,36 +1737,99 @@ async function dispatch(port, method, params) {
1584
1737
  }
1585
1738
 
1586
1739
  case 'execute_script': {
1740
+ // v1.22.2 (DIAGNOSTIC): Try scripting paths but log all errors so we can see WHY they fail
1587
1741
  const tab = await getSessionTab(port);
1588
1742
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot execute scripts on chrome:// pages');
1743
+
1744
+ const diag = { tried: [] };
1745
+
1746
+ // Step 1: try ISOLATED world
1589
1747
  try {
1590
1748
  const [result] = await chrome.scripting.executeScript({
1591
1749
  target: { tabId: tab.id },
1592
- func: new Function('return (' + params.code + ')'),
1593
- world: 'MAIN',
1750
+ world: 'ISOLATED',
1751
+ args: [params.code],
1752
+ func: (codeStr) => {
1753
+ try {
1754
+ const fn = new Function('return (' + codeStr + ')');
1755
+ return { __ok: true, value: fn() };
1756
+ } catch (e) {
1757
+ return { __scriptingError: true, message: String(e?.message || e), name: e?.name, world: 'ISOLATED' };
1758
+ }
1759
+ },
1594
1760
  });
1595
- return { result: result.result };
1761
+ const r = result?.result;
1762
+ diag.tried.push({ world: 'ISOLATED', result_keys: r ? Object.keys(r) : null, r_type: typeof r });
1763
+ if (r && typeof r === 'object' && r.__ok) {
1764
+ return { result: r.value, method: 'scripting-isolated' };
1765
+ }
1766
+ if (r && typeof r === 'object' && r.__scriptingError) {
1767
+ diag.isolated_error = r.message;
1768
+ }
1596
1769
  } catch (e) {
1597
- if (e.message?.includes('Content Security Policy') || e.message?.includes('unsafe-eval')) {
1598
- // CSP blocked — fall back to debugger Runtime.evaluate
1599
- const value = await debuggerEval(tab.id, params.code);
1600
- return { result: value, method: 'debugger' };
1770
+ diag.isolated_throw = String(e?.message || e);
1771
+ }
1772
+
1773
+ // Step 2: try MAIN world
1774
+ try {
1775
+ const [result] = await chrome.scripting.executeScript({
1776
+ target: { tabId: tab.id },
1777
+ world: 'MAIN',
1778
+ args: [params.code],
1779
+ func: (codeStr) => {
1780
+ try {
1781
+ const fn = new Function('return (' + codeStr + ')');
1782
+ return { __ok: true, value: fn() };
1783
+ } catch (e) {
1784
+ return { __scriptingError: true, message: String(e?.message || e), name: e?.name, world: 'MAIN' };
1785
+ }
1786
+ },
1787
+ });
1788
+ const r = result?.result;
1789
+ diag.tried.push({ world: 'MAIN', result_keys: r ? Object.keys(r) : null, r_type: typeof r });
1790
+ if (r && typeof r === 'object' && r.__ok) {
1791
+ return { result: r.value, method: 'scripting-main' };
1601
1792
  }
1602
- throw e;
1793
+ if (r && typeof r === 'object' && r.__scriptingError) {
1794
+ diag.main_error = r.message;
1795
+ }
1796
+ } catch (e) {
1797
+ diag.main_throw = String(e?.message || e);
1603
1798
  }
1799
+
1800
+ // Step 3: DIAG MODE — don't fallback to debugger, return scripting-state info instead
1801
+ return {
1802
+ result: '__SCRIPTING_FAILED__',
1803
+ method: 'scripting-failed',
1804
+ diag,
1805
+ note: 'Both ISOLATED + MAIN scripting paths returned null/error. See diag for details.',
1806
+ };
1604
1807
  }
1605
1808
 
1606
1809
  case 'click': {
1607
1810
  const tab = await getSessionTab(port);
1608
1811
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
1609
1812
 
1610
- // Resolve element (supports CSS + text selectors, auto-scrolls)
1611
- const el = await resolveElement(tab.id, params.selector);
1612
- if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
1813
+ // Wrap full click flow (incl. resolveElement) so debugger failures in EITHER
1814
+ // resolveElement (text-selectors use debuggerEval) OR debuggerClick trigger
1815
+ // the scripting-fallback. v1.21.2: previously only debuggerClick was wrapped,
1816
+ // leaving text-selector clicks unrecoverable when debugger was user-blocked.
1817
+ try {
1818
+ const el = await resolveElement(tab.id, params.selector);
1819
+ if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
1613
1820
 
1614
- // Always use debugger mouse events works on all sites including SPAs
1615
- await debuggerClick(tab.id, el.x, el.y);
1616
- return { ok: true, method: el.method || 'debugger', tag: el.tag, text: el.text };
1821
+ // Primary path: debugger mouse events (isTrusted=true, works on React/Angular SPAs)
1822
+ await debuggerClick(tab.id, el.x, el.y);
1823
+ return { ok: true, method: el.method || 'debugger', tag: el.tag, text: el.text };
1824
+ } catch (e) {
1825
+ // Fallback: synthetic click via chrome.scripting for anti-automation sites
1826
+ // (Apple ASC etc.) OR user-blocked-debugger scenarios.
1827
+ if (/Debugger detached/.test(e?.message || '')) {
1828
+ const r = await scriptingClick(tab.id, params.selector);
1829
+ if (r.ok) return { ok: true, method: 'scripting-fallback', tag: r.tag };
1830
+ }
1831
+ throw e;
1832
+ }
1617
1833
  }
1618
1834
 
1619
1835
  case 'fill': {
@@ -1785,11 +2001,24 @@ async function dispatch(port, method, params) {
1785
2001
  }
1786
2002
 
1787
2003
  case 'press_key': {
1788
- const tab = await getSessionTab(port);
2004
+ // v1.22: activate tab so key-event lands in foreground (otherwise Chrome routes to active tab)
2005
+ const tab = await getSessionTab(port, true);
1789
2006
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
1790
2007
  const key = params.key; // e.g. "Enter", "Tab", "Escape", "ArrowDown"
1791
2008
  const modifiers = (params.ctrl ? 2 : 0) | (params.alt ? 1 : 0) | (params.shift ? 8 : 0) | (params.meta ? 4 : 0);
1792
2009
 
2010
+ // v1.22: Chrome requires windowsVirtualKeyCode for navigation/system keys to trigger
2011
+ // scroll/form-submit behavior. Without these, key-event is dispatched but page doesn't react.
2012
+ const VK_CODES = {
2013
+ 'Backspace': 8, 'Tab': 9, 'Enter': 13, 'Shift': 16, 'Control': 17, 'Alt': 18,
2014
+ 'Escape': 27, 'Space': 32, ' ': 32,
2015
+ 'PageUp': 33, 'PageDown': 34, 'End': 35, 'Home': 36,
2016
+ 'ArrowLeft': 37, 'ArrowUp': 38, 'ArrowRight': 39, 'ArrowDown': 40,
2017
+ 'Delete': 46,
2018
+ };
2019
+ const vkCode = VK_CODES[key];
2020
+ const vkParams = vkCode ? { windowsVirtualKeyCode: vkCode, nativeVirtualKeyCode: vkCode } : {};
2021
+
1793
2022
  await debuggerAttach(tab.id);
1794
2023
  try {
1795
2024
  await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
@@ -1798,12 +2027,14 @@ async function dispatch(port, method, params) {
1798
2027
  code: params.code || key,
1799
2028
  modifiers,
1800
2029
  text: key.length === 1 ? key : '',
2030
+ ...vkParams,
1801
2031
  });
1802
2032
  await cdpSend(tab.id, 'Input.dispatchKeyEvent', {
1803
2033
  type: 'keyUp',
1804
2034
  key,
1805
2035
  code: params.code || key,
1806
2036
  modifiers,
2037
+ ...vkParams,
1807
2038
  });
1808
2039
  } finally {
1809
2040
  await debuggerDetach(tab.id);
@@ -1812,6 +2043,8 @@ async function dispatch(port, method, params) {
1812
2043
  }
1813
2044
 
1814
2045
  case 'scroll': {
2046
+ // v1.22: NO activate — CDP Input.dispatchMouseEvent goes via debugger directly to target,
2047
+ // doesn't need active tab. Re-activating on every scroll-call destabilizes debugger.
1815
2048
  const tab = await getSessionTab(port);
1816
2049
  if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
1817
2050
  // Scroll to element
@@ -1820,20 +2053,32 @@ async function dispatch(port, method, params) {
1820
2053
  if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
1821
2054
  return { ok: true, scrolled_to: params.selector };
1822
2055
  }
1823
- // Scroll by pixels using CDP mouseWheel — works in React containers with overflow:scroll
2056
+ // Scroll by pixels using CDP mouseWheel — split into smaller steps so IntersectionObservers fire.
2057
+ // FB/Twitter/IG only trigger lazy-load on continuous wheel events, not a single large delta.
1824
2058
  const dx = params.x || 0;
1825
2059
  const dy = params.y || 0;
1826
2060
  try {
1827
2061
  await debuggerAttach(tab.id);
1828
- await cdpSend(tab.id, 'Input.dispatchMouseEvent', {
1829
- type: 'mouseWheel', x: 400, y: 300, deltaX: dx, deltaY: dy,
1830
- });
1831
- await debuggerDetach(tab.id);
1832
- } catch {
1833
- // Fallback to window.scrollBy for simple pages
2062
+ const STEP_SIZE = 300; // pixels per wheel-event (matches a typical mouse-wheel notch)
2063
+ const totalSteps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)) / STEP_SIZE));
2064
+ const stepX = dx / totalSteps;
2065
+ const stepY = dy / totalSteps;
2066
+ for (let i = 0; i < totalSteps; i++) {
2067
+ await cdpSend(tab.id, 'Input.dispatchMouseEvent', {
2068
+ type: 'mouseWheel', x: 400, y: 300, deltaX: stepX, deltaY: stepY,
2069
+ });
2070
+ // Small delay between wheel-events so IntersectionObserver + lazy-load XHRs can fire
2071
+ if (i < totalSteps - 1) await new Promise(r => setTimeout(r, 80));
2072
+ }
2073
+ // After last wheel-event, give FB/Twitter/IG ~600ms to start lazy-load XHRs
2074
+ // before any subsequent commands run (caller often scrapes immediately after)
2075
+ await new Promise(r => setTimeout(r, 600));
2076
+ } catch (e) {
2077
+ // Fallback to window.scrollBy for simple pages (synthetic but works on non-anti-scrape sites)
1834
2078
  await debuggerEval(tab.id, `window.scrollBy(${dx}, ${dy})`);
2079
+ return { ok: true, scrolled: { x: dx, y: dy }, method: 'fallback', fallback_reason: e.message };
1835
2080
  }
1836
- return { ok: true, scrolled: { x: dx, y: dy } };
2081
+ return { ok: true, scrolled: { x: dx, y: dy }, method: 'mouseWheel-stepped' };
1837
2082
  }
1838
2083
 
1839
2084
  case 'hover': {
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Agent360 Browser MCP",
4
- "version": "1.20.1",
5
- "description": "Control your real Chrome from Claude Code — navigate, click, fill, screenshot, solve CAPTCHAs. 33 tools, multi-session.",
4
+ "version": "1.23.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
@@ -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,6 +357,45 @@ 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
400
  const { provider, store_in_vault } = args;
342
401
  const info = PROVIDER_PAGES[provider];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agent360/browser-mcp",
3
- "version": "1.20.1",
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.23.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",
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',
@@ -398,6 +403,28 @@ export const TOOLS = [
398
403
  },
399
404
  },
400
405
  },
406
+ {
407
+ name: 'browser_about',
408
+ 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.',
409
+ inputSchema: {
410
+ type: 'object',
411
+ properties: {
412
+ intent: {
413
+ type: 'string',
414
+ enum: ['wish', 'use_case', 'bug', 'info'],
415
+ description: 'What the user wants to share. "wish" = feature request, "use_case" = share what they built, "bug" = something broken, "info" = general (default: "info").',
416
+ },
417
+ title: {
418
+ type: 'string',
419
+ description: 'Optional pre-filled issue title (e.g. "Support hCaptcha v3"). Will be URL-encoded into the submit link.',
420
+ },
421
+ body: {
422
+ type: 'string',
423
+ description: 'Optional pre-filled body / first-comment draft. Will be URL-encoded into the submit link. Keep it short; user can expand on GitHub.',
424
+ },
425
+ },
426
+ },
427
+ },
401
428
  ];
402
429
 
403
430
  // Known provider token pages for browser_extract_token