# 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`:

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

```json
"user_key": { "header": "Authorization", "prefix": "Bearer " }
```
or, for stdio servers:
```json
"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`](api.md#mcp-user-keys-byok):
`GET /mcp-keys` (eligible servers, no key values), `PUT /mcp-keys/{server}`
(save/invalidate), `DELETE /mcp-keys/{server}` (reset to default).
The webclient renders the panel only when the eligible list is non-empty.