拆解 Block 开源 buzz 多 Agent 平台的 MCP 服务器

2026-08-05

本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。

如果你想知道一个给写代码的 Agent 用的 MCP 服务器到底该长什么样,最省事的办法不是读规范,而是找一份被真实 Agent 天天调用的实现读完它的常量。 Block 开源的多 Agent 通信平台 buzz(仓库 github.com/block/buzz,Apache-2.0,Copyright 2026 Block, Inc.)里就有这么一份:crates/buzz-dev-mcp。它总共十一个源文件,对外只暴露七个工具,但把「超时怎么定」「输出多长要截断」「截断掉的内容放哪」「子进程怎么保证被杀干净」这几件事全部写死成了可核对的数字。这些数字才是范本的价值所在——工具清单谁都会列,能扛住真实调用的边界值不是。

本站已经有几篇从设计角度谈 MCP 的文章:MCP 服务器选型标准讲的是你该挑谁,MCP 工具数量该给多少讲的是工具集规模的取舍,Agent 工具设计讲的是工具接口该怎么定义。这篇不重复那些判断,只做一件事:把一份真实实现从入口拆到常量,让你在动手写自己的服务器时有个能对照的参照物。

一、它在 buzz 里是什么位置

先把上下文交代清楚,不然你会不知道这个 MCP 服务器的客户端是谁。

buzz 的 README 把自己定位成「A workspace where humans and agents build together, on a relay you own.」,说白了是一个人和 Agent 共用同一批频道的自托管工作区。它建在 Nostr 之上——Nostr 是一套用公私钥做身份、把每条消息写成带签名的事件、由中继(relay,负责收发和暂存这些事件的服务器)转发的开放协议。README 里那句「It’s a Nostr relay: every message, reaction, workflow step, review approval, and git event is a signed event in one log」是项目自己的定位,意思是消息、反应、工作流步骤、评审批准、git 事件在它这里都是同一个日志里的签名事件。

buzz-dev-mcp 不负责这套通信,它是给跑在这套通信里的编码 Agent 配的工具箱。仓库根目录的 .env.example 里有一行配置说明写得很直白:BUZZ_ACP_MCP_COMMAND 是「Binary for an optional MCP server sidecar (e.g. buzz-dev-mcp for buzz-agent)」——它是 Agent 进程旁边的一个可选边车。crates/buzz-acp/src/pool.rs 的测试里构造的 McpServer 就是 name: "dev"command: "buzz-dev-mcp"crates/buzz-agent/src/agent.rs 的测试里出现的工具名是 buzz-dev-mcp__shell,也就是宿主给工具加了服务器名前缀之后的形态。

所以这份实现的读者视角很明确:它不是通用工具服务器,它是一个明确知道自己客户端是 LLM、且这个 LLM 正在改代码的服务器。后面很多设计取向都由这一点决定。

二、七个工具与十一个文件

crates/buzz-dev-mcp/src/lib.rs 用 rmcp 这个 Rust MCP 库的 #[tool_router] / #[tool] 宏声明工具,走 transport::stdio,也就是标准输入输出上的 MCP 传输。注册的工具一共七个:shellread_fileview_imagestr_replacetodo,外加两个下划线开头的钩子 _Stop_PostCompact

组成部分它负责什么仓库位置你什么时候会碰到它
进程入口三行代码,只调用库里的 run()crates/buzz-dev-mcp/src/main.rs想找入口,但真正的分发逻辑不在这里
工具注册与 stdio 服务声明七个工具、拼 ServerInfo、启动 stdio 传输crates/buzz-dev-mcp/src/lib.rs想抄工具描述和注册写法
shell每次调用起一个短命 shell 进程,跑完就收crates/buzz-dev-mcp/src/shell.rs超时、截断、进程杀不干净
read_file带行号读文本,offset / limit 开窗crates/buzz-dev-mcp/src/read_file.rs大文件分页读
str_replace原子改写单个文件并回一份 unified diffcrates/buzz-dev-mcp/src/str_replace.rs改动没匹配上、匹配到多处
view_image把图片转成 MCP image 内容块喂给多模态模型crates/buzz-dev-mcp/src/view_image.rs让 Agent 看截图
todo 与两个钩子进程内任务清单,收尾前拦一道crates/buzz-dev-mcp/src/todo.rsAgent 活没干完就想结束
路径解析与读文件流水线解析 → stat → 限长 → 读 → UTF-8 解码crates/buzz-dev-mcp/src/paths.rs路径报错、文件太大、非 UTF-8
PATH shim 与 git 身份建临时目录、挂多身份链接、装 git 配置crates/buzz-dev-mcp/src/shim.rs私钥怎么流转、commit 谁签的
内置 rg 与 tree系统没有就用自带的纯 Rust 回退实现crates/buzz-dev-mcp/src/rg.rssrc/tree.rs搜索 flag 不被支持

工具描述本身也值得抄。shell 的描述里直接把「哪些命令在 PATH 上」写进去了:rg(并且注明 prefer over grep)、treebuzzread_file 的描述末尾写着 Prefer over cat/head/tail,str_replace 的描述末尾写着 Prefer over sed/awk。这是很实际的一招——模型手里同时有 shell 和专用工具时,不明说它就会去 shellcat,然后你精心设计的分页协议一次都不会被用到。

另外 lib.rsget_info()state.bootstrap_instructions 塞进了 MCP 的 instructions 字段。这段引导文本在 shell.rsbuild_bootstrap 里拼装,内容是:当前工作目录、探测到的技术栈、当前 shell 名字加一句「set BUZZ_SHELL to override」、以及一句「Pass workdir per call rather than cd」。技术栈探测靠 detect_stack 扫工作目录里的标记文件,Cargo.toml 对应 rust (cargo),package.json 对应 node,还有 go.modpyproject.tomlrequirements.txtGemfilepom.xmlbuild.gradlebuild.gradle.kts,都没命中就是 unknown。等于是在会话开头就把「你在哪、这是什么项目、别用 cd」这三件事免费告诉模型一次。

三、一个二进制,五个身份

这是整份实现里最容易被抄走也最容易被忽略的部分。

main.rs 只有三行,真正的入口是 lib.rs 里的 run()。它做的第一件事是读 argv0 的 file stem,然后按名字分发:叫 rg 就跑内置的 ripgrep 兼容实现并退出,叫 tree 就跑目录树实现并退出,叫 git-credential-nostrgit-sign-nostr 就跑对应的 git 辅助程序,叫 buzz 就跑 buzz CLI,都不是才进入 MCP 服务器模式。源码注释把这条路径的意图写得很清楚:同步身份在构建任何 runtime 之前就退出,不启 tracing、不起 tokio。

那这些名字从哪来?shim.rsShim::install():建一个前缀为 buzz-dev-mcp- 的临时目录,Unix 上设成 0700,然后为 rgtreebuzzgit-credential-nostrgit-sign-nostr 这五个名字各建一个指回自身可执行文件的符号链接(Windows 上没有免提权符号链接,改成复制并补 .exe 后缀),最后把这个目录塞到 PATH 最前面。子进程的 PATH 就是这份 path_env

这个做法解决的是一个很具体的问题:Agent 会写 rg foo src/,但目标机器上不一定装了 ripgrep。有了 shim,命令总能跑通——rg.rstry_system_rg 会先在剔掉自己那一项的 PATH 里找真的 rg,找到就转发过去,找不到才用自带的纯 Rust 实现兜底。代价是兜底实现只认有限的 flag:--files-n-i-l-C-g,遇到别的直接返回 unsupported flag (fallback rg)

同一个临时目录还承担了身份的活。Shim::install() 会读 NOSTR_PRIVATE_KEY 然后无条件把它从本进程环境里删掉,注释写明理由是不管 keyfile 写没写成功,这个 key 都不能漏给子进程。key 被写进 shim 目录里的 .nostr-key(Unix 上用 OpenOptions::mode(0o600) 在创建时就定权限,避免出现世界可读的时间窗),内存里那份随后 zeroize 抹掉。接着 build_git_env 把一串 git 配置拼成 GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n 的扁平环境变量交给子进程,内容包括 credential.helper=nostrcredential.useHttpPath=truenostr.keyfilegpg.format=x509gpg.x509.program=git-sign-nostrcommit.gpgSign=truetag.gpgSign=trueuser.signingkey 是公钥 hex,user.emailderive_git_email 从公钥和中继地址拼成 <pubkey_hex>@<relay_host>user.name 取环境变量 BUZZ_ACP_DISPLAY_NAME 清洗后的值,没有就退回 npub(npub 是 Nostr 公钥的 bech32 文本形式)。

useHttpPath=true 那行的注释解释了为什么必须开:Buzz relay 用 NIP-98(NIP 是 Nostr 的规范编号)校验完整的 repo-root URL,不开这项 git 只传主机名,鉴权会被拒。

这里有件事你必须清楚:这套配置意味着 Agent 在这台机器上做的每一次 commit 和 tag,都是用你的 Nostr 私钥签的。Nostr 是密钥自持的——身份就是那对密钥,没有找回流程,私钥丢了等于身份丢了,泄漏了等于别人可以永久冒充你。私钥落在会话临时目录的 .nostr-key 里,BUZZ_PRIVATE_KEY 则是被有意保留继承给子进程的(注释写着 buzz CLI 需要它)。你在自己机器上挂这个边车之前,先把这条链路想明白。

四、真正值钱的是那些常量

工具骨架谁都会搭,难的是回答「输出 200MB 怎么办」。这份实现的答案全在常量里。

shell 的输出分级。shell.rs 顶部一排常量:默认超时 120000 毫秒,上限 600000 毫秒(工具描述里额外提示,git push 带钩子、cargo build、跑测试套件这类活建议给 300000 以上);命令字符串上限 1000000 字节;单条流最多捕获 10MB;给模型的正文一旦超过 50KB 或 2000 行就判定需要截断;截断后只回最后 8KB,前面加一行 notice 说明「showing last N bytes」、捕获了多少字节多少行、以及完整输出被写到了哪个 artifact 文件。artifact 落在会话临时目录(前缀 buzz-dev-mcp-session-)下的 artifacts/ 里,按 {call_id:06}.{stdout|stderr}.txt 命名,环形保留 8 个。

这个三段式很值得抄:给模型的是尾部,给人和后续调用的是完整文件,两者之间用一条 notice 建立索引。返回体是一个 JSON,字段包括 exit_codestdoutstderrtimed_outduration_msstdout_truncatedstderr_truncatedstdout_artifactstderr_artifactnotes——模型看得到自己被截断了,也拿得到去哪捞全量。

进程一定要杀干净。shell.rs 里有个 KillGroup 结构,Unix 下持有进程组 id,超时走 SIGTERM → 等 200 毫秒 → SIGKILL;Windows 下建一个 Job Object 并把子进程挂进去,设了 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,终止时用 TerminateJobObject 传退出码 137(注释说明这是对齐 Unix 上 SIGKILL 的 128+9)。注释里写明了为什么不能只 kill bash:MSYS 下 bash fork 出来的孙进程会继续握着 stdout/stderr 管道,你以为杀掉了,实际上读取端一直阻塞到孙进程自己退出。KillGroupDrop 是最后一道兜底。超时时 exit_code 报 124。

**read_file 的窗口协议。**默认 limit 是 2000 行,输出每行是 {行号}:{内容},头一行是 path (lines a-b of total)。没读完时末尾追一行 [showing lines a-b of total; use offset=N to continue],直接把下一次该传什么算好告诉模型。空文件、offset 超界、limit 传 0 各有各的明确文案,不会返回一个让模型困惑的空串。

**str_replace 的失败路径比成功路径花的力气多。**默认要求 old_str 在文件里恰好出现一次,replace_all 为 true 时才允许多处;old_str 为空直接拒;old_str / new_str 各自不得超过 1MB。判重用的是 count_occurrences_capped,数到 2 就返回,不做全量统计。匹配不上时它不是简单报个 not found,而是调 nearest_line_hint:取 old_str 的第一行,在文件前 200 行里逐行算相似度(用 similar 库的字符级 diff,比较前把两边截到 512 字节),相似度超过 0.6 的里面挑最高的一条,回一段带行号的提示,把 found 和 expected 并排列出来。匹配到多处时的报错是「provide more surrounding context to make the match unique」,明确告诉模型该怎么补救。

写入走 atomic_write:在目标文件所在目录建临时文件、写完 flush、persist 覆盖过去,并且在覆盖后把原文件的权限位补回去(注释点明了原因,原子重命名会把文件 mode 丢掉)。成功后回一份 context_radius(3) 的 unified diff,超过 64KB 就截断并标注 [diff truncated]

**view_image 的预算是照着模型侧的限制定的。**源文件上限 20MiB,出线前的原始字节上限 3MiB(注释说 base64 膨胀 4/3 之后约 4MiB,留在 5MiB 以内),默认最长边 1568 像素、可用 max_dim 覆盖但会被夹在 64 到 2048 之间,解码前先卡 6400 万像素的像素预算,解码器单次分配上限设成 256MiB,URL 抓取超时 10 秒。动图直接拒。

**todo 是给收尾纪律用的。**最多 50 条,每条最多 200 字符,模型每次传的是全量替换列表。如果有未完成项在替换中悄悄消失了,响应里会追加一条警告。_Stop 钩子在 Agent 想结束回合时被调用,只要还有未完成项就返回一段「You have open todo items. Keep working.」加上清单;_PostCompact 在上下文压缩之后被调用,把清单再吐一遍供重新注入。文本字符还做了过滤,控制字符、异常空白、零宽和双向控制字符一律拒——避免有人用不可见字符伪造渲染出来的清单。

五、边界与代价

这份实现放弃了不少东西,而且放弃得相当明确。

它不是沙箱。paths.rs 的模块注释第一段就写着 resolve_path 做解析和 canonicalize,「No containment enforcement — the resolved path may land anywhere on the filesystem (consistent with the shell tool’s posture)」。测试用例 read_allows_absolute_pathrun_allows_path_outside_workspace 就是专门锁住这个行为的。也就是说 read_filestr_replace 能读写工作区之外的任何路径,shell 更是能跑任意命令。这是一个刻意的取向:既然 shell 已经无所不能,再给文件工具加围栏只会制造虚假的安全感。代价是这东西的隔离必须放在外面做——容器、专用用户、专用机器,选一个。想清楚这条线在哪,可以参考Agent 工作区隔离

顺带一个你在自己实现里也可能踩的坑:view_image 的工具描述文本里写着相对路径「may not escape it」,但它走的同样是 paths.rs 里那个明说不做包含性检查的 resolve_path。工具描述是给模型看的,不是执行路径上的检查——把描述当成安全边界,早晚要出事。

它不管这些事。str_replace 建不了新文件(read_text_file 要求目标已存在且是常规文件),也不做多文件批量替换、不做语法感知,就是纯字符串匹配。所有文本工具只接受 UTF-8,解码失败直接报错,上限 10MB。shell 的 stdin 接的是 Stdio::null(),任何要交互输入的命令都没法用;而且每次调用是独立的短命进程,cd 和 export 出去的变量都不会留到下一次——这正是引导文本要专门说一句 pass workdir per call 的原因。todo 的状态只在进程内存里,进程一没就没了。

**平台差异是真实成本。**Windows 上没有免提权符号链接,五个多身份入口只能各复制一份完整二进制。resolve_bash 在 Windows 上的探测顺序有六级(BUZZ_SHELLGIT_BASH → PATH 上的 bash.exe 且排除 System32 → git.exe 的兄弟路径 ..\bin\bash.exe → 标准安装目录 → 注册表 SOFTWARE\GitForWindowsInstallPath),排除 System32 是为了不撞上 WSL 的启动器。paths.rs 里还专门写了 msys_to_windows,把 /c/Users/x 翻成 C:\Users\x//server/share 翻成 UNC 形式,而 /tmp/usr/... 这类根锚定路径明确不猜,宁可让它带着清晰的 path not accessible 失败。这些代码没有一行是「设计」出来的,全是被真实环境打出来的。

六、上手与避坑清单

  • **别只声明工具,要在描述里写清楚优先级。**会踩是因为模型手上同时有 shell 和专用工具,默认会挑最熟的那条路——它更习惯 cat 而不是你新造的 read_file。避法就是照抄 buzz 的做法,把 Prefer over cat/head/tail、Prefer over sed/awk 直接写进描述,并且在描述里告诉它 PATH 上有什么。
  • **超时值别只给一个默认值,要在描述里举例。**会踩是因为模型不知道 cargo buildls 不是一个量级,用默认值跑长任务,然后拿到一个 124 什么都没有。避法是像 shell 的描述那样点名场景:git push 带钩子、cargo build、测试套件用 300000 以上。
  • **截断策略必须让模型知道自己被截断了。**会踩是因为你只回尾部却不说,模型会把尾部当全部,然后基于半截输出下判断。避法是返回体里带 stdout_truncated 这种显式布尔位,再加一条指向完整文件的路径。
  • **超时后一定要杀进程组或 Job Object,不能只杀直接子进程。**会踩是因为 shell fork 出来的孙进程握着管道,你的读取任务会挂在那里,表现成「超时了但工具调用不返回」。避法是照 KillGroup 的形状做,两个平台各用各的原语,再加一个 Drop 兜底。
  • **改文件工具的错误信息要能自我修复。**会踩是因为只回 not found,模型只能瞎猜,然后连续三四轮都在改同一个 old_str。避法是像 nearest_line_hint 那样把最接近的那行连行号带出来,让下一轮直接改对。
  • **别把工具描述当安全边界。**会踩是因为描述写着不能逃出工作目录,实现里却没有对应检查,评审时看描述就放行了。避法是每条安全声明都回到执行路径上找那行代码,找不到就当它不存在。
  • **私钥链路要单独走一遍。**会踩是因为你只关心 Agent 能不能跑通,没注意 commit.gpgSign=true 意味着它的每次提交都在用你的身份签名,而 Nostr 密钥没有找回。避法是先确认私钥落盘的位置和权限、确认哪些变量会被继承给子进程、再决定这个边车配不配跑在你的日常开发机上。

收束

这份实现值得读的地方,不在于它用了 Rust 或者用了哪个 MCP 库,而在于它把所有会疼的地方都变成了一个可以核对的数字:120000、600000、50KB、2000 行、8KB、8 个 artifact、2000 行默认窗口、0.6 相似度、200 行扫描、64KB diff、1568 像素、50 条 todo。你写自己的服务器时,这些数字未必要照抄,但每一个位置你都得有一个自己的答案,答不上来的位置就是将来出事的位置。

接下来该读哪个文件,取决于你卡在哪:想抄工具声明和引导文本,读 crates/buzz-dev-mcp/src/lib.rs;想搞定输出体积和进程生命周期,读 crates/buzz-dev-mcp/src/shell.rs 顶部那一排常量和 KillGroup;想让改文件工具不再让模型空转,读 crates/buzz-dev-mcp/src/str_replace.rsnearest_line_hint;想弄清自己的密钥会流到哪,读 crates/buzz-dev-mcp/src/shim.rs。想从零搭一个最小可用的服务器再回头看这些细节,可以先过一遍MCP 服务器开发入门

本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源多 Agent 平台 buzz:语音在中继与本地如何分工Block 开源 buzz:多 Agent 平台的 Git 仓库托管链路

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。