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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/bwb +2 -0
- package/package.json +60 -0
- 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
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);
|