## 目的
`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 豁免(本机仍不退场该宿主)。
120 lines
4.9 KiB
JavaScript
120 lines
4.9 KiB
JavaScript
#!/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);
|
||
});
|
||
}
|