Coding Agent Harness 拆解 · MCP 集成:接入外部工具与资源(十三)
本文是《Coding Agent Harness 拆解》系列的一篇。整个系列逐个拆开一个真实在用的终端 AI 编程 agent(codebot),以及它底下的执行内核 agentcore。本篇聚焦一个具体问题:codebot 怎么把外部的 Model Context Protocol(MCP)服务器接进来,让模型除了内置的 read/write/bash,还能调用 GitHub、数据库、搜索这类"别人写好的远端工具"。做法是:远端的每个 MCP tool,最终都被包装成一个普通的 agentcore.Tool,内核完全不知道它来自网络另一端。
MCP 解决什么问题
内置工具是"焊死"在二进制里的:想加一个能查内部文档的工具,得改 agentcore 的代码、重新编译。MCP 把这件事外置成一个协议:外部进程(或 HTTP 服务)声明自己有哪些 tool、哪些 resource,codebot 作为客户端连上去、列出工具、按需调用。用户只要在配置里写一行,就多出一批工具,不用碰任何源码。
调用本身不难,难点在接线:多个 server 要并行连、连接会失败、server 运行中会临时增删工具(tools/list_changed)、这些远端工具还得和本地工具走同一套审批与提示词组装。codebot 把这些全收口在 internal/mcp 一个包里,对外只暴露一个 Manager。
一张图看清数据流
flowchart LR
subgraph codebot
Cfg["LoadAllMCPServers<br/>global + project 合并"]
Mgr["mcp.Manager"]
Sess["agent.Session"]
end
subgraph servers["MCP servers"]
S1["context7<br/>(stdio 子进程)"]
S2["github<br/>(http)"]
end
Cfg -->|"map[name]ServerConfig"| Mgr
Mgr -->|"StartAll 并行连接"| S1
Mgr -->|"StartAll 并行连接"| S2
S1 -->|"tools + instructions"| Mgr
S2 -->|"tools + instructions"| Mgr
Mgr -->|"NewMCPTool → mcp__server__tool"| Sess
Sess -->|"每轮 prompt 前刷新"| Sess
第一步:配置从哪来
internal/mcp/config.go 定义了单个 server 的配置,两种传输各一套字段:
// ServerConfig describes a single MCP server connection.
type ServerConfig struct {
Type string `json:"type,omitempty"` // "stdio" (default) or "http"
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
}
LoadAllMCPServers(cwd) 从两处读 settings.json 里的 mcp_servers 段并合并:全局 ~/.codebot/settings.json 先铺底,项目 <cwd>/.codebot/settings.json 再覆盖同名项:团队仓库钉死一批 server,个人配置补充自己的。配置里的 ${VAR} 会经 ExpandEnv / ExpandHeaders(同一个 envVarRe)展开,所以密钥写在环境变量里,不用明文进文件。
第二步:Manager 并行连接
Manager 管理一批 server 的生命周期。它的状态很紧凑(internal/mcp/manager.go):
type Manager struct {
mu sync.Mutex
clients map[string]*Client // name → live connection
failures map[string]string // name → error message
dirty atomic.Bool // set when a server signals tools/list_changed
}
StartAll 给每个 server 起一个 goroutine 去 Connect,用一个 buffered channel 收结果。关键是部分失败不拖累整体:某个 server 连不上,就把错误记进 failures,其余成功的照常进 clients:
func (m *Manager) StartAll(ctx context.Context, servers map[string]ServerConfig) []error {
ch := make(chan result, len(servers))
for name, cfg := range servers {
go func(name string, cfg ServerConfig) {
// onChange flips the dirty flag; wired straight into the client
c, err := Connect(ctx, name, cfg, func() { m.dirty.Store(true) })
ch <- result{name: name, client: c, err: err}
}(name, cfg)
}
// ... collect: successes into m.clients, failures into m.failures
}
注意传给 Connect 的那个回调:func() { m.dirty.Store(true) }。它就是后面"热刷新"的扳机:server 一旦通知工具列表变了,这个闭包被触发,dirty 被置位。
第三步:一条连接的真身
internal/mcp/client.go 的 Client 包一个 MCP session。Connect 按 cfg.Type 选传输:"http" 走 StreamableClientTransport(可注入自定义头),其余("stdio" 或空)走 CommandTransport,即 exec.Command(cfg.Command, cfg.Args...) 拉起一个子进程。
这里有个坑,源码注释点得很明白:SDK 用同一个 ctx 既做握手、又做整个生命周期的消息循环。codebot 于是单独造一个 session 级 ctx(只在 Close() 时取消),再用一个 30 秒 timer 从外部掐握手超时,把"连接慢"和"连上后长期存活"解绑:
sessionCtx, sessionCancel := context.WithCancel(ctx)
// ... cli.Connect runs in a goroutine, result on ch
select {
case <-timer.C: // handshake exceeded connectTimeout (30s)
sessionCancel()
return nil, fmt.Errorf("connect to %s: timeout after %v", name, connectTimeout)
case <-ctx.Done():
sessionCancel()
return nil, fmt.Errorf("connect to %s: %w", name, ctx.Err())
case r := <-ch: // connected; keep sessionCancel for Close()
c.session, c.cancel = r.session, sessionCancel
return c, nil
}
list_changed 的接收也在这层:若 onChange 非空,就给 client 装一个 ToolListChangedHandler,server 一推送通知,回调即被调用(也就是上面的 m.dirty.Store(true))。
::: tip 连接握手时 codebot 报的是自己的身份
NewClient 传的 ClientInfo{Name: "codebot", Version: "1.0.0"} 会在 MCP 握手里发给 server,server 端日志能看到是谁连的。server 回给你的 InitializeResult,就是下面“instructions 进上下文”的来源。
:::
第四步:把远端 tool 适配成 agentcore.Tool
这是整篇的枢纽。internal/mcp/adapter.go 的 MCPTool 实现了 agentcore.Tool 接口,对内核而言,它和 read、bash 没有任何区别:
func NewMCPTool(client *Client, tool protocol.Tool) *MCPTool {
return &MCPTool{
client: client,
tool: tool,
fullName: "mcp__" + client.Name() + "__" + tool.Name, // mcp__github__create_issue
}
}
命名规则 mcp__<server>__<tool> 是一切的锚点:全系列凡是要区分"本地工具 / 远端工具",都靠 config.IsMCPTool(name)(就是判 mcp__ 前缀)。它保证不同 server 的同名工具不会撞车,也让提示词组装、工具过滤、压缩策略都能靠前缀一眼认出 MCP 来源。
四个方法各司其职:
| 方法 | 干什么 | 关键点 |
| --- | --- | --- |
| Name() | 返回 fullName | 带 mcp__ 前缀的全限定名 |
| Schema() | 把 tool.InputSchema 转成 map[string]any | 空 schema 兜底成 {"type":"object"} |
| Execute() | client.CallTool 后抽出文本 | result.IsError 则当错误喂回模型 |
| PermissionMetadata() | 自报能力,供审批门判断 | KeyPrefix: "mcp" |
Execute 把远端返回的 CallToolResult 里所有 TextContent 拼成一段文本再 JSON 序列化,模型看到的就是一段字符串结果,看不见 MCP 协议细节。
值得单独说的是 PermissionMetadata:远端工具的"危险程度"不能全信 server 自报,codebot 自己推断能力:优先看 MCP 的 Annotations(DestructiveHint / OpenWorldHint / ReadOnlyHint),没有注解就退化成对工具名和描述做关键词匹配:
switch {
case containsAny(text, "search", "fetch", "browse", "http", ...):
return permission.CapabilityNetwork
case containsAny(text, "create", "update", "write", "delete", ...):
return permission.CapabilityWrite
case containsAny(text, "read", "list", "get", "query", ...):
return permission.CapabilityRead
default:
return permission.CapabilityUnknown
}
推断出的能力再决定审批门给不给放行、要不要人工确认。MCP 工具因此自动纳入了和本地工具同一套《权限与审批门》体系,没有另开后门。
第五步:list_changed 后热刷新
Manager 不主动轮询工具列表;它靠 dirty 这个 atomic.Bool 攒变更。真正拉取靠 RefreshIfDirty,一次 CompareAndSwap 同时完成"检查 + 复位",无锁且幂等:
func (m *Manager) RefreshIfDirty(ctx context.Context) ([]agentcore.Tool, bool) {
if !m.dirty.CompareAndSwap(true, false) {
return nil, false // nothing changed since last call
}
return m.Tools(ctx), true
}
Tools 遍历所有连接、对每个 client ListTools、逐个 NewMCPTool 包装。有个不起眼但关键的细节:它先经 sortedClients() 按 server 名排序再迭代。原因写在注释里:工具数组要喂进 LLM 请求,若按 map 的随机序输出,每次刷新字节顺序都变,会从前缀开始击穿 provider 的 prompt cache;排序让相同工具集产生稳定字节,缓存才留得住。
那么"什么时候调 RefreshIfDirty"?答案是每轮 prompt 之前。internal/bootstrap/assemble_runtime.go 把刷新逻辑挂到 session 的 before-prompt 钩子上:
if services.mcpManager != nil {
session.SetBeforePrompt(func() {
mcpTools, ok := services.mcpManager.RefreshIfDirty(context.Background())
if !ok {
return // clean; keep the current tool set
}
session.ReplaceMCPTools(mcpTools)
session.SetMCPInstructions(strings.Join(services.mcpManager.Instructions(), "\n\n"))
})
}
Session.Prompt 在真正发起一轮前会调 s.beforePrompt()(见 internal/agent/session_runtime.go)。于是每一轮都自检一次:脏了就换工具、刷新 instructions;不脏就零成本跳过。这条设计还顺带解决了并发安全:session 的提示词状态不是 goroutine-safe 的,所以 ConnectMCP 那个后台连接故意不碰 session,只 MarkDirty(),让工具从"唯一的写入者"(before-prompt 钩子)进 session。
sequenceDiagram
participant Server as MCP server
participant Client as mcp.Client
participant Mgr as Manager
participant Sess as Session
Server-->>Client: notifications/tools/list_changed
Client->>Mgr: onChange() → dirty.Store(true)
Note over Sess: 用户发下一条 prompt
Sess->>Mgr: RefreshIfDirty()
Mgr->>Server: ListTools(排序后)
Mgr-->>Sess: []agentcore.Tool
Sess->>Sess: ReplaceMCPTools + SetMCPInstructions
第六步:把 MCP 工具替换进工具集
session.ReplaceMCPTools 委托给 internal/agent/session_prompt.go 的 replaceMCPTools。它要处理一个微妙情形:session 里有 allTools(全集)和 activeTools(当前实际给模型的,可能被 plan 模式等过滤过)两份列表:
func (m *sessionPromptManager) replaceMCPTools(tools []agentcore.Tool) {
wasAllTools := sameToolSet(m.session.activeTools, m.session.allTools)
activeHasMCP := hasMCPTools(m.session.activeTools)
m.session.allTools = replaceMCPToolsInSlice(m.session.allTools, tools)
switch {
case wasAllTools: // active mirrors all → keep mirroring
m.session.activeTools = m.session.allTools
case activeHasMCP: // filtered set that had MCP → swap MCP in place
m.session.activeTools = replaceMCPToolsInSlice(m.session.activeTools, tools)
default: // active deliberately excludes MCP → leave it alone
return
}
m.session.agent.SetTools(m.session.activeTools...)
m.rebuildPrompt()
}
replaceMCPToolsInSlice 的动作很直白:把旧的 mcp__* 全剔掉,再把新一批追加进去,本地工具原样保留。这就是"每次 prompt 前把 MCP 工具替换进工具集"的字面含义:远端工具是可整体换掉的一层,本地工具则纹丝不动。
第七步:server instructions 怎么进上下文
很多 MCP server 在握手时会附一段使用说明(InitializeResult.Instructions),告诉模型"我这些工具该怎么配合用"。Client.Instructions() 取出它,Manager.Instructions() 把各 server 的说明按 ## <server 名> 分节拼起来(同样走排序,保证顺序稳定)。
这段文本不进"冻结"的系统提示词,落在动态块里。config.BuildDynamicSystemPart 把 MCP 工具目录和 overlay 文本一起组装:
// ## MCP Tools
// You have N additional tools from MCP servers:
// - **mcp__github__create_issue**: Create a new issue
// ...
rebuildPrompt 把系统提示词切成三块:identity(冻结)/ instructions(冻结)/ dynamic(不缓存)。MCP 工具目录和 server instructions 都落在第三块 dynamic 里,因为它们会在运行时增删。这样安排的道理和《上下文管理》一脉相承:前两块打上 ephemeral 缓存断点长期复用,第三块故意不设断点:MCP 一刷新它就变,若给它上缓存反而要为随即失效的写入多付 1.25x。易变的东西单独放一块,是这套提示词布局的中心思想。
运行时观测与重载
/mcp(internal/ui/commands/basics.go):调 Manager.Status(ctx) 列出每个 server 是连上了、连上但 ListTools 失败、还是压根没连上,附工具数汇总。Status 里的 ListTools 并行发起且不持锁做网络 I/O,一个卡住的 server 不拖垮命令。
- 重载 / 启动:改配置走
Manager.Reconfigure(关旧连接、清 failures、StartAll 新集合、MarkDirty);TUI 冷启动时 ConnectMCP 在后台 goroutine 里跑、界面先转圈,连完 MarkDirty,第一轮 prompt 时工具自然入场,握手不阻塞界面。
三问回顾
- MCP 集成到底解决什么? 让"给 agent 加一个外部工具"从"改源码重编译"变成"配置里写一行":远端工具与本地工具共用同一套执行、审批、提示词管线。
- 枢纽是哪个抽象?
MCPTool 实现 agentcore.Tool,用 mcp__<server>__<tool> 命名。内核对"网络另一端"一无所知,只看到又一个普通工具。
- 热刷新为什么不出乱子? 变更只置一个
atomic.Bool,真正拉取集中在每轮 prompt 前的唯一写入者(before-prompt 钩子)里,用 CompareAndSwap 做幂等自检;排序输出还顺手保住了 prompt cache。
延伸阅读