数据截至 (上游 commit e8fd37796aca)
万物皆 MCP:内置 server、hook、host 反身式设计
30 秒导读: nanobot 是一个 MCP host(见 index)。本章讲它最精妙的一个决定:它连自己的内部能力也不特殊对待——全部包成 MCP server,全部用 MCP 工具调用来触发。agent 是一个带
chat工具的 MCP server;hook 是对某个 MCP 工具的一次Call;host 对外也是 MCP over HTTP。整套系统因此只需要一种抽象。
1. 这是什么(零基础也能懂)
先说清楚一个词。MCP(Model Context Protocol,模型上下文协议):一套标准 JSON-RPC 消息,让"用工具的一方(host/client)"和"提供工具的一方(server)"用统一的 tools/list、tools/call、resources/read、prompts/get 对话。前面几章讲的都是 nanobot 怎么连外部 MCP server(见 03-tools-and-mcp)。
本章讲的是反过来的一招:nanobot 把自己的东西也塞进同一套协议。
一句话直觉——"dogfooding 到底":
- 别的框架里,"内置功能""agent 循环""插件 hook""HTTP 接口"通常是四套各写各的代码。
- nanobot 里,它们是同一样东西的四个位置:都是 MCP server / MCP 工具调用。写外部 server 的那套连接、映射、调用代码(第 3 章),原封不动就能跑内置 server、跑 agent、跑 hook。
这带来一个很爽的性质:能力可以互相替换。因为 agent 是个 MCP server,一个 agent 可以把另一个 agent 当工具用;因为 hook 是个工具调用,你可以用任何 MCP server(甚至一段 JS)去实现一个 hook。
本节不深入代码。记住一句话就行:在 nanobot 里,"内部"和"外部"、"能力"和"协议"之间没有围墙——万物皆 MCP。
2. 顶层全景(反身式设计怎么闭合)
这张图是本章的骨架。看点:右边那一圈(agent、内置 server、hook、UI)本来是"内部实现",但它们全都通过中间同一条 MCP 通道被调用。
怎么读:中间 tools.Service 是唯一的调度中心(第 3 章的主角);左边是外部世界通过 HTTP 进来;右边是被同一套 Call / GetClient 触发的四类"内部能力"。
外部世界 唯一调度中心 "内部" = 也是 MCP
┌────────────────┐ ┌──────────────── ─────┐ ┌──────────────────────────┐
│ MCP client / │ HTTP │ │ 工厂 │ 内置 server(惰性实例化) │
│ 浏览器 UI │─MCP────▶│ tools.Service │──────▶│ nanobot.meta / .agent / │
│ (Mcp-Session-Id)│ │ ─ GetClient(name) │ │ .system / .workflows / │
└────────────────┘ │ ─ Call(srv,tool) │ │ .artifacts / .skills / │
│ │ ─ RunHook(target) │ │ .tasks / obot-mcp-cli │
│ /mcp/ui │ │ └──────────────────────────┘
▼ │ 同一套连接/映射/调用 │ ┌──────────────────────────┐
┌────────────────┐ │ (对内对外无差别) │───────▶│ agent = 带 chat 工具的 │
│ UI 反向代理 │ │ │ │ MCP server(可互相调用) │
│ (embed dist / │ └─────────┬───────────┘ └──────────────────────────┘
│ proxy :5173) │ │ RunHook ┌──────────────────────────┐
└────────────────┘ └──────────────────────▶│ hook = 一次工具 Call │
│ (任意 MCP server / JS) │
└──────────────────────────┘
各部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
tools.Service | 唯一调度中心:连 server、建映射、发 Call、跑 hook | pkg/tools/service.go |
| 内置 server 工厂 | 把 8 个内部能力注册成 MCP server,惰性实例化 | pkg/runtime/runtime.go:127-168 |
| agent server | 把单个 agent 包成一个带 chat 工具的 MCP server | pkg/servers/agent/agent.go |
| hook 机制 | 匹配 → 把 hook 变成对某工具的 Call → 回填 structured content | pkg/mcp/hooks.go + pkg/tools/service.go:671 (RunHook) |
| host(HTTP) | 对外把整台 nanobot 暴露成 MCP over HTTP,管 session | pkg/server/server.go、pkg/mcp/httpserver.go |
| UI/浏览器代理 | /browser websocket 代理 + /api 分流,其余交给 MCP host | pkg/session/browser.go |
主线走一遍(高层):一条 HTTP MCP 请求进来 → httpserver.go 认领/新建 session → server.go 把 tools/call 路由到 tools.Service.Call → Call 用 GetClient(name) 拿到目标 client。关键在这里:目标可能是外部 server,也可能是某个内置 server 工厂、或某个 agent——Call 一视同仁。 沿途每次 Call 前后还会跑 hook,而 hook 本身又是一次 Call。闭合了。
3. 核心原理(逐个机制,由浅入深)
3.1 内置 server:自身能力也做成 MCP server,还惰性实例化
要解决的小问题: nanobot 有一堆"自带功能"——列聊天记录、跑 workflow、存 artifact、管 skill、跑定时任务……这些代码要怎么让 agent 用到?
思路: 不发明新接口。把每样功能写成一个实现了 mcp.MessageHandler 的 server,注册进和外部 server 同一张表。 这样 agent 引用它们的方式,和引用 github 这种外部 MCP server 一模一样。
mcp.MessageHandler 就一个方法(pkg/mcp/session.go:21):
// 示意,非源码 —— 这就是"成为一个 MCP server"的全部门槛
type MessageHandler interface {
OnMessage(ctx context.Context, msg Message) // 收到一条 MCP 消息就处理它
}
注册在哪: NewRuntime 里连着调 8 次 AddServer,每个都带一个 factory func(name string) mcp.MessageHandler(pkg/runtime/runtime.go:127-168)。真源码:
registry.AddServer("nanobot.meta", func(string) mcp.MessageHandler {
return meta.NewServer(sessiondata.NewData(r), opt.ConfigDir)
})
AddServer 只是把这个工厂存进一张 map(pkg/tools/service.go:121-126,serverFactories);它不立刻构造 server。这就是"惰性实例化":工厂只有等到第一次真被用到时才跑。
注册的这一批(pkg/runtime/runtime.go:127-168):
| server 名 | 职责 | 工厂 |
|---|---|---|
nanobot.meta | 元信息/自省:列 chat、更新 chat、列 agent | meta.NewServer |
nanobot.agent | 把某个 agent 包成 MCP server(见 3.2) | agent.NewServer |
nanobot.system | 系统信息(默认模型、配置目录) | system.NewServer |
nanobot.workflows / nanobot.workflow-tools | workflow 定义与执行 | workflows.NewServer / NewToolsServer |
nanobot.artifacts | 产物存取 | artifacts.NewServer |
nanobot.skills | skill 管理 | skills.NewServer |
nanobot.obot-mcp-cli | obot 平台 CLI 桥 | obotmcp.NewServer |
nanobot.tasks | 定时/异步任务(仅当配了 LoopbackURL + Store) | 复用同一个 taskServer 实例 |
惰性化发生在哪: 当有人 GetClient(ctx, name) 时,newClient 先查 serverFactories[name];命中了,才调用工厂造出 handler,并用 NewExistingServerSession 把这个内部 handler 包成一个进程内的 Wire,当成 client 的传输层(pkg/tools/service.go:377-397):
serverFactory, ok = s.serverFactories[name] // 先看是不是内置 server
...
if serverFactory != nil {
serverSession, err := mcp.NewExistingServerSession(
session.Context(), mcp.SessionState{}, serverFactory(name)) // 此刻才实例化
wire = serverSession // 内部 handler 直接当 client 的"线路"
}
妙在哪: 外部 server 走 stdio/HTTP 真传输;内部 server 走这个"假线路",两端都是 mcp.Client。上层 Call、ListTools、映射逻辑完全不用区分内外——第 3 章那套代码零改动地复用。nanobot.tasks 还展示了另一种玩法:它的工厂每次返回同一个 taskServer 单例(pkg/runtime/runtime.go:165-167),因为它带持久状态,不能每次重造。
tasks 只在 opt.LoopbackURL != "" && opt.Store != nil 时注册(pkg/runtime/runtime.go:159)——LoopbackURL 通常是 http://<addr>/mcp/chat(pkg/cli/serve.go:152),也就是nanobot 把自己的 HTTP 地址回环回来,让 task server 反过来当 nanobot 的一个 MCP client。反身到家了。
3.2 agent 也是 MCP server:一个 agent = 一个带 chat 工具的 server
要解决的小问题: agent 循环(见 02-agent-loop)是个复杂东西。怎么让"调一个 agent"和"调一个工具"看起来一样,好让 agent 之间能互相编排?
思路: 把每个 agent 包成一个 MCP server,这个 server 只暴露一个工具:chat-with-<agentName>。于是"和 agent 对话"就退化成"调它那个 chat 工具"。
工具长这样(pkg/servers/agent/chat_call.go:20-26):工具名是常量 types.AgentTool + agentName,即 "chat-with-" 前缀(pkg/types/chat.go:11),输入 schema 是固定的 types.ChatInputSchema——一个 {prompt, attachments} 对象(pkg/types/chat.go:17)。
server 的 OnMessage 就是一张 MCP 路由表(pkg/servers/agent/agent.go:58-104)。它把标准 MCP 方法一一接住:
| MCP 方法 | agent server 做什么 |
|---|---|
initialize | 声明支持 tools/prompts/resources,并异步预热工具映射(agent.go:341) |
tools/list / tools/call | 交给 s.tools(只有一个 chat 工具)(agent.go:66-70) |
resources/list / resources/read | 把聊天历史、流式进度、待处理 elicitation当成 MCP 资源暴露(agent.go:305-337) |
prompts/list / prompts/get | 转发 agent 配置里挂的 prompt(agent.go:200-232) |
注意 resources 这一手很巧:agent 把三样运行时状态建模成 MCP 资源(pkg/servers/agent/agent.go:319-337):
types.HistoryURI→ 聊天历史types.ProgressURI→ 当前这轮的流式输出(边生成边更新)types.ElicitationURI→ 待用户回答的问题
于是前端 UI 不需要私有 API——它就用标准 resources/read + resources/subscribe 去读进度、拿历史。UI 和 agent 之间也只有 MCP。
chat 工具怎么真跑一次对话(pkg/servers/agent/chat_call.go:189-272):
Invoke先处理 attachments、判断是否async。异步模式下,它立刻返回一个指向ProgressURI资源的链接,真正的对话丢进session.Go后台跑(chat_call.go:200-222)——客户端之后用 resources 订阅追进度。- 同步模式
chatInvoke里,核心就一句runtime.Call(ctx, agentName, agentName, ...)(chat_call.go:241)——agent server 处理 chat 工具的方式,又是发一次Call。而Call看到 target 是个 agent,会走sampleCall进真正的 agent 循环(见 3.3 与第 2 章)。 - 全程它注册了一个 session filter
appendProgress(chat_call.go:233-235),把底层每条notifications/progress累积进ProgressURI资源,并发notifications/resources/updated通知订阅者(chat_call.go:44-46、77-187)。这就是"流式 token 一边生成一边出现在 UI"的实现。
elicitation(向用户提问)也走 MCP(pkg/servers/agent/elicitation.go:43-64):agent 想问用户问题时,发标准的 elicitation/create 消息给 root session,同时把它记进 ElicitationURI 资源,好让 UI 能读到"当前有个待回答的问题"。
妙在哪: 因为 agent 是 server、chat 是工具,agent 编排 agent 是免费的——一个 agent 的配置里把另一个 agent 名字列进 mcpServers/agents,它就能像调工具一样调子 agent。ListTools 里可以看到这种对称:agent 会被合成出一个 chat-with-* 工具条目混进工具列表(pkg/tools/service.go:934-958)。
3.3 Call 的分流:同一个入口,内部按 target 类型分派
上面反复出现 tools.Service.Call。它是唯一的调用入口,内部才分流(pkg/tools/service.go:704-841)。三条岔路:
Call(ctx, server, tool, args)
│
├─ server 是 agent 且 tool 不是它自己的 chat 工具?
│ └─▶ sampleCall(...) → 进 agent 循环(第 2 章)
│
└─ 否则:GetClient(server).Call(tool, args)
├─ server 是内置工厂 → 进程内 Wire(3.1)
└─ server 是外部配置 → 真 stdio/HTTP 传输(第 3 章)
判断 target 是不是 agent,就查 config.Agents[server](pkg/tools/service.go:731-733、767)。是 agent 且不是在调它自己的 chat 工具,就转 sampleCall(service.go:814)进 sampling/agent 循环;否则一律 GetClient + c.Call(service.go:819-831)。上层永远只看见一个 Call。
顺带一提,Call 还统一负责发 notifications/progress 进度事件(service.go:735-811)——不管被调的是内置、外部还是 agent,进度上报的格式都一样。
4. 深入实现:hook 本质就是一次 MCP 工具调用
这是本章最精妙的一处,值得单独拆开讲。
4.1 直觉:别为 hook 发明新运行时
很多框架的 hook 是"注册一个回调函数"。nanobot 反问:既然一切都是 MCP 工具,hook 为什么不能就是"对某个工具的一次调用"? 于是:
一个 hook = 一次
Call(server, tool, in) → out。 输入是被 hook 的那个东西(config / request / response),输出是(可能被修改过的)同一个东西。
因为它就是普通工具调用,任何 MCP server 都能当 hook 的承载体——包括一个用 goja 跑的 JS 实现的 server(README 里提到的 hooks.ts 机制)。你不需要用 Go 写 hook。
4.2 配置形态:name?params → 一串 target
hook 在配置里是一个 map:键是"什么时候触发"(hook 名 + 可选查询参数),值是"触发时调哪些 target"。反序列化逻辑在 Hooks.UnmarshalJSON(pkg/mcp/hooks.go:20-42):键 parseHookDefinition 解析成 name + params(用 ?a=b 查询串语法,hooks.go:121-138),值是一串 HookTarget。