@agent360/browser-mcp 1.16.1 → 1.20.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/extension/background.js +998 -40
- package/extension/manifest.json +2 -2
- package/extension/offscreen.js +15 -1
- package/index.js +82 -21
- package/package.json +2 -2
- package/tools.js +56 -3
package/extension/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Agent360 Browser MCP",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "Control your real Chrome from Claude Code — navigate, click, fill, screenshot, solve CAPTCHAs.
|
|
4
|
+
"version": "1.20.0",
|
|
5
|
+
"description": "Control your real Chrome from Claude Code — navigate, click, fill, set dates, dismiss overlays, autocomplete, upload, screenshot, solve CAPTCHAs. 33 tools, multi-session, human-in-the-loop.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"tabs",
|
|
8
8
|
"tabGroups",
|
package/extension/offscreen.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const BASE_PORT = 9876;
|
|
12
|
-
const MAX_PORT =
|
|
12
|
+
const MAX_PORT = 9895;
|
|
13
13
|
const connections = new Map(); // port → WebSocket
|
|
14
14
|
|
|
15
15
|
function scanPorts() {
|
|
@@ -92,6 +92,20 @@ function updateStatus() {
|
|
|
92
92
|
}).catch(() => {});
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// Listen for terminate signals from background.js (sent when last tab in a session closes)
|
|
96
|
+
chrome.runtime.onMessage.addListener((msg) => {
|
|
97
|
+
if (msg.type !== 'terminate_mcp_session' || typeof msg.port !== 'number') return;
|
|
98
|
+
const ws = connections.get(msg.port);
|
|
99
|
+
if (!ws) return;
|
|
100
|
+
try {
|
|
101
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
102
|
+
ws.send(JSON.stringify({ type: 'terminate' }));
|
|
103
|
+
}
|
|
104
|
+
} catch {}
|
|
105
|
+
try { ws.close(); } catch {}
|
|
106
|
+
// ws.onclose handler removes from connections + notifies background
|
|
107
|
+
});
|
|
108
|
+
|
|
95
109
|
// Initial scan + frequent rescan for new servers
|
|
96
110
|
scanPorts();
|
|
97
111
|
setInterval(scanPorts, 2000);
|
package/index.js
CHANGED
|
@@ -14,10 +14,16 @@ 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 } from 'path';
|
|
17
|
+
import { dirname, join } from 'path';
|
|
18
18
|
import { fileURLToPath } from 'url';
|
|
19
|
+
import { readFileSync } from 'fs';
|
|
19
20
|
import { TOOLS, PROVIDER_PAGES } from './tools.js';
|
|
20
21
|
|
|
22
|
+
// Read version from package.json — single source of truth, never drifts
|
|
23
|
+
const PKG_VERSION = JSON.parse(
|
|
24
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')
|
|
25
|
+
).version;
|
|
26
|
+
|
|
21
27
|
// ── Auto-update on startup ─────────────────────────────────────────────────
|
|
22
28
|
|
|
23
29
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -47,7 +53,7 @@ try {
|
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
const BASE_PORT = 9876;
|
|
50
|
-
const MAX_PORT =
|
|
56
|
+
const MAX_PORT = 9895; // 20 ports instead of 10 — zombies die within 5s via parent check
|
|
51
57
|
let extensionSocket = null;
|
|
52
58
|
let activePort = null;
|
|
53
59
|
let wss = null; // Track WSS for graceful shutdown
|
|
@@ -55,6 +61,10 @@ let cmdId = 0;
|
|
|
55
61
|
let lastActivity = Date.now();
|
|
56
62
|
const pending = new Map();
|
|
57
63
|
|
|
64
|
+
// Timers hoisted to module scope so gracefulShutdown can clear them deterministically.
|
|
65
|
+
let heartbeat = null;
|
|
66
|
+
let parentCheck = null;
|
|
67
|
+
|
|
58
68
|
// ── WebSocket Server ───────────────────────────────────────────────────────
|
|
59
69
|
|
|
60
70
|
function createWSS(port = BASE_PORT) {
|
|
@@ -90,6 +100,12 @@ function createWSS(port = BASE_PORT) {
|
|
|
90
100
|
ws.on('message', (data) => {
|
|
91
101
|
let msg;
|
|
92
102
|
try { msg = JSON.parse(data.toString()); } catch { return; }
|
|
103
|
+
|
|
104
|
+
if (msg.type === 'terminate') {
|
|
105
|
+
gracefulShutdown('Terminate signal from extension (last tab closed)');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
93
109
|
const { id, result, error } = msg;
|
|
94
110
|
const p = pending.get(id);
|
|
95
111
|
if (!p) return;
|
|
@@ -112,14 +128,13 @@ function createWSS(port = BASE_PORT) {
|
|
|
112
128
|
process.stderr.write(`[MCP] WebSocket server listening on ws://127.0.0.1:${port}\n`);
|
|
113
129
|
});
|
|
114
130
|
|
|
115
|
-
// Heartbeat + idle timeout (4 hours)
|
|
116
|
-
setInterval(() => {
|
|
131
|
+
// Heartbeat + idle timeout (4 hours) — hoisted to module scope so gracefulShutdown can clear it
|
|
132
|
+
heartbeat = setInterval(() => {
|
|
117
133
|
if (extensionSocket && extensionSocket.readyState === 1) {
|
|
118
134
|
extensionSocket.ping();
|
|
119
135
|
}
|
|
120
136
|
if (Date.now() - lastActivity > 4 * 60 * 60 * 1000) {
|
|
121
|
-
|
|
122
|
-
process.exit(0);
|
|
137
|
+
gracefulShutdown('Idle timeout (4h)');
|
|
123
138
|
}
|
|
124
139
|
}, 20000);
|
|
125
140
|
}
|
|
@@ -128,12 +143,16 @@ createWSS();
|
|
|
128
143
|
|
|
129
144
|
// ── Send command to extension ───────────────────────────────────────────────
|
|
130
145
|
|
|
131
|
-
function sendToExtension(method, params = {}, timeoutMs = 30000) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
146
|
+
async function sendToExtension(method, params = {}, timeoutMs = 30000, _retries = 5) {
|
|
147
|
+
// Retry if extension is temporarily disconnected (reconnects every 2s)
|
|
148
|
+
if (!extensionSocket || extensionSocket.readyState !== 1) {
|
|
149
|
+
if (_retries > 0) {
|
|
150
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
151
|
+
return sendToExtension(method, params, timeoutMs, _retries - 1);
|
|
136
152
|
}
|
|
153
|
+
throw new Error('Chrome extension not connected after 5 retries. Open Chrome and ensure Agent360 Browser MCP extension is installed.');
|
|
154
|
+
}
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
137
156
|
const id = ++cmdId;
|
|
138
157
|
const timer = setTimeout(() => {
|
|
139
158
|
pending.delete(id);
|
|
@@ -206,11 +225,18 @@ For image grid challenges: cells are 0-indexed, left-to-right, top-to-bottom. A
|
|
|
206
225
|
- If a standard selector fails, the extension recursively searches shadow roots
|
|
207
226
|
- Text-based selectors ("text=Submit") also traverse shadow DOM
|
|
208
227
|
|
|
228
|
+
## Hard inputs — use the specialised tools first
|
|
229
|
+
- **Date inputs** → use browser_set_date (NOT browser_fill). Handles native date inputs, masked text inputs (MM/DD/YYYY etc.), AND calendar pickers (MUI, react-datepicker, AntD, Lexical/Meta). 3-path fallback with read-back verification.
|
|
230
|
+
- **Autocomplete / combobox** (Languages on Meta Ads, country selects, async dropdowns) → use browser_set_combobox (NOT browser_select_option). Types partial query, waits for filtered listbox, clicks option. Supports multi-value chips.
|
|
231
|
+
- **Drag-drop file zones without visible file input** → use browser_drop_file (NOT browser_upload_file). Finds hidden input in subtree/parent.
|
|
232
|
+
- **Annoying popups blocking the flow** (cookie banners, "Don't show again", Advantage+ tooltips, draft-confirm prompts) → call browser_dismiss_overlays before each major step. It only clicks safe close affordances by default; preserves forms with editable text fields.
|
|
233
|
+
|
|
209
234
|
## When things fail
|
|
210
235
|
- Element not found → try text-based selector instead of CSS
|
|
211
236
|
- Screenshot fails → debugger fallback is automatic
|
|
212
237
|
- Click doesn't work on SPA → debugger mouse events are used automatically
|
|
213
238
|
- CAPTCHA blocks page → use browser_ask_user, let human solve it
|
|
239
|
+
- browser_fill seemingly succeeds but value reverts → switch to browser_set_date or browser_set_combobox (most reverts are React-controlled validators)
|
|
214
240
|
|
|
215
241
|
## Extension updates
|
|
216
242
|
The MCP server auto-pulls the latest code from git on every new session startup.
|
|
@@ -219,7 +245,7 @@ If the extension files were updated, ask the user to reload it:
|
|
|
219
245
|
You cannot navigate to chrome:// pages — the user must do this manually.`;
|
|
220
246
|
|
|
221
247
|
const mcpServer = new Server(
|
|
222
|
-
{ name: 'agent360-browser', version:
|
|
248
|
+
{ name: 'agent360-browser', version: PKG_VERSION },
|
|
223
249
|
{ capabilities: { tools: {} } },
|
|
224
250
|
{ instructions: INSTRUCTIONS },
|
|
225
251
|
);
|
|
@@ -262,6 +288,10 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
262
288
|
browser_set_local_storage: 'set_local_storage',
|
|
263
289
|
browser_console_logs: 'console_logs',
|
|
264
290
|
browser_solve_captcha: 'solve_captcha',
|
|
291
|
+
browser_set_date: 'set_date',
|
|
292
|
+
browser_dismiss_overlays: 'dismiss_overlays',
|
|
293
|
+
browser_set_combobox: 'set_combobox',
|
|
294
|
+
browser_drop_file: 'drop_file',
|
|
265
295
|
};
|
|
266
296
|
|
|
267
297
|
if (name === 'browser_extract_token') {
|
|
@@ -335,21 +365,52 @@ async function handleExtractToken(args) {
|
|
|
335
365
|
return { content };
|
|
336
366
|
}
|
|
337
367
|
|
|
338
|
-
// ──
|
|
368
|
+
// ── Graceful shutdown ──────────────────────────────────────────────────────
|
|
369
|
+
// All shutdown paths funnel through gracefulShutdown so the cleanup chain runs
|
|
370
|
+
// deterministically — even on abrupt parent-exit. Without this, process.exit(0)
|
|
371
|
+
// was racing against WS close-handshake, leaving zombie tabs in Chrome.
|
|
372
|
+
|
|
373
|
+
let shuttingDown = false;
|
|
374
|
+
function gracefulShutdown(reason, code = 0) {
|
|
375
|
+
if (shuttingDown) return;
|
|
376
|
+
shuttingDown = true;
|
|
377
|
+
process.stderr.write(`[MCP] ${reason} — shutting down\n`);
|
|
339
378
|
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
379
|
+
// Stop timers so they can't re-enter gracefulShutdown
|
|
380
|
+
if (parentCheck) clearInterval(parentCheck);
|
|
381
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
382
|
+
|
|
383
|
+
// Close WS with explicit close-frame so extension's onclose handler fires
|
|
384
|
+
if (extensionSocket && extensionSocket.readyState === 1) {
|
|
385
|
+
try { extensionSocket.close(1000, 'mcp-shutdown'); } catch {}
|
|
386
|
+
}
|
|
387
|
+
if (wss) try { wss.close(); } catch {}
|
|
388
|
+
|
|
389
|
+
// 300ms grace for FIN-flush + extension session_disconnect cleanup
|
|
390
|
+
setTimeout(() => process.exit(code), 300);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
394
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
343
395
|
process.on('exit', () => {
|
|
396
|
+
// Safety net for direct process.exit calls that bypass gracefulShutdown
|
|
344
397
|
if (wss) try { wss.close(); } catch {}
|
|
345
398
|
if (extensionSocket) try { extensionSocket.close(); } catch {}
|
|
346
399
|
});
|
|
347
400
|
|
|
348
|
-
// Detect Claude Code exit
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
401
|
+
// Detect Claude Code exit — check if parent process is still alive
|
|
402
|
+
// stdin.on('end') doesn't work because MCP SDK's StdioServerTransport owns stdin
|
|
403
|
+
const parentPid = process.ppid;
|
|
404
|
+
parentCheck = setInterval(() => {
|
|
405
|
+
try {
|
|
406
|
+
process.kill(parentPid, 0); // signal 0 = check if process exists
|
|
407
|
+
} catch {
|
|
408
|
+
gracefulShutdown(`Parent process ${parentPid} died`);
|
|
409
|
+
}
|
|
410
|
+
}, 5000); // check every 5 seconds
|
|
411
|
+
|
|
412
|
+
// Also listen for stdin close as backup
|
|
413
|
+
process.stdin.on('end', () => gracefulShutdown('stdin closed'));
|
|
353
414
|
|
|
354
415
|
const transport = new StdioServerTransport();
|
|
355
416
|
await mcpServer.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent360/browser-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Browser MCP — control your real Chrome from Claude Code.
|
|
3
|
+
"version": "1.20.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.",
|
|
5
5
|
"mcpName": "io.github.Agent360dk/browser-mcp",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "index.js",
|
package/tools.js
CHANGED
|
@@ -56,7 +56,7 @@ export const TOOLS = [
|
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
name: 'browser_fill',
|
|
59
|
-
description: 'Fill a form input field with a value. Supports CSS selectors AND text-based selectors. Auto-scrolls and focuses the element. Works on CSP-strict sites via Chrome Debugger API.',
|
|
59
|
+
description: 'Fill a form input field with a value. Supports CSS selectors AND text-based selectors. Auto-scrolls and focuses the element. Works on CSP-strict sites via Chrome Debugger API. For date inputs use browser_set_date, for autocomplete/combobox use browser_set_combobox.',
|
|
60
60
|
inputSchema: {
|
|
61
61
|
type: 'object',
|
|
62
62
|
properties: {
|
|
@@ -120,7 +120,7 @@ export const TOOLS = [
|
|
|
120
120
|
},
|
|
121
121
|
{
|
|
122
122
|
name: 'browser_select_option',
|
|
123
|
-
description: 'Select an option from a dropdown menu. Works with native <select> elements AND custom dropdowns (Angular Material, React Select, etc.). For custom dropdowns: clicks the trigger, waits for options, then clicks the matching option by text.',
|
|
123
|
+
description: 'Select an option from a dropdown menu. Works with native <select> elements AND custom dropdowns (Angular Material, React Select, etc.). For custom dropdowns: clicks the trigger, waits for options, then clicks the matching option by text. For autocomplete (typing filters options) use browser_set_combobox instead.',
|
|
124
124
|
inputSchema: {
|
|
125
125
|
type: 'object',
|
|
126
126
|
properties: {
|
|
@@ -131,6 +131,59 @@ export const TOOLS = [
|
|
|
131
131
|
required: ['selector', 'option'],
|
|
132
132
|
},
|
|
133
133
|
},
|
|
134
|
+
{
|
|
135
|
+
name: 'browser_dismiss_overlays',
|
|
136
|
+
description: 'Dismiss visible popups, modals, tooltips, banners, and "Are you sure?"-style overlays in one call. Heuristic-based: finds close affordance via aria-label, text content (Skip/Cancel/Ikke nu/Don\'t show/Got it/Close), or × character button. Use when a flow is interrupted by unexpected dialogs (cookie banners, onboarding tooltips, draft-confirm prompts on Meta Ads, etc.). Returns list of what was dismissed.',
|
|
137
|
+
inputSchema: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
properties: {
|
|
140
|
+
scope: { type: 'string', enum: ['non_critical', 'aggressive'], description: 'non_critical (default): skip dialogs containing editable form inputs (preserves user data). aggressive: dismiss everything.' },
|
|
141
|
+
max_passes: { type: 'number', description: 'Number of dismissal passes (some overlays reveal others when closed). Default: 3' },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'browser_set_combobox',
|
|
147
|
+
description: 'Set value(s) on an autocomplete/combobox input. Handles the click → type query → wait for filtered listbox → click option flow as one MCP call. Supports multi-select (e.g., Languages on Meta Ads). Use when browser_select_option fails because options render lazily after typing.',
|
|
148
|
+
inputSchema: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
properties: {
|
|
151
|
+
selector: { type: 'string', description: 'CSS selector for the combobox/autocomplete input' },
|
|
152
|
+
value: { type: 'string', description: 'Single value to select (use this OR values)' },
|
|
153
|
+
values: { type: 'array', items: { type: 'string' }, description: 'Array of values for multi-select. E.g. ["Danish", "English", "Swedish"]' },
|
|
154
|
+
multi: { type: 'boolean', description: 'True if combobox accepts multiple values (chips). Default: auto-detected from presence of values array' },
|
|
155
|
+
query_chars: { type: 'number', description: 'How many characters to type as filter query (default: 4 or full value length, whichever is smaller)' },
|
|
156
|
+
wait_ms: { type: 'number', description: 'Max ms to wait for options listbox to appear after typing (default: 3000)' },
|
|
157
|
+
},
|
|
158
|
+
required: ['selector'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'browser_drop_file',
|
|
163
|
+
description: 'Upload a file by finding a hidden <input type="file"> within a drag-drop zone\'s subtree (or parent up to 2 levels). Use when browser_upload_file fails because the dropzone has no visible file input. Returns clear error if no input is found anywhere — pure drop-zones without backing inputs require manual handling.',
|
|
164
|
+
inputSchema: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
selector: { type: 'string', description: 'CSS selector for the drop-zone target element (e.g. ".upload-area")' },
|
|
168
|
+
file: { type: 'string', description: 'Single absolute file path' },
|
|
169
|
+
files: { type: 'array', items: { type: 'string' }, description: 'Array of absolute file paths' },
|
|
170
|
+
},
|
|
171
|
+
required: ['selector'],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'browser_set_date',
|
|
176
|
+
description: 'Robustly set a date input — handles native <input type="date">, masked text inputs (e.g. MM/DD/YYYY), and calendar pickers (MUI, react-datepicker, AntD, Lexical/Meta). Tries native value-set, format-aware typing via Input.insertText, and ARIA-based picker navigation in sequence with read-back verification. Use instead of browser_fill when fill fails or for any input that opens a calendar widget.',
|
|
177
|
+
inputSchema: {
|
|
178
|
+
type: 'object',
|
|
179
|
+
properties: {
|
|
180
|
+
selector: { type: 'string', description: 'CSS selector for the date input element' },
|
|
181
|
+
date: { type: 'string', description: 'ISO date string (YYYY-MM-DD), e.g. "2026-05-15"' },
|
|
182
|
+
skip_picker: { type: 'boolean', description: 'If true, only try native + masked paths and skip calendar-picker navigation (default: false)' },
|
|
183
|
+
},
|
|
184
|
+
required: ['selector', 'date'],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
134
187
|
{
|
|
135
188
|
name: 'browser_handle_dialog',
|
|
136
189
|
description: 'Handle JavaScript alert(), confirm(), or prompt() dialogs. Call this BEFORE triggering the action that causes the dialog. Waits for the dialog to appear, then accepts or dismisses it.',
|
|
@@ -307,7 +360,7 @@ export const TOOLS = [
|
|
|
307
360
|
},
|
|
308
361
|
{
|
|
309
362
|
name: 'browser_upload_file',
|
|
310
|
-
description: 'Upload a file to a <input type="file"> element on the page. Uses Chrome Debugger API to set files programmatically — no dialog needed.',
|
|
363
|
+
description: 'Upload a file to a <input type="file"> element on the page. Uses Chrome Debugger API to set files programmatically — no dialog needed. For drag-drop zones without visible file input use browser_drop_file.',
|
|
311
364
|
inputSchema: {
|
|
312
365
|
type: 'object',
|
|
313
366
|
properties: {
|