Files
MailUI4Agents/plugins/zcode-mail-bridge/mcp/server.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

120 lines
4.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.

#!/usr/bin/env node
/**
* AgentMail 的通用 MCP 服务器入口(stdio 传输,JSON-RPC)。
*
* 任何支持 MCP 的宿主都能直接挂载,只需注入 AGENTMAIL_* 环境变量:
*
* node <path>/mcp/server.mjs
*
* # 不属于任何单一宿主
*
* 本文件只做三件事:读 stdin 分帧 → lib/mcp-rpc.mjs 分发 → 写回 stdout。
* 工具集在 lib/tools.mjs,网关客户端在 lib/gateway.mjs,二者都不认识宿主。
* 此前入口注释写「ZCode」并额外挂载了 run_command / write_file 两个
* “会动机器”的工具 —— 那套门禁是为 ZCode 的 headless(`--mode yolo` +
* `--disallowed-tools`)双进程审批设计的,脱离该宿主后语义不成立,
* 且它把“工具面 = 邮件工具”这一件事与某个宿主绑死。故已移除。
*
* # 工具面只保留邮件协作工具
*
* 12 个纯邮件/寻址/附件工具,全部走网关的鉴权与会话收窄(与 pi / dsh /
* opencode 三桥同源)。需要动机器的能力由宿主自己的工具提供,不在本服务内。
*
* # stdout 是协议通道
*
* stdout 上**只能**出现协议消息。任何一行 `console.log` 都会被客户端当成
* JSON 解析失败 —— 于是服务器看起来「起来了但一个工具都没有」。
* 本文件里所有诊断一律 `console.error`(stderr 被客户端当日志转发,不影响协议)。
*
* # 起不来要说清楚
*
* 这是邮件驱动会话的一部分:没有本地 UI 让人看见崩溃。所以缺配置时
* 不是静默退出,而是把原因写到 stderr(进宿主日志),并在**每次工具调用**时
* 再报一次(模型能读,于是它会告诉人)。
*/
import { createInterface } from 'node:readline';
import { GatewayClient } from '../lib/gateway.mjs';
import { buildTools, indexTools } from '../lib/tools.mjs';
import { handleLine, SERVER_NAME, SERVER_VERSION } from '../lib/mcp-rpc.mjs';
import { isMainModule } from '../lib/is-main.mjs';
const log = (...parts) => console.error('[agentmail-mcp]', ...parts);
export async function main() {
const client = new GatewayClient(process.env);
// 常见情况是没配 agent_name(宿主没注入 userConfig / 环境变量没继承)——
// 用网关的默认值兜底会让它以别人的身份发信,所以宁可留空并在调用时报错。
const agentName = client.agentName;
const tools = buildTools({ client, agentName });
const byName = indexTools(tools);
const ctx = {
tools,
call: async (name, args) => {
const tool = byName.get(name);
if (!tool) throw new Error(`没有名为 ${name} 的工具`);
return tool.run(args);
}
};
log(`启动 v${SERVER_VERSION},网关 ${client.baseURL},身份 ${agentName || '(未配置)'},` +
`平台 ${client.platform},工具 ${tools.length} 个`);
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
// 关闭 stdin 不等于「可以立刻退出」:此刻可能还有在途的工具调用。
// 直接 `process.exit(0)` 会把它们的响应丢掉 —— 实测表现是
// 「协议消息全对,但访问网关的那两个调用完全没有响应」,
// 而客户端只能等到超时(看起来像服务器挂了)。
// 所以:计数在途工作,关闭后等它归零再退,且把 stdout 写入也计入,
// 否则最后一条响应可能在缓冲区里被丢掉。
let pending = 0;
let stdinClosed = false;
const exitIfDrained = () => {
if (stdinClosed && pending === 0) process.exit(0);
};
const writeOut = text =>
new Promise(resolve => {
process.stdout.write(text + '\n', resolve);
});
rl.on('line', line => {
pending++;
// 不串行化:每条消息各自发起,谁先完成谁先写回(MCP 靠 id 配对,
// 乱序是合法的)。实测确实会乱序 —— 两条 suggest_address 的耗时不同,
// 后发的先回。不要在这里排 Promise 链:那会让一个慢调用
// (例如 upload_attachment 传大文件)把后面的 read_inbox 堵住。
Promise.resolve()
.then(() => handleLine(line, ctx))
.then(out => (out === null || out === undefined ? undefined : writeOut(out)))
.catch(error => {
log('处理消息失败:', error?.message || error);
})
.finally(() => {
pending--;
exitIfDrained();
});
});
rl.on('close', () => {
stdinClosed = true;
log('stdin 关闭,等 ' + pending + ' 件在途工作结束后退出');
exitIfDrained();
});
}
// 直接执行时启动;被 import 时只导出。
//
// 判断**必须解析软链**(见 lib/is-main.mjs):生产布局是 `current` 软链,
// 直接比 `import.meta.url === 'file://'+argv[1]` 会判假 —— 服务器什么都不做、
// 无输出、退出码 0(实测)。
if (isMainModule(import.meta.url)) {
main().catch(error => {
log('致命错误:', error?.stack || error);
process.exit(1);
});
}