跳到主要内容

万物皆 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/listtools/callresources/readprompts/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、跑 hookpkg/tools/service.go
内置 server 工厂把 8 个内部能力注册成 MCP server,惰性实例化pkg/runtime/runtime.go:124-165
agent server把单个 agent 包成一个带 chat 工具的 MCP serverpkg/servers/agent/agent.go
hook 机制匹配 → 把 hook 变成对某工具的 Call → 回填 structured contentpkg/mcp/hooks.go + pkg/tools/service.go:625 (RunHook)
host(HTTP)对外把整台 nanobot 暴露成 MCP over HTTP,管 sessionpkg/server/server.gopkg/mcp/httpserver.go
UI 反向代理嵌入的前端静态资源 / 转发到 dev serverpkg/session/ui.go(前端源码 packages/ui)

主线走一遍(高层):一条 HTTP MCP 请求进来 → httpserver.go 认领/新建 session → server.gotools/call 路由到 tools.Service.CallCallGetClient(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:124-165)。真源码:

registry.AddServer("nanobot.meta", func(string) mcp.MessageHandler {
return meta.NewServer(sessiondata.NewData(r), opt.ConfigDir)
})

AddServer 只是把这个工厂存进一张 map(pkg/tools/service.go:116-121,serverFactories);它不立刻构造 server。这就是"惰性实例化":工厂只有等到第一次真被用到时才跑。

注册的这一批(pkg/runtime/runtime.go:124-165):

server 名职责工厂
nanobot.meta元信息/自省:列 chat、更新 chat、列 agentmeta.NewServer
nanobot.agent某个 agent 包成 MCP server(见 3.2)agent.NewServer
nanobot.system系统信息(默认模型、配置目录)system.NewServer
nanobot.workflows / nanobot.workflow-toolsworkflow 定义与执行workflows.NewServer / NewToolsServer
nanobot.artifacts产物存取artifacts.NewServer
nanobot.skillsskill 管理skills.NewServer
nanobot.obot-mcp-cliobot 平台 CLI 桥obotmcp.NewServer
nanobot.tasks定时/异步任务(仅当配了 LoopbackURL + Store)复用同一个 taskServer 实例

惰性化发生在哪: 当有人 GetClient(ctx, name) 时,newClient 先查 serverFactories[name];命中了,才调用工厂造出 handler,并用 NewExistingServerSession 把这个内部 handler 包成一个进程内的 Wire,当成 client 的传输层(pkg/tools/service.go:342-362):

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。上层 CallListTools、映射逻辑完全不用区分内外——第 3 章那套代码零改动地复用。nanobot.tasks 还展示了另一种玩法:它的工厂每次返回同一个 taskServer 单例(pkg/runtime/runtime.go:162-164),因为它带持久状态,不能每次重造。

tasks 只在 opt.LoopbackURL != "" && opt.Store != nil 时注册(pkg/runtime/runtime.go:156)——LoopbackURL 通常是 http://<addr>/mcp/chat(pkg/cli/serve.go:145),也就是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-4677-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:888-912)。

3.3 Call 的分流:同一个入口,内部按 target 类型分派

上面反复出现 tools.Service.Call。它是唯一的调用入口,内部才分流(pkg/tools/service.go:658-795)。三条岔路:

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:685-687767)。是 agent 且不是在调它自己的 chat 工具,就转 sampleCall(service.go:768)进 sampling/agent 循环;否则一律 GetClient + c.Call(service.go:773-785)。上层永远只看见一个 Call

顺带一提,Call 还统一负责发 notifications/progress 进度事件(service.go:689-765)——不管被调的是内置、外部还是 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

每个 target 是一个字符串,支持 !mutate: 前缀(pkg/mcp/hooks.go:103-111):

h.Target, h.MutateDisallowed = strings.CutPrefix(target, "!mutate:")

!mutate: 前缀 = "这个 hook 只能观察,不许改动"(MutateDisallowed=true)。前缀被剥掉后,剩下的 Target 就是一个 server/tool 引用——和普通工具引用同一种写法。

4.3 匹配与调用:InvokeHooks → RunHook → Call

触发点长这样(以 session 初始化 hook 为例,pkg/server/server.go:374):

sessionInit, err := mcp.InvokeHooks(ctx, s.runtime, c.Hooks, &sessionInit, "session", nil)

InvokeHooks(pkg/mcp/hooks.go:59-90)遍历所有 hook 映射,用 mapping.Matches(name, params) 挑出该触发的(hooks.go:142-152;* 通配任意 name),对每个 target 调 HookRunner.RunHook。它还把上一个 hook 的输出串成下一个 hook 的输入(current = &out,hooks.go:80-82)——形成一条可级联的处理链。

HookRunner 是个接口(pkg/mcp/hooks.go:14-16),真正实现它的是 tools.Service.RunHook(pkg/tools/service.go:625-656)。这就是**"hook 变成工具调用"落地的那一行**:

func (s *Service) RunHook(ctx context.Context, in, out any, target string) (hasOutput bool, _ error) {
server, tool, _ := strings.Cut(target, "/") // "server/tool"
result, err := s.Call(ctx, server, tool, in) // ← 就是一次普通 Call!
...
if result.StructuredContent != nil { // 把工具返回的 structured content
b, _ := json.Marshal(result.StructuredContent)
json.Unmarshal(b, &out) // 回填成 hook 的输出
return true, nil
}
return false, nil
}

三个要点:

  1. target 被切成 server/tool,直接喂给 s.Call hook 走的是和其它所有调用同一个 Call(4.3 那张分流图),所以 hook 也能是内置 server、外部 server、或 agent。
  2. 回填靠 structured content。 工具返回的 StructuredContent 反序列化进 out,就成了"被修改后的 config/request/response"。工具没返回 structured content(return false)= 这个 hook 没改东西,InvokeHooks 会保留原值。
  3. 工具报错就是 hook 报错(service.go:632-641)——hook 可以用"返回 error"来拒绝一次请求(比如策略校验不通过)。

4.4 config / request / response 三种 hook 都是这一套

配置里常见的三类 hook——改配置、改请求、改响应——在实现上没有区别,全都是"匹配 → Call → structured content 回填":

hook 类型输入(in)输出(out)典型用途
config hook配置对象改过的配置动态注入 model、按环境改 agent
request hook出站请求改过的请求脱敏、注入 header、策略拦截
response hook工具/模型响应改过的响应过滤输出、加水印

因为都走 Call,你可以用任意 MCP server 承载任意一类 hook——包括一段 JS。这正是"用 JS 写 hook"能成立的底层原因:JS 只是又一个 MCP server。

4.5 mutation 的可观测性:改了就留痕

当 hook 真的改动了 request/response,nanobot 会在返回内容里追加一条说明,让人/agent 看得见"这里被 hook 动过手脚"。addHookMutationContent(pkg/tools/service.go:797-825)从响应 Meta[HookMutationsMetaKey](ai.nanobot.hooks/mutations,pkg/mcp/session.go:19)读出 mutation 记录,把 request 的说明前插response 的说明后插Content:

if mutation := mutations["request"]; mutation.Mutated {
response.Content = append([]mcp.Content{hookMutationContent("request", mutation.Reasons)}, response.Content...)
}

生成的文本形如 "MCP request was mutated by hooks. Reasons: ..."(service.go:819-824)。这条设计保证了"隐形改写"不会真的隐形。

4.6 高层 flow 封装:让脚本也能开一次工具调用

pkg/tools/flows.go 是给 hook / 表达式脚本用的一层门面。newGlobals(flows.go:33-78)往脚本环境里塞了几个全局函数,其中 call 又是对 Call 的包装(flows.go:41-4580-87):

data["call"] = func(target string, args map[string]any) (map[string]any, error) {
return s.callFromScript(ctx, target, args, ...) // 内部还是 s.Call
}

于是 hook 里的一段 JS 也能 call("some-server/some-tool", {...}) 主动发起 MCP 调用;toOutput(flows.go:89-111)负责把 CallResult 摊平成脚本好用的 {content, isError, structuredContent, output}脚本、hook、agent、工具——最终都汇到同一个 Call


5. host 反身:对外也是 MCP,连 UI 都走 MCP

前面讲的是"对内"。最后一层反身是**"对外"**:nanobot 把整台自己也暴露成一个 MCP server。

5.1 HTTP 上的 MCP 协议

pkg/mcp/httpserver.go 把 MCP 的 JSON-RPC 架在 HTTP 上。serveHTTP(httpserver.go:277)按 HTTP 方法分派:

HTTP 方法语义
POST发一条 MCP 消息(initialize / tools/call / …)
GET打开 SSE 事件流,订阅这个 session 的异步消息(streamEvents,httpserver.go:177)
DELETE删除 session(httpserver.go:330-347)

session 头是关键:第一次 initialize 后,server 在响应里回一个 Mcp-Session-Id 头(httpserver.go:519),之后所有请求靠它认领同一个 session(ExtractIDsessions.Acquire,httpserver.go:281408-420)。SSE 流(GET)则按 session id 订阅广播,把后台产生的进度/通知推给这一个客户端(httpserver.go:228-245)。

进来的消息交给 MessageHandler,也就是 pkg/server/server.goServer.OnMessage(server.go:470)。它就是一张 handler 表(server.go:74-91),把 tools/call 等路由到 runtime.Call(server.go:200-252)——又回到那个唯一的 Call。对外的 tools/call 和对内的调用,殊途同归。

server.go 还在这里挂 host 级 hook:initialize 时跑 session hook(runInitHook,server.go:358-381),env 缺失时按 MCP 错误码返回缺哪些变量(reconcileEnv,server.go:295-330)。

5.2 UI 反向代理:前端也只跟 MCP 说话

nanobot 自带一个 Svelte 前端(源码 packages/ui),它不用私有 REST,而是走两个特殊 MCP 端点:/mcp/ui/mcp/chat(server.go:353-354 用路径区分 UI/chat 会话)。前端读历史、追进度、答 elicitation,全靠 3.2 里那几个 MCP 资源。

静态资源与代理逻辑在 pkg/session/ui.goUISession(ui.go:19-118)。它按请求分流:

请求进来
├─ /browser* → browserProxy(内置浏览器代理)
├─ 非浏览器 UA → 直接给下游 MCP handler(纯 MCP 客户端)
├─ /mcp* → MCP handler(ui.go:89-92)
├─ /api* → api handler(ui.go:94-97)
└─ 其它路径(浏览器) → 前端资源:
├─ 内嵌 dist 里有该文件 → 直接发(immutable 走 gzip+长缓存)
└─ 没有内嵌构建产物 → 反向代理到本地 dev server :5173(ui.go:113-116)

前端构建产物用 //go:embed all:*dist 嵌进二进制(packages/ui/fs.go),UISessionui.FSdist 子目录取文件(ui.go:99)。开发时没有 dist,就 httputil.NewSingleHostReverseProxy 反代到 localhost:5173(ui.go:114-115)——这就是本章标题里说的"UI 反向代理"。

提醒:pkg/reverseproxy/(server.go/tlsclient.go)是另一码事——它是给沙箱化 MCP server 做 mTLS 反向端口的(pkg/mcp/sandbox/reverseports.go:24),不是 UI 代理。UI 的反代是上面 ui.go 里那一句标准库调用。

5.3 组装:一个 mux 把这些拼起来

启动路径把上面几层拼成一个 http.Server(pkg/cli/root.go 附近):NewHTTPServer 造 MCP host,再用 session.UISession(...) 包一层 UI/代理,套 auth.Wrap,最后 otelhttp 包一层 tracing(/mcp/chat/mcp/ui、healthz 被排除出 trace)。整条链路从头到尾说的都是 MCP。


6. 巧妙之处(可借鉴的技术)

  • 一种抽象打通全场。 内部能力、agent、hook、host、UI,全部归约到"MCP server + 一次 Call"。写一次连接/映射/调用代码(第 3 章),五处复用。—— 反身式设计的最大红利。(pkg/tools/service.go:658)
  • 进程内 Wire 抹平内外差异。NewExistingServerSession 把一个内部 OnMessage handler 伪装成 client 的传输线路,于是"内置 server"和"外部 server"对上层无差别。(pkg/tools/service.go:356-362)
  • server 工厂惰性实例化。 AddServer 只存工厂,GetClient 第一次用到才构造;有状态的(nanobot.tasks)则返回单例。既省启动开销,又让有状态/无状态的 server 用同一接口注册。(pkg/runtime/runtime.go:124-165pkg/tools/service.go:342)
  • hook = 工具调用 + structured content 回填。 不发明 hook 运行时;RunHook 把 target 切成 server/tool 直接 Call,返回的 structured content 反序列化成新值。因此任意 MCP server(含 JS)都能当 hook。(pkg/tools/service.go:625-656)
  • !mutate: 前缀 + mutation 留痕。 用一个字符串前缀声明"只读 hook";改动了就在输出里追加说明,隐形改写不隐形。(pkg/mcp/hooks.go:109pkg/tools/service.go:797)
  • 运行时状态建模成 MCP 资源。 历史/进度/elicitation 做成 resources/read + 订阅,UI 因此无需私有 API。(pkg/servers/agent/agent.go:319-337)

7. 边界与局限

  • 循环依赖是明说的代价。 runtime 里 registry.SetSampler(sampler) 上方有一句自嘲注释 // This is a circular dependency. Oh well, so much for good design.(pkg/runtime/runtime.go:115-116)。反身式设计让 tools ↔ sampler ↔ agents 互相引用,只能事后回填打破。
  • hook 回填只认 structured content。 若一个 hook server 只返回文本 content、不给 StructuredContent,RunHook 就当它"没输出"(hasOutput=false),保留原值(pkg/tools/service.go:643-655)。想改东西的 hook 必须返回结构化结果。
  • nanobot.tasks 有前置条件。 没配 LoopbackURL + Store 就根本不注册(pkg/runtime/runtime.go:156),相关能力静默缺失。
  • UI dev 反代目标写死。 无内嵌 dist 时固定反代 http://localhost:5173(pkg/session/ui.go:114),端口不可配。
  • pkg/session/ui.go 里大段 cookie/session 逻辑被注释掉(ui.go:31-87),说明 UI 会话身份这块仍在演进中。

8. 代码地图(导航索引)

主题文件路径符号
注册内置 server(8 个工厂)pkg/runtime/runtime.goNewRuntime(AddServer 调用群)
工厂存表(不立即构造)pkg/tools/service.goAddServer / serverFactories
内置 server → 进程内 Wirepkg/tools/service.gonewClient / NewExistingServerSession
唯一调用入口与分流pkg/tools/service.goCall
hook 变工具调用pkg/tools/service.goRunHook
mutation 留痕pkg/tools/service.goaddHookMutationContent / hookMutationContent
脚本/flow 门面pkg/tools/flows.gonewGlobals / callFromScript / toOutput
hook 匹配与级联pkg/mcp/hooks.goInvokeHooks / HookMapping.Matches / HookTarget.UnmarshalJSON(!mutate:)
"成为 MCP server"的接口pkg/mcp/session.goMessageHandler
agent 包成 MCP serverpkg/servers/agent/agent.goServer / OnMessage / NewServer
chat 工具 = 一次 agent Callpkg/servers/agent/chat_call.gochatCall / Invoke / chatInvoke / appendProgress
attachments / 自动起标题pkg/servers/agent/chat_call_ui.goinlineAttachments / describeSession
elicitation 走 MCPpkg/servers/agent/elicitation.goExchangeElicitation
host:HTTP 上的 MCPpkg/mcp/httpserver.goHTTPServer.serveHTTP / streamEvents(Mcp-Session-Id)
host:方法路由到 Callpkg/server/server.goServer.OnMessage / handleCallTool / runInitHook
UI 反向代理 / 内嵌 distpkg/session/ui.goUISession
前端源码 / embedpackages/uiFS(packages/ui/fs.go)

相关章节: MCP 层与外部 server 连接见 03-tools-and-mcp;agent 循环(chat 工具最终跑进的地方)见 02-agent-loop;多模型 sampling 见 04-llm-dialects-and-sampling;配置里怎么声明 agent/hook 见 01-config-and-agents;全景与阅读地图见 index