mcpField
B
Bruno MCP Studio

Bruno MCP Studio

本地服务
github.com开发工具115 周前更新

关于 Bruno MCP Studio

Bruno MCP Studio 是供 Agent 创建、编辑、查看和运行 Bruno API 集合的 MCP 服务。它支持 `.bru` 和 `.yml` 格式,无需 Bruno CLI 即可运行请求并返回逐请求结果。

主要功能

  1. 01创建集合并局部编辑
  2. 02以结构化 JSON 读取请求和环境
  3. 03支持 `.bru` 与 `.yml` 格式
  4. 04运行 HTTP、WebSocket 和 gRPC 请求
  5. 05测试断言、依赖关系和身份

适用场景

  1. 01将 Agent 会话中的 API 测试整理为可复用套件
  2. 02更新请求时保留未建模的文件内容
  3. 03运行集合检查并查看逐请求结果

MCP 相关

通过 MCP 客户端提供 18 个工具,用于编写、读取和运行 Bruno 集合。请求在进程内执行,不调用 Bruno CLI。

配置

本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。

mcp.json · 通用配置
{
"mcpServers": {
"ostico-bruno-mcp-studio": {
"command": "npx",
"args": [
"-y",
"@ostico/bruno-mcp"
]
}
}
}
启动命令npx -y @ostico/bruno-mcp

本地服务需要在你的电脑上安装运行环境(如 Node.js 18+)。各客户端的配置文件位置不同,请参考客户端文档。

安装包
npm@ostico/bruno-mcp2.5.0

工具

18

收录的工具列表 · 更新于 2 周前

参数类型必填说明
namestring是
formatenum否yaml | bru
ignorearray否
baseUrlstring否
outputPathstring是要在其中创建集合目录的父目录绝对路径。会追加 name:name 为 "Billing"、此处传入 "/work/apis" 时,会创建 /work/apis/Billing;此处传入 "/work/apis/Billing" 时,则会创建 /work/apis/Billing/Billing。
descriptionstring否
workspacePathstring否要将新集合注册到的 workspace.yml 的绝对路径。默认使用 list_collections 读取的同一文件:BRUNO_WORKSPACE_PATH,或此平台上 Bruno 应用的工作区文件。
registerInWorkspaceboolean否设为 false 可在不修改任何工作区文件的情况下创建集合。在其他操作完成注册之前,它不会出现在 list_collections 中。

详细介绍

Bruno MCP Studio — 通过 Agent 编写、编辑和运行 Bruno 集合

浏览量 npm 版本 npm 下载量 node 许可证

将 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 DesktopmacOS ~/Library/Application Support/Claude/claude_desktop_config.json · Windows %APPDATA%/Claude/claude_desktop_config.json · Linux ~/.config/Claude/claude_desktop_config.json
Claude Codeclaude mcp add,或项目中的 .mcp.json
Cursor项目中的 .cursor/mcp.json,或全局配置文件
Codex CLI~/.codex/config.toml,位于 [mcp_servers.bruno-mcp] 表中(TOML,字段相同)
opencodeopencode.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、none
  • body.type: "form-data" — multipart 上传、每个部分的 contentType、多文件字段
  • auth.type — bearer、basic、api-key、digest、oauth2、inherit、none
  • scripts — 内联 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.ymlYAML — 优先检查
bruno.jsonBRU(旧版)
均不存在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 参数中设置:

上限默认值作用
maxMessages50停止前记录的入站帧数
maxDurationMs5000单次会话的最长时长
idleTimeoutMs1500结束会话的静默时长;0 表示一直等待到时长上限
sendIntervalMs0请求发送的消息之间的间隔;0 表示在同一个时钟周期内发送
includePayloadsfalse记录帧内容,而不只是大小和时间
maxFrameBytes65536记录载荷的单帧上限
maxTranscriptBytes1048576累计上限,按线路上的字节数计算
engineIoKeepalivefalse收到 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 请求可以完成以下流程:

  1. 连接到 ws://host:port/socket.io/?EIO=4&transport=websocket。两个查询参数都是必需的——EIO=4 用于选择 Engine.IO 版本,transport=websocket 则可避免服务器先等待 HTTP 长轮询握手。
  2. 服务器发送 Engine.IO OPEN 数据包 0{…}。其负载包含以毫秒为单位的 sid、pingInterval 和 pingTimeout。
  3. 发送 40 加入默认命名空间——在此之前一切都无法工作。命名空间名称为 40/namespace,。
  4. 服务器回复 40{"sid":"…"}。
  5. 以 42["event-name",payload] 的形式发送事件:4 表示 MESSAGE,2 表示 EVENT,后面跟着一个 JSON 数组,其第一个元素是事件名称。
  6. 服务器每隔 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_MSDNS 解析超时时间
BRUNO_WORKSPACE_PATHBruno 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_requestwrite_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 对你有帮助,请考虑支持其开发:

在 GitHub 上赞助 通过 PayPal 捐赠

评论

支持 Markdown

第一次接触 MCP?

从协议是什么,到在客户端里装上第一个服务,这几条回答覆盖了大多数入门问题。

一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。