"""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)