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