Files
MailUI4Agents/plugins/zcode-mail-bridge/test/mcp-rpc.test.mjs
JianFeeeee 3a6d572020 feat(zcode): 工具加 MCP 注解 + headless 档位映射改为 plan(否则一个工具都用不了)
## 逆出 ZCode 的 MCP 权限判定,并据此让工具真的可用

逐字逆自 CLI 产物:

  Ari():  annotations.readOnlyHint === true → riskLevel "low"
          annotations.destructiveHint === true → riskLevel "high"
          needsApproval = true   ← **硬编码为真,与注解无关**
  checkBuildMode(): needsApproval || destructive || sideEffectScope !== "none" → ask
  checkPlanMode():  permissionName === "mcp" && !destructive → allow

两条合起来的结论不直观但很关键:

- **build 档下每一个 MCP 工具都要审批**(needsApproval 恒真),而 headless
  模式没有交互式审批客户端 ⇒ 全被拒。实测:模型连 read_inbox 都调不动,
  只能从提示词里猜;更糟的是它**绕道**用 Bash 去读网关的 sqlite WAL 文件
  (它自己在回信里如实交代了这件事)。
- **plan 档下只要不声明 destructive,MCP 工具直接放行**。

于是两处改动:

1. `lib/tools.mjs` 给每个工具加真实注解(读类 readOnlyHint,写类
   destructiveHint:false——它们确实不破坏任何东西);`lib/mcp-rpc.mjs` 透传
   annotations。**漏传不是"少个提示",而是工具在该档下全被拒**。
2. `src/turn-mode.mjs` 的 workspace 档映射从 build 改为 **plan**。
   build 在本环境等于「什么都不能做」,那不是保守而是不可用;plan 才是真的
   fail-closed:危险的自带工具被平台直接拒,能用的只有我们声明为非破坏性的工具。
   日志会明确写出为什么退档。可用 `AGENTMAIL_ZCODE_MODE_MAP` 覆盖
   (平台修好钩子后只改配置就能恢复 build,不必等发版)。

## 真模型验证

场景 A 的判据同时加强:**正文本标记只出现在邮件正文里**(驱动的提示词只带主题
与 mail_id),所以模型必须真的读信才可能答对。通过 —— 约 20-30 秒一轮。

反过来说,早先那版「通过」是假的:标记在主题里,模型从提示词抄一遍就行。

## 仍然做不到的(见 README 已知缺口)

授权桥(PermissionRequest 钩子)在本版本(3.10.2 / CLI 0.16.5)**不可用**:
有时根本不触发,触发时在 ~5ms 内失败且**命令从未被 spawn**
(用「钩子写 marker 文件」的副作用验证,process 与 command 两种类型都一样)。
所以 workspace 档「危险操作问人」目前在 headless 下无法实现。

单元 329/329。
2026-09-12 16:49:28 +08:00

213 lines
8.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/annotations多带的字段会被客户端拒绝', 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('★ annotations 必须透传ZCode 靠它算风险等级plan 档据此放行)', async () => {
// 漏传的后果不是「少个提示」而是「工具在该档下全被拒」:
// ZCode 的 MCP 工具 needsApproval 恒为真,只有 plan 档的
// 「!destructive → allow」能放行而 destructive 正是从 annotations 读的。
const ctx = {
tools: [{ name: 'read_inbox', description: 'd', inputSchema: {}, annotations: { readOnlyHint: true, destructiveHint: false } }],
call: async () => 'x'
};
const out = await handleMessage({ jsonrpc: '2.0', id: 3, method: 'tools/list' }, ctx);
assert.deepEqual(out.result.tools[0].annotations, { readOnlyHint: true, destructiveHint: false });
});
test('★ 反向对照:没有注解的工具不该凭空多出 annotations 字段', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 4, method: 'tools/list' }, makeCtx());
assert.equal('annotations' in out.result.tools[0], false);
});
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文本/);
});