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 均返回
196 lines
7.3 KiB
JavaScript
196 lines
7.3 KiB
JavaScript
/**
|
||
* 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文本/);
|
||
});
|