Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/147.added.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 45 additions & 0 deletions docs/docs/integrations/claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,51 @@ 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
```

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
credential comes from the request `Authorization` header, so a token in `.env`
is ignored; `.env` still 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.
Expand Down
1 change: 1 addition & 0 deletions docs/docs/references/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 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` |

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
68 changes: 68 additions & 0 deletions src/infrahub_mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +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
Expand All @@ -13,6 +16,18 @@
AUTH_MODE_OIDC,
)

logger = logging.getLogger(__name__)

# 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"]

_VALID_LOG_LEVELS = {"debug", "info", "warning", "error"}
Expand Down Expand Up @@ -177,6 +192,59 @@ def _validate_auth_requirements(config: ServerConfig) -> None:
raise ValueError(msg)


def _prime_env_from_dotenv() -> None:
"""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 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``:

- 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

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}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[review] existing snapshot is not updated inside the loop — the documented case-insensitive invariant breaks within the file itself.

A .env containing case-variant duplicates (INFRAHUB_MCP_READ_ONLY=true + infrahub_mcp_read_only=false) injects both spellings into os.environ; which one pydantic-settings resolves is iteration-order dependent. Verified locally: read_only came out False even though true appeared first in the file.

Add applied keys back to the set (existing.add(key.lower())) as they are written.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5fda37f: applied keys are added to the existing set inside the loop, so case-variant duplicates within the file are no longer both set. Added a regression test.

for key, value in values.items():
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
os.environ[key] = value
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
existing.add(key.lower())


def load_config() -> ServerConfig:
"""Load and validate server configuration from environment variables."""
config = ServerConfig()
Expand Down
5 changes: 3 additions & 2 deletions src/infrahub_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
112 changes: 111 additions & 1 deletion tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -431,3 +435,109 @@ 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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[review] Existing TestLoadConfig tests now depend on an untracked repo-root .env.

The ~25 pre-existing TestLoadConfig tests call load_config() without monkeypatch.chdir, so they now read ./.env from the pytest CWD (the repo root). The docs added in this PR tell developers to create exactly that file; one containing INFRAHUB_MCP_READ_ONLY=true makes test_defaults_no_env fail, and LOG_LEVEL=warn makes every load_config() call in the suite raise — failing locally while CI stays green.

Suggest an autouse fixture in this module (or conftest) that points INFRAHUB_MCP_ENV_FILE at a nonexistent path (or chdirs to tmp_path) for the non-dotenv tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5fda37f: priming was removed from load_config(), so these tests no longer read any .env. An autouse fixture in conftest.py also disables .env probing by default.

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_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):
_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_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_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_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")
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
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading