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 gcr_system_default" },
"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.
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, which is what a user without a personal key falls back to.