Skip to main content
Glama
bachden

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.