/** * 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文本/); });