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