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:
2026-10-02 12:31:18 +08:00
parent 56c699b338
commit 29ad8aa204
10 changed files with 334 additions and 105 deletions

View File

@ -87,6 +87,14 @@ export function zcodeSessionFallback(sessionKey, rootOverride) {
* 之后,能动机器的只剩我们两个工具,而它们每次都要过 `requestApproval`。
* 这个才是这条路上真正拦得住东西的那一层。
*
* ★ 2026-10-02:`run_command` / `write_file` 已从 **MCP 工具面**移除
* (`mcp/server.mjs` 改为通用邮件服务,不再挂载它们)。原因是那套门禁
* 为 ZCode headless 的双进程审批(`--mode yolo` + 禁用自带工具 + 钩子转达)
* 而设计,脱离该宿主后语义不成立。此处 `detectModeEnforcement` 的自检
* 与下面的 `disallowedTools` 仍按原逻辑报告,但**实际后果变了**:
* 禁用自带工具后模型不再有执行类动作可做(只剩收发邮件),这是**安全侧
* 的失败关闭**,不是开放。若将来要在本宿主恢复执行能力,需重新设计门禁。
*
* 于是判据改成:先看我们自己那条链能不能真的拦住(模块在不在 + 禁用清单够不够),
* 再说平台那一层是否还参与。报 native 的含义是「该档位真的被强制,而非口头约定」。
*
@ -116,8 +124,9 @@ export function detectModeEnforcement({ hooksFile, env = process.env } = {}) {
return {
enforcement: 'native',
reason:
`执行类动作只能经我们自己的门禁(run_command / write_file 逐次请示),` +
`平台自带危险工具已禁用 ${denied.length} 项(--mode ${mode})` +
`平台自带危险工具已禁用 ${denied.length} 项(--mode ${mode}),` +
`且 AgentMail 侧不再提供执行类工具(run_command / write_file 已移除)` +
`⇒ 模型无任何执行面` +
(hookRegistered ? ';桌面模式另有平台钩子' : '')
};
}
@ -226,15 +235,17 @@ export function createDriver({ client, runTurnFn = runTurn, logFn = log, env = p
const sessionId = data?.session_id || '';
const tier = normalizeMode(data?.permission_mode);
const mode = zcodeModeForTier(tier);
// 危险的自带工具一律拿掉(三个档位同一张审过的清单);需要动手时,
// 模型改用我们自己的 run_command / write_file,门禁就在那里面。
// 危险的自带工具一律拿掉(三个档位同一张审过的清单)。
// ★ 2026-10-02:`run_command` / `write_file` 已从 MCP 工具面移除,
// 所以禁用后模型**没有**执行类动作可用(只剩收发邮件)—— 失败关闭。
const disallowedTools = denylistForTier(tier);
const cwd = resolveCwd(data);
const prev = sessions.get(sessionId);
const resume = prev?.zcodeSessionId || '';
logFn(`处理 ${data.mail_id}|${describeTier(tier, mode)}|cwd=${cwd}${resume ? `|续会话 ${resume}` : ''}`);
logFn(`已禁用 ${disallowedTools.length} 个自带工具(Bash/Write/Edit/js/…),执行类动作走 AgentMail 门禁`);
logFn(`已禁用 ${disallowedTools.length} 个自带工具(Bash/Write/Edit/js/…);` +
`本轮模型可做的只有收发邮件(执行类工具已不在 MCP 面内)`);
if (modeReachesPermissionHook(mode)) {
// 平台会问、我们的钩子会转达 —— 说明档位映射被配置改回了 build/edit。
logFn(`注意:--mode ${mode} 下平台自带工具会产生权限询问(映射被覆盖过?)`);

View File

@ -25,43 +25,32 @@ import { normalizeMode, DEFAULT_MODE, MODE_PLAN, MODE_FULL } from '../lib/permis
* 「工具不存在」(于是它反复换名字试,浪费整轮),或者它以为自己不能动手,
* 把本来能做完的活写成「建议你自己执行」。
*
* 同时要把**授权这件事本身**讲明白:`run_command`/`write_file` 会先向发件人
* 申请,被拒时工具会报错并给出原因。模型必须知道「被拒」是一个正常的、
* 需要它改道的结果,而不是重试同一个动作的理由。
* ★ 2026-10-02:`run_command`/`write_file` 已从 MCP 工具面移除(通用化)。
* 那两个工具的门禁是为 ZCode headless 的双进程审批设计的,脱离该宿主后
* 语义不成立。现在本平台**没有执行类工具**,文案必须如实说 —— 否则模型会
* 去找一个不存在的工具,把整轮浪费在换名字重试上。
*/
export function capabilityNote(tier) {
const t = normalizeMode(tier) || DEFAULT_MODE;
const head =
'## 你在这个平台上的执行能力\n\n' +
'ZCode 自带的 Bash / Write / Edit / js 等工具**已被禁用**(不是故障,是本平台的策略:' +
'邮件驱动的会话里没有本地界面可以给人审批,所以平台不做权限判定,改由我们自己的门禁负责)。';
if (t === MODE_PLAN) {
return (
head +
'\n\n当前是 **plan 档**:只读。你**不能**执行命令或写文件(`run_command`/`write_file` 在' +
'本档一律拒绝,不必尝试)。请把需要动手的步骤写进回信,说明「执行什么、为什么」。'
);
}
const gate =
t === MODE_FULL
? '当前是 **full 档**:发件人已声明全权,你的执行类调用**直接生效,不会打扰任何人**。'
: '当前是 **workspace 档**:每类执行动作**第一次调用会先向发件人申请授权**' +
'(他可以在界面上选「同意」「一直同意」或「拒绝」)。';
'邮件驱动的会话里没有本地界面可以给人审批)。' +
'AgentMail 侧**也没有提供执行类工具** —— 本平台只收发邮件与附件。';
return (
head +
'\n\n需要动手时请改用这两个工具:\n' +
'- `run_command`:执行一条 shell 命令(工作目录默认是本会话工作区)\n' +
'- `write_file`:写一个文件(父目录自动创建;平台自身的部署/数据库/服务配置目录' +
'无论谁批准都拒绝写入)\n' +
'\n' +
gate +
'\n\n被拒绝时工具会**报错并给出原因**(含决策人)。这是正常的业务结果,不是故障:' +
'不要重试同一个动作、也不要绕道去找其它执行手段,而应当在回信里说明' +
'「哪一步被拒了、为什么需要它、希望对方怎么做」。\n' +
'读写与搜索用自带的 Read / Glob / Grep(这些只读工具始终可用)。'
'\n\n所以你**不能**执行命令、不能读写工作区文件。' +
(t === MODE_PLAN
? '当前是 **plan 档**(只读)。'
: t === MODE_FULL
? '当前是 **full 档**,但平台仍然只提供邮件能力。'
: '当前是 **workspace 档**,同样只提供邮件能力。') +
'\n需要动手的步骤,请写进回信说明「执行什么、在哪个目录、为什么需要」,' +
'由具备本地环境的一方去做。' +
'\n读写与搜索用自带的 Read / Glob / Grep(这些只读工具始终可用)。' +
'\n\n若你调用了某个不存在的工具而报错,那是**真实结果**而非故障:' +
'不要重试、不要换名字再试,而应在回信里说清你需要什么。'
);
}