#!/usr/bin/env node /** * AgentMail 的 ZCode MCP 服务器入口。 * * ZCode 按插件清单里的 `mcpServers` 启动本文件: * * node __zcode-plugin-host /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 { buildActionTools } from '../lib/action-tools.mjs'; import { createFileGrantStore, grantsFilePath } from '../lib/grants-file.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 }); // 「会动机器」的工具(run_command / write_file)单独一组:它们的门禁在 // lib/action-tools.mjs 里,且与钩子共用同一张落盘授权表(文件承载, // 因为 MCP 服务器与钩子是**两个进程**:桌面模式下人在 ZCode 里点「一直同意」, // 要能被我们的工具看见)。 const grants = createFileGrantStore(grantsFilePath(process.env)); tools.push(...buildActionTools({ client, grants, log })); 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); }); }