MCP Configuration
Where the files live
| File | Location |
|---|---|
| User sidecar config | mcp.yaml in the profile config directory — $XDG_CONFIG_HOME/sonny when set, otherwise the platform-native location |
| Project chain | .sonny/mcp.yaml in every directory from the cwd up to the filesystem root |
| Allowlist | mcp.allowlist.yaml, in the same profile config directory as the sidecar config |
--mcp-config <path> on sonny run, sonny mcp list, sonny tools list and
sonny recipe resolve overrides the user sidecar path for that invocation only. It does
not move the project chain or the allowlist.
How layers merge
Four sources merge, then two gates run, in this exact order:
| # | Layer | Conflict rule |
|---|---|---|
| 1 | Your user mcp.yaml | the base |
| 2 | Pack-registry servers (the mcp_d/ directories of your recipe sources) | only servers the active recipe’s mcp.allowed names explicitly join; "*" pulls none; each replacement is announced at startup, and a name two sources both define loads from neither |
| 3 | The .sonny/mcp.yaml project chain | outer-first; a deeper directory replaces a shallower same-name server, and a project server wins over a user server |
| 4 | The active recipe’s own mcp.servers | beats all of the above — the recipe is the concrete packaged contract |
After the merge, the recipe’s resolved mcp.allowed filters the set:
- a list of names keeps only the servers it names, whichever layer declared them;
- an explicit
allowed: []keeps none; - an omitted
allowed, nomcp:block, or["*"]withallow_wildcard: truekeeps everything ("*"withoutallow_wildcardis ignored).
A server dropped here never reaches the allowlist, and every surface says what it dropped:
the TUI startup alert, a sub-agent stderr note, a sonny recipe resolve warning,
filtered by mcp.allowed rows in sonny mcp list --pack-file (JSON: filtered_by_allowed),
and a single mcp_not_allowed summary line in a headless --result-json.
Then the two gates run — the integration gate and allowlist enforcement. They are covered in Trust model .
Server schema
A mcp.yaml file is one key holding a list of servers:
servers:
- name: fs
type: stdio
command: /usr/bin/mcp-server-filesystem
args: ["--root", "/tmp"]
work_dir: /srv
enabled: true
auto_connect: true
- name: web
type: sse
url: "https://mcp.example.com/sse"
egress:
allow_hosts: ["mcp.example.com"]
allow_ports: [443]
auth:
type: bearer
token: ${MCP_TOKEN}
| Field | Type | Notes |
|---|---|---|
name | string | required; the merge key, the allowlist key, and the tool-name namespace |
type | string | stdio or sse. No http type exists; streamable_http is refused at load |
command | string | required for stdio; the executable to spawn |
args | list | argv for the stdio command |
url | string | required for sse; the SSE endpoint |
work_dir | string | working directory for the spawned stdio process |
env | map | extra environment for the stdio process; values expand ${VAR}/$VAR |
auth | object | none/api_key/bearer/basic, with api_key, api_key_header, api_key_env_var, token, username, password; only meaningful for sse |
egress | object | required for sse; see below. A stdio server cannot declare it |
enabled | bool | default true |
auto_connect | bool | default true; when false the server loads but is not connected at session start |
integration | string | Sonny-only; classifies the server for the integration gate (jira, gitlab, github, …). Omit it and Sonny guesses from name |
metadata | map | Sonny-only free-form labels (tenant, env, region, …) |
from_pack | string | Sonny-only; written by sonny mcp approve --write-config on an entry copied from a recipe that is not verified-signed |
The standalone sidecar is parsed strictly: it accepts the fields above plus
env_allowlist, env_inherit_all and timeout. Any other key — including the upstream
oauth block and schema_version — is rejected as a configuration error rather than
silently ignored. A recipe’s inline mcp.servers: decodes into the full schema type.
sse servers need an egress policy
Remote egress is deny-by-default. The MCP manager validates every remote server at
registration and refuses one whose egress.allow_hosts does not name its URL host.
servers:
- name: local-dev
type: sse
url: "http://localhost:3001/sse"
egress:
allow_hosts: ["localhost"]
allow_plain_http: true
The full block is {allow_hosts, allow_ports, allow_plain_http, allow_private_networks, deny_private_networks, proxy_url} — the same block a pack’s mcp.servers: entry takes.
Environment expansion
env values expand ${VAR}/$VAR against the current environment, or a recipe secrets:
export, at connect time. A name the environment leaves unset or empty is then read from the
operator’s owner-only (0600) <config-dir>/.env, or the file SONNY_DOTENV names, on
every connect path: the CLI, the TUI, and the desktop daemon. A connect that fails with a
reference still unresolved names the variable and that file.
Under active isolation the host environment comes first, so a variable you set — even to an empty value — wins over a recipe secret of the same name. A server an untrusted recipe declares resolves references only against the session’s own values.
Introspection
sonny mcp list # what would load, and why anything was dropped
sonny mcp list --format json
sonny mcp list --pack-file pack.yaml
sonny mcp list --connect # connect and enumerate tools (spawns processes)
| Flag | Effect |
|---|---|
--mcp-config <path> | use this sidecar config instead of standard discovery |
--pack-file <path> | merge this recipe’s inline mcp.servers over the config |
--connect | connect to enabled servers and enumerate their tools |
--connect-timeout <dur> | per-server connect timeout (only used with --connect) |
--format text|json | output shape |
--timeout is deprecated in favour of --connect-timeout; it still works and warns.Introspection goes through the same allowlist-gated core as a real session, so
sonny mcp list --connect, sonny tools list --mcp-config and
sonny recipe resolve --connect can never connect to an unapproved server either.