How Navi connects to external MCP servers, how their tools are exposed, and how per-user credentials (BYOK) work.
Each server lives in its own file mcp_servers.d/<name>.json:
{
"transport": "streamable_http",
"url": "https://creds.example.com/mcp",
"headers": { "Authorization": "Bearer ${NAVI_MCP_GNEXUS_CREDS_TOKEN}" },
"groups": {
"creds": ["search_secrets", "get_secret", "reveal_secret"]
},
"instructions": "MANDATORY: ...",
"user_key": { "header": "Authorization", "prefix": "Bearer " }
}
| Field | Applies to | Meaning | ||
|---|---|---|---|---|
transport |
all | stdio \ |
sse \ |
streamable_http |
command, args, env, cwd |
stdio |
Subprocess launch | ||
url, headers |
sse, streamable_http |
Endpoint and HTTP headers | ||
groups |
all | Named tool groups profiles reference instead of listing tools | ||
instructions |
all | Overlay injected next to the server's own initialize instructions | ||
user_key |
all | Declares the per-user credential slot (BYOK); omit for key-less servers |
The legacy monolithic mcp_servers.json is auto-migrated to mcp_servers.d/ on first load. Edits take effect on server restart or a reload_tools-triggered McpManager.reload_all().
Heads-up for admin config edits: PUT /admin/agents/mcp-servers round-trips the whole model — write back the config you read, or a user_key slot added by hand is silently erased.
mcp_servers.d/*.json is tracked in git, so a credential written into one is in history for good — and GET /admin/mcp/config hands the same value to the admin client. A config therefore never carries a live credential: it names the variable that holds it.
"headers": { "Authorization": "Bearer ${NAVI_MCP_GNTODO_TOKEN}" }
headers and env values are scanned, and only the braced ${NAME} form — a bare $NAME is left alone, so header values that contain $ survive..env (chmod 600, untracked; environment variable beats the file, as with Settings). A blank NAME= in .env counts as missing. .env is re-read on every (re)connect, so a rotated token is picked up without a restart — the canonical store for the value itself is gnexus-creds (see the platform's secrets rules; deploy/env.template lists the variable names).ValueError naming the server and the variable, rather than dropping the header — a server may answer an unauthenticated request as an anonymous user instead of returning 401, so silently dropping would fail open. McpManager.load_all isolates a failing server, so this shows up as one broken entry in /mcp/status.navi/mcp/client.py), next to the project-relative path resolution. This is what keeps every stored and serialised config placeholder-only: save_mcp_servers — reached by create_mcp_server and by PUT /admin/mcp/config — cannot write a secret back into a tracked file, and a GET→PUT round-trip is lossless.A user key (user_key) is injected first and overwrites the header, so a per-user credential simply replaces the placeholder — no substitution happens.
McpManager (navi/mcp/manager.py) holds one McpClient per server, reconnects dead ones in a 30 s health-check loop, and is the single call_tool gateway. Connection headers/env are fixed at transport open (navi/mcp/client.py) — a shared client cannot switch credentials per call.mcp__<server>__<tool>; profiles select servers via tools.agent.mcp / tools.subagent.mcp group maps. Registered MCP tools are process-wide, regardless of which profile uses them.McpTool.execute forwards ctx.user_id (falling back to the current_user_id ContextVar) into manager.call_tool(..., user_id=...).A config with a user_key slot lets each user bring their own credential:
"user_key": { "header": "Authorization", "prefix": "Bearer " }
or, for stdio servers:
"user_key": { "env": "API_KEY" }
Exactly one destination (validator navi/mcp/config.py::McpUserKey); a header is only valid on HTTP transports, an env only on stdio.
Resolution flow (McpManager._client_for):
user_id is None, or the config has no user_key, or the resolver has no saved key → the default shared client is used with the plaintext credential from the config file. This path never touches the database — behaviour without BYOK is byte-for-byte unchanged.KeyResolver.resolve(user_id, server) reads the user's key (30 s in-memory TTL; every failure — DB error, missing NAVI_AUTH_ENCRYPTION_KEY — logs a warning and resolves to None, i.e. falls back to the default rather than breaking the tool call).McpClient clone built from McpServerConfig.with_user_key(key) (deep copy of the config with the key injected into the header/env destination). Clones are cached per key snapshot: a changed key transparently rebuilds the client.Cache bounds: hard LRU cap of 32 per-user clients; per-user clients idle longer than 30 minutes are reaped by the health-check tick. Per-user clients are outside the health-check's reconnect logic — they reconnect lazily with backoff when next called. disconnect_all (reload/shutdown) tears user clients down as well.
Key changes (PUT/DELETE) fire the resolver's on_change callback, which drops affected per-user clients immediately.
mcp_user_keys table (postgres, navi/mcp/_ddl.py), Fernet-encrypted via NAVI_AUTH_ENCRYPTION_KEY (navi/mcp/keystore.py::McpKeyStore). One row per (user_id, server_name); deleting a user cascades. Keys never leave the backend through logs or REST responses.
Anon mode (NAVI_AUTH_ENABLED=false) is one shared local user — its saved key effectively becomes a system-level override, and requires NAVI_AUTH_ENCRYPTION_KEY to be set (the Fernet encryptor refuses an empty key).
Settings UI surface — see api.md: GET /mcp-keys (every profile-referenced server, keyed ones flagged, no key values), PUT /mcp-keys/{server} (save/invalidate), DELETE /mcp-keys/{server} (reset to default). The webclient lists all of them: rows for servers without a user_key slot are read-only context, and only slotted servers get a key input. Servers that declare an Authorization-style header (today gnexus-creds) carry a shared default in the config — as a ${...} placeholder, not a literal (see Secrets) — which is what a user without a personal key falls back to.