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 豁免(本机仍不退场该宿主)。
This commit is contained in:
@ -384,7 +384,12 @@ test('★ 自报 native 要有真凭据:门禁链就绪(yolo + 够长的禁
|
||||
assert.equal(r.enforcement, 'native');
|
||||
// 理由里必须点出**谁**在把关。以前这里写的是「钩子已注册」,而 yolo 下
|
||||
// 钩子根本不会触发 —— 那种理由会让人以为平台在管,实际平台什么都没管。
|
||||
assert.match(r.reason, /门禁|请示/);
|
||||
//
|
||||
// ★ 2026-10-02:执行类工具已从 MCP 面移除,所以 native 的凭据变成
|
||||
// 「平台自带危险工具已禁用 + AgentMail 侧无执行面 ⇒ 模型无执行路径」。
|
||||
// 凭据换了,**意图不变**:native 必须有真凭据,不能只报个标签。
|
||||
assert.match(r.reason, /门禁|执行面|无任何执行面|已禁用/);
|
||||
assert.match(r.reason, /不再提供执行类工具|无任何执行面/, '必须说清“没有执行面”才是当前凭据');
|
||||
assert.doesNotMatch(r.reason, /^钩子已注册/, '不能拿钩子当唯一凭据');
|
||||
});
|
||||
|
||||
|
||||
169
plugins/zcode-mail-bridge/test/generic-mcp.test.mjs
Normal file
169
plugins/zcode-mail-bridge/test/generic-mcp.test.mjs
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 通用化(去 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;
|
||||
}
|
||||
});
|
||||
@ -128,17 +128,22 @@ test('失败回信在没有任何尝试记录时也不崩', () => {
|
||||
|
||||
// ─── 能力说明(平台把自带危险工具禁掉了,模型必须知道)─────────────────
|
||||
|
||||
test('★ workspace 档:说清自带工具被禁、动手要用我们的工具、会被请示', () => {
|
||||
test('★ workspace 档:说清没有执行面(不带 run_command/write_file 这类不存在的东西)', () => {
|
||||
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: 'workspace' }) });
|
||||
assert.match(p, /Bash \/ Write \/ Edit \/ js/, '必须点名哪些自带工具不可用');
|
||||
assert.match(p, /禁用/);
|
||||
assert.match(p, /run_command/);
|
||||
assert.match(p, /write_file/);
|
||||
assert.match(p, /申请授权/, '模型必须知道动手会先请示');
|
||||
// 被拒是业务结果而非故障,且**不能靠重试或绕道** —— 这三件事必须都说
|
||||
assert.match(p, /报错并给出原因/);
|
||||
// ★ 2026-10-02:执行类工具已从 MCP 面移除,**不得**再向模型承诺它们。
|
||||
// 若这里再出现 run_command / write_file,模型会去找一个不存在的工具,
|
||||
// 把整轮浪费在换名字重试上 —— 这正是“如实说清能力”的反面。
|
||||
assert.doesNotMatch(p, /run_command/, '不得承诺不存在的执行工具');
|
||||
assert.doesNotMatch(p, /write_file/);
|
||||
assert.match(p, /不能\*\*执行命令/);
|
||||
// 工具不存在而报错是真实结果,不能靠重试或绕道
|
||||
assert.match(p, /真实结果/);
|
||||
assert.match(p, /不要重试/);
|
||||
assert.match(p, /绕道|其它执行手段/);
|
||||
assert.match(p, /换名字再试/);
|
||||
// 兜底路径要给出:需要动手就写进回信请人做,而不是自己硬试
|
||||
assert.match(p, /需要动手|写进回信/);
|
||||
// 只读工具要明确可用,否则模型会以为自己什么都干不了
|
||||
assert.match(p, /Read \/ Glob \/ Grep/);
|
||||
});
|
||||
@ -146,16 +151,20 @@ test('★ workspace 档:说清自带工具被禁、动手要用我们的工具
|
||||
test('★ plan 档:明说不能动手,别浪费一轮去试', () => {
|
||||
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: 'plan' }) });
|
||||
assert.match(p, /plan 档/);
|
||||
assert.match(p, /不能\*\*执行命令或写文件|不能\*\*执行/);
|
||||
assert.match(p, /一律拒绝/);
|
||||
assert.doesNotMatch(p, /申请授权/, 'plan 档不该说会去申请授权(它根本不会发请求)');
|
||||
assert.match(p, /不能\*\*执行命令/);
|
||||
assert.doesNotMatch(p, /申请授权/, '根本不会发请求,不该说会去申请');
|
||||
assert.doesNotMatch(p, /run_command|write_file/, '不得承诺不存在的执行工具');
|
||||
});
|
||||
|
||||
test('★ full 档:明说免问(否则模型会以为每步都要等人,反而不敢动手)', () => {
|
||||
test('★ full 档:即使全权,也明说平台没有执行面', () => {
|
||||
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: 'full' }) });
|
||||
assert.match(p, /full 档/);
|
||||
assert.match(p, /直接生效/);
|
||||
assert.match(p, /不会打扰|无需/);
|
||||
// ★ 2026-10-02:full 档只是“不问人”,不等于“工具存在”。
|
||||
// 旧文案说 full 档“直接生效、不会打扰”—— 那会让模型以为能动手,
|
||||
// 然后花一整轮去找一个已被移除的工具。
|
||||
assert.match(p, /不能\*\*执行命令/, 'full 档也必须明说没有执行面');
|
||||
assert.match(p, /只提供邮件能力/);
|
||||
assert.doesNotMatch(p, /run_command|write_file/);
|
||||
assert.doesNotMatch(p, /第一次调用会先向发件人申请授权/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user