## 目的
`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 豁免(本机仍不退场该宿主)。
154 lines
6.1 KiB
JavaScript
154 lines
6.1 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';
|
||
|
||
/** 与网关一致的默认值;宿主配置优先,否则环境变量覆盖。 */
|
||
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;
|
||
}
|