bwb-browser 2.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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/bwb +2 -0
  4. package/package.json +60 -0
  5. package/server.mjs +923 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 krsh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # bwb-browser-termux
2
+
3
+ **Browser Without Bloat** — a lightweight browser automation MCP server using raw Chrome DevTools Protocol (CDP).
4
+
5
+ No Playwright. No Puppeteer. Just CDP.
6
+
7
+ Works on **Termux/Android**, Linux, macOS, and Windows.
8
+
9
+ ## Why bwb?
10
+
11
+ | Feature | bwb | Playwright MCP | Puppeteer MCP |
12
+ |---------|-----|----------------|---------------|
13
+ | **Dependencies** | 3 packages | 50+ packages | 30+ packages |
14
+ | **Install size** | ~2MB | ~500MB+ | ~300MB+ |
15
+ | **Works on Termux** | ✅ | ❌ | ❌ |
16
+ | **Works in CI** | ✅ | ⚠️ Needs browser download | ⚠️ Needs browser download |
17
+ | **Uses existing Chrome** | ✅ | ❌ Downloads its own | ❌ Downloads its own |
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install -g bwb-browser-termux
23
+ ```
24
+
25
+ Or run directly:
26
+
27
+ ```bash
28
+ npx bwb-browser-termux
29
+ ```
30
+
31
+ ## Prerequisites
32
+
33
+ You need **Chrome** or **Chromium** installed. bwb will auto-detect it.
34
+
35
+ **Termux/Android:**
36
+ ```bash
37
+ pkg install chromium
38
+ ```
39
+
40
+ **Linux (Debian/Ubuntu):**
41
+ ```bash
42
+ sudo apt install chromium-browser
43
+ # or
44
+ sudo apt install google-chrome
45
+ ```
46
+
47
+ **macOS:**
48
+ ```bash
49
+ brew install --cask google-chrome
50
+ ```
51
+
52
+ **Windows:**
53
+ Download and install Google Chrome normally.
54
+
55
+ ## Usage
56
+
57
+ ### With Claude Desktop / OpenCode / any MCP client
58
+
59
+ Add to your MCP client config:
60
+
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "bwb": {
65
+ "command": "npx",
66
+ "args": ["bwb-browser-termux"]
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ For Termux, you may need the full path:
73
+
74
+ ```json
75
+ {
76
+ "mcpServers": {
77
+ "bwb": {
78
+ "command": "node",
79
+ "args": ["/path/to/bwb-browser-termux/server.js"]
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ ### Command line
86
+
87
+ ```bash
88
+ # Start the MCP server (stdio)
89
+ bwb
90
+
91
+ # With custom browser path
92
+ bwb --browser-path /usr/bin/chromium
93
+
94
+ # Custom CDP port
95
+ bwb --port 9333
96
+
97
+ # Visible browser (not headless)
98
+ bwb --headless false
99
+
100
+ # See all options
101
+ bwb --help
102
+ ```
103
+
104
+ ## Configuration
105
+
106
+ | CLI flag | Env var | Default | Description |
107
+ |----------|---------|---------|-------------|
108
+ | `--browser-path` | `BWB_CHROME_PATH` | auto-detected | Path to Chrome/Chromium binary |
109
+ | `--port` | `BWB_CDP_PORT` | `9222` | Remote debugging port |
110
+ | `--user-data-dir` | `BWB_USER_DATA_DIR` | `~/.cache/bwb-browser` | Browser profile directory |
111
+ | `--headless` | `BWB_HEADLESS` | `true` | Run headless (`true`/`false`) |
112
+
113
+ ## Tools (11)
114
+
115
+ | Tool | Description |
116
+ |------|-------------|
117
+ | `browser_goto` | Navigate to a URL |
118
+ | `browser_screenshot` | Take a screenshot |
119
+ | `browser_html` | Get page/selector HTML |
120
+ | `browser_text` | Get page/selector text |
121
+ | `browser_click` | Click an element by CSS selector |
122
+ | `browser_fill` | Fill an input field |
123
+ | `browser_elements` | List interactive elements (links, buttons, inputs, headings) |
124
+ | `browser_title` | Get current page title |
125
+ | `browser_url` | Get current URL |
126
+ | `browser_eval` | Execute JavaScript in page context |
127
+ | `browser_status` | Browser connection status |
128
+
129
+ ## License
130
+
131
+ MIT © krsh
package/bin/bwb ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import("../server.mjs").catch(e => { console.error("bwb:", e.message); process.exit(1); });
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "bwb-browser",
3
+ "version": "2.0.0",
4
+ "description": "Browser Without Bloat — lightweight browser automation MCP server using raw CDP. 30KB, zero bloat, works on any platform.",
5
+ "bin": {
6
+ "bwb": "bin/bwb"
7
+ },
8
+ "files": [
9
+ "server.mjs",
10
+ "bin/bwb",
11
+ "AGENTS.md"
12
+ ],
13
+ "keywords": ["browser", "automation", "mcp", "cdp", "chrome", "headless", "ai-agent", "llm"],
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/krshforever/bwb-browser.git"
18
+ },
19
+ "files": [
20
+ "bin/bwb",
21
+ "server.mjs",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "start": "node server.mjs",
27
+ "test": "node --check server.mjs"
28
+ },
29
+ "keywords": [
30
+ "mcp",
31
+ "model-context-protocol",
32
+ "browser",
33
+ "automation",
34
+ "cdp",
35
+ "chrome-devtools-protocol",
36
+ "termux",
37
+ "android",
38
+ "headless",
39
+ "bwb",
40
+ "browser-without-bloat"
41
+ ],
42
+ "author": "krsh",
43
+ "license": "MIT",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/krshforever/bwb-browser-termux.git"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/krshforever/bwb-browser-termux/issues"
50
+ },
51
+ "homepage": "https://github.com/krshforever/bwb-browser-termux#readme",
52
+ "engines": {
53
+ "node": ">=18.0.0"
54
+ },
55
+ "dependencies": {
56
+ "@modelcontextprotocol/sdk": "^1.0.0",
57
+ "chrome-remote-interface": "^0.34.0",
58
+ "zod": "^3.22.0"
59
+ }
60
+ }
package/server.mjs ADDED
@@ -0,0 +1,923 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bwb-browser — Browser Without Bloat MCP Server
4
+ *
5
+ * A lightweight browser automation MCP server using raw Chrome DevTools Protocol.
6
+ * No Playwright, no Puppeteer — just CDP. Works on Termux/Android and everywhere else.
7
+ *
8
+ * Configuration (ordered by precedence: CLI arg > env var > default):
9
+ * --browser-path / BWB_CHROME_PATH — Path to Chrome/Chromium executable
10
+ * --port / BWB_CDP_PORT — Remote debugging port (default: 9222)
11
+ * --user-data-dir / BWB_USER_DATA_DIR — Browser profile directory
12
+ * --headless / BWB_HEADLESS — Run headless (default: true)
13
+ * --screenshots-dir / BWB_SCREENSHOTS_DIR — Directory for saved screenshots
14
+ * --timeout / BWB_NAV_TIMEOUT — Navigation timeout in ms (default: 30000)
15
+ */
16
+
17
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
+ import { z } from "zod";
20
+ import { spawn, execSync } from "child_process";
21
+ import CDP from "chrome-remote-interface";
22
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
23
+ import { homedir, platform } from "os";
24
+ import { join, dirname } from "path";
25
+ import { fileURLToPath } from "url";
26
+
27
+ // ─── Config ───────────────────────────────────────────────────────────────────
28
+
29
+ const __dirname = dirname(fileURLToPath(import.meta.url));
30
+
31
+ function parseArgs() {
32
+ const args = process.argv.slice(2);
33
+ const cfg = {};
34
+ for (let i = 0; i < args.length; i++) {
35
+ 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.0"); process.exit(0);
43
+ case "--help": printHelp(); process.exit(0);
44
+ }
45
+ }
46
+ return cfg;
47
+ }
48
+
49
+ function printHelp() {
50
+ console.log(`
51
+ bwb-browser — Browser Without Bloat MCP Server
52
+
53
+ USAGE:
54
+ bwb [options]
55
+
56
+ OPTIONS:
57
+ --browser-path <path> Path to Chrome/Chromium binary
58
+ --port <number> CDP debug port (default: 9222)
59
+ --user-data-dir <path> Browser profile directory
60
+ --headless <bool> Run headless (default: true)
61
+ --screenshots-dir <path> Directory to save screenshots (default: /storage/emulated/0/Download/bwb-screenshots)
62
+ --timeout <ms> Navigation timeout in ms (default: 30000)
63
+ --version Print version
64
+ --help Show this help
65
+
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
90
+ `);
91
+ }
92
+
93
+ // ─── Dependency Check ─────────────────────────────────────────────────────────
94
+
95
+ // Verify all dependencies are resolvable before starting MCP server
96
+ async function ensureDeps() {
97
+ const { createRequire } = await import("module");
98
+ const req = createRequire(import.meta.url);
99
+ const needed = [
100
+ "@modelcontextprotocol/sdk/server/mcp.js",
101
+ "zod",
102
+ "chrome-remote-interface",
103
+ ];
104
+ const missing = [];
105
+ for (const spec of needed) {
106
+ try {
107
+ req.resolve(spec);
108
+ } catch {
109
+ missing.push(spec.split("/")[0].split("@")[0] || spec);
110
+ }
111
+ }
112
+ if (missing.length > 0) {
113
+ console.error(
114
+ `\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`
118
+ );
119
+ process.exit(1);
120
+ }
121
+ }
122
+
123
+ // ─── Config ───────────────────────────────────────────────────────────────────
124
+
125
+ const cfg = { ...parseArgs() };
126
+ cfg.port = cfg.port || parseInt(process.env.BWB_CDP_PORT || "0", 10);
127
+ cfg.headless = cfg.headless !== undefined ? cfg.headless : (process.env.BWB_HEADLESS !== "false");
128
+ cfg.userDataDir = cfg.userDataDir || process.env.BWB_USER_DATA_DIR || join(homedir(), ".cache", "bwb-browser");
129
+ cfg.screenshotsDir = cfg.screenshotsDir || process.env.BWB_SCREENSHOTS_DIR || "/storage/emulated/0/Download/bwb-screenshots";
130
+ cfg.navTimeout = cfg.navTimeout || parseInt(process.env.BWB_NAV_TIMEOUT || "30000", 10);
131
+
132
+ // Ensure screenshots directory exists
133
+ try { mkdirSync(cfg.screenshotsDir, { recursive: true }); } catch {}
134
+
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
+ }
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
+ };
499
+
500
+ function cleanupWatch() {
501
+ watchState.active = false;
502
+ for (const dispose of watchState.disposables) {
503
+ try { dispose(); } catch {}
504
+ }
505
+ watchState.disposables = [];
506
+ watchState.events = [];
507
+ }
508
+
509
+ function setupWatch(events, protocol) {
510
+ cleanupWatch();
511
+ watchState.active = true;
512
+
513
+ 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
+ });
523
+ });
524
+ protocol.Runtime.exceptionThrown((params) => {
525
+ 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
+ });
531
+ });
532
+ }
533
+
534
+ 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
+ });
543
+ });
544
+ protocol.Network.responseReceived((params) => {
545
+ // Only fire for actual pages/resources, not data: URIs
546
+ 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
+ });
555
+ });
556
+ }
557
+
558
+ 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
+ });
565
+ });
566
+ }
567
+ }
568
+
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
+ // ─── MCP Server ───────────────────────────────────────────────────────────────
600
+
601
+ const server = new McpServer({
602
+ name: "bwb-browser",
603
+ version: "2.0.0",
604
+ });
605
+
606
+ // Tool implementations
607
+ const tools = {
608
+ browser_goto: {
609
+ description: "Navigate to a URL. Returns page title and URL.",
610
+ schema: { url: z.string().describe("URL to navigate to") },
611
+ handler: async ({ url }) => {
612
+ const p = await ensureBrowser();
613
+ const { Page, Runtime } = p;
614
+ const result = await gotoUrl(Page, Runtime, url, cfg.navTimeout);
615
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
616
+ },
617
+ },
618
+
619
+ browser_screenshot: {
620
+ description: "Take a screenshot of the current page.",
621
+ schema: {
622
+ fullPage: z.boolean().describe("Full page screenshot (default false)").optional(),
623
+ quality: z.number().describe("JPEG quality 0-100 (default 80)").optional(),
624
+ },
625
+ 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
634
+ const savedPath = saveScreenshot(data);
635
+ const response = { screenshot: `data:image/jpeg;base64,${data.slice(0, 40)}...` };
636
+ 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
+ };
643
+ },
644
+ },
645
+
646
+ browser_html: {
647
+ description: "Get HTML source of the page or a CSS selector.",
648
+ schema: { selector: z.string().describe("Optional CSS selector").optional() },
649
+ handler: async ({ selector }) => {
650
+ const p = await ensureBrowser();
651
+ const { Runtime } = p;
652
+ const expr = selector
653
+ ? `document.querySelector(${JSON.stringify(selector)})?.outerHTML || ''`
654
+ : "document.documentElement.outerHTML";
655
+ const { result } = await Runtime.evaluate({ expression: expr });
656
+ return { content: [{ type: "text", text: result?.value || "" }] };
657
+ },
658
+ },
659
+
660
+ browser_text: {
661
+ description: "Get visible text content of the page or a CSS selector.",
662
+ schema: { selector: z.string().describe("Optional CSS selector").optional() },
663
+ handler: async ({ selector }) => {
664
+ const p = await ensureBrowser();
665
+ const { Runtime } = p;
666
+ const expr = selector
667
+ ? `document.querySelector(${JSON.stringify(selector)})?.textContent || ''`
668
+ : "document.body?.textContent || ''";
669
+ const { result } = await Runtime.evaluate({ expression: expr });
670
+ return { content: [{ type: "text", text: result?.value || "" }] };
671
+ },
672
+ },
673
+
674
+ browser_click: {
675
+ description: "Click an element by CSS selector. Uses CDP Input.dispatchMouseEvent for native events.",
676
+ schema: { selector: z.string().describe("CSS selector") },
677
+ handler: async ({ selector }) => {
678
+ const p = await ensureBrowser();
679
+ const { Page, Runtime, Input } = p;
680
+ 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
+ };
687
+ },
688
+ },
689
+
690
+ browser_fill: {
691
+ 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
+ },
696
+ handler: async ({ selector, text }) => {
697
+ const p = await ensureBrowser();
698
+ const { Page, Runtime, Input } = p;
699
+ await fillElement(Page, Runtime, Input, selector, text);
700
+ return { content: [{ type: "text", text: JSON.stringify({ filled: selector, text }) }] };
701
+ },
702
+ },
703
+
704
+ browser_elements: {
705
+ description: "List interactive elements by kind: links, buttons, inputs, headings.",
706
+ schema: { kind: z.enum(["links", "buttons", "inputs", "headings"]).describe("Element kind") },
707
+ handler: async ({ kind }) => {
708
+ const p = await ensureBrowser();
709
+ const { Runtime } = p;
710
+ const selectors = {
711
+ links: "document.querySelectorAll('a[href]')",
712
+ buttons: "document.querySelectorAll('button, input[type=button], input[type=submit], [role=button]')",
713
+ inputs: "document.querySelectorAll('input:not([type=hidden]):not([type=submit]):not([type=button]), textarea, select')",
714
+ headings: "document.querySelectorAll('h1,h2,h3,h4,h5,h6')",
715
+ };
716
+ const { result } = await Runtime.evaluate({
717
+ expression: `(() => {
718
+ 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
+ }));
725
+ })()`,
726
+ returnByValue: true,
727
+ });
728
+ return { content: [{ type: "text", text: JSON.stringify(result?.value || []) }] };
729
+ },
730
+ },
731
+
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
+ browser_eval: {
755
+ description: "Execute JavaScript in the page context.",
756
+ schema: { expression: z.string().describe("JavaScript expression") },
757
+ handler: async ({ expression }) => {
758
+ const p = await ensureBrowser();
759
+ const { Runtime } = p;
760
+ // Runtime.evaluate returns { result: {...}, exceptionDetails?: {...} }
761
+ // Check exceptionDetails BEFORE destructuring result
762
+ const response = await Runtime.evaluate({ expression, returnByValue: true });
763
+ if (response.exceptionDetails) {
764
+ const exc = response.exceptionDetails;
765
+ const msg = exc.exception?.description || exc.text || "Unknown JS error";
766
+ throw new Error(`JS Error: ${msg}`);
767
+ }
768
+ const { result } = response;
769
+ return { content: [{ type: "text", text: JSON.stringify(result?.value ?? result) }] };
770
+ },
771
+ },
772
+
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) }] };
801
+ },
802
+ },
803
+ };
804
+
805
+ // ─── GROUNDBREAKING: Live Browser Event Capture ─────────────────────────
806
+ // No other MCP browser server gives the agent feedback from the page.
807
+ // This turns bwb from "blind screenshot-taker" into "live debug partner."
808
+
809
+ browser_watch: {
810
+ 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.",
811
+ schema: {
812
+ action: z.enum(["start", "poll", "stop"]).describe("start=begin recording, poll=get events since last poll, stop=cleanup"),
813
+ events: z.array(z.enum(["console", "network", "navigation", "all"])).describe("Event types to capture (default: all)").optional(),
814
+ },
815
+ handler: async ({ action, events = ["all"] }) => {
816
+ if (action === "start") {
817
+ const p = await ensureBrowser();
818
+ // Enable domains needed for event capture
819
+ await p.Runtime.enable();
820
+ await p.Network.enable();
821
+ setupWatch(events, p);
822
+ return {
823
+ content: [{
824
+ type: "text",
825
+ text: JSON.stringify({ status: "watching", events, msg: "Recording started. Call browser_watch({action:'poll'}) to get events." }),
826
+ }],
827
+ };
828
+ }
829
+
830
+ if (action === "poll") {
831
+ const snapshot = [...watchState.events];
832
+ watchState.events = [];
833
+ return {
834
+ content: [{
835
+ type: "text",
836
+ text: JSON.stringify({ count: snapshot.length, events: snapshot }),
837
+ }],
838
+ };
839
+ }
840
+
841
+ if (action === "stop") {
842
+ const remaining = [...watchState.events];
843
+ cleanupWatch();
844
+ return {
845
+ content: [{
846
+ type: "text",
847
+ text: JSON.stringify({ status: "stopped", captured: remaining.length, events: remaining }),
848
+ }],
849
+ };
850
+ }
851
+
852
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid action" }) }] };
853
+ },
854
+ },
855
+
856
+ browser_waitForSelector: {
857
+ description: "Wait for a CSS selector to appear (visible) or disappear from the DOM. Polls every 200ms until found or timeout.",
858
+ schema: {
859
+ selector: z.string().describe("CSS selector to wait for"),
860
+ timeout: z.number().describe("Max wait time in ms (default: 10000)").optional(),
861
+ disappear: z.boolean().describe("Wait for element to disappear instead of appear (default: false)").optional(),
862
+ visible: z.boolean().describe("Require element to be visible (non-zero dimensions, default: true)").optional(),
863
+ },
864
+ handler: async ({ selector, timeout = 10000, disappear = false, visible = true }) => {
865
+ const p = await ensureBrowser();
866
+ const { Runtime } = p;
867
+ await waitForSelector(Runtime, selector, { timeout, disappear, visible });
868
+ return {
869
+ content: [{
870
+ type: "text",
871
+ text: JSON.stringify({ found: !disappear, disappeared: disappear }),
872
+ }],
873
+ };
874
+ },
875
+ },
876
+
877
+ browser_setViewport: {
878
+ description: "Change the viewport size (width × height). Useful for responsive testing or capturing full-page screenshots at specific dimensions.",
879
+ schema: {
880
+ width: z.number().min(320).max(7680).describe("Viewport width in pixels (default: 1280)"),
881
+ height: z.number().min(240).max(4320).describe("Viewport height in pixels (default: 720)"),
882
+ },
883
+ handler: async ({ width = 1280, height = 720 }) => {
884
+ const p = await ensureBrowser();
885
+ await p.Page.setViewport({ width, height });
886
+ return {
887
+ content: [{
888
+ type: "text",
889
+ text: JSON.stringify({ viewport: `${width}x${height}` }),
890
+ }],
891
+ };
892
+ },
893
+ },
894
+
895
+ browser_back: {
896
+ description: "Go back in browser history (like clicking the browser back button).",
897
+ schema: {},
898
+ handler: async () => {
899
+ const p = await ensureBrowser();
900
+ const { Page, Runtime } = p;
901
+ await Page.navigate({ url: "javascript:history.back()" });
902
+ await new Promise((r) => setTimeout(r, 500));
903
+ const { result } = await Runtime.evaluate({ expression: "document.title" });
904
+ return {
905
+ content: [{
906
+ type: "text",
907
+ text: JSON.stringify({ title: result?.value || "" }),
908
+ }],
909
+ };
910
+ },
911
+ },
912
+ };
913
+
914
+ // Register all tools
915
+ for (const [name, tool] of Object.entries(tools)) {
916
+ server.tool(name, tool.description, tool.schema, tool.handler);
917
+ }
918
+
919
+ // ─── Start ────────────────────────────────────────────────────────────────────
920
+
921
+ await ensureDeps();
922
+ const transport = new StdioServerTransport();
923
+ await server.connect(transport);