"""Per-user MCP credentials (BYOK) — REST for the settings UI.
A server config (``mcp_servers.d/*.json``) may declare a ``user_key`` slot;
only such servers appear here. Users save their own key, which overrides the
default plaintext credential for their tool calls; users without a key fall
back to the 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 _eligible_configs():
"""Configs that accept a user key and are referenced by at least one profile.
A server nobody connects to is noise in the settings UI even if its
config declares a user_key slot.
"""
from navi.api.deps import get_profile_registry
from navi.mcp.config import load_mcp_servers
referenced: set[str] = set()
for profile in get_profile_registry().all():
referenced.update(profile.tools.agent.mcp)
referenced.update(profile.tools.subagent.mcp)
return {
name: cfg
for name, cfg in load_mcp_servers().items()
if name in referenced and cfg.user_key is not None
}
class McpKeyItem(BaseModel):
server_name: str
transport: str
key_type: str # header | env
key_location: str # header name or env var name
prefix: str | None = None
instructions: str | None = None
has_key: bool
updated_at: str | None = None
@router.get("")
async def list_mcp_keys(
user: Annotated[User, Depends(require_user)],
) -> dict:
keyed = _eligible_configs()
if not keyed:
return {"items": []}
from navi.mcp.keystore import McpKeyStore
keys = await McpKeyStore(await _pool()).list_servers(user.id)
def _item(name: str, cfg) -> dict:
return McpKeyItem(
server_name=name,
transport=cfg.transport,
key_type="header" if cfg.user_key.header else "env",
key_location=cfg.user_key.header or cfg.user_key.env,
prefix=cfg.user_key.prefix,
instructions=cfg.instructions,
has_key=name in keys,
updated_at=keys[name].isoformat() if name in keys else None,
).model_dump()
return {"items": [_item(name, cfg) for name, cfg in sorted(keyed.items())]}
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)