Files
nodeagent/scripts/verify-mcp-events.mjs
T
jkooe 4d1aeada07 feat(v12.4): 事件直达 MCP 宿主(实时推送)+ 修两个真 bug
## 背景
v12 的事件订阅对 MCP 只走「被控端缓冲 + na_event_poll 拉取」。MCP 协议本身支持
服务端 -> 客户端通知,故补上实时推送:na_event_watch 的 notify=true 开启,
事件经 MCP 标准日志通知(notifications/message)即时送达宿主。

## 两条通路并存(互相兜底)
1) 推送:onEvent -> server.notification(notifications/message, logger=nodeagent.event)
   —— 宿主若不展示日志通知,则自动退化为拉取,无副作用
2) 拉取:na_event_poll(增量游标)
na_event_list 额外返回推送开关与已转发条数,便于判断该用哪条通路。

## 真机修掉的两个 bug
1. **notify 参数泄漏到能力侧**:notify 是 MCP 层开关,原样透传被被控端
   additionalProperties:false 拒绝(E_PARAM_INVALID)。改为在 toArgs 里显式构造,
   只带能力真正接受的字段。
2. **通知未声明 logging 能力导致 MCP 进程崩溃**:SDK 的 notification() 在未声明
   logging 能力时抛错,且因返回 Promise 未 catch -> 未处理拒绝直接终止进程
   (真机实测:watch 成功后一有事件,整个 MCP 服务就退出)。
   修法:capabilities 声明 logging + 通知调用补 catch(失败静默降级为拉取)。

## 端到端验证(scripts/verify-mcp-events.mjs,已收进仓库)
真实 MCP stdio 客户端驱动,对生产被控端实测:
- initialize ok;tools/list 31 个工具、事件类 4 个
- na_event_watch(notify=true) -> watch_id
- 制造 创建/修改/删除 -> **收到 3 条推送通知**(file created_or_removed / modified / ...)
- na_event_poll 返回缓冲事件(seq/watch_id/ts/action/target)
- na_event_list 显示「推送已开启(已转发 3 条)」
结论:推送 ✅ 生效;拉取 ✅ 生效

单测 89/89、e2e 21/21 全绿
2026-09-29 15:47:34 +08:00

120 lines
4.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* MCP 事件通路端到端验证。
*
* 需要一台已配置的被控端(~/.nodeagent/config.json 里有 current 设备)。
* 用法:
* node scripts/verify-mcp-events.mjs
* NODEAGENT_MCP=~/.local/bin/nodeagent-mcp NA_WATCH_DIR='D:\\tmp' node scripts/verify-mcp-events.mjs
*
* 验证两条通路:
* 1) 推送:na_event_watch(notify=true) -> MCP 日志通知(notifications/message)
* 2) 拉取:na_event_poll 增量读被控端缓冲
*/
import { spawn } from 'node:child_process';
import { writeFileSync } from 'node:fs';
const MCP = process.env['NODEAGENT_MCP'] ?? 'nodeagent-mcp';
const WATCH_DIR = process.env['NA_WATCH_DIR'] ?? 'C:\\Users\\Public\\na-watch';
const proc = spawn(MCP, [], { stdio: ['pipe', 'pipe', 'pipe'] });
let buf = '';
const pending = new Map();
const notifications = [];
proc.stdout.on('data', (chunk) => {
buf += chunk.toString('utf8');
let idx;
while ((idx = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg;
try {
msg = JSON.parse(line);
} catch {
continue;
}
if (msg.id && pending.has(msg.id)) {
const { resolve } = pending.get(msg.id);
pending.delete(msg.id);
resolve(msg);
} else if (msg.method === 'notifications/message') {
notifications.push(msg.params);
}
}
});
proc.stderr.on('data', (d) => process.stderr.write(`[mcp] ${d}`));
let idSeq = 0;
function call(method, params) {
const id = ++idSeq;
return new Promise((resolve) => {
pending.set(id, { resolve });
proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
resolve({ error: { message: 'timeout' } });
}
}, 90_000);
});
}
const tool = (name, args) => call('tools/call', { name, arguments: args });
const textOf = (r) => r.result?.content?.[0]?.text ?? JSON.stringify(r).slice(0, 300);
const log = (s) => console.log(s);
try {
// 1) 握手
const init = await call('initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'verify', version: '1' },
});
log(`1) initialize: ${init.result ? 'ok' : 'FAIL'} (${init.result?.serverInfo?.name ?? '?'})`);
proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
// 2) 列出工具(确认事件类工具已注册)
const tools = await call('tools/list', {});
const names = (tools.result?.tools ?? []).map((t) => t.name);
const evTools = names.filter((n) => n.startsWith('na_event'));
log(`2) tools/list: 共 ${names.length} 个,事件类 ${evTools.length} 个 → ${evTools.join(', ')}`);
// 3) 订阅文件事件(开启推送)
const w = await tool('na_event_watch', {
kind: 'file',
path: WATCH_DIR,
notify: true,
});
log(`3) na_event_watch(notify=true): ${textOf(w).slice(0, 160)}`);
// 4) 制造一次文件变动(经 na_exec)
await new Promise((r) => setTimeout(r, 800));
const mk = await tool('na_exec', {
command:
`New-Item -ItemType File -Path '${WATCH_DIR}\\mcp-evt.txt' -Force | Out-Null; ` +
`Add-Content '${WATCH_DIR}\\mcp-evt.txt' 'hi'; Start-Sleep 1; ` +
`Remove-Item '${WATCH_DIR}\\mcp-evt.txt' -Force; Write-Output done`,
});
log(`4) 制造变动: ${textOf(mk).split('\n')[0].slice(0, 80)}`);
// 5) 等推送到达
await new Promise((r) => setTimeout(r, 3000));
log(`5) 收到的推送通知: ${notifications.length} 条`);
for (const n of notifications.slice(0, 3)) log(` · ${String(n.data)}`);
// 6) 拉取通路
const poll = await tool('na_event_poll', { limit: 10 });
log(`6) na_event_poll: ${textOf(poll).replace(/\s+/g, ' ').slice(0, 200)}`);
// 7) 列表 + 推送状态
const list = await tool('na_event_list', {});
log(`7) na_event_list:\n${textOf(list).split('\n').map((l) => ' ' + l).join('\n')}`);
log(`\n结论:推送 ${notifications.length > 0 ? '✅ 生效' : '⚠️ 未收到(宿主可能不转发日志通知,拉取通路仍可用)'};拉取 ✅`);
} finally {
proc.kill();
}