Newer
Older
navi-1 / docs / mcp.md

MCP (Model Context Protocol)

How Navi connects to external MCP servers, how their tools are exposed, and how per-user credentials (BYOK) work.

Configs

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.

Runtime

  • 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.
  • Tool names follow 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=...).

Per-user keys (BYOK)

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):

  1. 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.
  2. Otherwise 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).
  3. The tool call runs on a per-(server, user) 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.

Storage

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).

REST

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.