@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.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Agent360 Browser MCP",
4
- "version": "1.16.0",
5
- "description": "Control your real Chrome from Claude Code — navigate, click, fill, screenshot, solve CAPTCHAs. 24 tools, multi-session, human-in-the-loop.",
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",
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  const BASE_PORT = 9876;
12
- const MAX_PORT = 9885;
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 = 9885;
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
- process.stderr.write('[MCP] Idle timeout (4h) — shutting down\n');
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
- return new Promise((resolve, reject) => {
133
- if (!extensionSocket || extensionSocket.readyState !== 1) {
134
- reject(new Error('Chrome extension not connected. Open Chrome and ensure Agent360 Browser MCP extension is installed.'));
135
- return;
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: '1.16.0' },
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
- // ── Start ───────────────────────────────────────────────────────────────────
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
- // Clean shutdown — release port so next session can use it
341
- process.on('SIGTERM', () => process.exit(0));
342
- process.on('SIGINT', () => process.exit(0));
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 (stdin closes when conversation ends)
349
- process.stdin.on('end', () => {
350
- process.stderr.write('[MCP] stdin closed — shutting down\n');
351
- process.exit(0);
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.16.1",
4
- "description": "Browser MCP — control your real Chrome from Claude Code. 29 tools, CAPTCHA solving, file upload, multi-session, human-in-the-loop.",
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: {