关于 Bruno Mcp
Bruno Mcp 是供 Agent 创建、查看、编辑和运行 Bruno API 测试集合的 MCP 服务。支持 `.bru` 与 `.yml` 格式,并可在不依赖 Bruno CLI 的情况下执行请求。
主要功能
- 01创建和组织集合
- 02读取并局部编辑请求
- 03管理环境与变量
- 04编写断言和测试脚本
- 05运行请求及依赖排序的套件
- 06支持 `.bru` 和 `.yml` 格式
适用场景
- 01将 Agent 会话中的测试整理为可复用套件
- 02更新请求时保留未修改字段
- 03无需安装 Bruno CLI 即可运行测试
MCP 相关
向 MCP 客户端提供 18 个 API 测试工具,包括集合和请求编写、环境管理及进程内执行。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
我们还没有获取到这个服务的安装包或远程地址,请按项目 README 中的说明安装。
工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
Bruno MCP Studio — 通过 Agent 编写、编辑和运行 Bruno 集合
将 Agent 的 API 测试转化为可保留的文件:它会直接编写和编辑 Bruno 集合,然后运行这些集合——支持 HTTP、WebSocket 和 gRPC,可使用一个或多个身份—— 并报告每个请求的通过/失败结果,可在 CI 中重新运行。
一个面向 Bruno 集合的独立 Model Context Protocol 服务。2026 年 8 月,Bruno 团队宣布推出官方 MCP 服务 usebruno/bruno-mcp,它通过 bru CLI 发现并运行请求。本服务的目标有所不同:它不但能运行集合,还能编写集合——以非破坏性的方式,逐字节匹配 Bruno 自带的写入器;它的运行器也在进程内执行,而非通过子进程执行,因此可以提供由调用方定义的执行组,每组拥有自己的变量存储和 Cookie jar;支持组内并发、在内存中完成 oauth2 和 digest 身份验证交换、gRPC 和 WebSocket,以及多身份授权测试。
如果你需要的是“列出我的集合并运行其中一个”,官方服务就能做到,而且 Bruno 会为其提供支持。如果你想让 Agent 构建并维护测试套件,这就是本服务的用途。
它为 Claude Code、Claude Desktop、Cursor、Windsurf、VS Code 或 Codex CLI 中的 AI Agent 提供 18 个工具,可使用真实的 Bruno 集合测试 API:创建和编辑请求、将请求作为结构化 JSON 读取、管理环境和变量、编写断言和测试脚本,然后运行集合——处理身份验证、Cookie、重定向、依赖排序等所有环节——并在同一轮对话中返回结果。不需要 Bruno GUI、Bruno CLI,也不需要调用 Shell。它在磁盘上留下的是一个普通的 Bruno 集合:你的 CI 可以通过 bru run 运行它,团队成员可以在 Bruno 应用中打开它。
**你的 Agent 已经了解 HTTP,但它不了解你的 API,也不了解 Bruno 的文件格式。**所以它只能猜。它凭记忆编写一个 .bru 文件,运行失败,然后重写文件,运行又以另一种方式失败。二十分钟后,你终于有了一个通过测试的请求,却不知道六次修改中究竟哪一次起了作用。每一轮对话都要花费成本,而这些工作没有以可供 CI 或团队的 Bruno GUI 使用的形式保存在磁盘上。
通常的替代方案是 curl。Agent 并不擅长不好 curl——问题在于 Shell 命令不保存状态。登录、获取令牌、创建资源、调用需要上一次响应中的 ID 的后续接口:每一步都是一条新命令,它们之间的衔接只存在于 Agent 的上下文中。会话一结束,一切便荡然无存。用 curl 测试 20 个端点,只会在对话记录中留下 20 条命令,不会留下 CI 或团队成员可以运行的成果。在集合中测试 20 个端点,留下的则是一套测试套件。
我询问了我的 Agent
我请负责维护这个服务的 Agent 说明,在没有 MCP 服务的情况下,它使用 Bruno 的体验如何,以及它是否可以直接用 curl 测试我所有的 API。以下是它的回答:
curl 可以——但只适合调用一次。要跑一套测试就不行了。调用之间不会保留任何信息,所以我得重新推导认证方式、重新转义请求体,并重新阅读每个响应来判断是否通过。对 40 个端点都这样做的话,我大部分精力都花在重新摸索上,而不是测试。
自己编写集合文件更糟,而且原因和你想的不一样。凭记忆,我能写对
.bru文件的大致结构,却会弄错细节——Bruno 也不会报错。它只会读取自己认识的键,忽略其他内容。单行tags: smoke看起来像是加了标签,实际上对运行器来说却是没有标签。按直觉写一个 tags 列表,结果每个字符都会各占磁盘上一行。这个服务曾把.yml变量和断言写在 Bruno 从未读取过的顶层键下:文件看起来完整,运行器看到的却是空请求。它自己的单元测试也通过了,因为测试模拟了序列化器,并断言了这些错误的字节。这种格式也会变化。Bruno 把变量移到了
runtime,还新增了第二种方言。我的认知还停留在之前的版本。这个服务导入了 Bruno 自己的语法包@usebruno/lang,并跟踪其版本——因此生成的字节来自 Bruno 的源码,而不是来自我的记忆。而且每次重写都会删掉写入器无法建模的内容。如果我手动编辑这些文件,就会根据自己的理解重新生成整个文件,任何我不知道的功能都会悄无声息地消失。这种失败模式你永远不会察觉,因为运行仍然会通过——只是它什么也没测。
服务改为这样做
- Agent 不再猜测格式——它调用工具,由服务使用 Bruno 自己的语法包写入字节
- 编辑会进行部分合并——
write_request只修改你传入的字段,并保留文件的其他内容 - 可以先读再写——
read_request返回结构化 JSON,两种格式的结构相同 - 由服务自行运行请求——支持变量、认证、断言和依赖排序,无需
bru二进制文件 - 不会悄悄丢失内容——此服务尚无法建模的字段会在格式支持的范围内原样带回;任何无法写入文件的内容都会在运行警告中明确列出,而不是悄悄从你的代码库中丢失
与 Bruno 保持字节级一致
这是最难复制的部分,因此有必要准确说明这意味着什么。
该服务不会封装 bru 二进制文件,而是自行实现请求管线,因此才能支持将机密保存在内存中、进行线路级测试以及在运行过程中执行钩子。这种自由也伴随着风险:独立实现可能会与团队实际使用的工具存在细微而不易察觉的差异。两种机制可以确保实现保持一致。
规则是移植而来,而非推断得出。 重定向上限、超时解析、正文模式的内容类型、变量插值顺序、selected 默认值、URL 编码——每一项规则都取自 Bruno 自身的源码(bruno-cli、bruno-filestore、bruno-lang、@usebruno/common;该服务也直接依赖这些项目),并加以复现,包括那些看起来像错误的部分。两种方言存在分歧时,我们会分别按各自的规则复现,而不是将其统一成任何一种 Bruno 读取器都不会生成的形式。
漂移门禁可验证一致性。 该服务写入的每个文件都会在测试套件中按对应方言,交由 Bruno 自身的读取器重新解析。用我们自己的预期结果来验证字节,只能证明我们自洽;用 Bruno 自身使用的读取器来验证,才是发现输出不再是 Bruno 输入的唯一方法。它确实发现过问题——例如,某个文件正文虽然能被正常解析,却会以完全不带正文的方式发送。
因此,我们的主张是:运行行为与 bru run 一致,迄今发现的所有差异都已修复。让一致性得以维持的是测试,而非承诺。即便如此,如果你还是发现了差异,那就是值得提交 Issue 的缺陷。
约定
一个集合,三种使用方:你的 Agent、你的 CI,以及团队的 Bruno GUI。
两种 Bruno 格式均可使用,服务会自动检测你使用的格式:.yml(opencollection)和 .bru(legacy)。
需要 Node.js >= 22。CI 会测试 22.x 和 24.x。
我应该使用哪个 Bruno MCP 服务?
有几个可选项,而且它们做的确实是不同的事;坦白说,答案不一定总是这个。下表中的每一项都根据对应项目自己的 README 或源代码整理,时间截至 2026 年 8 月。
| 服务 | 功能 | 写入 | 读取 | 运行 | .bru / .yml | 需要 bru | 工具 |
|---|---|---|---|---|---|---|---|
| 本项目 | 创建、读取并运行集合 | 创建 + 部分合并编辑 | 结构化 JSON | 是,使用自有管线 | 两者都支持 | 否 | 18 |
| usebruno/bruno-mcp | 官方版本,由 Bruno 团队开发——发现并运行请求。于 2026 年 8 月发布;撰写本文时,其首个实现仍是公开草案 | 否 | 请求元数据 | 是,通过 CLI | .bru | 是(已捆绑) | 3 |
@dmpv/bruno-mcp | 对集合进行只读索引和搜索 | 否 | 排序搜索、净化后的契约、已存储示例 | 否 | 两者都支持 | 否 | 8 |
| hungthai1401/bruno-mcp | 运行集合 | 否 | 否 | 是,通过 CLI | .bru | 是 | 仅运行 |
| jcr82/bruno-mcp-server | 运行并检查集合,生成报告文件 | 否 | 是 | 是,通过 CLI | .bru | 是 | 9 |
| djkz/bruno-api-mcp | 将每个请求转换为独立的 MCP 工具 | 否 | 将请求作为工具公开 | 一次一个 | .bru | 否 | 每个请求一个 |
| macarthy/bruno-mcp | 生成集合文件——本项目分叉自此项目,该项目自 2025 年 7 月起不再活跃 | 仅创建 | 否 | 否 | .bru | 不适用 | 8 |
如果你想要由 Bruno 自己维护的服务,以及由此带来的支持和持续维护保障(usebruno——官方版本;等它发布后,将是“发现并运行”这一用途的合理默认选择);想让 Agent 理解大型现有集合,同时完全避免写入风险,并按意图进行搜索(dmpv,首次发布于 2026 年 7 月,目前为 0.x 版本——虽新,但很有意思);已经在镜像中安装了 bru CLI,且只需要“运行这个集合”(hungthai1401);或者想让 Agent 通过现有请求调用你的 API,让每个请求都像原生工具一样,那么请选择其他版本。
如果你想让 Agent 写入集合,而不只是读取或运行;使用 .yml opencollection 格式;需要无需安装 Bruno CLI 即可运行;或者在意写入仓库的内容能否与 Bruno 应用写出的内容进行逐字节比较,那么请选择本项目。
特别说明一下本项目的分叉源,因为本项目正是由它而来:macarthy/bruno-mcp 注册了八个工具——create_collection、create_request、create_environment、create_crud_requests、create_test_suite、add_test_script、list_collections、get_collection_stats。它会写入 .bru 文件,但不会读取请求、运行任何内容,也不提供编辑工具;它的 src/bruno/request.ts 中虽然存在 updateRequest 辅助函数,但没有 MCP 工具会调用它。
功能
编写
- 集合 — 创建和整理集合,或发现 Bruno 已通过
workspace.yml识别的集合。此服务创建的集合会写入磁盘,但不会登记到该文件中,因此list_collections不会显示它,Bruno GUI 也不会列出它,直到有人在 GUI 中打开一次——其他所有操作都会直接执行 - 请求 — 支持所有 HTTP 方法,以及请求头、查询参数和路径参数、请求体、身份验证、断言、变量和设置
- 读取内容 —
read_request和read_environment会返回结构化 JSON,无论文件是.bru还是.yml,结果都相同,Agent 可以在编辑前检查内容 - 部分合并编辑 —
write_request只更改你传入的字段,并保留文件其余部分不变 - 增删改查和套件 — 创建包含五个请求的增删改查集合,以及按拓扑顺序排列依赖项的测试套件
- 环境 — 创建、替换、合并环境,或修补单个变量
- 双格式 — 自动检测
.bru(旧格式)和.yml(opencollection);读取.yaml并标记 - 多部分上传 — 支持带有各部分
Content-Type和多文件字段的form-data
运行
- 执行组 — 在一次调用中将一个集合作为多个隔离组运行:使用不同身份和环境,可串行或并发执行,组间不会相互影响
- 真正的并行 — 并发运行多个组,或运行组内请求,并发上限会根据机器性能设置
- Cookie jar — 登录状态会沿用到后续请求,仅限所属组使用,且绝不会写入磁盘
- 变量链式传递 — 可在多个请求间使用
bru.setVar()/bru.getVar(),并通过captureVariables读取变量值 - 异步脚本 — 支持顶层
await、bru.sleep(ms),以及在沙箱中使用setTimeout/setInterval - 内联脚本 — 创建或修改请求时,可直接附加请求前、响应后和测试脚本
- 自动应用身份验证 — 支持 bearer、basic、api-key、digest、OAuth 2.0(客户端凭据和密码授权),也可从集合或文件夹继承(
inherit) - 如实报告结果 — 报告每个组的摘要、捕获的响应体、每个请求的警告、解析失败和缺失的请求;组崩溃不会导致运行结果显示为成功
安全
- SSRF 防护 — 应用于每个请求和每次重定向,并固定已批准的地址
- 路径限制 — 约束请求引用、集合根目录、环境名称和文件上传路径
- 进程隔离脚本 — 使用独立的 V8 沙箱,清理环境变量并强制终止进程
安装
无需安装。 将客户端指向 npx,首次运行时它会获取已发布的软件包:
npx -y @ostico/bruno-mcp
这就是全部安装步骤,也是下方客户端配置使用的方式。软件包附带来源证明,因此 npm 可以显示构建你正在运行的 tarball 所用的提交和工作流。
固定版本,如果你不希望在启动时解析版本:
npm install @ostico/bruno-mcp
这会在 node_modules/.bin/ 中放置一个 bruno-mcp 可执行文件,并将服务本身放在 node_modules/@ostico/bruno-mcp/dist/index.js。
从源代码安装,用于开发或运行某个分支:
git clone https://github.com/Ostico/bruno-mcp-studio.git
cd bruno-mcp-studio
npm install # npm, not yarn — the yarn lockfile is stale
npm run build
无论采用哪种方式,都需要 Node.js >= 22。
连接客户端
任何 MCP 客户端都可以使用。 这是一个纯 stdio MCP 服务,没有针对特定客户端的代码:无论你的客户端如何调用它,只需将其指向
command: npx
args: ["-y", "@ostico/bruno-mcp"]
Claude Code 使用一行配置:
claude mcp add bruno -- npx -y @ostico/bruno-mcp
Claude Desktop、Claude Code、Cursor、Codex CLI、opencode、Windsurf、Zed、Cline、Continue、LM Studio、Gemini CLI、MCP Inspector,以及你自己的 SDK 客户端——都使用同一个服务。下面的内容不是兼容性列表,只是各客户端存放配置的位置。
大多数客户端使用相同的 JSON 格式:
{
"mcpServers": {
"bruno-mcp": {
"command": "npx",
"args": ["-y", "@ostico/bruno-mcp"],
"env": {}
}
}
}
运行克隆版本或固定版本的安装时,配置相同,只需将 "command": "node" 和 "args": ["/absolute/path/to/dist/index.js"] 设为对应值。
| 客户端 | 配置位置 |
|---|---|
| Claude Desktop | macOS ~/Library/Application Support/Claude/claude_desktop_config.json · Windows %APPDATA%/Claude/claude_desktop_config.json · Linux ~/.config/Claude/claude_desktop_config.json |
| Claude Code | claude mcp add,或项目中的 .mcp.json |
| Cursor | 项目中的 .cursor/mcp.json,或全局配置文件 |
| Codex CLI | ~/.codex/config.toml,位于 [mcp_servers.bruno-mcp] 表中(TOML,字段相同) |
| opencode | opencode.json,在 mcp 下配置为本地服务(使用其自身的格式) |
| 其他 | 按照相应客户端的文档配置——上面的命令和参数就是全部所需内容 |
配置格式由客户端决定,而非本服务,并且可能会变更。如果客户端的格式与上面的 JSON 不同,请遵循客户端文档;这里只需关注 command 和 args。
查看 INTEGRATION.md,了解示例、Docker 和故障排查。
快速开始
// 1. create a collection
{ "name": "my-api", "outputPath": "./collections", "baseUrl": "https://api.example.com" }
// 2. add a request with a test
{ "collectionPath": "./collections/my-api", "name": "Get Users", "method": "GET",
"url": "{{baseUrl}}/users",
"scripts": { "tests": "test(\"ok\", function() { expect(res.getStatus()).to.equal(200); });" } }
// 3. run it
{ "collectionPath": "./collections/my-api" }
工具
共 18 个工具。文件路径必须是绝对路径,或相对于集合的路径。
| 工具 | 功能 |
|---|---|
create_collection | 创建新集合。format: "yaml"(默认)或 "bru"。也会将集合注册到工作区,这样 list_collections 和 Bruno 应用就能看到它——设置 registerInWorkspace: false 可跳过注册,设置 workspacePath 可指定文件 |
list_collections | 从 Bruno 的 workspace.yml 中查找集合 |
get_collection_stats | 按方法统计数量,以及文件夹、环境和带 URL 的请求列表——可按 folder、method、nameContains 筛选;设置 includeRequests: false 则仅返回统计数量 |
write_request | 写入请求:method、url、headers、query、body、auth、scripts、settings。对于相应传输方式,设置 kind: "websocket" 或 kind: "grpc"。传入 collectionPath 和 name 可创建请求,传入 filePath 可编辑请求——编辑会进行部分合并,filename 可重命名文件 |
move_request | 将请求移动或复制到其他文件夹或集合 |
read_request | 将一个请求读取为 JSON,.bru 和 .yml 格式相同 |
list_requests | 列出集合中的所有请求文件,返回绝对路径 |
delete_request | 删除一个或多个请求文件。需要设置 confirm: true |
add_test_script | 为现有请求附加脚本(默认追加) |
remove_script | 移除一个脚本,但保留请求 |
create_environment | 创建新环境文件。除非设置 overwrite: true,否则不会覆盖现有文件 |
read_environment | 返回带有 disabled/secret 标记的变量。省略 name 可列出环境 |
update_environment | 替换或合并环境变量 |
set_environment_variable | 添加或更改一个变量 |
remove_environment_variable | 删除一个变量 |
run_collection | 执行请求、运行测试并返回结果 |
先读后写
read_request 会返回 method、url、headers、query 和 path 参数、body、auth mode、scripts、assertions、vars、settings 和 docs——两种格式的结构完全相同,因此磁盘上的格式不会显露出来。其 notes 数组会列出文件中声明但运行器不会执行的内容。
编辑前先使用它查看当前状态,写入后再使用它确认写入的内容。
read_environment 会返回每个变量及其值。密钥只会按名称返回——Bruno 在任一格式中都不会为密钥存储值,因此没有值可返回。
写入请求
传入 collectionPath 和 name 时,write_request 会创建请求;传入 filePath 时,则会编辑请求。编辑采用合并方式:省略的字段会保持不变。
值得注意的选项:
body.type—json、text、xml、sparql、graphql、form-urlencoded、form-data、file、binary、nonebody.type: "form-data"— multipart 上传、每个部分的contentType、多文件字段auth.type—bearer、basic、api-key、digest、oauth2、inherit、nonescripts— 内联pre-request、post-response、tests(无需单独调用add_test_script)settings.timeout— 脚本和请求的超时时间,单位为毫秒
name 和 filename 相互独立,与 Bruno 本身的行为一致:name 会更改文件内请求的名称,filename 会移动文件,因此要让两者保持一致,请同时传入它们。filename 是请求所在文件夹中的基本文件名,扩展名可选;若提供扩展名,则必须与集合格式匹配。若其他文件已使用该名称,则操作会被拒绝。响应中会返回移动后的路径——之后将其用作 filePath。
默认情况下,write_request 会替换同类型的脚本,因此重复调用具有幂等性。传入 scriptMode: "append" 可进行拼接。add_test_script 默认会追加,因为它执行的是添加操作。
在 .yml 集合中,post-response 和 tests 共用 Bruno 的单个 after-response 插槽,因此替换其中任何一个都会覆盖两者。
移动请求
move_request 会移动请求文件——可以移入其他文件夹,也可以通过 targetCollectionPath 移入其他集合。传入 copy: true 可改为复制。
文件会按原样逐字节移动,绝不会解析并重写,因此请求声明的任何内容都不会在移动过程中丢失。由此会产生两点影响。文件会保留原名,因此复制时需要使用不同的文件夹或集合;重命名请使用 write_request。此外,seq 会原样保留,因此请求可能会与声称使用相同序号的同级请求放在一起——系统会报告这种情况,而不会修正,因为重新编号意味着重写文件。Bruno 会按文件名打破此类平局,因此无论哪种情况,顺序都是确定的。
如果目标文件夹不存在,系统会创建该文件夹并报告此情况:没有设置文件的文件夹不包含文件夹级别的 auth、headers 或 scripts。
运行
{
"collectionPath": "./collections/my-api",
"environment": "dev",
"requests": ["auth/login.bru", "users"]
}
| 参数 | 含义 |
|---|---|
collectionPath | 集合,或集合的子文件夹 |
requests | 按顺序排列的请求文件和/或目录列表。省略则运行全部请求。[] 表示不运行任何请求 |
groups | 将集合分成多个互相隔离的组运行——见下文。不能与 requests 一起使用 |
environment | 环境名称,从 environments/<name>.yml 加载 |
collectionRoot | 当运行子文件夹时,collectionPath 所属的集合。必须是该路径本身或其祖先路径 |
variables | 仅用于本次运行的 {name: value}。绝不会写入磁盘——传递密钥的正确方式 |
captureVariables | 需要返回其值的 bru.setVar 变量名称 |
parallel | 并发运行各组。默认值为 false |
maxConcurrency | 同时执行的请求数量上限。省略时根据机器配置确定;设为 0 则不设上限 |
bail | 在首次失败时停止,而不是继续运行其余请求。默认值为 false |
cookieJar | 在整个运行期间保留 Cookie,使登录状态得以延续。默认值为 true |
includeResponseBody | 包含响应正文。默认值为 true |
maxResponseBodyBytes | 超过此大小时截断正文。默认值为 10240 |
report | 同时将运行结果写入磁盘——见报告文件 |
requests 中的目录会展开为其下的请求:在每个文件夹内按 seq 排序,先处理子文件夹;若排序相同,则按文件名排序。重复项会照常执行:同一个请求写两次,就会运行两次。
默认情况下,运行不会提前停止。请求失败、文件无法解析、名称匹配不到任何内容——这些情况都会逐一报告,并继续运行。
在首次失败时停止
bail: true 会在首次请求失败或其测试失败时停止运行。登录失效后还有 23 个请求,这 23 个失败都源于同一个原因,而这个原因反倒最不显眼。
{
"collectionPath": "./collections/my-api",
"bail": true
}
运行中未执行到的所有请求都会按原顺序返回,并标记为 skipped: true,同时带有 skipReason: "bail",以及原本要发送的 method 和 URL。这些请求会计入 summary.skipped,但既不计入 passed,也不计入 failed,因此 passed + failed 仍等于 total,不会让被截断的运行看起来像是一次更短且全部通过的运行。运行结果本身还会增加一个 bail 对象:
{
"bail": {
"reason": "test failure",
"at": "Login",
"path": "/collections/my-api/auth/login.bru",
"group": 0,
"skipped": 22
}
}
reason 的值可以是 request failure(没有收到响应)或 test failure(收到了响应,但某项检查失败)。后续组会整体跳过。
已在执行的请求不会被取消。使用 parallel 时,或某个组中的请求本身并发执行时,已经启动的请求仍会执行完毕,并正常报告结果——运行结果会在 warnings 中说明这一点,而不必让你从数量中自行推断。
执行组
一个组是在一次调用中独立运行的一组操作。它拥有自己的请求列表、环境、变量、parallel 标志、变量存储、Cookie jar 和 OAuth2 令牌。无论 parallel 设置为何值,组与组之间都不会相互传递任何内容。
以两个用户身份运行相同的请求,确保一个用户的登录令牌或会话 Cookie 不会传给另一个用户:
{
"collectionPath": "./collections/my-api",
"parallel": true,
"groups": [
{ "name": "alice", "requests": ["auth/login.bru", "orders"], "variables": { "user": "alice" } },
{ "name": "bob", "requests": ["auth/login.bru", "orders"], "variables": { "user": "bob" } }
]
}
parallel: true 会让两个组同时运行。每个组自己的请求仍按顺序执行;当 orders 依赖先前的登录时,这正是你需要的行为。
针对两个环境运行同一个套件:
{
"groups": [
{ "name": "staging", "requests": ["smoke"], "environment": "staging" },
{ "name": "production", "requests": ["smoke"], "environment": "production" }
]
}
组字段:name、requests、environment、variables、parallel、startAfter、data、dataFile。
docs/execution-groups.md 介绍了整个模型:组拥有的内容、两个 parallel 标志及其默认值、执行顺序、对数据行的迭代、并发上限,以及各层级失败时的表现。
- 省略
requests,即可在该组的身份下运行整个集合。空数组[]则什么也不运行。 environment替换运行级环境;variables则与运行级变量合并,以组内设置为准。- 在组上设置
parallel,即可并发运行该组自己的请求。它们共享该组的变量存储,因此确实可能同时争用同一个bru.setVar——这正是重现竞争条件时需要的情况。请将maxConcurrency至少设为竞争请求的数量,否则并发上限会在不作提示的情况下让它们串行执行。 startAfter: { group, requestsCompleted }会让一个组等待,直到另一个组执行到指定进度——这样可以在触发器触发前连接监听器,不必根据当天的延迟情况调整bru.sleep。需要启用运行级parallel;失败的请求仍计为已到达的位置;在任何操作运行前,循环依赖和永远无法打开的门控都会被拒绝。
结果
结果按组组织。没有顶层 results 数组,即使你没有传入 groups 也是如此——这种情况会被视为一个组;如果将结果展平,所有调用方就都得检查自己是通过哪种方式调用的。
{
"summary": { "total": 4, "passed": 3, "failed": 1, "duration_ms": 1250 },
"groups": [
{
"name": "alice",
"index": 0,
"summary": { "total": 2, "passed": 2, "failed": 0, "duration_ms": 620 },
"results": [
{
"name": "Get Users",
"method": "GET",
"url": "https://api.example.com/users",
"status": 200,
"duration_ms": 312,
"tests": [{ "description": "ok", "status": "pass" }],
"response_body": "[{\"id\":1}]",
"response_content_type": "application/json",
"response_body_truncated": false,
"response_headers": {
"content-type": "application/json",
"strict-transport-security": "max-age=31536000",
"set-cookie": ["session=[redacted]; HttpOnly; Secure; SameSite=Lax"]
}
}
],
"capturedVariableNames": ["authToken"]
}
]
}
每个组都有自己的 summary、results、missingRequests、capturedVariableNames、capturedVariables 和 warnings。顶层 summary 汇总整个运行。
response_headers 无需标志或测试脚本。名称与凭据相关的值会被遮蔽,而 set-cookie 是一个列表——每个 Cookie 各占一项,因为用逗号拼接后就无法再拆分;列表中的每一项都会保留所有属性,仅隐藏 Cookie 值。因此,检查 HttpOnly、Secure、SameSite 或 Strict-Transport-Security 只需一次调用。includeResponseBody: false 不会抑制这些信息:该标志针对的是响应正文的大小。
WebSocket 结果也包含 response_headers,其中保存握手响应——对于这种传输方式,会话 Cookie 或协商好的 sec-websocket-protocol 只会出现在 101 响应中,因为帧没有标头。gRPC 结果则会在其自身的 grpc 详情中报告元数据。
完全无法启动的组会报告 error 而非结果,并计为一次失败——否则,即使某个组已失效,整个运行看起来仍会是成功的。
运行级字段:parseErrors 和 parseFailures 列出无法解析的文件;warnings 则汇集其他值得关注的信息。
脚本
脚本在分叉进程内的 V8 上下文中运行(参见 安全性)。两种脚本都是异步函数,因此顶层可以使用 await。
测试和响应后置脚本可使用 test()、expect()、res 和 bru:
| API | 说明 |
|---|---|
test(name, fn) | 包装断言。必须使用,否则不会报告 |
expect(v) | Chai 风格:.to.equal、.include/.contain、.match、.have.property/.lengthOf/.keys、.be.above/.below/.least/.most/.oneOf、.throw,以及适用于上述所有方法的 .to.not.* |
res.getStatus() res.getStatusText() | |
res.getHeader(name) res.getHeaders() | 查找请求头时不区分大小写 |
res.getSetCookies() | 响应设置的 Cookie |
res.getBody() | 当媒体类型的子类型为 json 或以 +json 结尾时,已解析为对象 |
res.getResponseTime() | 毫秒 |
res(path, ...fns) | Bruno 的请求体查询语言:res("data.pets..name") 会向下查找所有 name,[0] 用于索引,[?] 使用回调进行筛选或映射。也可用作断言的左侧表达式,此时该语法无法单独使用 |
bru.setVar(name, v) bru.getVar(name) | 将值传递给后续请求,作为 {{name}} 使用 |
bru.sleep(ms) | 也可使用 setTimeout/setInterval 及其 clear* 方法 |
atob(s) btoa(s) | 两种脚本中均可使用 base64 函数。无需再次请求即可读取 JWT 载荷 |
请求前置脚本则可使用 req 和 bru——此时还没有响应。修改 req 会改变实际发送的内容:req.getUrl()、req.setUrl()、req.getMethod()、req.getHeader()、req.setHeader()、req.getHeaders()、req.getBody()、req.setBody()。
两个容易踩坑的地方
请用 test() 包装断言。 单独通过的 expect() 不会被记录,因此运行结果会显示 "tests": [],但请求仍会被视为通过——显示绿色,却没有任何断言。运行器会检测到这一点,并在该结果的 warnings 中提示。单独的失败断言不会被忽略:它会抛出异常,并作为脚本错误报告。
test("status is 200", function() { // ✅ recorded
expect(res.getStatus()).to.equal(200);
});
expect(res.getStatus()).to.equal(200); // ❌ runs, passes, reported nowhere
不要对 res.getBody() 使用 JSON.parse()。 当媒体类型的子类型为 json 或带有 +json 后缀时,它已经是对象——application/json、text/json、application/vnd.api+json——因此再次解析会抛出 SyntaxError: "[object Object]" is not valid JSON。直接读取字段即可。如果端点可能返回其中任意一种类型,请用分支处理:typeof b === "string" ? JSON.parse(b) : b。
读取令牌中的声明无需再次请求。atob 和 btoa 均可使用,
名称与 Bruno 自带沙箱中的名称相同,因此通常的 base64url 处理方式也适用:
test("the token is for the user we logged in as", function() {
const payload = res.getBody().token.split(".")[1];
const claims = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
expect(claims.uid).to.equal(bru.getVar("expectedUid"));
});
不提供 Buffer。它是一个宿主类,带有沙箱不应开放的功能;要忠实模拟它,就得提供一个功能不全的替代品,而这些缺陷你会逐一发现;直接引用会抛出 Buffer is not defined,并作为脚本错误报告,而不会悄悄产生错误行为。
休眠时间会计入脚本超时——settings.timeout,未设置时为 5000 ms。在默认设置下执行 await bru.sleep(10000) 会报告超时,而不是继续等待。
环境和变量
集合中的环境文件为 environments/<name>.yml:
name: dev
variables:
- name: baseUrl
value: https://api-dev.example.com
- name: apiKey
value: dev-key-123
- name: skipped
value: whatever
disabled: true
工具接受一个扁平对象作为变量({"baseUrl": "..."}),并会为你写入该数组。
{{name}} 会替换到 URL、标头、正文和身份验证中。已禁用的变量会被跳过;未解析的变量会保留原样,并在运行警告中列出。
优先级从低到高依次为:环境文件 → 运行 variables → 请求自身的 vars → 运行期间的 bru.setVar。这与 Bruno 的 --env-var 行为一致。
变量可以由其他变量构成:base_url: "https://{{host}}/{{stage}}" 会像在 bru run 中一样解析,使用 Bruno 自己的 interpolate。有一个刻意为之的例外:从响应中捕获的值——通过 bru.setVar 或响应后的 vars 块捕获——会作为文本插入,且不会再次扫描,因此响应中回显的 key={{api_key}} 不会导致下一个请求发送你的密钥。
生成器。 {{$guid}}、{{$timestamp}}、{{$randomEmail}} 以及 Bruno 约 120 个动态变量中的其他变量,可用于 URL、标头、查询参数、正文和身份验证。它们不是变量:无需声明,每次出现都会生成自己的值,也不会被报告为未解析。没有生成器响应的关键字——{{$gid}}——会保留原样并在警告中列出,因此拼写错误仍会显现。在 JSON 正文或 GraphQL 变量块中,生成的值会进行转义,因此生成换行符的生成器({{$randomLoremParagraphs}})不会导致文档无法解析。
密钥: Bruno 格式都不会存储密钥的值——只存储其名称。因此,请将密钥作为运行 variables 传入;它们会留在内存中,绝不会写入文件。
环境名称是名称,不是路径。任何包含分隔符的内容都会被拒绝。
报告文件
run_collection 以 JSON 形式返回结果,供 Agent 读取。测试运行的另外两类使用者会读取文件,因此 report 会写入这些文件:
{ "collectionPath": "/path/to/collection",
"report": { "junit": "reports/junit.xml", "html": "reports/run.html" } }
指定任意一种格式,也可以两种都指定。随后,结果中会包含 reports,每个写入的文件对应一项,其中包含该文件的绝对路径和字节大小。
路径仅限于集合内。 如果路径解析后指向集合之外,系统会拒绝该路径,并将原因作为运行警告;运行本身仍会成功,因为结果才是请求的内容,文件只是副产品。如果你的工作流会从其他位置收集报告,请在之后复制文件——允许文件写入调用方指定的任意位置,其授权范围远大于运行其请求。集合内缺失的父目录会自动创建,已有报告会被覆盖。
JUnit XML 遵循 bru run --reporter-junit:每个请求对应一个 <testsuite>,每项断言或测试对应一个 <testcase>,出错的请求会作为套件错误报告。它有四处不同之处,每一处都是因为另一种做法会让报告看起来比实际运行结果更绿:
- 对于已运行但未验证任何内容的请求,会添加一个说明情况的跳过状态
testcase,而不是空套件。空套件在所有 CI 汇总中都不可见,这恰好会造成“运行通过,却什么都没检查”的误读;requestsWithoutTests计数器正是为了揭示这种情况而存在。 - 无法解析的请求文件、解析后找不到的指定请求,以及发生崩溃的组,各自都会有独立套件。只列出已执行内容的报告会显示部分运行,却不说明这只是部分内容。
- 指定组的标签会合并到套件名称中,因为 JUnit 没有组的概念;否则,同一请求在两种身份下运行时将无法区分。
- 不包含
hostname属性。上游会将机器名称写入文件;这些报告是为了提交到代码库而设计的。
HTML 报告是 Bruno 自有的报告,由 @usebruno/common 渲染,并以执行组作为迭代——以两种身份运行时,报告会显示两个区块。需要注意两点:页面会嵌入运行数据,但会从 unpkg.com 加载 Vue 和 naive-ui,因此打开时需要网络连接,离线时不会显示任何内容;请求面板为空,因为结果不会保留发送时的请求内容。出于同样的原因,断言和脚本测试共用一个列表——结果无法区分二者。
报告在磁盘上保存结果中包含的内容:包括响应正文,响应头也会像 JSON 中一样进行遮蔽。
格式
| 集合中的标记文件 | 格式 |
|---|---|
opencollection.yml | YAML — 优先检查 |
bruno.json | BRU(旧版) |
| 均不存在 | YAML |
除非传入 format: "bru",否则新集合均采用 YAML 格式。
.yaml 请求文件会像 .yml 一样按 YAML 读取,因为其他 Bruno 相关工具会生成这类文件。但 Bruno 自带的应用和 bru run 都无法识别该扩展名,因此每个已读取的 .yaml 文件都会列在运行警告中——如果悄无声息地跳过,就会导致运行显示通过,但 Bruno 实际上无法看到该请求。将扩展名改为 .yml 即可清除警告。此服务不会写入 .yaml 文件。
gRPC 和 WebSocket 请求
集合中可以同时包含 gRPC 和 WebSocket 请求以及 HTTP 请求。此服务会读取、保留并报告这些请求:read_request 会返回类型、目标、gRPC 的方法和 proto 路径、元数据块,以及存储的消息数量;list_requests 会列出它们;编辑集合中的任意请求也不再会破坏这些请求。在此之前,这两种格式都会丢失目标块、凭据和所有已存储消息,因此,对无关请求执行一次 write_request 就会在重写文件时将这些内容一并删除。
run_collection 会同时运行两者。 gRPC 请求会根据其 .proto 声明的服务执行一次一元调用;WebSocket 请求会打开套接字,发送文件中存储的帧,并记录收到的内容,直到达到上限。两者各自报告详情:gRPC 结果包含 gRPC 状态码、详情字符串和经过脱敏的尾随元数据;WebSocket 结果包含会话记录、结束会话的 stop_reason,以及是否发生了截断。gRPC 代码存放在自己的字段中,绝不会映射到结果的 status,因为 gRPC 的 OK 是 0,而 0 是此 API 表示拒绝的哨兵值——否则,成功调用和安全拒绝在首先读取的字段中就无法区分。
WebSocket 会话没有自然的结束点,因此需要设定上限;每项上限都可以在每次运行的 run_collection websocket 参数中设置:
| 上限 | 默认值 | 作用 |
|---|---|---|
maxMessages | 50 | 停止前记录的入站帧数 |
maxDurationMs | 5000 | 单次会话的最长时长 |
idleTimeoutMs | 1500 | 结束会话的静默时长;0 表示一直等待到时长上限 |
sendIntervalMs | 0 | 请求发送的消息之间的间隔;0 表示在同一个时钟周期内发送 |
includePayloads | false | 记录帧内容,而不只是大小和时间 |
maxFrameBytes | 65536 | 记录载荷的单帧上限 |
maxTranscriptBytes | 1048576 | 累计上限,按线路上的字节数计算 |
engineIoKeepalive | false | 收到 engine.io 的 2 时回复 3 |
时长上限是安全上限,而不是发送计划,因此通常由 idleTimeoutMs 结束会话:当连续 1500 ms 没有收到任何内容时,会话就会停止并报告 stop_reason: "idle"。这不算截断,因为没有触发任何上限标志,且时长上限也未耗尽。计时从收到第一帧时开始,而不是从连接时开始,因此不发送任何消息的纯监听请求仍会等待完整的 maxDurationMs,以便接收之后可能发言的对端。对于消息间隔长于响应间隔的协议,请将其设为 0。
sendIntervalMs 可用于实现发送—等待—发送协议。默认值 0 会让请求中的所有消息在同一个时钟周期内发出,因此所有回复都会在最后一条消息发出后到达,无法断言消息的交互顺序;设置间隔后,会话记录便会包含发送之间收到的每条回复,并标出其实际到达时间。需要了解两个后果。maxDurationMs 必须足以覆盖整个定时发送序列——如果会话在发送到一半时停止,会按消息在请求中指定的名称列出未发出的消息,而不是留下少发一条消息的会话记录,让人误以为对端停止了响应。序列尚未发送完毕时,不会启动静默计时,因此即使 sendIntervalMs 长于 idleTimeoutMs 也没有问题:请求在自身消息之间有意留出的间隔,不算对端的静默。
子协议通过请求中的 Sec-WebSocket-Protocol 标头指定;有多个时以逗号分隔,并在握手时协商。服务端同意使用的子协议会出现在该结果的 response_headers 中。这里和 Bruno 中都没有单独的字段来存放它。过去,写入此标头反而不如省略它:该库会根据连接时提供的列表验证服务端的回复,因此服务端明明完全按标头要求执行,握手却仍会被拒绝,因为服务端提供的子协议不在请求列表中。出于同样的原因,请求中指定的 Sec-WebSocket-Version 也会以同样方式处理。
每条传输记录都会说明帧的类型——text、binary、ping、pong 或 close——记录会话发送的消息所设置的 title,并且对于关闭帧,还会记录对端提供的 close_code,其原因则作为该条记录的载荷:1000 表示正常告别,1006 表示对端消失且未正常关闭,1008 表示拒绝,1011 表示服务端错误。控制帧不计入 maxMessages,否则每秒发送一次 ping 的对端就会自行结束会话,并且在没有收到回复时报告 count。二进制帧的载荷为 base64,所有类型的帧中,bytes 都表示实际在线路上传输的大小。后置响应脚本看到的字段也相同,因为在这种传输方式下,传输记录就是 res.body。
断言 gRPC 或 WebSocket 结果
这两种传输方式都会运行后置响应脚本和测试脚本,并且 res 的结构经过设计,只需了解一种格式,而非两种。与 HTTP 不同之处值得明确说明,因为猜错会导致测试永远无法失败。
对于 WebSocket 请求:
res.getBody()是传输记录——与结果中携带的数组相同,传给脚本的是结构化数据,而非 JSON 文本。res.rawBody则保留序列化后的形式。res.getStatus()始终为0。会话没有状态码,凭空编造一个还不如没有。结果体现在res.statusText中,其中包含停止原因(count、timeout、bytes、closed或error)。- 因此,WebSocket 断言应检查帧和
statusText。基于res.getStatus()编写的测试断言的是一个常量。
test("the server answered our subscribe", function() {
const inbound = res.getBody().filter(f => f.direction === "in" && f.type === "text");
expect(inbound.length).to.be.at.least(1);
expect(inbound[0].payload).to.contain('"subscribed"');
expect(res.statusText).to.equal("count");
});
无论 includePayloads 的值如何,脚本看到的载荷始终是真实载荷。 该标志控制的是 结果 中的传输记录,而不是 res 中的传输记录,因为出站帧是在 {{var}} 插值之后记录的,而默认返回的结果不应包含你传入的所有机密信息。这与 HTTP 已有的区别相同——res.body 始终包含完整请求体,而 response_body 是否包含请求体则由 includeResponseBody 控制。这意味着在 CI 中,includePayloads: false 并同时进行内容断言才是预期用法,而不是权宜之计:断言会检查载荷,返回内容则只包含方向、时间和大小。
对于 gRPC 请求,res 更接近 HTTP:res.getStatus() 是 gRPC 状态码(0 表示 OK),res.statusText 是服务端提供的 details(如果有),否则为该状态码的规范名称;res.getBody() 是解析后的响应消息,响应 trailers 则以 headers 的形式传入。
出于安全考虑,includePayloads 默认关闭,而非由用户偏好决定:出站帧是在替换 {{var}} 之后记录的,因此若默认记录,就会把 variables 中传入的所有机密信息写入默认返回的结果。出于相关原因,engineIoKeepalive 也默认关闭——它会在线路上发送请求未声明的帧;即使启用,也只会在实际收到 OPEN 帧后才作出回复。
现在可以直接编写 WebSocket 请求,而不必复制现有请求。write_request 接受带有 url 和 websocket.messages 的 kind: "websocket",并会拒绝该传输方式不支持的字段:HTTP 方法、请求体、查询参数和路径参数。每条消息都包含 content,还可选择包含 title 和类型 text 或 binary;未命名的消息会按位置命名为 message 1、message 2,与 Bruno 的命名方式完全一致。Headers、auth、assert、vars、settings 和脚本的用法与 HTTP 请求相同;以两种格式写入的文件都与 Bruno 为相同请求写出的文件逐字节一致——这一点已通过上游自带的写入器验证,而非通过我们的解析器往返验证。
两个格式中,一个字段的记录方式不同。selected: false 表示消息已编写但未发送。.yml 会写入这个 false 值。.bru 只表达 true 的情况:上游的写入器会在标志设为 true 时写入该标志,否则不写;读取器则会将缺失的标志解析为 false。因此,在这种格式中,未选中的消息和未标记的消息是同一回事,两者都不会发送。运行时也遵循这种解析方式,这意味着手写的 .bru 消息只有在写明 selected: true 时才会发送;所有因缺少该标志而跳过的消息都会列在结果的警告中,所以现在不发送任何内容的请求会说明原因,而不是报告会话为空。将未选中的消息写入 .bru 集合时,会像 Bruno 一样不带标志写入,因此文件的行为符合预期;这种格式唯一丢失的是报告信息,因为读回请求时,发现的是缺少标志,而不是值为 false。
gRPC 请求的编写方式相同,使用 kind: "grpc":包含一个 url,以及 grpc 下的完全限定 method、protoPath、methodType 和 messages。它同样不接受 HTTP 方法、请求体、查询参数和路径参数。编写前,有三点值得了解。
请求头会变成元数据,这是该传输唯一的请求头形式——Bruno 的 gRPC 读取器不会查看 gRPC 请求中的 headers 块,因此 headers 参数会改为写入 metadata。protoPath 必须已存在于集合中,无论你使用哪种写法,都会以相对于集合的路径存储,因为将绝对路径写入共享文件,就等于提交操作者的目录布局。解析到集合外部的路径会被拒绝,包括符号链接;导入文件时,无论经过多少层级,只要路径离开集合,也会被拒绝(众所周知的 google/protobuf/ 导入并非文件,因此不会被拒绝)。methodType 的四种值都可接受,因为 Bruno 会写入全部四种;但这里只能运行 unary,因此其他三种值会生成 Bruno 可以打开的文件,而 run_collection 会按名称拒绝运行。与 WebSocket 一样,两种格式中的字节内容都与 Bruno 自己的写入器完全一致,包括两种格式在拼写上的差异:.bru 在 grpc 块中写入 protoPath,.yml 则写入 protoFilePath。
write_request 可以编辑这两种传输。 url、请求头、身份验证、assert、vars、settings、name 和 sequence 均适用,嵌套的 websocket 或 grpc 对象也一样——包括其中的消息,以及 gRPC 的 method、protoPath 和 methodType。每个字段都会写入该传输对应的位置,因此编辑 gRPC 请求头会写入 metadata,绝不会写入 headers 块。编辑未指定的所有内容都会保持字节级不变;这点在这里比 HTTP 更重要:编辑会根据解析后的模型重新生成整个文件,因此模型未保留的内容会不作提示地丢失。
仍会被拒绝的是该传输确实无处可放的内容——HTTP 方法、请求体、查询参数、路径参数,以及另一种传输的对象;拒绝时会指出具体名称,并保持文件字节不变。过去,url、请求头和身份验证也会被拒绝,这意味着 WebSocket 请求的目标在文件整个生命周期内都无法更改。
请求前脚本会在两种传输上运行,并能访问各自实际拥有的内容。bru.setVar 会生效,值也会传递给同一请求自己的 {{placeholders}},因此脚本可以计算房间名、主题或目标,然后发起连接。req.setUrl 会替换目标。req.setHeader 会写入传输各自的请求头:WebSocket 的握手请求头,或 gRPC 调用的元数据——两者实际上是同一层,因为 grpc-js 会将元数据作为 HTTP/2 请求头发送到线上。脚本抛出错误会在拨号前停止请求,报告的就是该错误本身。req.getUrl() 和 req.getHeaders() 会读取替换后的目标和请求自身的请求头;传输层计算出的凭据不在其中,因为认证是在脚本运行之后应用的。唯一不生效的是 req.setBody(),它会改为发出警告:这两种传输都不会发送单个请求体——WebSocket 会话发送的是消息列表,gRPC 一元调用发送的是一条有类型的消息——因此没有可供单个值替换的内容,贸然猜测会在线路上发送文件中从未编写过的字节。
两种传输都会延迟加载,并且这是通过强制检查而非断言来保证的:测试会记录真实服务解析的每个模块;如果仅运行 HTTP,却出现 @grpc/grpc-js 或 ws,测试就会失败。实测表明,仅运行 HTTP 时会加载 undici,而不会加载这两个模块。
有两种情况会直接拒绝处理,而不是猜测。文件中声明的类型与目标块不一致(type: grpc 却使用 http: 块)时,会报错并指出两者,因为类型决定读取器报告什么,而块决定运行器连接什么。.bru 请求的目标 URL 为空时,也会拒绝写入,因为该格式会丢弃这样的块,却保留与之关联的凭据——结果看起来像是编写了请求,实际却无处可去。
有五项功能是有意不实现的。流式 gRPC 调用和保持打开的 WebSocket 会话会让运行结果取决于读取时机,而这里的每个响应都是调用方可以断言的值。服务反射会通过正在测试的同一连接获取架构,因此这里要求提供 .proto 路径。代理支持和证书固定不会延伸到这些传输:门控仅支持 undici,@grpc/grpc-js 没有代理 API,并且会自行遵循环境中的 http_proxy;ws 则需要自己的代理 Agent。至于 socket.io 或 MQTT 块,则会凭空创造上游尚未选定的文件格式;一旦上游作出选择,就意味着需要迁移。
socket.io 不需要专用块,因为它是构建于 WebSocket 之上的帧格式约定,而不是独立协议。根据 socket.io 4.8.3 的实测,普通的 ws 请求可以完成以下流程:
- 连接到
ws://host:port/socket.io/?EIO=4&transport=websocket。两个查询参数都是必需的——EIO=4用于选择 Engine.IO 版本,transport=websocket则可避免服务器先等待 HTTP 长轮询握手。 - 服务器发送 Engine.IO OPEN 数据包
0{…}。其负载包含以毫秒为单位的sid、pingInterval和pingTimeout。 - 发送
40加入默认命名空间——在此之前一切都无法工作。命名空间名称为40/namespace,。 - 服务器回复
40{"sid":"…"}。 - 以
42["event-name",payload]的形式发送事件:4表示 MESSAGE,2表示 EVENT,后面跟着一个 JSON 数组,其第一个元素是事件名称。 - 服务器每隔
pingInterval发送一次2(PING),如果客户端未在pingTimeout内回复3(PONG),服务器就会断开连接。如果录制时长超过该窗口,请在run_collection上设置websocket.engineIoKeepalive;此选项默认关闭,并且只有在收到真实的 OPEN 帧后才会回复。 步骤 1 到 5 的帧已存储在请求文件中,因此只有步骤 6 需要从运行器获取信息。这里固定使用EIO=4——Engine.IO v2 和 v3 的帧格式不同。确认(以42<id>[…]发送并由43<id>[…]响应)和二进制附件(一个45占位符后跟独立的二进制帧)可以手动编写,但实际操作起来并不容易。
安全性
SSRF。 系统会解析并检查所有出站 URL,包括每一跳重定向。私有、环回、链路本地及其他保留地址都会被拒绝,并且会为请求固定已批准的地址,以防名称在请求过程中解析到其他地址。每次请求遇到拒绝时,都会报告 SSRF blocked 错误,状态码为 0。
脚本在分叉出的临时进程中的 V8 上下文里运行。子进程会获得经过清理的环境,因此即使脚本逃出上下文,也无法读取服务端的机密信息——这些信息不在它的地址空间中。其标准输出会通过管道传输,而不会继承,因此它无法向 MCP JSON-RPC 流写入内容。失控脚本会通过对子进程发送 SIGKILL 加以限制,仅靠上下文内超时无法保证这一点。这是通过操作系统进程实现的纵深防御,并非沙箱:它不会阻止代码在子进程中运行,而是让代码在其中运行毫无用处。
路径。 请求引用必须位于集合内。collectionRoot 必须包含集合路径。环境名称不得包含分隔符。
文件上传。 form-data 文件部分指定的是服务器磁盘上的路径,因此会受到限制:只能读取集合根目录、用户主目录、操作系统临时目录或操作员添加的目录下的文件。除此之外,任何以 . 开头的路径组件都会被拒绝——因此,即使允许访问主目录,~/.ssh/id_rsa、.env 和 .aws 仍不可读取。相对路径会基于集合根目录解析。
操作员可使用以下例外设置,默认均关闭:
| 变量 | 作用 |
|---|---|
BRUNO_SSRF_ALLOWLIST | 以逗号分隔的精确主机名、IP 字面量和/或 CIDR 网段,即使属于私有地址也允许访问。主机名条目会与 URL 中的名称拼写匹配;地址条目会与 URL 解析出的地址匹配,也允许访问 localhost 等原本会被拦截的名称,前提是其解析出的所有地址都在允许列表中。启动时读取一次,不受工具参数影响;不接受通配符 |
BRUNO_UPLOAD_DIRS | 上传文件可读取的额外目录 |
BRUNO_PROXY_HOSTS | 允许使用集合代理的主机 |
BRUNO_INSECURE_TLS_HOSTS | 允许跳过证书验证的主机 |
BRUNO_DNS_TIMEOUT_MS | DNS 解析超时时间 |
BRUNO_WORKSPACE_PATH | Bruno workspace.yml 文件的位置 |
允许列表条目只与目标的准确拼写匹配。允许列表中的主机名不会被解析——操作员背书的是这个名称,而不是它当前指向的内容——因此它不会涵盖该主机名对应的地址;允许列表中的地址也不会涵盖解析到该地址的名称。因此,localhost 和 127.0.0.1 是两个独立条目;如果请求使用其中一个,却只允许了另一个,就会看起来像保护机制不一致,实则是缺少条目。拒绝信息会说明这一点。
这限制了 run_collection 可获取的内容。拥有 Shell 访问权限的 Agent 仍可访问网络,因此应将其视为一道防护,而非边界。
常见问题
是否需要安装 Bruno CLI(bru)?
不需要。请求处理流程在此处实现——包括变量、身份验证、Cookie、重定向、断言、脚本和依赖排序——因此不会调用 bru,也无需将该二进制文件加入 PATH。这也正是上文提到兼容性工作的原因:必须严格要求独立实现忠实遵循原有行为。
它支持 .yml opencollection 文件,还是只支持 .bru?
两种都支持,而且它会检测集合使用哪种格式,而不是询问你。新集合默认使用 .yml;如果你想使用旧版方言,可以在 create_collection 中指定 format: "bru"。两种格式确实存在差异时——确实如此——每种格式都会按照 Bruno 自身对应方言的写入器进行编写。
不过,一个集合只能使用一种方言,不能混用:根清单决定所用格式,而 Bruno 只会读取对应扩展名,因此在 bruno.json 集合中放置 .yml 请求文件,就 Bruno 而言,它只是目录中的一个文件。此 MCP 服务仍会读取、写入并运行这类文件——拒绝处理会让你无法进行修复,而修复方式恰恰是重命名该文件——但所有处理或列出这类文件的工具都会告知你 Bruno 无法识别它,并指出具体文件名。
我可以在 CI 中运行它吗?
它生成的集合是标准 Bruno 集合,因此 CI 可以使用 bru run 运行,就像由人通过应用创建的一样。MCP 服务本身用于编写和调试流程,也就是 Agent 参与其中的场景。它还会生成 JUnit XML 和 HTML 报告文件——请参阅报告文件——这样,即使是由 Agent 驱动的运行,也会留下 CI 仪表板所需的产物。
它会重写 Bruno 应用写入的文件吗?
只会修改你要求更改的字段。通过 write_request 进行的编辑是部分合并;对于此服务未建模的键,只要格式支持保留,就会在写回时保留——.yml 始终支持,.bru 则仅在其语法包含可容纳该键的字典块时支持——并且测试套件会使用 Bruno 自身的读取器验证每次写入。删除操作必须明确指定 confirm: true。
哪些 MCP 客户端可以使用?
任何客户端都可以——这是一个纯 stdio 服务,不包含针对特定客户端的代码。Claude Code、Claude Desktop、Cursor、Windsurf、VS Code、Codex CLI、opencode、Zed、Cline、Continue、LM Studio、Gemini CLI、MCP Inspector,或你自己的 SDK 客户端均可。请参阅连接客户端,了解每个客户端将配置存放在哪里。
我的密钥会如何处理?
密钥环境变量只会在运行期间保存在内存中,绝不会写入文件——两种方言都不会将密钥值存储到磁盘,这是 Bruno 的设计,并非此服务新增的限制。返回给 Agent 的结果会对凭据进行脱敏,包括放在查询参数中的凭据。脚本会在派生的 V8 沙箱中运行,且环境变量会经过清理。请参阅安全性。
这和原始的 bruno-mcp 是同一个项目吗?
它最初是 macarthy/bruno-mcp 的一个分支;该项目自 2025 年 7 月起便未再活跃,而且生成集合文件后不会运行它们。上述所有内容——运行器、读取器、两种方言、兼容性门禁和沙箱——都是分叉后构建的。仓库现已更名为 Ostico/bruno-mcp-studio(公告);npm 包名未变,仍为 @ostico/bruno-mcp。
这与 Bruno 有关联吗?
没有。Bruno 是 usebruno 的产品,其名称和商标归其所有。这是一个社区项目,会读取 Bruno 的开源软件包以保持兼容;它未获 usebruno 认可,也与 usebruno 无关联。
Bruno 团队于 2026 年 8 月宣布推出官方 MCP 服务:usebruno/bruno-mcp。这不是那个服务,也不会与它争夺该定位——请参阅我应该使用哪个 Bruno MCP 服务?,了解各自适用的场景。
升级到 2.5.0 — 四个工具已合并到 write_request
四个工具已移除。它们的功能现在由 write_request 完成,语义相同:编辑仍只会更新你传入的字段,并逐字节保留所有其他字段。
| 在 2.5.0 中移除 | 改为调用 |
|---|---|
create_request、modify_request | write_request |
create_test_suite、create_crud_requests | 使用 requests 和用于排序的 dependencies 调用 write_request |
delete_request 未作更改,仍然可用;现在它还接受 filePaths,可在一次调用中删除一组文件。
此更改会安全失败。 客户端在连接时请求工具列表,并获取当前名称,因此读取列表的 Agent 不受影响。调用已移除的名称会收到未知工具错误或权限提示——绝不会返回错误结果,也不会静默失败。
真正需要修改的是固定了名称的配置:--allowedTools mcp__bruno-mcp__create_request 参数、settings.json 中的权限条目、钩子匹配器,以及 CLAUDE.md 或技能中指示 Agent 按名称调用这四个工具的任何文字。
版本管理
工具名称在连接时通过 tools/list 发现,而非链接确定,因此重命名或移除工具会在次要版本中发布。调用要么行为完全不变,要么不存在,后一种情况会明确报错。
更改现有调用的行为会在主要版本中发布。 这是危险的改动:2.0.0 将文件夹变成了执行组,因此调用方什么都没改,得到的结果却不同。任何比这更不明显的改动都不值得发布主要版本,因为不能传达危险信号的主要版本会让人学会忽视主要版本。
从 1.x 升级
requestPath和folder已移除。两者都改为requests,即按顺序排列的文件和/或目录数组。- 没有顶层
results数组。 请读取result.groups[0].results。 parallel: true过去会隔离每个文件夹。现在不再如此:没有groups时,整个选定内容属于同一个组,共享一个存储和一个 Cookie 容器。若要保留旧行为,请将文件夹指定为不同的组。seq不再约束执行。它仅用于指定默认顺序和报告顺序。- 空的
requests: []不会运行任何内容。过去它会运行整个集合。 - 已移除的导出项:
BruGenerator、generateBruFile、createBasicBruFile。
详情请参阅 CHANGELOG.md。
开发
npm run build # compile to dist/
npm test # full suite
npm run test:unit # unit only
npm run typecheck
npm run lint
请使用 npm,而不是 yarn——锁文件属于 npm,CI 运行 npm ci。
有关 CI 检查内容、提交规范以及每次提交都需要的签署(DCO),请参阅 CONTRIBUTING.md。
许可证
MIT — 请参阅 LICENSE。贡献按相同条款接受,并需要提供 DCO 签署。
链接
支持
如果 Test-Guard 对你有帮助,请考虑支持其开发:
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。