bwb-browser 2.0.4 → 3.1.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,127 @@
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
+ // ─── --setup mode ──────────────────────────────────────────────────
32
+ if (process.argv.includes('--setup')) {
33
+ const { runSetup } = await import('./lib/setup.mjs');
34
+ runSetup();
35
+ process.exit(0);
36
+ }
37
+
38
+ import {
39
+ gotoUrl, clickElement, fillElement, waitForSelector,
40
+ } from "./lib/helpers.mjs";
41
+
42
+ import {
43
+ getActiveProtocol, createTab, closeTab, switchTab, listTabs, syncActiveTab, clearTabs,
44
+ } from "./lib/tabs.mjs";
45
+
46
+ import { saveSession, loadSession, listSessions } from "./lib/session.mjs";
47
+ import { diagnosePage } from "./lib/diagnose.mjs";
48
+ import { applyRealisticProfile } from "./lib/fingerprint.mjs";
49
+ import { executeInstruction } from "./lib/act.mjs";
50
+
27
51
  // ─── Config ───────────────────────────────────────────────────────────────────
28
52
 
29
53
  const __dirname = dirname(fileURLToPath(import.meta.url));
30
54
 
31
55
  function parseArgs() {
32
56
  const args = process.argv.slice(2);
33
- const cfg = {};
57
+ const cliCfg = {};
34
58
  for (let i = 0; i < args.length; i++) {
35
59
  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);
60
+ case "--browser-path": cliCfg.browserPath = args[++i]; break;
61
+ case "--port": cliCfg.port = parseInt(args[++i], 10); break;
62
+ case "--user-data-dir": cliCfg.userDataDir = args[++i]; break;
63
+ case "--headless": cliCfg.headless = args[++i] !== "false"; break;
64
+ case "--screenshots-dir": cliCfg.screenshotsDir = args[++i]; break;
65
+ case "--timeout": cliCfg.navTimeout = parseInt(args[++i], 10); break;
66
+ case "--version": console.log("bwb-browser 3.0.0"); process.exit(0);
43
67
  case "--help": printHelp(); process.exit(0);
44
68
  }
45
69
  }
46
- return cfg;
70
+ return cliCfg;
47
71
  }
48
72
 
49
73
  function printHelp() {
50
74
  console.log(`
51
- bwb-browser — Browser Without Bloat MCP Server
75
+ bwb-browser v3.0.0 — Browser Without Bloat
76
+
77
+ Browser automation for AI agents. 76KB. 25 tools. Zero heavy dependencies.
78
+ Uses raw CDP — no Playwright, no Puppeteer, no 400MB downloads.
79
+
80
+ Built on Termux/Android. Runs everywhere. Weighs nothing.
52
81
 
53
82
  USAGE:
54
83
  bwb [options]
55
84
 
56
85
  OPTIONS:
57
86
  --browser-path <path> Path to Chrome/Chromium binary
58
- --port <number> CDP debug port (default: 9222)
87
+ --port <number> CDP debug port (default: 0 = random)
59
88
  --user-data-dir <path> Browser profile directory
60
89
  --headless <bool> Run headless (default: true)
61
- --screenshots-dir <path> Directory to save screenshots (default: /storage/emulated/0/Download/bwb-screenshots)
90
+ --screenshots-dir <path> Directory to save screenshots
62
91
  --timeout <ms> Navigation timeout in ms (default: 30000)
63
92
  --version Print version
64
93
  --help Show this help
65
94
 
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
95
+ TOOLS (25):
96
+ CORE BROWSING:
97
+ browser_goto Navigate to a URL
98
+ browser_screenshot Take a screenshot
99
+ browser_html Get page/selector HTML
100
+ browser_text Get page/selector text
101
+ browser_title Get page title
102
+ browser_url Get current URL
103
+ browser_back Go back in history
104
+
105
+ INTERACTION:
106
+ browser_click Click an element
107
+ browser_fill Fill an input field
108
+ browser_elements List interactive elements
109
+ browser_eval Execute JavaScript
110
+ browser_setViewport Change viewport size
111
+
112
+ 🔥 ADVANCED:
113
+ browser_act Natural language page interaction (one tool does it all)
114
+ browser_watch Live page event capture (console, network)
115
+ browser_diagnose Full page health diagnostic
116
+ browser_fingerprint Realistic browser profile for testing
117
+ browser_waitForSelector Wait for element to appear/disappear
118
+
119
+ MULTI-TAB:
120
+ browser_newTab Create a new tab
121
+ browser_closeTab Close a tab
122
+ browser_switchTab Switch to a different tab
123
+ browser_listTabs List all open tabs
124
+
125
+ SESSION:
126
+ browser_saveCookies Save session cookies to disk
127
+ browser_loadCookies Load session cookies from disk
128
+ browser_listSessions List saved sessions
129
+
130
+ LIFECYCLE:
131
+ browser_status Browser connection status
132
+ browser_restart Restart the browser
133
+
134
+ If bwb saves you time or money, consider supporting development:
135
+ https://github.com/sponsors/krshforever
90
136
  `);
91
137
  }
92
138
 
93
139
  // ─── Dependency Check ─────────────────────────────────────────────────────────
94
140
 
95
- // Verify all dependencies are resolvable before starting MCP server
96
141
  async function ensureDeps() {
97
142
  const { createRequire } = await import("module");
98
143
  const req = createRequire(import.meta.url);
@@ -103,515 +148,96 @@ async function ensureDeps() {
103
148
  ];
104
149
  const missing = [];
105
150
  for (const spec of needed) {
106
- try {
107
- req.resolve(spec);
108
- } catch {
151
+ try { req.resolve(spec); } catch {
109
152
  missing.push(spec.split("/")[0].split("@")[0] || spec);
110
153
  }
111
154
  }
112
155
  if (missing.length > 0) {
113
156
  console.error(
114
157
  `\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`
158
+ `Run: npm install -g bwb-browser\n` +
159
+ `Or: cd "${__dirname}" && npm install\n` +
160
+ `Or: npx bwb-browser\n`
118
161
  );
119
162
  process.exit(1);
120
163
  }
121
164
  }
122
165
 
123
- // ─── Config ───────────────────────────────────────────────────────────────────
166
+ // ─── Apply Config ────────────────────────────────────────────────────────────
124
167
 
125
- const cfg = { ...parseArgs() };
168
+ Object.assign(cfg, parseArgs());
126
169
  cfg.port = cfg.port || parseInt(process.env.BWB_CDP_PORT || "0", 10);
127
170
  cfg.headless = cfg.headless !== undefined ? cfg.headless : (process.env.BWB_HEADLESS !== "false");
128
171
  cfg.userDataDir = cfg.userDataDir || process.env.BWB_USER_DATA_DIR || join(homedir(), ".cache", "bwb-browser");
129
172
  cfg.screenshotsDir = cfg.screenshotsDir || process.env.BWB_SCREENSHOTS_DIR || "/storage/emulated/0/Download/bwb-screenshots";
130
173
  cfg.navTimeout = cfg.navTimeout || parseInt(process.env.BWB_NAV_TIMEOUT || "30000", 10);
131
174
 
132
- // Ensure screenshots directory exists
133
175
  try { mkdirSync(cfg.screenshotsDir, { recursive: true }); } catch {}
134
176
 
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
- }
177
+ // ─── Watch State (Live Page Event Capture) ─────────────────────────────────────
153
178
 
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
- }
179
+ const WATCH_MAX_EVENTS = 5000;
198
180
 
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;
181
+ function watchPush(event) {
182
+ if (watchState.events.length >= WATCH_MAX_EVENTS) watchState.events.shift();
183
+ watchState.events.push(event);
214
184
  }
215
185
 
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
- }
443
-
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
- }
462
-
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 {}
477
- }
478
-
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
- };
186
+ const watchState = { active: false, events: [], disposables: [] };
499
187
 
500
188
  function cleanupWatch() {
501
189
  watchState.active = false;
502
- for (const dispose of watchState.disposables) {
503
- try { dispose(); } catch {}
504
- }
190
+ for (const dispose of watchState.disposables) { try { dispose(); } catch {} }
505
191
  watchState.disposables = [];
506
192
  watchState.events = [];
507
193
  }
508
194
 
509
- function setupWatch(events, protocol) {
195
+ function setupWatch(events, cdp) {
510
196
  cleanupWatch();
511
197
  watchState.active = true;
512
198
 
513
199
  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
- });
200
+ cdp.Runtime.consoleAPICalled((params) => {
201
+ watchPush({ type: "console", timestamp: Date.now(), level: params.type || "log",
202
+ text: (params.args || []).map(a => a.value !== undefined ? String(a.value) : a.description || "").join(" ") });
523
203
  });
524
- protocol.Runtime.exceptionThrown((params) => {
204
+ cdp.Runtime.exceptionThrown((params) => {
525
205
  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
- });
206
+ watchPush({ type: "exception", timestamp: Date.now(), text: d?.exception?.description || d?.text || "Unknown exception" });
531
207
  });
532
208
  }
533
-
534
209
  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
- });
210
+ cdp.Network.requestWillBeSent((params) => {
211
+ watchPush({ type: "network", timestamp: Date.now(), subtype: "request", url: params.request?.url || "", method: params.request?.method || "GET" });
543
212
  });
544
- protocol.Network.responseReceived((params) => {
545
- // Only fire for actual pages/resources, not data: URIs
213
+ cdp.Network.responseReceived((params) => {
546
214
  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
- });
215
+ watchPush({ type: "network", timestamp: Date.now(), subtype: "response", url: params.response?.url || "", status: params.response?.status || 0, mimeType: params.response?.mimeType || "" });
555
216
  });
556
217
  }
557
-
558
218
  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
- });
219
+ cdp.Page.frameNavigated((params) => {
220
+ watchPush({ type: "navigation", timestamp: Date.now(), url: params.frame?.url || "" });
565
221
  });
566
222
  }
567
223
  }
568
224
 
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
225
  // ─── MCP Server ───────────────────────────────────────────────────────────────
600
226
 
601
- const server = new McpServer({
602
- name: "bwb-browser",
603
- version: "2.0.4",
604
- });
227
+ const server = new McpServer({ name: "bwb-browser", version: "3.0.0" });
605
228
 
606
229
  // Tool implementations
607
230
  const tools = {
231
+ // ═══════════════ CORE BROWSING ═══════════════
232
+
608
233
  browser_goto: {
609
234
  description: "Navigate to a URL. Returns page title and URL.",
610
235
  schema: { url: z.string().describe("URL to navigate to") },
611
236
  handler: async ({ url }) => {
612
- const p = await ensureBrowser();
613
- const { Page, Runtime } = p;
237
+ const cdp = await getActiveProtocol();
238
+ const { Page, Runtime } = cdp;
614
239
  const result = await gotoUrl(Page, Runtime, url, cfg.navTimeout);
240
+ syncActiveTab(result.title, result.url);
615
241
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
616
242
  },
617
243
  },
@@ -623,23 +249,15 @@ const tools = {
623
249
  quality: z.number().describe("JPEG quality 0-100 (default 80)").optional(),
624
250
  },
625
251
  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
252
+ const { Page } = await getActiveProtocol();
253
+ const { data } = await Page.captureScreenshot({ format: "jpeg", quality, captureBeyondViewport: fullPage });
634
254
  const savedPath = saveScreenshot(data);
635
255
  const response = { screenshot: `data:image/jpeg;base64,${data.slice(0, 40)}...` };
636
256
  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
- };
257
+ return { content: [
258
+ { type: "image", data, mimeType: "image/jpeg" },
259
+ { type: "text", text: JSON.stringify(response) },
260
+ ]};
643
261
  },
644
262
  },
645
263
 
@@ -647,8 +265,7 @@ const tools = {
647
265
  description: "Get HTML source of the page or a CSS selector.",
648
266
  schema: { selector: z.string().describe("Optional CSS selector").optional() },
649
267
  handler: async ({ selector }) => {
650
- const p = await ensureBrowser();
651
- const { Runtime } = p;
268
+ const { Runtime } = await getActiveProtocol();
652
269
  const expr = selector
653
270
  ? `document.querySelector(${JSON.stringify(selector)})?.outerHTML || ''`
654
271
  : "document.documentElement.outerHTML";
@@ -661,8 +278,7 @@ const tools = {
661
278
  description: "Get visible text content of the page or a CSS selector.",
662
279
  schema: { selector: z.string().describe("Optional CSS selector").optional() },
663
280
  handler: async ({ selector }) => {
664
- const p = await ensureBrowser();
665
- const { Runtime } = p;
281
+ const { Runtime } = await getActiveProtocol();
666
282
  const expr = selector
667
283
  ? `document.querySelector(${JSON.stringify(selector)})?.textContent || ''`
668
284
  : "document.body?.textContent || ''";
@@ -671,31 +287,58 @@ const tools = {
671
287
  },
672
288
  },
673
289
 
290
+ browser_title: {
291
+ description: "Get current page title.",
292
+ schema: {},
293
+ handler: async () => {
294
+ const { Runtime } = await getActiveProtocol();
295
+ const { result } = await Runtime.evaluate({ expression: "document.title" });
296
+ return { content: [{ type: "text", text: result?.value || "" }] };
297
+ },
298
+ },
299
+
300
+ browser_url: {
301
+ description: "Get current page URL.",
302
+ schema: {},
303
+ handler: async () => {
304
+ const { Runtime } = await getActiveProtocol();
305
+ const { result } = await Runtime.evaluate({ expression: "window.location.href" });
306
+ return { content: [{ type: "text", text: result?.value || "" }] };
307
+ },
308
+ },
309
+
310
+ browser_back: {
311
+ description: "Go back in browser history (like clicking the browser back button).",
312
+ schema: {},
313
+ handler: async () => {
314
+ const { Page, Runtime } = await getActiveProtocol();
315
+ await Page.navigate({ url: "javascript:history.back()" });
316
+ await new Promise(r => setTimeout(r, 500));
317
+ const { result } = await Runtime.evaluate({ expression: "document.title" });
318
+ syncActiveTab(result?.value, undefined);
319
+ return { content: [{ type: "text", text: JSON.stringify({ title: result?.value || "" }) }] };
320
+ },
321
+ },
322
+
323
+ // ═══════════════ INTERACTION ═══════════════
324
+
674
325
  browser_click: {
675
326
  description: "Click an element by CSS selector. Uses CDP Input.dispatchMouseEvent for native events.",
676
327
  schema: { selector: z.string().describe("CSS selector") },
677
328
  handler: async ({ selector }) => {
678
- const p = await ensureBrowser();
679
- const { Page, Runtime, Input } = p;
329
+ const cdp = await getActiveProtocol();
330
+ const { Page, Runtime, Input } = cdp;
680
331
  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
- };
332
+ return { content: [{ type: "text", text: JSON.stringify({ clicked: selector, tag: info.tag, text: info.text }) }] };
687
333
  },
688
334
  },
689
335
 
690
336
  browser_fill: {
691
337
  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
- },
338
+ schema: { selector: z.string().describe("CSS selector for input"), text: z.string().describe("Text to fill") },
696
339
  handler: async ({ selector, text }) => {
697
- const p = await ensureBrowser();
698
- const { Page, Runtime, Input } = p;
340
+ const cdp = await getActiveProtocol();
341
+ const { Page, Runtime, Input } = cdp;
699
342
  await fillElement(Page, Runtime, Input, selector, text);
700
343
  return { content: [{ type: "text", text: JSON.stringify({ filled: selector, text }) }] };
701
344
  },
@@ -705,8 +348,7 @@ const tools = {
705
348
  description: "List interactive elements by kind: links, buttons, inputs, headings.",
706
349
  schema: { kind: z.enum(["links", "buttons", "inputs", "headings"]).describe("Element kind") },
707
350
  handler: async ({ kind }) => {
708
- const p = await ensureBrowser();
709
- const { Runtime } = p;
351
+ const { Runtime } = await getActiveProtocol();
710
352
  const selectors = {
711
353
  links: "document.querySelectorAll('a[href]')",
712
354
  buttons: "document.querySelectorAll('button, input[type=button], input[type=submit], [role=button]')",
@@ -716,12 +358,7 @@ const tools = {
716
358
  const { result } = await Runtime.evaluate({
717
359
  expression: `(() => {
718
360
  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
- }));
361
+ 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
362
  })()`,
726
363
  returnByValue: true,
727
364
  });
@@ -729,129 +366,95 @@ const tools = {
729
366
  },
730
367
  },
731
368
 
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
369
  browser_eval: {
755
370
  description: "Execute JavaScript in the page context.",
756
371
  schema: { expression: z.string().describe("JavaScript expression") },
757
372
  handler: async ({ expression }) => {
758
- const p = await ensureBrowser();
759
- const { Runtime } = p;
760
- // Runtime.evaluate returns { result: {...}, exceptionDetails?: {...} }
761
- // Check exceptionDetails BEFORE destructuring result
373
+ const { Runtime } = await getActiveProtocol();
762
374
  const response = await Runtime.evaluate({ expression, returnByValue: true });
763
375
  if (response.exceptionDetails) {
764
376
  const exc = response.exceptionDetails;
765
- const msg = exc.exception?.description || exc.text || "Unknown JS error";
766
- throw new Error(`JS Error: ${msg}`);
377
+ throw new Error(`JS Error: ${exc.exception?.description || exc.text || "Unknown JS error"}`);
767
378
  }
768
379
  const { result } = response;
769
380
  return { content: [{ type: "text", text: JSON.stringify(result?.value ?? result) }] };
770
381
  },
771
382
  },
772
383
 
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) }] };
384
+ browser_setViewport: {
385
+ description: "Change the viewport size (width × height). Useful for responsive testing.",
386
+ schema: {
387
+ width: z.number().min(320).max(7680).describe("Viewport width in pixels (default: 1280)"),
388
+ height: z.number().min(240).max(4320).describe("Viewport height in pixels (default: 720)"),
389
+ },
390
+ handler: async ({ width = 1280, height = 720 }) => {
391
+ const { Emulation } = await getActiveProtocol();
392
+ await Emulation.setDeviceMetricsOverride({ width, height, deviceScaleFactor: 1, mobile: false });
393
+ return { content: [{ type: "text", text: JSON.stringify({ viewport: `${width}x${height}` }) }] };
801
394
  },
802
395
  },
803
396
 
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."
397
+ // ═══════════════ 🔥 ADVANCED ═══════════════
398
+
399
+ browser_act: {
400
+ 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.",
401
+ schema: { instruction: z.string().describe("Natural language instruction for what to do on the page") },
402
+ handler: async ({ instruction }) => {
403
+ const cdp = await getActiveProtocol();
404
+ const result = await executeInstruction(cdp, instruction);
405
+ if (result.url) syncActiveTab(result.title, result.url);
406
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
407
+ },
408
+ },
807
409
 
808
410
  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.",
411
+ description: "GROUNDBREAKING: Live capture of page events (console, network, navigation, exceptions). Start recording, browse around, then poll to see everything that happened.",
810
412
  schema: {
811
413
  action: z.enum(["start", "poll", "stop"]).describe("start=begin recording, poll=get events since last poll, stop=cleanup"),
812
414
  events: z.array(z.enum(["console", "network", "navigation", "all"])).describe("Event types to capture (default: all)").optional(),
813
415
  },
814
416
  handler: async ({ action, events = ["all"] }) => {
815
417
  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
- };
418
+ const cdp = await getActiveProtocol();
419
+ await cdp.Runtime.enable();
420
+ await cdp.Network.enable();
421
+ setupWatch(events, cdp);
422
+ return { content: [{ type: "text", text: JSON.stringify({ status: "watching", events, msg: "Recording started. Poll to get events." }) }] };
827
423
  }
828
-
829
424
  if (action === "poll") {
830
425
  const snapshot = [...watchState.events];
831
426
  watchState.events = [];
832
- return {
833
- content: [{
834
- type: "text",
835
- text: JSON.stringify({ count: snapshot.length, events: snapshot }),
836
- }],
837
- };
427
+ return { content: [{ type: "text", text: JSON.stringify({ count: snapshot.length, events: snapshot }) }] };
838
428
  }
839
-
840
429
  if (action === "stop") {
841
430
  const remaining = [...watchState.events];
842
431
  cleanupWatch();
843
- return {
844
- content: [{
845
- type: "text",
846
- text: JSON.stringify({ status: "stopped", captured: remaining.length, events: remaining }),
847
- }],
848
- };
432
+ return { content: [{ type: "text", text: JSON.stringify({ status: "stopped", captured: remaining.length, events: remaining }) }] };
849
433
  }
850
-
851
434
  return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid action" }) }] };
852
435
  },
853
436
  },
854
437
 
438
+ browser_diagnose: {
439
+ description: "Full page health diagnostic. Returns performance metrics, console errors, broken images, meta tags, and a health score. Like Lighthouse for your agent.",
440
+ schema: {},
441
+ handler: async () => {
442
+ const cdp = await getActiveProtocol();
443
+ const report = await diagnosePage(cdp);
444
+ return { content: [{ type: "text", text: JSON.stringify(report) }] };
445
+ },
446
+ },
447
+
448
+ browser_fingerprint: {
449
+ 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.",
450
+ schema: {},
451
+ handler: async () => {
452
+ const cdp = await getActiveProtocol();
453
+ const result = await applyRealisticProfile(cdp);
454
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
455
+ },
456
+ },
457
+
855
458
  browser_waitForSelector: {
856
459
  description: "Wait for a CSS selector to appear (visible) or disappear from the DOM. Polls every 200ms until found or timeout.",
857
460
  schema: {
@@ -861,67 +464,126 @@ const tools = {
861
464
  visible: z.boolean().describe("Require element to be visible (non-zero dimensions, default: true)").optional(),
862
465
  },
863
466
  handler: async ({ selector, timeout = 10000, disappear = false, visible = true }) => {
864
- const p = await ensureBrowser();
865
- const { Runtime } = p;
467
+ const { Runtime } = await getActiveProtocol();
866
468
  await waitForSelector(Runtime, selector, { timeout, disappear, visible });
867
- return {
868
- content: [{
869
- type: "text",
870
- text: JSON.stringify({ found: !disappear, disappeared: disappear }),
871
- }],
872
- };
469
+ return { content: [{ type: "text", text: JSON.stringify({ found: !disappear, disappeared: disappear }) }] };
873
470
  },
874
471
  },
875
472
 
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)"),
473
+ // ═══════════════ MULTI-TAB ═══════════════
474
+
475
+ browser_newTab: {
476
+ description: "Create a new browser tab, optionally navigate to a URL. Automatically switches to the new tab.",
477
+ schema: { url: z.string().describe("URL to navigate to in the new tab (optional)").optional() },
478
+ handler: async ({ url }) => {
479
+ const result = await createTab(url);
480
+ return { content: [{ type: "text", text: JSON.stringify({ tab: result.id, title: result.title, url: result.url }) }] };
881
481
  },
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
- };
482
+ },
483
+
484
+ browser_closeTab: {
485
+ 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.",
486
+ schema: { targetId: z.string().describe("Target tab ID to close (optional, defaults to active tab)").optional() },
487
+ handler: async ({ targetId }) => {
488
+ const result = await closeTab(targetId);
489
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
896
490
  },
897
491
  },
898
492
 
899
- browser_back: {
900
- description: "Go back in browser history (like clicking the browser back button).",
493
+ browser_switchTab: {
494
+ description: "Switch to a different browser tab by targetId.",
495
+ schema: { targetId: z.string().describe("Target tab ID to switch to") },
496
+ handler: async ({ targetId }) => {
497
+ const result = switchTab(targetId);
498
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
499
+ },
500
+ },
501
+
502
+ browser_listTabs: {
503
+ description: "List all open browser tabs with their IDs, titles, URLs, and active status.",
901
504
  schema: {},
902
505
  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
- };
506
+ const result = listTabs();
507
+ return { content: [{ type: "text", text: JSON.stringify({ tabs: result, count: result.length }) }] };
508
+ },
509
+ },
510
+
511
+ // ═══════════════ SESSION ═══════════════
512
+
513
+ browser_saveCookies: {
514
+ description: "Save the current browser session (cookies) to disk. 'Login once, agent works for days.' Sessions persist across agent and server restarts.",
515
+ schema: { name: z.string().describe("Name for this session (e.g., 'twitter-login', 'gmail')") },
516
+ handler: async ({ name }) => {
517
+ const cdp = await getActiveProtocol();
518
+ const result = await saveSession(name, cdp);
519
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
520
+ },
521
+ },
522
+
523
+ browser_loadCookies: {
524
+ description: "Load a saved browser session (cookies) from disk. Navigate to the target domain after loading for the cookies to take effect.",
525
+ schema: { name: z.string().describe("Session name to load (e.g., 'twitter-login')") },
526
+ handler: async ({ name }) => {
527
+ const cdp = await getActiveProtocol();
528
+ const result = await loadSession(name, cdp);
529
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
530
+ },
531
+ },
532
+
533
+ browser_listSessions: {
534
+ description: "List all saved browser sessions with cookie counts and save dates.",
535
+ schema: {},
536
+ handler: async () => {
537
+ const sessions = listSessions();
538
+ return { content: [{ type: "text", text: JSON.stringify({ sessions, count: sessions.length }) }] };
539
+ },
540
+ },
541
+
542
+ // ═══════════════ LIFECYCLE ═══════════════
543
+
544
+ browser_status: {
545
+ description: "Get browser and page status including opened tabs and connection info.",
546
+ schema: {},
547
+ handler: async () => {
548
+ const status = { connected: false, port: cfg.port, actualPort: null, running: false, pid: null, tabs: [] };
549
+ if (browser && !browserExited) {
550
+ status.running = true;
551
+ status.pid = browser.pid;
552
+ status.tabs = listTabs();
553
+ try {
554
+ if (actualCdpPort) {
555
+ status.actualPort = actualCdpPort;
556
+ const targets = await CDP.List({ port: actualCdpPort });
557
+ status.connected = true;
558
+ status.targets = targets.map(t => ({ type: t.type, url: t.url, title: t.title }));
559
+ } else {
560
+ const targets = await CDP.List({ port: cfg.port || 9222 });
561
+ status.connected = true;
562
+ status.targets = targets.map(t => ({ type: t.type, url: t.url, title: t.title }));
563
+ }
564
+ } catch { status.connected = false; }
565
+ }
566
+ return { content: [{ type: "text", text: JSON.stringify(status) }] };
567
+ },
568
+ },
569
+
570
+ browser_restart: {
571
+ description: "Cleanly restart the browser process. Useful for freeing memory, clearing state, or recovering from issues during long-running sessions.",
572
+ schema: {},
573
+ handler: async () => {
574
+ clearTabs(); // Kill stale tab connections before restart
575
+ const result = await restartBrowser();
576
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
914
577
  },
915
578
  },
916
579
  };
917
580
 
918
- // Register all tools
581
+ // ─── Register & Start ─────────────────────────────────────────────────────────
582
+
919
583
  for (const [name, tool] of Object.entries(tools)) {
920
584
  server.tool(name, tool.description, tool.schema, tool.handler);
921
585
  }
922
586
 
923
- // ─── Start ────────────────────────────────────────────────────────────────────
924
-
925
587
  await ensureDeps();
926
588
  const transport = new StdioServerTransport();
927
589
  await server.connect(transport);