/** * 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}} ctx * @returns {Promise} 要写回的消息;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 必须透传:ZCode 用它算风险等级(readOnlyHint→low / // destructiveHint→high),而 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); }