"""Unit tests for `${VAR}` credential resolution in MCP server configs."""
import pytest
from navi.mcp.config import McpServerConfig
from navi.mcp.secrets import env_values, resolve_secrets
def _http(authorization: str) -> McpServerConfig:
return McpServerConfig(
transport="streamable_http",
url="https://creds.example.com/mcp",
headers={"Authorization": authorization},
)
class TestSubstitution:
def test_header_placeholder_is_resolved(self):
resolved = resolve_secrets(
_http("Bearer ${NAVI_TEST_TOKEN}"), {"NAVI_TEST_TOKEN": "s3cret"}, server="demo"
)
assert resolved.headers["Authorization"] == "Bearer s3cret"
def test_stdio_env_placeholder_is_resolved(self):
cfg = McpServerConfig(
transport="stdio", command="python", env={"API_KEY": "${NAVI_TEST_KEY}"}
)
assert resolve_secrets(cfg, {"NAVI_TEST_KEY": "abc"}).env["API_KEY"] == "abc"
def test_several_placeholders_in_one_value(self):
cfg = McpServerConfig(transport="stdio", command="python", env={"PAIR": "${A}:${B}"})
assert resolve_secrets(cfg, {"A": "1", "B": "2"}).env["PAIR"] == "1:2"
def test_bare_dollar_names_are_left_alone(self):
"""Header values legitimately contain `$` (nginx-style) — only the
braced form opts in."""
resolved = resolve_secrets(_http("Bearer $NAVI_TEST_TOKEN"), {"NAVI_TEST_TOKEN": "x"})
assert resolved.headers["Authorization"] == "Bearer $NAVI_TEST_TOKEN"
def test_only_headers_and_env_are_scanned(self):
cfg = McpServerConfig(
transport="streamable_http", url="https://${HOST}/mcp", command="${CMD}"
)
resolved = resolve_secrets(cfg, {"HOST": "creds.example.com", "CMD": "python"})
assert resolved.url == "https://${HOST}/mcp"
assert resolved.command == "${CMD}"
def test_values_without_placeholders_are_untouched(self):
cfg = McpServerConfig(
transport="stdio",
command="python",
env={"FLAG": "a/b", "SESSION_FILES_DIR": "./session_files"},
)
assert resolve_secrets(cfg, {}).env == {
"FLAG": "a/b",
"SESSION_FILES_DIR": "./session_files",
}
def test_does_not_mutate_the_original(self):
"""`save_mcp_servers` writes configs back to the file: a resolved copy
leaking into it would bake the credential into a tracked file."""
cfg = _http("Bearer ${NAVI_TEST_TOKEN}")
resolve_secrets(cfg, {"NAVI_TEST_TOKEN": "s3cret"})
assert cfg.headers["Authorization"] == "Bearer ${NAVI_TEST_TOKEN}"
def test_resolution_defaults_to_the_environment(self, monkeypatch):
monkeypatch.setenv("NAVI_TEST_FROM_PROCESS", "from-process")
resolved = resolve_secrets(_http("Bearer ${NAVI_TEST_FROM_PROCESS}"))
assert resolved.headers["Authorization"] == "Bearer from-process"
class TestMissingVariable:
"""A missing value fails loudly instead of dropping the header: a server may
answer an unauthenticated request as an anonymous user rather than 401."""
def test_raises_naming_the_server_and_the_variable(self):
with pytest.raises(ValueError) as excinfo:
resolve_secrets(
_http("Bearer ${NAVI_MCP_GNTODO_TOKEN}"), {}, server="gntodo"
)
message = str(excinfo.value)
assert "gntodo" in message
assert "NAVI_MCP_GNTODO_TOKEN" in message
def test_names_the_field_that_references_it(self):
with pytest.raises(ValueError) as excinfo:
resolve_secrets(_http("${NAVI_TEST_MISSING}"), {}, server="demo")
assert "headers.Authorization" in str(excinfo.value)
class TestEnvValues:
def test_process_environment_beats_the_env_file(self, monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
(tmp_path / ".env").write_text("NAVI_TEST_PREC=from-file\n")
monkeypatch.setenv("NAVI_TEST_PREC", "from-env")
assert env_values()["NAVI_TEST_PREC"] == "from-env"
def test_blank_value_in_the_env_file_is_treated_as_missing(self, monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
(tmp_path / ".env").write_text("NAVI_TEST_BLANK=\nNAVI_TEST_FILLED=ok\n")
monkeypatch.delenv("NAVI_TEST_BLANK", raising=False)
values = env_values()
assert "NAVI_TEST_BLANK" not in values
assert values["NAVI_TEST_FILLED"] == "ok"
def test_missing_env_file_falls_back_to_the_process_environment(self, monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path) # no .env here
monkeypatch.setenv("NAVI_TEST_ONLY_ENV", "v")
assert env_values()["NAVI_TEST_ONLY_ENV"] == "v"