Implementation Plan: pi-a2a-ext — omp 作为 MCP 服务器
日期:2026-09-10
上游:docs/superpowers/specs/2026-09-10-a2a-mcp-bridge-design.md(已批准)
执行方式:本计划可在独立会话执行,任务间无共享状态依赖(除 T6→T7 的编译/接线)。
Header
- Goal:让装了插件的 omp 进程变成 Streamable HTTP MCP 服务器;远程 omp 通过
type: http连入,直接调用宿主Main会话的工具;宿主不调用任何 LLM API;写/exec 类工具 审批复用宿主内建门(默认 yolo 直通,策略 prompt 时宿主 TUI 弹窗)。 - Architecture:单进程。扩展入口
extensions/a2a-bridge.ts(session_start时起服务器);src/server.ts手写 JSON-RPC on Bun.serve(零第三方依赖,MCP SDK 未安装且纯 JSON 响应已满足宿主客户端);src/bridge.ts经AgentRegistry.global().get("Main").session.getToolByName(name).execute(...)执行;审批由ExtensionToolWrapper内建门接管(注册表工具即 wrapper);src/config.ts/src/auth.ts管 token。 - Tech Stack:Bun(宿主即 Bun 运行时)、TypeScript(extension 源码直载,无构建步骤)、node:crypto(token/常数时间比较)、零 npm 依赖。
已验证的宿主事实(实现时直接引用,勿重查)
| 事实 | 出处 |
|---|---|
扩展自动发现:~/.omp/agent/extensions/*.ts 直接加载(herdr 即此机制) | 实测目录 |
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";根导出 AgentRegistry/MAIN_AGENT_ID/ToolInfo/ExtensionContext | src/index.ts:23-25,40、src/sdk.ts:712 |
AgentRegistry.global().get("Main") → {session: AgentSession | null};所有模式(TUI/rpc/print)的顶层会话都以 Main 注册,且注册先于 session_start 派发(attachSession 后触发) | src/registry/agent-registry.ts:72-87、src/sdk.ts:1745,3321 |
AgentSession 公开 settings/sessionManager/modelRegistry/model(src/session/agent-session.ts:532-534,1981,4977,5218);getToolByName(name) 返回注册表工具(即 ExtensionToolWrapper,内建审批门;src/session/session-tools.ts:408) | src/session/*.ts |
wrapper 审批门读 context.settings/context.autoApprove(extensibility/extensions/wrapper.ts:196-197);弹窗/fail-closed 走 this.runner.hasUI() + this.runner.getUIContext().select()(wrapper.ts:309,325,333)——不读 ctx.ui/hasUI;runner 在构造时绑定宿主会话,桥无需也无法注入审批 UI | extensibility/extensions/wrapper.ts |
宿主 MCP 客户端:协议 2025-11-25,Accept: application/json, text/event-stream,纯 JSON 响应 OK;GET SSE 405 容忍;notification 200/202 OK;tools/list do-while 分页遇无 nextCursor 即止 | src/mcp/types.ts:168、src/mcp/transports/http.ts、src/mcp/client.ts:233-244 |
schema:pi-ai 的 toolWireSchema(tool) 在 @oh-my-pi/pi-ai/utils/schema(根入口不导出,src/utils/schema/index.ts:14 re-export ./wire) | @oh-my-pi/pi-ai/src/utils/schema/wire.ts:585-609 |
crypto.randomUUID() / crypto.getRandomValues(32) base64url / timingSafeEqual 均可用 | node:crypto |
任务
T1 — 脚手架:包清单 + 目录结构
- Files
package.json(新建):{"name":"pi-a2a-ext","type":"module","private":true,"pi":{"extensions":["./extensions/a2a-bridge.ts"]}}tsconfig.json(新建):{"compilerOptions":{"strict":true,"noEmit":true,"allowImportingTsExtensions":true,"module":"esnext","moduleResolution":"bundler","target":"esnext","types":["bun"]}}extensions/、src/、test/目录
- Change
- 写 package.json、tsconfig.json,建空目录(各放一个占位
.gitkeep)。 - 不引入任何依赖;不装 MCP SDK。
- 写 package.json、tsconfig.json,建空目录(各放一个占位
- Acceptance
bun tsc --noEmit(或bun x tsc --noEmit)无输出退出 0。git status可见新目录。
T2 — src/config.ts:配置加载 + token 生成
- Files:
src/config.ts - Change
export interface BridgeConfig { port: number; token: string; host: string; deny: string[]; denyMCPTools: boolean }export const DEFAULT_PORT = 0(0=随机)。export function configPath(env = process.env): string—env.A2A_BRIDGE_CONFIG || join(getAgentDir(), "a2a-bridge.json");getAgentDir从@oh-my-pi/pi-coding-agent根导入。export async function loadConfig(env = process.env): Promise<BridgeConfig>— 文件存在→JSON.parse(畸形→抛错带路径);不存在→生成默认(token: generateToken(),host:"127.0.0.1",deny:[],denyMCPTools:false),mkdir -p dirname,写 0600,返回。export function generateToken(): string—base64url(randomBytes(32))(crypto.randomBytes或getRandomValues;用node:crypto的randomBytes+base64url)。export async function saveConfig(cfg, env)— 写回 JSON(JSON.stringify(cfg,null,2)),chmod 0o600。export function isDenied(cfg, name): boolean—cfg.deny.includes(name);denyMCPTools && name.startsWith("mcp__")。
- Acceptance
- 临时脚本:
TMP=$(mktemp -d); A2A_BRIDGE_CONFIG=$TMP/c.json bun -e '...loadConfig...'→ 文件生成、0600、token 44 字符 base64url、二次 load 幂等(token 不变)。 - 畸形 JSON 文件 → 抛错信息含路径。
- 临时脚本:
T3 — src/auth.ts:Bearer 校验
- Files:
src/auth.ts - Change
export function extractBearer(authHeader: string | null): string | null— 正则/^Bearer\s+(.+)$/,无匹配→null。export function tokenEqual(a: string, b: string): boolean—Buffer.byteLength不等→false;等→timingSafeEqual(Buffer.from(a), Buffer.from(b))。export function authorize(cfg: BridgeConfig, headers: Headers): boolean—extractBearer(headers.get("authorization"))→tokenEqual。
- Acceptance
- 临时脚本断言:正确 token 过、错误 token 拒、无头拒、
Bearer前缀缺失拒、长度不等拒且不抛。
- 临时脚本断言:正确 token 过、错误 token 拒、无头拒、
T4 — src/server.ts:JSON-RPC 服务器
- Files:
src/server.ts - Change
export interface BridgeDeps { getTools(): Promise<McpTool[]>; callTool(name: string, args: unknown): Promise<{content: McpContent[]; isError: boolean; errorText?: string}>; serverInfo(): {name:string; version:string} };McpTool = {name:string; description:string; inputSchema:Record<string,unknown>};McpContent = {type:"text"; text:string} | {type:"image"; data:string; mimeType:string}(类型放src/types.ts或 server.ts 顶部,由 T4 定义、T5 消费——契约在此固定)。export async function startServer(cfg: BridgeConfig, deps: BridgeDeps): Promise<{port:number; stop():void}>:Bun.serve({hostname: cfg.host, port: cfg.port, fetch: handler, maxRequestBodySize: 1024*1024});cfg.port===0时实际端口取server.port。handler(req):GET→new Response(null,{status:405});DELETE→ 会话 id 移除 + 204;非 POST → 405。authorize(cfg, req.headers)失败 → 401{jsonrpc:"2.0",id:null,error:{code:-32000,message:"unauthorized"}}(HTTP 401 + JSON-RPC error body,content-type application/json)。- 读 body 上限 1 MiB(Bun.serve maxRequestBodySize 已拦,超出自然 413);
JSON.parse失败 → 400 JSON-RPC error{code:-32700,message:"parse error"}。 - 会话:
req.headers.get("mcp-session-id");不在 Map 且方法不是 initialize → 404(HTTP)+ error{code:-32000,message:"unknown session"}(客户端会重连);initialize → 生成randomUUID()存 Map,响应头Mcp-Session-Id。 - 分派(按
msg.method,id 原样回):initialize→{protocolVersion: msg.params?.protocolVersion ?? "2025-11-25", capabilities:{tools:{}}, serverInfo: deps.serverInfo()}notifications/initialized/ 任意 method 以notifications/开头且无 id → 202 空体(不响应 result)ping→{}tools/list→{tools: await deps.getTools()}(无 nextCursor)tools/call→const r = await deps.callTool(name, params.arguments);{content, isError};callTool 抛错 → result{content:[{type:"text",text:msg}],isError:true}- 其它 →
-32601 method not found
- 请求体为 JSON 数组(batch)→
-32600 invalid request。
- 端口占用重试:
port!==0时 catch 一次 → 退回 0 重试(startServer内部循环 ≤2 次)。 stop():server.stop(true)。
- 响应统一
Content-Type: application/json;不实现 SSE。
- Acceptance
- 临时脚本
bun -e:起startServer(port 0,deps 用 stub),fetch依次:无 token 401 → 无头 initialize 404 → 带 token initialize 200(断言 protocolVersion/serverInfo/会话头)→notifications/initialized202 →ping→tools/list(stub 内容原样)→tools/call(stub)→ 未知 method -32601 → GET 405。全部断言过。
- 临时脚本
T5 — src/bridge.ts:工具目录 + 执行
- Files:
src/bridge.ts(消费 T4 的McpTool/McpContent契约) - Change
import { AgentRegistry, type ExtensionContext } from "@oh-my-pi/pi-coding-agent";import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"(根入口不导出,必须走该子路径;src/utils/schema/index.ts:14re-export./wire。仍用 try/catch 包调用:schema 转换失败→fallback 直接用ToolInfo.parameters作 inputSchema 并 warn 一次)。export function buildToolCatalog(pi: ExtensionAPI, cfg: BridgeConfig): () => Promise<McpTool[]>:- 闭包内
Map<string, McpTool>缓存;每次调用:pi.getAllTools()→ 过滤isDenied(cfg, t.name)→ 每项{name, description: t.description ?? "", inputSchema: toInputSchema(t.parameters)}(缓存按 name;重扫时新名字才转换)→ 返回数组。 toInputSchema(parameters):try { return toolWireSchema({parameters}) as Record<string,unknown> } catch { return parameters as Record<string,unknown> ?? {type:"object"} }。
- 闭包内
export function buildCallTool(pi: ExtensionAPI, extCtx: ExtensionContext): (name: string, args: unknown) => Promise<{content: McpContent[]; isError: boolean}>:- 校验
isDenied→{content:[{type:"text",text:\tool '${name}' is not exposed by this bridge`}],isError:true}`(T4 侧也过滤,双保险)。 const ref = AgentRegistry.global().get("Main");!ref?.session→ error "main session not available"。const tool = ref.session.getToolByName(name);!tool→ errorunknown tool '${name}'。- 组装
ctx(全公开面):(const ctx: any = {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,};extCtx即第二参数,session_start回调传入并闭包捕获;ctx字段如与声明合并后的AgentToolContext不匹配用断言。注意:ctx 里的ui/hasUI只是信息性字段——wrapper 的审批弹窗/fail-closed 走this.runner(wrapper.ts:309,325,333),不读这两个字段;真正影响审批的是settings。) const r = await tool.execute(crypto.randomUUID(), args as any, undefined, undefined, ctx)。- 映射:
toMcpContent(r.content):text→{type:"text",text};image→{type:"image",data:块.data, mimeType:块.mimeType};其它→{type:"text",text:JSON.stringify(块)}。isError: !!r.isError。 catch(e)→{content:[{type:"text",text:e.message}],isError:true}(审批 deny、无 UI fail-closed、执行异常全覆盖)。
- 校验
- 模块无副作用;
buildToolCatalog/buildCallTool在 T6 的 session_start 里实例化(拿 pi + extCtx)。
- Acceptance
- 依赖 T6 接线后才能集成验证;本任务先保证类型通过
bun x tsc --noEmit。 - 集成断言(T7 冒烟覆盖):
tools/call read真实文件内容匹配;tools/call未知名 isError;deny 列表项双端消失。
- 依赖 T6 接线后才能集成验证;本任务先保证类型通过
T6 — extensions/a2a-bridge.ts:入口接线
- Files:
extensions/a2a-bridge.ts - Change
export default function a2aBridge(pi: ExtensionAPI) { ... };import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent"。- 模块级
let server: {port:number; stop():void} | null = null; let cfg: BridgeConfig;。 pi.on("session_start", async (_ev, extCtx) => { if (server) return; cfg = await loadConfig(); const deps = {getTools: buildToolCatalog(pi, cfg), callTool: buildCallTool(pi, extCtx), serverInfo: () => ({name:"omp-a2a-bridge", version:"0.1.0"})}; server = await startServer(cfg, deps); extCtx.ui.notify(\A2A bridge: http://${cfg.host}:${server.port} token=${cfg.token}`, "info"); })`。- 注:
buildCallTool(pi, extCtx)持 extCtx 闭包即可(每次调用现查 Main session,不缓存 session 引用)。
- 注:
pi.on("session_shutdown", async () => { server?.stop(); server = null; })。pi.registerCommand("a2a", { description: "A2A MCP bridge status/rotate", handler: async (args, ctx) => { const [sub] = args.trim().split(/\s+/); if (sub === "rotate") { cfg.token = generateToken(); await saveConfig(cfg); ctx.ui.notify("A2A token rotated; update remote mcp.json"); return; } ctx.ui.notify(server ? \A2A bridge: http://${cfg.host}:${server.port} token=${cfg.token}` : "A2A bridge not running"); } })`。- 无其它注册;不触碰工具、不改系统提示。
- Acceptance
bun x tsc --noEmit通过。- 真实宿主加载后:
~/.omp/agent/extensions/a2a-bridge.ts(软链到本文件)→ 启动omp→ notify 显示 URL/token;/a2a显示 status;/a2a rotate换 token。
T7 — test/smoke.mts:无头 E2E 冒烟
- Files:
test/smoke.mts - Change
- 脚本逻辑:
const tmp = mkdtempSync(join(tmpdir(), "a2a-smoke-"));建tmp/.omp/agent/extensions/a2a-bridge.ts(symlinkSync指向仓库extensions/a2a-bridge.ts);envA2A_BRIDGE_CONFIG=tmp/.omp/agent/a2a-bridge.json、HOME=tmp(保留必要的 PATH)。spawn("omp", ["--mode","rpc"], {env});等待a2a-bridge.json出现(poll ≤15s)→ 读 cfg 拿 URL/token。- 裸
fetchMCP 客户端:initialize(带 Bearer)→ 断言result.protocolVersion、serverInfo.name;notifications/initialized→ 202;tools/list→ 断言含read、不含 deny 项(cfg.deny 预设["bash"]);tools/call read <tmp 内文件>→ 断言 text 含文件内容;tools/call no_such_tool→isError:true;无 token 请求 → 401;tools/call里调bash(deny 项)→ isError。 - kill 子进程;清理 tmp(
rmSync recursive force)。
- 审批 fail-closed 用例:同一 smoke 里第二个场景——写临时 settings(
tmp/.omp/agent/config.yml或 env 指定tools.approvalMode? 简化:宿主默认 yolo 即可测直通;prompt 路径属人工验证,不进自动化)。- 注:rpc 模式无 TUI → 默认 yolo 时 read 直通。若宿主默认非 yolo(用户配置),本测试用隔离 HOME 不受影响。
- 断言失败
process.exit(1);成功打印 "SMOKE OK"。
- 脚本逻辑:
- Acceptance
bun test/smoke.mts在本机全绿(会真的起一个 omp 子进程,需网络无关:模型解析不要求在线——工具调用不经 LLM)。- 若宿 主
omp二进制不在 PATH:脚本内const OMP = process.env.OMP_BIN ?? "omp"并文档说明。
T8 — README 与人工验证清单
- Files:
README.md(新建) - Change
- 安装:
ln -s <repo>/extensions/a2a-bridge.ts ~/.omp/agent/extensions/a2a-bridge.ts(或 npm 包形式:pi install pi-a2a-ext,若将来发布)。 - 宿主侧:启动 omp → notify 里抄 URL/token;
/a2a查看;/a2a rotate轮换。 - 远程侧 mcp.json 示例(HTTP + Bearer)。
- 跨机:
ssh -L <port>:127.0.0.1:<port> user@host一行;显式host:0.0.0.0需自行防火墙。 - 人工验证清单(TUI 审批):宿主 TUI +
bash工具策略 prompt → 远程 tools/call bash → 宿主弹窗 Approve/Deny → 结果回传。 - 安全说明:token 即信任凭证;默认回环;风险声明(桥接环)。
- 安装:
- Acceptance:README 步骤可被照做;
bun test/smoke.mts仍绿。
T9 — 收尾:git + brain + 回归
- Change
git add -A && git commit(信息:feat: omp-as-MCP-server a2a bridge (extensions + src + smoke))。- brain:
brain append-timeline --id a2a-mcp-bridge --kind decision --summary "spec approved; implementation plan written; zero-dep hand-written JSON-RPC server on Bun.serve"。 - 重跑
bun test/smoke.mts确认。
- Acceptance:提交存在;brain 时间线有新条目;smoke 绿。
依赖图
T1 ─▶ T2 ─▶ T3 ─▶ T4 ──┐
│ │ │
└────────────┴─▶ T5 ─▶ T6 ─▶ T7
│
├─▶ T8
└─▶ T9
- T4 定义
McpTool/McpContent/BridgeDeps契约,T5 消费。 - T6 依赖 T2–T5 全部;T7 依赖 T6;T8/T9 依赖 T6 后。
- T1–T5 相互独立可并行(T4/T5 契约已在此固定)。
实施注意(避开已探明的坑)
AgentRegistry.global().get("Main")在session_start回调触发时已注册(SDK 注册先于会话事件),无需等待。tool.execute的ctx必须含settings(否则 wrapper 按默认 yolo 判定,用户 prompt 策略失效);autoApprove不设(undefined 即不强制)。crypto.randomUUID用node:crypto导入(Bun 全局也有,但显式导入更稳)。- 扩展文件被宿主 Bun 直接 import,
src/用相对路径 import(../src/server.ts),带.ts扩展名(allowImportingTsExtensions)。 toolWireSchema若 import 失败不要硬失败:fallback 直接传 parameters。冒烟会覆盖 read 的 schema 是否可用。- 不要在扩展里
console.log到 stdout(宿主 stdout 归协议/UI 用);信息走ctx.ui.notify。 - 宿主工具内容块类型:
TextContent({type:"text",text})与ImageContent({type:"image",data,mimeType})——映射按此两个判别分支。