homelab-mcp
README.md
# homelab-mcp
MCP server for homelab diagnostics + auto-update pipeline. Runs on
TrueNAS (or anywhere) as the central control plane; talks to one or
more downstream Docker hosts (Unraid, QNAP, etc.) over SSH.
## What it does
- **Read-only diagnostics** (MCP tools, safe to call any time):
- `list_stacks_tool` — every compose project + single container on a host
- `stack_status_tool` — full state of one stack (containers, digests)
- `recent_events_tool` — docker events from the last N seconds
- `get_logs_tool` — last N log lines of a container (capped at 5000)
- `check_nfs_shares_tool` — mounted NFS exports
- `check_dns_tool` — name resolution against the host's resolver
- `check_vpn_health_tool` — gluetun container health
- **Image-drift visibility** (MCP tools, populates `pending_updates`):
- `trigger_scan_tool` — on-demand scan of one or all hosts
- `list_pending_updates_tool` — rows from the latest scan / cron
- `pending_update_dismiss_tool` — drop one row after deciding not to apply
- `dismiss_all_pending_tool(host, stack=None)` — bulk-dismiss (one host
or one host+stack). Useful for blanket-ignoring a non-actionable drift.
- **Smart update** (MCP tools, runs the full pipeline for one row at a time):
- `apply_update_tool(host, stack, force=False)` — fetch release notes →
LLM classify → policy gate → snapshot → pull → up -d → healthcheck →
rollback on failure. Returns `{action, verdict, apply_result}`. The
`force=True` override flips the policy so BREAKING still gets applied
(healthcheck + rollback still run).
- `apply_all_pending_tool(host, force=False, max_rows=50)` — bulk-apply
every pending row on a host. Per-row isolation: a single failure
doesn't stop the others. Returns per-row results + counts.
- `get_update_history_tool(host, stack, limit=20)` — past update
attempts (applied, failed, rolled_back) from the SQLite state DB.
Capped at 200 rows.
- **HTTP endpoints** (for monitoring; not MCP tools):
- `GET /health` — `{status: "ok", uptime_seconds: N}`
- `GET /status` — daemon info + state DB summary
- **Benchmark framework** (diagnostic MCP tools, safe to call with `require_approval=True`):
- `exec_in_container_tool` — run a tightly allowlisted command inside any
container; `rm`, shells, and package managers are blocked by default.
- `http_probe_tool` — curl-style HTTP check from inside the container network.
- `db_snapshot_tool` / `db_restore_tool` — dump and restore SQLite databases
using Python's stdlib `sqlite3` (no `sqlite3` CLI required in the target
image). Restore builds a temp file and atomically swaps it into place, so
it works even over a live, existing schema.
- **Auto-update pipeline** (cron-style; one cycle at a time):
- **Fetch release notes** — image → GitHub repo heuristic
(ghcr.io / quay.io / lscr.io/linuxserver → GitHub Releases API,
then CHANGELOG.md fallback). 8KB cap.
- **Classify via LLM** — OpenAI-compatible chat-completions
endpoint (works with Ollama, vLLM, MiniMax M3). Returns
`SAFE` / `CAUTION` / `BREAKING` plus migration steps, compose
changes, env changes. Any LLM error falls back to `CAUTION`
(better to over-apply than under-classify).
- **Apply policy**:
- default `safe-and-caution` — auto-apply SAFE + CAUTION, notify on BREAKING
- `safe-only` — auto-apply SAFE only, notify on CAUTION + BREAKING
- **Apply** — `docker compose pull && docker compose up -d` from
the resolved stack directory. Stack-dir resolution: 4-tier
(label override > compose working_dir > Dockge path > CA
compose.manager). Probes container health post-restart.
- **Auto-rollback** on apply failure (pull old digest, up -d).
- **Notify** — ntfy by default (configurable); BREAKING alerts
go to the ntfy topic with high priority.
## Quick start
```bash
# 1. Sync deps (the project is configured to use .venv/)
uv sync --extra dev
source .venv/bin/activate
# 2. Configure
cp .env.example .env
# ... edit .env (set HOMELAB_MCP_HOSTS, ntfy topic, LLM endpoint, etc.)
# 3. Test
pytest
# 4. Run the MCP server (SSE on :18790)
python -m homelab_mcp
# 5. Run one cycle of the auto-apply pipeline (dry-run)
python -m homelab_mcp.auto_apply_main --dry-run
```
> If `uv sync` puts the venv in a `venv/` directory instead of
> `.venv/`, set `UV_PROJECT_ENVIRONMENT=.venv` once or use
> `uv venv .venv && uv sync --extra dev` to force the location.
## Deploying the benchmark framework
See [`deploy/DEPLOY.md`](deploy/DEPLOY.md) for Phase 1 + 2 deployment,
rollback, and snapshot-path conventions.
## Install on Unraid
```bash
# On Unraid:
bash scripts/install-on-unraid.sh
# (pulls the GHCR image, starts the daemon, registers a 6h cron)
```
## Install on TrueNAS (the "main hub" config)
```bash
# On TrueNAS, in a Dockge stack directory:
git clone https://github.com/<owner>/homelab-mcp.git
cd homelab-mcp
cp scripts/dockge-stack/.env.example .env
# ... edit .env (set HOMELAB_MCP_HOSTS=["truenas","unraid"],
# HOMELAB_MCP_LOCAL_HOST_ALIAS=truenas, etc.)
docker compose -f scripts/dockge-stack/compose.yaml up -d
bash scripts/deploy-on-truenas.sh
# (runs 5 in-the-field smoke tests)
```
## Configuration
All configuration is via environment variables. See [`.env.example`](.env.example).
| Env var | Default | Notes |
| --- | --- | --- |
| `HOMELAB_MCP_HOSTS` | `["unraid"]` | JSON list of host aliases |
| `HOMELAB_MCP_PORT` | `18790` | MCP SSE transport port |
| `HOMELAB_MCP_STATE_DIR` | `~/.local/share/homelab-mcp` | sqlite + last_scan.txt |
| `HOMELAB_MCP_SSH_CONFIG` | `~/.ssh/config` | for RemoteSSH |
| `HOMELAB_MCP_POLL_ENABLED` | `true` | visibility cron on/off |
| `HOMELAB_MCP_POLL_INTERVAL` | `21600` | seconds between scans (6h) |
| `HOMELAB_MCP_NTFY_URL` | `https://ntfy.sh/` | notifier base |
| `HOMELAB_MCP_NTFY_TOPIC` | `""` | required for alerts |
| `HOMELAB_MCP_NTFY_PRIORITY` | `default` | default priority |
| `HOMELAB_MCP_DISCORD_WEBHOOK_URL` | `""` | Discord webhook (empty = disabled) |
| `HOMELAB_MCP_DISCORD_USERNAME` | `homelab-mcp` | webhook display name |
| `HOMELAB_MCP_PUSHOVER_APP_TOKEN` | `""` | Pushover app token (empty = disabled) |
| `HOMELAB_MCP_PUSHOVER_USER_KEY` | `""` | Pushover user key |
| `HOMELAB_MCP_PUSHOVER_DEVICE` | `""` | target device by name (optional) |
| `HOMELAB_MCP_PUSHOVER_SOUND` | `pushover` | alert sound |
| `HOMELAB_MCP_LLM_ENDPOINT` | `http://localhost:11434/v1/chat/completions` | OpenAI-compatible |
| `HOMELAB_MCP_LLM_API_KEY` | `""` | Bearer token (optional) |
| `HOMELAB_MCP_LLM_MODEL` | `""` | model name (required) |
| `HOMELAB_MCP_LLM_TIMEOUT` | `30` | seconds |
| `HOMELAB_MCP_AUTO_APPLY_POLICY` | `safe-and-caution` | or `safe-only` |
| `HOMELAB_MCP_LOCAL_HOST_ALIAS` | `unraid` | set to `truenas` for TrueNAS deploys |
| `HOMELAB_MCP_DOCKGE_STACKS_ROOT` | `/mnt/Data/appdata/dockge/stacks` | Dockge stacks path |
## Architecture
```
+------------+
| TrueNAS | <-- MCP daemon here (LocalDocker)
| (main hub) |
+-----+------+
|
SSH | (RemoteSSH for unraid + qnap)
|
+--------------+--------------+
| |
+----+-----+ +------+----+
| Unraid | | QNAP |
| (117 ct) | | (60+ ct) |
+----------+ +-----------+
```
- **LocalDocker backend**: the daemon host's docker socket
(`unix:///var/run/docker.sock`).
- **RemoteSSH backend**: SSH config alias → `ssh <alias> docker ps`.
Each host alias in `HOMELAB_MCP_HOSTS` must have a corresponding
`Host` block in `HOMELAB_MCP_SSH_CONFIG`.
- **Stack-dir resolution** (in order):
1. `auto-update.stack-dir` label on the container (override)
2. `com.docker.compose.project.working_dir` (the standard compose label)
3. If `com.dockge.owner` or `auto-update.dockge=true` is set →
`<HOMELAB_MCP_DOCKGE_STACKS_ROOT>/<project>`
4. Otherwise → `<compose_manager_root>/<project>` (CA compose.manager
style; `compose_manager_root` is auto-detected for the local host)
## Tests
```bash
uv run pytest -q
```
Currently 183 tests across:
- config (env-var loading, validation)
- state (SQLite CRUD, idempotent writes, busy_timeout)
- hosts (LocalDocker + RemoteSSH, structural conformance to HostClient)
- updater.registry (image-ref parsing, registry result kinds)
- updater.scanner (drift detection, transient errors, digests)
- updater.pipeline (snapshot, apply+probe, rollback, run_pipeline)
- updater.release_notes (image→GitHub, GH Releases API, CHANGELOG fallback, truncation)
- updater.risk (SAFE/CAUTION/BREAKING classification, error fallbacks)
- updater.notifier (ntfy POST, multi-fan-out, console)
- updater.notifier_backends (Discord + Pushover: body building, network errors, MultiNotifier dispatch, error isolation)
- updater.auto_apply (policy decisions, classifier-fail CAUTION fallback)
- auto_apply_main (cron arg parsing, per-row exception isolation, summary)
- tools.apply_update (smart-update pipeline: no-pending, BREAKING-notify, SAFE-apply, force-override)
- tools.apply_all_pending (bulk apply with per-row isolation, max_rows cap)
- tools.get_update_history (limit clamping, state forwarding)
- tools.dismiss_all_pending (host-wide + stack-scoped dismiss, per-row error isolation)
- server (FastMCP singleton, tool registration, build_hosts wiring)
### Live SSH tests (opt-in)
There is a separate test file at `tests/test_live_ssh.py` that
exercises the `RemoteSSH` backend against a real homelab host. These
are **skipped by default** (the sandbox has no LAN egress). To run
them on a host that has SSH access to your homelab:
```bash
HOMELAB_MCP_LIVE=1 \
HOMELAB_MCP_LIVE_HOST_NAME=unraid \
HOMELAB_MCP_LIVE_HOST_HOSTNAME=192.168.1.104 \
HOMELAB_MCP_LIVE_HOST_USER=root \
HOMELAB_MCP_SSH_CONFIG=/root/.ssh/config \
pytest tests/test_live_ssh.py -v
```
The tests cover 9 real-host behaviors:
- `list_containers` returns real data
- `list_stacks` shape is right
- `inspect_container` returns valid `docker inspect` JSON
- `run_command` exit code, stdout, stderr are captured correctly
- `run_command` timeout cancels the command
- The configured user can run `docker` (no permission errors)
- A `compose_pull` against a real directory returns successfully
Full configuration recipe + troubleshooting is in
[`tests/live/README.md`](tests/live/README.md).
## Notifiers
The auto-apply pipeline supports **three backends**, all sharing the
same `Notifier` protocol. Configure any combination — the orchestrator
dispatches to all of them.
### ntfy (default)
Set `HOMELAB_MCP_NTFY_TOPIC` and you get free-form notifications on
every BREAKING alert. Topic-based, no account required, supports
priorities and tags.
### Discord
Set `HOMELAB_MCP_DISCORD_WEBHOOK_URL` to get color-coded embeds in a
Discord channel. BREAKING alerts are red, CAUTION orange, SAFE green.
To create a webhook: Discord server → channel settings → Integrations
→ Webhooks → New Webhook → copy URL.
### Pushover
Set both `HOMELAB_MCP_PUSHOVER_APP_TOKEN` (create at
https://pushover.net/apps/build) and `HOMELAB_MCP_PUSHOVER_USER_KEY`
to get push notifications on your phone. Sounds and per-device
targeting are configurable.
### Adding a new notifier
Implement the `Notifier` protocol in `homelab_mcp/updater/`:
```python
class Notifier(Protocol):
async def notify(
self, text: str, *,
title: str = "",
tags: list[str] | None = None,
priority: str | None = None,
click: str | None = None,
) -> None: ...
```
Then add it to `_build_notifier` in `homelab_mcp/auto_apply_main.py`.
Tests should follow the pattern in
`tests/test_updater_notifier_backends.py`: pure `_build_body` +
network `_post` separated.
## Security
- The MCP server runs in SSE mode and is bound to `0.0.0.0:18790` by
default. **Put a reverse proxy in front of it** (npmplus, Traefik,
Caddy) with OIDC or HTTP basic auth — don't expose 18790 directly.
- ntfy topics should be unique and unguessable. Add a token in the
URL (`HOMELAB_MCP_NTFY_URL=https://ntfy.sh/?token=...`) for
private topics.
- LLM API keys never appear in logs or state. The state DB at
`HOMELAB_MCP_STATE_DIR/state.db` is not encrypted — host-level
ACLs apply.
- The `auto-update` machinery touches docker. Run as a dedicated
user with docker-socket access; do not run as root if you can
avoid it.
## License
MIT.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues