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

@ -1,20 +1,35 @@
# zcode-mail-bridge —— AgentMail 的 ZCode 适配
# zcode-mail-bridge —— AgentMail 的通用 MCP 服务(+ ZCode 适配)
让 [ZCode](https://zcode.z.ai)(z.ai 的 Electron 客户端)成为 AgentMail 里
一个能收发邮件、传附件、**把危险工具授权交给人**的 Agent。
**`mcp/server.mjs` 是一个通用 MCP 服务器**:任何支持 MCP 的宿主挂上它就能收发邮件,
不需要为 AgentMail 写专用插件。
```bash
AGENTMAIL_GATEWAY_URL=http://127.0.0.1:8180 \
AGENTMAIL_AGENT_NAME=my-agent \
AGENTMAIL_AGENT_SECRET=<secret> \
AGENTMAIL_MCP_PLATFORM=my-host \ # 可选,默认 mcp;用于服务端统计平台
node mcp/server.mjs
```
宿主配置里写 `{"command":"node","args":["…/mcp/server.mjs"],"env":{…}}` 即可。
协议是 stdio 上的换行分隔 JSON-RPC(`lib/mcp-rpc.mjs`,零依赖手写),暴露 11 个邮件
工具。工具名、参数名与渲染文本与 pi / dsh / opencode 三桥一致。
## ZCode 适配(本目录其余部分)
ZCode 用**插件**扩展能力(`.zcode-plugin/plugin.json` 声明
`skills` / `commands` / `hooks` / `mcpServers`),所以适配它的正确形状是一个插件,
而不是又一个常驻桥进程。本目录就是那个插件。
而不是又一个常驻桥进程。本目录同时还是那个插件(`src/index.mjs` 的邮件驱动、
`hooks/` 的授权钩子都只服务 ZCode)。
## 组成
```
.zcode-plugin/plugin.json 插件清单(MCP 服务器 + hooks 目录)
mcp/server.mjs MCP 服务器入口(stdio,换行分隔 JSON-RPC)
.zcode-plugin/plugin.json ZCode 插件清单(MCP 服务器 + hooks 目录)
mcp/server.mjs ★ 通用 MCP 服务器(stdio,换行分隔 JSON-RPC)
hooks/permission.mjs PermissionRequest 钩子:把授权问给人、等决定、回结论
hooks/hooks.json 钩子注册(matcher + 进程型钩子 + 超时)
src/index.mjs ★ 邮件驱动:收到来信 → 起一轮 ZCode → 回信
src/index.mjs ★ 邮件驱动(仅 ZCode):收到来信 → 起一轮 ZCode → 回信
src/zcode-run.mjs 跑一轮(headless CLI + stream-json 解析)
src/prompt.mjs 由邮件构造提示词与回信文案
src/turn-mode.mjs 档位 → `--mode` 映射(授权系统在不在的关键)
@ -22,12 +37,32 @@ lib/mcp-rpc.mjs 协议层(纯函数,可穷举测试)
lib/tools.mjs 11 个 AgentMail 工具(与另三个桥同名同参)
lib/gateway.mjs 网关 HTTP 客户端
lib/hook-policy.mjs 档位判定(纯函数)
lib/grants-file.mjs 「一直同意」的跨进程持久化
lib/grants-file.mjs 「一直同意」的跨进程持久化(仅 ZCode 钩子用)
lib/explicit-sends.mjs 模型自己发过信的记录(工具与驱动跨进程对齐)
lib/action-tools.mjs ⚠ 已不在 MCP 面内(见下方「为什么没有 run_command」)
lib/approval.mjs 授权判定共用库(ZCode 钩子路径)
lib/{addressing,inbox-format,bounded,discovery,attachment-ids,
permission-mode,relay-key,permission-grants,sse-client,
catchup,relay-dedup,relay-policy,workspace}.js
← 与 pi/dsh/opencode 三桥**逐字节同源**(见下)
## 为什么没有 run_command / write_file
`lib/action-tools.mjs` 里的两个「会动机器」工具曾挂在 MCP 面上。**2026-10-02 移除**:
它们的门禁是为 ZCode headless 的**双进程审批**设计的(MCP 服务器进程问人、
ZCode 钩子进程等回答、中间靠落盘授权表对齐)。一旦通用化、脱离那个宿主,
这套门禁的前提就不成立——挂在通用服务上等于提供一个没有审批的旁路。
现在 MCP 面只有邮件与附件能力。需要动机器的宿主请用**它自己的**工具
(Claude Desktop / codex 等都有各自受管的执行能力),并由它们自己决定是否审批。
`lib/action-tools.mjs` / `lib/approval.mjs` / `lib/grants-file.mjs` 与 `hooks/`
源码仍在仓库里,供 ZCode 的**桌面模式**(人开着 ZCode 干活、平台自己问人)
继续使用;只是不再由通用 MCP 面装载。
`test/generic-mcp.test.mjs` 钉死了这一边界:挂回执行工具、改回硬编码平台、
或让文案指回 ZCode 插件设置,都会让判据转红。
test/ 单元测试(含继承的共用测试)
test/manual/permission-e2e.mjs 授权桥端到端(真去点同意/拒绝)
test/manual/driver-e2e.mjs 邮件驱动端到端(桩 CLI,真网关真邮件)
@ -48,33 +83,24 @@ test/manual/driver-e2e.mjs 邮件驱动端到端(桩 CLI,真网关真
`test/tools.test.mjs` 里有一条断言直接拿 pi 桥的工具名做对照:少一个就会让某个平台
的行为与其它平台不同,而那种问题只在单一平台复现,排查代价最高。
### 2)执行门禁(headless 的主力路径)—— `lib/action-tools.mjs` + `lib/approval.mjs`
### 2)执行门禁 —— 已从 MCP 面移除(2026-10-02)
**这是本平台现在真正的安全边界。** 平台自带的 `Bash`/`Write`/`Edit`/`js` 等
32 项「能动机器」的工具全部被 `--disallowed-tools` 拿掉(清单见
`src/turn-mode.mjs` 的 `REVIEWED_DENYLIST`,逐条的取舍理由写在那里),
模型唯一能动手的路径是我们自己的两个工具:
~~`lib/action-tools.mjs` + `lib/approval.mjs`~~ **不在通用 MCP 面内**。
ZCode headless 下它曾是本平台真正的安全边界(禁用 32 项自带工具,
模型只能经 `run_command` / `write_file` 动手,两者逐次请示发件人)。
| 工具 | 作用 | 批准后 |
|---|---|---|
| `run_command` | 执行一条 shell 命令(`bash -c`,默认 cwd = 会话工作区) | 真执行;退出码 / stdout / stderr 原样交回模型;输出超 16000 字符截断并标明截了多少 |
| `write_file` | 写文件(覆盖写,父目录自动建) | 真写;平台保护目录之外才行 |
通用化后这套门禁**失效**:它依赖 ZCode 特有的双进程审批(MCP 进程问、
钩子进程答、落盘表对齐)。挂在通用服务上 = 一个没有审批的旁路。
所以移除,理由与取舍见上文「为什么没有 run_command / write_file」。
两者的语义与档位对齐:
源码保留(桌面模式的 ZCode 仍走它),但 `mcp/server.mjs` 不再装载。
`src/prompt.mjs` 的能力说明也已改成如实告知「本平台没有执行面」——
否则模型会去找不存在的工具,整轮浪费在换名字重试上。
历史档位语义(`plan` 直接拒绝 / `workspace` 请示 / `full` 直接执行)
见 `src/turn-mode.mjs` 与下文的「三道不可动摇的规矩」。
| 档位 | `--mode` | `run_command` / `write_file` |
|---|---|---|
| `plan` | `plan` | **直接拒绝**,且**根本不发授权请求**(注定拒绝的事不该打扰人) |
| `workspace`(默认) | `yolo` | 每次调用**先向发件人请示**,拿到同意才执行 |
| `full` | `yolo` | 直接执行(该档语义就是发件人已给全权) |
为什么 workspace 敢用 `yolo`:平台那条路在本环境下**不可用**——
MCP 工具的 `needsApproval` 在产物里硬编码为 `true`,headless 没有审批客户端
可问 ⇒ `build`/`edit` 档下**每个** MCP 工具都被拒(连 `read_inbox` 都调不动)。
于是选择只有两个:「平台问、但问不到人 → 全拒」与「平台不问、我们自己问」。
后者才是真的可用且仍然可审计。
**三道不可动摇的规矩**(`lib/approval.mjs` 的文件头有完整推导):
<details>
<summary>(历史,已不适用于 MCP 面)原执行门禁的三道规矩</summary>
1. **只有明确同意才放行** —— 判据是共用库的前缀白名单(`^同意|一直同意|allow|approve|always|yes`,
四个桥共用同一份)。看不懂的文本、空串、`拒绝`、`deny`、平台自己的 `shutdown` 哨兵
@ -96,6 +122,8 @@ MCP 工具的 `needsApproval` 在产物里硬编码为 `true`,headless 没有
模型必须看见原因才有机会改道。opencode 上「工具失败但报成功」导致模型连试 6 次、
最后放弃整个任务的教训。
</details>
### 2b)授权桥(`PermissionRequest` 钩子)—— 交互(桌面)模式用
**注意:headless 驱动这条路上这个钩子不参与**(`--mode yolo` 下平台不做任何权限判定,