Files
MailUI4Agents/plugins/zcode-mail-bridge/lib/gateway.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

129 lines
4.8 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.

/**
* 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;
}