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 全绿
This commit is contained in:
1 parent
82a44bb13f
commit
4d1aeada07
2 files changed
+197
-2
No files matched your search
@@ -0,0 +1,119 @@
|
||||
#!/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();
|
||||
}
|
||||
Reference in new issue
Block a user