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 均返回
73 lines
3.1 KiB
JavaScript
73 lines
3.1 KiB
JavaScript
/**
|
||
* 把工具参数里的附件 id 列表归一成 `string[]`。
|
||
*
|
||
* # 为什么需要它(生产实测)
|
||
*
|
||
* opencode 上一轮把四个步骤全做完了 —— 下载两个附件、读出内容、上传回传文件 ——
|
||
* 却在最后一步卡住:`send_mail` 连续 **6 次**失败,模型自己总结为
|
||
* 「attachment_ids 参数有框架级序列化 bug」,然后放弃了整个任务。
|
||
*
|
||
* 真实原因不是框架 bug,而是模型把数组写成了 **JSON 字符串**:
|
||
*
|
||
* attachment_ids = "[\"10e73e9f-c2a9-4226-bdb5-34ef1b340eb8\"]"
|
||
*
|
||
* 桥把这个字符串原样转发给服务端,服务端的严格解码器按契约拒收
|
||
* (`字段 "attachment_ids" 类型不对:期望 string 数组,收到 string`)。
|
||
*
|
||
* # 修在哪一层
|
||
*
|
||
* **不在服务端放宽。** 服务端那个"严格"是刻意的,它挡的是字段名拼错、结构写错
|
||
* 这类真错误 —— 松开之后真 bug 会被静默接受(同一封邮件少几个附件,HTTP 仍是
|
||
* 200)。
|
||
*
|
||
* **在桥这一层收。** 桥是适配器:模型侧的形状天生不可靠(它按自然语言直觉填
|
||
* 参数),而适配器的职责就是把不可靠的输入归一成契约要求的形状。对模型宽容、
|
||
* 对服务端严格,这与 homeagent 那个 Go 插件里的 `stringList` 是同一个判断
|
||
* (那边的注释写着:「也接受单个字符串……拒绝它只会换来一次重试,而意图毫无
|
||
* 歧义」)。
|
||
*
|
||
* # 接受的形状
|
||
*
|
||
* - `["a", "b"]` 数组
|
||
* - `"[\"a\", \"b\"]"` JSON 数组字符串 ← **本次事故的形状**
|
||
* - `"a"` 单个 id
|
||
* - `"a, b"` / `"a b"` 逗号或空白分隔
|
||
* - `[null, "a", 3]` 混进杂质:丢掉坏的、留下好的
|
||
*
|
||
* 逐项过滤而不是整体放弃:三个附件里有一个写坏,不该变成"一个都不发"。
|
||
* 空串与 null 一并丢掉 —— 服务端的 parseAttachmentIDs 也跳过空串,
|
||
* 与它保持一致,免得插件放过去的东西换个形状在服务端再失败一次。
|
||
*
|
||
* @param {unknown} value 工具参数里的原始值
|
||
* @returns {string[]} 归一后的 id 列表(永不为 null)
|
||
*/
|
||
export function normalizeAttachmentIDs(value) {
|
||
if (value === null || value === undefined) return [];
|
||
if (Array.isArray(value)) return value.flatMap(normalizeAttachmentIDs);
|
||
if (typeof value !== 'string') return [];
|
||
|
||
const raw = value.trim();
|
||
if (raw === '') return [];
|
||
|
||
// JSON 数组字符串:本次事故的形状。解析失败不算错 —— 它可能就是普通 id。
|
||
if (raw.startsWith('[')) {
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
if (Array.isArray(parsed)) return parsed.flatMap(normalizeAttachmentIDs);
|
||
} catch {
|
||
// 落到下面的分隔符分支
|
||
}
|
||
}
|
||
|
||
// 逗号/空白分隔:模型偶尔写成 "a, b"。
|
||
// 只在确实含分隔符时切分,避免把单个 id 切坏 —— uuid 里没有这些字符。
|
||
if (/[,\s]/.test(raw)) {
|
||
return raw
|
||
.split(/[,\s]+/)
|
||
.map(s => s.trim())
|
||
.filter(s => s !== '');
|
||
}
|
||
|
||
return [raw];
|
||
}
|