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