Block 开源多 Agent 通信平台 buzz 的工具来源:内置工具与 MCP 如何合并
本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。
**在 buzz 里,Agent 手上那份工具清单不是配置出来的静态列表,而是每一轮模型请求前重新算一遍的结果。**算的方式很直白:先问 MCP 注册表当下还活着哪些工具,再看这个会话有没有发现技能,有才补上一个内置工具。理解了这个”每轮重算”,你才能解释为什么某个工具昨天在、今天不在,也才知道该去哪个文件排查。
一、先把对象说清楚:buzz 是什么,这篇不谈什么
buzz 指的是 Block 开源的那个多 Agent 通信平台,仓库在 https://github.com/block/buzz,许可证 Apache-2.0(Copyright 2026 Block, Inc.)。仓库 README 把自己定位成一个人和 Agent 一起干活的工作区,并强调中继由你自己持有——这是项目自己的说法,不是本文替它下的判断。
它建在 Nostr 之上。对不熟悉去中心化协议的读者,这里补三句:Nostr 里传的每条消息叫事件(event),发件人用自己的私钥给事件签名,任何人拿公钥就能验签,所以消息的作者身份不依赖某个中心账号系统;负责收、存、转发这些事件的服务器叫中继(relay),你可以自己跑一台;kind 是事件的类型编号,决定这条事件是资料、文本消息还是别的什么。这些不是我推的,仓库里有对应位置:crates/buzz-core/src/event.rs 定义了 StoredEvent,把一条 nostr::Event 包上中继侧的元数据(接收时间、channel 归属、是否已验签);crates/buzz-core/src/kind.rs 是 kind 号的登记处;docs/nips/ 下放了 15 份 NIP 规范 md 加 2 份 fixtures json,另有 crates/buzz-core/src/pairing/NIP-AB.md 算第 16 份。
密钥自持这件事要说清代价:身份就是那把私钥,私钥丢了等于身份丢了,没有找回入口。这一点在后面讲环境变量透传时还会再撞上一次。
这篇只谈一件事:Agent 能调什么工具,两条来源怎么合并。协议本身怎么定义工具、能力如何协商,站内的 MCP 协议是什么 已经讲过;工具集该怎么切分、粒度怎么定,看 Agent 工具设计;工具描述那段自然语言怎么写才不让模型误调,看 Agent 工具描述写法。本文是第四块:一个具体项目里,这些东西落到代码是什么形状。
二、两条来源在哪一行合流
合流点在 crates/buzz-agent/src/agent.rs 的主循环里,一共四行:
let mut tools = self.mcp.tools();
// Inject the built-in load_skill tool when skills are available.
if !self.skills.is_empty() {
tools.push(builtin::load_skill_def());
}
读出三个结论。
第一,MCP 是主来源,内置只有补丁式的一个。self.mcp.tools() 返回的是 McpRegistry 里所有可见工具,内置侧只往后面追加了 load_skill。
第二,内置工具是条件注入的。没有发现任何技能,这个工具就根本不进清单——模型不会看到它,自然也不会去调。
第三,这段在循环体内,每一轮都会重新执行。McpRegistry::tools() 的过滤逻辑(crates/buzz-agent/src/mcp.rs)不是照抄注册时的快照:它逐个检查工具所属服务器的当前状态,服务器处于 Healthy 就看它这次报上来的工具名里还有没有这一项,处于 Dead 则要求重启次数还没耗尽、且上一次健康时报过这个名字。也就是说,一台 MCP 服务器挂到重启预算用完,它的工具会从清单里静默消失,而模型只会感觉到”这个能力没了”。
调用侧的分流回到 agent.rs,在 execute_calls 里:名字等于内置工具名就地执行、不走任何进程间往返;否则先用 has() 判存在、用 is_hook() 排除隐藏工具,两关都过才排进并发执行队列,否则合成一条 unknown tool 结果丢回给模型。
三、MCP 侧:名字在注册那一刻就被定死
McpRegistry::spawn_all 是这条来源的入口。传进来的每台服务器是一个 McpServerStdio(crates/buzz-agent/src/types.rs),字段只有 name、command、args、env——命令加参数,也就是说这条路径上的 MCP 服务器是被拉起来的子进程,通过标准输入输出说话(代码里用的是 TokioChildProcess)。
注册时做了几件对使用者影响很大的事:
**名字校验。**服务器名和工具名都要过 valid_name:非空、有长度上限、且只允许 ASCII 字母数字、下划线、连字符。任何一个含双下划线的名字会被直接拒绝。原因在下一条。
**限定名拼装。**分隔符是写死的:
const SEP: &str = "__";
限定名就是 {服务器名}__{工具名}。因为两侧都禁了双下划线,这个拼法可以被反解析且不会歧义——agent.rs 里判断一次调用是不是”发消息”动作,用的正是”限定名以 __shell 结尾”这种写法,注释里明确说明了这个等价关系成立的前提就是上面那两条校验。限定名还有长度上限常量 MAX_QNAME_LEN(64),超了就在启动阶段报错。
**重复即失败。**服务器重名、限定名撞车,都是直接返回错误,不是后来者覆盖。整个会话的工具总数也有上限常量 MAX_TOOLS_PER_SESSION,服务器台数上限是 MAX_MCP_SERVERS(16)。
**描述和 schema 会被裁。**工具描述按 MAX_DESCRIPTION_BYTES 截断;输入 schema 走 cap_schema,超过 MAX_SCHEMA_BYTES 时——注意这里——不是截断,是整个换成空对象,并且只打一条 warn 日志:
tracing::warn!(
"tool {qname} schema is {size} bytes (>{MAX_SCHEMA_BYTES}); replacing with empty object"
);
这条是排障时最容易漏掉的:一个参数结构特别庞大的工具,注册成功了、名字也在清单里,但模型看到的是一个”无参数”工具,于是开始瞎调。
**隐藏的 hook 工具。**裸名以下划线开头的工具,tools() 会把它过滤掉,模型永远看不见;同时 is_hook() 保证模型即便瞎猜出这个名字,调用也会被当成 unknown 拒绝。它们只能由 call_hooks 在生命周期节点触发。仓库自带的 crates/buzz-dev-mcp 就注册了两个:_Stop(Agent 准备结束这一轮前问一句”还有没有没做完的事”)和 _PostCompact(上下文压缩之后把待办状态重新注入)。_PostCompact 的调用点在 crates/buzz-agent/src/handoff.rs,刻意排在历史清空之后,并且注入的文本被显式标注为不可信来源。
hook 默认是关的:允许名单来自环境变量 MCP_HOOK_SERVERS,不配就一个都不跑。它们还是 fail-open 的——超时、报错、返回空白,一律当没发生,不阻塞 Agent。
四、内置侧:load_skill,以及提示里那层 hints
内置工具只有一个,定义在 crates/buzz-agent/src/builtin.rs:load_skill。它自己的描述写得很清楚——系统提示里只列技能的名字和描述,正文按需加载。
这就引出了 hints 那一层。crates/buzz-agent/src/hints.rs 干两件事:
**一是拼装项目提示。**它从当前目录往上找 git 根(.git 是目录或文件都认,后者是 worktree 的形态),把根到当前目录这条链上每一级的 AGENTS.md 依次读出来拼在一起,并在最前面补上家目录那份作为全局层,家目录已经在链上时不会重复读。总量有上限常量 MAX_HINTS_BYTES,超了就在边界处截断。
**二是发现技能。**扫的目录是写死的三个,外加家目录下的一个:
const SKILL_DIRS: &[&str] = &[".agents/skills", ".goose/skills", ".claude/skills"];
每个子目录里必须有 SKILL.md,且 YAML frontmatter 里的 name 非空,否则整条跳过。同名先到先得,项目级压过全局级。发现时还会把技能目录树里除 SKILL.md 之外的所有文件预先枚举成 supporting_files,遇到本身就是技能的子目录不再往下钻。
两件事的产物是一段以 # Additional Instructions 开头的文本,里面分 ## Project Hints 和 ## Available Skills 两节。技能那节只写 - 名字: 描述 一行,正文一个字都不进提示——仓库的单元测试专门断言了这一点。这段文本随后在 crates/buzz-agent/src/lib.rs 里被拼到基础系统提示后面,拼完还要过一道总长度检查,超了就直接拒绝建会话。
于是 load_skill 的职责就明确了:模型在提示里看到某个技能名,判断这次用得上,才调这个工具把正文取回来。它支持两种入参形式,纯名字取 SKILL.md 正文(frontmatter 会被剥掉,末尾追加一节支撑文件清单),技能名/相对路径 取某个支撑文件。取支撑文件时先在预枚举列表里匹配,再做一次路径规范化守卫,确认解析结果仍在技能目录内,越界直接拒绝。输出统一受 MAX_SKILL_BODY_BYTES 约束。
顺带消一个歧义:同目录下的 crates/buzz-agent/src/catalog.rs 名字看着像”工具目录”,其实是 Databricks 的模型发现,跟工具来源没关系,排查工具问题时别往那儿看。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
McpRegistry | 拉起 MCP 子进程、生成限定名、维护健康/死亡状态与重启退避 | crates/buzz-agent/src/mcp.rs | 工具没出现、名字被拒、服务器反复重启 |
tools() 过滤 | 每轮按服务器当前状态算出模型可见的工具清单 | crates/buzz-agent/src/mcp.rs | 工具中途消失、死掉的服务器工具还在/已不在 |
load_skill | 唯一的内置工具,按需读技能正文与支撑文件 | crates/buzz-agent/src/builtin.rs | 技能加载失败、想读的支撑文件被拒 |
| hints 层 | 拼 AGENTS.md 链、发现技能、产出提示里那两节 | crates/buzz-agent/src/hints.rs | 技能没出现在提示里、项目约定没生效 |
| 合并与分流 | 两条来源合成一份清单;内置就地执行,MCP 走并发队列 | crates/buzz-agent/src/agent.rs | 想知道某次调用到底走了哪条路 |
| 自带工具服务器 | 以 MCP 形式提供 shell、read_file、str_replace 等 | crates/buzz-dev-mcp/src/ | 想知道那些”基础能力”是从哪进来的 |
表里最后一行值得单独说:连 shell、read_file、view_image、str_replace、todo 这些看着像”框架自带”的能力,在 buzz 里也是通过 MCP 进来的,住在独立的 buzz-dev-mcp crate 里。这是一个明确的设计取向——内置面只留一个跟提示层强耦合的工具,其余一律走同一条协议通道。
五、边界与代价:这个设计放弃了什么
**放弃了热插拔。**工具清单在会话开始时定型,注册期的重名、超长、超量全是硬错误,会话直接起不来。运行期唯一的变化是”某台服务器死了,它的工具消失”,没有反向的”运行中加一台服务器”。换来的是限定名全程稳定、模型不会遇到同名工具指向不同实现。
**放弃了完整的环境继承。**拉起子进程前先 env_clear(),再按一份白名单逐个放行。白名单里的每一项在源码注释里都写了理由——代理相关的变量大小写两套都要放,因为 libcurl 忽略大写的 HTTP_PROXY 而 Go/Python 工具链多读大写,只放一套会让半条工具链静默失灵;TLS 信任相关的变量不放,会让所有 https 请求在自签 CA 的代理后面全挂。代价是:你在父进程里设的任何自定义变量,不在白名单也不在服务器声明的 env 里,工具就是拿不到。
**没有做逐次调用的工具审批。**preflight 阶段只判两件事:这个名字存不存在、是不是隐藏 hook。没有”这次写文件要不要问一下人”的闸门。想要这类约束,得靠系统提示、靠工具自身实现,或者靠外面的编排层——参见 Agent 最小权限设计。
**信任边界是敞开的,而且源码写明了。**白名单里包含 NOSTR_PRIVATE_KEY、BUZZ_PRIVATE_KEY、BUZZ_RELAY_URL、BUZZ_AUTH_TAG,注释里直说 MCP 子进程被当作和 Agent 运行时同等信任。注释也记录了一个缓解动作:自带的 dev-mcp 会把 NOSTR_PRIVATE_KEY 写进 keyfile 后从自己的环境里移除,它再往下拉的子进程看不到。但这只是那一台服务器自己的行为,不是框架给的保证。往下推的结论很硬:**你挂上去的每一台 MCP 服务器,都拿得到能签出你身份的私钥;而 Agent 有权发消息、有权改仓库工作区里的文件。**第三方 server 要不要挂,是个安全决策,不是配置决策。
**工具结果会被裁剪,且裁剪方式有偏好。**结果预算分两档,一档管总量(含图片),一档单管文本。文本超预算时挖掉中间、保留头尾,并在中间留一条说明省掉了多少字节的标记——理由是工具输出的身份在开头、结论在结尾,只砍尾巴会丢掉模型最需要的那部分。图片则要么整块通过要么整块换成一行说明。这意味着你不能指望一次 shell 调用把整份构建日志喂给模型,超长输出的正确姿势是重新跑一条更窄的命令。相关的排查思路可以对照 Agent 工具调错的排查。
它明确不管的事:不管模型有没有能力正确使用这些工具,不管工具之间的语义冲突,不管你的 MCP 服务器自身有没有做鉴权,也不做工具输出的可信度判断——hook 的输出甚至被显式标记为不可信后才注入上下文。
六、上手与避坑清单
**服务器名或工具名里带了双下划线、点号、斜杠。**为什么会踩:不少现成的 MCP 服务器习惯用带点号或斜杠的命名,而限定名的拼法要求这两处干净。怎么避:注册前按”只留字母数字下划线连字符、且不含双下划线”改一遍名,这是启动期硬错误,不改就整个会话起不来。
**服务器名和工具名都很长。**为什么会踩:限定名长度上限是 64,一个 20 字符的服务器名配一个 45 字符的工具名就爆了,而且报错发生在启动阶段,看着像”MCP 挂了”。怎么避:给服务器起短名,长名字留给工具描述。
**工具的入参 schema 很大。**为什么会踩:超限时 schema 被整个换成空对象,只有一条 warn 日志,注册和列举全是成功的,表现出来是”模型总是不传参数”。怎么避:怀疑这类症状时先去日志里搜 schema 相关的 warn,再把嵌套过深的参数结构拍平。同名冲突的坑另见 MCP 工具命名冲突。
**以为 _Stop 这类 hook 会自动生效。**为什么会踩:仓库自带的 dev-mcp 确实注册了 hook 工具,容易默认它开着。怎么避:hook 走 MCP_HOOK_SERVERS 允许名单,不配就一个都不跑;配了也要记住它是 fail-open 的,超时和报错都会被当成”没意见”,别把它当强制闸门用。
**技能写好了却没出现在提示里。**为什么会踩:发现逻辑对目录和 frontmatter 都很挑——目录必须落在那三个约定路径或家目录下的技能目录,SKILL.md 必须有能解析的 frontmatter 且 name 非空,同名还会被先扫到的那份挡掉。怎么避:先确认路径,再确认 frontmatter 能被 YAML 解析,最后确认没有重名。另外整层 hints 可以被环境变量整体关掉,排查时先确认它是开着的。
**把 load_skill 当通用文件读取工具用。**为什么会踩:它的第二种入参形式看起来像个路径参数。怎么避:记住它只能命中发现阶段预枚举好的支撑文件列表,之后还有一道目录内的路径守卫,任何越界读取都会被拒——这是设计,不是 bug。
**给 Agent 配的 MCP 服务器来路不明。**为什么会踩:环境变量白名单会把身份相关的密钥透传下去,而配置一台服务器只需要写一行命令,心理门槛很低。怎么避:把”新增一台 MCP 服务器”当成一次安全评审来做,至少确认这个进程的代码你读过或来源可信。
收尾:一份自检清单
排查”某个工具为什么不在”的时候,按这个顺序走通常最快:确认服务器进程还活着且没耗尽重启预算 → 确认限定名没被长度或字符规则拒掉 → 确认这个工具的裸名不是下划线开头 → 确认 schema 没有被换成空对象 → 如果查的是 load_skill,确认技能真的被发现了。
想继续往下读,建议的顺序是:crates/buzz-agent/src/agent.rs 看合并与分流的全貌,crates/buzz-agent/src/mcp.rs 看注册期校验和运行期状态机,crates/buzz-agent/src/hints.rs 看提示层怎么被拼出来,最后翻 crates/buzz-dev-mcp/src/lib.rs,看一台真实的 MCP 服务器把工具描述写成了什么样子——那几段描述本身就是不错的写法样本。
本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 把 Agent 接进 buzz:Block 开源多 Agent 通信平台的三层结构 和 Block 开源的多 Agent 通信平台 buzz:用 ACP 把现成编码智能体接进去。