Newer
Older
navi-1 / navi / api / routes / mcp_keys.py
"""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)