Newer
Older
navi-1 / navi / mcp / secrets.py
"""Runtime resolution of ``${VAR}`` placeholders in MCP credentials.

A tracked config file must never carry a live credential: ``mcp_servers.d/*.json``
lives in git, so a bearer token written there is in history for good. Instead the
file *names* the variable holding the value:

    "headers": {"Authorization": "Bearer ${NAVI_MCP_GNTODO_TOKEN}"}

and the value lives in the service's ``.env`` (chmod 600, untracked) or in the
process environment — the platform's rule for runtime secrets.

Resolution happens at transport-open time, next to the ``resolve_paths`` call
that already turns project-relative paths absolute. Keeping it there is what
makes the scheme safe: every stored or serialised config stays placeholder-only,
so ``save_mcp_servers`` — reached by ``create_mcp_server`` and
``PUT /admin/mcp/config`` — cannot write a secret back into a tracked file, and
``GET /admin/mcp/config`` returns the placeholder instead of the token.
"""

from __future__ import annotations

import os
import re
from collections.abc import Mapping
from pathlib import Path

from dotenv import dotenv_values

from navi.config import Settings

from .config import McpServerConfig

# Braced ``${NAME}`` only. A bare ``$NAME`` is deliberately left alone: header
# values legitimately contain ``$`` (nginx-style variables), and substituting
# there would corrupt them.
_PLACEHOLDER = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")


def env_values() -> dict[str, str]:
    """Credential values: the process environment over the project's ``.env``.

    Precedence matches ``pydantic-settings`` (an environment variable beats the
    file). Re-read on every call on purpose — a rotated token is picked up on the
    next (re)connect without a restart.
    """
    values: dict[str, str] = {}
    env_file = Settings.model_config.get("env_file", ".env")
    if env_file:
        # A blank (`FOO=` in .env) is a missing credential, not an empty one.
        for key, value in dotenv_values(Path(env_file)).items():
            if value:
                values[key] = value
    values.update(os.environ)
    return values


def resolve_secrets(
    cfg: McpServerConfig,
    values: Mapping[str, str] | None = None,
    server: str | None = None,
) -> McpServerConfig:
    """Return a deep copy of *cfg* with ``${VAR}`` resolved in header/env values.

    Only ``headers`` and ``env`` are scanned: those are the credential-bearing
    fields (``env`` is where a stdio server's key would live, as ``user_key``'s
    env branch implies).

    Raises:
        ValueError: a placeholder names a variable that is not set. We refuse to
            connect rather than drop the key — a server may answer an
            unauthenticated request as an anonymous user instead of returning
            401, so dropping silently fails open. ``McpManager.load_all``
            isolates a failing server, so this surfaces as one broken entry in
            ``/mcp/status`` rather than a dead startup.
    """
    if not cfg.headers and not cfg.env:
        return cfg

    pool = dict(values) if values is not None else env_values()
    resolved = cfg.model_copy(deep=True)
    where = f"MCP server '{server}'" if server else "MCP config"

    for field_name in ("headers", "env"):
        block = getattr(resolved, field_name)
        if not block:
            continue
        setattr(
            resolved,
            field_name,
            {
                key: _substitute(value, pool, f"{where} {field_name}.{key}")
                for key, value in block.items()
            },
        )
    return resolved


def _substitute(value: object, pool: Mapping[str, str], where: str) -> object:
    if not isinstance(value, str):
        return value

    def replace(match: re.Match[str]) -> str:
        name = match.group(1)
        if name not in pool:
            raise ValueError(
                f"{where} references ${{{name}}}, which is not set — "
                "add it to the service .env (see docs/mcp.md)"
            )
        return pool[name]

    return _PLACEHOLDER.sub(replace, value)