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

146 lines
5.7 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.

/**
* MCP(Model Context Protocol)的 stdio 传输层与 JSON-RPC 分发。
*
* # 为什么手写而不引 `@modelcontextprotocol/sdk`
*
* 协议面很小:`initialize` / `notifications/initialized` / `tools/list` /
* `tools/call`。SDK 会带来一个 1MB 上下的打包产物与一条构建链,而本插件的
* 其余部分(网关客户端 + 工具)本就零运行时依赖 —— 与 pi/opencode/dsh 三个桥
* 的取向一致。手写还能让这一层成为**可单测的纯函数**,而不是只能靠连上宿主才验。
*
* # 分帧
*
* stdio 传输是**换行分隔的 JSON**(一行一条消息,UTF-8),不是 Content-Length 分帧。
* 这一点是照官方插件实测确认的:它的打包产物里出现 `StdioServerTransport` 与
* `split("\n")`,而 `Content-Length` 出现 **0 次**。
*
* # 职责边界
*
* 本模块只做「消息进 → 消息出」,不碰 stdin/stdout,也不认识具体工具 ——
* 于是它可以在测试里被穷举,而 I/O 只剩 server.mjs 里那一小段胶水。
*/
export const PROTOCOL_VERSION = '2024-11-05';
export const SERVER_NAME = 'agentmail';
export const SERVER_VERSION = '0.1.0';
/** JSON-RPC 错误码(只列我们真的会返回的)。 */
export const RPC_ERROR = {
PARSE: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL: -32603
};
const result = (id, value) => ({ jsonrpc: '2.0', id, result: value });
const failure = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
/**
* 处理一条已解析的 JSON-RPC 消息。
*
* @param {any} msg 解析后的消息
* @param {{tools: Array<{name:string, description:string, inputSchema:object,
* annotations?: object}>,
* call: (name: string, args: object) => Promise<string>}} ctx
* @returns {Promise<object|null>} 要写回的消息;notification(无 id)返回 null
*/
export async function handleMessage(msg, ctx) {
// 通知(没有 id)不需要回复。`notifications/initialized` 就走这条 ——
// 若它也回一条,客户端会把响应与请求错配,后续调用全乱。
const isNotification = msg === null || typeof msg !== 'object' || !('id' in msg);
const id = isNotification ? null : msg.id;
if (typeof msg !== 'object' || msg === null || typeof msg.method !== 'string') {
return isNotification
? null
: failure(id, RPC_ERROR.INVALID_REQUEST, '请求缺少 method');
}
switch (msg.method) {
case 'initialize':
return isNotification
? null
: result(id, {
// 回显客户端给的协议版本:不认识的版本也回显,交由客户端决定是否降级 ——
// 自作主张改成我们的版本会让客户端以为协商成功而按新语义调用。
protocolVersion: msg.params?.protocolVersion || PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }
});
case 'notifications/initialized':
return null; // 纯通知
case 'ping':
return isNotification ? null : result(id, {});
case 'tools/list':
return isNotification
? null
: result(id, {
tools: ctx.tools.map(t => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
// annotations 必须透传:宿主据此算风险等级(readOnlyHint→low /
// destructiveHint→high —— ZCode 的规则是逐字逆自其 CLI 产物),
// 而 plan 档下「非破坏性的 MCP 工具直接放行」
// 依赖它。漏传的后果不是「少个提示」,而是工具在该档下全被拒。
...(t.annotations ? { annotations: t.annotations } : {})
}))
});
case 'tools/call': {
if (isNotification) return null;
const name = msg.params?.name;
const args = msg.params?.arguments ?? {};
if (typeof name !== 'string' || name === '') {
return failure(id, RPC_ERROR.INVALID_PARAMS, 'tools/call 缺少 name');
}
const known = ctx.tools.some(t => t.name === name);
if (!known) {
return failure(id, RPC_ERROR.INVALID_PARAMS, `没有名为 ${name} 的工具`);
}
try {
const text = await ctx.call(name, args);
return result(id, { content: [{ type: 'text', text: String(text ?? '') }] });
} catch (error) {
// 工具失败**不能**回 JSON-RPC error —— 那样模型看不到失败原因,
// 只会看到一次协议错误。MCP 的约定是 result + isError:true,
// 于是错误文本进入对话,模型能据此改正(例如换一个 attachment_id)。
return result(id, {
content: [
{ type: 'text', text: `工具 ${name} 执行失败:${error?.message || error}` }
],
isError: true
});
}
}
default:
return isNotification
? null
: failure(id, RPC_ERROR.METHOD_NOT_FOUND, `不支持的方法 ${msg.method}`);
}
}
/**
* 把一行文本解析成消息并处理,返回要写回的行(或不返回)。
*
* 解析失败时**必须**回一条带 id=null 的解析错误(JSON-RPC 规定),
* 否则客户端会一直等这一条的响应。
*/
export async function handleLine(line, ctx) {
const text = String(line ?? '').trim();
if (text === '') return null;
let msg;
try {
msg = JSON.parse(text);
} catch {
return JSON.stringify(failure(null, RPC_ERROR.PARSE, '不是合法的 JSON'));
}
const out = await handleMessage(msg, ctx);
return out === null ? null : JSON.stringify(out);
}