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 ${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.

Secrets

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}" }
  • Only headers and env values are scanned, and only the braced ${NAME} form — a bare $NAME is left alone, so header values that contain $ survive.
  • The value comes from the process environment over the project's .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).
  • A missing variable fails the connection with 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.
  • Resolution happens at transport open (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.

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 — as a ${...} placeholder, not a literal (see Secrets) — which is what a user without a personal key falls back to.