bwb-browser 2.0.4 → 3.0.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/server.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Configuration (ordered by precedence: CLI arg > env var > default):
9
9
  * --browser-path / BWB_CHROME_PATH — Path to Chrome/Chromium executable
10
- * --port / BWB_CDP_PORT — Remote debugging port (default: 9222)
10
+ * --port / BWB_CDP_PORT — Remote debugging port (default: 0 = random free port)
11
11
  * --user-data-dir / BWB_USER_DATA_DIR — Browser profile directory
12
12
  * --headless / BWB_HEADLESS — Run headless (default: true)
13
13
  * --screenshots-dir / BWB_SCREENSHOTS_DIR — Directory for saved screenshots
@@ -17,82 +17,120 @@
17
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
19
  import { z } from "zod";
20
- import { spawn, execSync } from "child_process";
21
20
  import CDP from "chrome-remote-interface";
22
- import { existsSync, mkdirSync, writeFileSync } from "fs";
23
- import { homedir, platform } from "os";
21
+ import { mkdirSync } from "fs";
22
+ import { homedir } from "os";
24
23
  import { join, dirname } from "path";
25
24
  import { fileURLToPath } from "url";
26
25
 
26
+ import {
27
+ ensureBrowser, restartBrowser, saveScreenshot,
28
+ cfg, browser, browserExited, actualCdpPort,
29
+ } from "./lib/browser.mjs";
30
+
31
+ import {
32
+ gotoUrl, clickElement, fillElement, waitForSelector,
33
+ } from "./lib/helpers.mjs";
34
+
35
+ import {
36
+ getActiveProtocol, createTab, closeTab, switchTab, listTabs, syncActiveTab, clearTabs,
37
+ } from "./lib/tabs.mjs";
38
+
39
+ import { saveSession, loadSession, listSessions } from "./lib/session.mjs";
40
+ import { diagnosePage } from "./lib/diagnose.mjs";
41
+ import { applyRealisticProfile } from "./lib/fingerprint.mjs";
42
+ import { executeInstruction } from "./lib/act.mjs";
43
+
27
44
  // ─── Config ───────────────────────────────────────────────────────────────────
28
45
 
29
46
  const __dirname = dirname(fileURLToPath(import.meta.url));
30
47
 
31
48
  function parseArgs() {
32
49
  const args = process.argv.slice(2);
33
- const cfg = {};
50
+ const cliCfg = {};
34
51
  for (let i = 0; i < args.length; i++) {
35
52
  switch (args[i]) {
36
- case "--browser-path": cfg.browserPath = args[++i]; break;
37
- case "--port": cfg.port = parseInt(args[++i], 10); break;
38
- case "--user-data-dir": cfg.userDataDir = args[++i]; break;
39
- case "--headless": cfg.headless = args[++i] !== "false"; break;
40
- case "--screenshots-dir": cfg.screenshotsDir = args[++i]; break;
41
- case "--timeout": cfg.navTimeout = parseInt(args[++i], 10); break;
42
- case "--version": console.log("bwb-browser 2.0.4"); process.exit(0);
53
+ case "--browser-path": cliCfg.browserPath = args[++i]; break;
54
+ case "--port": cliCfg.port = parseInt(args[++i], 10); break;
55
+ case "--user-data-dir": cliCfg.userDataDir = args[++i]; break;
56
+ case "--headless": cliCfg.headless = args[++i] !== "false"; break;
57
+ case "--screenshots-dir": cliCfg.screenshotsDir = args[++i]; break;
58
+ case "--timeout": cliCfg.navTimeout = parseInt(args[++i], 10); break;
59
+ case "--version": console.log("bwb-browser 3.0.0"); process.exit(0);
43
60
  case "--help": printHelp(); process.exit(0);
44
61
  }
45
62
  }
46
- return cfg;
63
+ return cliCfg;
47
64
  }
48
65
 
49
66
  function printHelp() {
50
67
  console.log(`
51
- bwb-browser — Browser Without Bloat MCP Server
68
+ bwb-browser v3.0.0 — Browser Without Bloat
69
+
70
+ Browser automation for AI agents. 76KB. 25 tools. Zero heavy dependencies.
71
+ Uses raw CDP — no Playwright, no Puppeteer, no 400MB downloads.
72
+
73
+ Built on Termux/Android. Runs everywhere. Weighs nothing.
52
74
 
53
75
  USAGE:
54
76
  bwb [options]
55
77
 
56
78
  OPTIONS:
57
79
  --browser-path <path> Path to Chrome/Chromium binary
58
- --port <number> CDP debug port (default: 9222)
80
+ --port <number> CDP debug port (default: 0 = random)
59
81
  --user-data-dir <path> Browser profile directory
60
82
  --headless <bool> Run headless (default: true)
61
- --screenshots-dir <path> Directory to save screenshots (default: /storage/emulated/0/Download/bwb-screenshots)
83
+ --screenshots-dir <path> Directory to save screenshots
62
84
  --timeout <ms> Navigation timeout in ms (default: 30000)
63
85
  --version Print version
64
86
  --help Show this help
65
87
 
66
- ENVIRONMENT VARIABLES:
67
- BWB_CHROME_PATH Path to Chrome/Chromium binary
68
- BWB_CDP_PORT CDP debug port
69
- BWB_USER_DATA_DIR Browser profile directory
70
- BWB_HEADLESS Run headless (true/false)
71
- BWB_SCREENSHOTS_DIR Directory to save screenshots
72
- BWB_NAV_TIMEOUT Navigation timeout in ms
73
-
74
- TOOLS (15):
75
- browser_goto Navigate to a URL
76
- browser_screenshot Take a screenshot
77
- browser_html Get page/selector HTML
78
- browser_text Get page/selector text
79
- browser_click Click an element
80
- browser_fill Fill an input field
81
- browser_elements List interactive elements
82
- browser_title Get page title
83
- browser_url Get current URL
84
- browser_eval Execute JavaScript (with exception capture)
85
- browser_status Browser connection status
86
- browser_watch Live page event capture (console, network, navigation)
87
- browser_waitForSelector Wait for element to appear/disappear
88
- browser_setViewport Change viewport size
89
- browser_back Go back in history
88
+ TOOLS (25):
89
+ CORE BROWSING:
90
+ browser_goto Navigate to a URL
91
+ browser_screenshot Take a screenshot
92
+ browser_html Get page/selector HTML
93
+ browser_text Get page/selector text
94
+ browser_title Get page title
95
+ browser_url Get current URL
96
+ browser_back Go back in history
97
+
98
+ INTERACTION:
99
+ browser_click Click an element
100
+ browser_fill Fill an input field
101
+ browser_elements List interactive elements
102
+ browser_eval Execute JavaScript
103
+ browser_setViewport Change viewport size
104
+
105
+ 🔥 ADVANCED:
106
+ browser_act Natural language page interaction (one tool does it all)
107
+ browser_watch Live page event capture (console, network)
108
+ browser_diagnose Full page health diagnostic
109
+ browser_fingerprint Realistic browser profile for testing
110
+ browser_waitForSelector Wait for element to appear/disappear
111
+
112
+ MULTI-TAB:
113
+ browser_newTab Create a new tab
114
+ browser_closeTab Close a tab
115
+ browser_switchTab Switch to a different tab
116
+ browser_listTabs List all open tabs
117
+
118
+ SESSION:
119
+ browser_saveCookies Save session cookies to disk
120
+ browser_loadCookies Load session cookies from disk
121
+ browser_listSessions List saved sessions
122
+
123
+ LIFECYCLE:
124
+ browser_status Browser connection status
125
+ browser_restart Restart the browser
126
+
127
+ If bwb saves you time or money, consider supporting development:
128
+ https://github.com/sponsors/krshforever
90
129
  `);
91
130
  }
92
131
 
93
132
  // ─── Dependency Check ─────────────────────────────────────────────────────────
94
133
 
95
- // Verify all dependencies are resolvable before starting MCP server
96
134
  async function ensureDeps() {
97
135
  const { createRequire } = await import("module");
98
136
  const req = createRequire(import.meta.url);
@@ -103,515 +141,96 @@ async function ensureDeps() {
103
141
  ];
104
142
  const missing = [];
105
143
  for (const spec of needed) {
106
- try {
107
- req.resolve(spec);
108
- } catch {
144
+ try { req.resolve(spec); } catch {
109
145
  missing.push(spec.split("/")[0].split("@")[0] || spec);
110
146
  }
111
147
  }
112
148
  if (missing.length > 0) {
113
149
  console.error(
114
150
  `\nMissing dependencies: ${missing.join(", ")}\n` +
115
- `Run: npm install -g bwb-browser-termux\n` +
116
- `Or: cd "${__dirname}" && npm install\n` +
117
- `Or: npx bwb-browser-termux\n`
151
+ `Run: npm install -g bwb-browser\n` +
152
+ `Or: cd "${__dirname}" && npm install\n` +
153
+ `Or: npx bwb-browser\n`
118
154
  );
119
155
  process.exit(1);
120
156
  }
121
157
  }
122
158
 
123
- // ─── Config ───────────────────────────────────────────────────────────────────
159
+ // ─── Apply Config ────────────────────────────────────────────────────────────
124
160
 
125
- const cfg = { ...parseArgs() };
161
+ Object.assign(cfg, parseArgs());
126
162
  cfg.port = cfg.port || parseInt(process.env.BWB_CDP_PORT || "0", 10);
127
163
  cfg.headless = cfg.headless !== undefined ? cfg.headless : (process.env.BWB_HEADLESS !== "false");
128
164
  cfg.userDataDir = cfg.userDataDir || process.env.BWB_USER_DATA_DIR || join(homedir(), ".cache", "bwb-browser");
129
165
  cfg.screenshotsDir = cfg.screenshotsDir || process.env.BWB_SCREENSHOTS_DIR || "/storage/emulated/0/Download/bwb-screenshots";
130
166
  cfg.navTimeout = cfg.navTimeout || parseInt(process.env.BWB_NAV_TIMEOUT || "30000", 10);
131
167
 
132
- // Ensure screenshots directory exists
133
168
  try { mkdirSync(cfg.screenshotsDir, { recursive: true }); } catch {}
134
169
 
135
- let browser = null;
136
- let protocol = null;
137
- let browserStartup = null;
138
- let browserExited = false;
139
- let actualCdpPort = null; // actual port Chrome picked (parsed from stderr)
140
-
141
- // ─── Kill Orphaned Chrome (Termux-safe) ───────────────────────────────────────
142
-
143
- // `fuser -k` and `lsof` can't read /proc/net/tcp on Termux/Android (permission denied).
144
- // Instead, kill by PID from `ps` — works on every platform.
145
- function killOrphanedChrome() {
146
- try {
147
- execSync(
148
- `ps aux | grep -E "[c]hrome" | grep -v grep | awk '{print $2}' | xargs -r kill -9 2>/dev/null; true`,
149
- { encoding: "utf8", timeout: 5000 }
150
- );
151
- } catch {}
152
- }
153
-
154
- // ─── Browser Detection ────────────────────────────────────────────────────────
155
-
156
- function findBrowserPath(cliPath) {
157
- if (cliPath) return cliPath;
158
- const envPath = process.env.BWB_CHROME_PATH;
159
- if (envPath) return envPath;
160
-
161
- const os = platform();
162
- const home = homedir();
163
-
164
- const candidates = {
165
- android: [
166
- "/data/data/com.termux/files/usr/bin/chromium-browser",
167
- "/data/data/com.termux/files/usr/bin/chromium",
168
- "/data/data/com.termux/files/usr/bin/google-chrome",
169
- ],
170
- linux: [
171
- "google-chrome",
172
- "chromium-browser",
173
- "chromium",
174
- "google-chrome-stable",
175
- "/usr/bin/google-chrome",
176
- "/usr/bin/chromium-browser",
177
- "/snap/bin/chromium",
178
- ],
179
- darwin: [
180
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
181
- "/Applications/Chromium.app/Contents/MacOS/Chromium",
182
- ],
183
- win32: [
184
- "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
185
- "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
186
- join(home, "AppData\\Local\\Google\\Chrome\\Application\\chrome.exe"),
187
- ],
188
- };
189
-
190
- const osCandidates = candidates[os] || candidates.linux;
191
- for (const bin of osCandidates) {
192
- try {
193
- const path = execSync(`which "${bin}" 2>/dev/null || echo "no"`, { encoding: "utf8", timeout: 3000 }).trim();
194
- if (path && path !== "no") return path;
195
- } catch { /* try next */ }
196
- if (existsSync(bin)) return bin;
197
- }
198
-
199
- return null;
200
- }
201
-
202
- function assertBrowserExists(cliPath) {
203
- const path = findBrowserPath(cliPath);
204
- if (!path) {
205
- throw new Error(
206
- "Cannot find Chrome/Chromium. Set BWB_CHROME_PATH env var or pass --browser-path.\n" +
207
- "Install on Termux: pkg install chromium\n" +
208
- "Install on Linux: apt install chromium-browser\n" +
209
- "Install on macOS: brew install --cask google-chrome\n" +
210
- "Install on Windows: Download from https://www.google.com/chrome/"
211
- );
212
- }
213
- return path;
214
- }
215
-
216
- // ─── Browser Lifecycle ────────────────────────────────────────────────────────
217
-
218
- async function ensureBrowser() {
219
- // If protocol is active, return it
220
- if (protocol && !browserExited) return protocol;
221
-
222
- // If another call is already starting the browser, join it
223
- if (browserStartup) return browserStartup;
224
-
225
- // Close stale protocol if browser was restarted
226
- if (protocol) {
227
- try { await protocol.close(); } catch {}
228
- protocol = null;
229
- }
230
- // Reset exit flag if trying to restart
231
- browserExited = false;
232
- actualCdpPort = null;
233
-
234
- let startResolve, startReject;
235
- browserStartup = new Promise((res, rej) => { startResolve = res; startReject = rej; });
236
- browserStartup.catch(() => { browserStartup = null; });
237
-
238
- (async () => {
239
- try {
240
- const browserPath = assertBrowserExists(cfg.browserPath);
241
-
242
- // Kill any lingering Chrome processes from previous sessions
243
- // Cannot use `fuser -k` on Termux (no /proc/net/tcp access)
244
- killOrphanedChrome();
245
- // Small pause for OS to release resources
246
- await new Promise(r => setTimeout(r, 500));
247
-
248
- // Port 0 = Chrome picks a random free port (avoids conflicts)
249
- const debugPort = cfg.port || 0;
250
- const args = [
251
- "--headless",
252
- "--no-sandbox",
253
- "--disable-gpu",
254
- "--disable-dev-shm-usage",
255
- "--disable-setuid-sandbox",
256
- "--disable-software-rasterizer",
257
- "--remote-debugging-port=" + debugPort,
258
- "--user-data-dir=" + cfg.userDataDir,
259
- ];
260
-
261
- if (!cfg.headless) args.shift();
262
-
263
- browser = spawn(browserPath, args, {
264
- stdio: ["ignore", "pipe", "pipe"],
265
- env: { ...process.env, DISPLAY: process.env.DISPLAY || ":0" },
266
- });
267
-
268
- let resolved = false;
269
-
270
- // Mark browser as exited when process dies
271
- browser.on("exit", (code, signal) => {
272
- browserExited = true;
273
- if (!resolved) {
274
- // Browser died before CDP connected
275
- clearTimeout(startTimeout);
276
- startReject(new Error(`Browser exited with code ${code} (signal ${signal}) before CDP connected`));
277
- }
278
- // Don't reset protocol here — let the next ensureBrowser() call handle it
279
- });
280
-
281
- browser.on("error", (err) => {
282
- if (!resolved) {
283
- clearTimeout(startTimeout);
284
- startReject(new Error(`Browser spawn failed: ${err.message}`));
285
- }
286
- });
287
-
288
- const startTimeout = setTimeout(() => {
289
- if (!resolved) {
290
- browserExited = true;
291
- try { browser.kill("SIGKILL"); } catch {}
292
- startReject(new Error(`Browser startup timed out after 15s. Check: ${browserPath}`));
293
- }
294
- }, 15000);
295
-
296
- const listener = (data) => {
297
- const msg = data.toString();
298
- // Extract actual port from: "DevTools listening on ws://127.0.0.1:PORT/PATH"
299
- // CDP() accepts {port: N} — NOT a ws:// URL as endpoint
300
- const portMatch = msg.match(/DevTools listening on ws:\/\/[^:]+:(\d+)\//);
301
- if (portMatch) {
302
- actualCdpPort = parseInt(portMatch[1], 10);
303
- clearTimeout(startTimeout);
304
- resolved = true;
305
- CDP({ port: actualCdpPort })
306
- .then((p) => {
307
- protocol = p;
308
- startResolve(p);
309
- })
310
- .catch((err) => {
311
- try { browser.kill("SIGKILL"); } catch {}
312
- browserExited = true;
313
- startReject(new Error(`CDP connection failed: ${err.message}`));
314
- });
315
- }
316
- };
317
-
318
- browser.stderr.on("data", listener);
319
- } catch (err) {
320
- browserStartup = null;
321
- browserExited = true;
322
- startReject(err);
323
- }
324
- })();
325
-
326
- return browserStartup;
327
- }
328
-
329
- // ─── Navigation Helper ────────────────────────────────────────────────────────
330
-
331
- async function gotoUrl(page, runtime, url, timeoutMs) {
332
- await page.enable();
333
-
334
- // Register event listeners BEFORE calling navigate
335
- // loadEventFired fires when page fully loads (CSS, images, etc.)
336
- const loadPromise = page.loadEventFired().then(() => true);
337
- // First meaningful paint — earlier than load for faster SPAs
338
- const domPromise = page.domContentEventFired().then(() => true);
339
-
340
- await page.navigate({ url });
341
-
342
- // Wait for load event OR timeout, whichever comes first
343
- await Promise.race([
344
- Promise.all([loadPromise, domPromise]),
345
- new Promise(r => setTimeout(() => r(false), timeoutMs)),
346
- ]);
347
-
348
- // Small grace for JS framework rendering
349
- await new Promise(r => setTimeout(r, 500));
350
-
351
- const { result } = await runtime.evaluate({ expression: "document.title" });
352
- return { title: result?.value || "", url };
353
- }
354
-
355
- // ─── Click Helper (uses CDP Input.dispatchMouseEvent) ─────────────────────────
356
-
357
- async function clickElement(page, runtime, input, selector) {
358
- // Get element bounding box via JS
359
- const { result } = await runtime.evaluate({
360
- expression: `(() => {
361
- const el = document.querySelector(${JSON.stringify(selector)});
362
- if (!el) return JSON.stringify({ error: 'NOT_FOUND' });
363
- const rect = el.getBoundingClientRect();
364
- return JSON.stringify({
365
- x: rect.x + rect.width / 2,
366
- y: rect.y + rect.height / 2,
367
- width: rect.width,
368
- height: rect.height,
369
- tag: el.tagName,
370
- text: (el.textContent || '').trim().slice(0, 50),
371
- });
372
- })()`,
373
- });
374
-
375
- let info;
376
- try { info = JSON.parse(result.value); } catch {
377
- throw new Error(`Element not found: ${selector}`);
378
- }
379
-
380
- if (info.error === "NOT_FOUND") {
381
- throw new Error(`Element not found: ${selector}`);
382
- }
383
-
384
- // Also try native click for form elements
385
- await runtime.evaluate({
386
- expression: `document.querySelector(${JSON.stringify(selector)})?.click()`,
387
- });
388
-
389
- // Dispatch real mouse events via CDP Input domain
390
- const x = Math.round(info.x);
391
- const y = Math.round(info.y);
392
- await input.dispatchMouseEvent({ type: "mousePressed", x, y, button: "left", clickCount: 1 });
393
- await input.dispatchMouseEvent({ type: "mouseReleased", x, y, button: "left", clickCount: 1 });
394
-
395
- return info;
396
- }
397
-
398
- // ─── Fill Helper (uses CDP Input.insertText) ──────────────────────────────────
399
-
400
- async function fillElement(page, runtime, input, selector, text) {
401
- // Focus the element first
402
- const { result } = await runtime.evaluate({
403
- expression: `(() => {
404
- const el = document.querySelector(${JSON.stringify(selector)});
405
- if (!el) return 'NOT_FOUND';
406
- el.focus();
407
- el.value = '';
408
- return 'FOCUSED';
409
- })()`,
410
- });
411
-
412
- if (result.value === "NOT_FOUND") {
413
- throw new Error(`Element not found: ${selector}`);
414
- }
415
-
416
- // Clear existing text via CDP Input domain
417
- await input.dispatchKeyEvent({ type: "keyDown", key: "Control" });
418
- await input.dispatchKeyEvent({ type: "keyDown", key: "a" });
419
- await input.dispatchKeyEvent({ type: "keyUp", key: "a" });
420
- await input.dispatchKeyEvent({ type: "keyUp", key: "Control" });
421
- await input.dispatchKeyEvent({ type: "keyDown", key: "Delete" });
422
- await input.dispatchKeyEvent({ type: "keyUp", key: "Delete" });
423
-
424
- // Insert text via CDP Input domain
425
- await input.insertText({ text });
426
- }
427
-
428
- // ─── Screenshot Helper ────────────────────────────────────────────────────────
429
-
430
- function saveScreenshot(base64Data) {
431
- const now = new Date();
432
- const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
433
- const filename = `bwb-${timestamp}.jpeg`;
434
- const filepath = join(cfg.screenshotsDir, filename);
435
- try {
436
- mkdirSync(cfg.screenshotsDir, { recursive: true });
437
- writeFileSync(filepath, Buffer.from(base64Data, "base64"));
438
- return filepath;
439
- } catch (err) {
440
- return null;
441
- }
442
- }
170
+ // ─── Watch State (Live Page Event Capture) ─────────────────────────────────────
443
171
 
444
- // ─── Cleanup ──────────────────────────────────────────────────────────────────
445
-
446
- let cleaningUp = false;
447
-
448
- function cleanupSync() {
449
- if (cleaningUp) return;
450
- cleaningUp = true;
451
- try {
452
- if (browser) {
453
- browser.kill("SIGTERM");
454
- // Max 3s for graceful shutdown
455
- setTimeout(() => {
456
- try { browser?.kill("SIGKILL"); } catch {}
457
- }, 3000);
458
- browser = null;
459
- }
460
- } catch {}
461
- }
172
+ const WATCH_MAX_EVENTS = 5000;
462
173
 
463
- async function cleanupAsync() {
464
- if (cleaningUp) return;
465
- cleaningUp = true;
466
- try {
467
- if (protocol) await protocol.close();
468
- } catch {}
469
- try {
470
- if (browser) {
471
- browser.kill("SIGTERM");
472
- await new Promise(r => setTimeout(r, 2000));
473
- try { browser?.kill("SIGKILL"); } catch {}
474
- browser = null;
475
- }
476
- } catch {}
174
+ function watchPush(event) {
175
+ if (watchState.events.length >= WATCH_MAX_EVENTS) watchState.events.shift();
176
+ watchState.events.push(event);
477
177
  }
478
178
 
479
- process.on("exit", cleanupSync);
480
- process.on("SIGINT", () => { cleanupSync(); process.exit(0); });
481
- process.on("SIGTERM", () => { cleanupSync(); process.exit(0); });
482
- process.on("SIGHUP", () => { cleanupSync(); process.exit(0); });
483
-
484
- // ─── Watch State (Groundbreaking: Live Page Event Capture) ─────────────────────
485
- //
486
- // This is the feature NO other MCP browser server has:
487
- // Agent calls browser_watch({action:"start"}) → browser starts recording console
488
- // messages, network requests, navigations, and JS exceptions in real-time.
489
- // Agent calls browser_watch({action:"poll"}) → gets ALL events since last poll.
490
- // Agent calls browser_watch({action:"stop"}) → cleans up.
491
- //
492
- // No more flying blind — the agent can SEE what the page is doing internally.
493
-
494
- const watchState = {
495
- active: false,
496
- events: [],
497
- disposables: [],
498
- };
179
+ const watchState = { active: false, events: [], disposables: [] };
499
180
 
500
181
  function cleanupWatch() {
501
182
  watchState.active = false;
502
- for (const dispose of watchState.disposables) {
503
- try { dispose(); } catch {}
504
- }
183
+ for (const dispose of watchState.disposables) { try { dispose(); } catch {} }
505
184
  watchState.disposables = [];
506
185
  watchState.events = [];
507
186
  }
508
187
 
509
- function setupWatch(events, protocol) {
188
+ function setupWatch(events, cdp) {
510
189
  cleanupWatch();
511
190
  watchState.active = true;
512
191
 
513
192
  if (events.includes("console") || events.includes("all")) {
514
- protocol.Runtime.consoleAPICalled((params) => {
515
- watchState.events.push({
516
- type: "console",
517
- timestamp: Date.now(),
518
- level: params.type || "log",
519
- text: (params.args || [])
520
- .map((a) => a.value !== undefined ? String(a.value) : a.description || "")
521
- .join(" "),
522
- });
193
+ cdp.Runtime.consoleAPICalled((params) => {
194
+ watchPush({ type: "console", timestamp: Date.now(), level: params.type || "log",
195
+ text: (params.args || []).map(a => a.value !== undefined ? String(a.value) : a.description || "").join(" ") });
523
196
  });
524
- protocol.Runtime.exceptionThrown((params) => {
197
+ cdp.Runtime.exceptionThrown((params) => {
525
198
  const d = params.exceptionDetails;
526
- watchState.events.push({
527
- type: "exception",
528
- timestamp: Date.now(),
529
- text: d?.exception?.description || d?.text || "Unknown exception",
530
- });
199
+ watchPush({ type: "exception", timestamp: Date.now(), text: d?.exception?.description || d?.text || "Unknown exception" });
531
200
  });
532
201
  }
533
-
534
202
  if (events.includes("network") || events.includes("all")) {
535
- protocol.Network.requestWillBeSent((params) => {
536
- watchState.events.push({
537
- type: "network",
538
- timestamp: Date.now(),
539
- subtype: "request",
540
- url: params.request?.url || "",
541
- method: params.request?.method || "GET",
542
- });
203
+ cdp.Network.requestWillBeSent((params) => {
204
+ watchPush({ type: "network", timestamp: Date.now(), subtype: "request", url: params.request?.url || "", method: params.request?.method || "GET" });
543
205
  });
544
- protocol.Network.responseReceived((params) => {
545
- // Only fire for actual pages/resources, not data: URIs
206
+ cdp.Network.responseReceived((params) => {
546
207
  if (params.response?.url?.startsWith("data:")) return;
547
- watchState.events.push({
548
- type: "network",
549
- timestamp: Date.now(),
550
- subtype: "response",
551
- url: params.response?.url || "",
552
- status: params.response?.status || 0,
553
- mimeType: params.response?.mimeType || "",
554
- });
208
+ watchPush({ type: "network", timestamp: Date.now(), subtype: "response", url: params.response?.url || "", status: params.response?.status || 0, mimeType: params.response?.mimeType || "" });
555
209
  });
556
210
  }
557
-
558
211
  if (events.includes("navigation") || events.includes("all")) {
559
- protocol.Page.frameNavigated((params) => {
560
- watchState.events.push({
561
- type: "navigation",
562
- timestamp: Date.now(),
563
- url: params.frame?.url || "",
564
- });
212
+ cdp.Page.frameNavigated((params) => {
213
+ watchPush({ type: "navigation", timestamp: Date.now(), url: params.frame?.url || "" });
565
214
  });
566
215
  }
567
216
  }
568
217
 
569
- // ─── waitForSelector Helper ──────────────────────────────────────────────────
570
-
571
- async function waitForSelector(runtime, selector, opts = {}) {
572
- const timeout = opts.timeout || 10000;
573
- const disappear = opts.disappear || false;
574
- const start = Date.now();
575
-
576
- while (Date.now() - start < timeout) {
577
- const { result } = await runtime.evaluate({
578
- expression: `(() => {
579
- const el = document.querySelector(${JSON.stringify(selector)});
580
- if (!el) return JSON.stringify({ status: "NOT_FOUND" });
581
- const rect = el.getBoundingClientRect();
582
- const hidden = rect.width === 0 || rect.height === 0;
583
- const text = (el.textContent || "").trim().slice(0, 200);
584
- return JSON.stringify({ status: "FOUND", tag: el.tagName, text, hidden });
585
- })()`,
586
- });
587
- const info = JSON.parse(result?.value || "{}");
588
-
589
- if (disappear && info.status === "NOT_FOUND") return true;
590
- if (!disappear && info.status === "FOUND" && !info.hidden) return true;
591
- if (!disappear && info.status === "FOUND" && !opts.visible) return true;
592
-
593
- await new Promise((r) => setTimeout(r, 200));
594
- }
595
-
596
- throw new Error(`browser_waitForSelector: "${selector}" not ${disappear ? "disappeared" : "found"} within ${timeout}ms`);
597
- }
598
-
599
218
  // ─── MCP Server ───────────────────────────────────────────────────────────────
600
219
 
601
- const server = new McpServer({
602
- name: "bwb-browser",
603
- version: "2.0.4",
604
- });
220
+ const server = new McpServer({ name: "bwb-browser", version: "3.0.0" });
605
221
 
606
222
  // Tool implementations
607
223
  const tools = {
224
+ // ═══════════════ CORE BROWSING ═══════════════
225
+
608
226
  browser_goto: {
609
227
  description: "Navigate to a URL. Returns page title and URL.",
610
228
  schema: { url: z.string().describe("URL to navigate to") },
611
229
  handler: async ({ url }) => {
612
- const p = await ensureBrowser();
613
- const { Page, Runtime } = p;
230
+ const cdp = await getActiveProtocol();
231
+ const { Page, Runtime } = cdp;
614
232
  const result = await gotoUrl(Page, Runtime, url, cfg.navTimeout);
233
+ syncActiveTab(result.title, result.url);
615
234
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
616
235
  },
617
236
  },
@@ -623,23 +242,15 @@ const tools = {
623
242
  quality: z.number().describe("JPEG quality 0-100 (default 80)").optional(),
624
243
  },
625
244
  handler: async ({ fullPage = false, quality = 80 }) => {
626
- const p = await ensureBrowser();
627
- const { Page } = p;
628
- const { data } = await Page.captureScreenshot({
629
- format: "jpeg",
630
- quality,
631
- captureBeyondViewport: fullPage,
632
- });
633
- // Save to disk for user access
245
+ const { Page } = await getActiveProtocol();
246
+ const { data } = await Page.captureScreenshot({ format: "jpeg", quality, captureBeyondViewport: fullPage });
634
247
  const savedPath = saveScreenshot(data);
635
248
  const response = { screenshot: `data:image/jpeg;base64,${data.slice(0, 40)}...` };
636
249
  if (savedPath) response.savedTo = savedPath;
637
- return {
638
- content: [
639
- { type: "image", data, mimeType: "image/jpeg" },
640
- { type: "text", text: JSON.stringify(response) },
641
- ],
642
- };
250
+ return { content: [
251
+ { type: "image", data, mimeType: "image/jpeg" },
252
+ { type: "text", text: JSON.stringify(response) },
253
+ ]};
643
254
  },
644
255
  },
645
256
 
@@ -647,8 +258,7 @@ const tools = {
647
258
  description: "Get HTML source of the page or a CSS selector.",
648
259
  schema: { selector: z.string().describe("Optional CSS selector").optional() },
649
260
  handler: async ({ selector }) => {
650
- const p = await ensureBrowser();
651
- const { Runtime } = p;
261
+ const { Runtime } = await getActiveProtocol();
652
262
  const expr = selector
653
263
  ? `document.querySelector(${JSON.stringify(selector)})?.outerHTML || ''`
654
264
  : "document.documentElement.outerHTML";
@@ -661,8 +271,7 @@ const tools = {
661
271
  description: "Get visible text content of the page or a CSS selector.",
662
272
  schema: { selector: z.string().describe("Optional CSS selector").optional() },
663
273
  handler: async ({ selector }) => {
664
- const p = await ensureBrowser();
665
- const { Runtime } = p;
274
+ const { Runtime } = await getActiveProtocol();
666
275
  const expr = selector
667
276
  ? `document.querySelector(${JSON.stringify(selector)})?.textContent || ''`
668
277
  : "document.body?.textContent || ''";
@@ -671,31 +280,58 @@ const tools = {
671
280
  },
672
281
  },
673
282
 
283
+ browser_title: {
284
+ description: "Get current page title.",
285
+ schema: {},
286
+ handler: async () => {
287
+ const { Runtime } = await getActiveProtocol();
288
+ const { result } = await Runtime.evaluate({ expression: "document.title" });
289
+ return { content: [{ type: "text", text: result?.value || "" }] };
290
+ },
291
+ },
292
+
293
+ browser_url: {
294
+ description: "Get current page URL.",
295
+ schema: {},
296
+ handler: async () => {
297
+ const { Runtime } = await getActiveProtocol();
298
+ const { result } = await Runtime.evaluate({ expression: "window.location.href" });
299
+ return { content: [{ type: "text", text: result?.value || "" }] };
300
+ },
301
+ },
302
+
303
+ browser_back: {
304
+ description: "Go back in browser history (like clicking the browser back button).",
305
+ schema: {},
306
+ handler: async () => {
307
+ const { Page, Runtime } = await getActiveProtocol();
308
+ await Page.navigate({ url: "javascript:history.back()" });
309
+ await new Promise(r => setTimeout(r, 500));
310
+ const { result } = await Runtime.evaluate({ expression: "document.title" });
311
+ syncActiveTab(result?.value, undefined);
312
+ return { content: [{ type: "text", text: JSON.stringify({ title: result?.value || "" }) }] };
313
+ },
314
+ },
315
+
316
+ // ═══════════════ INTERACTION ═══════════════
317
+
674
318
  browser_click: {
675
319
  description: "Click an element by CSS selector. Uses CDP Input.dispatchMouseEvent for native events.",
676
320
  schema: { selector: z.string().describe("CSS selector") },
677
321
  handler: async ({ selector }) => {
678
- const p = await ensureBrowser();
679
- const { Page, Runtime, Input } = p;
322
+ const cdp = await getActiveProtocol();
323
+ const { Page, Runtime, Input } = cdp;
680
324
  const info = await clickElement(Page, Runtime, Input, selector);
681
- return {
682
- content: [{
683
- type: "text",
684
- text: JSON.stringify({ clicked: selector, tag: info.tag, text: info.text }),
685
- }],
686
- };
325
+ return { content: [{ type: "text", text: JSON.stringify({ clicked: selector, tag: info.tag, text: info.text }) }] };
687
326
  },
688
327
  },
689
328
 
690
329
  browser_fill: {
691
330
  description: "Clear and fill an input field with text using native CDP Input.insertText.",
692
- schema: {
693
- selector: z.string().describe("CSS selector for input"),
694
- text: z.string().describe("Text to fill"),
695
- },
331
+ schema: { selector: z.string().describe("CSS selector for input"), text: z.string().describe("Text to fill") },
696
332
  handler: async ({ selector, text }) => {
697
- const p = await ensureBrowser();
698
- const { Page, Runtime, Input } = p;
333
+ const cdp = await getActiveProtocol();
334
+ const { Page, Runtime, Input } = cdp;
699
335
  await fillElement(Page, Runtime, Input, selector, text);
700
336
  return { content: [{ type: "text", text: JSON.stringify({ filled: selector, text }) }] };
701
337
  },
@@ -705,8 +341,7 @@ const tools = {
705
341
  description: "List interactive elements by kind: links, buttons, inputs, headings.",
706
342
  schema: { kind: z.enum(["links", "buttons", "inputs", "headings"]).describe("Element kind") },
707
343
  handler: async ({ kind }) => {
708
- const p = await ensureBrowser();
709
- const { Runtime } = p;
344
+ const { Runtime } = await getActiveProtocol();
710
345
  const selectors = {
711
346
  links: "document.querySelectorAll('a[href]')",
712
347
  buttons: "document.querySelectorAll('button, input[type=button], input[type=submit], [role=button]')",
@@ -716,12 +351,7 @@ const tools = {
716
351
  const { result } = await Runtime.evaluate({
717
352
  expression: `(() => {
718
353
  const items = Array.from(${selectors[kind]});
719
- return items.map(el => ({
720
- tag: el.tagName.toLowerCase(),
721
- text: (el.textContent || '').trim().slice(0, 100),
722
- id: el.id || '',
723
- className: (el.className || '').toString().slice(0, 50),
724
- }));
354
+ return items.map(el => ({ tag: el.tagName.toLowerCase(), text: (el.textContent || '').trim().slice(0, 100), id: el.id || '', className: (el.className || '').toString().slice(0, 50) }));
725
355
  })()`,
726
356
  returnByValue: true,
727
357
  });
@@ -729,129 +359,95 @@ const tools = {
729
359
  },
730
360
  },
731
361
 
732
- browser_title: {
733
- description: "Get current page title.",
734
- schema: {},
735
- handler: async () => {
736
- const p = await ensureBrowser();
737
- const { Runtime } = p;
738
- const { result } = await Runtime.evaluate({ expression: "document.title" });
739
- return { content: [{ type: "text", text: result?.value || "" }] };
740
- },
741
- },
742
-
743
- browser_url: {
744
- description: "Get current page URL.",
745
- schema: {},
746
- handler: async () => {
747
- const p = await ensureBrowser();
748
- const { Runtime } = p;
749
- const { result } = await Runtime.evaluate({ expression: "window.location.href" });
750
- return { content: [{ type: "text", text: result?.value || "" }] };
751
- },
752
- },
753
-
754
362
  browser_eval: {
755
363
  description: "Execute JavaScript in the page context.",
756
364
  schema: { expression: z.string().describe("JavaScript expression") },
757
365
  handler: async ({ expression }) => {
758
- const p = await ensureBrowser();
759
- const { Runtime } = p;
760
- // Runtime.evaluate returns { result: {...}, exceptionDetails?: {...} }
761
- // Check exceptionDetails BEFORE destructuring result
366
+ const { Runtime } = await getActiveProtocol();
762
367
  const response = await Runtime.evaluate({ expression, returnByValue: true });
763
368
  if (response.exceptionDetails) {
764
369
  const exc = response.exceptionDetails;
765
- const msg = exc.exception?.description || exc.text || "Unknown JS error";
766
- throw new Error(`JS Error: ${msg}`);
370
+ throw new Error(`JS Error: ${exc.exception?.description || exc.text || "Unknown JS error"}`);
767
371
  }
768
372
  const { result } = response;
769
373
  return { content: [{ type: "text", text: JSON.stringify(result?.value ?? result) }] };
770
374
  },
771
375
  },
772
376
 
773
- browser_status: {
774
- description: "Get browser and page status including connected tabs.",
775
- schema: {},
776
- handler: async () => {
777
- const status = { connected: false, port: cfg.port, actualPort: null, running: false, pid: null };
778
- if (browser && !browserExited) {
779
- status.running = true;
780
- status.pid = browser.pid;
781
- try {
782
- // Use actual port Chrome picked, or try configured port
783
- let targets;
784
- if (actualCdpPort) {
785
- status.actualPort = actualCdpPort;
786
- targets = await CDP.List({ port: actualCdpPort });
787
- } else {
788
- targets = await CDP.List({ port: cfg.port || 9222 });
789
- }
790
- status.connected = true;
791
- status.targets = targets.map(t => ({
792
- type: t.type,
793
- url: t.url,
794
- title: t.title,
795
- }));
796
- } catch {
797
- status.connected = false;
798
- }
799
- }
800
- return { content: [{ type: "text", text: JSON.stringify(status) }] };
377
+ browser_setViewport: {
378
+ description: "Change the viewport size (width × height). Useful for responsive testing.",
379
+ schema: {
380
+ width: z.number().min(320).max(7680).describe("Viewport width in pixels (default: 1280)"),
381
+ height: z.number().min(240).max(4320).describe("Viewport height in pixels (default: 720)"),
382
+ },
383
+ handler: async ({ width = 1280, height = 720 }) => {
384
+ const { Emulation } = await getActiveProtocol();
385
+ await Emulation.setDeviceMetricsOverride({ width, height, deviceScaleFactor: 1, mobile: false });
386
+ return { content: [{ type: "text", text: JSON.stringify({ viewport: `${width}x${height}` }) }] };
801
387
  },
802
388
  },
803
389
 
804
- // ─── GROUNDBREAKING: Live Browser Event Capture ─────────────────────────
805
- // No other MCP browser server gives the agent feedback from the page.
806
- // This turns bwb from "blind screenshot-taker" into "live debug partner."
390
+ // ═══════════════ 🔥 ADVANCED ═══════════════
391
+
392
+ browser_act: {
393
+ description: "GROUNDBREAKING: Natural language page interaction. One tool call does what normally takes 5-10. Examples: 'search for laptops under $1000', 'click the login button', 'go to google.com', 'fill email with test@test.com', 'extract the prices', 'scroll down'. Uses rule-based DOM heuristics — no LLM dependency.",
394
+ schema: { instruction: z.string().describe("Natural language instruction for what to do on the page") },
395
+ handler: async ({ instruction }) => {
396
+ const cdp = await getActiveProtocol();
397
+ const result = await executeInstruction(cdp, instruction);
398
+ if (result.url) syncActiveTab(result.title, result.url);
399
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
400
+ },
401
+ },
807
402
 
808
403
  browser_watch: {
809
- description: "GROUNDBREAKING: Live capture of page events (console, network, navigation, exceptions). Start recording, browse around, then poll to see everything that happened. First tool of its kind in any MCP browser server.",
404
+ description: "GROUNDBREAKING: Live capture of page events (console, network, navigation, exceptions). Start recording, browse around, then poll to see everything that happened.",
810
405
  schema: {
811
406
  action: z.enum(["start", "poll", "stop"]).describe("start=begin recording, poll=get events since last poll, stop=cleanup"),
812
407
  events: z.array(z.enum(["console", "network", "navigation", "all"])).describe("Event types to capture (default: all)").optional(),
813
408
  },
814
409
  handler: async ({ action, events = ["all"] }) => {
815
410
  if (action === "start") {
816
- const p = await ensureBrowser();
817
- // Enable domains needed for event capture
818
- await p.Runtime.enable();
819
- await p.Network.enable();
820
- setupWatch(events, p);
821
- return {
822
- content: [{
823
- type: "text",
824
- text: JSON.stringify({ status: "watching", events, msg: "Recording started. Call browser_watch({action:'poll'}) to get events." }),
825
- }],
826
- };
411
+ const cdp = await getActiveProtocol();
412
+ await cdp.Runtime.enable();
413
+ await cdp.Network.enable();
414
+ setupWatch(events, cdp);
415
+ return { content: [{ type: "text", text: JSON.stringify({ status: "watching", events, msg: "Recording started. Poll to get events." }) }] };
827
416
  }
828
-
829
417
  if (action === "poll") {
830
418
  const snapshot = [...watchState.events];
831
419
  watchState.events = [];
832
- return {
833
- content: [{
834
- type: "text",
835
- text: JSON.stringify({ count: snapshot.length, events: snapshot }),
836
- }],
837
- };
420
+ return { content: [{ type: "text", text: JSON.stringify({ count: snapshot.length, events: snapshot }) }] };
838
421
  }
839
-
840
422
  if (action === "stop") {
841
423
  const remaining = [...watchState.events];
842
424
  cleanupWatch();
843
- return {
844
- content: [{
845
- type: "text",
846
- text: JSON.stringify({ status: "stopped", captured: remaining.length, events: remaining }),
847
- }],
848
- };
425
+ return { content: [{ type: "text", text: JSON.stringify({ status: "stopped", captured: remaining.length, events: remaining }) }] };
849
426
  }
850
-
851
427
  return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid action" }) }] };
852
428
  },
853
429
  },
854
430
 
431
+ browser_diagnose: {
432
+ description: "Full page health diagnostic. Returns performance metrics, console errors, broken images, meta tags, and a health score. Like Lighthouse for your agent.",
433
+ schema: {},
434
+ handler: async () => {
435
+ const cdp = await getActiveProtocol();
436
+ const report = await diagnosePage(cdp);
437
+ return { content: [{ type: "text", text: JSON.stringify(report) }] };
438
+ },
439
+ },
440
+
441
+ browser_fingerprint: {
442
+ description: "Apply a realistic browser fingerprint to reduce false-positive automation detection in CI/testing. Normalizes navigator.webdriver, plugins, languages, chrome.runtime, and user-agent for more realistic test conditions.",
443
+ schema: {},
444
+ handler: async () => {
445
+ const cdp = await getActiveProtocol();
446
+ const result = await applyRealisticProfile(cdp);
447
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
448
+ },
449
+ },
450
+
855
451
  browser_waitForSelector: {
856
452
  description: "Wait for a CSS selector to appear (visible) or disappear from the DOM. Polls every 200ms until found or timeout.",
857
453
  schema: {
@@ -861,67 +457,126 @@ const tools = {
861
457
  visible: z.boolean().describe("Require element to be visible (non-zero dimensions, default: true)").optional(),
862
458
  },
863
459
  handler: async ({ selector, timeout = 10000, disappear = false, visible = true }) => {
864
- const p = await ensureBrowser();
865
- const { Runtime } = p;
460
+ const { Runtime } = await getActiveProtocol();
866
461
  await waitForSelector(Runtime, selector, { timeout, disappear, visible });
867
- return {
868
- content: [{
869
- type: "text",
870
- text: JSON.stringify({ found: !disappear, disappeared: disappear }),
871
- }],
872
- };
462
+ return { content: [{ type: "text", text: JSON.stringify({ found: !disappear, disappeared: disappear }) }] };
873
463
  },
874
464
  },
875
465
 
876
- browser_setViewport: {
877
- description: "Change the viewport size (width × height). Useful for responsive testing or capturing full-page screenshots at specific dimensions.",
878
- schema: {
879
- width: z.number().min(320).max(7680).describe("Viewport width in pixels (default: 1280)"),
880
- height: z.number().min(240).max(4320).describe("Viewport height in pixels (default: 720)"),
466
+ // ═══════════════ MULTI-TAB ═══════════════
467
+
468
+ browser_newTab: {
469
+ description: "Create a new browser tab, optionally navigate to a URL. Automatically switches to the new tab.",
470
+ schema: { url: z.string().describe("URL to navigate to in the new tab (optional)").optional() },
471
+ handler: async ({ url }) => {
472
+ const result = await createTab(url);
473
+ return { content: [{ type: "text", text: JSON.stringify({ tab: result.id, title: result.title, url: result.url }) }] };
881
474
  },
882
- handler: async ({ width = 1280, height = 720 }) => {
883
- const p = await ensureBrowser();
884
- await p.Emulation.setDeviceMetricsOverride({
885
- width,
886
- height,
887
- deviceScaleFactor: 1,
888
- mobile: false,
889
- });
890
- return {
891
- content: [{
892
- type: "text",
893
- text: JSON.stringify({ viewport: `${width}x${height}` }),
894
- }],
895
- };
475
+ },
476
+
477
+ browser_closeTab: {
478
+ description: "Close a browser tab by targetId. If no targetId provided, closes the active tab. Cannot close the last remaining tab — use browser_restart instead.",
479
+ schema: { targetId: z.string().describe("Target tab ID to close (optional, defaults to active tab)").optional() },
480
+ handler: async ({ targetId }) => {
481
+ const result = await closeTab(targetId);
482
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
896
483
  },
897
484
  },
898
485
 
899
- browser_back: {
900
- description: "Go back in browser history (like clicking the browser back button).",
486
+ browser_switchTab: {
487
+ description: "Switch to a different browser tab by targetId.",
488
+ schema: { targetId: z.string().describe("Target tab ID to switch to") },
489
+ handler: async ({ targetId }) => {
490
+ const result = switchTab(targetId);
491
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
492
+ },
493
+ },
494
+
495
+ browser_listTabs: {
496
+ description: "List all open browser tabs with their IDs, titles, URLs, and active status.",
901
497
  schema: {},
902
498
  handler: async () => {
903
- const p = await ensureBrowser();
904
- const { Page, Runtime } = p;
905
- await Page.navigate({ url: "javascript:history.back()" });
906
- await new Promise((r) => setTimeout(r, 500));
907
- const { result } = await Runtime.evaluate({ expression: "document.title" });
908
- return {
909
- content: [{
910
- type: "text",
911
- text: JSON.stringify({ title: result?.value || "" }),
912
- }],
913
- };
499
+ const result = listTabs();
500
+ return { content: [{ type: "text", text: JSON.stringify({ tabs: result, count: result.length }) }] };
501
+ },
502
+ },
503
+
504
+ // ═══════════════ SESSION ═══════════════
505
+
506
+ browser_saveCookies: {
507
+ description: "Save the current browser session (cookies) to disk. 'Login once, agent works for days.' Sessions persist across agent and server restarts.",
508
+ schema: { name: z.string().describe("Name for this session (e.g., 'twitter-login', 'gmail')") },
509
+ handler: async ({ name }) => {
510
+ const cdp = await getActiveProtocol();
511
+ const result = await saveSession(name, cdp);
512
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
513
+ },
514
+ },
515
+
516
+ browser_loadCookies: {
517
+ description: "Load a saved browser session (cookies) from disk. Navigate to the target domain after loading for the cookies to take effect.",
518
+ schema: { name: z.string().describe("Session name to load (e.g., 'twitter-login')") },
519
+ handler: async ({ name }) => {
520
+ const cdp = await getActiveProtocol();
521
+ const result = await loadSession(name, cdp);
522
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
523
+ },
524
+ },
525
+
526
+ browser_listSessions: {
527
+ description: "List all saved browser sessions with cookie counts and save dates.",
528
+ schema: {},
529
+ handler: async () => {
530
+ const sessions = listSessions();
531
+ return { content: [{ type: "text", text: JSON.stringify({ sessions, count: sessions.length }) }] };
532
+ },
533
+ },
534
+
535
+ // ═══════════════ LIFECYCLE ═══════════════
536
+
537
+ browser_status: {
538
+ description: "Get browser and page status including opened tabs and connection info.",
539
+ schema: {},
540
+ handler: async () => {
541
+ const status = { connected: false, port: cfg.port, actualPort: null, running: false, pid: null, tabs: [] };
542
+ if (browser && !browserExited) {
543
+ status.running = true;
544
+ status.pid = browser.pid;
545
+ status.tabs = listTabs();
546
+ try {
547
+ if (actualCdpPort) {
548
+ status.actualPort = actualCdpPort;
549
+ const targets = await CDP.List({ port: actualCdpPort });
550
+ status.connected = true;
551
+ status.targets = targets.map(t => ({ type: t.type, url: t.url, title: t.title }));
552
+ } else {
553
+ const targets = await CDP.List({ port: cfg.port || 9222 });
554
+ status.connected = true;
555
+ status.targets = targets.map(t => ({ type: t.type, url: t.url, title: t.title }));
556
+ }
557
+ } catch { status.connected = false; }
558
+ }
559
+ return { content: [{ type: "text", text: JSON.stringify(status) }] };
560
+ },
561
+ },
562
+
563
+ browser_restart: {
564
+ description: "Cleanly restart the browser process. Useful for freeing memory, clearing state, or recovering from issues during long-running sessions.",
565
+ schema: {},
566
+ handler: async () => {
567
+ clearTabs(); // Kill stale tab connections before restart
568
+ const result = await restartBrowser();
569
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
914
570
  },
915
571
  },
916
572
  };
917
573
 
918
- // Register all tools
574
+ // ─── Register & Start ─────────────────────────────────────────────────────────
575
+
919
576
  for (const [name, tool] of Object.entries(tools)) {
920
577
  server.tool(name, tool.description, tool.schema, tool.handler);
921
578
  }
922
579
 
923
- // ─── Start ────────────────────────────────────────────────────────────────────
924
-
925
580
  await ensureDeps();
926
581
  const transport = new StdioServerTransport();
927
582
  await server.connect(transport);