@glittering0717/ai-browser 1.3.1 → 2.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.
Files changed (37) hide show
  1. package/README.md +56 -5
  2. package/bin/ai-browser.cjs +41 -4
  3. package/examples/AGENT_INSTRUCTIONS.md +49 -164
  4. package/llms-install.md +67 -5
  5. package/package.json +3 -1
  6. package/scripts/cli_call.cjs +222 -0
  7. package/scripts/ensure_running.cjs +195 -0
  8. package/scripts/skill.cjs +133 -0
  9. package/skills/ai-browser/SKILL.md +56 -0
  10. package/skills/ai-browser/references/cli.md +81 -0
  11. package/skills/ai-browser/references/hard-pages.md +52 -0
  12. package/skills/ai-browser/references/network.md +39 -0
  13. package/skills/ai-browser/references/tasks.md +44 -0
  14. package/skills/ai-browser/references/troubleshooting.md +31 -0
  15. package/skills/content-publish/SKILL.md +61 -0
  16. package/skills/read-webpage/SKILL.md +52 -0
  17. package/skills/web-network-monitor/SKILL.md +52 -0
  18. package/src/main/axExtractor.js +50 -6
  19. package/src/main/capability_probe.js +93 -5
  20. package/src/main/index.js +99 -6
  21. package/src/main/mcp_args.js +122 -0
  22. package/src/main/mcp_server.js +72 -159
  23. package/src/main/mcp_tools.js +127 -86
  24. package/src/main/mcp_ws.js +7 -1
  25. package/src/main/page_manager.js +481 -71
  26. package/src/main/window_open_shim_source.js +117 -0
  27. package/src/preload/bridge.cjs +22 -4
  28. package/src/preload/extractor.cjs +161 -17
  29. package/src/shared/doc_coverage.js +264 -0
  30. package/src/shared/effect_state.js +160 -0
  31. package/src/shared/frame_probe.cjs +91 -1
  32. package/src/shared/frame_read_source.js +260 -0
  33. package/src/shared/opened_tab.js +71 -0
  34. package/src/shared/ref_epoch.js +99 -0
  35. package/src/shared/tab_hooks.js +33 -0
  36. package/src/shared/version.js +1 -1
  37. package/tools/coverage_probe.cjs +111 -9
@@ -9,15 +9,13 @@ import {
9
9
  ListResourcesRequestSchema,
10
10
  ListResourceTemplatesRequestSchema,
11
11
  } from '@modelcontextprotocol/sdk/types.js';
12
- import net from 'node:net';
13
- import { spawn } from 'node:child_process';
14
12
  import path from 'node:path';
15
13
  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';
14
+ // 启动逻辑与 CLI 共用一份(scripts/ensure_running.cjs)。曾经这里有一份私有实现,
15
+ // 结果 MCP 能自动拉起浏览器、CLI 不能 —— 同一件事两个行为。
16
+ import ensureMod from '../../scripts/ensure_running.cjs';
20
17
  import { MCP_TOOLS } from './mcp_tools.js';
18
+ import { actParams, networkParams } from './mcp_args.js';
21
19
  import { evaluateGuardError } from '../shared/guards.js';
22
20
  // 截图体积上限(与 WS 侧共用同一把尺):超过就不把图塞进上下文。
23
21
  import screenshotOpts from '../shared/screenshot_opts.cjs';
@@ -32,96 +30,29 @@ const ELECTRON_PORT = config.wsPort;
32
30
  const __filename = fileURLToPath(import.meta.url);
33
31
  const __dirname = path.dirname(__filename);
34
32
 
35
- // App/package root — identical layout in a git checkout and inside
36
- // node_modules/@glittering0717/ai-browser: src/main/ → up two levels.
37
- const APP_ROOT = path.resolve(__dirname, '..', '..');
38
- const { resolveElectronBinary } = electronResolve;
33
+ const { ensureRunning, checkPort } = ensureMod;
39
34
 
40
35
  // Persistent WS client — one reused connection with incrementing ids.
41
36
  // Lazy launch: no Electron is started at boot. The first browse_* call that
42
- // fails to connect triggers `ensureRunning`, which spawns Electron on demand
43
- // (ensureElectronRunning is idempotent). `ensureElectronRunning` is a hoisted
44
- // function declaration, so referencing it here is safe.
37
+ // fails to connect triggers `ensureRunning`, which starts Electron on demand.
38
+ //
39
+ // 2026-10-20: the launch logic moved to scripts/ensure_running.cjs so the CLI
40
+ // (`ai-browser call` / `ai-browser ensure`) shares ONE implementation with this
41
+ // server. Duplicating it here is what made "install and use" a two-step dance
42
+ // for CLI callers: MCP auto-started the browser, the CLI did not.
45
43
  const wsClient = createWsClient({
46
44
  url: WS_URL,
47
45
  timeout: WS_TIMEOUT,
48
- ensureRunning: ensureElectronRunning,
46
+ ensureRunning: () => ensureBrowser(),
49
47
  });
50
48
 
51
- // Probe whether the Electron WS server is already listening on the port.
52
- function checkPort(port) {
53
- return new Promise((resolve) => {
54
- const sock = net.connect({ port, host: '127.0.0.1' });
55
- let done = false;
56
- const finish = (ok) => { if (!done) { done = true; sock.destroy(); resolve(ok); } };
57
- sock.once('connect', () => finish(true));
58
- sock.once('error', () => finish(false));
59
- setTimeout(() => finish(false), 500);
60
- });
61
- }
62
-
63
- function waitForPort(port, timeoutMs) {
64
- const start = Date.now();
65
- return new Promise(async (resolve) => {
66
- while (Date.now() - start < timeoutMs) {
67
- if (await checkPort(port)) return resolve(true);
68
- await new Promise(r => setTimeout(r, 500));
69
- }
70
- resolve(false);
49
+ /** 拉起浏览器(幂等)。失败时抛带原因的 Error,由 mcp_ws 并进最终错误信息。 */
50
+ async function ensureBrowser() {
51
+ const info = await ensureRunning({
52
+ port: ELECTRON_PORT,
53
+ log: (m) => console.error('[mcp] ' + m),
71
54
  });
72
- }
73
-
74
- // Spawn Electron detached so it survives this MCP subprocess.
75
- //
76
- // 两个坑都是实测踩出来的(2026-10-12):
77
- //
78
- // ① `ELECTRON_RUN_AS_NODE` 必须删掉。本机 shell 就带着它,此时 `electron .` 会
79
- // 被当作普通 Node 运行,拿 Node 加载 ESM 入口直接崩,端口永不监听,而报错是
80
- // 一串 Node 的 ESM 栈 —— 完全不提 Electron。e2e 脚本都删了它,只有这里漏了。
81
- // 详见 scripts/start.cjs 顶部。
82
- //
83
- // ② 启动失败必须**说出来**。此前这里 return false,而 mcp_ws.js 又把它 catch 掉
84
- // 吞了,于是 agent 先重试 120 秒,最后只拿到 "WS reconnect timeout:
85
- // connection refused" —— 没有任何线索表明浏览器根本没起来,更别说为什么。
86
- // 现在抛一个带原因的 Error,由 mcp_ws 并进最终错误信息。
87
- async function ensureElectronRunning() {
88
- if (await checkPort(ELECTRON_PORT)) return true;
89
- console.error('[mcp] port 9223 not listening — spawning the ai-browser Electron app');
90
- // Spawn the electron binary directly (resolved from this package's
91
- // dependencies), pointed at the app root. Do NOT shell out to `npm start`:
92
- // npm is not guaranteed on PATH for MCP clients, and on Windows the
93
- // executable is npm.cmd — direct spawn avoids both classes of failure.
94
- // AI_BROWSER_LAUNCHED_BY_AGENT:告诉 Electron 它是被 agent 拉起来的,因此
95
- // 窗口出现时**不要**抢焦点(showInactive)—— 否则 agent 第一次连上来就会把
96
- // 用户正在做的事打断。人手动 `ai-browser` / `npm start` 时没有这个变量。
97
- const childEnv = { ...process.env, AI_BROWSER_LAUNCHED_BY_AGENT: '1' };
98
- delete childEnv.ELECTRON_RUN_AS_NODE;
99
- let child;
100
- try {
101
- child = spawn(resolveElectronBinary(), [APP_ROOT], {
102
- cwd: APP_ROOT,
103
- detached: true,
104
- stdio: 'ignore',
105
- env: childEnv,
106
- });
107
- } catch (e) {
108
- throw new Error(`could not start the browser: ${e.message}`);
109
- }
110
- child.unref();
111
- // 子进程自己提前退出(例如上面那个 Node ESM 崩溃)时,等满 30 秒是纯浪费 ——
112
- // 而且会掩盖真实原因。这里挂一个 exit 监听,把退出码留给下面的诊断。
113
- let earlyExit = null;
114
- child.on('exit', (code, signal) => { earlyExit = { code, signal }; });
115
- // Give Electron up to 30s to start the WS server.
116
- const ok = await waitForPort(ELECTRON_PORT, 30000);
117
- if (!ok) {
118
- const why = earlyExit
119
- ? `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)`
120
- : 'Electron did not come up within 30s (slow first-run binary download? try ELECTRON_MIRROR for users behind a slow network)';
121
- console.error('[mcp] ' + why);
122
- throw new Error(`could not start the browser: ${why}`);
123
- }
124
- return true;
55
+ return info.running;
125
56
  }
126
57
 
127
58
  const server = new Server(
@@ -178,39 +109,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
178
109
  }
179
110
 
180
111
  case 'browse_act': {
112
+ // 参数映射收在 mcp_args.js 单点完成:schema 对 agent 暴露分组,
113
+ // page_manager 只认扁平。旧扁平参数继续被接受(向后兼容)。
181
114
  const result = await wsClient.call('ui.act', {
182
115
  action: args.action,
183
116
  target: args.target,
184
- params: {
185
- text: args.text,
186
- value: args.value,
187
- url: args.url,
188
- keep_tab: args.keep_tab,
189
- file: args.file,
190
- offset: args.offset,
191
- limit: args.limit,
192
- // drag:终点与端点锚点。to_x/to_y 是绝对视口坐标(用于拖到空白处,
193
- // 例如平移画布);to_target 是落到另一个元素上(例如连线)。
194
- // 这些名字与 raw WS 层完全一致 —— 归一由 page_manager._cdpDrag 单点负责,
195
- // 这里不再做第二套映射(两套名字 = 迟早有一层不认识)。
196
- to_target: args.to_target,
197
- dx: args.dx,
198
- dy: args.dy,
199
- to_x: args.to_x,
200
- to_y: args.to_y,
201
- from_anchor: args.from_anchor,
202
- from: (args.from_x !== undefined && args.from_y !== undefined)
203
- ? { x: args.from_x, y: args.from_y }
204
- : undefined,
205
- to_anchor: args.to_anchor,
206
- hold: args.hold,
207
- button: args.button,
208
- // press:单个键或按键序列
209
- key: args.key,
210
- keys: args.keys,
211
- dx: args.delta_x,
212
- dy: args.delta_y,
213
- },
117
+ params: actParams(args),
214
118
  tab: args.tab
215
119
  });
216
120
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
@@ -276,54 +180,63 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
276
180
  return { content: [{ type: 'text', text: JSON.stringify(slim) }] };
277
181
  }
278
182
 
279
- case 'browse_list_tabs': {
280
- const result = await wsClient.call('ui.list_tabs', {});
281
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
282
- }
283
-
284
- case 'browse_new_tab': {
285
- const result = await wsClient.call('ui.new_tab', { url: args.url || null });
286
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
287
- }
288
-
289
- case 'browse_close_tab': {
290
- const result = await wsClient.call('ui.close_tab', { tab: args.tab });
291
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
292
- }
293
-
294
- case 'browse_set_active_tab': {
295
- const result = await wsClient.call('ui.set_active_tab', { tab: args.tab });
296
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
183
+ case 'browse_tabs': {
184
+ // 4 个单动词工具(list/new/close/set_active)合并为一个。
185
+ // 理由:它们操作同一个实体(标签页),每个只承载一个动词,是工具数量
186
+ // 膨胀里最没有道理的一种。合并后参数只有 op/tab/url 三个。
187
+ const op = args.op;
188
+ if (op === 'list') {
189
+ const result = await wsClient.call('ui.list_tabs', {});
190
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
191
+ }
192
+ if (op === 'new') {
193
+ const result = await wsClient.call('ui.new_tab', { url: args.url || null });
194
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
195
+ }
196
+ if (op === 'close') {
197
+ const result = await wsClient.call('ui.close_tab', { tab: args.tab });
198
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
199
+ }
200
+ if (op === 'switch') {
201
+ const result = await wsClient.call('ui.set_active_tab', { tab: args.tab });
202
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
203
+ }
204
+ return {
205
+ content: [{ type: 'text', text: `Error: unknown op "${op}" — use list / new / close / switch` }],
206
+ isError: true
207
+ };
297
208
  }
298
209
 
299
210
  case 'browse_network': {
300
- const result = args.operation === 'list' || args.operation === 'configure' || args.operation === 'clear'
211
+ const p = networkParams(args);
212
+ const isListLike =
213
+ args.operation === 'list' || args.operation === 'configure' || args.operation === 'clear';
214
+ const result = isListLike
301
215
  ? await wsClient.call('ui.network_' + args.operation, {
302
- tab: args.tab,
303
- method: args.method,
304
- url_contains: args.url_contains,
305
- status: (args.status_min !== undefined || args.status_max !== undefined)
306
- ? { min: args.status_min, max: args.status_max } : undefined,
307
- resource_type: args.resource_type,
308
- state: args.state,
309
- started_after: args.started_after,
310
- started_before: args.started_before,
311
- limit: args.limit,
312
- before_seq: args.before_seq,
313
- enabled: args.enabled,
314
- capture_bodies: args.capture_bodies,
216
+ tab: p.tab,
217
+ method: p.method,
218
+ url_contains: p.url_contains,
219
+ status: p.status,
220
+ resource_type: p.resource_type,
221
+ state: p.state,
222
+ started_after: p.started_after,
223
+ started_before: p.started_before,
224
+ limit: p.limit,
225
+ before_seq: p.before_seq,
226
+ enabled: p.enabled,
227
+ capture_bodies: p.capture_bodies
315
228
  })
316
229
  : await wsClient.call('ui.network_get', {
317
- tab: args.tab,
318
- network_id: args.network_id,
319
- include_request_headers: args.include_request_headers,
320
- include_request_body: args.include_request_body,
321
- include_response_headers: args.include_response_headers,
322
- include_response_body: args.include_response_body,
323
- include_sensitive_headers: args.include_sensitive_headers,
324
- request_body_offset: args.request_body_offset,
325
- response_body_offset: args.response_body_offset,
326
- body_limit: args.body_limit,
230
+ tab: p.tab,
231
+ network_id: p.network_id,
232
+ include_request_headers: p.include_request_headers,
233
+ include_request_body: p.include_request_body,
234
+ include_response_headers: p.include_response_headers,
235
+ include_response_body: p.include_response_body,
236
+ include_sensitive_headers: p.include_sensitive_headers,
237
+ request_body_offset: p.request_body_offset,
238
+ response_body_offset: p.response_body_offset,
239
+ body_limit: p.body_limit
327
240
  });
328
241
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
329
242
  }
@@ -42,37 +42,81 @@ export const MCP_TOOLS = [
42
42
  {
43
43
  name: 'browse_act',
44
44
  title: 'Act on element',
45
- description: 'Act on an element by data-ai-id: click, type (append at caret), setContent (replace all), clear, focus, hover, scroll_to, upload, get_value (read-only), drag, press, wheel, paste.',
45
+ description: 'Act on a data-ai-id element. type appends at caret, setContent replaces all. Verb-specific params live in the group of the same name (click/read/press/wheel/drag).',
46
46
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
47
47
  inputSchema: {
48
48
  type: 'object',
49
49
  properties: {
50
- action: { type: 'string', enum: ['click', 'type', 'clear', 'focus', 'hover', 'scroll_to', 'upload', 'get_value', 'drag', 'press', 'wheel', 'paste'], description: 'Action.' },
51
- html: { type: 'string', description: 'paste: HTML to inject (preferred). Editors convert it through their own paste pipeline (Draft.js/ProseMirror/Quill), so headings/code blocks/bold/lists survive. Plain text loses all formatting.' },
52
- target: { type: 'string', description: 'Element data-ai-id from the semantic tree (e.g. e:button-12, axf-3-1) — NOT the visible label. Omit for press (keys go to the current focus); for drag you may also omit it and pass from_x/from_y to start from a bare point. upload also accepts a CSS selector (descendant selectors with spaces included), which is the only way to reach upload widgets that have no semantic node.' },
53
- text: { type: 'string', description: 'Text to type (type/setContent). For paste: plain-text fallback used when html is absent.' },
54
- value: { type: 'string', description: 'Value for select.' },
55
- offset: { type: 'integer', description: 'get_value: start offset in Unicode code points (default 0).' },
56
- limit: { type: 'integer', description: 'get_value: max code points to return (default 20000, hard cap 1000000).' },
57
- url: { type: 'string', description: 'For click: click the link whose href matches this URL instead of guessing the target label.' },
58
- keep_tab: { type: 'boolean', description: 'For click: keep the current tab active instead of auto-following a newly opened tab.' },
59
- file: { type: 'string', description: 'Absolute path to upload (upload only). target may be the file input itself, the control that opens the picker, or — when the control has no semantic node at all — a CSS selector such as ".upload-button". Works inside cross-process iframes.' },
60
- timeout_ms: { type: 'integer', description: 'Override the action deadline in ms (default 15000; type/setContent/drag/press scale with text length / duration / repeat; 1s floor, 300s cap). A timeout returns success=false, timed_out=true and warns the action may have partially applied.' },
61
- to_target: { type: 'string', description: 'drag: data-ai-id to drop onto (e.g. drag from one node\'s output to another node to connect them).' },
62
- dx: { type: 'number', description: 'drag: horizontal distance to move (use with dy instead of to_target).' },
63
- dy: { type: 'number', description: 'drag: vertical distance to move.' },
64
- to_x: { type: 'number', description: 'drag: absolute viewport x to end at (overrides dx).' },
65
- to_y: { type: 'number', description: 'drag: absolute viewport y to end at.' },
66
- from_x: { type: 'number', description: 'drag: explicit viewport x to start the drag at (no target needed — e.g. panning empty canvas).' },
67
- from_y: { type: 'number', description: 'drag: explicit viewport y to start the drag at.' },
68
- from_anchor: { type: 'string', enum: ['center', 'left', 'right', 'top', 'bottom'], description: 'drag: which point of target to grab; a side prefers that node\'s connection handle (default center).' },
69
- to_anchor: { type: 'string', enum: ['center', 'left', 'right', 'top', 'bottom'], description: 'drag: which point of to_target to drop on.' },
70
- hold: { type: 'array', items: { type: 'string' }, description: 'drag: modifier keys held during the drag (e.g. ["Shift"]).' },
71
- button: { type: 'string', enum: ['left', 'middle', 'right'], description: 'drag: mouse button (default left). Canvas apps often pan on middle/right drag.' },
72
- key: { type: 'string', description: 'press: a key name such as Delete, Escape, ArrowRight, Enter, or a combo like "Meta+a".' },
73
- delta_x: { type: 'number', description: 'wheel: horizontal scroll amount (positive scrolls right).' },
74
- delta_y: { type: 'number', description: 'wheel: vertical scroll amount. Negative scrolls up / zooms out; add hold:["Control"] for canvas zoom.' },
75
- keys: { type: 'array', items: { type: 'string' }, description: 'press: sequence of keys to press in order.' },
50
+ action: { type: 'string', enum: ['click', 'type', 'setContent', 'clear', 'focus', 'hover', 'scroll_to', 'upload', 'get_value', 'drag', 'press', 'wheel', 'paste'], description: 'Verb. Verb-specific params live in the group of the same name.' },
51
+ target: { type: 'string', description: 'data-ai-id from the tree (e.g. e:button-12) — NOT the visible label. Optional for press; drag may use drag.from; upload also takes a CSS selector.' },
52
+ text: { type: 'string', description: 'type: appended at the caret. setContent: replaces all. paste: plain fallback when html is absent.' },
53
+ html: { type: 'string', description: 'paste: HTML, preferred — editors keep headings/lists/bold. Plain text loses formatting.' },
54
+ value: { type: 'string', description: 'Option value for a <select>.' },
55
+ file: { type: 'string', description: 'upload: absolute path. target may be the input, the trigger, or a CSS selector.' },
56
+ timeout_ms: { type: 'integer', description: 'Deadline ms (default 15000, 1s..300s). On timeout: success=false, timed_out=true, may have partially applied.' },
57
+ click: {
58
+ type: 'object',
59
+ description: 'click only.',
60
+ properties: {
61
+ url: { type: 'string', description: 'Hit the link whose href matches this URL (for composite cards with several links).' },
62
+ keep_tab: { type: 'boolean', description: 'Stay on this tab instead of following a newly opened one.' },
63
+ opened_tab_wait_ms: { type: 'integer', description: 'Extra wait ms for a site-opened tab (default 250 on link targets; pass it to force, 0 off).' }
64
+ }
65
+ },
66
+ read: {
67
+ type: 'object',
68
+ description: 'get_value only — read-only: does not focus the element or fire input events.',
69
+ properties: {
70
+ offset: { type: 'integer', description: 'Start offset in Unicode code points (default 0).' },
71
+ limit: { type: 'integer', description: 'Max code points to return (default 20000, hard cap 1000000).' }
72
+ }
73
+ },
74
+ press: {
75
+ type: 'object',
76
+ description: 'press only. Keys go to the current focus, so target is optional.',
77
+ properties: {
78
+ key: { type: 'string', description: 'Delete, Escape, ArrowRight, Enter, or a combo like "Meta+a".' },
79
+ keys: { type: 'array', items: { type: 'string' }, description: 'Sequence to press in order.' }
80
+ }
81
+ },
82
+ wheel: {
83
+ type: 'object',
84
+ description: 'wheel only. Lands on target, or the viewport centre when target is omitted.',
85
+ properties: {
86
+ dx: { type: 'number', description: 'Horizontal delta (positive scrolls right).' },
87
+ dy: { type: 'number', description: 'Vertical delta. Negative scrolls up / zooms out.' },
88
+ hold: { type: 'array', items: { type: 'string' }, description: 'Modifiers held, e.g. ["Control"] to zoom a canvas.' }
89
+ }
90
+ },
91
+ drag: {
92
+ type: 'object',
93
+ description: 'drag only. Give from/to for absolute points, dx/dy for a relative move, or target_to to drop onto another element.',
94
+ properties: {
95
+ from: {
96
+ type: 'object',
97
+ description: 'Start point. Omit to start from target.',
98
+ properties: {
99
+ x: { type: 'number' },
100
+ y: { type: 'number' },
101
+ anchor: { type: 'string', enum: ['center', 'left', 'right', 'top', 'bottom'], description: 'A side prefers that node\'s connection handle.' }
102
+ }
103
+ },
104
+ to: {
105
+ type: 'object',
106
+ description: 'End point in viewport coords.',
107
+ properties: {
108
+ x: { type: 'number' },
109
+ y: { type: 'number' },
110
+ anchor: { type: 'string', enum: ['center', 'left', 'right', 'top', 'bottom'] }
111
+ }
112
+ },
113
+ dx: { type: 'number', description: 'Horizontal distance instead of to.x.' },
114
+ dy: { type: 'number', description: 'Vertical distance instead of to.y.' },
115
+ target_to: { type: 'string', description: 'data-ai-id to drop onto (e.g. connect one node\'s output to another).' },
116
+ hold: { type: 'array', items: { type: 'string' }, description: 'Modifiers held during the drag, e.g. ["Shift"].' },
117
+ button: { type: 'string', enum: ['left', 'middle', 'right'], description: 'Canvas apps often pan on middle/right drag.' }
118
+ }
119
+ },
76
120
  tab: { type: 'integer', description: 'Tab id (default: active tab).' }
77
121
  },
78
122
  required: ['action']
@@ -164,81 +208,78 @@ export const MCP_TOOLS = [
164
208
  }
165
209
  },
166
210
  {
167
- name: 'browse_list_tabs',
168
- title: 'List tabs',
169
- description: 'List open tabs (id, url, title, active).',
170
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
171
- inputSchema: { type: 'object', properties: {} }
172
- },
173
- {
174
- name: 'browse_new_tab',
175
- title: 'New tab',
176
- description: 'Open a new tab, optionally at a URL.',
211
+ name: 'browse_tabs',
212
+ title: 'Tabs',
213
+ description: 'Tabs: op=list / new / close / switch. switch makes a tab the default target for later calls, so you rarely pass tab explicitly.',
214
+ // 合并前是 4 个工具(list / new / close / set_active),每个只承载一个动词。
215
+ // 合并后 destructiveHint 只能是 false —— 否则 op=list 也会被标成破坏性操作;
216
+ // close 的破坏性写在 description 里。这是合并的唯一语义代价,接受。
177
217
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
178
218
  inputSchema: {
179
219
  type: 'object',
180
220
  properties: {
181
- url: { type: 'string', description: 'Optional URL.' }
182
- }
183
- }
184
- },
185
- {
186
- name: 'browse_close_tab',
187
- title: 'Close tab',
188
- description: 'Close a tab by id.',
189
- annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
190
- inputSchema: {
191
- type: 'object',
192
- properties: {
193
- tab: { type: 'integer', description: 'Tab id.' }
194
- },
195
- required: ['tab']
196
- }
197
- },
198
- {
199
- name: 'browse_set_active_tab',
200
- title: 'Switch active tab',
201
- description: 'Switch the active tab.',
202
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
203
- inputSchema: {
204
- type: 'object',
205
- properties: {
206
- tab: { type: 'integer', description: 'Tab id.' }
221
+ op: { type: 'string', enum: ['list', 'new', 'close', 'switch'], description: 'list = id/url/title/active for every tab. switch = make `tab` the default target.' },
222
+ tab: { type: 'integer', description: 'close / switch: the target tab id.' },
223
+ url: { type: 'string', description: 'new: open at this URL (default about:blank).' }
207
224
  },
208
- required: ['tab']
225
+ required: ['op']
209
226
  }
210
227
  },
211
228
  {
212
229
  name: 'browse_network',
213
230
  title: 'Inspect network',
214
- description: 'Query captured HTTP requests (Chrome Network panel): list/get/clear/configure. Captures method, headers and POST bodies; sensitive headers redacted by default.',
231
+ description: 'Query captured HTTP requests: list/get/clear/configure. Captures headers and POST bodies; sensitive headers redacted. Each op reads its own group: filters / include / body_offset / configure.',
215
232
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
216
233
  inputSchema: {
217
234
  type: 'object',
218
235
  properties: {
219
236
  operation: { type: 'string', enum: ['list', 'get', 'clear', 'configure'], description: 'Operation.' },
220
- tab: { type: 'integer', description: 'Tab id (default: active tab).' },
221
237
  network_id: { type: 'string', description: 'get: id from list.' },
222
- method: { type: 'array', items: { type: 'string' }, description: 'list: filter by HTTP method.' },
223
- url_contains: { type: 'string', description: 'list: URL substring filter.' },
224
- status_min: { type: 'integer', description: 'list: minimum status.' },
225
- status_max: { type: 'integer', description: 'list: maximum status.' },
226
- resource_type: { type: 'array', items: { type: 'string' }, description: 'list: XHR/Fetch/Document/...' },
227
- state: { type: 'array', items: { type: 'string' }, description: 'list: pending/finished/failed.' },
228
- started_after: { type: 'string', description: 'list: ISO timestamp.' },
229
- started_before: { type: 'string', description: 'list: ISO timestamp.' },
230
- limit: { type: 'integer', description: 'list: page size (default 50, max 200).' },
231
- before_seq: { type: 'integer', description: 'list: cursor from pagination.next_before_seq.' },
232
- include_request_headers: { type: 'boolean' },
233
- include_request_body: { type: 'boolean' },
234
- include_response_headers: { type: 'boolean' },
235
- include_response_body: { type: 'boolean' },
236
- include_sensitive_headers: { type: 'boolean', description: 'Requires AI_BROWSER_NETWORK_SENSITIVE=1.' },
237
- request_body_offset: { type: 'integer' },
238
- response_body_offset: { type: 'integer' },
239
- body_limit: { type: 'integer', description: 'Body page size (default 65536, max 262144).' },
240
- enabled: { type: 'boolean', description: 'configure: turn capture on/off.' },
241
- capture_bodies: { type: 'string', enum: ['none', 'api', 'all'], description: 'configure.' }
238
+ filters: {
239
+ type: 'object',
240
+ description: 'list only.',
241
+ properties: {
242
+ method: { type: 'array', items: { type: 'string' }, description: 'HTTP methods.' },
243
+ url_contains: { type: 'string', description: 'URL substring.' },
244
+ status_min: { type: 'integer' },
245
+ status_max: { type: 'integer' },
246
+ resource_type: { type: 'array', items: { type: 'string' }, description: 'XHR / Fetch / Document / …' },
247
+ state: { type: 'array', items: { type: 'string' }, description: 'pending / finished / failed.' },
248
+ started_after: { type: 'string', description: 'ISO timestamp.' },
249
+ started_before: { type: 'string', description: 'ISO timestamp.' },
250
+ limit: { type: 'integer', description: 'Page size (default 50, max 200).' },
251
+ before_seq: { type: 'integer', description: 'Cursor from pagination.next_before_seq.' }
252
+ }
253
+ },
254
+ include: {
255
+ type: 'object',
256
+ description: 'get only — what to bring back.',
257
+ properties: {
258
+ request_headers: { type: 'boolean' },
259
+ response_headers: { type: 'boolean' },
260
+ request_body: { type: 'boolean' },
261
+ response_body: { type: 'boolean' },
262
+ sensitive_headers: { type: 'boolean', description: 'Requires AI_BROWSER_NETWORK_SENSITIVE=1.' },
263
+ body_limit: { type: 'integer', description: 'Body page size (default 65536, max 262144).' }
264
+ }
265
+ },
266
+ body_offset: {
267
+ type: 'object',
268
+ description: 'get only — paginate a large body.',
269
+ properties: {
270
+ request: { type: 'integer' },
271
+ response: { type: 'integer' }
272
+ }
273
+ },
274
+ configure: {
275
+ type: 'object',
276
+ description: 'configure only.',
277
+ properties: {
278
+ enabled: { type: 'boolean', description: 'Turn capture on/off.' },
279
+ capture_bodies: { type: 'string', enum: ['none', 'api', 'all'] }
280
+ }
281
+ },
282
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' }
242
283
  },
243
284
  required: ['operation']
244
285
  }
@@ -102,7 +102,13 @@ export function createWsClient({
102
102
 
103
103
  // Attempt failed — release this socket's connect-phase listeners fully so
104
104
  // its later error/close can't touch live state (MW-4).
105
- try { ws.removeAllListeners(); ws.close(); } catch (_e) {}
105
+ // ⚠️ close() 一个还在 CONNECTING 的 socket 会异步再发一次 'error'
106
+ // ("WebSocket was closed before the connection was established")。
107
+ // removeAllListeners 把 connect-phase 的 once('error') 也摘了,这个迟到
108
+ // 的 error 就没有监听器 → EventEmitter 直接 throw → 整个 MCP server 进程
109
+ // 崩掉,lazy launch 重试循环根本走不到(表现为"首次调用必崩")。
110
+ // 挂一个 no-op error 兜底:迟到事件只被吞掉,不影响 MW-4 的状态隔离。
111
+ try { ws.removeAllListeners(); ws.on('error', () => {}); ws.close(); } catch (_e) {}
106
112
  const err = connectionErr || new Error('connection refused');
107
113
  // eslint-disable-next-line no-console
108
114
  if (attempt > 0 && attempt % 4 === 0) console.error('[mcp] ws reconnect attempt', attempt, '-', err.message);