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 均返回
129 lines
4.8 KiB
JavaScript
129 lines
4.8 KiB
JavaScript
/**
|
||
* AgentMail 网关的 HTTP 客户端。
|
||
*
|
||
* 与 pi / dsh / opencode 三个桥的同名模块**同一套请求头与端点**
|
||
* (`Authorization: Bearer <key>` + `X-Agent-Name`,路径前缀 `/api/v1`)。
|
||
* 刻意不共用文件:那三个桥的客户端与各自平台的会话生命周期耦合,
|
||
* 而这个只服务 MCP 的请求-响应模型;共用的部分(地址解析、收件箱渲染、
|
||
* 附件 id 归一)已经抽在 `lib/` 里逐字节同源。
|
||
*/
|
||
|
||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||
import { dirname, basename } from 'node:path';
|
||
|
||
/** 与网关一致的默认值;无头部署时由 ZCode 的 userConfig / 环境变量覆盖。 */
|
||
const DEFAULT_BASE = 'http://127.0.0.1:8180';
|
||
|
||
export class GatewayError extends Error {
|
||
constructor(status, body, path) {
|
||
// 把状态码与响应体一起带上:只报「请求失败」会让模型无从改正
|
||
// (是密钥不对?会话别名被占?预算用尽?三种要完全不同的应对)。
|
||
super(`HTTP ${status} ${path}${body ? `:${body}` : ''}`);
|
||
this.status = status;
|
||
this.body = body;
|
||
this.path = path;
|
||
}
|
||
}
|
||
|
||
export class GatewayClient {
|
||
constructor(env = process.env) {
|
||
this.baseURL = String(env.AGENTMAIL_GATEWAY_URL || DEFAULT_BASE).replace(/\/+$/, '');
|
||
this.agentKey = String(env.AGENTMAIL_AGENT_KEY || '').trim();
|
||
this.agentSecret = String(env.AGENTMAIL_AGENT_SECRET || '').trim();
|
||
this.agentName = String(env.AGENTMAIL_AGENT_NAME || '').trim();
|
||
}
|
||
|
||
/** 配置是否足以发请求 —— 缺密钥时要在第一次调用就明确报错,而不是收到 401 再猜。 */
|
||
checkConfig() {
|
||
const missing = [];
|
||
if (!this.agentKey && !this.agentSecret) missing.push('AGENTMAIL_AGENT_KEY');
|
||
if (!this.agentName) missing.push('AGENTMAIL_AGENT_NAME');
|
||
return missing;
|
||
}
|
||
|
||
authHeaders() {
|
||
const headers = { 'X-Agent-Name': this.agentName };
|
||
// 密钥优先;没有密钥时退回 secret(与 pi/opencode/dsh 三桥同款兜底,
|
||
// 服务端两条路都认)。两者都没有时上面 checkConfig 已经拦住了。
|
||
if (this.agentKey) headers.Authorization = `Bearer ${this.agentKey}`;
|
||
else if (this.agentSecret) headers['X-Agent-Secret'] = this.agentSecret;
|
||
return headers;
|
||
}
|
||
|
||
async get(path) {
|
||
const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() });
|
||
return this.#parse(res, path);
|
||
}
|
||
|
||
async post(path, body) {
|
||
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
|
||
method: 'POST',
|
||
headers: { ...this.authHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body ?? {})
|
||
});
|
||
return this.#parse(res, path);
|
||
}
|
||
|
||
async #parse(res, path) {
|
||
const text = await res.text();
|
||
let data = null;
|
||
try {
|
||
data = text ? JSON.parse(text) : null;
|
||
} catch {
|
||
data = null;
|
||
}
|
||
if (!res.ok) {
|
||
// 服务端的错误信息是给人看的(中文、可操作),优先透传给模型
|
||
const message = (data && (data.error || data.message)) || text.slice(0, 300);
|
||
throw new GatewayError(res.status, message, path);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* 上传附件。字段名必须是 `file`(服务端 `FormFile("file")`),
|
||
* 返回的是 `{attachment:{...}}` 这种**嵌套**形状 —— 按顶层解会得到空 id,
|
||
* 而那是静默的(homeagent 踩过:HTTP 200、附件数为 0)。
|
||
*/
|
||
async uploadFile(buf, filename) {
|
||
const form = new FormData();
|
||
form.append('file', new Blob([buf]), filename);
|
||
const res = await fetch(`${this.baseURL}/api/v1/attachments`, {
|
||
method: 'POST',
|
||
headers: this.authHeaders(),
|
||
body: form
|
||
});
|
||
const data = await this.#parse(res, '/attachments');
|
||
const attachment = data?.attachment;
|
||
if (!attachment?.attachment_id) {
|
||
throw new Error('上传响应里没有 attachment_id(服务端响应结构可能已变更)');
|
||
}
|
||
return attachment;
|
||
}
|
||
|
||
async downloadFile(attachmentID) {
|
||
const res = await fetch(`${this.baseURL}/api/v1/attachments/${attachmentID}`, {
|
||
headers: this.authHeaders()
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
throw new GatewayError(res.status, body.slice(0, 200), `/attachments/${attachmentID}`);
|
||
}
|
||
return Buffer.from(await res.arrayBuffer());
|
||
}
|
||
}
|
||
|
||
/** 读本地文件并上传,返回附件的展示用信息。 */
|
||
export async function uploadLocalFile(client, filePath) {
|
||
const data = await readFile(filePath);
|
||
return client.uploadFile(data, basename(filePath));
|
||
}
|
||
|
||
/** 下载附件并落盘,必要时建父目录。 */
|
||
export async function downloadToFile(client, attachmentID, savePath) {
|
||
const buf = await client.downloadFile(attachmentID);
|
||
await mkdir(dirname(savePath), { recursive: true });
|
||
await writeFile(savePath, buf);
|
||
return buf.length;
|
||
}
|