infra-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build-and-publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write # required for trusted publishing (OIDC)
13
+ contents: read # required for actions/checkout on private repos
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.11"
20
+
21
+ - name: Build distributions
22
+ run: |
23
+ python -m pip install --upgrade build
24
+ python -m build
25
+
26
+ - name: Check distributions
27
+ run: |
28
+ python -m pip install --upgrade twine
29
+ twine check dist/*
30
+
31
+ - name: Publish to PyPI
32
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,24 @@
1
+ docs/
2
+ specs/
3
+ CLAUDE.md
4
+ SKILL.md
5
+ __pycache__/
6
+ *.pyc
7
+ .venv/
8
+ venv/
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ .pytest_cache/
13
+ .ruff_cache/
14
+ *.log
15
+ .env*
16
+ .DS_Store
17
+ Thumbs.db
18
+ infra-mcp.yaml
19
+ infra-mcp.discovered.yaml
20
+ !infra-mcp.yaml.example
21
+ .claude/
22
+ .idea/
23
+ .specify/
24
+ _demo.py
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: infra-mcp
3
+ Version: 0.1.0
4
+ Summary: Local stdio MCP server for read-only diagnosis of on-prem Linux VMs and PostgreSQL databases
5
+ Project-URL: Homepage, https://github.com/esp4ce/infra-mcp
6
+ Project-URL: Repository, https://github.com/esp4ce/infra-mcp
7
+ Author: esp4ce
8
+ License: MIT
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: keyring>=24.0
11
+ Requires-Dist: mcp[cli]<2.0,>=1.28
12
+ Requires-Dist: paramiko<4,>=3.0
13
+ Requires-Dist: psycopg2-binary>=2.9
14
+ Requires-Dist: pydantic>=2.11
15
+ Requires-Dist: pyyaml>=6.0
16
+ Requires-Dist: sshtunnel>=0.4
17
+ Requires-Dist: typer>=0.12
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
20
+ Requires-Dist: ruff>=0.6; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # infra-mcp
24
+
25
+ A local **stdio MCP server** that gives an AI agent read-only visibility into
26
+ on-premise Linux VMs (SSH + journald) and PostgreSQL databases. The agent can
27
+ diagnose service failures, retrieve bounded logs, and check DB health without any
28
+ user terminal interaction.
29
+
30
+ v0.1 is deliberately **read-only**. Every remote operation is gated by:
31
+
32
+ - an **SSH command/service allowlist** (checked before any network call),
33
+ - a **SQL `SELECT` guard** + `READ ONLY` transaction,
34
+ - a **directory allowlist** for log-file access (with `..` traversal blocked),
35
+
36
+ and every executed remote command is written to an append-only **audit log**.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ uv tool install infra-mcp
42
+ # or from source:
43
+ uv tool install -e /path/to/infra-probe
44
+ ```
45
+
46
+ (`pip install infra-mcp` also works.)
47
+
48
+ ## Configure
49
+
50
+ Copy [`infra-mcp.yaml.example`](infra-mcp.yaml.example) to
51
+ `~/.infra-mcp/infra-mcp.yaml` and edit it. Override the path with `--config` or
52
+ the `INFRA_MCP_CONFIG` environment variable.
53
+
54
+ Generate a starter config from your `~/.ssh/config`:
55
+
56
+ ```bash
57
+ infra-mcp generate-config -o ~/.infra-mcp/infra-mcp.yaml
58
+ ```
59
+
60
+ Create the read-only PostgreSQL role(s) (admin password is prompted, never stored):
61
+
62
+ ```bash
63
+ infra-mcp setup
64
+ ```
65
+
66
+ Check VM reachability:
67
+
68
+ ```bash
69
+ infra-mcp test
70
+ ```
71
+
72
+ ## Run
73
+
74
+ ```bash
75
+ infra-mcp run
76
+ # or: python -m infra_mcp run
77
+ ```
78
+
79
+ Register it with your MCP client (Claude Code, Cursor, …) as a **stdio** server
80
+ whose command is `infra-mcp run`.
81
+
82
+ ## Tools
83
+
84
+ | Tool | Purpose |
85
+ |------|---------|
86
+ | `list_vms` | All VMs with reachability + watched services (no IPs) |
87
+ | `get_infra_overview` | Service states + DB health for one VM in a single call |
88
+ | `get_service_status` | systemd state, uptime, last 5 log lines |
89
+ | `get_service_logs` | Bounded journald logs, filtered by severity |
90
+ | `get_log_file` | Last N lines of an allowed log file, optional grep |
91
+ | `get_db_status` | Connection counts, waiting locks, long-running query count |
92
+ | `query_db` | Bounded caller-supplied `SELECT` |
93
+ | `get_audit_log` | Recent entries from the local audit log |
94
+
95
+ All output is bounded at the source (hard cap 200 log lines, 100 DB rows) and
96
+ returned as plain text / compact TSV.
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ uv pip install -e ".[dev]"
102
+ pytest
103
+ ruff check .
104
+ ```
105
+
106
+ Tests cover output bounding, the SQL guard, and the path/command allowlists — no
107
+ live VM or database required.
@@ -0,0 +1,85 @@
1
+ # infra-mcp
2
+
3
+ A local **stdio MCP server** that gives an AI agent read-only visibility into
4
+ on-premise Linux VMs (SSH + journald) and PostgreSQL databases. The agent can
5
+ diagnose service failures, retrieve bounded logs, and check DB health without any
6
+ user terminal interaction.
7
+
8
+ v0.1 is deliberately **read-only**. Every remote operation is gated by:
9
+
10
+ - an **SSH command/service allowlist** (checked before any network call),
11
+ - a **SQL `SELECT` guard** + `READ ONLY` transaction,
12
+ - a **directory allowlist** for log-file access (with `..` traversal blocked),
13
+
14
+ and every executed remote command is written to an append-only **audit log**.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ uv tool install infra-mcp
20
+ # or from source:
21
+ uv tool install -e /path/to/infra-probe
22
+ ```
23
+
24
+ (`pip install infra-mcp` also works.)
25
+
26
+ ## Configure
27
+
28
+ Copy [`infra-mcp.yaml.example`](infra-mcp.yaml.example) to
29
+ `~/.infra-mcp/infra-mcp.yaml` and edit it. Override the path with `--config` or
30
+ the `INFRA_MCP_CONFIG` environment variable.
31
+
32
+ Generate a starter config from your `~/.ssh/config`:
33
+
34
+ ```bash
35
+ infra-mcp generate-config -o ~/.infra-mcp/infra-mcp.yaml
36
+ ```
37
+
38
+ Create the read-only PostgreSQL role(s) (admin password is prompted, never stored):
39
+
40
+ ```bash
41
+ infra-mcp setup
42
+ ```
43
+
44
+ Check VM reachability:
45
+
46
+ ```bash
47
+ infra-mcp test
48
+ ```
49
+
50
+ ## Run
51
+
52
+ ```bash
53
+ infra-mcp run
54
+ # or: python -m infra_mcp run
55
+ ```
56
+
57
+ Register it with your MCP client (Claude Code, Cursor, …) as a **stdio** server
58
+ whose command is `infra-mcp run`.
59
+
60
+ ## Tools
61
+
62
+ | Tool | Purpose |
63
+ |------|---------|
64
+ | `list_vms` | All VMs with reachability + watched services (no IPs) |
65
+ | `get_infra_overview` | Service states + DB health for one VM in a single call |
66
+ | `get_service_status` | systemd state, uptime, last 5 log lines |
67
+ | `get_service_logs` | Bounded journald logs, filtered by severity |
68
+ | `get_log_file` | Last N lines of an allowed log file, optional grep |
69
+ | `get_db_status` | Connection counts, waiting locks, long-running query count |
70
+ | `query_db` | Bounded caller-supplied `SELECT` |
71
+ | `get_audit_log` | Recent entries from the local audit log |
72
+
73
+ All output is bounded at the source (hard cap 200 log lines, 100 DB rows) and
74
+ returned as plain text / compact TSV.
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ uv pip install -e ".[dev]"
80
+ pytest
81
+ ruff check .
82
+ ```
83
+
84
+ Tests cover output bounding, the SQL guard, and the path/command allowlists — no
85
+ live VM or database required.
@@ -0,0 +1,45 @@
1
+ # infra-mcp configuration — single source of truth (plain text).
2
+ # Copy to ~/.infra-mcp/infra-mcp.yaml and edit. Manage file permissions yourself
3
+ # (e.g. chmod 600). Override the location with the INFRA_MCP_CONFIG env var or
4
+ # the --config / -c CLI flag.
5
+
6
+ # Local audit log location (append-only JSONL). Optional.
7
+ audit_log_path: ~/.infra-mcp/audit.jsonl
8
+
9
+ # Application log level (NOT the audit log). Optional. Default: WARNING.
10
+ log_level: WARNING
11
+
12
+ # At least one VM is required.
13
+ vms:
14
+ - name: vm-med-files # logical name, lowercase-kebab, starts with a letter
15
+ host: 192.168.1.10 # SSH hostname or IP (never exposed to the agent)
16
+ user: deploy # SSH username
17
+ # SSH auth: provide key_path OR password (at least one).
18
+ key_path: ~/.ssh/id_rsa # path to SSH private key
19
+ # password: "secret" # plain-text SSH password (alternative to key_path)
20
+ # Per-VM known_hosts override. Optional; falls back to ~/.ssh/known_hosts.
21
+ known_hosts_file: ~/.ssh/known_hosts
22
+
23
+ # Watched systemd units. This list is the ALLOWLIST for service tool calls —
24
+ # a service not listed here is rejected before any SSH connection is made.
25
+ services:
26
+ - karaf
27
+ - haproxy
28
+
29
+ # Allowed directories for get_log_file. Must be ABSOLUTE paths. A requested
30
+ # path (after `..` normalization) must resolve inside one of these.
31
+ log_dirs:
32
+ - /var/log/karaf
33
+ - /var/log/haproxy
34
+
35
+ # PostgreSQL databases on this VM (connection is local to the VM).
36
+ databases:
37
+ - name: prod-db # logical name, globally unique across all VMs
38
+ db_name: medfiles # actual PostgreSQL database name
39
+ user: infra_readonly # read-only role (created by `infra-mcp setup`)
40
+ # Read-only role password. Use the sentinel "keyring" to load it from the
41
+ # system keychain instead (stored under service "infra-mcp", key = name).
42
+ password: "keyring"
43
+ host: localhost # optional, default localhost
44
+ port: 5432 # optional, default 5432
45
+ slow_query_ms: 1000 # optional, default 1000; threshold for "long-running"
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "infra-mcp"
7
+ version = "0.1.0"
8
+ description = "Local stdio MCP server for read-only diagnosis of on-prem Linux VMs and PostgreSQL databases"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "esp4ce" }]
13
+ dependencies = [
14
+ "mcp[cli]>=1.28,<2.0",
15
+ "paramiko>=3.0,<4", # sshtunnel 0.4 (unmaintained) references paramiko.DSSKey, removed in paramiko 4
16
+ "psycopg2-binary>=2.9",
17
+ "sshtunnel>=0.4",
18
+ "pydantic>=2.11",
19
+ "pyyaml>=6.0",
20
+ "typer>=0.12",
21
+ "keyring>=24.0",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/esp4ce/infra-mcp"
26
+ Repository = "https://github.com/esp4ce/infra-mcp"
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=8.0", "ruff>=0.6"]
30
+
31
+ [project.scripts]
32
+ infra-mcp = "infra_mcp.cli:app"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/infra_mcp"]
36
+
37
+ [tool.ruff]
38
+ line-length = 100
39
+ target-version = "py311"
40
+
41
+ [tool.pytest.ini_options]
42
+ pythonpath = ["src"]
43
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """infra-mcp: read-only MCP server for on-prem VM and PostgreSQL diagnosis."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Enable `python -m infra_mcp`."""
2
+
3
+ from infra_mcp.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,28 @@
1
+ """Append-only JSONL audit writer. One line per remote command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+
10
+ def _now_iso() -> str:
11
+ """ISO-8601 UTC timestamp, e.g. 2026-06-17T14:22:01Z."""
12
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
13
+
14
+
15
+ def log_command(audit_log_path: Path, target: str, cmd: str, exit_code: int) -> None:
16
+ """Append one audit entry. Creates the parent directory if needed."""
17
+ audit_log_path.parent.mkdir(parents=True, exist_ok=True)
18
+ entry = {"ts": _now_iso(), "target": target, "cmd": cmd, "exit_code": exit_code}
19
+ with audit_log_path.open("a", encoding="utf-8") as f:
20
+ f.write(json.dumps(entry, separators=(",", ":")) + "\n")
21
+
22
+
23
+ def read_tail(audit_log_path: Path, lines: int) -> list[str]:
24
+ """Return the last `lines` audit entries, oldest first. Empty if no log yet."""
25
+ if not audit_log_path.exists():
26
+ return []
27
+ all_lines = audit_log_path.read_text(encoding="utf-8").splitlines()
28
+ return [ln for ln in all_lines if ln.strip()][-lines:]
@@ -0,0 +1,126 @@
1
+ """Typer CLI: run / setup / generate-config / test subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+
12
+ from infra_mcp.config import load_config
13
+ from infra_mcp.errors import InfraMcpError
14
+
15
+ app = typer.Typer(add_completion=False, help="Read-only infra diagnosis MCP server.")
16
+
17
+ _ConfigOpt = typer.Option(None, "--config", "-c", help="Path to infra-mcp.yaml")
18
+
19
+
20
+ @app.command()
21
+ def run(config: Optional[Path] = _ConfigOpt) -> None:
22
+ """Start the stdio MCP server."""
23
+ from infra_mcp import server
24
+
25
+ try:
26
+ server.init_config(config)
27
+ except InfraMcpError as e:
28
+ typer.echo(f"ERROR: {e}", err=True)
29
+ raise typer.Exit(1) from e
30
+ server.mcp.run(transport="stdio")
31
+
32
+
33
+ @app.command()
34
+ def setup(config: Optional[Path] = _ConfigOpt) -> None:
35
+ """Scan VMs, detect PostgreSQL, and create read-only roles. Idempotent."""
36
+ from infra_mcp import setup as setup_mod
37
+
38
+ try:
39
+ cfg = load_config(config)
40
+ except InfraMcpError as e:
41
+ typer.echo(f"ERROR: {e}", err=True)
42
+ raise typer.Exit(1) from e
43
+ results = setup_mod.run_setup(cfg)
44
+ for name, ok, msg in results:
45
+ mark = "OK " if ok else "FAIL"
46
+ typer.echo(f"[{mark}] {name}: {msg}")
47
+ if any(not ok for _, ok, _ in results):
48
+ raise typer.Exit(1)
49
+
50
+
51
+ @app.command(name="generate-config")
52
+ def generate_config(
53
+ output: Optional[Path] = typer.Option(
54
+ None, "--output", "-o", help="Write to this path instead of stdout"
55
+ ),
56
+ ) -> None:
57
+ """Discover VMs from ~/.ssh/config and write a starter infra-mcp.yaml."""
58
+ from infra_mcp import setup as setup_mod
59
+
60
+ yaml_text = setup_mod.generate_config()
61
+ if output is None:
62
+ typer.echo(yaml_text)
63
+ else:
64
+ output.expanduser().parent.mkdir(parents=True, exist_ok=True)
65
+ output.expanduser().write_text(yaml_text, encoding="utf-8")
66
+ typer.echo(f"Wrote {output}")
67
+
68
+
69
+ @app.command()
70
+ def discover(
71
+ config: Optional[Path] = _ConfigOpt,
72
+ output: Optional[Path] = typer.Option(
73
+ None, "--output", "-o", help="Write enriched YAML here (default: stdout)"
74
+ ),
75
+ in_place: bool = typer.Option(
76
+ False, "--in-place", "-i", help="Overwrite the loaded config file in place"
77
+ ),
78
+ ) -> None:
79
+ """Connect to each configured VM and refresh services / log_dirs / databases."""
80
+ from infra_mcp import setup as setup_mod
81
+
82
+ try:
83
+ cfg = load_config(config)
84
+ except InfraMcpError as e:
85
+ typer.echo(f"ERROR: {e}", err=True)
86
+ raise typer.Exit(1) from e
87
+ yaml_text = setup_mod.discover_config(cfg)
88
+ target = config if (in_place and config) else output
89
+ if target is None:
90
+ typer.echo(yaml_text)
91
+ else:
92
+ target.expanduser().parent.mkdir(parents=True, exist_ok=True)
93
+ target.expanduser().write_text(yaml_text, encoding="utf-8")
94
+ typer.echo(f"Wrote {target}")
95
+
96
+
97
+ @app.command()
98
+ def test(config: Optional[Path] = _ConfigOpt) -> None:
99
+ """Attempt an SSH reachability check against each configured VM."""
100
+ from infra_mcp import ssh
101
+
102
+ try:
103
+ cfg = load_config(config)
104
+ except InfraMcpError as e:
105
+ typer.echo(f"ERROR: {e}", err=True)
106
+ raise typer.Exit(1) from e
107
+ any_unreachable = False
108
+ for vm in cfg.vms:
109
+ start = time.monotonic()
110
+ reachable = ssh.is_reachable(vm)
111
+ latency_ms = int((time.monotonic() - start) * 1000)
112
+ if reachable:
113
+ typer.echo(f"[OK ] {vm.name}: reachable ({latency_ms} ms)")
114
+ else:
115
+ any_unreachable = True
116
+ typer.echo(f"[FAIL] {vm.name}: unreachable")
117
+ if any_unreachable:
118
+ raise typer.Exit(1)
119
+
120
+
121
+ def main() -> None:
122
+ app()
123
+
124
+
125
+ if __name__ == "__main__":
126
+ sys.exit(app())
@@ -0,0 +1,153 @@
1
+ """Pydantic config models for infra-mcp.yaml + fail-fast load_config()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+ from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
10
+
11
+ from infra_mcp.errors import ConfigError
12
+
13
+ _NAME_PATTERN = r"^[a-z][a-z0-9-]*$"
14
+ _KEYRING_SENTINEL = "keyring"
15
+
16
+
17
+ class DatabaseConfig(BaseModel):
18
+ """One PostgreSQL database, embedded under its VM."""
19
+
20
+ name: str = Field(pattern=_NAME_PATTERN)
21
+ db_name: str
22
+ user: str
23
+ password: str
24
+ host: str = "localhost"
25
+ port: int = 5432
26
+ slow_query_ms: int = 1000
27
+
28
+ # SSH coordinates of the parent VM, stamped at load time by VMConfig so DB
29
+ # tools can open an ephemeral tunnel without a separate persistent connection.
30
+ ssh_host: str | None = None
31
+ ssh_user: str | None = None
32
+ ssh_password: str | None = None
33
+ ssh_key_path: Path | None = None
34
+ ssh_known_hosts_file: Path | None = None
35
+
36
+ @field_validator("slow_query_ms")
37
+ @classmethod
38
+ def _positive_threshold(cls, v: int) -> int:
39
+ if v <= 0:
40
+ raise ValueError("slow_query_ms must be > 0")
41
+ return v
42
+
43
+
44
+ class VMConfig(BaseModel):
45
+ """One logical VM entry."""
46
+
47
+ name: str = Field(pattern=_NAME_PATTERN)
48
+ host: str
49
+ user: str
50
+ key_path: Path | None = None
51
+ password: str | None = None
52
+ known_hosts_file: Path | None = None
53
+
54
+ @model_validator(mode="after")
55
+ def _require_auth(self) -> "VMConfig":
56
+ if self.key_path is None and self.password is None:
57
+ raise ValueError(
58
+ f"VM {self.name}: provide key_path or password for SSH auth"
59
+ )
60
+ return self
61
+ services: list[str] = Field(default_factory=list)
62
+ # Remote (Linux) directories — validated as POSIX absolute paths, NOT local
63
+ # OS paths, so they work when the server runs on Windows/macOS.
64
+ log_dirs: list[str] = Field(default_factory=list)
65
+ databases: list[DatabaseConfig] = Field(default_factory=list)
66
+
67
+ @model_validator(mode="after")
68
+ def _stamp_db_ssh(self) -> "VMConfig":
69
+ for db in self.databases:
70
+ db.ssh_host = self.host
71
+ db.ssh_user = self.user
72
+ db.ssh_password = self.password
73
+ db.ssh_key_path = self.key_path
74
+ db.ssh_known_hosts_file = self.known_hosts_file
75
+ return self
76
+
77
+ @field_validator("log_dirs")
78
+ @classmethod
79
+ def _abs_log_dirs(cls, v: list[str]) -> list[str]:
80
+ for d in v:
81
+ if not d.startswith("/"):
82
+ raise ValueError(f"log_dirs entry must be an absolute path: {d}")
83
+ return v
84
+
85
+
86
+ class InfraMcpConfig(BaseModel):
87
+ """Top-level config file model."""
88
+
89
+ vms: list[VMConfig] = Field(min_length=1)
90
+ audit_log_path: Path = Path("~/.infra-mcp/audit.jsonl").expanduser()
91
+ log_level: str = "WARNING"
92
+
93
+ @model_validator(mode="after")
94
+ def _unique_db_names(self) -> InfraMcpConfig:
95
+ seen: set[str] = set()
96
+ for vm in self.vms:
97
+ for db in vm.databases:
98
+ if db.name in seen:
99
+ raise ValueError(f"duplicate database name across VMs: {db.name}")
100
+ seen.add(db.name)
101
+ return self
102
+
103
+ def find_vm(self, name: str) -> VMConfig | None:
104
+ return next((vm for vm in self.vms if vm.name == name), None)
105
+
106
+ def find_db(self, name: str) -> tuple[VMConfig, DatabaseConfig] | None:
107
+ for vm in self.vms:
108
+ for db in vm.databases:
109
+ if db.name == name:
110
+ return vm, db
111
+ return None
112
+
113
+
114
+ def _resolve_keyring_passwords(config: InfraMcpConfig) -> None:
115
+ """Replace the `keyring` sentinel with the credential from the system keychain."""
116
+ import keyring
117
+
118
+ for vm in config.vms:
119
+ for db in vm.databases:
120
+ if db.password == _KEYRING_SENTINEL:
121
+ secret = keyring.get_password("infra-mcp", db.name)
122
+ if secret is None:
123
+ raise ConfigError(
124
+ f"database {db.name} uses keyring but no credential found "
125
+ f"under infra-mcp:{db.name}"
126
+ )
127
+ db.password = secret
128
+
129
+
130
+ def default_config_path() -> Path:
131
+ """Resolve config path from INFRA_MCP_CONFIG or the default location."""
132
+ env = os.environ.get("INFRA_MCP_CONFIG")
133
+ if env:
134
+ return Path(env).expanduser()
135
+ return Path("~/.infra-mcp/infra-mcp.yaml").expanduser()
136
+
137
+
138
+ def load_config(path: Path | None = None) -> InfraMcpConfig:
139
+ """Load and validate config. Raises ConfigError on any failure (fail fast)."""
140
+ cfg_path = Path(path) if path is not None else default_config_path()
141
+ if not cfg_path.exists():
142
+ raise ConfigError(f"config file not found: {cfg_path}")
143
+ try:
144
+ raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
145
+ except yaml.YAMLError as e:
146
+ raise ConfigError(f"invalid YAML in {cfg_path}: {e}") from e
147
+ try:
148
+ config = InfraMcpConfig.model_validate(raw)
149
+ except ValidationError as e:
150
+ raise ConfigError(f"config validation failed: {e}") from e
151
+ config.audit_log_path = config.audit_log_path.expanduser()
152
+ _resolve_keyring_passwords(config)
153
+ return config