Files
MailUI4Agents/plugins/zcode-mail-bridge/test/generic-mcp.test.mjs
JianFeeeee 29ad8aa204 feat(mcp): 去 ZCode 影子 —— mcp/server.mjs 改为通用 MCP 服务
## 目的

`mcp/server.mjs` 此前注释与行为都绑定 ZCode,接入端必须为 AgentMail 写
专用插件。去掉这层绑定后,任何支持 MCP 的宿主挂一行配置即可用:

    {"command":"node","args":["…/mcp/server.mjs"],"env":{
      "AGENTMAIL_GATEWAY_URL":…,"AGENTMAIL_AGENT_NAME":…,
      "AGENTMAIL_AGENT_SECRET":…,"AGENTMAIL_MCP_PLATFORM":"my-host"}}

协议层(零依赖手写 stdio JSON-RPC)与 11 个邮件工具本就与宿主无关,
真正要动的只有 4 处耦合 + 工具面。

## 改动

**1. 移除执行类工具(`run_command` / `write_file`)**
它们的门禁(lib/action-tools.mjs + lib/approval.mjs + 落盘授权表)是为
ZCode headless 的**双进程审批**设计的:MCP 进程问人、ZCode 钩子进程等回答、
中间靠文件对齐。脱离该宿主后这套门禁的前提不成立,挂在通用服务上等于
提供一条**没有审批的旁路**。
`lib/` 里三个模块与 `hooks/` 源码保留(桌面模式的 ZCode 仍走它们),
只是 server.mjs 不再装载。

**2. platform 可配置**:`AGENTMAIL_MCP_PLATFORM`,默认 `mcp`,
空白值回落默认值。原先硬编码 `'zcode'`(两处)。

**3. 错误文案去宿主名**:不再让模型/人「去 ZCode 的插件设置里填写」,
改为说明设置 `AGENTMAIL_*` 环境变量。

**4. 提示词如实说能力**(src/prompt.mjs):原文案向模型承诺
`run_command`/`write_file` 可用并分档描述「会被请示 / 直接生效」。
工具移除后那变成**指向不存在工具的承诺** —— 模型会去找、把整轮浪费在
换名字重试上。改为明说「本平台没有执行面,需要动手就写进回信请人做」。
三档措辞仍互不相同(`plan`/`workspace`/`full`),因为「档位仍存在但都无
执行面」这件事模型需要知道。

## ★★ 顺带修掉一个真实缺陷(端到端撞出来的)

`connect_to_server` 对 secret-only 的 Agent **一直 400**:
`/agent/register` 只认 `Authorization: Bearer` 或 body 里的 `secret`,
不认 `X-Agent-Secret` 头(其它接口才认),而它漏了 `body.secret`。
dsh / pi 正是 secret-only 配置 ⇒ 它们调「连一下服务器」必然失败,
且模型看不出该改什么。
lib/gateway.mjs 的 `register()` 本来就做对了,tools.mjs 里是手抄的劣化副本。
修后实测 `HTTP 400` → `已连接 …(状态:registered)`。

## 判据

新增 `test/generic-mcp.test.mjs`(5 格)。**这三件事此前无人看守**:
变异验证时「把 action-tools 挂回 server.mjs」与「platform 硬编码回 zcode」
都能全套通过 —— 因为没有判据看 server.mjs 实际挂了什么、也没人看 platform。

改写的 4 格(prompt 3 格 + driver 1 格)保留原意图(不向模型撒谎、
native 自报要有真凭据、工具不存在时不要重试),改为断言新事实。

**变异验证**(每条都确认已应用后才数红格):

    挂回 action-tools            → 红 3
    platform 硬编码 zcode        → 红 3
    platform 空白不回落           → 红 3
    文案指回 ZCode 插件设置        → 红 3
    删掉 body.secret(400 复现)  → 红 3

全套 **402/402**。

## 端到端验收

写了一个**非 ZCode 宿主**探针(纯 stdio JSON-RPC,不加载任何插件),
对着真实网关跑通:initialize → tools/list(11 个,无执行类)→
connect_to_server(registered)→ suggest_address。

## 未做

- 未发布到 npm registry(`npx` 即用需要发布或指向仓库路径)。
- 未改 `check-deploy-drift.mjs` 的 zcode 豁免(本机仍不退场该宿主)。
2026-10-02 12:31:18 +08:00

169 lines
6.9 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.

/**
* 通用化(去 ZCode 影子)的看守判据。
*
* ★ 2026-10-02 新增。这三条改动的判别力此前**无人看守**:
* 变异验证时「把 action-tools 挂回 server.mjs」与「platform 硬编码回 zcode」
* 都能通过全套测试 —— 因为没有一条判据看 server.mjs 实际挂了什么、
* 也没有一条看注册时上报的 platform。改动本身是对的,但没有判据就等于
* 下次谁都能悄悄改回去。
*
* 这里验的都是**结构**(源码 + 模块行为),不联网。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { GatewayClient } from '../lib/gateway.mjs';
import { buildTools, indexTools } from '../lib/tools.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..');
/**
* server.mjs 真实挂载的工具清单。
*
* 走真实模块(main 会挂 stdio,但工具集合只依赖 client/agentName),
* 复刻它那段装配逻辑 —— 直接 import 会启动服务器。
*/
function mountedToolNames() {
const client = new GatewayClient({
AGENTMAIL_AGENT_NAME: 'probe',
AGENTMAIL_AGENT_KEY: 'k'
});
return [...indexTools(buildTools({ client, agentName: client.agentName })).keys()];
}
test('★ MCP 面**不含**会动机器的工具(run_command / write_file 已移除)', () => {
const names = mountedToolNames();
// 这两个是「会动机器」的。它们的门禁为 ZCode headless 双进程审批设计,
// 脱离该宿主后语义不成立 —— 挂在通用 MCP 服务上等于给人一个没门禁的旁路。
assert.ok(!names.includes('run_command'), 'run_command 不得回到通用 MCP 面');
assert.ok(!names.includes('write_file'), 'write_file 不得回到通用 MCP 面');
// 工具清单就这些 —— 挂进来的每个工具都是产品决策,不是实现细节
assert.deepEqual(names.sort(), [
'connect_to_server',
'download_attachment',
'forward_mail',
'list_contacts',
'read_inbox',
'read_mail',
'read_thread',
'send_mail',
'session_participants',
'suggest_address',
'upload_attachment'
]);
});
test('★ server.mjs 源码不 import action-tools / grants-file(结构层钉死,不只验运行结果)', async () => {
const src = await readFile(join(ROOT, 'mcp', 'server.mjs'), 'utf8');
assert.doesNotMatch(src, /action-tools/, 'server.mjs 不该再 import 执行类工具');
assert.doesNotMatch(src, /grants-file/, '授权表是 ZCode 钩子的东西,不属通用 MCP 面');
// 反向对照:它必须真的挂上了邮件工具,否则「没挂 action-tools」可能只是空文件
assert.match(src, /buildTools/, 'server.mjs 必须装配邮件工具');
});
test('★ platform 可配置且默认 mcp(通用服务不冒充任何宿主)', () => {
// 默认
const dflt = new GatewayClient({ AGENTMAIL_AGENT_NAME: 'a', AGENTMAIL_AGENT_KEY: 'k' });
assert.equal(dflt.platform, 'mcp');
// 可配置:宿主可自报平台名(如 codex / claude-code),便于服务端统计
const custom = new GatewayClient({
AGENTMAIL_AGENT_NAME: 'a',
AGENTMAIL_AGENT_KEY: 'k',
AGENTMAIL_MCP_PLATFORM: 'my-host'
});
assert.equal(custom.platform, 'my-host');
// 空白值不得变成空字符串(会让服务端统计出一个空平台)
const blank = new GatewayClient({
AGENTMAIL_AGENT_NAME: 'a',
AGENTMAIL_AGENT_KEY: 'k',
AGENTMAIL_MCP_PLATFORM: ' '
});
assert.equal(blank.platform, 'mcp', '空白 platform 必须回落默认值,不能是空串');
});
test('★ 注册载荷带可配置 platform,且源码里不残留 zcode 字面量', async () => {
const gw = await readFile(join(ROOT, 'lib', 'gateway.mjs'), 'utf8');
// 注册时上报的是 client.platform,不是硬编码
assert.match(gw, /platform: this\.platform/, 'register 必须用可配置的 platform');
assert.doesNotMatch(gw, /platform: 'zcode'/, 'register 不得硬编码 zcode');
// 面向用户的文案不得把宿主名写死 —— 通用服务提着 ZCode 说「请在 ZCode 的
// 插件设置里填写」,会让人去一个不存在的界面找配置。
const tools = await readFile(join(ROOT, 'lib', 'tools.mjs'), 'utf8');
assert.doesNotMatch(tools, /请在 ZCode 的插件设置里/, '错误文案不得指向 ZCode 插件设置');
});
test('★ connect_to_server 对 secret-only 的 Agent 也能注册(实测 400 过)', async () => {
// 根因:`/agent/register` 只认 `Authorization: Bearer` 或 **body 里的 secret**,
// 不认 `X-Agent-Secret` 头。而 connect_to_server 早前只发了那个头 ⇒
// dsh / pi 这类 secret-only 的 Agent 调它必得 400,且模型看不出该改什么。
//
// 这条是**行为**判据:真起一个 fetch 替身,按 secret-only 装配调用该工具,
// 检查请求体里确实带了 secret。
const src = await readFile(join(ROOT, 'lib', 'tools.mjs'), 'utf8');
assert.match(
src,
/secret:\s*client\.agentSecret/,
'connect_to_server 在没有 Bearer 时必须把 secret 放进请求体'
);
// 端到端:真调一次工具,拦截 fetch 看它发出去什么。
let captured = null;
const fakeFetch = async (url, init) => {
captured = { url, init };
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ status: 'registered' })
};
};
const realFetch = globalThis.fetch;
globalThis.fetch = fakeFetch;
try {
const tools = buildTools({
client: {
baseURL: 'http://fake',
agentName: 'dsh',
agentKey: '',
agentSecret: 'sekrit',
platform: 'mcp',
checkConfig: () => [],
get: async () => ({}),
post: async () => ({}),
uploadFile: async () => ({}),
downloadFile: async () => Buffer.alloc(0)
},
agentName: 'dsh'
});
const byName = indexTools(tools);
await byName.get('connect_to_server').run({});
assert.ok(captured, 'connect_to_server 应当真的发出请求');
const body = JSON.parse(captured.init.body);
assert.equal(body.secret, 'sekrit', 'secret-only 装配时 body 必须带 secret');
assert.equal(body.platform, 'mcp', 'platform 仍应是可配置值');
// 反向对照:没有 secret 时不能硬塞空串(那是另一种错)
const tools2 = buildTools({
client: {
baseURL: 'http://fake', agentName: 'a', agentKey: 'key', agentSecret: '',
platform: 'mcp', checkConfig: () => [], get: async () => ({}), post: async () => ({}),
uploadFile: async () => ({}), downloadFile: async () => Buffer.alloc(0)
},
agentName: 'a'
});
captured = null;
await indexTools(tools2).get('connect_to_server').run({});
assert.equal(
JSON.parse(captured.init.body).secret,
undefined,
'有 Bearer 时 body 不该塞 secret'
);
} finally {
globalThis.fetch = realFetch;
}
});