bruno-headless
by bachden
README.md
# bruno-headless
Serves a single [Bruno](https://usebruno.com) workspace over MCP, on machines with no UI.
Full CRUD over collections, folders, requests, environments and dotenv files, plus request
execution with Bruno's scripting, tests and assertions — over HTTP, GraphQL, gRPC and
WebSocket. No Electron, no display, no keychain.
```
BRUNO_WORKSPACE=/srv/api-workspace \
BRUNO_MCP_TOKEN=$(openssl rand -hex 32) \
npm start
```
## How it is put together
Three layers, and only the middle one is vendored:
**The request engine is bruno-cli's**, vendored verbatim into `src/bruno/runtime/` (18
files). It is already headless — nothing in it imports `electron`, an Electron store, or
the OS keychain — and it brings pre-request and post-response scripts, tests, assertions,
variable updates and the whole interpolation pipeline with it. Nothing under `src/bruno`
is ever hand-edited.
**gRPC and WebSocket are driven directly from `@usebruno/requests`**, which exports the
same `GrpcClient` and `WsClient` the desktop app uses. The desktop's ~1000 lines around
them are IPC plumbing that streams events to a renderer; `src/protocols/` replaces that
with adapters that collect the event stream into a single result, which is what a tool
call needs. Nothing extra is vendored for this.
**CRUD is this project's own code** (`src/collections.js`), written against
`@usebruno/filestore`'s parsers and serializers — the same ones Bruno uses. This module
decides which file to touch and how to merge a change; it never formats `.bru` or `.yml`
by hand, so files stay byte-compatible with the desktop app and the CLI.
To re-sync the engine after upstream changes:
```bash
node scripts/sync-bruno.js /path/to/bruno
npm install
npm test
```
The script recomputes the require closure itself, so new upstream dependencies are picked
up automatically. `src/bruno/VENDOR.json` records the source commit and file list.
`vendor/@usebruno/` holds the workspace packages, which must be vendored because their
monorepo versions do not match the registry (`@usebruno/filestore` is `0.1.0` here and
`0.11.0` on npm). Source maps and esm builds are stripped — bruno-headless is CommonJS
throughout — which takes that tree from 39 MB to under 7 MB.
## Configuration
All configuration is environment variables. See `.env.example`.
| Variable | Default | Meaning |
| --- | --- | --- |
| `BRUNO_WORKSPACE` | — | **Required.** Workspace directory to serve. |
| `BRUNO_MCP_HOST` | `127.0.0.1` | Bind address. |
| `BRUNO_MCP_PORT` | `3847` | Bind port. |
| `BRUNO_MCP_TOKEN` | generated | Bearer token. Generated and printed at startup if unset. |
| `BRUNO_GLOBAL_ENVIRONMENT` | — | Workspace global environment to treat as active, by name. |
| `BRUNO_WORKSPACE_NAME` | from `workspace.yml` | Display name override. |
| `BRUNO_SANDBOX` | `safe` | `safe` runs scripts in QuickJS, `developer` in a Node VM with full access. |
| `BRUNO_REQUEST_TIMEOUT_MS` | `120000` | Per-request execution timeout. |
| `BRUNO_MAX_REQUEST_FILES` | `20000` | Cap on files scanned when listing requests. |
| `BRUNO_SSL_VERIFY` | `true` | TLS certificate verification. |
| `BRUNO_CA_CERT` | — | Path to an additional CA certificate. |
| `BRUNO_IGNORE_TRUSTSTORE` | `false` | Ignore the system truststore when a custom CA is set. |
| `BRUNO_STORE_COOKIES` | `true` | Keep a cookie jar for the process lifetime. |
| `BRUNO_USE_PROXY` | `true` | Honour proxy configuration. |
| `BRUNO_LOG_LEVEL` | `info` | `error`, `warn`, `info`, `debug`. |
Nothing is persisted outside the workspace: OAuth2 tokens and cookies live in memory for
the life of the process, so there is no data directory to provision.
A `workspace.yml` is optional — collections are discovered by scanning for `bruno.json`
and `opencollection.yml`, so a plain directory of collections works and is named after its
folder.
## Endpoints
- `POST /mcp` — streamable-HTTP MCP, requires `Authorization: Bearer <token>`
- `GET /healthz` — unauthenticated liveness, reports the served workspace
Requests are handled statelessly (no session id), so clients can reconnect freely and the
service can sit behind a load balancer without sticky sessions.
```json
{
"mcpServers": {
"bruno": {
"type": "http",
"url": "http://127.0.0.1:3847/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
}
```
## Tools
38 tools. The workspace is fixed at startup, so no tool takes a `workspace_path` or
`workspace_uid` — every call targets `BRUNO_WORKSPACE` implicitly.
| Area | Tools |
| --- | --- |
| Service | `bruno_status`, `bruno_list_workspaces` |
| Collections | `list`, `get`, `create`, `update`, `update_tab`, `clone`, `move`, `delete`, `list_collection_items`, `resequence_items` |
| Folders | `get_folder`, `create_folder`, `update_folder`, `update_folder_tab`, `delete_folder`, `move_item` |
| Requests | `list_requests`, `search_requests`, `get_request`, `create_request`, `update_request`, `update_request_tab`, `duplicate_request`, `delete_request` |
| Environments | `list_environments`, `get_environment`, `create_environment`, `update_environment`, `delete_environment` |
| Dotenv | `get_dotenv`, `set_dotenv`, `delete_dotenv` |
| Execution | `prepare_request`, `run_request`, `get_request_run`, `list_request_runs` |
`bruno_prepare_request` resolves a request fully and returns it **without sending it**,
along with any variables that stayed unresolved — the cheapest way for an agent to check
its work before firing a request at a real API.
### WebSocket runs
A WebSocket stays open indefinitely, but a tool call has to return, so `bruno_run_request`
takes a stopping rule for `ws` requests. It returns as soon as any of these is true:
| Parameter | Default | Stops when |
| --- | --- | --- |
| — | — | the server closes the connection, or errors |
| `idle_timeout_ms` | `2000` | no frame has arrived for this long |
| `max_messages` | `100` | this many frames have arrived |
| `duration_ms` | `30000` | this much total time has elapsed |
The result reports `terminationReason` so the caller knows which rule fired. On connect,
every message in the request that has content is sent — matching Bruno's own behaviour.
## Variable resolution
The engine is Bruno's, so precedence is Bruno's. Layers, from lowest:
1. Workspace global environment (`<workspace>/environments/*`, selected by `BRUNO_GLOBAL_ENVIRONMENT`)
2. Collection variables (`collection.bru`)
3. Folder variables (`folder.bru`)
4. Request variables
5. Selected collection environment (`environment_name` / `environment_uid` per call)
6. `.env` files — collection and workspace level — reached as `{{process.env.NAME}}`
7. Process environment
8. `runtime_variables` passed to the call
One deliberate addition over bruno-cli: it reads only a collection's `.env`, because it
runs against a bare collection path. Here a workspace can also hold a shared `.env` one
level up, and collection values win on conflict.
## Security
- **Bearer token on every MCP call**, compared in constant time. Generated at startup if
`BRUNO_MCP_TOKEN` is unset — convenient for a first run, but it changes on restart, so
set it explicitly for anything long-lived.
- **The workspace is a hard boundary.** `collection_path`, `location` and `target_location`
are resolved against the workspace root and rejected if they escape it, whether written
relative or absolute.
- **Scripts run in QuickJS by default.** `BRUNO_SANDBOX=developer` gives collection
scripts a Node VM with full access to the host — only set it for collections you trust.
- **Binding to `0.0.0.0` logs a warning.** The default is loopback. Requests execute with
the service's network access, so treat the port as privileged and firewall it or front
it with a proxy that terminates TLS.
- Tool arguments are redacted by key name before reaching debug logs, since dotenv
contents and environment values flow through them.
## Logging
One JSON object per line on stdout, errors on stderr — what container collectors and
journald expect.
The vendored engine is a CLI underneath and prints a coloured progress line per request
straight to stdout, which would corrupt that stream. Rather than patch a vendored file,
`start()` redirects the global console into the logger: engine chatter becomes `debug`
(tagged `"source":"engine"`), its warnings and errors keep their level. Embedders who need
the console back get a `restoreConsole()` from `start()`.
## Known limitations
- **Interactive OAuth2 does not work.** Authorization-code grants need a browser. The
non-interactive grants (`client_credentials`, `password`) work, as does supplying a
token directly. Everything else — basic, bearer, API key, AWS SigV4, digest, NTLM —
is unaffected.
- **Environment secrets are not encrypted.** Desktop Bruno pushes variables marked
`secret` into the OS keychain, which has no headless equivalent; here they stay in the
environment file as written. Keep anything that must not sit in the collection in `.env`
files or the process environment. A workspace carrying secrets written by desktop Bruno
will not resolve them — re-enter those values.
- **OAuth2 tokens and cookies do not survive a restart.** They are in-memory only.
- **Single workspace per process.** By design. Run one instance per workspace.
## Docker
```bash
docker build -t bruno-headless .
docker run -d --name bruno-headless \
-p 127.0.0.1:3847:3847 \
-v /srv/api-workspace:/workspace \
-e BRUNO_MCP_TOKEN=$(openssl rand -hex 32) \
bruno-headless
```
Runs as a non-root user, uses `tini` so `SIGTERM` reaches the graceful shutdown, and ships
a healthcheck against `/healthz`. Mount the workspace read-only to expose a strictly
read-and-execute service.
## systemd
```ini
[Unit]
Description=bruno-headless
After=network-online.target
[Service]
Type=simple
User=bruno
WorkingDirectory=/opt/bruno-headless
EnvironmentFile=/etc/bruno-headless.env
ExecStart=/usr/bin/node bin/bruno-headless.js
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/api-workspace
[Install]
WantedBy=multi-user.target
```
## Tests
```bash
npm test
```
`test/smoke.js` starts the real service on a real socket and drives it with the real MCP
client, against live HTTP, WebSocket and gRPC servers: transport and auth, the tool
surface, CRUD on the filesystem, every layer of variable precedence, scripts/assertions/
tests, all four protocols, and the workspace boundary.
## Layout
```
bin/bruno-headless.js entry point
src/index.js startup sequence
src/config.js environment configuration
src/collections.js CRUD over collections, folders, requests, environments, dotenv
src/execute.js resolution and execution, dispatched by protocol
src/environment.js the variable layers a request resolves against
src/protocols/grpc.js gRPC adapter over @usebruno/requests
src/protocols/ws.js WebSocket adapter, with the run termination policy
src/workspace.js the single workspace
src/mcp/server.js streamable-HTTP MCP server
src/mcp/tools.js tool definitions and the workspace boundary
src/bruno/runtime/ bruno-cli's engine, vendored, never edited
vendor/@usebruno/ vendored workspace packages
scripts/sync-bruno.js re-vendor from a Bruno checkout
```
## Licence
MIT — see `LICENSE`, and `NOTICE` for third-party attribution.
This project redistributes code from [Bruno](https://github.com/usebruno/bruno), also MIT
licensed, Copyright (c) 2022 Anoop M D, Anusree P S and Contributors. The vendored trees
(`src/bruno/runtime/` and `vendor/@usebruno/`) each carry a copy of Bruno's licence, and
`scripts/sync-bruno.js` refuses to vendor without one. bruno-headless is an independent
project, not affiliated with or endorsed by Bruno.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues