Files
MailUI4Agents/plugins/zcode-mail-bridge/lib/gateway.mjs
JianFeeeee 29ad8aa204 feat(mcp): 去 ZCode 影子 —— mcp/server.mjs 改为通用 MCP 服务
## 目的

`mcp/server.mjs` 此前注释与行为都绑定 ZCode,接入端必须为 AgentMail 写
专用插件。去掉这层绑定后,任何支持 MCP 的宿主挂一行配置即可用:

    {"command":"node","args":["…/mcp/server.mjs"],"env":{
      "AGENTMAIL_GATEWAY_URL":…,"AGENTMAIL_AGENT_NAME":…,
      "AGENTMAIL_AGENT_SECRET":…,"AGENTMAIL_MCP_PLATFORM":"my-host"}}

协议层(零依赖手写 stdio JSON-RPC)与 11 个邮件工具本就与宿主无关,
真正要动的只有 4 处耦合 + 工具面。

## 改动

**1. 移除执行类工具(`run_command` / `write_file`)**
它们的门禁(lib/action-tools.mjs + lib/approval.mjs + 落盘授权表)是为
ZCode headless 的**双进程审批**设计的:MCP 进程问人、ZCode 钩子进程等回答、
中间靠文件对齐。脱离该宿主后这套门禁的前提不成立,挂在通用服务上等于
提供一条**没有审批的旁路**。
`lib/` 里三个模块与 `hooks/` 源码保留(桌面模式的 ZCode 仍走它们),
只是 server.mjs 不再装载。

**2. platform 可配置**:`AGENTMAIL_MCP_PLATFORM`,默认 `mcp`,
空白值回落默认值。原先硬编码 `'zcode'`(两处)。

**3. 错误文案去宿主名**:不再让模型/人「去 ZCode 的插件设置里填写」,
改为说明设置 `AGENTMAIL_*` 环境变量。

**4. 提示词如实说能力**(src/prompt.mjs):原文案向模型承诺
`run_command`/`write_file` 可用并分档描述「会被请示 / 直接生效」。
工具移除后那变成**指向不存在工具的承诺** —— 模型会去找、把整轮浪费在
换名字重试上。改为明说「本平台没有执行面,需要动手就写进回信请人做」。
三档措辞仍互不相同(`plan`/`workspace`/`full`),因为「档位仍存在但都无
执行面」这件事模型需要知道。

## ★★ 顺带修掉一个真实缺陷(端到端撞出来的)

`connect_to_server` 对 secret-only 的 Agent **一直 400**:
`/agent/register` 只认 `Authorization: Bearer` 或 body 里的 `secret`,
不认 `X-Agent-Secret` 头(其它接口才认),而它漏了 `body.secret`。
dsh / pi 正是 secret-only 配置 ⇒ 它们调「连一下服务器」必然失败,
且模型看不出该改什么。
lib/gateway.mjs 的 `register()` 本来就做对了,tools.mjs 里是手抄的劣化副本。
修后实测 `HTTP 400` → `已连接 …(状态:registered)`。

## 判据

新增 `test/generic-mcp.test.mjs`(5 格)。**这三件事此前无人看守**:
变异验证时「把 action-tools 挂回 server.mjs」与「platform 硬编码回 zcode」
都能全套通过 —— 因为没有判据看 server.mjs 实际挂了什么、也没人看 platform。

改写的 4 格(prompt 3 格 + driver 1 格)保留原意图(不向模型撒谎、
native 自报要有真凭据、工具不存在时不要重试),改为断言新事实。

**变异验证**(每条都确认已应用后才数红格):

    挂回 action-tools            → 红 3
    platform 硬编码 zcode        → 红 3
    platform 空白不回落           → 红 3
    文案指回 ZCode 插件设置        → 红 3
    删掉 body.secret(400 复现)  → 红 3

全套 **402/402**。

## 端到端验收

写了一个**非 ZCode 宿主**探针(纯 stdio JSON-RPC,不加载任何插件),
对着真实网关跑通:initialize → tools/list(11 个,无执行类)→
connect_to_server(registered)→ suggest_address。

## 未做

- 未发布到 npm registry(`npx` 即用需要发布或指向仓库路径)。
- 未改 `check-deploy-drift.mjs` 的 zcode 豁免(本机仍不退场该宿主)。
2026-10-02 12:31:18 +08:00

154 lines
6.1 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';
/** 与网关一致的默认值;宿主配置优先,否则环境变量覆盖。 */
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();
// 注册时上报的 platform(服务端按它统计在线桥,WebUI 会显示成平台名)。
// 默认 'mcp' —— 本客户端是通用 MCP 服务器,不属于任何单一宿主。
this.platform = String(env.AGENTMAIL_MCP_PLATFORM || 'mcp').trim() || 'mcp';
}
/** 配置是否足以发请求 —— 缺密钥时要在第一次调用就明确报错,而不是收到 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;
}
/**
* 向网关登记自己(`POST /agent/register`)。
*
* 驱动启动时调一次。**不能省**:没登记过的新部署只会在心跳与 SSE 上
* 反复受拒,而日志里只有看不见的 4xx —— 而驱动的日志是唯一能被看到的地方。
*
* 注意这个端点的认证方式与其它接口**不同**:它只认
* `Authorization: Bearer <key>` 或 **body 里的 `secret`**,
* 不认 `X-Agent-Secret` 头(其它接口认)。实测踩过:
* HTTP 400 需要 Authorization: Bearer <密钥> 或 body 里的 secret
* 所以没密钥时把 secret 放进 body。
*/
async register(extra = {}) {
if (!this.agentName) throw new Error('缺少 AGENTMAIL_AGENT_NAME');
if (!this.agentKey && !this.agentSecret) {
throw new Error('缺少 AGENTMAIL_AGENT_KEY 或 AGENTMAIL_AGENT_SECRET');
}
const body = { name: this.agentName, platform: this.platform, ...extra };
if (!this.agentKey) body.secret = this.agentSecret;
return this.post('/agent/register', body);
}
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;
}