bruno-headless
A headless MCP server for Bruno workspaces, enabling CRUD operations on collections, folders, requests, environments, and dotenv files, plus request execution with scripting, tests, and assertions over HTTP, GraphQL, gRPC, and WebSocket.
README
bruno-headless
Serves a single Bruno 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:
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, requiresAuthorization: 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.
{
"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:
- Workspace global environment (
<workspace>/environments/*, selected byBRUNO_GLOBAL_ENVIRONMENT) - Collection variables (
collection.bru) - Folder variables (
folder.bru) - Request variables
- Selected collection environment (
environment_name/environment_uidper call) .envfiles — collection and workspace level — reached as{{process.env.NAME}}- Process environment
runtime_variablespassed 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_TOKENis 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,locationandtarget_locationare resolved against the workspace root and rejected if they escape it, whether written relative or absolute. - Scripts run in QuickJS by default.
BRUNO_SANDBOX=developergives collection scripts a Node VM with full access to the host — only set it for collections you trust. - Binding to
0.0.0.0logs 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
secretinto 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.envfiles 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
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
[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
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, 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.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。