From f3d844fc861d3fb8c389ab89dc25a763e4cbabec Mon Sep 17 00:00:00 2001 From: Iddo Date: Tue, 21 Jul 2026 09:33:25 +0200 Subject: [PATCH 1/6] feat(config): load Infrahub credentials from .env in stdio/.mcp.json mode Prime os.environ from a .env file at the top of load_config() (before ServerConfig and any InfrahubClient() are built), so the API token can be kept out of a committed .mcp.json. Real environment variables win over the file, case-insensitively to match ServerConfig(case_sensitive=False); the path defaults to ./.env and is overridable via INFRAHUB_MCP_ENV_FILE. --- changelog/147.added.md | 1 + pyproject.toml | 1 + src/infrahub_mcp/config.py | 28 ++++++++++++++++++++ tests/unit/test_config.py | 53 +++++++++++++++++++++++++++++++++++++- uv.lock | 2 ++ 5 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 changelog/147.added.md diff --git a/changelog/147.added.md b/changelog/147.added.md new file mode 100644 index 0000000..2ddd208 --- /dev/null +++ b/changelog/147.added.md @@ -0,0 +1 @@ +Load Infrahub credentials from a `.env` file in stdio / `.mcp.json` mode, so the API token can be kept out of a committed `.mcp.json`. Real environment variables take precedence, and `INFRAHUB_MCP_ENV_FILE` overrides the default `./.env` path. diff --git a/pyproject.toml b/pyproject.toml index 17ac908..f8e095b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "infrahub-sdk>=1.22.0", "pydantic>=2.10,<3", "pydantic-settings>=2.6,<3", + "python-dotenv>=1.0,<2", "toonify>=0.1.2", ] diff --git a/src/infrahub_mcp/config.py b/src/infrahub_mcp/config.py index 941f883..fc4ddc7 100644 --- a/src/infrahub_mcp/config.py +++ b/src/infrahub_mcp/config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import string from typing import Literal @@ -177,8 +178,35 @@ def _validate_auth_requirements(config: ServerConfig) -> None: raise ValueError(msg) +def _prime_env_from_dotenv() -> None: + """Load a ``.env`` file into ``os.environ`` before config/client construction. + + The path comes from ``INFRAHUB_MCP_ENV_FILE`` read raw from the environment + (it must resolve before the :class:`ServerConfig` model exists), defaulting + to ``./.env``. A missing file is a silent no-op. Loading here means both the + MCP ``ServerConfig`` (``INFRAHUB_MCP_*``) and the Infrahub SDK client + (``INFRAHUB_*``) pick the values up, since both read ``os.environ``. + + Real environment variables always win over the file. The precedence check is + **case-insensitive**: both settings models use ``case_sensitive=False``, so a + real ``infrahub_mcp_read_only`` must not be shadowed by a ``.env`` entry + spelled ``INFRAHUB_MCP_READ_ONLY``. ``python-dotenv``'s own ``override=False`` + only compares exact spellings, which would let such a case-variant slip + through — so we apply the values ourselves, skipping any key already present + in any case. + """ + from dotenv import dotenv_values # noqa: PLC0415 + + path = os.environ.get("INFRAHUB_MCP_ENV_FILE") or ".env" + existing = {key.lower() for key in os.environ} + for key, value in dotenv_values(path).items(): + if value is not None and key.lower() not in existing: + os.environ[key] = value + + def load_config() -> ServerConfig: """Load and validate server configuration from environment variables.""" + _prime_env_from_dotenv() config = ServerConfig() _validate_auth_requirements(config) return config diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 45e6757..d3a8339 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -3,12 +3,16 @@ from __future__ import annotations import os +from typing import TYPE_CHECKING from unittest.mock import patch import pytest from pydantic import ValidationError -from infrahub_mcp.config import ServerConfig, load_config +from infrahub_mcp.config import ServerConfig, _prime_env_from_dotenv, load_config + +if TYPE_CHECKING: + from pathlib import Path class TestServerConfig: @@ -431,3 +435,50 @@ def test_auth_mode_none_ignores_oidc_fields(self) -> None: config = load_config() assert config.auth_mode == "none" assert config.oidc_config_url == "https://example.com/.well-known/openid-configuration" + + +class TestDotenv: + def test_populates_missing_var(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (tmp_path / ".env").write_text("INFRAHUB_API_TOKEN=from-dotenv\n") + monkeypatch.chdir(tmp_path) + with patch.dict(os.environ, {}, clear=True): + _prime_env_from_dotenv() + assert os.environ["INFRAHUB_API_TOKEN"] == "from-dotenv" # noqa: S105 + + def test_real_env_wins(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (tmp_path / ".env").write_text("INFRAHUB_API_TOKEN=from-dotenv\n") + monkeypatch.chdir(tmp_path) + with patch.dict(os.environ, {"INFRAHUB_API_TOKEN": "from-env"}, clear=True): + _prime_env_from_dotenv() + assert os.environ["INFRAHUB_API_TOKEN"] == "from-env" # noqa: S105 + + def test_real_env_wins_case_insensitively(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # ServerConfig uses case_sensitive=False, so a lowercase real env var must + # not be shadowed by an uppercase .env entry that resolves to the same key. + (tmp_path / ".env").write_text("INFRAHUB_API_TOKEN=from-dotenv\n") + monkeypatch.chdir(tmp_path) + with patch.dict(os.environ, {"infrahub_api_token": "from-env-lower"}, clear=True): + _prime_env_from_dotenv() + assert "INFRAHUB_API_TOKEN" not in os.environ + assert os.environ["infrahub_api_token"] == "from-env-lower" # noqa: SIM112, S105 + + def test_custom_path_honored(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + custom = tmp_path / "secrets.env" + custom.write_text("INFRAHUB_API_TOKEN=from-custom\n") + monkeypatch.chdir(tmp_path) # ensure no ./.env is picked up instead + with patch.dict(os.environ, {"INFRAHUB_MCP_ENV_FILE": str(custom)}, clear=True): + _prime_env_from_dotenv() + assert os.environ["INFRAHUB_API_TOKEN"] == "from-custom" # noqa: S105 + + def test_missing_file_is_noop(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) # empty dir, no .env + with patch.dict(os.environ, {}, clear=True): + _prime_env_from_dotenv() + assert "INFRAHUB_API_TOKEN" not in os.environ + + def test_load_config_primes_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (tmp_path / ".env").write_text("INFRAHUB_MCP_READ_ONLY=true\n") + monkeypatch.chdir(tmp_path) + with patch.dict(os.environ, {}, clear=True): + config = load_config() + assert config.read_only is True diff --git a/uv.lock b/uv.lock index df6fc05..a400b3b 100644 --- a/uv.lock +++ b/uv.lock @@ -772,6 +772,7 @@ dependencies = [ { name = "infrahub-sdk" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "python-dotenv" }, { name = "toonify" }, ] @@ -796,6 +797,7 @@ requires-dist = [ { name = "infrahub-sdk", specifier = ">=1.22.0" }, { name = "pydantic", specifier = ">=2.10,<3" }, { name = "pydantic-settings", specifier = ">=2.6,<3" }, + { name = "python-dotenv", specifier = ">=1.0,<2" }, { name = "toonify", specifier = ">=0.1.2" }, ] From 79afb1f3d04a8508f8dff01f576dc58a3fba0aac Mon Sep 17 00:00:00 2001 From: Iddo Date: Tue, 21 Jul 2026 09:33:25 +0200 Subject: [PATCH 2/6] docs: document .env credential loading for .mcp.json setups --- docs/docs/integrations/claude-code.mdx | 42 ++++++++++++++++++++++++++ docs/docs/references/configuration.mdx | 1 + 2 files changed, 43 insertions(+) diff --git a/docs/docs/integrations/claude-code.mdx b/docs/docs/integrations/claude-code.mdx index 39ee1ce..e81395f 100644 --- a/docs/docs/integrations/claude-code.mdx +++ b/docs/docs/integrations/claude-code.mdx @@ -29,6 +29,48 @@ Create `.mcp.json` at the root of the repository where you want the Infrahub ser Claude Code loads the server on the next session. Run `claude` in the project directory and you should see `infrahub` listed under **/mcp**. +## Keeping the token out of `.mcp.json` + +`.mcp.json` is usually committed to your repository, so it should not contain +secrets. Instead, keep only the non-secret `INFRAHUB_ADDRESS` in its `env:` +block and put the token in a `.env` file (kept out of version control) in the +same directory. + +`.mcp.json` (committed): + +```json +{ + "mcpServers": { + "infrahub": { + "command": "uvx", + "args": ["infrahub-mcp"], + "env": { + "INFRAHUB_ADDRESS": "http://localhost:8000" + } + } + } +} +``` + +`.env` (add it to `.gitignore`; place it in the directory Claude Code launches from): + +```bash +INFRAHUB_API_TOKEN=your-token-here +# or, instead of a token: +# INFRAHUB_USERNAME=admin +# INFRAHUB_PASSWORD=infrahub +``` + +The server loads `.env` at startup into its environment. Values already set in +the real environment (including the `.mcp.json` `env:` block) take precedence, +so `.env` never overrides an explicitly set variable. To load a file from a +different location, set `INFRAHUB_MCP_ENV_FILE=/path/to/file`. + +`.env` applies to the default stdio / `.mcp.json` setup (`auth_mode=none`). In +the HTTP `token-passthrough` and `basic-passthrough` modes the per-request +credential comes from the HTTP `Authorization` header, so `.env` there only +supplies `INFRAHUB_ADDRESS`. + ## User-scoped setup For always-on access across every project, add the same block to `~/.claude/settings.json` instead. The server is spawned once per Claude Code session. diff --git a/docs/docs/references/configuration.mdx b/docs/docs/references/configuration.mdx index f48a84d..d598579 100644 --- a/docs/docs/references/configuration.mdx +++ b/docs/docs/references/configuration.mdx @@ -10,6 +10,7 @@ The Infrahub MCP server is configured entirely through environment variables. Al | --- | --- | --- | | `INFRAHUB_ADDRESS` | URL of your Infrahub instance | **required** | | `INFRAHUB_API_TOKEN` | API token used by the MCP server to call Infrahub (not needed in `token-passthrough` auth mode) | — | +| `INFRAHUB_MCP_ENV_FILE` | Path to a `.env` file loaded into the environment at startup (real env vars win); a missing file is ignored | `./.env` | | `MCP_HOST` | Bind address for the HTTP server | `0.0.0.0` | | `MCP_PORT` | Port for the HTTP server | `8001` | From 5fda37f5690e0ac92e29209c854cc38991c92a36 Mon Sep 17 00:00:00 2001 From: Iddo Date: Fri, 24 Jul 2026 12:56:42 +0200 Subject: [PATCH 3/6] fix(config): scope .env to INFRAHUB_ keys and prime at server startup Addresses review feedback: - Copy only INFRAHUB_-prefixed keys, so a foreign project .env cannot inject LOG_LEVEL, proxy settings, or unrelated secrets into the process. - Move priming out of load_config() into the server lifespan, so importing the package never reads the filesystem or mutates os.environ (and the existing load_config tests no longer depend on a repo-root .env). - Track applied keys so case-variant duplicates within the file are not both set. - Guard the read with try/except (OSError, UnicodeDecodeError) and a warning. - Empty INFRAHUB_MCP_ENV_FILE disables loading; an explicit missing path warns. - Add an autouse test fixture disabling .env probing by default. - Reword docs to reflect INFRAHUB_-only scope and passthrough behavior. --- docs/docs/integrations/claude-code.mdx | 20 ++++---- docs/docs/references/configuration.mdx | 2 +- src/infrahub_mcp/config.py | 68 ++++++++++++++++++-------- src/infrahub_mcp/server.py | 5 +- tests/unit/conftest.py | 9 ++++ tests/unit/test_config.py | 54 ++++++++++++++++++-- 6 files changed, 122 insertions(+), 36 deletions(-) diff --git a/docs/docs/integrations/claude-code.mdx b/docs/docs/integrations/claude-code.mdx index e81395f..62ae4cb 100644 --- a/docs/docs/integrations/claude-code.mdx +++ b/docs/docs/integrations/claude-code.mdx @@ -61,15 +61,17 @@ INFRAHUB_API_TOKEN=your-token-here # INFRAHUB_PASSWORD=infrahub ``` -The server loads `.env` at startup into its environment. Values already set in -the real environment (including the `.mcp.json` `env:` block) take precedence, -so `.env` never overrides an explicitly set variable. To load a file from a -different location, set `INFRAHUB_MCP_ENV_FILE=/path/to/file`. - -`.env` applies to the default stdio / `.mcp.json` setup (`auth_mode=none`). In -the HTTP `token-passthrough` and `basic-passthrough` modes the per-request -credential comes from the HTTP `Authorization` header, so `.env` there only -supplies `INFRAHUB_ADDRESS`. +At server startup the server loads `INFRAHUB_`-prefixed variables from `.env` +into its environment (for example `INFRAHUB_ADDRESS`, `INFRAHUB_API_TOKEN`, +`INFRAHUB_USERNAME`, `INFRAHUB_PASSWORD`); keys without that prefix are ignored. +Values already set in the real environment (including the `.mcp.json` `env:` +block) take precedence, so `.env` never overrides an explicitly set variable. +Set `INFRAHUB_MCP_ENV_FILE=/path/to/file` to load a different file, or +`INFRAHUB_MCP_ENV_FILE=` (empty) to disable loading. + +In the HTTP `token-passthrough` and `basic-passthrough` modes the Infrahub +credential comes from the request `Authorization` header, so a token in `.env` +is ignored; `.env` still supplies `INFRAHUB_ADDRESS`. ## User-scoped setup diff --git a/docs/docs/references/configuration.mdx b/docs/docs/references/configuration.mdx index d598579..2705897 100644 --- a/docs/docs/references/configuration.mdx +++ b/docs/docs/references/configuration.mdx @@ -10,7 +10,7 @@ The Infrahub MCP server is configured entirely through environment variables. Al | --- | --- | --- | | `INFRAHUB_ADDRESS` | URL of your Infrahub instance | **required** | | `INFRAHUB_API_TOKEN` | API token used by the MCP server to call Infrahub (not needed in `token-passthrough` auth mode) | — | -| `INFRAHUB_MCP_ENV_FILE` | Path to a `.env` file loaded into the environment at startup (real env vars win); a missing file is ignored | `./.env` | +| `INFRAHUB_MCP_ENV_FILE` | Path to a `.env` file whose `INFRAHUB_`-prefixed keys are loaded at startup (real env vars win). A missing default file is ignored; set to empty to disable loading | `./.env` | | `MCP_HOST` | Bind address for the HTTP server | `0.0.0.0` | | `MCP_PORT` | Port for the HTTP server | `8001` | diff --git a/src/infrahub_mcp/config.py b/src/infrahub_mcp/config.py index fc4ddc7..16b3648 100644 --- a/src/infrahub_mcp/config.py +++ b/src/infrahub_mcp/config.py @@ -2,8 +2,10 @@ from __future__ import annotations +import logging import os import string +from pathlib import Path from typing import Literal from pydantic import AliasChoices, Field, field_validator @@ -14,6 +16,13 @@ AUTH_MODE_OIDC, ) +logger = logging.getLogger(__name__) + +# Only keys with this prefix are copied from a .env file — this covers the SDK's +# INFRAHUB_* connection variables (and INFRAHUB_MCP_*) while ignoring unrelated +# keys in a foreign project .env (proxies, third-party secrets, logging flags). +_DOTENV_KEY_PREFIX = "INFRAHUB_" + AuthMode = Literal["none", "oidc", "token-passthrough", "basic-passthrough"] _VALID_LOG_LEVELS = {"debug", "info", "warning", "error"} @@ -179,34 +188,53 @@ def _validate_auth_requirements(config: ServerConfig) -> None: def _prime_env_from_dotenv() -> None: - """Load a ``.env`` file into ``os.environ`` before config/client construction. - - The path comes from ``INFRAHUB_MCP_ENV_FILE`` read raw from the environment - (it must resolve before the :class:`ServerConfig` model exists), defaulting - to ``./.env``. A missing file is a silent no-op. Loading here means both the - MCP ``ServerConfig`` (``INFRAHUB_MCP_*``) and the Infrahub SDK client - (``INFRAHUB_*``) pick the values up, since both read ``os.environ``. - - Real environment variables always win over the file. The precedence check is - **case-insensitive**: both settings models use ``case_sensitive=False``, so a - real ``infrahub_mcp_read_only`` must not be shadowed by a ``.env`` entry - spelled ``INFRAHUB_MCP_READ_ONLY``. ``python-dotenv``'s own ``override=False`` - only compares exact spellings, which would let such a case-variant slip - through — so we apply the values ourselves, skipping any key already present - in any case. + """Load ``INFRAHUB_``-prefixed variables from a ``.env`` file into ``os.environ``. + + Called once at server startup (from the lifespan), never at import time, so + importing the package neither reads the filesystem nor mutates the process + environment. + + Only keys beginning with ``INFRAHUB_`` are copied, so an unrelated project + ``.env`` in the launch directory cannot inject proxy settings, third-party + secrets, or logging flags the operator never set. Real environment variables + always win over the file; the comparison is case-insensitive to match the + settings models' ``case_sensitive=False`` resolution, and applied keys are + tracked so case-variant duplicates inside the file cannot both be set. + + Path resolution via ``INFRAHUB_MCP_ENV_FILE``: + + - unset: default to ``./.env``; a missing file is a silent no-op. + - empty string: loading is disabled. + - set to a path: that file is loaded, with a warning when it does not exist. """ from dotenv import dotenv_values # noqa: PLC0415 - path = os.environ.get("INFRAHUB_MCP_ENV_FILE") or ".env" + configured = os.environ.get("INFRAHUB_MCP_ENV_FILE") + if configured is not None and not configured: + return # explicitly disabled via an empty value + path = configured or ".env" + if configured is not None and not Path(path).is_file(): + logger.warning("INFRAHUB_MCP_ENV_FILE=%r is not a readable file; no .env values loaded.", path) + return + + try: + values = dotenv_values(path) + except (OSError, UnicodeDecodeError) as exc: + logger.warning("Could not read .env file %r: %s", path, exc) + return + existing = {key.lower() for key in os.environ} - for key, value in dotenv_values(path).items(): - if value is not None and key.lower() not in existing: - os.environ[key] = value + for key, value in values.items(): + if value is None or not key.upper().startswith(_DOTENV_KEY_PREFIX): + continue + if key.lower() in existing: + continue + os.environ[key] = value + existing.add(key.lower()) def load_config() -> ServerConfig: """Load and validate server configuration from environment variables.""" - _prime_env_from_dotenv() config = ServerConfig() _validate_auth_requirements(config) return config diff --git a/src/infrahub_mcp/server.py b/src/infrahub_mcp/server.py index bb35315..c0be7f0 100644 --- a/src/infrahub_mcp/server.py +++ b/src/infrahub_mcp/server.py @@ -18,7 +18,7 @@ set_passthrough_basic, set_passthrough_token, ) -from infrahub_mcp.config import ServerConfig, load_config +from infrahub_mcp.config import ServerConfig, _prime_env_from_dotenv, load_config from infrahub_mcp.constants import ( AUTH_MODE_BASIC_PASSTHROUGH, AUTH_MODE_OIDC, @@ -72,7 +72,8 @@ def _validate_env() -> None: @asynccontextmanager async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # noqa: ARG001 - """Manage the application lifecycle: validate config, create client, yield context.""" + """Manage the application lifecycle: load .env, validate config, create client, yield context.""" + _prime_env_from_dotenv() _validate_env() client = ( None if _config.auth_mode in {AUTH_MODE_TOKEN_PASSTHROUGH, AUTH_MODE_BASIC_PASSTHROUGH} else InfrahubClient() diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 1d99d08..5fa639e 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,6 +1,15 @@ import pytest +@pytest.fixture(autouse=True) +def _disable_dotenv_loading(monkeypatch: pytest.MonkeyPatch) -> None: + """Disable ``.env`` probing by default so a developer's repo-root ``.env`` never + leaks into the test environment. Tests that exercise ``.env`` loading clear the + environment themselves (``patch.dict(..., clear=True)``) and are unaffected. + """ + monkeypatch.setenv("INFRAHUB_MCP_ENV_FILE", "") + + @pytest.fixture def locationsite_filters() -> dict[str, str]: return { diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index d3a8339..02b115b 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -476,9 +476,55 @@ def test_missing_file_is_noop(self, tmp_path: Path, monkeypatch: pytest.MonkeyPa _prime_env_from_dotenv() assert "INFRAHUB_API_TOKEN" not in os.environ - def test_load_config_primes_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - (tmp_path / ".env").write_text("INFRAHUB_MCP_READ_ONLY=true\n") + def test_ignores_non_infrahub_keys(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # A foreign project .env must not inject unrelated vars (LOG_LEVEL, proxies, secrets). + (tmp_path / ".env").write_text("LOG_LEVEL=warn\nHTTPS_PROXY=http://x\nINFRAHUB_API_TOKEN=t\n") monkeypatch.chdir(tmp_path) with patch.dict(os.environ, {}, clear=True): - config = load_config() - assert config.read_only is True + _prime_env_from_dotenv() + assert os.environ["INFRAHUB_API_TOKEN"] == "t" # noqa: S105 + assert "LOG_LEVEL" not in os.environ + assert "HTTPS_PROXY" not in os.environ + + def test_case_variant_within_file_applies_once(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Case-variant duplicates in the file must not both land in the environment. + (tmp_path / ".env").write_text("INFRAHUB_MCP_READ_ONLY=true\ninfrahub_mcp_read_only=false\n") + monkeypatch.chdir(tmp_path) + with patch.dict(os.environ, {}, clear=True): + _prime_env_from_dotenv() + assert os.environ["INFRAHUB_MCP_READ_ONLY"] == "true" + assert "infrahub_mcp_read_only" not in os.environ + + def test_empty_path_disables_loading(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (tmp_path / ".env").write_text("INFRAHUB_API_TOKEN=from-dotenv\n") + monkeypatch.chdir(tmp_path) + with patch.dict(os.environ, {"INFRAHUB_MCP_ENV_FILE": ""}, clear=True): + _prime_env_from_dotenv() + assert "INFRAHUB_API_TOKEN" not in os.environ + + def test_explicit_missing_path_warns( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + monkeypatch.chdir(tmp_path) + missing = str(tmp_path / "nope.env") + with patch.dict(os.environ, {"INFRAHUB_MCP_ENV_FILE": missing}, clear=True), caplog.at_level("WARNING"): + _prime_env_from_dotenv() + assert "INFRAHUB_API_TOKEN" not in os.environ + assert "nope.env" in caplog.text + + def test_unreadable_file_is_noop(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # A non-UTF-8 .env must not crash startup. + (tmp_path / ".env").write_bytes(b"\xff\xfeINFRAHUB_API_TOKEN=x\n") + monkeypatch.chdir(tmp_path) + with patch.dict(os.environ, {}, clear=True): + _prime_env_from_dotenv() # must not raise + assert "INFRAHUB_API_TOKEN" not in os.environ + + def test_load_config_does_not_read_dotenv(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Priming happens at server startup, not in load_config(), so importing/loading + # config never reads a CWD .env or mutates the environment. + (tmp_path / ".env").write_text("INFRAHUB_API_TOKEN=from-dotenv\n") + monkeypatch.chdir(tmp_path) + with patch.dict(os.environ, {}, clear=True): + load_config() + assert "INFRAHUB_API_TOKEN" not in os.environ From 5f0ae8319a2d197477fc4b26a790b916eab4386b Mon Sep 17 00:00:00 2001 From: Iddo Date: Fri, 24 Jul 2026 13:05:14 +0200 Subject: [PATCH 4/6] fix(config): source only Infrahub connection vars from .env, not INFRAHUB_MCP_* Priming runs at server startup, after ServerConfig is snapshotted at import, so INFRAHUB_MCP_* values in a .env would never take effect. Rather than reload config at runtime (which would let a stray .env silently change auth_mode or read_only), scope .env to the SDK connection variables (INFRAHUB_ prefix excluding INFRAHUB_MCP_) and document that MCP server settings come from the real environment or the .mcp.json env block. --- docs/docs/integrations/claude-code.mdx | 13 +++++----- docs/docs/references/configuration.mdx | 2 +- src/infrahub_mcp/config.py | 36 +++++++++++++++++--------- tests/unit/test_config.py | 17 +++++++++--- 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/docs/docs/integrations/claude-code.mdx b/docs/docs/integrations/claude-code.mdx index 62ae4cb..e02424e 100644 --- a/docs/docs/integrations/claude-code.mdx +++ b/docs/docs/integrations/claude-code.mdx @@ -61,12 +61,13 @@ INFRAHUB_API_TOKEN=your-token-here # INFRAHUB_PASSWORD=infrahub ``` -At server startup the server loads `INFRAHUB_`-prefixed variables from `.env` -into its environment (for example `INFRAHUB_ADDRESS`, `INFRAHUB_API_TOKEN`, -`INFRAHUB_USERNAME`, `INFRAHUB_PASSWORD`); keys without that prefix are ignored. -Values already set in the real environment (including the `.mcp.json` `env:` -block) take precedence, so `.env` never overrides an explicitly set variable. -Set `INFRAHUB_MCP_ENV_FILE=/path/to/file` to load a different file, or +At server startup the server loads the Infrahub connection variables from `.env` +into its environment: `INFRAHUB_ADDRESS`, `INFRAHUB_API_TOKEN`, +`INFRAHUB_USERNAME`, and `INFRAHUB_PASSWORD`. Other keys are ignored, including +`INFRAHUB_MCP_*` server settings (set those in the real environment or the +`.mcp.json` `env:` block). Values already set in the real environment take +precedence, so `.env` never overrides an explicitly set variable. Set +`INFRAHUB_MCP_ENV_FILE=/path/to/file` to load a different file, or `INFRAHUB_MCP_ENV_FILE=` (empty) to disable loading. In the HTTP `token-passthrough` and `basic-passthrough` modes the Infrahub diff --git a/docs/docs/references/configuration.mdx b/docs/docs/references/configuration.mdx index 2705897..904ff6e 100644 --- a/docs/docs/references/configuration.mdx +++ b/docs/docs/references/configuration.mdx @@ -10,7 +10,7 @@ The Infrahub MCP server is configured entirely through environment variables. Al | --- | --- | --- | | `INFRAHUB_ADDRESS` | URL of your Infrahub instance | **required** | | `INFRAHUB_API_TOKEN` | API token used by the MCP server to call Infrahub (not needed in `token-passthrough` auth mode) | — | -| `INFRAHUB_MCP_ENV_FILE` | Path to a `.env` file whose `INFRAHUB_`-prefixed keys are loaded at startup (real env vars win). A missing default file is ignored; set to empty to disable loading | `./.env` | +| `INFRAHUB_MCP_ENV_FILE` | Path to a `.env` file whose Infrahub connection keys (`INFRAHUB_ADDRESS`/`INFRAHUB_API_TOKEN`/`INFRAHUB_USERNAME`/`INFRAHUB_PASSWORD`, not `INFRAHUB_MCP_*`) are loaded at startup (real env vars win). A missing default file is ignored; set to empty to disable loading | `./.env` | | `MCP_HOST` | Bind address for the HTTP server | `0.0.0.0` | | `MCP_PORT` | Port for the HTTP server | `8001` | diff --git a/src/infrahub_mcp/config.py b/src/infrahub_mcp/config.py index 16b3648..58f543c 100644 --- a/src/infrahub_mcp/config.py +++ b/src/infrahub_mcp/config.py @@ -18,10 +18,15 @@ logger = logging.getLogger(__name__) -# Only keys with this prefix are copied from a .env file — this covers the SDK's -# INFRAHUB_* connection variables (and INFRAHUB_MCP_*) while ignoring unrelated -# keys in a foreign project .env (proxies, third-party secrets, logging flags). -_DOTENV_KEY_PREFIX = "INFRAHUB_" +# A .env file supplies only the Infrahub SDK connection variables: keys with the +# INFRAHUB_ prefix but NOT the INFRAHUB_MCP_ prefix. This ignores unrelated keys +# in a foreign project .env (proxies, third-party secrets, logging flags), and +# deliberately excludes INFRAHUB_MCP_* server settings: ServerConfig is read once +# at import, before .env priming runs, so loading those keys would set values that +# never take effect. MCP server settings must come from the real environment or +# the .mcp.json "env" block. +_DOTENV_INCLUDE_PREFIX = "INFRAHUB_" +_DOTENV_EXCLUDE_PREFIX = "INFRAHUB_MCP_" AuthMode = Literal["none", "oidc", "token-passthrough", "basic-passthrough"] @@ -188,18 +193,22 @@ def _validate_auth_requirements(config: ServerConfig) -> None: def _prime_env_from_dotenv() -> None: - """Load ``INFRAHUB_``-prefixed variables from a ``.env`` file into ``os.environ``. + """Load Infrahub connection variables from a ``.env`` file into ``os.environ``. Called once at server startup (from the lifespan), never at import time, so importing the package neither reads the filesystem nor mutates the process environment. - Only keys beginning with ``INFRAHUB_`` are copied, so an unrelated project - ``.env`` in the launch directory cannot inject proxy settings, third-party - secrets, or logging flags the operator never set. Real environment variables - always win over the file; the comparison is case-insensitive to match the - settings models' ``case_sensitive=False`` resolution, and applied keys are - tracked so case-variant duplicates inside the file cannot both be set. + Only the Infrahub SDK connection variables are copied: keys with the + ``INFRAHUB_`` prefix excluding ``INFRAHUB_MCP_`` (``INFRAHUB_ADDRESS``, + ``INFRAHUB_API_TOKEN``, ``INFRAHUB_USERNAME``, ``INFRAHUB_PASSWORD``). An + unrelated project ``.env`` therefore cannot inject proxy settings, third-party + secrets, or logging flags, and ``INFRAHUB_MCP_*`` server settings are not + sourced from ``.env`` (``ServerConfig`` is built at import, before this runs, + so they would never take effect). Real environment variables always win over + the file; the comparison is case-insensitive to match the settings models' + ``case_sensitive=False`` resolution, and applied keys are tracked so + case-variant duplicates inside the file cannot both be set. Path resolution via ``INFRAHUB_MCP_ENV_FILE``: @@ -225,7 +234,10 @@ def _prime_env_from_dotenv() -> None: existing = {key.lower() for key in os.environ} for key, value in values.items(): - if value is None or not key.upper().startswith(_DOTENV_KEY_PREFIX): + key_upper = key.upper() + if value is None or not key_upper.startswith(_DOTENV_INCLUDE_PREFIX): + continue + if key_upper.startswith(_DOTENV_EXCLUDE_PREFIX): continue if key.lower() in existing: continue diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 02b115b..c4a5183 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -486,14 +486,25 @@ def test_ignores_non_infrahub_keys(self, tmp_path: Path, monkeypatch: pytest.Mon assert "LOG_LEVEL" not in os.environ assert "HTTPS_PROXY" not in os.environ + def test_ignores_mcp_settings(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # INFRAHUB_MCP_* server settings are NOT sourced from .env: ServerConfig is built + # at import before priming, so loading them would set values that never take effect. + (tmp_path / ".env").write_text("INFRAHUB_MCP_READ_ONLY=true\nINFRAHUB_MCP_AUTH_MODE=token-passthrough\nINFRAHUB_API_TOKEN=t\n") + monkeypatch.chdir(tmp_path) + with patch.dict(os.environ, {}, clear=True): + _prime_env_from_dotenv() + assert os.environ["INFRAHUB_API_TOKEN"] == "t" # noqa: S105 + assert "INFRAHUB_MCP_READ_ONLY" not in os.environ + assert "INFRAHUB_MCP_AUTH_MODE" not in os.environ + def test_case_variant_within_file_applies_once(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # Case-variant duplicates in the file must not both land in the environment. - (tmp_path / ".env").write_text("INFRAHUB_MCP_READ_ONLY=true\ninfrahub_mcp_read_only=false\n") + (tmp_path / ".env").write_text("INFRAHUB_API_TOKEN=first\ninfrahub_api_token=second\n") monkeypatch.chdir(tmp_path) with patch.dict(os.environ, {}, clear=True): _prime_env_from_dotenv() - assert os.environ["INFRAHUB_MCP_READ_ONLY"] == "true" - assert "infrahub_mcp_read_only" not in os.environ + assert os.environ["INFRAHUB_API_TOKEN"] == "first" # noqa: S105 + assert "infrahub_api_token" not in os.environ def test_empty_path_disables_loading(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: (tmp_path / ".env").write_text("INFRAHUB_API_TOKEN=from-dotenv\n") From 094d24bf62470286ae7e9edb8e82ef06bc7ef2cc Mon Sep 17 00:00:00 2001 From: Iddo Date: Fri, 24 Jul 2026 13:06:50 +0200 Subject: [PATCH 5/6] style: apply ruff format to test_config --- tests/unit/test_config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index c4a5183..7049ffc 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -489,7 +489,9 @@ def test_ignores_non_infrahub_keys(self, tmp_path: Path, monkeypatch: pytest.Mon def test_ignores_mcp_settings(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # INFRAHUB_MCP_* server settings are NOT sourced from .env: ServerConfig is built # at import before priming, so loading them would set values that never take effect. - (tmp_path / ".env").write_text("INFRAHUB_MCP_READ_ONLY=true\nINFRAHUB_MCP_AUTH_MODE=token-passthrough\nINFRAHUB_API_TOKEN=t\n") + (tmp_path / ".env").write_text( + "INFRAHUB_MCP_READ_ONLY=true\nINFRAHUB_MCP_AUTH_MODE=token-passthrough\nINFRAHUB_API_TOKEN=t\n" + ) monkeypatch.chdir(tmp_path) with patch.dict(os.environ, {}, clear=True): _prime_env_from_dotenv() From 23cbb75f1b9cfd7c4a545b00300137cade81a17c Mon Sep 17 00:00:00 2001 From: Iddo Date: Fri, 24 Jul 2026 13:23:50 +0200 Subject: [PATCH 6/6] chore: re-trigger Cloudflare Pages build (transient build failure; docs build clean locally and in CI)