homelab-mcp
# homelab-mcp
A [FastMCP](https://gofastmcp.com/) server that puts an entire homelab behind a single MCP endpoint. It exposes 85 tools across 29 services (Prometheus, Loki, Proxmox, Portainer, Plex, Sonarr/Radarr, Synology, PBS, Backblaze, Technitium DNS, Gitea, WireGuard, OwnTracks, ntfy, and more) so that any LLM — a small local model or a cloud one — can answer "what's going on in the lab?" with one tool call, no PromQL, no multi-step reasoning, no per-service API knowledge.
## Key features
- **Summary-first tool design.** Every tool returns a complete, pre-digested answer (e.g. `get_homelab_overview`, `what_needs_attention`, `explain_host_health`). Raw query tools (`query_prometheus`, `query_logs`) exist but are secondary. Tool docstrings double as MCP descriptions, tuned so small local LLMs pick the right tool.
- **85 tools across 29 services**, organized into 39 tool modules plus a built-in knowledge base (services, hosts, IPs, docs, compose stacks, topology graph).
- **Conditional registration.** Each tool module checks its env vars at startup and skips registration when credentials are missing — a partial `.env` yields a smaller but fully working server, never a broken one.
- **Dual transport.** stdio by default (for local MCP clients), streamable-http via `MCP_TRANSPORT` (for network clients). HTTP mode adds an OIDC + trusted-LAN auth chain.
- **Mostly read-only.** Eleven write tools exist (container restarts, Vikunja tasks, kanban moves and task comments, Overseerr approvals, image updates, ntfy push notifications); writes are audit-logged to Loki and support `dry_run=True`.
## Architecture
```
server.py entrypoint: lifespan, auth chain, /health route, tool registration
└─ lifespan one long-lived httpx.AsyncClient per configured service,
created inside an AsyncExitStack; session-auth services
(Transmission, Synology, Technitium, wg-easy, NPM, B2, MySpeed)
are wrapped in lib/auth.py's SessionAuthManager
└─ tools/*.py one module per service; each exposes register(mcp) and
self-skips when its config.py env vars are unset
└─ config.py env-driven config (python-dotenv) + knowledge loaders
└─ data/ services.yaml, hosts.yaml, topology.yaml, baselines.yaml,
docs/, stacks/, vault/ — synced from other repos via
`make sync-data` locally; fetched live from Gitea in prod,
refreshed by a background task on a configurable interval
```
Tools return plain dicts (FastMCP serializes to JSON); errors are never raised to the client, they come back as `{"error": <code>, "message": <human-readable>}`.
## Quickstart
Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/).
```bash
uv sync # install deps from uv.lock
make pull-env # hydrate .env from Infisical (see below)
make dev # stdio transport (for Claude Desktop / MCP clients)
make http # streamable-http on port 8000 (override with MCP_PORT)
```
`make pull-env` fetches every key from the Infisical secret store using the scoped read-only machine identity in `~/.config/infisical/homelab-mcp.env`, and writes `.env` at mode 0600. If a `.env.local` exists it is appended after the pulled block, so local-only overrides win (python-dotenv takes the last occurrence). Running the server against your own infrastructure instead? Skip Infisical and use `cp .env.example .env`, filling in URLs/keys for the services you have.
Only the services you configure get registered — an empty `.env` still starts, with just the credential-free tool groups.
The knowledge tools (service/host/IP lookup, topology, baselines) read four seed files: `data/hosts.yaml`, `data/services.yaml`, `data/topology.yaml`, `data/baselines.yaml`. The server boots fine without them — the knowledge tools just start empty. To create them for your own infrastructure:
```bash
uv run scripts/bootstrap_registries.py # or --dry-run to preview
```
This discovers hosts and services from whatever you configured in `.env` (Proxmox, Portainer, Scanopy, NPM, Prometheus) and writes skeleton YAMLs with `TODO` markers for the fields only you know: role descriptions, the cross-service `aliases` map (which upstream name maps to which host — cross-tool joins depend on it), dependency edges, and the never-restart container list. Alternatively, copy the `data/*.example.yaml` files (shipped in the public mirror) and edit by hand.
## Docker / production
```bash
docker compose up -d # builds the image, serves streamable-http on :5774
curl localhost:5774/health # {"status": "ok", "version": "..."}
```
The image (multi-stage `uv` build on `python:3.13-slim`) defaults to `MCP_TRANSPORT=streamable-http` and `MCP_PORT=5774`, with a Docker `HEALTHCHECK` polling `/health`. `data/stacks`, `data/docs`, and `data/vault` are stripped at build time and fetched live from Gitea at runtime instead.
Production runs on the `beast` host via Gitea CI (`.gitea/workflows/ci.yml`): quality gates on every branch, and on a master push it builds a CalVer+SHA-tagged image, pushes to the Gitea registry, renders `stack/docker-compose.yaml` with the pinned tag, deploys over SSH, and polls `/health` until the new version reports live. CI never ships secrets: beast's `.env` is rendered by the host's Infisical agent from the `/beast/homelab-mcp` folder of the homelab-infra store, and the deploy only asserts that the file exists. To rotate a secret, change it in Infisical, give the agent ~60 seconds to re-render, then `docker compose up -d --force-recreate homelab-mcp` — a container's environment is frozen at process start, so a re-render alone does not reach the running server.
In HTTP mode the server authenticates via a MultiAuth chain: Authentik OIDC (JWT verification) for external traffic, plus a LAN bypass that trusts direct, un-proxied requests from `MCP_TRUSTED_CIDRS` (default `192.168.1.0/24,127.0.0.0/8`).
## Configuration
All configuration is environment variables, loaded by python-dotenv from `.env` (local dev) or injected via `env_file:` (Docker). See `.env.example` (with `config.py` as the authoritative list) — roughly 75 vars, almost all of the form `<SERVICE>_URL` + `<SERVICE>_API_KEY`/token/password.
There is no validation layer on purpose: a missing var simply means that service's tools are not registered. Non-service knobs include `MCP_TRANSPORT`, `MCP_PORT`, `MCP_TRUSTED_CIDRS`, `REFRESH_INTERVAL_SECONDS`, and `DOC_REFRESH_INTERVAL_SECONDS`.
## Tool catalog
| Area | Modules | What you get |
|---|---|---|
| Monitoring | prometheus, loki, healthchecks, myspeed, internet_health | Host/container/GPU/storage health, recent errors, container logs, cron check status, speed tests, external internet health score with trend and variance windows plus provider outages, raw PromQL/LogQL |
| Infrastructure | proxmox, docker, npm, crowdsec | Proxmox nodes and VMs/CTs, containers across hosts (Portainer), reverse-proxy hosts and certs, CrowdSec alerts and decisions |
| Media | plex, tautulli, sonarr, radarr, overseerr, transmission, prowlarr | Streams, library stats, upcoming/wanted, requests, torrents, indexer health |
| Storage | synology, pbs, backblaze | NAS status, backup jobs and datastores, B2 usage |
| DNS / Network | technitium, wireguard, scanopy | DNS lookups and zone records, VPN peers, network topology and IP info |
| Location | owntracks | Where each tracked device is now (coordinates, battery, connection, named regions), device-health inventory, movement history with distance and longest stop, recorder health |
| DevOps / AI | gitea, litellm, llama, searxng | Repos, PRs, CI runs, LLM proxy status, llama-server slots/metrics, web search and page fetch |
| Knowledge | knowledge, graph, freshness, baselines, refresh | Service/host/IP lookup, docs search, dependency chains, "is this normal?" baseline comparison, forced registry/doc refresh |
| Aggregation | aggregation, compound, health, changefeed | One-call overviews (homelab/media/infra), `what_needs_attention`, `what_changed_last_24h`, health explainers |
| Write ops | docker, compound, vikunja, overseerr, ntfy | `restart_container`, `safe_restart_container` (dependency-aware), `create_vikunja_task`, `update_vikunja_task`, `move_vikunja_task`, `create_vikunja_comment`, `create_task_from_alert`, Overseerr approve/decline, `send_ntfy_notification` |
To see exactly what's registered against *your* `.env`, connect any MCP client and issue a `tools/list` request — the set varies with configured credentials.
## Development
```bash
make test # pytest, 689 tests
make lint # ruff check + format check
make format # ruff format + autofix
make hooks # point git at .githooks (pre-commit: ruff on staged Python files;
# pre-push: full lint + test suite)
make sync-data # rsync docs/stacks/vault from sibling repos into data/
```
Tests mock httpx transports; nothing in the suite touches the real lab.
## Project layout
```
server.py entrypoint, lifespan, auth, tool registration
config.py env config + knowledge loaders
resources.py MCP resources
lib/ auth (session managers), hosts, audit, meta, http (shared request ladder),
redact, refresh facade (refresh_registries/refresh_content), per-service helpers
tools/ 38 tool modules, one register(mcp) each
scripts/ bootstrap_registries.py (registry generator), pull_env.py (.env from Infisical) + publish tooling
tests/ pytest suite (689 tests)
data/ knowledge base (synced/fetched, not source code)
docs/ operational references (e.g. the Crow's Nest tool rename map)
stack/ production compose file rendered by CI
Dockerfile multi-stage uv build
docker-compose.yml local container run
.gitea/workflows/ CI + deploy pipeline
.githooks/ pre-commit / pre-push hooks
```
## Honest notes
- **Self-signed certs:** the Proxmox, PBS, Portainer, and Synology clients use `verify=False`. Fine for a LAN homelab, not a pattern to copy anywhere TLS actually matters.
- **Write tools:** deliberately few, all audit-logged to Loki, all previewable with `dry_run=True`. Still — an LLM with this server can restart your containers. Configure only the credentials you're comfortable delegating.
- **Scope:** built for a single user on a trusted LAN (`192.168.1.0/24`). The LAN-bypass auth grants full access to any un-proxied client in the trusted CIDRs, and there is no per-tool authorization. External access is expected to come through a reverse proxy with OIDC in front.
- **Knowledge freshness:** registry/doc data refreshes on a timer and can drift between refreshes; responses include staleness metadata, and `refresh_registries` / `refresh_docs` force an update.
TDQS
Scored across 16 tools
Most tools have clearly distinct purposes, but pairs like get_service_info vs explain_service_health and get_host_info vs explain_host_health could be confused since they cover the same entity with different details. The overview tools (homelab, infra, media) also have overlapping metrics but are scoped clearly enough.
All names use lowercase_snake_case and follow a verb-first pattern (search_, get_, show_, explain_, fetch_, refresh_). The two 'what_' tools deviate from strict verb_noun but are still clear and consistent in style.
16 tools is slightly above the typical 3-15 well-scoped range, but the homelab monitoring domain is broad and each tool addresses a distinct aspect. The count feels justified rather than excessive.
The set covers most read-only monitoring needs: info lookup, health, overviews, change timeline, and refresh actions. However, what_needs_attention explicitly references get_pbs_status for backup checks, but that tool is not actually in the set, a notable gap. There are also no control/mutation tools, which may be intentional but limits the surface.