Files
MailUI4Agents/plugins/zcode-mail-bridge/test/mcp-rpc.test.mjs
JianFeeeee e0e6f86d94 feat(zcode): AgentMail 的 ZCode 插件 —— MCP 工具面 + 官方宿主启动验证
ZCode 用插件扩展能力(.zcode-plugin/plugin.json 声明 skills/commands/hooks/
mcpServers),所以适配它的正确形状是**插件**而不是又一个独立桥进程。

本提交是第一步:把 AgentMail 的工具面做成 MCP 服务器。

协议层(lib/mcp-rpc.mjs)手写,不引 @modelcontextprotocol/sdk:
协议面只有 initialize / notifications/initialized / tools/list / tools/call,
手写可省掉一条构建链与 1MB 打包产物(与 pi/opencode/dsh 三桥零运行时依赖的
取向一致),并让这一层成为可穷举的纯函数。分帧照官方插件产物实测确认是
换行分隔 JSON(Content-Length 出现 0 次,StdioServerTransport + split("\n"))。

工具面(lib/tools.mjs)与另三个桥**同名同参**,渲染走共用的
addressing/inbox-format/discovery(逐字节同源,已纳入 check-shared-libs.sh)。
测试里有一条断言直接拿 pi 桥的工具名做对照:少一个就让某平台行为与其它平台不同,
那种问题只在单平台复现,排查代价最高。

两处按真实缺陷定的行为:
- 工具失败回 result+isError 而非 JSON-RPC error —— 后者会让模型看不到失败原因,
  只能重试(opencode 连试 6 次发不出附件正是这个后果)
- attachment_ids 声明放宽为 anyOf 数组/字符串并在桥侧归一 —— 模型常写成
  JSON 字符串,服务端严格解码会拒(同样来自 opencode 那次失败)

入口 mcp/server.mjs 修掉一个真实缺陷:stdin 关闭即 process.exit 会杀掉在途请求,
表现为「协议全对但访问网关的调用完全没有响应」。现按在途计数 drain,
且把 stdout 写入也计入,避免最后一条响应卡在缓冲区。

顺带修 check-shared-libs.sh 的一个既有假绿:本机 PATH 上的 diff 是鸿蒙 SDK
工具链的 diff,不认 -q 且对不同的文件仍返回 0 —— 于是该检查器**一直是永真输出**。
改用 cmp -s,并加自检(判据本身必须先被证明能发现差异)。反向验证:
让 zcode 或 pi 的共用模块分叉,检查器都正确报错并返回 1。

验证:
- 单元 33 项 + 继承共用测试 87 项 = 120/120
- `zcode plugins list` → agentmail@inline [enabled],mcp: plugin:agentmail:agentmail
- 经官方 `node zcode.cjs __zcode-plugin-host <server.mjs>` 启动 → 握手与 tools/list 正常
- 真实网关调用:以 zcode 身份 read_inbox / suggest_address / list_contacts 均返回
2026-09-12 13:47:51 +08:00

196 lines
7.3 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.

/**
* MCP 协议层的测试。
*
* 这一层是手写的,所以它必须被穷举 —— 否则「工具没出现」「模型收不到错误」
* 这类问题只能连上 ZCode 才能发现,而那时线索要少得多。
*
* 每条断言都对应一个**真实的失败模式**,不是为覆盖率写的。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { handleMessage, handleLine, RPC_ERROR } from '../lib/mcp-rpc.mjs';
const TOOLS = [
{ name: 'read_inbox', description: '读收件箱', inputSchema: { type: 'object' } },
{ name: 'send_mail', description: '发信', inputSchema: { type: 'object' } }
];
/** 造一个 ctx`call` 默认成功,可换成抛错来验失败路径。 */
const makeCtx = (impl = async () => '结果文本') => ({
tools: TOOLS,
call: impl
});
test('initialize 回显客户端给的协议版本', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26' } },
makeCtx()
);
assert.equal(out.result.protocolVersion, '2025-03-26');
assert.deepEqual(out.result.capabilities, { tools: { listChanged: false } });
assert.equal(out.result.serverInfo.name, 'agentmail');
});
test('initialize 缺参数时用默认版本兜底,而不是崩', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 1, method: 'initialize' }, makeCtx());
assert.ok(out.result.protocolVersion);
});
test('notifications/initialized 不回响应(回了会让后续调用错配)', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', method: 'notifications/initialized' },
makeCtx()
);
assert.equal(out, null);
});
test('任何无 id 的消息都不回响应', async () => {
const out = await handleMessage({ jsonrpc: '2.0', method: 'tools/list' }, makeCtx());
assert.equal(out, null);
});
test('tools/list 只暴露 name/description/inputSchema多带的字段会被客户端拒绝', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, makeCtx());
assert.equal(out.result.tools.length, 2);
for (const t of out.result.tools) {
assert.deepEqual(Object.keys(t).sort(), ['description', 'inputSchema', 'name']);
}
});
test('tools/call 成功时回 content 文本数组', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'read_inbox', arguments: {} } },
makeCtx()
);
assert.deepEqual(out.result, { content: [{ type: 'text', text: '结果文本' }] });
assert.equal(out.result.isError, undefined);
});
test('tools/call 把 arguments 原样交给工具', async () => {
let seen = null;
const ctx = makeCtx(async (name, args) => {
seen = { name, args };
return 'ok';
});
await handleMessage(
{
jsonrpc: '2.0',
id: 4,
method: 'tools/call',
params: { name: 'send_mail', arguments: { to: 'admin@/tmp', subject: 's' } }
},
ctx
);
assert.deepEqual(seen, { name: 'send_mail', args: { to: 'admin@/tmp', subject: 's' } });
});
test('tools/call 缺 arguments 时当空对象,不抛错', async () => {
let seen = null;
const ctx = makeCtx(async (name, args) => {
seen = args;
return 'ok';
});
const out = await handleMessage(
{ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'read_inbox' } },
ctx
);
assert.deepEqual(seen, {});
assert.equal(out.result.isError, undefined);
});
test('★ 工具执行失败回 result+isError不回 JSON-RPC error', async () => {
// 判据的关键:模型必须能看到失败原因。若回 JSON-RPC error
// 客户端只会显示一次协议错误,模型拿不到「为什么失败」,
// 也就无法改正opencode 上连试 6 次发不出附件就是这个后果)。
const ctx = makeCtx(async () => {
throw new Error('HTTP 409附件已随其他邮件发出');
});
const out = await handleMessage(
{ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'send_mail', arguments: {} } },
ctx
);
assert.equal(out.error, undefined, '不该是 JSON-RPC error');
assert.equal(out.result.isError, true);
assert.match(out.result.content[0].text, /附件已随其他邮件发出/);
});
test('★ 反向对照:成功时绝不带 isError', async () => {
// 与上一条构成对照:同样的入参、同样的方法,只翻转工具行为,
// isError 必须跟着翻转。否则「总是 isError」也会让上一条通过。
const ok = await handleMessage(
{ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'send_mail', arguments: {} } },
makeCtx()
);
const bad = await handleMessage(
{ jsonrpc: '2.0', id: 8, method: 'tools/call', params: { name: 'send_mail', arguments: {} } },
makeCtx(async () => {
throw new Error('x');
})
);
assert.equal(ok.result.isError, undefined);
assert.equal(bad.result.isError, true);
});
test('tools/call 未知工具名回 INVALID_PARAMS', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'not_a_tool' } },
makeCtx()
);
assert.equal(out.error.code, RPC_ERROR.INVALID_PARAMS);
assert.equal(out.result, undefined);
});
test('tools/call 缺 name 回 INVALID_PARAMS', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 10, method: 'tools/call', params: {} },
makeCtx()
);
assert.equal(out.error.code, RPC_ERROR.INVALID_PARAMS);
});
test('未知方法回 METHOD_NOT_FOUND', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 11, method: 'x/y' }, makeCtx());
assert.equal(out.error.code, RPC_ERROR.METHOD_NOT_FOUND);
});
test('ping 有响应', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 12, method: 'ping' }, makeCtx());
assert.deepEqual(out.result, {});
});
test('缺 method 回 INVALID_REQUEST', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 13 }, makeCtx());
assert.equal(out.error.code, RPC_ERROR.INVALID_REQUEST);
});
test('id 原样回显(含 0 与字符串 id', async () => {
for (const id of [0, 'abc', 42]) {
const out = await handleMessage({ jsonrpc: '2.0', id, method: 'ping' }, makeCtx());
assert.equal(out.id, id);
}
});
// ─── handleLine分帧与解析 ───────────────────────────────────────
test('handleLine 空行不产生响应', async () => {
assert.equal(await handleLine('', makeCtx()), null);
assert.equal(await handleLine(' ', makeCtx()), null);
});
test('handleLine 非法 JSON 回带 id=null 的解析错误', async () => {
// 必须回:不回的话客户端会一直等这一条的响应。
const out = await handleLine('{not json', makeCtx());
const parsed = JSON.parse(out);
assert.equal(parsed.error.code, RPC_ERROR.PARSE);
assert.equal(parsed.id, null);
});
test('handleLine 输出是单行(换行会破坏分帧)', async () => {
const out = await handleLine(
JSON.stringify({ jsonrpc: '2.0', id: 14, method: 'tools/call', params: { name: 'read_inbox' } }),
makeCtx(async () => '多行\n文本\n在此')
);
assert.equal(out.includes('\n'), false, '响应里不能有裸换行(应被转义进 JSON 字符串)');
assert.match(JSON.parse(out).result.content[0].text, /多行\n文本/);
});