Files
MailUI4Agents/plugins/zcode-mail-bridge/mcp/server.mjs
JianFeeeee e0e6f86d94 feat(zcode): AgentMail 的 ZCode 插件 —— MCP 工具面 + 官方宿主启动验证
ZCode 用插件扩展能力(.zcode-plugin/plugin.json 声明 skills/commands/hooks/
mcpServers),所以适配它的正确形状是**插件**而不是又一个独立桥进程。

本提交是第一步:把 AgentMail 的工具面做成 MCP 服务器。

协议层(lib/mcp-rpc.mjs)手写,不引 @modelcontextprotocol/sdk:
协议面只有 initialize / notifications/initialized / tools/list / tools/call,
手写可省掉一条构建链与 1MB 打包产物(与 pi/opencode/dsh 三桥零运行时依赖的
取向一致),并让这一层成为可穷举的纯函数。分帧照官方插件产物实测确认是
换行分隔 JSON(Content-Length 出现 0 次,StdioServerTransport + split("\n"))。

工具面(lib/tools.mjs)与另三个桥**同名同参**,渲染走共用的
addressing/inbox-format/discovery(逐字节同源,已纳入 check-shared-libs.sh)。
测试里有一条断言直接拿 pi 桥的工具名做对照:少一个就让某平台行为与其它平台不同,
那种问题只在单平台复现,排查代价最高。

两处按真实缺陷定的行为:
- 工具失败回 result+isError 而非 JSON-RPC error —— 后者会让模型看不到失败原因,
  只能重试(opencode 连试 6 次发不出附件正是这个后果)
- attachment_ids 声明放宽为 anyOf 数组/字符串并在桥侧归一 —— 模型常写成
  JSON 字符串,服务端严格解码会拒(同样来自 opencode 那次失败)

入口 mcp/server.mjs 修掉一个真实缺陷:stdin 关闭即 process.exit 会杀掉在途请求,
表现为「协议全对但访问网关的调用完全没有响应」。现按在途计数 drain,
且把 stdout 写入也计入,避免最后一条响应卡在缓冲区。

顺带修 check-shared-libs.sh 的一个既有假绿:本机 PATH 上的 diff 是鸿蒙 SDK
工具链的 diff,不认 -q 且对不同的文件仍返回 0 —— 于是该检查器**一直是永真输出**。
改用 cmp -s,并加自检(判据本身必须先被证明能发现差异)。反向验证:
让 zcode 或 pi 的共用模块分叉,检查器都正确报错并返回 1。

验证:
- 单元 33 项 + 继承共用测试 87 项 = 120/120
- `zcode plugins list` → agentmail@inline [enabled],mcp: plugin:agentmail:agentmail
- 经官方 `node zcode.cjs __zcode-plugin-host <server.mjs>` 启动 → 握手与 tools/list 正常
- 真实网关调用:以 zcode 身份 read_inbox / suggest_address / list_contacts 均返回
2026-09-12 13:47:51 +08:00

103 lines
3.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 的 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';
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 时只导出(便于测试与宿主按需调用)。
const isDirect = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
if (isDirect) {
main().catch(error => {
log('致命错误:', error?.stack || error);
process.exit(1);
});
}