A2A 桥设计:omp 作为 MCP 服务器(pi-a2a-ext)
日期:2026-09-10 状态:待评审 上游决策:BRAIN.md Q1(omp 宿主)、Q2(MCP 传输)、Q3(stdio 默认——本 spec 修订为 HTTP,见下)、Q4(TypeScript 插件)、Q5(只暴露工具)
1. 目标与范围
让远程 agent(典型:另一台机器或另一个目录里的 omp 实例)通过标准 MCP 协议,直接调用当前正在运行的 omp 会话的工具。
- 宿主 omp 进程内加载本扩展,起一个 Streamable HTTP MCP 服务器。
- 远程 omp 通过
mcp.json的type: http连入,tools/list看到宿主全部工具,tools/call由宿主会话执行。 - 写/exec 类工具按宿主
tools.approval策略执行:策略为prompt时触发宿主 TUI 实时审批弹窗;用户 Approve/Deny,结果原路返回远程。宿主默认yolo即直通(与本地调用语义一致)。
v1 不做:resources/prompts 暴露、server-to-client 进度通知(SSE)、调用取消、多会话路由(固定 Main)、OAuth(仅静态 Bearer token)。
对 BRAIN.md Q3 的修订
Q3 原结论「stdio 优先」。实施研究推翻:stdio 服务器必须独占 stdin/stdout,与运行中的 omp TUI 冲突;而审批转发到宿主 TUI、执行落在当前会话,都要求服务器与宿主同进程。结论改为:v1 仅 Streamable HTTP(127.0.0.1 起步)。本 spec 落定后同步更新 brain 页面。
2. 环境事实(已验证)
| 事实 | 来源 |
|---|---|
宿主 omp 18.1.16;扩展面 @oh-my-pi/pi-coding-agent,兼容别名 @earendil-works/* / @mariozechner/* 由 loader shim 解析 | 全局安装源码 extensibility/plugins/legacy-pi-compat.ts;superpowers 插件即 import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" |
宿主 MCP 客户端协议版本 2025-11-25,Accept: application/json, text/event-stream,纯 JSON 响应即满足;GET 长连接可选(405/非 2xx → 客户端静默忽略);notification 接受 200/202 | src/mcp/types.ts:168、src/mcp/transports/http.ts |
pi.getAllTools() 返回 ToolInfo[]:{name, description, parameters, promptGuidelines?, sourceInfo},含核心+扩展+MCP 工具 | extensibility/extensions/types.ts:700-706 |
AgentRegistry.global().get("Main").session → AgentSession(公开 settings/sessionManager/modelRegistry/model);getToolByName(name) 返回注册表工具(src/session/session-tools.ts:408) | src/registry/agent-registry.ts |
AgentToolContext(pi-agent-core 经声明合并)= CustomToolContext 必备 {sessionManager, modelRegistry, model, isIdle(), hasQueuedMessages(), abort()} + 可选 {settings, autoApprove, fetch, localProtocolOptions, ui?, hasUI?, toolCall?} | extensibility/custom-tools/types.ts:85-106、tools/context.ts:5-19 |
扩展 ExtensionContext 公开 isIdle()/hasPendingMessages()/abort()/ui/hasUI/localProtocolOptions/modelRegistry → 与 session.* 拼接即可构造完整 ctx | extensibility/extensions/runner.ts:1160-1207 |
pi-ai 的 toolWireSchema(tool) / arkToWireSchema(schema) 把 ArkType/TypeBox/JSON Schema 统一转 JSON Schema 2020-12;导入走子路径 @oh-my-pi/pi-ai/utils/schema(根入口不导出) | @oh-my-pi/pi-ai/src/utils/schema/wire.ts:585-609、src/utils/schema/index.ts:14 |
| MCP SDK 未安装;裸 Bun.serve 手写 JSON-RPC 即可满足已验证的协议面 | 全盘 find 无果 |
3. 架构
远程 omp 宿主 omp(装本扩展)
mcp.json: {type:"http", url, headers:{Authorization}}
│ ┌────────────────────────────────────────┐
│ POST / initialize / tools/list / tools/call │ extensions/a2a-bridge.ts │
├─────────────────────────────────────────────▶│ session_start → startServer(ctx) │
│ ◀── JSON-RPC responses (application/json) │ ┌──────────────────────────────────┐ │
│ │ │ server.ts Bun.serve 127.0.0.1 │ │
│ │ │ auth.ts Bearer token 校验 │ │
└──────────────────────────────────────────────│──│ bridge.ts tools/list + call │ │
│ └───────────────┬──────────────────┘ │
│ AgentRegistry.global().get("Main") │
│ .session.getToolByName(name) │
│ .execute(id, args, signal, │
│ onUpdate, ctx) │
│ │ 内建审批门 (ExtensionToolWrapper)
│ ▼ prompt → ctx.ui.select │
│ 宿主 TUI 弹窗 Approve / Deny │
└────────────────────────────────────────┘
单进程、单文件入口 + 几个小模块。无 IPC、无子进程、无第三方依赖。
4. 模块
pi_a2a_ext/
package.json # {"name":"pi-a2a-ext","pi":{"extensions":["./extensions/a2a-bridge.ts"]}}
extensions/a2a-bridge.ts # 唯一入口:默认导出 (pi: ExtensionAPI) => void
src/
config.ts # 读写 ~/.omp/agent/a2a-bridge.json;token 生成/持久化
auth.ts # token → 请求头校验(常数时间比较)
server.ts # Bun.serve + JSON-RPC 路由(initialize/tools/list/tools/call/ping)
bridge.ts # 工具目录(getAllTools+deny → MCP tool)与执行(Main session → tool.execute)
test/smoke.mts # 无头 E2E 冒烟(§8)
4.1 入口 a2a-bridge.ts
pi.on("session_start", ...):加载配置(无则创建含随机 token 的默认配置),startServer(),ctx.ui.notify显示 URL + token 摘要。pi.on("session_shutdown", ...)→server.stop()。session_tree不处理:ctx 每次tools/call现取Main.session,树切换无需重启服务器。- 注册命令
/a2a(pi.registerCommand):status(打印 URL/token/端口/已服务调用数)与rotate(重生成 token 写回配置)。 - 端口占用 → 回退随机端口(配置里
port: 0即随机)。
4.2 工具目录(tools/list)
数据源 pi.getAllTools()(全部列出——ToolInfo 无 availability 字段,不可用 MCP 工具自然不在注册表里):
{
name: t.name,
description: t.description ?? "",
inputSchema: jsonSchemaOf(t.parameters), // 统一转 JSON Schema 2020-12(§2 最后一行)
}
- 配置
deny: string[]:列出的工具从tools/list消失;tools/call同名请求也返回isError(防绕过,即使期间getAllTools变化)。 - schema 转换只发生一次/工具,按工具名缓存;
tools/list每次重扫名单(成本低),schema 缓存按(name)失效即可(工具重建罕见且无害)。
4.3 执行(tools/call)
async function callTool(name, args, extCtx) {
if (denied(name)) return err(`tool '${name}' is not exposed by this bridge`);
const ref = AgentRegistry.global().get("Main");
if (!ref?.session) return err("main session not available");
const session = ref.session;
const tool = session.getToolByName(name);
if (!tool) return err(`unknown tool '${name}'`);
const ctx = {
sessionManager: session.sessionManager,
modelRegistry: session.modelRegistry,
model: session.model,
settings: session.settings,
isIdle: extCtx.isIdle,
hasQueuedMessages: extCtx.hasPendingMessages,
abort: extCtx.abort,
ui: extCtx.ui,
hasUI: extCtx.hasUI,
localProtocolOptions: extCtx.localProtocolOptions,
}; // 全公开面拼装(§2 表),类型与 AgentToolContext 对齐
try {
const r = await tool.execute(randomUUID(), args, undefined, undefined, ctx);
return { content: toMcpContent(r), isError: !!r.isError };
} catch (e) { return err(e.message); } // 审批 deny / 执行错误统一走 isError
}
- 审批复用宿主门:注册表工具即
ExtensionToolWrapper。审批门读 ctx 的settings(session.settings,与宿主同源)/autoApprove决定approvalMode与逐工具策略;弹窗与 fail-closed 走 wrapper 构造时绑定的this.runner(wrapper.ts:309runner.hasUI()、:333runner.getUIContext().select()),不读 ctx.ui/hasUI——桥无需也无法注入审批 UI,机制:approvalMode: yolo→ 直通;- 策略
prompt→ runner 的宿主 TUI 弹 Approve/Deny,用户按键后继续——桥不写任何审批逻辑; - 宿主无交互 UI(print/rpc 模式)→ wrapper fail-closed 抛错(含 yolo 提示文本),返回
isError。
- 取消:v1
signal传undefined(wrapper 对signal: undefined安全)。 - 结果映射:
content[]的text→{type:"text",text};image→{type:"image",data,mimeType};未知块转 text。isError透传为 MCP result 的isError。 - 并发:多次
tools/call并行时无共享状态;审批弹窗由宿主 UI 队列天然串行化。
4.4 server.ts(协议面,对齐 §2 已验证客户端行为)
POST /:解析 JSON-RPC。鉴权失败 → HTTP 401(不泄露原因差异)。initialize→{protocolVersion: 客户端请求值(缺省 "2025-11-25"), capabilities:{tools:{}}, serverInfo:{name:"omp-a2a-bridge", version}},响应头Mcp-Session-Id(随机生成,内存记录,后续请求校验匹配)。notifications/initialized→ 202 空体。tools/list→{tools:[...]}(不带nextCursor:客户端 do-while 分页遇缺省即止,mcp/client.ts:233-244)。tools/call→{content, isError}。ping→{}。其余方法 → JSON-RPC-32601。
GET /→ 405(客户端静默跳过 SSE;§2 已验证)。DELETE /→ 204(可选会话注销)。- 响应一律
application/json。未知/过期Mcp-Session-Id→ 404(规范行为,触发客户端重建会话)。 - 绑定
127.0.0.1默认;host配置显式改0.0.0.0时启动 notify 打安全警告。 - 单请求体上限 1 MB(超出 → 413/400)。工具执行不设桥级超时(长任务如 build 由工具自身控制;阻断型审批由宿主 UI 超时兜底)。
4.5 config.ts
~/.omp/agent/a2a-bridge.json(0600):
{ "port": 0, "token": "<base64url 32B,首次启动生成>", "host": "127.0.0.1", "deny": [], "denyMCPTools": false }
- token 生成:
crypto.getRandomValues(32B)→ base64url;文件不存在则创建。 - 远程配置示例(写入 README 段与 notify 文本):
{"omp-host":{"type":"http","url":"http://<host>:<port>/","headers":{"Authorization":"Bearer <token>"}}}
5. 错误模型
| 层 | 情形 | 返回 |
|---|---|---|
| HTTP | token 缺失/错误 | 401 |
| HTTP | 方法不是 POST/GET/DELETE | 405 |
| HTTP | body 非 JSON-RPC / 超限 | 400 + JSON-RPC error |
| JSON-RPC | 未知 method | -32601 |
| MCP | 工具不存在 / 被 deny / 会话不可用 | result.isError=true + 文本 |
| MCP | 审批 deny(宿主拒绝)/ 执行抛错 | result.isError=true + 错误文本 |
桥自身不实现 approve/deny 路径(§4.3 宿主门接管),错误文本原样透传(含 wrapper 的 "Tool call denied by user: ...")。 |
6. 安全边界
- 默认仅绑回环;远程机需 SSH 端口转发(README 给一行命令)或显式
host:0.0.0.0+防火墙自管。 - 静态 Bearer token,常数时间比较;
rotate即时生效。token 仅出现在启动 notify 与配置文件(0600)。 - v1 无 per-remote 身份区分:能拿到 token = 能触发该工具的宿主审批。审批门是最后防线,默认语义继承宿主
tools.approvalMode。 - MCP 工具(
mcp__server__tool)也在getAllTools()内,存在桥接环风险(远程把本桥再注册、宿主再连回它)。默认暴露(全量原则),文档声明;denyMCPTools: true可整体排除。