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 均返回
This commit is contained in:
139
plugins/zcode-mail-bridge/lib/mcp-rpc.mjs
Normal file
139
plugins/zcode-mail-bridge/lib/mcp-rpc.mjs
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* MCP(Model Context Protocol)的 stdio 传输层与 JSON-RPC 分发。
|
||||
*
|
||||
* # 为什么手写而不引 `@modelcontextprotocol/sdk`
|
||||
*
|
||||
* 协议面很小:`initialize` / `notifications/initialized` / `tools/list` /
|
||||
* `tools/call`。SDK 会带来一个 1MB 上下的打包产物与一条构建链,而本插件的
|
||||
* 其余部分(网关客户端 + 工具)本就零运行时依赖 —— 与 pi/opencode/dsh 三个桥
|
||||
* 的取向一致。手写还能让这一层成为**可单测的纯函数**,而不是只能靠连上宿主才验。
|
||||
*
|
||||
* # 分帧
|
||||
*
|
||||
* stdio 传输是**换行分隔的 JSON**(一行一条消息,UTF-8),不是 Content-Length 分帧。
|
||||
* 这一点是照官方插件实测确认的:它的打包产物里出现 `StdioServerTransport` 与
|
||||
* `split("\n")`,而 `Content-Length` 出现 **0 次**。
|
||||
*
|
||||
* # 职责边界
|
||||
*
|
||||
* 本模块只做「消息进 → 消息出」,不碰 stdin/stdout,也不认识具体工具 ——
|
||||
* 于是它可以在测试里被穷举,而 I/O 只剩 server.mjs 里那一小段胶水。
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = '2024-11-05';
|
||||
export const SERVER_NAME = 'agentmail';
|
||||
export const SERVER_VERSION = '0.1.0';
|
||||
|
||||
/** JSON-RPC 错误码(只列我们真的会返回的)。 */
|
||||
export const RPC_ERROR = {
|
||||
PARSE: -32700,
|
||||
INVALID_REQUEST: -32600,
|
||||
METHOD_NOT_FOUND: -32601,
|
||||
INVALID_PARAMS: -32602,
|
||||
INTERNAL: -32603
|
||||
};
|
||||
|
||||
const result = (id, value) => ({ jsonrpc: '2.0', id, result: value });
|
||||
const failure = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
||||
|
||||
/**
|
||||
* 处理一条已解析的 JSON-RPC 消息。
|
||||
*
|
||||
* @param {any} msg 解析后的消息
|
||||
* @param {{tools: Array<{name:string, description:string, inputSchema:object}>,
|
||||
* call: (name: string, args: object) => Promise<string>}} ctx
|
||||
* @returns {Promise<object|null>} 要写回的消息;notification(无 id)返回 null
|
||||
*/
|
||||
export async function handleMessage(msg, ctx) {
|
||||
// 通知(没有 id)不需要回复。`notifications/initialized` 就走这条 ——
|
||||
// 若它也回一条,客户端会把响应与请求错配,后续调用全乱。
|
||||
const isNotification = msg === null || typeof msg !== 'object' || !('id' in msg);
|
||||
const id = isNotification ? null : msg.id;
|
||||
|
||||
if (typeof msg !== 'object' || msg === null || typeof msg.method !== 'string') {
|
||||
return isNotification
|
||||
? null
|
||||
: failure(id, RPC_ERROR.INVALID_REQUEST, '请求缺少 method');
|
||||
}
|
||||
|
||||
switch (msg.method) {
|
||||
case 'initialize':
|
||||
return isNotification
|
||||
? null
|
||||
: result(id, {
|
||||
// 回显客户端给的协议版本:不认识的版本也回显,交由客户端决定是否降级 ——
|
||||
// 自作主张改成我们的版本会让客户端以为协商成功而按新语义调用。
|
||||
protocolVersion: msg.params?.protocolVersion || PROTOCOL_VERSION,
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }
|
||||
});
|
||||
|
||||
case 'notifications/initialized':
|
||||
return null; // 纯通知
|
||||
|
||||
case 'ping':
|
||||
return isNotification ? null : result(id, {});
|
||||
|
||||
case 'tools/list':
|
||||
return isNotification
|
||||
? null
|
||||
: result(id, {
|
||||
tools: ctx.tools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema
|
||||
}))
|
||||
});
|
||||
|
||||
case 'tools/call': {
|
||||
if (isNotification) return null;
|
||||
const name = msg.params?.name;
|
||||
const args = msg.params?.arguments ?? {};
|
||||
if (typeof name !== 'string' || name === '') {
|
||||
return failure(id, RPC_ERROR.INVALID_PARAMS, 'tools/call 缺少 name');
|
||||
}
|
||||
const known = ctx.tools.some(t => t.name === name);
|
||||
if (!known) {
|
||||
return failure(id, RPC_ERROR.INVALID_PARAMS, `没有名为 ${name} 的工具`);
|
||||
}
|
||||
try {
|
||||
const text = await ctx.call(name, args);
|
||||
return result(id, { content: [{ type: 'text', text: String(text ?? '') }] });
|
||||
} catch (error) {
|
||||
// 工具失败**不能**回 JSON-RPC error —— 那样模型看不到失败原因,
|
||||
// 只会看到一次协议错误。MCP 的约定是 result + isError:true,
|
||||
// 于是错误文本进入对话,模型能据此改正(例如换一个 attachment_id)。
|
||||
return result(id, {
|
||||
content: [
|
||||
{ type: 'text', text: `工具 ${name} 执行失败:${error?.message || error}` }
|
||||
],
|
||||
isError: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return isNotification
|
||||
? null
|
||||
: failure(id, RPC_ERROR.METHOD_NOT_FOUND, `不支持的方法 ${msg.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一行文本解析成消息并处理,返回要写回的行(或不返回)。
|
||||
*
|
||||
* 解析失败时**必须**回一条带 id=null 的解析错误(JSON-RPC 规定),
|
||||
* 否则客户端会一直等这一条的响应。
|
||||
*/
|
||||
export async function handleLine(line, ctx) {
|
||||
const text = String(line ?? '').trim();
|
||||
if (text === '') return null;
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(text);
|
||||
} catch {
|
||||
return JSON.stringify(failure(null, RPC_ERROR.PARSE, '不是合法的 JSON'));
|
||||
}
|
||||
const out = await handleMessage(msg, ctx);
|
||||
return out === null ? null : JSON.stringify(out);
|
||||
}
|
||||
Reference in New Issue
Block a user