"""Per-user MCP credentials (BYOK) — REST for the settings UI.
``GET`` lists every MCP server a profile connects to, so the settings tab can
show what is wired up at all. A server config (``mcp_servers.d/*.json``) may
additionally declare a ``user_key`` slot; only such servers accept a personal
key. Users save their own key, which overrides the default plaintext
credential for their tool calls; users without a key fall back to that
default. Keys are never returned — only presence and update time.
"""
from typing import Annotated
import structlog
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from navi.api.deps import require_user
from navi.auth import User
log = structlog.get_logger()
router = APIRouter(prefix="/mcp-keys", tags=["mcp-keys"])
def _pool():
from navi.api.deps import get_session_store
return get_session_store()._get_pool()
def _connected_profiles():
"""Server name → ids of the profiles that connect it.
A server nobody connects to is noise in the settings UI, so the listing is
scoped to what is actually reachable by some profile.
"""
from navi.api.deps import get_profile_registry
connected: dict[str, list[str]] = {}
for profile in get_profile_registry().all():
names = set(profile.tools.agent.mcp) | set(profile.tools.subagent.mcp)
for name in names:
connected.setdefault(name, []).append(profile.id)
return connected
class McpKeyItem(BaseModel):
server_name: str
transport: str
accepts_user_key: bool
key_type: str | None = None # header | env — None when the server has no slot
key_location: str | None = None # header name or env var name
prefix: str | None = None
has_key: bool = False
updated_at: str | None = None
profiles: list[str] = []
@router.get("")
async def list_mcp_keys(
user: Annotated[User, Depends(require_user)],
) -> dict:
from navi.mcp.config import load_mcp_servers
connected = _connected_profiles()
listed = {
name: cfg
for name, cfg in load_mcp_servers().items()
if name in connected
}
if not listed:
return {"items": []}
keys: dict = {}
if any(cfg.user_key is not None for cfg in listed.values()):
from navi.mcp.keystore import McpKeyStore
keys = await McpKeyStore(await _pool()).list_servers(user.id)
def _item(name: str, cfg) -> dict:
slot = cfg.user_key
return McpKeyItem(
server_name=name,
transport=cfg.transport,
accepts_user_key=slot is not None,
key_type=None if slot is None else ("header" if slot.header else "env"),
key_location=None if slot is None else (slot.header or slot.env),
prefix=slot.prefix if slot is not None else None,
has_key=name in keys,
updated_at=keys[name].isoformat() if name in keys else None,
profiles=sorted(connected[name]),
).model_dump()
# Servers accepting a personal key first (they are the actionable rows),
# then alphabetical.
ordered = sorted(listed.items(), key=lambda kv: (kv[1].user_key is None, kv[0]))
return {"items": [_item(name, cfg) for name, cfg in ordered]}
class SaveMcpKeyRequest(BaseModel):
key: str = Field(min_length=1, max_length=4096)
def _get_config(server_name: str):
"""The config for *server_name*; 404 when missing or key-less."""
from navi.mcp.config import load_mcp_servers
cfg = load_mcp_servers().get(server_name)
if cfg is None or cfg.user_key is None:
raise HTTPException(status_code=404, detail="Unknown server or it accepts no user key")
return cfg
@router.put("/{server_name}")
async def save_mcp_key(
server_name: str,
payload: SaveMcpKeyRequest,
user: Annotated[User, Depends(require_user)],
) -> dict:
_get_config(server_name)
from navi.mcp.keystore import McpKeyStore, get_key_resolver
updated_at = await McpKeyStore(await _pool()).set(
user.id, server_name, payload.key.strip(),
)
get_key_resolver().invalidate(user.id, server_name)
log.info("mcp_key.saved", user_id=user.id, server=server_name)
return {
"server_name": server_name,
"has_key": True,
"updated_at": updated_at.isoformat(),
}
@router.delete("/{server_name}", status_code=204)
async def delete_mcp_key(
server_name: str,
user: Annotated[User, Depends(require_user)],
) -> None:
_get_config(server_name)
from navi.mcp.keystore import McpKeyStore, get_key_resolver
deleted = await McpKeyStore(await _pool()).delete(user.id, server_name)
get_key_resolver().invalidate(user.id, server_name)
if not deleted:
# Key never existed — still 204, the desired end state is reached.
return
log.info("mcp_key.deleted", user_id=user.id, server=server_name)