## 逆出 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。
213 lines
8.3 KiB
JavaScript
213 lines
8.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/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文本/);
|
||
});
|