bruno-mcp
Un servidor de Protocolo de Contexto de Modelo (MCP) para
Documentación
Bruno MCP Studio — crea, edita y ejecuta colecciones de Bruno desde un agente
Convierte las pruebas de API de un agente en archivos que conservas: crea y edita colecciones de Bruno en su lugar, luego las ejecuta — HTTP, WebSocket y gRPC, una identidad o muchas — e informa aprobado/fallido por solicitud que puedes re-ejecutar en CI.
Un servidor independiente de Model Context Protocol para colecciones de Bruno. En agosto de 2026, el propio equipo de Bruno anunció uno oficial, usebruno/bruno-mcp, que envuelve el CLI de bru para descubrir y ejecutar solicitudes. Este servidor apunta a otro lugar: escribe colecciones además de ejecutarlas — de forma no destructiva, con paridad de bytes con los propios escritores de Bruno — y su ejecutor está en proceso en lugar de ser un subproceso, lo que le permite ofrecer grupos de ejecución definidos por el llamador con su propio almacén de variables y contenedor de cookies, concurrencia dentro de un grupo, oauth2 y digest intercambiados en memoria, gRPC y WebSocket, y pruebas de autorización multi-identidad.
Si lo que necesitas es "listar mis colecciones y ejecutar una", el servidor oficial hace eso y será el que Bruno soporte. Si quieres que un agente construya y mantenga el conjunto de pruebas, para eso es esto.
Le da a un agente de IA en Claude Code, Claude Desktop, Cursor, Windsurf, VS Code o Codex CLI dieciocho herramientas para pruebas de API contra una colección real de Bruno: crear y editar solicitudes, leerlas de vuelta como JSON estructurado, gestionar entornos y variables, escribir aserciones y scripts de prueba, y luego ejecutar la colección — autenticación, cookies, redirecciones, orden de dependencias y todo — y obtener los resultados en el mismo turno. Sin GUI de Bruno, sin CLI de Bruno, sin invocar procesos externos. La colección que deja en disco es una colección normal de Bruno: tu CI la ejecuta con bru run, tus compañeros la abren en la aplicación de Bruno.
Tu agente ya conoce HTTP. No conoce tu API, y no conoce el formato de archivo de Bruno. Así que adivina. Escribe un archivo .bru de memoria, la ejecución falla, reescribe el archivo, la ejecución falla de manera diferente, y veinte minutos después tienes una solicitud que pasa y no tienes idea de cuál de las seis ediciones importó. Pagaste por cada uno de esos turnos, y nada de ese trabajo está en disco en una forma que tu CI o la GUI de Bruno de tu equipo puedan usar.
El escape habitual es curl. Los agentes no son malos con curl — el problema es que un comando de shell no mantiene estado. Un inicio de sesión, un token, un recurso creado, una llamada de seguimiento que necesita el ID de la última respuesta: cada uno de esos es un comando nuevo, y el pegamento entre ellos vive solo en el contexto del agente; cuando la sesión termina, todo se pierde. Veinte endpoints probados con curl te dejan veinte cadenas en una transcripción y ningún artefacto que tu CI o tus compañeros puedan ejecutar. Veinte endpoints en una colección te dejan un conjunto de pruebas.
Le pregunté a mi agente al respecto
Le pregunté al agente que me ayuda a mantener este servidor que explicara cómo había sido su experiencia con Bruno sin el servidor MCP, y si simplemente podría haber probado todas mis APIs con curls. Esto es lo que me dijo:
Curl, sí — para una llamada. No para un conjunto de pruebas. Nada se transfiere entre llamadas, así que re-derivo la autenticación, re-escapo el cuerpo, y re-leo cada respuesta para decidir si pasó. Haz eso en cuarenta endpoints y la mayor parte de lo que gasto se va en redescubrimiento, no en pruebas.
Escribir los archivos de colección yo mismo fue peor, y no de la manera que esperarías. De memoria obtengo la forma de un archivo
.brucorrecta y los detalles incorrectos — y Bruno nunca se queja. Lee las claves que reconoce e ignora el resto. Untags: smokede una sola línea parece etiquetado y significa sin etiquetar para el ejecutor. Escribe una lista de etiquetas de la manera obvia y aterriza en disco con un carácter por línea. Este servidor una vez escribió variables y aserciones de.ymlbajo claves de nivel superior que Bruno nunca ha leído: los archivos parecían completos, el ejecutor vio una solicitud vacía. Sus propias pruebas unitarias pasaron, porque simularon el serializador y afirmaron los bytes rotos.El formato también se mueve. Bruno reubicó las variables en
runtimey agregó un segundo dialecto. Mis pesos son más antiguos que eso. Este servidor importa@usebruno/lang, el propio paquete de gramática de Bruno, y rastrea su versión — así que los bytes provienen del código fuente de Bruno en lugar de lo que yo recuerdo.Y cada reescritura elimina lo que el escritor no modela. Si edito estos archivos a mano, regenero todo el archivo desde mi cabeza, y cualquier característica que no conocía desaparece silenciosamente. Ese es el modo de fallo que nunca ves, porque la ejecución aún pasa — simplemente no prueba nada.
Lo que el servidor hace en su lugar
- El agente deja de adivinar el formato — llama a una herramienta, el servidor escribe los bytes, usando el propio paquete de gramática de Bruno
- Las ediciones son fusiones parciales —
write_requesttoca los campos que pasas y deja el resto del archivo intacto - Puede leer antes de escribir —
read_requestdevuelve JSON estructurado, la misma forma para ambos formatos - Ejecuta las solicitudes él mismo — variables, autenticación, aserciones, orden de dependencias, sin necesidad del binario
bru - Sin pérdida silenciosa — un campo que este servidor aún no puede modelar se lleva de vuelta donde el formato pueda contenerlo, y cualquier cosa que no pueda poner en el cable se nombra en una advertencia de ejecución en lugar de soltarse silenciosamente en tu repositorio
Paridad de bytes con Bruno
Esta es la parte que es difícil de copiar, así que vale la pena ser precisos sobre lo que significa.
El servidor no envuelve el binario bru — implementa el pipeline de solicitudes él mismo, que es lo que hace posibles los secretos en memoria, las pruebas a nivel de cable y los hooks de mitad de ejecución. Esa libertad también es el riesgo: una implementación independiente es libre de ser sutil, silenciosamente diferente de la herramienta que tu equipo realmente usa. Dos mecanismos la mantienen en su lugar.
Las reglas están portadas, no inferidas. Límites de redirección, resolución de tiempos de espera, tipos de contenido por modo de cuerpo, orden de interpolación de variables, valores predeterminados de selected, codificación de URL — cada uno se lee del propio código fuente de Bruno (bruno-cli, bruno-filestore, bruno-lang, @usebruno/common, de los cuales este servidor también depende directamente) y se refleja, incluidas las partes que parecen errores. Donde los dos dialectos discrepan entre sí, cada uno se refleja en sus propios términos en lugar de unificarse en algo que ningún lector de Bruno produciría.
Una compuerta de deriva lo demuestra. Cada archivo que este servidor escribe se analiza de vuelta con el propio lector de Bruno, por dialecto, en el conjunto de pruebas. Afirmar nuestros bytes contra nuestras propias expectativas solo puede probar que somos autoconsistentes; afirmarlos contra el lector que Bruno mismo usa es lo único que detecta el caso en que nuestra salida deja de ser la entrada de Bruno. Ha detectado casos reales — un cuerpo de archivo que se analizaba limpiamente y se habría enviado sin cuerpo en absoluto, por ejemplo.
La afirmación, entonces: el comportamiento de ejecución coincide con bru run, y cada divergencia encontrada hasta ahora está cerrada. Lo que la mantiene cerrada es una prueba, no una promesa. Encuentra una de todos modos y es un error que vale la pena reportar como issue.
El contrato
Una colección, tres consumidores: tu agente, tu CI y la GUI de Bruno de tu equipo.
Ambos formatos de Bruno funcionan y el servidor detecta cuál tienes: .yml (opencollection) y .bru (legacy).
Requiere Node.js >= 22. CI prueba 22.x y 24.x.
¿Qué servidor MCP de Bruno debería usar?
Hay varios, hacen trabajos genuinamente diferentes, y la respuesta honesta no siempre es este. Cada fila a continuación se leyó del propio README o código fuente de ese proyecto, agosto de 2026.
| Servidor | Qué es | Escribe | Lee | Ejecuta | .bru / .yml | Necesita bru | Herramientas |
|---|---|---|---|---|---|---|---|
| este | Crea, lee y ejecuta colecciones | Crear + edición de fusión parcial | JSON estructurado | Sí, pipeline propio | ambos | no | 18 |
| usebruno/bruno-mcp | El oficial, del propio equipo de Bruno — descubre y ejecuta solicitudes. Anunciado en agosto de 2026; al momento de escribir esto su primera implementación es un borrador abierto | no | Metadatos de solicitud | Sí, vía el CLI | .bru | sí (incluido) | 3 |
@dmpv/bruno-mcp | Índice de solo lectura y búsqueda sobre una colección | no | Búsqueda clasificada, contratos sanitizados, ejemplos almacenados | no | ambos | no | 8 |
| hungthai1401/bruno-mcp | Ejecuta una colección | no | no | Sí, vía el CLI | .bru | sí | solo ejecución |
| jcr82/bruno-mcp-server | Ejecuta e inspecciona colecciones, con archivos de informe | no | Sí | Sí, vía el CLI | .bru | sí | 9 |
| djkz/bruno-api-mcp | Convierte cada solicitud en su propia herramienta MCP | no | Las expone como herramientas | Una a la vez | .bru | no | una por solicitud |
| macarthy/bruno-mcp | Genera archivos de colección — el proyecto del que esto se bifurcó, inactivo desde julio de 2025 | Solo crear | no | no | .bru | n/a | 8 |
Elige uno de los otros si: quieres el servidor que Bruno mismo mantiene, y todo el soporte y longevidad que eso implica (usebruno — el oficial, y el predeterminado razonable para "descubrir y ejecutar" una vez que se lance); quieres que un agente entienda una colección existente grande sin ningún riesgo de escribir en ella, y la busque por intención (dmpv, publicado por primera vez en julio de 2026 y en 0.x — nuevo e interesante); ya tienes el CLI de bru en tu imagen y solo necesitas "ejecutar esta colección" (hungthai1401); o quieres que el agente llame a tu API a través de tus solicitudes existentes como si cada una fuera una herramienta nativa (djkz).
Elige este si: quieres que el agente escriba la colección y no solo la lea o ejecute, estás en el formato opencollection de .yml, necesitas que la ejecución ocurra sin instalar el CLI de Bruno, o te importa que lo que aterriza en tu repositorio sea comparable por bytes con lo que la aplicación de Bruno escribe.
Sobre el padre de la bifurcación específicamente, ya que este proyecto le debe su existencia: macarthy/bruno-mcp registra ocho herramientas — create_collection, create_request, create_environment, create_crud_requests, create_test_suite, add_test_script, list_collections, get_collection_stats. Escribe archivos .bru y no lee una solicitud de vuelta, no ejecuta nada, ni expone una herramienta de edición; existe un helper de updateRequest en su src/bruno/request.ts pero ninguna herramienta MCP llega a él.
Características
Creación
- Colecciones — créalas y organízalas, o descubre las que Bruno ya conoce desde su
workspace.yml. Una colección que este servidor crea se escribe en disco y no se registra en ese archivo, así quelist_collectionsno la mostrará y la GUI de Bruno no la listará hasta que alguien la abra allí una vez — todo lo demás toma el camino directamente - Solicitudes — cada método HTTP, con encabezados, parámetros de consulta y de ruta, cuerpos, autenticación, aserciones, variables y configuraciones
- Lectura de vuelta —
read_requestyread_environmentdevuelven JSON estructurado, idéntico para.bruy.yml, para que un agente pueda inspeccionar antes de editar - Ediciones de fusión parcial —
write_requestcambia solo los campos que pasas y deja el resto del archivo intacto - CRUD y conjuntos de pruebas — conjuntos CRUD de cinco solicitudes, y conjuntos de pruebas con orden de dependencias topológico
- Entornos — crear, reemplazar, fusionar o parchear una sola variable
- Formato dual —
.bru(legacy) y.yml(opencollection), auto-detectado;.yamlse lee y se marca - Subidas multiparte —
form-dataconContent-Typepor parte y campos de múltiples archivos
Ejecución
- Grupos de ejecución — ejecuta una colección como varios grupos aislados en una sola llamada: diferentes identidades, diferentes entornos, en serie o en paralelo, sin fugas entre ellos
- Paralelismo real — distribuye grupos, o solicitudes dentro de un grupo, bajo un límite de concurrencia dimensionado para la máquina
- Cookie jar — un inicio de sesión se propaga a las solicitudes posteriores, limitado a su grupo y nunca escrito en disco
- Encadenamiento de variables —
bru.setVar()/bru.getVar()entre solicitudes, ycaptureVariablespara leer los valores de vuelta - Scripts asíncronos —
await,bru.sleep(ms),setTimeout/setIntervalde nivel superior dentro del sandbox - Scripts en línea — adjunta scripts de pre-solicitud, post-respuesta y pruebas directamente al crear o modificar una solicitud
- Autenticación aplicada por ti — bearer, básica, api-key, digest, OAuth 2.0 (credenciales de cliente y concesiones de contraseña), o
inheritde la colección o carpeta - Resultados honestos — resúmenes por grupo, cuerpos de respuesta capturados, advertencias por solicitud, fallos de análisis y solicitudes faltantes, todo reportado; un grupo bloqueado no puede hacer que una ejecución se lea como exitosa
Seguridad
- Protección SSRF en cada solicitud y cada salto de redirección, con las direcciones aprobadas fijadas
- Confinamiento de rutas para referencias de solicitudes, raíces de colecciones, nombres de entornos y cargas de archivos
- Scripts aislados por proceso — un sandbox V8 bifurcado con un entorno depurado y un cierre forzado
Instalación
Nada que instalar. Apunta tu cliente a npx y este obtiene el paquete publicado en la primera ejecución:
npx -y @ostico/bruno-mcp
Esa es toda la instalación, y es lo que usan las configuraciones de cliente a continuación. El paquete incluye procedencia, por lo que npm puede mostrarte qué commit y flujo de trabajo compilaron el tarball que estás ejecutando.
Fijado en su lugar, si prefieres no resolver una versión al inicio:
npm install @ostico/bruno-mcp
lo que coloca un ejecutable bruno-mcp en node_modules/.bin/ y el servidor en sí en node_modules/@ostico/bruno-mcp/dist/index.js.
Desde el código fuente, para desarrollo o para ejecutar una rama:
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 en cualquier caso.
Conectar un cliente
Cualquier cliente MCP funciona. Este es un servidor MCP stdio simple sin código específico de cliente: como sea que tu cliente lo llame, apúntalo a
command: npx
args: ["-y", "@ostico/bruno-mcp"]
Claude Code lo toma como una línea:
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, tu propio cliente SDK — todos el mismo servidor. Nada de lo siguiente es una lista de compatibilidad; es solo dónde guarda cada cliente su configuración.
La mayoría de los clientes usan la misma forma JSON:
{
"mcpServers": {
"bruno-mcp": {
"command": "npx",
"args": ["-y", "@ostico/bruno-mcp"],
"env": {}
}
}
}
Ejecutar un clon, o una instalación fijada, es la misma configuración con "command": "node" y "args": ["/absolute/path/to/dist/index.js"].
| Cliente | Dónde va |
|---|---|
| Claude Desktop | macOS ~/Library/Application Support/Claude/claude_desktop_config.json · Windows %APPDATA%/Claude/claude_desktop_config.json · Linux ~/.config/Claude/claude_desktop_config.json |
| Claude Code | claude mcp add, o .mcp.json en el proyecto |
| Cursor | .cursor/mcp.json en el proyecto, o el global |
| Codex CLI | ~/.codex/config.toml, bajo una tabla [mcp_servers.bruno-mcp] (TOML, mismos campos) |
| opencode | opencode.json, bajo mcp como servidor local (su propio esquema) |
| Otros | Lo que ese cliente documente — el comando y los argumentos anteriores son todo lo que necesita |
Los esquemas de configuración son del cliente, no de este servidor, y se mueven. Si el formato de un cliente difiere del JSON anterior, sigue la documentación del cliente; solo command y args importan aquí.
Consulta INTEGRATION.md para ejemplos prácticos, Docker y solución de problemas.
Inicio 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" }
Herramientas
18 herramientas. Las rutas de archivo son absolutas, o relativas a la colección.
| Herramienta | Qué hace |
|---|---|
create_collection | Nueva colección. format: "yaml" (predeterminado) o "bru". También la registra en el espacio de trabajo, para que list_collections y la aplicación Bruno puedan verla — registerInWorkspace: false para omitir eso, workspacePath para elegir el archivo |
list_collections | Encuentra colecciones desde workspace.yml de Bruno |
get_collection_stats | Conteos por método, carpetas, entornos, lista de solicitudes con URLs — filtrable por folder, method, nameContains, o includeRequests: false solo para conteos |
write_request | Escribe una solicitud: método, url, encabezados, consulta, cuerpo, autenticación, scripts, configuraciones. kind: "websocket" o kind: "grpc" para esos transportes. Pasa collectionPath y name para crear una, filePath para editar una — una edición es una fusión parcial, y filename renombra el archivo |
move_request | Mueve o copia una solicitud a otra carpeta o colección |
read_request | Lee una solicitud de vuelta como JSON, misma forma para .bru y .yml |
list_requests | Cada archivo de solicitud en la colección, como rutas absolutas |
delete_request | Elimina uno o más archivos de solicitud. Necesita confirm: true |
add_test_script | Adjunta un script a una solicitud existente (agrega por defecto) |
remove_script | Elimina un script, conserva la solicitud |
create_environment | Nuevo archivo de entorno. Se niega a sobrescribir a menos que overwrite: true |
read_environment | Variables con sus banderas disabled/secret. Omite name para listar entornos |
update_environment | Reemplaza o fusiona las variables de un entorno |
set_environment_variable | Agrega o cambia una variable |
remove_environment_variable | Elimina una variable |
run_collection | Ejecuta solicitudes, ejecuta sus pruebas, devuelve resultados |
Leer antes de escribir
read_request devuelve método, url, encabezados, parámetros de consulta y ruta, cuerpo, modo de autenticación, scripts, aserciones, vars, configuraciones y documentación — forma idéntica para ambos formatos, por lo que el formato en disco permanece invisible. Su array notes nombra cualquier cosa que el archivo declare y que el ejecutor no procesará.
Úsalo antes de una edición para ver el estado actual, y después de una escritura para confirmar lo que se escribió.
read_environment devuelve cada variable con su valor. Los secretos vuelven solo por nombre — Bruno no almacena ningún valor para un secreto en ninguno de los formatos, por lo que no hay nada que devolver.
Escribir solicitudes
write_request crea cuando pasas collectionPath y name, y edita cuando pasas filePath. Una edición fusiona: los campos que omites se dejan intactos.
Opciones notables:
body.type—json,text,xml,sparql,graphql,form-urlencoded,form-data,file,binary,nonebody.type: "form-data"— cargas multiparte,contentTypepor parte, campos de múltiples archivosauth.type—bearer,basic,api-key,digest,oauth2,inherit,nonescripts—pre-request,post-response,testsen línea (sin necesidad de una llamada separada aadd_test_script)settings.timeout— tiempo de espera de script y solicitud en ms
name y filename son independientes, como en Bruno mismo: name cambia el nombre de la solicitud dentro del archivo y filename mueve el archivo, así que pasa ambos para mantenerlos sincronizados. Un filename es un nombre base en la propia carpeta de la solicitud, su extensión es opcional y debe coincidir con el formato de la colección si se da, y un nombre ya ocupado por otro archivo se rechaza. La ruta a la que se movió vuelve en la respuesta — úsala como filePath de ahí en adelante.
write_request reemplaza un script del mismo tipo por defecto, por lo que repetir una llamada es idempotente. Pasa scriptMode: "append" para concatenar. add_test_script agrega por defecto, siendo una adición.
En colecciones .yml, post-response y tests comparten el único espacio after-response de Bruno, por lo que reemplazar cualquiera sobrescribe ambos.
Mover solicitudes
move_request reubica un archivo de solicitud — a otra carpeta, o a otra colección con targetCollectionPath. Pasa copy: true para duplicarlo en su lugar.
Los bytes se mueven textualmente, nunca se analizan ni reescriben, por lo que nada que una solicitud declare puede perderse en el camino. Dos consecuencias se derivan de eso. El archivo conserva su nombre, por lo que una copia necesita una carpeta o colección diferente; renombrar es write_request. Y seq llega sin cambios, por lo que la solicitud puede aterrizar junto a un hermano que reclama el mismo número — eso se reporta en lugar de repararse, porque renumerar significa reescribir el archivo. Bruno rompe ese empate por nombre de archivo, por lo que el orden está definido de cualquier manera.
Una carpeta de destino faltante se crea y se reporta: una carpeta sin archivo de configuración no lleva autenticación, encabezados o scripts a nivel de carpeta.
Ejecución
{
"collectionPath": "./collections/my-api",
"environment": "dev",
"requests": ["auth/login.bru", "users"]
}
| Parámetro | Significado |
|---|---|
collectionPath | Colección, o una subcarpeta de una |
requests | Lista ordenada de archivos de solicitud y/o directorios. Omite para ejecutar todo. [] no ejecuta nada |
groups | Ejecuta la colección como varios grupos aislados — ver abajo. No se puede combinar con requests |
environment | Nombre del entorno, cargado desde environments/<name>.yml |
collectionRoot | La colección a la que pertenece collectionPath, al ejecutar una subcarpeta. Debe ser esa ruta o un ancestro |
variables | {name: value} solo para esta ejecución. Nunca se escribe en disco — la forma correcta de pasar un secreto |
captureVariables | Nombres de variables bru.setVar cuyos valores quieres de vuelta |
parallel | Ejecuta los grupos concurrentemente. Predeterminado false |
maxConcurrency | Límite de solicitudes en vuelo. Omite para derivar uno de la máquina; 0 lo eleva |
bail | Detente en el primer fallo en lugar de ejecutar el resto. Predeterminado false |
cookieJar | Mantén cookies a lo largo de la ejecución para que un inicio de sesión se propague. Predeterminado true |
includeResponseBody | Incluye cuerpos de respuesta. Predeterminado true |
maxResponseBodyBytes | Trunca cuerpos más allá de este tamaño. Predeterminado 10240 |
report | También escribe la ejecución en disco — ver Archivos de informe |
Un directorio en requests se expande a las solicitudes debajo de él, ordenadas por seq dentro de cada carpeta, subcarpetas primero, empates rotos por nombre de archivo. Los duplicados se respetan: nombrar una solicitud dos veces la ejecuta dos veces.
Por defecto nada detiene una ejecución temprano. Una solicitud que falla, un archivo que no se analiza, un nombre que no coincide con nada — cada uno se reporta y la ejecución continúa.
Detenerse en el primer fallo
bail: true detiene la ejecución en la primera solicitud que falla o cuyas pruebas fallan. Veintitrés solicitudes detrás de un inicio de sesión que dejó de funcionar son veintitrés fallos por una causa, y la causa es la menos visible de ellas.
{
"collectionPath": "./collections/my-api",
"bail": true
}
Todo lo que la ejecución no alcanzó vuelve en su lugar, marcado skipped: true con skipReason: "bail", llevando el método y la URL que habría enviado. Esas solicitudes se cuentan en summary.skipped y en ni passed ni failed, por lo que passed + failed sigue igualando total y una ejecución truncada no puede leerse como una más corta que fue exitosa. La ejecución misma gana un objeto bail:
{
"bail": {
"reason": "test failure",
"at": "Login",
"path": "/collections/my-api/auth/login.bru",
"group": 0,
"skipped": 22
}
}
reason es o request failure (nada volvió) o test failure (volvió y una verificación falló). Los grupos posteriores se omiten por completo.
Nada cancela una solicitud ya en vuelo. Con parallel, o con un grupo propio que se ejecuta concurrentemente, las solicitudes que ya habían comenzado aún terminan y se reportan normalmente — la ejecución lo dice en warnings en lugar de dejarte inferirlo del conteo.
Grupos de ejecución
Un grupo es una ejecución aislada dentro de una llamada. Posee su lista de solicitudes, entorno, variables, bandera parallel, almacén de variables, cookie jar y tokens OAuth2. Nada cruza de un grupo a otro, en ninguna dirección, en cualquier configuración de parallel.
Las mismas solicitudes como dos usuarios, sin posibilidad de que el token o la cookie de sesión de un inicio de sesión llegue al otro:
{
"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 ejecuta los dos grupos uno contra el otro. Las solicitudes de cada grupo permanecen en serie, que es lo que quieres cuando orders depende del inicio de sesión anterior.
Una suite contra dos entornos:
{
"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 cubre todo el modelo: qué posee un grupo, las dos banderas parallel y sus valores predeterminados, el orden, las iteraciones sobre filas de datos, el límite de concurrencia y cómo se ve un fallo en cada nivel.
- Omite
requestspara ejecutar la colección completa bajo la identidad de ese grupo. Un[]vacío no ejecuta nada. environmentreemplaza el de nivel de ejecución;variablesfusiona sobre los de nivel de ejecución, ganando el grupo.- Establece
parallelen un grupo para ejecutar sus propias solicitudes de forma concurrente. Comparten el almacén de ese grupo, por lo que pueden competir genuinamente por unbru.setVar— ese es el punto al reproducir una condición de carrera. Dale amaxConcurrencyal menos tantos espacios como corredores, o el límite los serializa silenciosamente. startAfter: { group, requestsCompleted }mantiene un grupo hasta que otro haya llegado a ese punto — un listener conectado antes de que se dispare un trigger, sin unbru.sleepajustado a la latencia de ese día. Requiereparallela nivel de ejecución; una solicitud que falló aún cuenta como posición alcanzada; los ciclos y las puertas que nunca podrían abrirse se rechazan antes de que se ejecute nada.
Resultados
Los resultados tienen forma de grupo. No hay un array results de nivel superior, ni siquiera cuando no pasaste ningún groups — ese caso es un grupo, y aplanarlo haría que cada llamador verificara de qué manera había llamado.
{
"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 lleva su propio summary, results, missingRequests, capturedVariableNames, capturedVariables y warnings. El summary de nivel superior cubre toda la ejecución.
response_headers no necesita bandera ni script de prueba. Los valores con nombre de credencial se enmascaran, y set-cookie es una lista — una entrada por cookie, porque una unida por comas no se puede dividir de nuevo — cuyas entradas conservan cada atributo con solo el valor de la cookie retenido. Verificar HttpOnly, Secure, SameSite o Strict-Transport-Security es por lo tanto una sola llamada. includeResponseBody: false no los suprime: esa bandera trata sobre el tamaño de un cuerpo.
Un resultado de WebSocket lleva response_headers también, conteniendo la respuesta de handshake — el 101 es el único lugar donde aparece una cookie de sesión o un sec-websocket-protocol acordado para ese transporte, ya que los frames no tienen cabeceras. Un resultado de gRPC informa sus metadatos bajo su propio detalle grpc en su lugar.
Un grupo que no pudo iniciar en absoluto informa error en lugar de resultados y cuenta como un fallo — de lo contrario, una ejecución con un grupo muerto se leería como verde.
Campos de nivel de ejecución: parseErrors y parseFailures nombran archivos que no pudieron analizarse, warnings recopila cualquier otra cosa que valga la pena ver.
Scripts
Los scripts se ejecutan en un contexto V8 dentro de un proceso bifurcado (ver Seguridad). Ambos tipos son funciones asíncronas, por lo que await de nivel superior funciona.
Las pruebas y post-respuesta reciben test(), expect(), res y bru:
| API | Notas |
|---|---|
test(name, fn) | Envuelve aserciones. Requerido para que una se informe |
expect(v) | Estilo Chai: .to.equal, .include/.contain, .match, .have.property/.lengthOf/.keys, .be.above/.below/.least/.most/.oneOf, .throw, y .to.not.* para cualquiera de ellos |
res.getStatus() res.getStatusText() | |
res.getHeader(name) res.getHeaders() | La búsqueda de cabeceras no distingue entre mayúsculas y minúsculas |
res.getSetCookies() | Cookies que la respuesta estableció |
res.getBody() | Ya analizado cuando el subtipo del tipo de medio es json o termina en +json |
res.getResponseTime() | ms |
res(path, ...fns) | El lenguaje de consulta de Bruno sobre el cuerpo: res("data.pets..name") desciende a cada name, [0] indexa, [?] filtra o mapea con un callback. También es válido como lado izquierdo de una aserción, donde la sintaxis no podría aparecer desnuda |
bru.setVar(name, v) bru.getVar(name) | Pasa valores a solicitudes posteriores como {{name}} |
bru.sleep(ms) | También setTimeout/setInterval y sus clear* |
atob(s) btoa(s) | base64, en ambos tipos de script. Suficiente para leer un payload de JWT sin una segunda solicitud |
Los scripts de pre-solicitud reciben req y bru en su lugar — aún no hay respuesta. Mutar req cambia lo que se envía: req.getUrl(), req.setUrl(), req.getMethod(), req.getHeader(), req.setHeader(), req.getHeaders(), req.getBody(), req.setBody().
Dos cosas que sorprenden a la gente
Envuelve las aserciones en test(). Un expect() que pasa desnudo nunca se registra, por lo que la ejecución informa "tests": [] mientras la solicitud cuenta como aprobada — verde sin nada verificado. El ejecutor detecta esto y lo dice en el warnings de ese resultado. Una aserción fallida desnuda no es silenciosa: lanza y se informa como error de script.
test("status is 200", function() { // ✅ recorded
expect(res.getStatus()).to.equal(200);
});
expect(res.getStatus()).to.equal(200); // ❌ runs, passes, reported nowhere
No hagas JSON.parse(res.getBody()). Ya es un objeto siempre que el subtipo del tipo de medio sea json o lleve el sufijo +json — application/json, text/json, application/vnd.api+json — por lo que analizar de nuevo lanza SyntaxError: "[object Object]" is not valid JSON. Lee los campos directamente. Si un endpoint puede devolver cualquiera de los dos, ramifica: typeof b === "string" ? JSON.parse(b) : b.
Leer una reclamación de un token no necesita una segunda solicitud. atob y btoa están ambos presentes, bajo los nombres que usa el propio sandbox de Bruno, por lo que el baile habitual 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 no está disponible. Es una clase de host con capacidades que un sandbox no debería entregar, y un sustituto fiel sería un fake cuyas lagunas encontrarías de una en una; una referencia desnuda lanza Buffer is not defined, que se informa como error de script en lugar de comportarse mal silenciosamente.
Dormir cuenta contra el tiempo de espera del script — settings.timeout, 5000 ms cuando no se establece. await bru.sleep(10000) bajo el valor predeterminado informa un tiempo de espera en lugar de esperar.
Entornos y variables
Un entorno es environments/<name>.yml en la colección:
name: dev
variables:
- name: baseUrl
value: https://api-dev.example.com
- name: apiKey
value: dev-key-123
- name: skipped
value: whatever
disabled: true
Las herramientas toman variables como un objeto plano ({"baseUrl": "..."}) y escriben ese array por ti.
{{name}} se sustituye en urls, cabeceras, cuerpos y autenticación. Las variables deshabilitadas se omiten; las no resueltas se dejan como están escritas y se nombran en las advertencias de la ejecución.
Precedencia, de menor a mayor: archivo de entorno → variables de ejecución → vars propio de una solicitud → bru.setVar durante la ejecución. Esto coincide con el comportamiento --env-var de Bruno.
Una variable puede construirse a partir de otras: base_url: "https://{{host}}/{{stage}}" se resuelve de la manera que lo hace bajo bru run, usando el propio interpolate de Bruno. Una excepción, deliberada: un valor capturado de una respuesta — por bru.setVar o un bloque vars post-respuesta — se inserta como texto y nunca se vuelve a escanear, por lo que una respuesta que haga eco de key={{api_key}} no puede hacer que la siguiente solicitud envíe tu clave.
Generadores. {{$guid}}, {{$timestamp}}, {{$randomEmail}} y el resto de las ~120 variables dinámicas de Bruno funcionan en urls, cabeceras, parámetros de consulta, cuerpos y autenticación. No son variables: nada las declara, cada ocurrencia produce su propio valor, y ninguna se informa como no resuelta. Una palabra clave a la que ningún generador responde — {{$gid}} — se deja como está escrita y se nombra en las advertencias, por lo que un error tipográfico aún sale a la superficie. En un cuerpo JSON o un bloque de variables de GraphQL, el valor generado se escapa, por lo que un generador que emite un salto de línea ({{$randomLoremParagraphs}}) deja el documento analizable.
Secretos: ningún formato de Bruno almacena el valor de un secreto — solo su nombre. Así que pasa los secretos como variables de ejecución, que permanecen en memoria y nunca se escriben en un archivo.
Un nombre de entorno es un nombre, no una ruta. Cualquier cosa que contenga un separador se rechaza.
Archivos de informe
run_collection devuelve sus resultados como JSON, que es lo que lee un agente. Los otros dos consumidores de una ejecución de pruebas leen archivos, por lo que report los escribe:
{ "collectionPath": "/path/to/collection",
"report": { "junit": "reports/junit.xml", "html": "reports/run.html" } }
Nombra cualquiera de los formatos o ambos. El resultado entonces lleva reports, una entrada por archivo escrito, con su ruta absoluta y su tamaño en bytes.
Las rutas están confinadas a la colección. Una ruta que se resuelve fuera de ella se rechaza y la razón se convierte en una advertencia de ejecución; la ejecución en sí aún tiene éxito, porque los resultados son lo que se pidió y el archivo es un subproducto. Copia el archivo después si tu pipeline recopila informes desde otro lugar — escribir donde un llamador apunte es una autorización mucho mayor que ejecutar sus solicitudes. Los directorios padre faltantes dentro de la colección se crean, y un informe existente se sobrescribe.
El XML JUnit sigue bru run --reporter-junit: un <testsuite> por solicitud, un <testcase> por aserción o prueba, y una solicitud que falló se informa como error de suite. Cuatro cosas que hace de manera diferente, cada una porque la alternativa es un informe que se lee más verde que la ejecución:
- Una solicitud que se ejecutó y no verificó nada obtiene un testcase omitido que lo dice, en lugar de una suite vacía. Una suite vacía es invisible en cada resumen de CI, que es exactamente la lectura de "corrió verde, no verificó nada" que el contador
requestsWithoutTestsexiste para exponer. - Un archivo de solicitud que no se analizaría, una solicitud nombrada que se resolvió a nada, y un grupo que se estrelló cada uno obtienen una suite propia. Un informe que lista solo lo que se ejecutó dice que un subconjunto corrió sin decir que era un subconjunto.
- La etiqueta de un grupo nombrado se pliega en el nombre de la suite, ya que JUnit no tiene concepto de uno y dos identidades que ejecutan la misma solicitud serían indistinguibles.
- Sin atributo
hostname. Upstream escribe el nombre de la máquina en el archivo; estos informes están destinados a ser confirmados.
El informe HTML es el propio de Bruno, renderizado por @usebruno/common, con grupos de ejecución como sus iteraciones — una ejecución de dos identidades se lee como dos secciones. Dos cosas a saber: la página incrusta los datos de la ejecución pero carga Vue y naive-ui desde unpkg.com, por lo que necesita acceso a la red cuando se abre y no muestra nada sin conexión; y su panel de solicitud está vacío, porque un resultado no retiene la solicitud tal como se envió. Las aserciones y las pruebas de script comparten una lista por la misma razón — un resultado no las distingue.
Un informe contiene lo que los resultados contienen, en disco: cuerpos de respuesta incluidos, cabeceras de respuesta enmascaradas exactamente como están en el JSON.
Formatos
| Archivo marcador en la colección | Formato |
|---|---|
opencollection.yml | YAML — verificado primero |
bruno.json | BRU (legado) |
| ninguno | YAML |
Las colecciones nuevas son YAML a menos que pases format: "bru".
Los archivos de solicitud .yaml se leen como YAML, exactamente como .yml, porque otras herramientas adyacentes a Bruno los escriben. Pero la propia aplicación de Bruno y bru run no reconocen la extensión, por lo que cada archivo .yaml leído se nombra en las advertencias de la ejecución — un pase silencioso sería una ejecución verde de una solicitud que Bruno no puede ver. Renombra a .yml para limpiarlo. Nada de lo que este servidor escribe usa .yaml.
Solicitudes gRPC y WebSocket
Una colección puede contener solicitudes gRPC y WebSocket junto con las HTTP. Este servidor las lee, las preserva y las reporta: read_request devuelve el tipo, el destino, el método y la ruta del proto para gRPC, su bloque de metadatos y cuántos mensajes están almacenados; list_requests las lista; y editar cualquier solicitud en la colección ya no las destruye. Antes de esto, ambos formatos descartaban el bloque de destino, las credenciales y cada mensaje almacenado, por lo que un solo write_request en una solicitud no relacionada reescribía el archivo sin ellos.
run_collection ejecuta ambos. Una solicitud gRPC realiza una llamada unaria contra el servicio que declara su .proto; una solicitud WebSocket abre el socket, envía las tramas que el archivo almacena y registra lo que regresa hasta que se alcanza un límite. Cada una reporta su propio detalle: un resultado gRPC lleva el código de estado gRPC, la cadena de detalles y los metadatos finales redactados, y un resultado WebSocket lleva la transcripción, el stop_reason que la terminó y si fue truncada. El código gRPC vive en su propio campo y nunca se mapea al status del resultado, porque el OK de gRPC es 0 y 0 es el centinela de rechazo de esta API — una llamada exitosa y un rechazo de seguridad serían indistinguibles en el campo que se lee primero.
Una sesión WebSocket no tiene un final natural, por lo que está limitada, y cada límite es configurable por ejecución bajo el argumento websocket de run_collection:
| Límite | Predeterminado | Qué hace |
|---|---|---|
maxMessages | 50 | Tramas entrantes registradas antes de detenerse |
maxDurationMs | 5000 | Techo de tiempo de pared para una sesión |
idleTimeoutMs | 1500 | Silencio que termina una sesión; 0 espera el techo |
sendIntervalMs | 0 | Espacio entre los mensajes que envía una solicitud; 0 los envía en un solo tick |
includePayloads | false | Registrar el contenido de las tramas, no solo el tamaño y el tiempo |
maxFrameBytes | 65536 | Techo por trama en la carga útil registrada |
maxTranscriptBytes | 1048576 | Techo acumulativo, contado desde el tamaño del cable |
engineIoKeepalive | false | Responder a un 2 de engine.io con un 3 |
El techo de tiempo de pared es un límite de seguridad más que un horario, por lo que idleTimeoutMs es lo que normalmente termina una sesión: una vez que no ha llegado nada durante 1500 ms, se detiene y reporta stop_reason: "idle", que no se cuenta como truncamiento porque no hay bit de límite y el techo quedó sin gastar. El reloj se arma con la primera trama, no al conectar, por lo que una solicitud de solo escucha que no escribe mensajes aún espera maxDurationMs por un par que aún pueda hablar. Establézcalo en 0 para un protocolo cuyos espacios sean más largos que sus respuestas.
sendIntervalMs es lo que hace alcanzable un protocolo de enviar-esperar-enviar. En el valor predeterminado de 0, los mensajes de una solicitud salen todos en un solo tick, por lo que cada respuesta llega después del último de ellos y el intercambio no tiene orden sobre el cual afirmar; establezca un espacio y la transcripción lleva cada respuesta entre los envíos a los que pertenece, en el desplazamiento en el que realmente llegó. Dos consecuencias que vale la pena conocer. maxDurationMs tiene que cubrir toda la secuencia espaciada — una sesión detenida a mitad de camino nombra los mensajes que nunca salieron, por su nombre de autor, en lugar de dejar una transcripción con un envío menos que se lea como un par que dejó de responder. Y el límite de inactividad no se arma mientras la secuencia aún está saliendo, por lo que un sendIntervalMs más largo que idleTimeoutMs es seguro: el espacio que una solicitud deja deliberadamente entre sus propios mensajes no es el silencio del par.
Un subprotocolo se escribe como un encabezado Sec-WebSocket-Protocol en la solicitud, separado por comas para más de uno, y se negocia en el apretón de manos; el que el servidor acordó regresa en el response_headers de ese resultado. No hay un campo separado para ello, aquí ni en Bruno. Escribir el encabezado solía ser peor que omitirlo: la biblioteca valida la respuesta del servidor contra la lista que se le dio en la conexión, por lo que un servidor que hizo exactamente lo que el encabezado pedía tenía su apretón de manos rechazado por ofrecer un subprotocolo que nadie solicitó. Un Sec-WebSocket-Version escrito se honra de la misma manera, por la misma razón.
Cada entrada de transcripción dice qué tipo de trama fue — text, binary, ping, pong o close — lleva el title de autor de un mensaje que la sesión envió, y, en una trama de cierre, el close_code que dio el par, con su razón como la carga útil de esa entrada: 1000 es una despedida ordinaria, 1006 un par que desapareció sin una, 1008 un rechazo, 1011 un error del servidor. Las tramas de control no cuentan para maxMessages, o un par que haga ping una vez por segundo terminaría una sesión por sí mismo y reportaría count por uno que no recibió respuesta. La carga útil de una trama binaria es base64 y bytes es el tamaño real del cable para cada tipo. Un script posterior a la respuesta ve los mismos campos, porque la transcripción es lo que res.body es en este transporte.
Afirmando sobre un resultado gRPC o WebSocket
Ambos transportes ejecutan scripts posteriores a la respuesta y de prueba, y res está formado para que haya una cosa que aprender en lugar de dos. Lo que difiere de HTTP vale la pena decirlo directamente, porque adivinarlo mal hace una prueba que no puede fallar.
En una solicitud WebSocket:
res.getBody()es la transcripción — el mismo arreglo que lleva el resultado, entregado al script como una estructura en lugar de como texto JSON.res.rawBodymantiene la forma serializada.res.getStatus()es siempre0. Una sesión no tiene estado, e inventar uno sería peor que no tener ninguno. El resultado está enres.statusText, que lleva la razón de detención (count,timeout,bytes,closedoerror).- Entonces una afirmación WebSocket lee tramas y
statusText. Una prueba escrita contrares.getStatus()afirma sobre una 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");
});
Las cargas útiles que ve un script son siempre las reales, sea lo que sea que diga includePayloads. Esa bandera limita la transcripción en el resultado, no la de res, porque las tramas salientes se registran después de la interpolación de {{var}} y un resultado devuelto por defecto no debe llevar cada secreto que pasaste. Esta es la división que HTTP ya tiene — res.body siempre mantiene el cuerpo completo mientras que response_body está limitado por includeResponseBody. Significa que includePayloads: false junto con afirmaciones de contenido es la forma prevista para CI, no un trabajo alternativo: las afirmaciones verifican las cargas útiles, y lo que regresa mantiene solo dirección, tiempo y tamaños.
En una solicitud gRPC, res está más cerca de HTTP: res.getStatus() es el código de estado gRPC (0 es OK), res.statusText es el propio details del servidor cuando proporcionó alguno y el nombre canónico del código en caso contrario, res.getBody() es el mensaje de respuesta analizado, y los trailers de respuesta llegan como los encabezados.
includePayloads está desactivado por defecto como una propiedad de seguridad, no una preferencia: las tramas salientes se registran después de la sustitución de {{var}}, por lo que registrarlas por defecto escribiría cada secreto pasado en variables en un resultado que se devuelve por defecto. engineIoKeepalive está desactivado por una razón relacionada — pone una trama en el cable que la solicitud no escribió — e incluso cuando está activado, responde solo después de que una trama OPEN haya sido realmente vista.
Una solicitud WebSocket ahora puede ser escrita en lugar de copiada. write_request toma kind: "websocket" con una url y websocket.messages, y rechaza los campos para los que ese transporte no tiene lugar: un método HTTP, un cuerpo, parámetros de consulta, parámetros de ruta. Cada mensaje lleva content y, opcionalmente, un title y un type de text o binary; un mensaje sin título se nombra message 1, message 2 por posición, exactamente como Bruno nombra uno. Encabezados, autenticación, assert, vars, settings y scripts funcionan como lo hacen para una solicitud HTTP, y el archivo escrito es byte-idéntico a lo que Bruno escribe para la misma solicitud en ambos formatos — probado contra el propio escritor de upstream, no contra un viaje de ida y vuelta a través de nuestro analizador.
Un campo se registra de manera diferente por los dos formatos. selected: false marca un mensaje como escrito pero no enviado. .yml escribe el falso. .bru expresa solo la mitad verdadera: el escritor de upstream emite la bandera cuando está establecida y nada cuando no lo está, y su lector resuelve una bandera ausente a false — así que en ese dialecto un mensaje deseleccionado y uno sin marcar son el mismo mensaje, y ninguno se envía. Una ejecución sigue esa lectura, lo que significa que un mensaje .bru escrito a mano se envía solo si dice selected: true; cada mensaje omitido por la falta de ello se nombra en las advertencias del resultado, por lo que una solicitud que ahora no envía nada dice por qué en lugar de reportar una sesión vacía. Escribir un mensaje deseleccionado en una colección .bru lo escribe sin bandera, exactamente como Bruno lo hace, por lo que el archivo se comporta como se pidió; lo que el dialecto pierde es solo el informe, ya que leer la solicitud de vuelta encuentra la bandera ausente en lugar de falsa.
Una solicitud gRPC se escribe de la misma manera, con kind: "grpc": una url, y bajo grpc, el method completamente calificado, el protoPath, el methodType y el messages. También rechaza un método HTTP, un cuerpo, parámetros de consulta y parámetros de ruta. Tres cosas sobre ello valen la pena saber antes de escribir una.
Los encabezados se convierten en metadatos, que es la única superficie de encabezado de ese transporte — un bloque headers en una solicitud gRPC es uno que el lector gRPC de Bruno nunca mira, por lo que el argumento headers se escribe como metadata en su lugar. El protoPath ya debe existir dentro de la colección y se almacena relativo a ella sea cual sea la ortografía que des, porque una ruta absoluta es el diseño de directorio del operador comprometido en un archivo compartido; una ruta que se resuelve fuera de la colección se rechaza, incluidos los enlaces simbólicos, al igual que una cuyas importaciones la dejan a tantos saltos adentro (una importación de google/protobuf/ bien conocida no es un archivo y no se rechaza). Y los cuatro valores de methodType se aceptan, porque Bruno escribe los cuatro — pero solo unary se ejecuta aquí, por lo que los otros tres escriben un archivo que Bruno puede abrir y run_collection rechazará por nombre. Como con WebSocket, los bytes son idénticos al propio escritor de Bruno en ambos formatos, incluido el desacuerdo de los dos dialectos sobre la ortografía: .bru escribe protoPath dentro del bloque grpc, .yml escribe protoFilePath.
write_request edita ambos transportes. Una url, encabezados, autenticación, assert, vars, settings, name y sequence se aplican todos, al igual que el objeto anidado websocket o grpc — sus mensajes, y para gRPC el method, protoPath y methodType. Cada campo se escribe donde ese transporte lo mantiene, por lo que una edición de encabezado gRPC aterriza en metadata y nunca escribe un bloque headers. Todo lo que la edición no nombra regresa byte-idéntico, lo que importa más aquí que para HTTP: una edición regenera todo el archivo desde un modelo analizado, por lo que cualquier cosa que el modelo no lleve se pierde sin un mensaje.
Lo que todavía se rechaza es lo que el transporte genuinamente no tiene lugar — un método HTTP, un cuerpo, parámetros de consulta, parámetros de ruta y el objeto del otro transporte — por nombre, dejando el archivo byte-sin cambios. Rechazar url, encabezados y autenticación también solía ser el comportamiento, lo que significaba que el destino de una solicitud WebSocket no podía cambiarse por la vida del archivo.
Un script de pre-solicitud se ejecuta en ambos transportes y alcanza lo que cada uno realmente tiene. bru.setVar se respeta y el valor llega al propio {{placeholders}} de esa misma solicitud, por lo que un script puede calcular un nombre de sala, un tema o un destino y luego marcarlo. req.setUrl reemplaza el destino. req.setHeader escribe en la superficie de cabeceras propia del transporte: las cabeceras de handshake de un WebSocket, o los metadatos de una llamada gRPC — que es la misma superficie, ya que grpc-js coloca los metadatos en el cable como cabeceras HTTP/2. Un script que lanza una excepción detiene la solicitud antes de que se marque nada, y el fallo se reporta como tal. req.getUrl() y req.getHeaders() leen el destino sustituido y las cabeceras propias de la solicitud; las credenciales que el transporte calcula no están entre ellas, porque la autenticación se aplica después del script. Lo único que no se respeta es req.setBody(), que en su lugar advierte: ningún transporte envía un cuerpo único — una sesión WebSocket envía una lista de mensajes y una llamada gRPC unaria envía un mensaje tipado — por lo que no hay nada que un valor pueda reemplazar, y adivinar pondría bytes en el cable que el archivo nunca autorizó.
Ambos transportes se cargan de forma perezosa, y eso se aplica en lugar de afirmarse: una prueba registra cada módulo que el servidor real resuelve y falla si una ejecución solo-HTTP nombra @grpc/grpc-js o ws. Medido, una ejecución solo-HTTP carga undici y ninguno de ellos.
Dos cosas se rechazan en lugar de adivinarse. Un archivo cuyo tipo declarado y bloque de destino discrepan (type: grpc con un bloque http:) es un error de análisis que nombra ambos, porque el tipo decide lo que un lector reporta mientras que el bloque decide lo que un ejecutor contacta. Y una solicitud .bru cuya URL de destino está vacía se rechaza al escribir, porque el formato descarta dicho bloque mientras conserva las credenciales a su lado — el resultado parecería autorizado y no iría a ninguna parte.
Cinco cosas deliberadamente no están construidas. Llamadas gRPC en streaming y sesiones WebSocket mantenidas abiertas harían que el resultado de una ejecución dependiera de cuándo se leyó, y cada respuesta aquí es un valor que un llamador puede verificar. Reflexión de servidor obtendría el esquema a través de la misma conexión bajo prueba, por lo que se requiere una ruta .proto en su lugar. Soporte de proxy y fijación de certificados no alcanzan estos transportes: la compuerta es solo undici, @grpc/grpc-js no expone una API de proxy y respeta el http_proxy ambiental por su cuenta, y ws necesitaría un agente propio. Y un bloque socket.io o MQTT inventaría un formato de archivo que el upstream no ha elegido, lo cual es una migración en el momento en que lo haga.
socket.io no necesita bloque, porque es una convención de enmarcado sobre WebSocket en lugar de un protocolo propio. Medido contra socket.io 4.8.3, una solicitud ws simple alcanza uno:
- Conectar a
ws://host:port/socket.io/?EIO=4&transport=websocket. Ambos parámetros de consulta son obligatorios —EIO=4selecciona la versión de Engine.IO, ytransport=websocketevita que el servidor espere un handshake de long-polling HTTP primero. - El servidor envía
0{…}, el paquete OPEN de Engine.IO. Su carga útil llevasid,pingIntervalypingTimeouten milisegundos. - Enviar
40para unirse al namespace predeterminado — nada funciona antes de esto. Un namespace con nombre es40/namespace,. - El servidor responde
40{"sid":"…"}. - Enviar un evento como
42["event-name",payload]:4para MESSAGE,2para EVENT, luego un arreglo JSON cuyo primer elemento es el nombre del evento. - El servidor envía
2(PING) cadapingIntervaly desconecta a un cliente que no responda3(PONG) dentro depingTimeout. Establecerwebsocket.engineIoKeepaliveenrun_collectionsi una grabación sobrevive esa ventana; está desactivado por defecto y responde solo después de que se haya visto un frame OPEN real.
Los pasos 1 a 5 son frames que el archivo de solicitud ya almacena, por lo que solo el paso 6 necesita algo del ejecutor. Esto está fijado a EIO=4 — Engine.IO v2 y v3 enmarcan de manera diferente. Los acks (42<id>[…] respondidos por 43<id>[…]) y los adjuntos binarios (un placeholder 45 seguido de frames binarios separados) son escribibles a mano y desagradables en la práctica.
Seguridad
SSRF. Cada URL saliente, incluido cada salto de redirección, se resuelve y verifica. Las direcciones privadas, de loopback, link-local y otras reservadas se rechazan, y las direcciones aprobadas se fijan para la solicitud de modo que el nombre no pueda resolverse a otra cosa en el medio. Un rechazo se reporta por solicitud como un error SSRF blocked con estado 0.
Scripts se ejecutan en un contexto V8 dentro de un proceso bifurcado y desechable. El hijo recibe un entorno depurado, por lo que un script que escape del contexto aún no puede leer los secretos del servidor — no están en su espacio de direcciones. Su stdout está canalizado, nunca heredado, por lo que no puede escribir en el flujo JSON-RPC de MCP. Un script descontrolado está limitado por SIGKILL en el hijo, lo que el timeout dentro del contexto por sí solo no puede garantizar. Esto es defensa en profundidad mediante un proceso del SO, no una cárcel: no impide que el código se ejecute en el hijo, hace que ejecutarse allí no valga la pena.
Rutas. Las referencias de solicitud deben permanecer dentro de la colección. collectionRoot debe contener la ruta de la colección. Los nombres de entorno no pueden contener separadores.
Subidas de archivos. Una parte de archivo form-data nombra una ruta en el disco del servidor, por lo que está confinada: legible solo bajo la raíz de la colección, el directorio de inicio del usuario, el directorio temporal del SO o un directorio que el operador haya agregado. Además, cualquier componente de ruta que comience con . se rechaza — por lo que ~/.ssh/id_rsa, .env y .aws permanecen ilegibles aunque el inicio esté permitido. Las rutas relativas se resuelven contra la raíz de la colección.
Vías de escape del operador, todas desactivadas por defecto:
| Variable | Efecto |
|---|---|
BRUNO_SSRF_ALLOWLIST | Nombres de host exactos separados por comas, literales IP y/o rangos CIDR permitidos a pesar de ser privados. Una entrada de nombre de host se compara con la ortografía en la URL; una entrada de dirección se compara con la dirección a la que resuelve la URL, y también permite un nombre bloqueado de otro modo como localhost cuando cada dirección a la que resuelve está en la lista de permitidos. Se lee una vez al inicio y nunca se ve influenciada por argumentos de herramientas; los comodines se rechazan |
BRUNO_UPLOAD_DIRS | Directorios adicionales desde los que las subidas pueden leer |
BRUNO_PROXY_HOSTS | Hosts permitidos para usar el proxy de una colección |
BRUNO_INSECURE_TLS_HOSTS | Hosts permitidos para omitir la verificación de certificados |
BRUNO_DNS_TIMEOUT_MS | Timeout de resolución DNS |
BRUNO_WORKSPACE_PATH | Dónde encontrar el workspace.yml de Bruno |
Una entrada de lista de permitidos coincide con la ortografía exacta de un destino. Un nombre de host en la lista de permitidos nunca se resuelve — el operador respondió por el nombre, no por lo que apunta hoy — por lo que no cubre las direcciones detrás de él, y una dirección en la lista de permitidos no cubre un nombre que resuelve a ella. localhost y 127.0.0.1 son por lo tanto dos entradas, y permitir una mientras una solicitud usa la otra parece una protección inconsistente cuando es una entrada faltante. Los rechazos lo dicen.
Esto restringe lo que run_collection obtendrá. Un agente con acceso a shell puede alcanzar la red de todos modos, así que trátalo como una capa, no como un límite.
FAQ
¿Necesita el CLI de Bruno (bru) instalado?
No. La canalización de solicitudes está implementada aquí — variables, autenticación, cookies, redirecciones, aserciones, scripts, ordenamiento de dependencias — por lo que nada invoca a bru y nada necesita el binario en PATH. Esa es también la razón por la que existe el trabajo de paridad anterior: una implementación independiente debe mantenerse deliberadamente fiel al original.
¿Soporta archivos opencollection .yml, o solo .bru?
Ambos, y detecta cuál usa una colección en lugar de preguntarte. Las colecciones nuevas usan .yml por defecto; create_collection toma format: "bru" si quieres el dialecto heredado. Donde los dos formatos realmente discrepan — y lo hacen — cada uno se escribe como el propio escritor de Bruno para ese dialecto lo escribe.
Una colección es un dialecto u otro, sin embargo, no una mezcla: el manifiesto raíz lo elige, y Bruno lee solo esa extensión, por lo que una solicitud .yml dentro de una colección bruno.json es un archivo en un directorio en lo que a Bruno respecta. Este servidor aún lee, escribe y ejecuta dicho archivo — rechazarlo te dejaría sin poder realizar la corrección, que es un renombrado de ese mismo archivo — pero cada herramienta que toca o lista uno te dice que Bruno no puede verlo, y lo nombra.
¿Puedo ejecutar esto en CI?
La colección que produce es una colección normal de Bruno, por lo que CI la ejecuta con bru run exactamente como si un humano la hubiera creado en la aplicación. El propio servidor MCP es para el bucle de creación y depuración, donde un agente está presente. También escribe archivos de informe JUnit XML y HTML — ver Archivos de informe — por lo que una ejecución impulsada por un agente aún deja el artefacto que un panel de CI espera.
¿Reescribirá archivos que la aplicación de Bruno escribió?
Solo los campos que pediste cambiar. Una edición a través de write_request es una fusión parcial, una clave que este servidor no modela se lleva de vuelta donde el formato puede llevarla — .yml en todo, y .bru donde su gramática tiene un bloque de diccionario para contenerla — y cada escritura se verifica contra el propio lector de Bruno en la suite de pruebas. Las eliminaciones necesitan un confirm: true explícito.
¿Qué clientes MCP funcionan?
Cualquiera de ellos — este es un servidor stdio simple sin código específico de cliente. Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, Codex CLI, opencode, Zed, Cline, Continue, LM Studio, Gemini CLI, el MCP Inspector, o tu propio cliente SDK. Ver Conectar un cliente para dónde guarda cada uno su configuración.
¿Qué pasa con mis secretos?
Las variables de entorno secretas permanecen en memoria durante la ejecución y nunca se escriben en un archivo — ningún dialecto almacena el valor de un secreto en disco, lo cual es diseño de Bruno, no una limitación agregada aquí. Las credenciales se redactan de los resultados devueltos al agente, incluida una colocada en un parámetro de consulta. Los scripts se ejecutan en un sandbox V8 bifurcado con un entorno depurado. Ver Seguridad.
¿Es esto lo mismo que el bruno-mcp original?
Comenzó como un fork de macarthy/bruno-mcp, que ha estado inactivo desde julio de 2025 y generaba archivos de colección sin ejecutarlos. Todo lo anterior — el ejecutor, los lectores, ambos dialectos, la compuerta de paridad, el sandbox — se construyó después del fork. El repositorio ahora es Ostico/bruno-mcp-studio (anuncio); el nombre del paquete npm no cambia, @ostico/bruno-mcp.
¿Está afiliado con Bruno?
No. Bruno es un producto de usebruno y su nombre y marcas comerciales les pertenecen. Este es un proyecto comunitario que lee los paquetes de código abierto de Bruno para mantenerse compatible con ellos; no está respaldado ni afiliado con usebruno.
El propio equipo de Bruno anunció un servidor MCP oficial, usebruno/bruno-mcp, en agosto de 2026. Este no es ese, y no compite por ese rol — ver ¿Qué servidor MCP de Bruno debería usar? para qué es bueno cada uno.
Actualización a 2.5.0 — cuatro herramientas se fusionaron en write_request
Cuatro herramientas han desaparecido. Lo que hacían, write_request lo hace, con la misma semántica: una edición
sigue actualizando solo los campos que pasas y preserva cada otro campo, byte por byte.
| Eliminado en 2.5.0 | Llama en su lugar |
|---|---|
create_request, modify_request | write_request |
create_test_suite, create_crud_requests | write_request con requests, y dependencies para ordenar |
delete_request no cambia y sigue aquí; ahora también toma filePaths para eliminar un conjunto
en una sola llamada.
Esto falla de forma cerrada. Un cliente solicita la lista de herramientas al conectarse y obtiene los nombres actuales, por lo que un agente que lee la lista no se ve afectado. Una llamada a un nombre eliminado es un error de herramienta desconocida o un aviso de permiso — nunca un resultado incorrecto, nunca uno silencioso.
Lo que realmente necesita edición es la configuración que fija un nombre: un argumento --allowedTools mcp__bruno-mcp__create_request, una entrada de permiso settings.json, un matcher de hooks, y cualquier prosa en un CLAUDE.md o una habilidad que le diga a un agente que llame a uno de los cuatro por nombre.
Versionado
Los nombres de herramientas se descubren desde tools/list al conectar, no se vinculan contra ellos, por lo que renombrar o eliminar una herramienta se publica en una versión menor. Una llamada o se comporta exactamente como antes o no existe, y el segundo caso es ruidoso.
Cambiar lo que hace una llamada sin cambios se publica en una versión mayor. Ese es el tipo peligroso: 2.0.0 convirtió carpetas en grupos de ejecución, por lo que un llamador que no cambió nada obtuvo un resultado diferente. Nada más silencioso que eso merece una mayor, porque las mayores que no señalan peligro te enseñan a ignorar las mayores.
Actualización desde 1.x
requestPathyfolderhan desaparecido. Ambos se convierten enrequests, un array ordenado de archivos y/o directorios.- No hay un array
resultsde nivel superior. Leeresult.groups[0].results. parallel: truesolía aislar cada carpeta. Ya no lo hace: singroups, toda la selección es un grupo que comparte un almacén y una jarra de cookies. Nombra las carpetas como grupos separados para mantener el comportamiento anterior.seqya no restringe la ejecución. Es solo el orden predeterminado y el orden de reporte.- Un
requests: []vacío no ejecuta nada. Solía ejecutar toda la colección. - Exportaciones eliminadas:
BruGenerator,generateBruFile,createBasicBruFile.
Detalles completos en CHANGELOG.md.
Desarrollo
npm run build # compile to dist/
npm test # full suite
npm run test:unit # unit only
npm run typecheck
npm run lint
Usa npm, no yarn — el lockfile es de npm y CI ejecuta npm ci.
Consulta CONTRIBUTING.md para ver qué verifica CI, las convenciones de commits y el sign-off (DCO) que cada commit necesita.
Licencia
MIT — ver LICENSE. Las contribuciones se aceptan bajo los mismos términos, con un sign-off DCO.
Enlaces
Soporte
Si Test-Guard te resulta útil, considera apoyar su desarrollo: