Skip to content

MCP Servers

Ship MCP servers with a capability — stdio and HTTP, inline and file-based, with env interpolation and flag gating.

MCP (Model Context Protocol) servers extend a capability with tools that aren’t Python — shell commands, Node services, remote APIs, or anything with its own lifecycle. Declare them in the manifest and the runtime starts, stops, and supervises them alongside your Python tools.

mcp:
servers:
intel-server:
command: node
args: [mcp/intel.js]
env:
API_BASE: ${INTEL_API_BASE:-https://intel.example.com}

That server starts with the capability, its tools appear in the runtime’s tool registry, and it exits cleanly when the capability reloads.

You can declare MCP servers in two places, and they merge:

mcp:
files:
- .mcp.json
servers:
override-server:
command: node
args: [mcp/override.js]

Inline servers under mcp.servers.<name> live in capability.yaml. They can use flag gating and the full manifest feature set.

File-based servers come from a .mcp.json or mcp.json in the capability root, using the standard mcpServers format that Claude Code, Cursor, and other MCP clients read. The loader auto-discovers these files when mcp: is omitted. On name conflicts, the inline version wins. File-based servers cannot use when: gating — declare them inline if you need conditional loading.

{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["@modelcontextprotocol/server-filesystem", "/workspace"]
}
}
}

You never specify transport explicitly. The loader picks one based on the fields you set:

Field presentTransport
command:stdio
url:streamable-http
# stdio — the runtime spawns the process
intel-server:
command: node
args: [mcp/intel.js]
# HTTP — the runtime opens a streaming connection
remote-intel:
url: https://mcp.example.com/intel
headers:
Authorization: Bearer ${INTEL_API_TOKEN}

Setting both is a validation error.

Two kinds of placeholders are recognized in command, args, url, headers, and env:

FormResolved atSource
${CAPABILITY_ROOT}Parse timeCapability directory on disk
${VAR}Connect timeos.environ
${VAR:-default}Connect timeos.environ, falling back to the default

Connect-time resolution means you can push a capability that references ${INTEL_API_TOKEN} without having the token set locally. The error only fires when the server starts without the variable.

intel-server:
command: ${CAPABILITY_ROOT}/bin/intel
args: ['--config', '${CAPABILITY_ROOT}/config.json']
env:
API_BASE: ${INTEL_API_BASE:-https://intel.example.com}
API_TOKEN: ${INTEL_API_TOKEN}

Unset ${VAR} without a default raises a ValueError at connect time with the name of the missing variable.

A header value can be an object instead of a string, which lets you mark it optional: true. When an optional header’s ${VAR} is unset (and has no :-default), the whole header is dropped from the request instead of being sent with an empty value:

remote-intel:
url: https://mcp.example.com/intel
headers:
Authorization:
value: Bearer ${INTEL_API_TOKEN}
optional: true
  • INTEL_API_TOKEN setAuthorization: Bearer <token> is sent.
  • INTEL_API_TOKEN unset → no Authorization header at all (not a bare Authorization: Bearer).

This matters for servers that fall back to OAuth: a blank Authorization header suppresses the 401 challenge that drives reactive OAuth, so omitting it entirely keeps OAuth as the clean default. A plain string header, or an object header without optional: true, keeps the strict behavior above — an unset ${VAR} with no default still raises.

Stdio servers run with the capability root as their working directory. Relative paths in command, args, or config files resolve against that root.

You rarely declare an auth “mode.” You put credentials where the server reads them — a header for HTTP, an env var for stdio — and OAuth fills in the rest reactively. At a glance:

The server…What you write
is publicjust url: (or command:) — nothing else
takes a static token over HTTPa headers: entry, e.g. Authorization: Bearer ${VAR}
takes a static token as a stdio subprocessan env: var the process reads, e.g. API_KEY: ${VAR}
uses OAuthjust url: — OAuth is reactive-by-default (below)
uses OAuth but needs a specific scope / client nameurl: plus an auth: block (see below)
should use OAuth or a static token when one is setan optional Authorization header

Each is covered in detail below.

API key / bearer token. For an HTTP server put the token in headers; for a stdio server put it in env. Use a ${VAR} placeholder either way, so the secret resolves at connect time and is never baked into the manifest:

mcp:
servers:
# HTTP server — token travels as a request header
remote-intel:
url: https://mcp.example.com/intel
headers:
Authorization: Bearer ${INTEL_API_TOKEN}
# stdio server — token handed to the subprocess via env
local-intel:
command: npx
args: ['@acme/intel-mcp']
env:
INTEL_API_KEY: ${INTEL_API_TOKEN}

stdio servers also inherit the sandbox environment (user secrets), so a server that already reads INTEL_API_KEY may need no env: mapping at all — the env: block only adds or overrides values (see environment variables).

OAuth. Nothing to declare. If a server answers with 401, the runtime discovers its OAuth endpoints (RFC 9728 / RFC 8414), registers a client, and runs the authorization flow for you. This is reactive-by-default — the same url: entry that works for a public server works for an OAuth one.

mcp:
servers:
linear:
url: https://mcp.linear.app/mcp

How it behaves:

  • The runtime decides when your browser opens. At startup, a server that needs OAuth is not authenticated silently and never pops a browser — it shows as needs_auth in the TUI Services screen. You click Authenticate (or POST /api/mcp/{capability}/{server}/reconnect) to open the browser and complete the flow.
  • Tokens persist in ~/.dreadnode/mcp-auth.json (mode 0600, keyed by server URL) and are reused and refreshed across launches. Once authenticated, the server connects on startup with no prompt.
  • Static auth wins. If you set an Authorization header, that’s used as-is; OAuth only engages when the server actually challenges and no header token is working.
  • Headless (SSH, CI, no display, or DREADNODE_HEADLESS=1): the authorization URL is logged instead of opening a browser.

OAuth by default, or a static token when one is set. To let a single server entry use OAuth normally but accept a static token when the user provides one, mark the Authorization header optional. With the token set, it’s sent and wins over OAuth; with it unset, the header is omitted entirely so the 401 → OAuth flow runs cleanly:

mcp:
servers:
linear:
url: https://mcp.linear.app/mcp
headers:
Authorization:
value: Bearer ${LINEAR_TOKEN}
optional: true

This replaces the old advice to ship a separate wrapper capability for the API-key path — one entry now covers both.

For stdio servers written in Python, ship the server as a self-contained PEP 723 script and let uv resolve dependencies at spawn. This is the recommended pattern — no shared venv to manage, dependencies live next to the code, and the same script works identically in local dev and a sandbox.

mcp:
servers:
intel:
command: uv
args: ['run', '${CAPABILITY_ROOT}/mcp_server.py']
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "fastmcp>=2.0",
# "httpx>=0.27",
# ]
# ///
from fastmcp import FastMCP
server = FastMCP("intel")
@server.tool()
async def lookup(host: str) -> dict:
...
if __name__ == "__main__":
server.run()

uv run reads the /// script block, provisions an isolated environment on first spawn (cached across restarts), and execs the server. The shebang is optional — it lets the file run directly without uv run when you’re iterating locally.

Use when: on an inline server to load it only when a flag is on:

flags:
burp:
description: Route traffic through Burp Suite proxy at :9876
default: false
mcp:
servers:
burp-proxy:
command: node
args: [mcp/burp.js]
when: [burp]

when: takes a list of flag names. The server loads if any flag in the list is true. Empty lists and undeclared flag names are validation errors.

See Flags for the full resolution story.

One MCP server failing to start doesn’t block the rest of the capability. Failed servers produce a health entry you can see in the TUI capability manager, and the runtime keeps going with the servers that did start.

This matters for capabilities that ship multiple integrations: a broken Burp install doesn’t take down your intel server.

The TUI Services screen surfaces actions on each server row:

  • Reconnect — drop and re-establish the connection (reuses any stored OAuth token). On a server in needs_auth, this action reads Authenticate and opens the browser to complete OAuth.
  • Re-authenticate — for HTTP servers, clears the stored OAuth token for this server only (other servers keep theirs) and runs a fresh authorization flow. Never rm ~/.dreadnode/mcp-auth.json to re-auth — that wipes every server’s tokens.

From a worker, call client.reconnect_mcp_server(capability, server_name) to force a fresh connection — see the Worker API reference.