@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.
- package/README.md +56 -5
- package/bin/ai-browser.cjs +41 -4
- package/examples/AGENT_INSTRUCTIONS.md +49 -164
- package/llms-install.md +67 -5
- package/package.json +3 -1
- package/scripts/cli_call.cjs +222 -0
- package/scripts/ensure_running.cjs +195 -0
- package/scripts/skill.cjs +133 -0
- package/skills/ai-browser/SKILL.md +56 -0
- package/skills/ai-browser/references/cli.md +81 -0
- package/skills/ai-browser/references/hard-pages.md +52 -0
- package/skills/ai-browser/references/network.md +39 -0
- package/skills/ai-browser/references/tasks.md +44 -0
- package/skills/ai-browser/references/troubleshooting.md +31 -0
- package/skills/content-publish/SKILL.md +61 -0
- package/skills/read-webpage/SKILL.md +52 -0
- package/skills/web-network-monitor/SKILL.md +52 -0
- package/src/main/axExtractor.js +50 -6
- package/src/main/capability_probe.js +93 -5
- package/src/main/index.js +99 -6
- package/src/main/mcp_args.js +122 -0
- package/src/main/mcp_server.js +72 -159
- package/src/main/mcp_tools.js +127 -86
- package/src/main/mcp_ws.js +7 -1
- package/src/main/page_manager.js +481 -71
- package/src/main/window_open_shim_source.js +117 -0
- package/src/preload/bridge.cjs +22 -4
- package/src/preload/extractor.cjs +161 -17
- package/src/shared/doc_coverage.js +264 -0
- package/src/shared/effect_state.js +160 -0
- package/src/shared/frame_probe.cjs +91 -1
- package/src/shared/frame_read_source.js +260 -0
- package/src/shared/opened_tab.js +71 -0
- package/src/shared/ref_epoch.js +99 -0
- package/src/shared/tab_hooks.js +33 -0
- package/src/shared/version.js +1 -1
- package/tools/coverage_probe.cjs +111 -9
package/src/main/mcp_server.js
CHANGED
|
@@ -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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
//
|
|
44
|
-
//
|
|
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:
|
|
46
|
+
ensureRunning: () => ensureBrowser(),
|
|
49
47
|
});
|
|
50
48
|
|
|
51
|
-
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 '
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
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:
|
|
303
|
-
method:
|
|
304
|
-
url_contains:
|
|
305
|
-
status:
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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:
|
|
318
|
-
network_id:
|
|
319
|
-
include_request_headers:
|
|
320
|
-
include_request_body:
|
|
321
|
-
include_response_headers:
|
|
322
|
-
include_response_body:
|
|
323
|
-
include_sensitive_headers:
|
|
324
|
-
request_body_offset:
|
|
325
|
-
response_body_offset:
|
|
326
|
-
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
|
}
|
package/src/main/mcp_tools.js
CHANGED
|
@@ -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
|
|
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: '
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
value: { type: 'string', description: '
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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: '
|
|
168
|
-
title: '
|
|
169
|
-
description: '
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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: ['
|
|
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
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
}
|
package/src/main/mcp_ws.js
CHANGED
|
@@ -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
|
-
|
|
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);
|