第 7 章 · 01 两层扩展与 MCP 插件客户端 本节摘要:本节进入 Reasonix 的扩展体系。SPEC §1.4 定义了两层扩展:第一层是编译期 built-in(Go 包,通过 自注册进默认 registry),与 Reasonix 主二进制同进程;第二层是运行时外部插件(stdio JSON-RPC 子进程,MCP 兼容)。本节聚焦第二层里的 MCP 客户端 :它连接外部 MCP 服务器,把远程的 tools/prompts/resources 适配成本地的 接口,让 agent 把插件工具和 built-in 一视同仁。线协议是 JSON-RPC 2.
本节摘要:本节进入 Reasonix 的扩展体系。SPEC §1.4 定义了两层扩展:第一层是编译期 built-in(Go 包,通过
init()自注册进默认 registry),与 Reasonix 主二进制同进程;第二层是运行时外部插件(stdio JSON-RPC 子进程,MCP 兼容)。本节聚焦第二层里的 MCP 客户端internal/plugin:它连接外部 MCP 服务器,把远程的 tools/prompts/resources 适配成本地的tool.Tool接口,让 agent 把插件工具和 built-in 一视同仁。线协议是 JSON-RPC 2.0,传输层有三种(stdio 子进程 / Streamable HTTP / legacy HTTP+SSE),用 transport 接口屏蔽差异,让 MCP 级逻辑(handshake / tools/list / tools/call)只写一遍。MCP 服务器在 config 的[[plugin.servers]]声明,贡献的工具以mcp__<server>__<tool>命名。cmd/reasonix-plugin-example是参考的 MCP stdio 插件示例。本节还会点明:插件贡献的工具会进入 tool schema,进而影响第 6 章讲的 cache-stable 前缀——这是扩展体系与 Cache-first 契约的直接交汇。
内容来源:原项目源码
internal/plugin/(plugin.go、transport_stdio.go、transport_http.go、transport_sse.go)、cmd/reasonix-plugin-example/main.go、docs/SPEC.md§1.4/§3.3,精读并套用体系化模板。
⚠️ 注意:Reasonix 的"MCP 客户端"指的是 Reasonix 作为客户端去连接外部 MCP 服务器(服务器是别人写的子进程)。别把方向搞反——Reasonix 不在这里"提供"MCP 服务,它"消费"MCP 服务。另外,MCP 只是两层扩展中的一种(运行时外部插件里的"只贡献工具"那种);下一节的 Extension Protocol v1 sidecar 更强(能拦截事件、贡献 Provider、提供 UI)。
阅读完本节,你应当能够:
init() 自注册)与运行时外部插件(stdio JSON-RPC 子进程)。internal/plugin 的定位:MCP 客户端,把远程 tools/prompts/resources 适配成本地接口。mcp__<server>__<tool> 与 annotations.readOnlyHint 的映射。cmd/reasonix-plugin-example 作为最小 MCP stdio 服务器的协议实现。Reasonix 的 SPEC 第 1.4 条把扩展体系定义成两层,原文:
4. **Two extension tiers.** Compile-time built-ins (self-register via `init()`), and runtime external plugins (stdio JSON-RPC subprocesses, MCP-compatible).
翻译:两层扩展:编译期 built-in(通过 init() 自注册),和运行时外部插件(stdio JSON-RPC 子进程,MCP 兼容)。
用图把两层展开:
两层的关键区别:
| 维度 | 编译期 built-in | 运行时外部插件 |
|---|---|---|
| 形态 | Go 包,编译进主二进制 | 外部子进程(独立可执行文件) |
| 注册方式 | init() 自注册 |
配置声明 + 启动子进程 |
| 进程 | 与 Reasonix 同进程 | 独立进程(stdio/HTTP 通信) |
| 升级 | 重新编译 Reasonix | 替换子进程,不动主二进制 |
| 信任 | 完全信任(同进程) | 配置声明即授权(SPEC §3.3) |
| 能力 | 提供 tool/provider/outputstyle 等 | MCP 贡献工具;Extension 更强(下节) |
第一层(built-in)在前几章已经反复遇到:internal/tool/builtin 里的文件工具、bash 工具等都是 built-in,通过 init() 注册进默认 registry;cmd/reasonix/main.go 入口 blank-import 这些包让它们的 init() 执行。第二层(外部插件)是本节和下节的主角。
第二层内部又分两种:MCP 客户端(本节,只贡献 tools/prompts/resources)和 Extension Protocol v1 sidecar(下节,能拦截事件/贡献 Provider/提供 UI)。SPEC §1.4 说的"runtime external plugins (stdio JSON-RPC subprocesses, MCP-compatible)"主要指 MCP;Extension 是 Reasonix 自己更强的协议。
internal/plugin 的包注释把定位讲得很清楚:
// Package plugin is Reasonix's MCP client. It connects to external MCP servers and // adapts their tools to the tool.Tool interface, so the agent treats plugin // tools and built-ins uniformly. The wire protocol is JSON-RPC 2.0 in every // case; only the transport differs (stdio subprocess, Streamable HTTP, or the // legacy HTTP+SSE). A transport interface hides that difference so the MCP-level // logic — handshake, tools/list, tools/call — is written once.
翻译:internal/plugin 是 Reasonix 的 MCP 客户端。它连接外部 MCP 服务器,把它们提供的工具适配成 tool.Tool 接口,让 agent 把插件工具和 built-in 一视同仁。线协议在所有情况都是 JSON-RPC 2.0,只有传输层不同(stdio 子进程 / Streamable HTTP / legacy HTTP+SSE)。一个 transport 接口隐藏这个差异,让 MCP 级逻辑(握手 / tools/list / tools/call)只写一遍。
三个关键设计。
第一,适配器模式。agent 内部只认 tool.Tool 接口(第 5 章讲过),不关心工具是 built-in 还是远程插件。internal/plugin 的工作就是把远程 MCP 工具包装成实现 tool.Tool 接口的本地对象。这样 agent 的主循环、permission、checkpoint、execute_one 等所有逻辑都不用为插件工具特判——统一处理。
第二,JSON-RPC 2.0 是线协议。不管传输层是 stdio 还是 HTTP,线协议都是 JSON-RPC 2.0(request 有 id, response 按 id 匹配;notification 无 id)。这意味着 MCP 级的逻辑(发什么请求、收什么响应、怎么解析工具列表)对所有传输都一样。
第三,transport 接口屏蔽差异。transport 接口(call / notify / close)抽象了"怎么把 JSON 消息发出去、怎么收回来"。stdio transport 用子进程的 stdin/stdout,HTTP transport 用 HTTP 请求,SSE transport 用 HTTP+SSE 流。MCP 级逻辑只调 transport 接口,不关心底下是哪种。这是经典的"协议与传输分离"——同一套协议跑在多种传输上。
SPEC §3.3 和源码列出三种传输:
// stdioTransport speaks newline-delimited JSON-RPC 2.0 over a subprocess's // stdin/stdout — the MCP stdio convention (one JSON message per line, ...
| 传输 | 形态 | 适用场景 |
|---|---|---|
| stdio(默认) | 本地子进程,一行一个 JSON 消息走 stdin/stdout | 本地工具(最常见) |
| Streamable HTTP | HTTP 请求,流式响应 | 远程 MCP 服务器 |
| legacy HTTP+SSE | HTTP + Server-Sent Events(老协议) | 兼容旧 MCP 服务器 |
stdio 是默认且最常见——一个 MCP 服务器就是个独立的可执行文件,Reasonix 把它当子进程启动,通过它的 stdin/stdout 收发 JSON-RPC 消息。transport_stdio.go 顶部定义了关闭预算:closeWaitBudget = 5 * time.Second(关闭时最多等 5 秒)、gracefulCloseWaitBudget = 750 * time.Millisecond(优雅关闭等 750ms)。这些常量控制 Reasonix 关闭 MCP 服务器时的超时行为——先发 shutdown 通知,等一会,还不退出就强杀。
stdio 传输有个细节:MCPProcessMode。源码:
// MCPProcessHost runs authorized stdio MCP as a trusted host process that // does not inherit the agent Bash command sandbox. This is the product // default so servers such as chrome-devtools-mcp can reach the real browser, // Keychain, LaunchServices, and local app services. MCPProcessHost MCPProcessMode = "host" // MCPProcessConfined wraps the process with sandbox.CommandArgs. Reserved for // internal managed deployments and tests; never auto-selected for user installs. MCPProcessConfined MCPProcessMode = "confined"
默认是 host 模式:授权的 stdio MCP 服务器作为受信宿主进程运行,不继承 agent 的 Bash 命令沙箱。注释举例:这样 chrome-devtools-mcp 才能访问真实浏览器、Keychain、LaunchServices、本地应用服务。confined(沙箱包裹)只给内部托管部署和测试用,绝不自动选给用户安装。这是个重要的信任决策——MCP 服务器默认是高权限的(因为要能访问系统资源)。
MCP 的线协议流程,从 plugin-example 的注释看得很清楚:
Protocol, one JSON object per line: - initialize → {protocolVersion, capabilities, serverInfo} - notifications/initialized (notification, no id) → ignored - tools/list → {tools: [{name, description, inputSchema, annotations}]} - tools/call {name, arguments} → {content: [{type:"text", text}], isError} - prompts/list → {prompts: [{name, description, arguments}]} - prompts/get {name, arguments} → {messages: [{role, content:{type,text}}]} - resources/list → {resources: [{uri, name, description, mimeType}]} - resources/read {uri} → {contents: [{uri, mimeType, text}]}
MCP 不只贡献工具,还贡献 prompts(提示模板)和 resources(资源)。完整流程:
几个要点。
第一,protocolVersion。源码 const protocolVersion = "2024-11-05"——Reasonix 在 initialize 时广告的 MCP 协议版本。这是 MCP 标准的版本协商。
第二,initialize 是 request,notifications/initialized 是 notification。initialize 有 id(等响应),initialized 是通知(无 id,发了就完)。这是 JSON-RPC 2.0 的 request/notification 区分。
第三,tools/list 只调一次(握手时),tools/call 每次调工具时。握手时 Reasonix 拿到工具列表,把它们注册进本地 registry(适配成 tool.Tool)。之后 agent 调用某工具时,Reasonix 才发 tools/call 给服务器执行。所以工具列表是"启动时拉取、缓存",工具执行是"按需调用"。
第四,日志走 stderr,stdout 专留给 JSON-RPC。plugin-example 的注释强调:"Logs go to stderr (reasonix forwards plugin stderr to the terminal); stdout is reserved for JSON-RPC so it must never carry stray prose." MCP stdio 协议要求 stdout 只能走 JSON 消息,任何杂散文字都会破坏协议解析。所以插件日志必须走 stderr,Reasonix 会把 stderr 转发到终端。
MCP 工具适配进 Reasonix 后,命名遵循约定:mcp__<server>__<tool>。plugin-example 的注释举例:
Then reasonix surfaces its tools as "mcp__example__echo" / "mcp__example__wordcount", its prompt as the "/mcp__example__review" slash command, and its resource as the "@example:doc://style-guide" reference.
也就是说,一个名为 example 的 MCP 服务器,提供的 echo 工具在 Reasonix 里叫 mcp__example__echo。这种 <前缀>__<服务器>__<工具> 的命名避免了不同服务器的同名工具冲突,也让 permission 规则能按服务器前缀匹配。
readOnly 映射。SPEC §3.3:
- A tool's MCP `annotations.readOnlyHint` maps to `Tool.ReadOnly()`. It defaults
MCP 工具的 annotations.readOnlyHint(MCP 标准里的一个提示字段)映射到 Reasonix 的 Tool.ReadOnly() 方法。这个 ReadOnly() 在第 5 章 02 节讲过——它决定工具是否触发 checkpoint 快照(非只读工具才拍快照)。所以 MCP 工具通过这个映射,自然融入 Reasonix 的 checkpoint 体系:声明了 readOnlyHint 的 MCP 工具不会触发文件快照。
授权模型:安装即授权。SPEC §3.3 / 安全模型:
- **MCP authorization.** Installing an MCP server authorizes all of its tools;
安装一个 MCP 服务器,就授权了它的所有工具。这是个"全有或全无"的授权——你不能"只安装但只允许其中某几个工具"(那是 permission 规则的事,不是 MCP 授权)。安装动作本身就是信任声明。结合上一节的 MCPProcessHost 默认(不继承沙箱),MCP 服务器默认有相当高的权限,所以 Reasonix 在 install preview / reasonix plugin show / capability diagnostics / Desktop installer 里都会显示 MCP 服务器的启动命令供用户审查。
💡 契约要点:MCP 客户端的设计核心是"适配 + 统一":用 transport 接口屏蔽传输差异(stdio/HTTP/SSE),用 JSON-RPC 2.0 统一线协议,用
tool.Tool接口让插件工具和 built-in 一视同仁。agent 主循环、permission、checkpoint 都不用为 MCP 工具特判。代价是:安装即全授权、默认不沙箱——MCP 服务器是高信任实体。理解这套"统一接口 + 高信任"的取舍,才能用好 MCP。
cmd/reasonix-plugin-example 是个可运行的参考 MCP stdio 服务器,它存在的目的注释写得很直接:
// Command reasonix-plugin-example is a reference Reasonix plugin: a minimal MCP stdio // server speaking newline-delimited JSON-RPC 2.0 on stdin/stdout. It exists to // document the contract end-to-end (the protocol the internal/plugin client // drives) and to give users a working example to copy.
翻译:它是个最小 MCP stdio 服务器,在 stdin/stdout 上说换行分隔的 JSON-RPC 2.0。它存在是为了端到端地文档化契约(internal/plugin 客户端驱动的协议),并给用户一个可工作的示例去复制。
它在 reasonix.toml 里的配置方式:
[[plugins]] name = "example" command = "reasonix-plugin-example"
配好后 Reasonix 把它的工具暴露成 mcp__example__echo / mcp__example__wordcount,prompt 暴露成 /mcp__example__review slash 命令,resource 暴露成 @example:doc://style-guide 引用。
main.go 的主循环很简单:从 stdin 一行行读 JSON-RPC,按 method 分派(initialize / tools/list / tools/call / prompts/list / prompts/get / resources/list / resources/read),把响应写回 stdout。version 通过 -ldflags "-X main.version=..." 注入,在 initialize 的 serverInfo 里报告——这样 Reasonix(和人)能看到跑的是哪个构建。这个示例是学习 MCP stdio 协议的最佳起点,因为它把契约用最小可运行代码完整演示了一遍。
本节最后,把 MCP 和第 6 章的 Cache-first 契约连起来。这一点容易被忽略,但很重要。
第 6 章 01 节讲过:工具 schema 是 cache-stable 前缀的一部分(base prompt + tools schema + memory 索引)。MCP 服务器贡献的工具,适配后会进 Reasonix 的 tool registry,序列化进 tools schema,从而成为前缀的一部分。
这意味着两件事。
第一,启用/禁用一个 MCP 服务器会改变前缀字节,触发一次 cache miss。因为 MCP 工具进/出 registry,tools schema 变了,前缀变了。所以 Reasonix 在 PLUGIN_PACKAGES.md 里提到:"Imported servers default to auto_start=false; users connect them on demand so startup does not change the provider-visible tool schema."(导入的服务器默认 auto_start=false;用户按需连接,这样启动不会改变 provider 可见的工具 schema。)这是个有意识的缓存友好设计——MCP 服务器默认不自动连,避免每次启动都因为连了一堆 MCP 而让 tool schema 变化。
第二,MCP 工具的 description / inputSchema 任何字节变化都会让前缀变化。所以 MCP 服务器的工具描述最好是稳定的(不要每次 tools/list 返回不同描述)。这也是为什么 internal/agent/cache_shape.go 的 normalizeToolSchemas 要先排序再 hash——保证"相同工具集"产生相同前缀指纹,不受注册顺序干扰。
所以扩展体系和 Cache-first 契约不是割裂的:扩展贡献的工具直接进前缀,扩展的任何变动都会影响缓存。这也是为什么第 6 章 01 节的 scripts/check-cache-impact.sh 把 internal/plugin/* 列为 cache-sensitive 路径——改 plugin 包可能影响 tool schema,进而影响前缀。理解这条联系,你才算看清 Reasonix 各章节之间的耦合。
init() 自注册,同进程)vs 运行时外部插件(stdio JSON-RPC 子进程);外部插件里又分 MCP(本节,只贡献工具)和 Extension(下节,更强)。tool.Tool,让 agent 一视同仁。host(不继承沙箱,让 chrome-devtools-mcp 这类能访问真实系统资源);confined 仅内部/测试用。2024-11-05;日志走 stderr,stdout 专留 JSON-RPC。mcp__<server>__<tool>;MCP annotations.readOnlyHint 映射到 Tool.ReadOnly()(决定是否触发 checkpoint)。[[plugins]] 配置。auto_start=false,且 internal/plugin/* 是 cache-sensitive 路径。下一节,我们看比 MCP 更强的扩展:Extension Protocol v1 sidecar(
internal/extension,约 20K 行)。它除了贡献工具,还能拦截运行时事件(interceptor)、贡献流式 Provider、提供结构化 UI。我们会讲清 Extension 与 MCP 的能力边界差异、17 个冻结的拦截钩子点、替换策略槽(slot),以及 Plugin Manifest v1 版本化插件包。