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.
Two sources: inline and file
Section titled “Two sources: inline and file”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"] } }}Transport is inferred
Section titled “Transport is inferred”You never specify transport explicitly. The loader picks one based on the fields you set:
| Field present | Transport |
|---|---|
command: | stdio |
url: | streamable-http |
# stdio — the runtime spawns the processintel-server: command: node args: [mcp/intel.js]
# HTTP — the runtime opens a streaming connectionremote-intel: url: https://mcp.example.com/intel headers: Authorization: Bearer ${INTEL_API_TOKEN}Setting both is a validation error.
Variable interpolation
Section titled “Variable interpolation”Two kinds of placeholders are recognized in command, args, url, headers, and env:
| Form | Resolved at | Source |
|---|---|---|
${CAPABILITY_ROOT} | Parse time | Capability directory on disk |
${VAR} | Connect time | os.environ |
${VAR:-default} | Connect time | os.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.
Optional headers
Section titled “Optional headers”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: trueINTEL_API_TOKENset →Authorization: Bearer <token>is sent.INTEL_API_TOKENunset → noAuthorizationheader at all (not a bareAuthorization: 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.
Working directory
Section titled “Working directory”Stdio servers run with the capability root as their working directory. Relative paths in command, args, or config files resolve against that root.
Authentication
Section titled “Authentication”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 public | just url: (or command:) — nothing else |
| takes a static token over HTTP | a headers: entry, e.g. Authorization: Bearer ${VAR} |
| takes a static token as a stdio subprocess | an env: var the process reads, e.g. API_KEY: ${VAR} |
| uses OAuth | just url: — OAuth is reactive-by-default (below) |
| uses OAuth but needs a specific scope / client name | url: plus an auth: block (see below) |
| should use OAuth or a static token when one is set | an 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/mcpHow 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_authin the TUI Services screen. You click Authenticate (orPOST /api/mcp/{capability}/{server}/reconnect) to open the browser and complete the flow. - Tokens persist in
~/.dreadnode/mcp-auth.json(mode0600, 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
Authorizationheader, 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: trueThis replaces the old advice to ship a separate wrapper capability for the API-key path — one entry now covers both.
Python MCP servers with uv
Section titled “Python MCP servers with uv”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.
Flag gating
Section titled “Flag gating”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.
Failure isolation
Section titled “Failure isolation”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.
Reconnecting and re-authenticating
Section titled “Reconnecting and re-authenticating”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.jsonto 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.