## 缺陷:入口判断不解析软链 → 生产形态下 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;坏路径能被握手判据发现(反向对照)。
107 lines
4.1 KiB
JavaScript
107 lines
4.1 KiB
JavaScript
#!/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_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 || '(未配置)'},` +
|
||
`工具 ${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);
|
||
});
|
||
}
|