从零搭建 Agent Harness 系列(二十四)MCP 当手、A2A 当同事

系列二十三把历史和 Token 落到磁盘,断档只补 Observation。路线图下一阶段是工具生态:本地 Registry 已经能跑 read_file / bash,还缺两样外来的东西。

MCP 是远程的:一次函数调用,拿回一段结果。A2A 是远程的同事:整件任务丢过去,对方自己想、自己调工具,回来交一份报告。模型两边都只看见本地 ToolDefinition,不会直接讲 JSON-RPC。

本文对应 go-tiny-claw 两次提交:

1
2
d516575  feat: 将 MCP 工具和 A2A 委派接到本地 Registry
47c256a feat: 支持 Streamable HTTP MCP,并改用 OpenAI 环境变量

一、外来的手必须进同一道门

路线图写得很死:外部工具及远程 Agent 必须复用同一个 Context、Approval 和 Observability,不能因为来自 MCP 就绕过本地安全边界。

所以这一刀的形状是适配,不是另起一套引擎:

1
2
3
4
5
6
7
8
9
10
.claw/mcp.json
↓ command 或 url
MCP Server(stdio 子进程 / HTTP)
↓ initialize → tools/list
Adapter(mcp_<server>_<tool>)
↓ schema.ToolDefinition + RiskLevel
本地 Registry
↓ Approval Gate
↓ tools/call
MCP Server

名字加前缀,两个服务器都叫 search 也不会撞。风险写在配置里,不信远端自己报的安全等级;缺省当成 dangerous,默认要审批。

A2A 更简单:Registry 里多一个 delegate_agent,风险同样是危险。参数是 message,外加 url.claw/a2a.json 里的 peer。模型发出去的是「请对方做完这件事」,不是远程那一侧的工具列表。

二、Client 只认三件事

MCP 规范很长。第一刀只做模型真正用得到的:

1
2
3
initialize + notifications/initialized
tools/list
tools/call

resourcespromptssampling、工具热刷新都没做。启动时列一次,挂到 Registry,进程活着就用这份。某个服务器连不上,整份 MCP 一起失败:与其 silently 少几只手,不如启动时就看见。

Client 本身不读 stdin、不发 HTTP。它只分配 JSON-RPC id,调 Transport

1
2
3
SendRequest
SendNotification
Close

取消时立刻把错误还给调用方,notifications/cancelled 异步发出去。stdio 底下是 io.Pipe,同步写会堵死:读循环还卡在等响应,写通知没人读。取消测试曾经因此卡满 1 秒。

三、stdio 先通,HTTP 再拆一层

本地调试用 stdio:command + args + env,工作目录就是 Agent 的 workspace,换行分隔 JSON-RPC。子进程跟 Factory 走,factory.Close() 杀掉。

要接托管服务(例如只给 URL 和 Bearer 的 OTA),stdio 不够。第二刀把读写从 Client 里抽走,补上 Streamable HTTP:

1
2
3
4
5
6
7
8
POST JSON-RPC
Accept: application/json, text/event-stream
记下响应头 MCP-Session-Id
initialize 之后带上 MCP-Protocol-Version
SSE 只收匹配当前 id 的 data:
通知接受 202 或 200
Close 时 DELETE 会话
401 / 403 → MCP 鉴权失败

协议版本写死 2025-11-25。GET 挂长连接、会话恢复、断线续传都没做。响应可以是一整段 JSON,也可以是 SSE;扫描到对应 id 就返回,通知事件丢掉。

配置互斥:一个服务器只能有 commandurl,两个都写或两个都不写直接失败。HTTP 的 headers 支持 ${ENV}。变量不存在或值为空,加载配置失败——密钥不要写进 mcp.json,也不要在缺 Key 时默默连上去。

示例(密钥在环境变量里,不进仓库):

1
2
3
4
5
6
7
8
{
"name": "combos-ota",
"url": "https://ota.combos.fun/mcp",
"headers": {
"Authorization": "Bearer ${COMBOS_OTA_MCP_KEY}"
},
"risk": "mutating"
}

启动脚本要先 source .env。直接 go run 不会读 .env。缺了 COMBOS_OTA_MCP_KEY,整个 Agent 起不来,这是故意的。

四、连接挂在 Factory 上,不挂在单次 Run

MCP 子进程和 HTTP 会话都贵。Factory 懒创建一份 Manager,每次 NewRuntime 只往新 Registry 上 Register。REPL 多轮、WebSocket 多会话,共用同一批远端。

cmd/clawcmd/claw_serverdefer factory.Close()。stdio 杀进程,HTTP 发 DELETE。不关就会留下 npx 子进程,或者远端会话一直占着。

Adapter 的 Executetools/call 的文本块拼起来。远端标了 isError,返回中文错误,走 Recovery,不伪装成功 Observation。

五、A2A 用自己的信封,不假装官方协议

官方 A2A 是另一套 JSON-RPC。这一刀只解决「把任务交给另一个 claw」:

1
2
3
POST /a2a/message
GET /a2a/agent-card
GET /.well-known/agent-card.json

响应仍是项目约定的 {code, message, data}。挂在现有 :8081 上,不另开端口。入站走 Manager,Ask 类工具一律 Deny:远端没有人坐在终端前点 y,不能让远程 Agent 改你的磁盘。

出站工具危险,要本地人批。回来的是一段报告,不是对方内部的 ToolCall 轨迹。spawn_subagent 仍是进程内子智能体,这一刀没有把它注册进 Factory。

六、测试要证明什么

MCP:

  1. stdio 能 tools/list / tools/call,注册名带 mcp_ 前缀
  2. 取消立刻返回,不必等远端做完
  3. 没有配置文件等于没有远端,不报错
  4. 必须只写 commandurl 之一
  5. HTTP 能握手、列工具、调用,并带上 Session 头
  6. ${ENV} 能展开;缺变量或 command/url 同时出现则失败

A2A:

  1. 入站空消息拒绝
  2. Agent Card 能 GET
  3. delegate_agent 能把 peer 解析成 URL
  4. 既没有 url 也没有已知 peer 则失败

七、阶段十一还没做完的

1
2
3
4
5
6
MCP resources / prompts / sampling
启动后刷新工具列表
GET SSE 长连接与会话恢复
官方 A2A JSON-RPC / 流式
把 spawn_subagent 挂进 Factory
某个 MCP 失败时跳过而不是整组失败

阶段九留下的 TotalCostCNY 仍是 0。未使用的 Claude Provider 已经删掉,入口改成 OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODEL。费用记账仍不是 MCP 的前置条件。

路线图里的「工具发现和刷新」「远程超时单独治理」「工具版本兼容」都还粗。现在的发现是启动时列一次;超时复用调用方 Context;版本就是握手里那个字符串。

总结

二十三解决「进程没了如何接上」。二十四解决「手和同事从哪来」:

1
2
3
4
5
6
7
8
9
模型只看见本地 ToolDefinition

MCP:stdio 或 Streamable HTTP,进同一 Registry / Gate

名字加前缀,缺省危险,密钥走 ${ENV}

连接挂 Factory,Close 杀进程或 DELETE 会话

A2A:自己的信封,入站 Ask 一律拒

阶段十二是评测、性能和部署。MCP 先能调用、A2A 先能交差,就可以停。