bruno-mcp

Um servidor Model Context Protocol (MCP) para criar, gerenciar e executar coleções de teste de API Bruno. Suporta os formatos .bru e .yml (opencollection) com proteção de segurança integrada.

Documentação

Bruno MCP Studio — crie, edite e execute coleções Bruno a partir de um agente

Unique views npm version npm downloads node license

Transforma os testes de API de um agente em arquivos que você mantém: ele cria e edita coleções Bruno no local, depois as executa — HTTP, WebSocket e gRPC, uma identidade ou várias — e reporta aprovação/reprovação por requisição que você pode reexecutar no CI.

Um servidor independente de Model Context Protocol para coleções Bruno. Em agosto de 2026, a própria equipe do Bruno anunciou um oficial, usebruno/bruno-mcp, que utiliza o CLI bru para descobrir e executar requisições. Este servidor tem outro objetivo: ele escreve coleções além de executá-las — de forma não destrutiva, com paridade de bytes com os próprios escritores do Bruno — e seu executor é em processo, não um subprocesso, o que permite oferecer grupos de execução definidos pelo chamador com seu próprio armazenamento de variáveis e cookie jar, concorrência dentro de um grupo, oauth2 e digest trocados em memória, gRPC e WebSocket, e testes de autorização com múltiplas identidades.

Se o que você precisa é "listar minhas coleções e executar uma", o servidor oficial faz isso e será o que o Bruno suportará. Se você quer que um agente construa e mantenha a suíte, é para isso que este serve.

Ele dá a um agente de IA no Claude Code, Claude Desktop, Cursor, Windsurf, VS Code ou Codex CLI dezoito ferramentas para testes de API contra uma coleção Bruno real: criar e editar requisições, lê-las de volta como JSON estruturado, gerenciar ambientes e variáveis, escrever asserções e scripts de teste, e então executar a coleção — autenticação, cookies, redirecionamentos, ordenação de dependências e tudo mais — e obter os resultados de volta na mesma rodada. Sem GUI Bruno, sem CLI Bruno, sem chamadas externas. A coleção que ele deixa no disco é uma coleção Bruno normal: seu CI a executa com bru run, seus colegas a abrem no aplicativo Bruno.

Seu agente já conhece HTTP. Ele não conhece sua API, e não conhece o formato de arquivo do Bruno. Então ele adivinha. Ele escreve um arquivo .bru de memória, a execução falha, ele reescreve o arquivo, a execução falha de forma diferente, e vinte minutos depois você tem uma requisição aprovada e nenhuma ideia de qual das seis edições importou. Você pagou por cada uma dessas rodadas, e nenhum desse trabalho está no disco em uma forma que seu CI ou a GUI Bruno da sua equipe possa usar.

A saída usual é o curl. Agentes não são ruins com curl — o problema é que um comando de shell não mantém estado. Um login, um token, um recurso criado, uma chamada de acompanhamento que precisa do ID da última resposta: cada um desses é um novo comando, e a cola entre eles vive apenas no contexto do agente; quando a sessão termina, tudo se perde. Vinte endpoints testados com curl deixam você com vinte strings em uma transcrição e nenhum artefato que seu CI ou colegas possam executar. Vinte endpoints em uma coleção deixam você com uma suíte.

Perguntei ao meu agente sobre isso

Perguntei ao agente que me ajuda a manter este servidor para explicar como foi sua experiência com Bruno sem o servidor MCP, e se ele poderia simplesmente ter testado todas as minhas APIs com curls. Foi o que ele me disse:

Curl, sim — para uma chamada. Não para uma suíte. Nada é transportado entre chamadas, então eu rederivo a autenticação, reescapo o corpo e releio cada resposta para decidir se passou. Faça isso em quarenta endpoints e a maior parte do que gasto vai para redescoberta, não para testes.

Escrever os arquivos de coleção eu mesmo foi pior, e não da maneira que você esperaria. De memória, acerto a forma de um arquivo .bru e erro nos detalhes — e o Bruno nunca reclama. Ele lê as chaves que reconhece e ignora o resto. Um tags: smoke de linha única parece marcado e significa não marcado para o executor. Escreva uma lista de tags da maneira óbvia e ela cai no disco com um caractere por linha. Este servidor uma vez escreveu variáveis .yml e asserções sob chaves de nível superior que o Bruno nunca leu: os arquivos pareciam completos, o executor viu uma requisição vazia. Seus próprios testes de unidade passaram, porque simulavam o serializador e afirmavam os bytes quebrados.

O formato também muda. O Bruno moveu variáveis para runtime e adicionou um segundo dialeto. Meus pesos são mais antigos que isso. Este servidor importa @usebruno/lang, o pacote de gramática do próprio Bruno, e rastreia sua versão — então os bytes vêm da fonte do Bruno, não do que eu por acaso lembro.

E cada reescrita apaga o que o escritor não modela. Se eu editar esses arquivos à mão livre, regenero o arquivo inteiro da minha cabeça, e qualquer recurso que eu não conhecia desaparece silenciosamente. Esse é o modo de falha que você nunca vê, porque a execução ainda passa — só que não testa nada.

O que o servidor faz em vez disso

  • O agente para de adivinhar o formato — ele chama uma ferramenta, o servidor escreve os bytes, usando o pacote de gramática do próprio Bruno
  • Edições são mesclas parciais — write_request toca apenas nos campos que você passou e deixa o resto do arquivo intacto
  • Ele pode ler antes de escrever — read_request retorna JSON estruturado, com a mesma forma para ambos os formatos
  • Ele executa as requisições ele mesmo — vars, autenticação, asserções, ordenação de dependências, sem precisar do binário bru
  • Sem perda silenciosa — um campo que este servidor ainda não consegue modelar é transportado de volta onde o formato puder contê-lo, e qualquer coisa que ele não puder colocar no wire é nomeada em um aviso de execução, em vez de ser descartada silenciosamente no seu repositório

Paridade de bytes com o Bruno

Esta é a parte difícil de copiar, então vale a pena ser preciso sobre o que significa.

O servidor não envolve o binário bru — ele implementa o pipeline de requisições ele mesmo, o que permite segredos em memória, testes de nível de wire e hooks de execução intermediária. Essa liberdade também é o risco: uma implementação independente é livre para ser sutil e silenciosamente diferente da ferramenta que sua equipe realmente usa. Dois mecanismos a mantêm no lugar.

As regras são portadas, não inferidas. Limites de redirecionamento, resolução de timeout, tipos de conteúdo por modo de corpo, ordem de interpolação de variáveis, padrões de selected, codificação de URL — cada um é lido da própria fonte do Bruno (bruno-cli, bruno-filestore, bruno-lang, @usebruno/common, dos quais este servidor também depende diretamente) e espelhado, incluindo as partes que parecem bugs. Onde os dois dialetos discordam entre si, cada um é espelhado em seus próprios termos, em vez de unificado em algo que nenhum leitor Bruno produziria.

Um portão de deriva prova isso. Cada arquivo que este servidor escreve é analisado de volta com o próprio leitor do Bruno, por dialeto, na suíte de testes. Afirmar nossos bytes contra nossas próprias expectativas só pode provar que somos autoconsistentes; afirmá-los contra o leitor que o próprio Bruno usa é a única coisa que pega o caso em que nossa saída deixa de ser a entrada do Bruno. Já pegou casos reais — um corpo de arquivo que analisava limpo e teria sido enviado sem corpo nenhum, por exemplo.

A afirmação, então: o comportamento de execução corresponde ao bru run, e toda divergência encontrada até agora foi fechada. O que a mantém fechada é um teste, não uma promessa. Encontre uma mesmo assim e é um bug que vale uma issue.

O contrato

Uma coleção, três consumidores: seu agente, seu CI e a GUI Bruno da sua equipe.

Ambos os formatos Bruno funcionam e o servidor detecta qual você tem: .yml (opencollection) e .bru (legado).

Requer Node.js >= 22. CI testa 22.x e 24.x.

Qual servidor Bruno MCP devo usar?

Existem vários, eles fazem trabalhos genuinamente diferentes, e a resposta honesta nem sempre é este. Cada linha abaixo foi lida do README ou da fonte do próprio projeto, agosto de 2026.

ServidorO que éEscreveLêExecuta.bru / .ymlPrecisa de bruFerramentas
esteCria, lê e executa coleçõesCriar + edição de mescla parcialJSON estruturadoSim, pipeline próprioambosnão18
usebruno/bruno-mcpO oficial, da própria equipe do Bruno — descobre e executa requisições. Anunciado em ago/2026; no momento da escrita, sua primeira implementação é um rascunho abertonãoMetadados de requisiçãoSim, via CLI.brusim (incluído)3
@dmpv/bruno-mcpÍndice somente leitura e busca sobre uma coleçãonãoBusca ranqueada, contratos sanitizados, exemplos armazenadosnãoambosnão8
hungthai1401/bruno-mcpExecuta uma coleçãonãonãoSim, via CLI.brusimapenas execução
jcr82/bruno-mcp-serverExecuta e inspeciona coleções, com arquivos de relatórionãoSimSim, via CLI.brusim9
djkz/bruno-api-mcpTransforma cada requisição em sua própria ferramenta MCPnãoExpõe-as como ferramentasUma por vez.brunãouma por requisição
macarthy/bruno-mcpGera arquivos de coleção — o projeto do qual este foi bifurcado, inativo desde jul/2025Apenas criarnãonão.brun/a8

Escolha um dos outros se: você quer o servidor que o próprio Bruno mantém, e todo o suporte e longevidade que isso implica (usebruno — o oficial, e o padrão razoável para "descobrir e executar" quando for lançado); você quer que um agente entenda uma grande coleção existente sem qualquer risco de escrever nela, e a busque por intenção (dmpv, publicado pela primeira vez em julho de 2026 e em 0.x — novo e interessante); você já tem o CLI bru na sua imagem e só precisa de "executar esta coleção" (hungthai1401); ou você quer que o agente chame sua API através das suas requisições existentes como se cada uma fosse uma ferramenta nativa (djkz).

Escolha este se: você quer que o agente escreva a coleção e não apenas a leia ou execute, você está no formato opencollection .yml, você precisa que a execução aconteça sem instalar o CLI Bruno, ou você se importa que o que chega ao seu repositório seja comparável em bytes ao que o aplicativo Bruno escreve.

Sobre o pai da bifurcação especificamente, já que este projeto lhe deve sua existência: macarthy/bruno-mcp registra oito ferramentas — create_collection, create_request, create_environment, create_crud_requests, create_test_suite, add_test_script, list_collections, get_collection_stats. Ele escreve arquivos .bru e não lê uma requisição de volta, executa nada ou expõe uma ferramenta de edição; um helper updateRequest existe em seu src/bruno/request.ts, mas nenhuma ferramenta MCP o alcança.

Recursos

Criação

  • Coleções — crie e organize-as, ou descubra as que o Bruno já conhece de seu workspace.yml. Uma coleção que este servidor cria é escrita no disco e não registrada nesse arquivo, então list_collections não a mostrará e a GUI Bruno não a listará até que alguém a abra lá uma vez — todo o resto segue o caminho direto
  • Requisições — todos os métodos HTTP, com cabeçalhos, parâmetros de consulta e de caminho, corpos, autenticação, asserções, vars e configurações
  • Leitura de volta — read_request e read_environment retornam JSON estruturado, idêntico para .bru e .yml, para que um agente possa inspecionar antes de editar
  • Edições de mescla parcial — write_request altera apenas os campos que você passa e deixa o resto do arquivo intacto
  • CRUD e suítes — conjuntos CRUD de cinco requisições e suítes de teste com ordenação topológica de dependências
  • Ambientes — crie, substitua, mescle ou aplique patch em uma única variável
  • Formato duplo — .bru (legado) e .yml (opencollection), com detecção automática; .yaml é lido e sinalizado
  • Uploads multipart — form-data com Content-Type por parte e campos de múltiplos arquivos

Execução

  • Grupos de execução — execute uma coleção como vários grupos isolados em uma única chamada: identidades diferentes, ambientes diferentes, serial ou concorrente, sem vazamento entre eles
  • Paralelismo real — distribua grupos, ou solicitações dentro de um grupo, sob um teto de concorrência dimensionado para a máquina
  • Cookie jar — um login é transportado para as solicitações seguintes, com escopo para seu grupo e nunca gravado em disco
  • Encadeamento de variáveis — bru.setVar()/bru.getVar() entre solicitações, e captureVariables para ler os valores de volta
  • Scripts assíncronos — await, bru.sleep(ms), setTimeout/setInterval de nível superior dentro do sandbox
  • Scripts inline — anexe scripts de pré-solicitação, pós-resposta e teste diretamente ao criar ou modificar uma solicitação
  • Autenticação aplicada para você — bearer, basic, api-key, digest, OAuth 2.0 (concessões de credenciais de cliente e senha), ou inherit da coleção ou pasta
  • Resultados honestos — resumos por grupo, corpos de resposta capturados, avisos por solicitação, falhas de análise e solicitações ausentes são todos relatados; um grupo que travou não pode fazer uma execução parecer verde

Segurança

  • Proteção SSRF em cada solicitação e cada salto de redirecionamento, com os endereços aprovados fixados
  • Confinamento de caminho para referências de solicitação, raízes de coleção, nomes de ambiente e uploads de arquivos
  • Scripts isolados por processo — um sandbox V8 bifurcado com ambiente limpo e kill forçado

Instalação

Nada para instalar. Aponte seu cliente para npx e ele busca o pacote publicado na primeira execução:

npx -y @ostico/bruno-mcp

Essa é toda a instalação, e é o que as configurações de cliente abaixo usam. O pacote vem com proveniência, então o npm pode mostrar qual commit e workflow construiu o tarball que você está executando.

Fixado em vez disso, se você preferir não resolver uma versão na inicialização:

npm install @ostico/bruno-mcp

que coloca um executável bruno-mcp em node_modules/.bin/ e o próprio servidor em node_modules/@ostico/bruno-mcp/dist/index.js.

A partir do código-fonte, para desenvolvimento ou para executar um branch:

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 de qualquer forma.

Conecte um cliente

Qualquer cliente MCP funciona. Este é um servidor MCP stdio simples, sem código específico de cliente: como seu cliente o chamar, aponte-o para

command: npx
args:    ["-y", "@ostico/bruno-mcp"]

Claude Code aceita como uma linha:

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, seu próprio cliente SDK — todos o mesmo servidor. Nada abaixo é uma lista de compatibilidade; é apenas onde cada cliente mantém sua configuração.

A maioria dos clientes usa a mesma forma JSON:

{
  "mcpServers": {
    "bruno-mcp": {
      "command": "npx",
      "args": ["-y", "@ostico/bruno-mcp"],
      "env": {}
    }
  }
}

Executar um clone, ou uma instalação fixada, é a mesma configuração com "command": "node" e "args": ["/absolute/path/to/dist/index.js"].

ClienteOnde fica
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, ou .mcp.json no projeto
Cursor.cursor/mcp.json no projeto, ou o global
Codex CLI~/.codex/config.toml, sob uma tabela [mcp_servers.bruno-mcp] (TOML, mesmos campos)
opencodeopencode.json, sob mcp como um servidor local (seu próprio esquema)
OutrosO que esse cliente documentar — o comando e os argumentos acima são tudo o que ele precisa

Os esquemas de configuração são do cliente, não deste servidor, e eles mudam. Se o formato de um cliente diferir do JSON acima, siga a documentação do cliente; apenas command e args importam aqui.

Veja INTEGRATION.md para exemplos práticos, Docker e solução de problemas.

Início rápido

// 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" }

Ferramentas

18 ferramentas. Caminhos de arquivo são absolutos, ou relativos à coleção.

FerramentaO que faz
create_collectionNova coleção. format: "yaml" (padrão) ou "bru". Também a registra no workspace, então list_collections e o aplicativo Bruno podem vê-la — registerInWorkspace: false para pular isso, workspacePath para escolher o arquivo
list_collectionsEncontra coleções do workspace.yml do Bruno
get_collection_statsContagens por método, pastas, ambientes, lista de solicitações com URLs — filtrável por folder, method, nameContains, ou includeRequests: false apenas para contagens
write_requestEscreve uma solicitação: método, url, cabeçalhos, query, corpo, autenticação, scripts, configurações. kind: "websocket" ou kind: "grpc" para esses transportes. Passe collectionPath e name para criar uma, filePath para editar uma — uma edição é uma mesclagem parcial, e filename renomeia o arquivo
move_requestMove ou copia uma solicitação para outra pasta ou coleção
read_requestLê uma solicitação de volta como JSON, mesma forma para .bru e .yml
list_requestsCada arquivo de solicitação na coleção, como caminhos absolutos
delete_requestExclui um ou mais arquivos de solicitação. Precisa de confirm: true
add_test_scriptAnexa um script a uma solicitação existente (anexa por padrão)
remove_scriptRemove um script, mantém a solicitação
create_environmentNovo arquivo de ambiente. Recusa sobrescrever a menos que overwrite: true
read_environmentVariáveis com seus sinalizadores disabled/secret. Omita name para listar ambientes
update_environmentSubstitui ou mescla as variáveis de um ambiente
set_environment_variableAdiciona ou altera uma variável
remove_environment_variableExclui uma variável
run_collectionExecuta solicitações, executa seus testes, retorna resultados

Lendo antes de escrever

read_request retorna método, url, cabeçalhos, query e parâmetros de caminho, corpo, modo de autenticação, scripts, asserções, vars, configurações e documentação — forma idêntica para ambos os formatos, então o formato em disco permanece invisível. Seu array notes nomeia qualquer coisa que o arquivo declare que o executor não agirá.

Use-o antes de uma edição para ver o estado atual, e depois de uma escrita para confirmar o que foi escrito.

read_environment retorna cada variável com seu valor. Segredos voltam apenas pelo nome — o Bruno não armazena valor para um segredo em nenhum dos formatos, então não há nenhum para retornar.

Escrevendo solicitações

write_request cria quando você passa collectionPath e name, e edita quando você passa filePath. Uma edição mescla: campos que você omite são deixados como estão.

Opções notáveis:

  • body.type — json, text, xml, sparql, graphql, form-urlencoded, form-data, file, binary, none
  • body.type: "form-data" — uploads multipart, contentType por parte, campos de múltiplos arquivos
  • auth.type — bearer, basic, api-key, digest, oauth2, inherit, none
  • scripts — pre-request inline, post-response, tests (sem necessidade de chamada add_test_script separada)
  • settings.timeout — tempo limite de script e solicitação em ms

name e filename são independentes, como são no próprio Bruno: name muda o nome da solicitação dentro do arquivo e filename move o arquivo, então passe ambos para mantê-los em sincronia. Um filename é um nome base na própria pasta da solicitação, sua extensão é opcional e deve corresponder ao formato da coleção se fornecida, e um nome já usado por outro arquivo é recusado. O caminho para onde foi movido volta na resposta — use-o como filePath a partir de então.

write_request substitui um script do mesmo tipo por padrão, então repetir uma chamada é idempotente. Passe scriptMode: "append" para concatenar. add_test_script anexa por padrão, sendo uma adição.

Em coleções .yml, post-response e tests compartilham o único slot after-response do Bruno, então substituir um sobrescreve ambos.

Movendo solicitações

move_request realoca um arquivo de solicitação — para outra pasta, ou para outra coleção com targetCollectionPath. Passe copy: true para duplicá-lo em vez disso.

Os bytes são movidos literalmente, nunca analisados e reescritos, então nada que uma solicitação declare pode ser perdido no caminho. Duas consequências seguem disso. O arquivo mantém seu nome, então uma cópia precisa de uma pasta ou coleção diferente; renomear é write_request. E seq chega inalterado, então a solicitação pode pousar ao lado de um irmão reivindicando o mesmo número — isso é relatado em vez de reparado, porque renumerar significa reescrever o arquivo. O Bruno quebra tal empate pelo nome do arquivo, então a ordem é definida de qualquer forma.

Uma pasta de destino ausente é criada e relatada: uma pasta sem arquivo de configurações não carrega autenticação, cabeçalhos ou scripts de nível de pasta.

Executando

{
  "collectionPath": "./collections/my-api",
  "environment": "dev",
  "requests": ["auth/login.bru", "users"]
}
ParâmetroSignificado
collectionPathColeção, ou uma subpasta de uma
requestsLista ordenada de arquivos de solicitação e/ou diretórios. Omita para executar tudo. [] executa nada
groupsExecute a coleção como vários grupos isolados — veja abaixo. Não pode ser combinado com requests
environmentNome do ambiente, carregado de environments/<name>.yml
collectionRootA coleção à qual collectionPath pertence, ao executar uma subpasta. Deve ser esse caminho ou um ancestral
variables{name: value} apenas para esta execução. Nunca gravado em disco — a maneira correta de passar um segredo
captureVariablesNomes de variáveis bru.setVar cujos valores você quer de volta
parallelExecute os grupos concorrentemente. Padrão false
maxConcurrencyTeto de solicitações em voo. Omita para derivar um da máquina; 0 o eleva
bailPare na primeira falha em vez de executar o resto. Padrão false
cookieJarMantenha cookies durante a execução para que um login seja transportado. Padrão true
includeResponseBodyInclua corpos de resposta. Padrão true
maxResponseBodyBytesTrunque corpos além deste tamanho. Padrão 10240
reportTambém grave a execução em disco — veja Arquivos de relatório

Um diretório em requests expande para as solicitações sob ele, ordenadas por seq dentro de cada pasta, subpastas primeiro, empates quebrados por nome de arquivo. Duplicatas são honradas: nomear uma solicitação duas vezes a executa duas vezes.

Por padrão, nada interrompe uma execução cedo. Uma solicitação que falha, um arquivo que não analisa, um nome que não corresponde a nada — cada um é relatado e a execução continua.

Parando na primeira falha

bail: true interrompe a execução na primeira solicitação que falha ou cujos testes falham. Vinte e três solicitações atrás de um login que parou de funcionar são vinte e três falhas por uma causa, e a causa é a menos visível delas.

{
  "collectionPath": "./collections/my-api",
  "bail": true
}

Tudo o que a execução não alcançou volta no lugar, marcado skipped: true com skipReason: "bail", carregando o método e a URL que teria enviado. Essas solicitações são contadas em summary.skipped e em nenhum passed nem failed, então passed + failed ainda é igual a total e uma execução truncada não pode parecer uma mais curta que ficou verde. A própria execução ganha um objeto bail:

{
  "bail": {
    "reason": "test failure",
    "at": "Login",
    "path": "/collections/my-api/auth/login.bru",
    "group": 0,
    "skipped": 22
  }
}

reason é ou request failure (nada voltou) ou test failure (voltou e uma verificação falhou). Grupos posteriores são pulados inteiros.

Nada cancela uma solicitação já em voo. Com parallel, ou com um grupo próprio que executa concorrentemente, as solicitações que já haviam começado ainda terminam e são relatadas normalmente — a execução diz isso em warnings em vez de deixar você inferir pela contagem.

Grupos de execução

Um grupo é uma execução isolada dentro de uma chamada. Ele possui sua própria lista de solicitações, ambiente, variáveis, sinalizador parallel, armazenamento de variáveis, cookie jar e tokens OAuth2. Nada cruza de um grupo para outro, em qualquer direção, em qualquer configuração de parallel. As mesmas requisições como dois usuários, sem chance de o token de um login ou o cookie de sessão chegar ao outro:

{
  "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 executa os dois grupos um contra o outro. As requisições de cada grupo permanecem em série, que é o que você quer quando orders depende do login anterior a ele.

Uma suíte contra dois ambientes:

{
  "groups": [
    { "name": "staging",    "requests": ["smoke"], "environment": "staging" },
    { "name": "production", "requests": ["smoke"], "environment": "production" }
  ]
}

Campos de grupo: name, requests, environment, variables, parallel, startAfter, data, dataFile.

docs/execution-groups.md cobre o modelo inteiro: o que um grupo possui, as duas flags parallel e seus padrões, ordenação, iterações sobre linhas de dados, o teto de concorrência e como uma falha se parece em cada nível.

  • Omita requests para executar a coleção inteira sob a identidade desse grupo. Um [] vazio não executa nada.
  • environment substitui o do nível de execução; variables faz merge sobre os do nível de execução, com o grupo vencendo.
  • Defina parallel em um grupo para executar suas próprias requisições concorrentemente. Elas compartilham o armazenamento desse grupo, então podem genuinamente disputar um bru.setVar — o ponto ao reproduzir uma condição de corrida. Dê a maxConcurrency pelo menos tantos slots quantos forem os competidores, ou o limite os serializa silenciosamente.
  • startAfter: { group, requestsCompleted } segura um grupo até que outro tenha chegado até ali — um listener conectado antes de um trigger disparar, sem um bru.sleep ajustado para a latência daquele dia. Requer parallel no nível de execução; uma requisição que falhou ainda conta como uma posição alcançada; ciclos e portões que nunca poderiam abrir são recusados antes que qualquer coisa execute.

Resultados

Os resultados têm a forma de grupo. Não há array results de nível superior, nem mesmo quando você não passou groups — nesse caso é um único grupo, e achatá-lo faria cada chamador verificar de que maneira havia chamado.

{
  "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"]
    }
  ]
}

Cada grupo carrega seu próprio summary, results, missingRequests, capturedVariableNames, capturedVariables e warnings. O summary de nível superior cobre a execução inteira.

response_headers não precisa de flag nem de script de teste. Valores nomeados como credenciais são mascarados, e set-cookie é uma lista — uma entrada por cookie, porque um valor unido por vírgulas não pode ser separado de volta — cujas entradas mantêm todos os atributos com apenas o valor do cookie omitido. Verificar HttpOnly, Secure, SameSite ou Strict-Transport-Security é, portanto, uma única chamada. includeResponseBody: false não os suprime: essa flag diz respeito ao tamanho de um corpo.

Um resultado WebSocket carrega response_headers também, contendo a resposta do handshake — o 101 é o único lugar onde um cookie de sessão ou um sec-websocket-protocol acordado aparece para esse transporte, já que frames não têm cabeçalhos. Um resultado gRPC relata seus metadados sob seu próprio detalhe grpc.

Um grupo que não conseguiu iniciar relata error em vez de resultados e conta como uma falha — caso contrário, uma execução com um grupo morto pareceria verde.

Campos de nível de execução: parseErrors e parseFailures nomeiam arquivos que não puderam ser analisados, warnings coleta qualquer outra coisa que valha a pena ver.

Scripts

Scripts são executados em um contexto V8 dentro de um processo bifurcado (veja Segurança). Ambos os tipos são funções assíncronas, então await de nível superior funciona.

Testes e pós-resposta recebem test(), expect(), res e bru:

APINotas
test(name, fn)Encapsula asserções. Necessário para que uma seja relatada
expect(v)Estilo Chai: .to.equal, .include/.contain, .match, .have.property/.lengthOf/.keys, .be.above/.below/.least/.most/.oneOf, .throw e .to.not.* para qualquer um deles
res.getStatus() res.getStatusText()
res.getHeader(name) res.getHeaders()A busca por cabeçalho não diferencia maiúsculas de minúsculas
res.getSetCookies()Cookies que a resposta definiu
res.getBody()Já analisado quando o subtipo do tipo de mídia é json ou termina em +json
res.getResponseTime()ms
res(path, ...fns)A linguagem de consulta do Bruno sobre o corpo: res("data.pets..name") desce para cada name, [0] indexa, [?] filtra ou mapeia com um callback. Também é válido como lado esquerdo de uma asserção, onde a sintaxe não poderia aparecer nua
bru.setVar(name, v) bru.getVar(name)Passa valores para requisições posteriores como {{name}}
bru.sleep(ms)Também setTimeout/setInterval e seus clear*
atob(s) btoa(s)base64, em ambos os tipos de script. Suficiente para ler um payload de JWT sem uma segunda requisição

Scripts de pré-requisição recebem req e bru em vez disso — ainda não há resposta. Mutar req muda o que é enviado: req.getUrl(), req.setUrl(), req.getMethod(), req.getHeader(), req.setHeader(), req.getHeaders(), req.getBody(), req.setBody().

Duas coisas que pegam as pessoas de surpresa

Encapsule asserções em test(). Um expect() passando sem encapsulamento nunca é registrado, então a execução relata "tests": [] enquanto a requisição conta como aprovada — verde sem nada verificado. O executor percebe isso e o diz no warnings daquele resultado. Uma asserção falhando sem encapsulamento não é silenciosa: ela lança e é relatada como um erro de script.

test("status is 200", function() {          // ✅ recorded
  expect(res.getStatus()).to.equal(200);
});

expect(res.getStatus()).to.equal(200);      // ❌ runs, passes, reported nowhere

Não use JSON.parse(res.getBody()). Já é um objeto sempre que o subtipo do tipo de mídia é json ou carrega o sufixo +json — application/json, text/json, application/vnd.api+json — então analisar novamente lança SyntaxError: "[object Object]" is not valid JSON. Leia os campos diretamente. Se um endpoint pode retornar qualquer um dos dois, faça uma ramificação: typeof b === "string" ? JSON.parse(b) : b.

Ler uma declaração de um token não precisa de uma segunda requisição. atob e btoa estão ambos presentes, sob os nomes que o próprio sandbox do Bruno usa, então a dança usual de base64url funciona:

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 não está disponível. É uma classe de host com capacidades que um sandbox não deveria entregar, e um substituto fiel seria um fake cujas lacunas você descobriria uma de cada vez; uma referência nua lança Buffer is not defined, que é relatado como um erro de script em vez de se comportar mal silenciosamente.

Dormir conta contra o tempo limite do script — settings.timeout, 5000 ms quando não definido. await bru.sleep(10000) sob o padrão relata um tempo limite em vez de esperar.

Ambientes e variáveis

Um ambiente é environments/<name>.yml na coleção:

name: dev
variables:
  - name: baseUrl
    value: https://api-dev.example.com
  - name: apiKey
    value: dev-key-123
  - name: skipped
    value: whatever
    disabled: true

As ferramentas recebem variáveis como um objeto plano ({"baseUrl": "..."}) e escrevem esse array para você.

{{name}} é substituído em urls, cabeçalhos, corpos e autenticação. Variáveis desabilitadas são ignoradas; as não resolvidas são deixadas como escritas e nomeadas nos avisos da execução.

Precedência, da mais baixa para a mais alta: arquivo de ambiente → variables da execução → vars da própria requisição → bru.setVar durante a execução. Isso corresponde ao comportamento --env-var do Bruno.

Uma variável pode ser construída a partir de outras: base_url: "https://{{host}}/{{stage}}" resolve da mesma maneira que sob bru run, usando o próprio interpolate do Bruno. Uma exceção, deliberada: um valor capturado de uma resposta — por bru.setVar ou um bloco vars de pós-resposta — é inserido como texto e nunca é verificado novamente, então uma resposta que ecoa key={{api_key}} não pode fazer a próxima requisição enviar sua chave.

Geradores. {{$guid}}, {{$timestamp}}, {{$randomEmail}} e o resto das ~120 variáveis dinâmicas do Bruno funcionam em urls, cabeçalhos, parâmetros de consulta, corpos e autenticação. Elas não são variáveis: nada as declara, cada ocorrência produz seu próprio valor, e nenhuma é relatada como não resolvida. Uma palavra-chave à qual nenhum gerador responde — {{$gid}} — é deixada como escrita e nomeada nos avisos, então um erro de digitação ainda aparece. Em um corpo JSON ou em um bloco de variáveis GraphQL, o valor gerado é escapado, então um gerador que emite uma nova linha ({{$randomLoremParagraphs}}) deixa o documento analisável.

Segredos: nenhum formato do Bruno armazena o valor de um segredo — apenas seu nome. Então passe segredos como variables da execução, que permanecem na memória e nunca são escritos em um arquivo.

Um nome de ambiente é um nome, não um caminho. Qualquer coisa contendo um separador é recusada.

Arquivos de relatório

run_collection retorna seus resultados como JSON, que é o que um agente lê. Os outros dois consumidores de uma execução de teste leem arquivos, então report os escreve:

{ "collectionPath": "/path/to/collection",
  "report": { "junit": "reports/junit.xml", "html": "reports/run.html" } }

Nomeie um formato ou ambos. O resultado então carrega reports, uma entrada por arquivo escrito, com seu caminho absoluto e seu tamanho em bytes.

Caminhos são confinados à coleção. Um caminho que resolve para fora dela é recusado e o motivo se torna um aviso da execução; a execução em si ainda é bem-sucedida, porque os resultados são o que foi pedido e o arquivo é um subproduto. Copie o arquivo depois se seu pipeline coleta relatórios de outro lugar — escrever onde um chamador aponta é uma autorização muito maior do que executar suas requisições. Diretórios pai ausentes dentro da coleção são criados, e um relatório existente é sobrescrito.

O XML JUnit segue bru run --reporter-junit: um <testsuite> por requisição, um <testcase> por asserção ou teste, e uma requisição que errou relatada como um erro de suíte. Quatro coisas que ele faz de forma diferente, cada uma porque a alternativa é um relatório que parece mais verde do que a execução:

  • Uma requisição que executou e não verificou nada recebe um testcase ignorado dizendo isso, em vez de uma suíte vazia. Uma suíte vazia é invisível em todo resumo de CI, que é exatamente a leitura "executou verde, não verificou nada" que o contador requestsWithoutTests existe para expor.
  • Um arquivo de requisição que não pôde ser analisado, uma requisição nomeada que resolveu para nada e um grupo que travou recebem cada um uma suíte própria. Um relatório listando apenas o que executou diz que um subconjunto executou sem dizer que era um subconjunto.
  • O rótulo de um grupo nomeado é incorporado ao nome da suíte, já que o JUnit não tem o conceito de um, e duas identidades executando a mesma requisição seriam indistinguíveis de outra forma.
  • Sem atributo hostname. O upstream escreve o nome da máquina no arquivo; estes relatórios são feitos para serem commitados.

O relatório HTML é o próprio do Bruno, renderizado por @usebruno/common, com grupos de execução como suas iterações — uma execução de duas identidades lê-se como duas seções. Duas coisas para saber: a página incorpora os dados da execução, mas carrega Vue e naive-ui de unpkg.com, então precisa de acesso à rede quando aberta e não mostra nada offline; e seu painel de requisição está vazio, porque um resultado não retém a requisição como foi enviada. Asserções e testes de script compartilham uma lista pelo mesmo motivo — um resultado não os distingue.

Um relatório contém o que os resultados contêm, no disco: corpos de resposta incluídos, cabeçalhos de resposta mascarados exatamente como estão no JSON.

Formatos

Arquivo marcador na coleçãoFormato
opencollection.ymlYAML — verificado primeiro
bruno.jsonBRU (legado)
nenhumYAML

Novas coleções são YAML, a menos que você passe format: "bru".

Arquivos de requisição .yaml são lidos como YAML, exatamente como .yml, porque outras ferramentas adjacentes ao Bruno os escrevem. Mas o próprio aplicativo do Bruno e bru run não reconhecem a extensão, então cada arquivo .yaml lido é nomeado nos avisos da execução — uma passagem silenciosa seria uma execução verde de uma requisição que o Bruno não pode ver. Renomeie para .yml para limpar isso. Nada que este servidor escreve usa .yaml.

Requisições gRPC e WebSocket

Uma coleção pode conter requisições gRPC e WebSocket junto com as HTTP. Este servidor lê, preserva e reporta elas: read_request retorna o tipo, o alvo, o método e o caminho do proto para gRPC, seu bloco de metadados, e quantas mensagens estão armazenadas; list_requests as lista; e editar qualquer requisição na coleção não as destrói mais. Antes disso, ambos os formatos descartavam o bloco de alvo, as credenciais e cada mensagem armazenada, então um write_request em uma requisição não relacionada reescrevia o arquivo sem eles.

run_collection executa ambos. Uma requisição gRPC realiza uma chamada unária contra o serviço que seu .proto declara; uma requisição WebSocket abre o socket, envia os frames que o arquivo armazena e registra o que volta até que um limite seja alcançado. Cada um reporta seu próprio detalhe: um resultado gRPC carrega o código de status gRPC, a string de detalhes e metadados de cauda editados, e um resultado WebSocket carrega a transcrição, o stop_reason que a encerrou e se foi truncada. O código gRPC vive em seu próprio campo e nunca é mapeado no status do resultado, porque o OK do gRPC é 0 e 0 é o sentinela de recusa desta API — uma chamada bem-sucedida e uma recusa de segurança seriam indistinguíveis no campo lido primeiro.

Uma sessão WebSocket não tem fim natural, então é limitada, e cada limite é configurável por execução sob o argumento websocket de run_collection:

LimitePadrãoO que faz
maxMessages50Frames de entrada registrados antes de parar
maxDurationMs5000Teto de relógio de parede para uma sessão
idleTimeoutMs1500Silêncio que encerra uma sessão; 0 espera pelo teto
sendIntervalMs0Intervalo entre as mensagens que uma requisição envia; 0 as envia em um único tick
includePayloadsfalseRegistrar o conteúdo dos frames, não apenas tamanho e tempo
maxFrameBytes65536Teto por frame no payload registrado
maxTranscriptBytes1048576Teto cumulativo, contado a partir do tamanho do fio
engineIoKeepalivefalseResponder a um 2 do engine.io com um 3

O teto de relógio de parede é um limite de segurança em vez de um agendamento, então idleTimeoutMs é o que geralmente encerra uma sessão: uma vez que nada chegou por 1500 ms, ela para e reporta stop_reason: "idle", o que não é contado como truncamento porque nenhum bit de limite e o teto ficou sem uso. O relógio é armado pelo primeiro frame, não na conexão, então uma requisição somente de escuta que não autora mensagens ainda espera maxDurationMs por um par que pode ainda falar. Defina-o como 0 para um protocolo cujos intervalos são maiores que suas respostas.

sendIntervalMs é o que torna um protocolo de enviar-esperar-enviar alcançável. No padrão de 0, as mensagens de uma requisição saem todas em um único tick, então cada resposta chega após a última delas e a troca não tem ordem para afirmar; defina um intervalo e a transcrição carrega cada resposta entre os envios a que pertence, no deslocamento em que realmente chegou. Duas consequências que valem saber. maxDurationMs tem que cobrir toda a sequência ritmada — uma sessão parada no meio nomeia as mensagens que nunca saíram, pelo nome que foram autoradas, em vez de deixar uma transcrição com um envio a menos para ser lida como um par que parou de responder. E o limite de ociosidade não é armado enquanto a sequência ainda está saindo, então um sendIntervalMs maior que idleTimeoutMs é seguro: o intervalo que uma requisição deliberadamente deixa entre suas próprias mensagens não é o silêncio do par.

Um subprotocolo é autorado como um cabeçalho Sec-WebSocket-Protocol na requisição, separado por vírgulas para mais de um, e é negociado no handshake; aquele que o servidor concordou volta no response_headers desse resultado. Não há campo separado para ele, aqui ou no Bruno. Escrever o cabeçalho costumava ser pior do que omiti-lo: a biblioteca valida a resposta do servidor contra a lista que foi dada na conexão, então um servidor que fez exatamente o que o cabeçalho pedia tinha seu handshake recusado por oferecer um subprotocolo que ninguém solicitou. Um Sec-WebSocket-Version autorado é honrado da mesma forma, pela mesma razão.

Cada entrada de transcrição diz que tipo de frame era — text, binary, ping, pong ou close — carrega o title autorado de uma mensagem que a sessão enviou, e, em um frame de fechamento, o close_code que o par deu, com sua razão como o payload dessa entrada: 1000 é um adeus comum, 1006 um par que desapareceu sem um, 1008 uma recusa, 1011 um erro de servidor. Frames de controle não contam para maxMessages, ou um par que faz ping uma vez por segundo encerraria uma sessão sozinho e reportaria count para um que não recebeu resposta. O payload de um frame binário é base64 e bytes é o tamanho real do fio para cada tipo. Um script pós-resposta vê os mesmos campos, porque a transcrição é o que res.body é neste transporte.

Afirmando sobre um resultado gRPC ou WebSocket

Ambos os transportes executam scripts pós-resposta e de teste, e res é moldado para que haja uma coisa a aprender em vez de duas. O que difere do HTTP vale ser dito diretamente, porque adivinhar errado faz um teste que não pode falhar.

Em uma requisição WebSocket:

  • res.getBody() é a transcrição — o mesmo array que o resultado carrega, entregue ao script como uma estrutura em vez de texto JSON. res.rawBody mantém a forma serializada.
  • res.getStatus() é sempre 0. Uma sessão não tem status, e inventar um seria pior do que não ter nenhum. O resultado está em res.statusText, que carrega a razão de parada (count, timeout, bytes, closed ou error).
  • Então uma afirmação WebSocket lê frames e statusText. Um teste escrito contra res.getStatus() afirma sobre uma constante.
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");
});

Os payloads que um script vê são sempre os reais, seja o que includePayloads disser. Essa flag controla a transcrição no resultado, não a em res, porque frames de saída são registrados após a interpolação de {{var}} e um resultado retornado por padrão não deve carregar cada segredo que você passou. Esta é a divisão que o HTTP já tem — res.body sempre contém o corpo completo enquanto response_body é controlado por includeResponseBody. Isso significa que includePayloads: false junto com afirmações de conteúdo é a forma pretendida para CI, não um workaround: as afirmações verificam os payloads, e o que volta contém apenas direção, tempo e tamanhos.

Em uma requisição gRPC, res é mais próximo do HTTP: res.getStatus() é o código de status gRPC (0 é OK), res.statusText é o próprio details do servidor quando ele forneceu algum e o nome canônico do código caso contrário, res.getBody() é a mensagem de resposta analisada, e os trailers de resposta chegam como os cabeçalhos.

includePayloads está desligado por padrão como uma propriedade de segurança, não uma preferência: frames de saída são registrados após a substituição de {{var}}, então registrá-los por padrão escreveria cada segredo passado em variables em um resultado que é retornado por padrão. engineIoKeepalive está desligado por uma razão relacionada — ele coloca um frame no fio que a requisição não autorou — e mesmo quando ligado, responde apenas depois que um frame OPEN foi realmente visto.

Uma requisição WebSocket agora pode ser autorada em vez de copiada. write_request aceita kind: "websocket" com uma url e websocket.messages, e recusa os campos para os quais esse transporte não tem lugar: um método HTTP, um corpo, parâmetros de consulta, parâmetros de caminho. Cada mensagem carrega content e, opcionalmente, um title e um type de text ou binary; uma mensagem sem título é nomeada message 1, message 2 por posição, exatamente como o Bruno nomeia uma. Cabeçalhos, autenticação, assert, vars, settings e scripts funcionam como fazem para uma requisição HTTP, e o arquivo escrito é byte-idêntico ao que o Bruno escreve para a mesma requisição em ambos os formatos — comprovado contra o próprio escritor upstream, não contra um round-trip através do nosso parser.

Um campo é registrado de forma diferente pelos dois formatos. selected: false marca uma mensagem como autorada mas não enviada. .yml escreve o falso. .bru expressa apenas a metade verdadeira: o escritor upstream emite a flag quando está definida e nada quando não está, e seu leitor resolve uma flag ausente para false — então nesse dialeto uma mensagem desmarcada e uma não marcada são a mesma mensagem, e nenhuma é enviada. Uma execução segue essa leitura, o que significa que uma mensagem .bru escrita à mão é enviada apenas se disser selected: true; cada mensagem pulada pela falta dela é nomeada nos avisos do resultado, então uma requisição que agora não envia nada diz o porquê em vez de reportar uma sessão vazia. Autorar uma mensagem desmarcada em uma coleção .bru a escreve sem flag, exatamente como o Bruno faz, então o arquivo se comporta como pedido; o que o dialeto perde é apenas o relatório, já que ler a requisição de volta encontra a flag ausente em vez de falsa.

Uma requisição gRPC é autorada da mesma forma, com kind: "grpc": uma url, e sob grpc, o method totalmente qualificado, o protoPath, o methodType e o messages. Ela também recusa um método HTTP, um corpo, parâmetros de consulta e parâmetros de caminho. Três coisas sobre ela valem saber antes de escrever uma.

Cabeçalhos se tornam metadados, que é a única superfície de cabeçalho desse transporte — um bloco headers em uma requisição gRPC é um que o leitor gRPC do Bruno nunca olha, então o argumento headers é escrito como metadata em vez disso. O protoPath já deve existir dentro da coleção e é armazenado relativo a ela, qualquer que seja a grafia que você der, porque um caminho absoluto é o layout de diretório do operador comprometido em um arquivo compartilhado; um caminho que resolve fora da coleção é recusado, symlinks incluídos, assim como um cujos imports o deixam a quantos saltos for (um import google/protobuf/ bem conhecido não é um arquivo e não é recusado). E todos os quatro valores methodType são aceitos, porque o Bruno escreve todos os quatro — mas apenas unary executa aqui, então os outros três autoram um arquivo que o Bruno pode abrir e run_collection recusará pelo nome. Como com WebSocket, os bytes são idênticos ao próprio escritor do Bruno em ambos os formatos, incluindo o desacordo dos dois dialetos sobre grafia: .bru escreve protoPath dentro do bloco grpc, .yml escreve protoFilePath.

write_request edita ambos os transportes. Uma url, cabeçalhos, autenticação, assert, vars, settings, name e sequence todos se aplicam, assim como o objeto aninhado websocket ou grpc — suas mensagens, e para gRPC o method, protoPath e methodType. Cada campo é escrito onde esse transporte o mantém, então uma edição de cabeçalho gRPC cai em metadata e nunca escreve um bloco headers. Tudo o que a edição não nomeia volta byte-idêntico, o que importa mais aqui do que para HTTP: uma edição regenera o arquivo inteiro a partir de um modelo analisado, então qualquer coisa que o modelo não carrega se vai sem uma mensagem.

O que ainda é recusado é o que o transporte genuinamente não tem lugar — um método HTTP, um corpo, parâmetros de consulta, parâmetros de caminho, e o objeto do outro transporte — pelo nome, deixando o arquivo byte-inalterado. Recusar url, cabeçalhos e autenticação também costumava ser o comportamento, o que significava que o alvo de uma requisição WebSocket não podia ser alterado pela vida do arquivo. Um script de pré-requisição é executado em ambos os transportes e alcança o que cada um deles realmente possui. bru.setVar é honrado e o valor chega ao próprio {{placeholders}} da mesma requisição, então um script pode calcular um nome de sala, um tópico ou um destino e então discá-lo. req.setUrl substitui o destino. req.setHeader escreve na superfície de cabeçalhos do próprio transporte: os cabeçalhos de handshake de um WebSocket ou os metadados de uma chamada gRPC — que é a mesma superfície, já que o grpc-js coloca metadados no fio como cabeçalhos HTTP/2. Um script que lança uma exceção interrompe a requisição antes que qualquer coisa seja discada, e a falha é reportada como ela mesma. req.getUrl() e req.getHeaders() leem o destino substituído e os cabeçalhos da própria requisição; credenciais que o transporte calcula não estão entre eles, porque a autenticação é aplicada após o script. A única coisa que não é honrada é req.setBody(), que emite um aviso: nenhum dos transportes envia um corpo único — uma sessão WebSocket envia uma lista de mensagens e uma chamada gRPC unária envia uma mensagem tipada — então não há nada para um único valor substituir, e adivinhar colocaria bytes no fio que o arquivo nunca criou.

Ambos os transportes são carregados de forma preguiçosa, e isso é imposto em vez de apenas afirmado: um teste registra cada módulo que o servidor real resolve e falha se uma execução somente HTTP nomear @grpc/grpc-js ou ws. Medido, uma execução somente HTTP carrega undici e nenhum deles.

Duas coisas são recusadas em vez de adivinhadas. Um arquivo cujo tipo declarado e o bloco de destino discordam (type: grpc com um bloco http:) é um erro de análise que nomeia ambos, porque o tipo decide o que um leitor reporta enquanto o bloco decide o que um executor contata. E uma requisição .bru cujo URL de destino está vazio é recusada na escrita, porque o formato descarta tal bloco enquanto mantém as credenciais ao lado — o resultado pareceria criado e não iria a lugar nenhum.

Cinco coisas são deliberadamente não construídas. Chamadas gRPC de streaming e sessões WebSocket mantidas abertas fariam o resultado de uma execução depender de quando foi lido, e cada resposta aqui é um valor que um chamador pode verificar. Reflexão de servidor buscaria o esquema pela mesma conexão sob teste, então um caminho .proto é exigido em vez disso. Suporte a proxy e fixação de certificado não alcançam esses transportes: a portaria é somente undici, @grpc/grpc-js não expõe API de proxy e honra http_proxy ambiente por conta própria, e ws precisaria de um agente próprio. E um bloco socket.io ou MQTT inventaria um formato de arquivo que o upstream não escolheu, o que é uma migração no momento em que isso acontece.

socket.io não precisa de bloco, porque é uma convenção de enquadramento sobre WebSocket em vez de um protocolo próprio. Medido contra socket.io 4.8.3, uma requisição ws simples alcança um:

  1. Conecte-se a ws://host:port/socket.io/?EIO=4&transport=websocket. Ambos os parâmetros de consulta são obrigatórios — EIO=4 seleciona a versão do Engine.IO, e transport=websocket impede que o servidor espere um handshake de long-polling HTTP primeiro.
  2. O servidor envia 0{…}, o pacote OPEN do Engine.IO. Sua carga útil carrega sid, pingInterval e pingTimeout em milissegundos.
  3. Envie 40 para entrar no namespace padrão — nada funciona antes disso. Um namespace nomeado é 40/namespace,.
  4. O servidor responde 40{"sid":"…"}.
  5. Envie um evento como 42["event-name",payload]: 4 para MESSAGE, 2 para EVENT, depois um array JSON cujo primeiro elemento é o nome do evento.
  6. O servidor envia 2 (PING) a cada pingInterval e desconecta um cliente que não responde 3 (PONG) dentro de pingTimeout. Defina websocket.engineIoKeepalive em run_collection se uma gravação ultrapassar essa janela; está desligado por padrão e responde somente após um quadro OPEN real ter sido visto.

Os passos 1 a 5 são quadros que o arquivo de requisição já armazena, então apenas o passo 6 precisa de algo do executor. Isso está fixado em EIO=4 — Engine.IO v2 e v3 enquadram de forma diferente. Acks (42<id>[…] respondido por 43<id>[…]) e anexos binários (um espaço reservado 45 seguido por quadros binários separados) são graváveis manualmente e desagradáveis na prática.

Segurança

SSRF. Cada URL de saída, incluindo cada salto de redirecionamento, é resolvido e verificado. Endereços privados, loopback, link-local e outros reservados são recusados, e os endereços aprovados são fixados para a requisição para que o nome não possa resolver para outra coisa no meio. Uma recusa é reportada por requisição como um erro SSRF blocked com status 0.

Scripts são executados em um contexto V8 dentro de um processo bifurcado e descartável. O filho recebe um ambiente limpo, então um script que escape do contexto ainda não pode ler os segredos do servidor — eles não estão em seu espaço de endereço. Seu stdout é canalizado, nunca herdado, então não pode escrever no fluxo JSON-RPC do MCP. Um script descontrolado é limitado por SIGKILL no filho, o que o timeout no contexto sozinho não pode garantir. Isso é defesa em profundidade via um processo do SO, não uma jaula: não impede que o código seja executado no filho, torna inútil executar lá.

Caminhos. Referências de requisição devem permanecer dentro da coleção. collectionRoot deve conter o caminho da coleção. Nomes de ambiente não podem conter separadores.

Uploads de arquivos. Uma parte de arquivo form-data nomeia um caminho no disco do servidor, então é confinada: legível somente sob a raiz da coleção, o diretório inicial do usuário, o diretório temporário do SO ou um diretório adicionado pelo operador. Além disso, qualquer componente de caminho que comece com . é recusado — então ~/.ssh/id_rsa, .env e .aws permanecem ilegíveis mesmo que o diretório inicial seja permitido. Caminhos relativos resolvem contra a raiz da coleção.

Escotilhas de escape do operador, todas desligadas por padrão:

VariávelEfeito
BRUNO_SSRF_ALLOWLISTNomes de host exatos separados por vírgula, literais de IP e/ou faixas CIDR permitidos apesar de privados. Uma entrada de nome de host é comparada com a grafia no URL; uma entrada de endereço é comparada com o endereço ao qual o URL resolve, e também permite um nome bloqueado como localhost quando cada endereço ao qual resolve está na lista de permissões. Lido uma vez na inicialização e nunca influenciado por argumentos de ferramenta; curingas são rejeitados
BRUNO_UPLOAD_DIRSDiretórios extras dos quais uploads podem ler
BRUNO_PROXY_HOSTSHosts permitidos a usar o proxy de uma coleção
BRUNO_INSECURE_TLS_HOSTSHosts permitidos a pular a verificação de certificado
BRUNO_DNS_TIMEOUT_MSTempo limite de resolução de DNS
BRUNO_WORKSPACE_PATHOnde encontrar o workspace.yml do Bruno

Uma entrada na lista de permissões corresponde à grafia exata de um destino. Um nome de host na lista de permissões nunca é resolvido — o operador atestou o nome, não o que ele aponta hoje — então não cobre os endereços por trás dele, e um endereço na lista de permissões não cobre um nome que resolve para ele. localhost e 127.0.0.1 são, portanto, duas entradas, e permitir uma enquanto uma requisição usa a outra parece uma proteção inconsistente quando é uma entrada ausente. Recusas dizem isso.

Isso restringe o que run_collection buscará. Um agente com acesso ao shell pode alcançar a rede de qualquer forma, então trate como uma camada, não uma fronteira.

FAQ

Precisa do CLI do Bruno (bru) instalado?

Não. O pipeline de requisição é implementado aqui — variáveis, autenticação, cookies, redirecionamentos, asserções, scripts, ordenação de dependências — então nada chama bru e nada precisa do binário no PATH. É também por isso que o trabalho de paridade acima existe: uma implementação independente tem que ser mantida contra o original deliberadamente.

Suporta arquivos opencollection .yml ou apenas .bru?

Ambos, e detecta qual dialeto uma coleção usa em vez de perguntar a você. Novas coleções usam .yml por padrão; create_collection aceita format: "bru" se você quiser o dialeto legado. Onde os dois formatos genuinamente discordam — e discordam — cada um é escrito da maneira que o próprio escritor do Bruno para aquele dialeto escreve.

Uma coleção é um dialeto ou o outro, no entanto, não uma mistura: o manifesto raiz escolhe, e o Bruno lê somente essa extensão, então uma requisição .yml dentro de uma coleção bruno.json é um arquivo em um diretório no que diz respeito ao Bruno. Este servidor ainda lê, escreve e executa tal arquivo — recusar deixaria você incapaz de realizar a correção, que é uma renomeação desse mesmo arquivo — mas toda ferramenta que toca ou lista um deles diz que o Bruno não pode vê-lo e o nomeia.

Posso executar isso em CI?

A coleção que produz é uma coleção Bruno normal, então CI a executa com bru run exatamente como se um humano a tivesse criado no aplicativo. O próprio servidor MCP é para o loop de autoria e depuração, onde um agente está na sala. Ele também escreve arquivos de relatório JUnit XML e HTML — veja Arquivos de relatório — então uma execução dirigida por um agente ainda deixa o artefato que um painel de CI espera.

Ele reescreverá arquivos que o aplicativo Bruno escreveu?

Somente os campos que você pediu para alterar. Uma edição via write_request é uma mesclagem parcial, uma chave que este servidor não modela é carregada de volta onde o formato pode carregá-la — .yml em todo lugar, e .bru onde sua gramática tem um bloco de dicionário para segurá-la — e cada escrita é verificada contra o próprio leitor do Bruno no conjunto de testes. Exclusões precisam de um confirm: true explícito.

Quais clientes MCP funcionam?

Qualquer um — este é um servidor stdio simples sem código específico de cliente. Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, Codex CLI, opencode, Zed, Cline, Continue, LM Studio, Gemini CLI, o MCP Inspector ou seu próprio cliente SDK. Veja Conectar um cliente para onde cada um mantém sua configuração.

O que acontece com meus segredos?

Variáveis de ambiente secretas permanecem na memória durante a execução e nunca são escritas em um arquivo — nenhum dialeto armazena o valor de um segredo no disco, o que é o design do Bruno, não uma limitação adicionada aqui. Credenciais são redigidas dos resultados retornados ao agente, incluindo uma colocada em um parâmetro de consulta. Scripts são executados em um sandbox V8 bifurcado com um ambiente limpo. Veja Segurança.

É o mesmo que o bruno-mcp original?

Começou como um fork de macarthy/bruno-mcp, que está inativo desde julho de 2025 e gerava arquivos de coleção sem executá-los. Tudo acima — o executor, os leitores, ambos os dialetos, o portão de paridade, o sandbox — foi construído após o fork. O repositório agora é Ostico/bruno-mcp-studio (anúncio); o nome do pacote npm não mudou, @ostico/bruno-mcp.

É afiliado ao Bruno?

Não. Bruno é um produto da usebruno e seu nome e marcas registradas pertencem a eles. Este é um projeto comunitário que lê os pacotes de código aberto do Bruno para permanecer compatível com eles; não é endossado nem afiliado à usebruno.

A própria equipe do Bruno anunciou um servidor MCP oficial, usebruno/bruno-mcp, em agosto de 2026. Este não é ele e não compete por esse papel — veja Qual servidor MCP do Bruno devo usar? para o que cada um é bom.

Atualizando para 2.5.0 — quatro ferramentas foram mescladas em write_request

Quatro ferramentas se foram. O que elas faziam, write_request faz, com a mesma semântica: uma edição ainda atualiza somente os campos que você passa e preserva todos os outros campos, byte por byte.

Removido em 2.5.0Chame em vez disso
create_request, modify_requestwrite_request
create_test_suite, create_crud_requestswrite_request com requests, e dependencies para ordenação

delete_request não mudou e ainda está aqui; agora também aceita filePaths para excluir um conjunto em uma única chamada. Isso falha de forma segura (fail-closed). Um cliente solicita a lista de ferramentas na conexão e recebe os nomes atuais, portanto um agente que lê a lista não é afetado. Uma chamada para um nome removido gera um erro de ferramenta desconhecida ou um prompt de permissão — nunca um resultado errado, nunca um resultado silencioso.

O que realmente precisa ser editado é a configuração que fixa um nome: um argumento --allowedTools mcp__bruno-mcp__create_request, uma entrada de permissão settings.json, um matcher de hook e qualquer texto em um CLAUDE.md ou uma skill que instrua um agente a chamar um dos quatro pelo nome.

Versionamento

Os nomes das ferramentas são descobertos a partir de tools/list na conexão, não vinculados por link, portanto renomear ou remover uma ferramenta é lançado em uma versão menor. Uma chamada ou se comporta exatamente como antes ou não existe, e o segundo caso é ruidoso.

Alterar o que uma chamada inalterada faz é lançado em uma versão principal. Esse é o tipo perigoso: 2.0.0 transformou pastas em grupos de execução, então um chamador que não mudou nada obteve um resultado diferente. Nada mais silencioso do que isso merece uma versão principal, porque versões principais que não sinalizam perigo ensinam você a ignorá-las.

Atualizando da versão 1.x

  • requestPath e folder foram removidos. Ambos se tornam requests, um array ordenado de arquivos e/ou diretórios.
  • Não há array results de nível superior. Leia result.groups[0].results.
  • parallel: true costumava isolar cada pasta. Não isola mais: sem groups, toda a seleção é um único grupo compartilhando um store e um cookie jar. Nomeie as pastas como grupos separados para manter o comportamento antigo.
  • seq não restringe mais a execução. É apenas a ordem padrão e a ordem de relatório.
  • Um requests: [] vazio não executa nada. Costumava executar a coleção inteira.
  • Exportações removidas: BruGenerator, generateBruFile, createBasicBruFile.

Detalhes completos em CHANGELOG.md.

Desenvolvimento

npm run build       # compile to dist/
npm test            # full suite
npm run test:unit   # unit only
npm run typecheck
npm run lint

Use npm, não yarn — o lockfile é do npm e o CI executa npm ci.

Veja CONTRIBUTING.md para o que o CI verifica, as convenções de commit e a assinatura (DCO) que todo commit precisa.

Licença

MIT — veja LICENSE. Contribuições são aceitas sob os mesmos termos, com uma assinatura DCO.

Links


Suporte

Se o Test-Guard é útil para você, considere apoiar seu desenvolvimento:

Sponsor on GitHub Donate via PayPal