Files
MailUI4Agents/plugins/zcode-mail-bridge/mcp/server.mjs
JianFeeeee 6a7356ebe7 fix(zcode): 软链部署下入口静默不执行 + 部署脚本支持 zcode 快照
## 缺陷:入口判断不解析软链 → 生产形态下 main() 从不执行

`mcp/server.mjs` 与 `src/index.mjs` 都这么判断是否被直接执行:

    import.meta.url === `file://${process.argv[1]}`

而 ESM 的 `import.meta.url` 是**解析过软链的真实路径**,argv 是命令行里写的那个。
生产布局是软链(`/opt/agentmail/plugins/<name>/current` → 时间戳目录),
于是两者不等、`main()` 从不执行:**没有输出、没有报错、退出码 0**。

它是被部署脚本的后置验证抓到的:第一次从快照起 MCP 服务器时
「握手 0 个工具、stderr 一个字都没有」,而同一个文件从仓库路径跑完全正常
(仓库路径没有软链)。这类缺陷只在部署形态下出现,本地怎么试都对;
表现(静默成功)又与「功能没被调用」一模一样。

修法:新增 `lib/is-main.mjs`,**两侧都 realpath** 后比较(只归一 != 「同一文件」)。
单测含目录软链、文件软链、文件不存在(保守判否,避免被 import 时误跑一遍)。

## 部署脚本:引入 HOST 概念,四个插件一条部署路径

pi/opencode/dsh 的宿主是 systemd 单元,zcode 的宿主是 ZCode 应用本身 ——
没有我们的单元可重启。于是:

- `HOST=systemd`:重启单元 + 看网关库里有没有新心跳(原有判据)
- `HOST=zcode`:**从快照起一次 MCP 服务器并走完握手**,这与 ZCode 加载插件
  走的是同一份入口代码;另查 `plugins list` 报告的路径是不是 current

后置验证带**判据自检**:握手函数对坏路径必须返回 0,否则判据本身失效就拒绝通过。
计数用 `grep -o | wc -l` 而不是 `grep -c` —— 每条响应只占一行,tools/list 的
11 个工具名全在同一行,用 -c 会得到 2,把好快照判失败(实测踩过)。

## 生产已切到快照

`/opt/agentmail/plugins/zcode-mail-bridge/current → 20260912-150547`,
`~/.zcode/cli/config.json` 的 `plugins.dirs` 已指向 current,
`zcode plugins list` 报的路径就是快照路径。仓库不再是生产代码。

验证:单测 325/325;快照握手 12 个 name 字段;官方 __zcode-plugin-host 从快照
启动正常;钩子从快照跑通 409 → block;坏路径能被握手判据发现(反向对照)。
2026-09-12 15:06:49 +08:00

107 lines
4.1 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 的 ZCode MCP 服务器入口。
*
* ZCode 按插件清单里的 `mcpServers` 启动本文件:
*
* node <zcode.cjs> __zcode-plugin-host <plugin>/mcp/server.mjs
*
* 启动后说 MCP换行分隔 JSON-RPC走 stdio工具实现在 lib/tools.mjs。
*
* # stdout 是协议通道
*
* stdout 上**只能**出现协议消息。任何一行 `console.log` 都会被客户端当成
* JSON 解析失败 —— 于是服务器看起来「起来了但一个工具都没有」。
* 本文件里所有诊断一律 `console.error`stderr 被客户端当日志转发,不影响协议)。
*
* # 起不来要说清楚
*
* 这是邮件驱动会话的一部分:没有本地 UI 让人看见崩溃。所以缺配置时
* 不是静默退出,而是把原因写到 stderr进 ZCode 日志),并在**每次工具调用**时
* 再报一次(模型能读,于是它会告诉人)。
*/
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_nameuserConfig 没填、环境变量没继承)——
// 用网关的默认值兜底会让它以别人的身份发信,所以宁可留空并在调用时报错。
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 || '(未配置)'}` +
`工具 ${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);
});
}