@glittering0717/ai-browser 0.0.0-stage → 1.2.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.
@@ -0,0 +1,408 @@
1
+ // main/mcp_server.js — MCP server for AI Browser
2
+ // Exposes browse_web tool so Claude Code/Cursor/Codex can navigate, read, act on web pages
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import {
6
+ CallToolRequestSchema,
7
+ ListToolsRequestSchema,
8
+ ListPromptsRequestSchema,
9
+ ListResourcesRequestSchema,
10
+ ListResourceTemplatesRequestSchema,
11
+ } from '@modelcontextprotocol/sdk/types.js';
12
+ import net from 'node:net';
13
+ import { spawn } from 'node:child_process';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ // Shared Electron-binary resolver. npm >= 11 blocks dependency install scripts
17
+ // by default (so plain `npx` leaves electron without its engine); the resolver
18
+ // self-heals by running electron's bundled installer on first launch.
19
+ import electronResolve from '../../scripts/electron_resolve.cjs';
20
+ import { MCP_TOOLS } from './mcp_tools.js';
21
+ import { evaluateGuardError } from '../shared/guards.js';
22
+ import { config, wsUrl } from '../shared/config.js';
23
+ import { VERSION } from '../shared/version.js';
24
+ import { createWsClient } from './mcp_ws.js';
25
+
26
+ const WS_URL = wsUrl();
27
+ const WS_TIMEOUT = 15000;
28
+ const ELECTRON_PORT = config.wsPort;
29
+
30
+ const __filename = fileURLToPath(import.meta.url);
31
+ const __dirname = path.dirname(__filename);
32
+
33
+ // App/package root — identical layout in a git checkout and inside
34
+ // node_modules/@glittering0717/ai-browser: src/main/ → up two levels.
35
+ const APP_ROOT = path.resolve(__dirname, '..', '..');
36
+ const { resolveElectronBinary } = electronResolve;
37
+
38
+ // Persistent WS client — one reused connection with incrementing ids.
39
+ // Lazy launch: no Electron is started at boot. The first browse_* call that
40
+ // fails to connect triggers `ensureRunning`, which spawns Electron on demand
41
+ // (ensureElectronRunning is idempotent). `ensureElectronRunning` is a hoisted
42
+ // function declaration, so referencing it here is safe.
43
+ const wsClient = createWsClient({
44
+ url: WS_URL,
45
+ timeout: WS_TIMEOUT,
46
+ ensureRunning: ensureElectronRunning,
47
+ });
48
+
49
+ // Probe whether the Electron WS server is already listening on the port.
50
+ function checkPort(port) {
51
+ return new Promise((resolve) => {
52
+ const sock = net.connect({ port, host: '127.0.0.1' });
53
+ let done = false;
54
+ const finish = (ok) => { if (!done) { done = true; sock.destroy(); resolve(ok); } };
55
+ sock.once('connect', () => finish(true));
56
+ sock.once('error', () => finish(false));
57
+ setTimeout(() => finish(false), 500);
58
+ });
59
+ }
60
+
61
+ function waitForPort(port, timeoutMs) {
62
+ const start = Date.now();
63
+ return new Promise(async (resolve) => {
64
+ while (Date.now() - start < timeoutMs) {
65
+ if (await checkPort(port)) return resolve(true);
66
+ await new Promise(r => setTimeout(r, 500));
67
+ }
68
+ resolve(false);
69
+ });
70
+ }
71
+
72
+ // Spawn Electron detached so it survives this MCP subprocess.
73
+ //
74
+ // 两个坑都是实测踩出来的(2026-10-12):
75
+ //
76
+ // ① `ELECTRON_RUN_AS_NODE` 必须删掉。本机 shell 就带着它,此时 `electron .` 会
77
+ // 被当作普通 Node 运行,拿 Node 加载 ESM 入口直接崩,端口永不监听,而报错是
78
+ // 一串 Node 的 ESM 栈 —— 完全不提 Electron。e2e 脚本都删了它,只有这里漏了。
79
+ // 详见 scripts/start.cjs 顶部。
80
+ //
81
+ // ② 启动失败必须**说出来**。此前这里 return false,而 mcp_ws.js 又把它 catch 掉
82
+ // 吞了,于是 agent 先重试 120 秒,最后只拿到 "WS reconnect timeout:
83
+ // connection refused" —— 没有任何线索表明浏览器根本没起来,更别说为什么。
84
+ // 现在抛一个带原因的 Error,由 mcp_ws 并进最终错误信息。
85
+ async function ensureElectronRunning() {
86
+ if (await checkPort(ELECTRON_PORT)) return true;
87
+ console.error('[mcp] port 9223 not listening — spawning the ai-browser Electron app');
88
+ // Spawn the electron binary directly (resolved from this package's
89
+ // dependencies), pointed at the app root. Do NOT shell out to `npm start`:
90
+ // npm is not guaranteed on PATH for MCP clients, and on Windows the
91
+ // executable is npm.cmd — direct spawn avoids both classes of failure.
92
+ // AI_BROWSER_LAUNCHED_BY_AGENT:告诉 Electron 它是被 agent 拉起来的,因此
93
+ // 窗口出现时**不要**抢焦点(showInactive)—— 否则 agent 第一次连上来就会把
94
+ // 用户正在做的事打断。人手动 `ai-browser` / `npm start` 时没有这个变量。
95
+ const childEnv = { ...process.env, AI_BROWSER_LAUNCHED_BY_AGENT: '1' };
96
+ delete childEnv.ELECTRON_RUN_AS_NODE;
97
+ let child;
98
+ try {
99
+ child = spawn(resolveElectronBinary(), [APP_ROOT], {
100
+ cwd: APP_ROOT,
101
+ detached: true,
102
+ stdio: 'ignore',
103
+ env: childEnv,
104
+ });
105
+ } catch (e) {
106
+ throw new Error(`could not start the browser: ${e.message}`);
107
+ }
108
+ child.unref();
109
+ // 子进程自己提前退出(例如上面那个 Node ESM 崩溃)时,等满 30 秒是纯浪费 ——
110
+ // 而且会掩盖真实原因。这里挂一个 exit 监听,把退出码留给下面的诊断。
111
+ let earlyExit = null;
112
+ child.on('exit', (code, signal) => { earlyExit = { code, signal }; });
113
+ // Give Electron up to 30s to start the WS server.
114
+ const ok = await waitForPort(ELECTRON_PORT, 30000);
115
+ if (!ok) {
116
+ const why = earlyExit
117
+ ? `Electron exited early (code=${earlyExit.code}, signal=${earlyExit.signal}) — a common cause is ELECTRON_RUN_AS_NODE being set in the environment, or the electron binary being missing (check node_modules/electron/dist)`
118
+ : 'Electron did not come up within 30s (slow first-run binary download? try ELECTRON_MIRROR for users behind a slow network)';
119
+ console.error('[mcp] ' + why);
120
+ throw new Error(`could not start the browser: ${why}`);
121
+ }
122
+ return true;
123
+ }
124
+
125
+ const server = new Server(
126
+ { name: 'ai-browser-mcp', version: VERSION },
127
+ // resources/prompts 声明为空能力:协议要求即使一个都没有也要正常应答这两个
128
+ // list 请求(部分目录扫描器,如 Smithery,会把 "Method not found" 记为健康
129
+ // 检查失败)。能力清单保留声明,客户端据此知道服务端支持该方法族。
130
+ { capabilities: { tools: {}, resources: {}, prompts: {} } }
131
+ );
132
+
133
+ // Tool definitions — curated minimal MCP_TOOLS (see mcp_tools.js). Kept small
134
+ // to reduce per-message token cost; low-frequency/flow capabilities live in
135
+ // skills/ (read-webpage, web-network-monitor).
136
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
137
+ tools: MCP_TOOLS
138
+ }));
139
+
140
+ // 无资源 / 无提示词模板 —— 返回空集合而不是 protocol error。
141
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
142
+ server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));
143
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
144
+
145
+ // Tool execution
146
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
147
+ const { name, arguments: args = {} } = request.params;
148
+
149
+ switch (name) {
150
+ case 'browse_navigate': {
151
+ const result = await wsClient.call('ui.navigate', { url: args.url, tab: args.tab });
152
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
153
+ }
154
+
155
+ case 'browse_get_tree': {
156
+ const result = await wsClient.call('ui.get_tree', {
157
+ focusedOnly: args.focused_only || false,
158
+ ax: args.ax === true ? true : undefined,
159
+ subset: args.subset || undefined,
160
+ mode: args.mode || undefined,
161
+ tab: args.tab
162
+ });
163
+ // Don't truncate mid-JSON — a sliced JSON string is unparseable and
164
+ // worse than no data. Return the full tree; MCP transport handles
165
+ // large messages fine.
166
+ return {
167
+ content: [{
168
+ type: 'text',
169
+ text: JSON.stringify(result)
170
+ }]
171
+ };
172
+ }
173
+
174
+ case 'browse_act': {
175
+ const result = await wsClient.call('ui.act', {
176
+ action: args.action,
177
+ target: args.target,
178
+ params: {
179
+ text: args.text,
180
+ value: args.value,
181
+ url: args.url,
182
+ keep_tab: args.keep_tab,
183
+ file: args.file,
184
+ offset: args.offset,
185
+ limit: args.limit,
186
+ // drag:终点与端点锚点。to_x/to_y 是绝对视口坐标(用于拖到空白处,
187
+ // 例如平移画布);to_target 是落到另一个元素上(例如连线)。
188
+ // 这些名字与 raw WS 层完全一致 —— 归一由 page_manager._cdpDrag 单点负责,
189
+ // 这里不再做第二套映射(两套名字 = 迟早有一层不认识)。
190
+ to_target: args.to_target,
191
+ dx: args.dx,
192
+ dy: args.dy,
193
+ to_x: args.to_x,
194
+ to_y: args.to_y,
195
+ from_anchor: args.from_anchor,
196
+ from: (args.from_x !== undefined && args.from_y !== undefined)
197
+ ? { x: args.from_x, y: args.from_y }
198
+ : undefined,
199
+ to_anchor: args.to_anchor,
200
+ hold: args.hold,
201
+ button: args.button,
202
+ // press:单个键或按键序列
203
+ key: args.key,
204
+ keys: args.keys,
205
+ dx: args.delta_x,
206
+ dy: args.delta_y,
207
+ },
208
+ tab: args.tab
209
+ });
210
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
211
+ }
212
+
213
+ case 'browse_evaluate': {
214
+ const js = String(args.js || '');
215
+ // Defense-in-depth via the shared single-source guard: contextIsolation
216
+ // already blocks Node access, but reject obvious Node exfil patterns + cap
217
+ // length before forwarding. Same contract as the raw WS ui.evaluate path.
218
+ const guardErr = evaluateGuardError(js);
219
+ if (guardErr) {
220
+ // 工具结果形式的错误(isError:true),而不是 -32603:目录审核与客户端
221
+ // 都会实际调用一次工具,不透明的 internal error 会被当成"坏了"。
222
+ return { content: [{ type: 'text', text: `Error: ${guardErr}` }], isError: true };
223
+ }
224
+ const result = await wsClient.call('ui.evaluate', { js, tab: args.tab });
225
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
226
+ }
227
+
228
+ case 'browse_scroll': {
229
+ const result = await wsClient.call('ui.scroll', {
230
+ direction: args.direction || 'down',
231
+ amount: args.amount,
232
+ target: args.target,
233
+ tab: args.tab
234
+ });
235
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
236
+ }
237
+
238
+ case 'browse_wait': {
239
+ const result = await wsClient.call('ui.wait', {
240
+ condition: args.condition,
241
+ target: args.target,
242
+ text: args.text,
243
+ timeout_ms: args.timeout_ms,
244
+ tab: args.tab
245
+ });
246
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
247
+ }
248
+
249
+ case 'browse_list_tabs': {
250
+ const result = await wsClient.call('ui.list_tabs', {});
251
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
252
+ }
253
+
254
+ case 'browse_new_tab': {
255
+ const result = await wsClient.call('ui.new_tab', { url: args.url || null });
256
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
257
+ }
258
+
259
+ case 'browse_close_tab': {
260
+ const result = await wsClient.call('ui.close_tab', { tab: args.tab });
261
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
262
+ }
263
+
264
+ case 'browse_set_active_tab': {
265
+ const result = await wsClient.call('ui.set_active_tab', { tab: args.tab });
266
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
267
+ }
268
+
269
+ case 'browse_network': {
270
+ const result = args.operation === 'list' || args.operation === 'configure' || args.operation === 'clear'
271
+ ? await wsClient.call('ui.network_' + args.operation, {
272
+ tab: args.tab,
273
+ method: args.method,
274
+ url_contains: args.url_contains,
275
+ status: (args.status_min !== undefined || args.status_max !== undefined)
276
+ ? { min: args.status_min, max: args.status_max } : undefined,
277
+ resource_type: args.resource_type,
278
+ state: args.state,
279
+ started_after: args.started_after,
280
+ started_before: args.started_before,
281
+ limit: args.limit,
282
+ before_seq: args.before_seq,
283
+ enabled: args.enabled,
284
+ capture_bodies: args.capture_bodies,
285
+ })
286
+ : await wsClient.call('ui.network_get', {
287
+ tab: args.tab,
288
+ network_id: args.network_id,
289
+ include_request_headers: args.include_request_headers,
290
+ include_request_body: args.include_request_body,
291
+ include_response_headers: args.include_response_headers,
292
+ include_response_body: args.include_response_body,
293
+ include_sensitive_headers: args.include_sensitive_headers,
294
+ request_body_offset: args.request_body_offset,
295
+ response_body_offset: args.response_body_offset,
296
+ body_limit: args.body_limit,
297
+ });
298
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
299
+ }
300
+
301
+ case 'browse_canvas': {
302
+ const op = args.operation;
303
+ if (op === 'list') {
304
+ const result = await wsClient.call('ui.canvas_list', { tab: args.tab });
305
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
306
+ }
307
+ if (op === 'read') {
308
+ const result = await wsClient.call('ui.canvas_read', {
309
+ tab: args.tab,
310
+ canvas_id: args.canvas_id,
311
+ view: args.view,
312
+ since_seq: args.since_seq,
313
+ limit: args.limit,
314
+ });
315
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
316
+ }
317
+ if (op === 'configure') {
318
+ const result = await wsClient.call('ui.canvas_configure', {
319
+ tab: args.tab, mode: args.mode, clear: args.clear,
320
+ });
321
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
322
+ }
323
+ if (op === 'capture') {
324
+ // 视觉兜底:会消耗调用方的视觉 token,因此只在语义层读不懂时用。
325
+ const result = await wsClient.call('ui.canvas_capture', { tab: args.tab, canvas_id: args.canvas_id });
326
+ const data = result && result.data_base64;
327
+ if (!data) {
328
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
329
+ }
330
+ return {
331
+ content: [
332
+ { type: 'text', text: JSON.stringify({ ...result, data_base64: undefined }) },
333
+ { type: 'image', data, mimeType: result.mime_type || 'image/png' },
334
+ ],
335
+ };
336
+ }
337
+ throw new Error('browse_canvas: unknown operation ' + String(op));
338
+ }
339
+
340
+ case 'browse_diff': {
341
+ const op = args.operation;
342
+ if (op === 'snapshot') {
343
+ const result = await wsClient.call('ui.snapshot', {
344
+ tab: args.tab, js: args.js, label: args.label,
345
+ });
346
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
347
+ }
348
+ if (op === 'diff') {
349
+ const result = await wsClient.call('ui.diff', {
350
+ tab: args.tab, snapshot: args.snapshot, js: args.js,
351
+ rearm: args.rearm, forget: args.forget,
352
+ });
353
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
354
+ }
355
+ throw new Error('browse_diff: unknown operation ' + String(op));
356
+ }
357
+
358
+ case 'browse_capabilities': {
359
+ const result = await wsClient.call('ui.capabilities', {
360
+ tab: args.tab, include_network: args.include_network, network_limit: args.network_limit,
361
+ });
362
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
363
+ }
364
+
365
+ case 'browse_repaint': {
366
+ // "The window shows a stale page" has no observable state to detect (the
367
+ // DOM tree and evaluate already report the NEW page while the screen shows
368
+ // the OLD one), so the agent's only handle on it is an explicit request.
369
+ // Until now this was reachable over the raw WS only, which left MCP-only
370
+ // agents — the common case — with no fallback for the exact symptom this
371
+ // was built for.
372
+ const result = await wsClient.call('ui.repaint', { tab: args.tab });
373
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
374
+ }
375
+
376
+ case 'browse_downloads': {
377
+ const result = await wsClient.call('ui.downloads', { tab: args.tab });
378
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
379
+ }
380
+
381
+ case 'browse_quit': {
382
+ // Fire and forget — the WS server closes before we'd get the response.
383
+ try { await wsClient.call('ui.quit', {}); } catch (e) {}
384
+ return { content: [{ type: 'text', text: 'AI Browser shutting down' }] };
385
+ }
386
+
387
+ default:
388
+ throw new Error(`Unknown tool: ${name}`);
389
+ }
390
+ });
391
+
392
+ async function main() {
393
+ // Lazy loading: do NOT spawn Electron here. It used to start at MCP-server
394
+ // boot via ensureElectronRunning(), which made every MCP client launch pull up
395
+ // ai-browser even before any browse_* tool was called. Now Electron is brought
396
+ // up on first use, inside wsClient's connection-retry loop (via ensureRunning).
397
+ const listening = await checkPort(ELECTRON_PORT);
398
+ if (!listening) console.error('[mcp] ai-browser not running — will auto-start on first browse_* call');
399
+ const transport = new StdioServerTransport();
400
+ await server.connect(transport);
401
+ // stderr is for logging only; stdout is the MCP protocol
402
+ console.error('AI Browser MCP server started (stdio, lazy Electron)');
403
+ }
404
+
405
+ main().catch((e) => {
406
+ console.error('MCP server fatal:', e.message);
407
+ process.exit(1);
408
+ });