I wanted to ask my AI assistant plain questions about my homelab — "are all my nodes up?", "any firing alerts?" — and get real answers from the real cluster. What I absolutely did not want was to hand a language model a button that could reboot a node or delete a VM. Convenience is lovely right up until the model confidently does the wrong thing at 2 a.m.
The fix is a read-only MCP server: a small program that exposes your homelab to an AI as query tools and nothing else, connecting with credentials that can only read. The safety isn't a promise you extract from the model — it's baked into the architecture. There is simply no tool, and no permission, to change anything. Every number below came out of my own running cluster.
Make the addresses your own. Replace
10.0.0.104with your Prometheus host. Keep the read-only API token in your secret store and pass it via an environment variable — never paste a real token into the server file.
What MCP is, in one minute
The Model Context Protocol (MCP) is an open standard — originally from Anthropic — for connecting AI assistants to tools. An MCP server publishes typed-function tools; an MCP client (Claude Desktop, Claude Code, and a growing list) lets the model discover and call them. Instead of hallucinating your cluster's state, the model asks your server and gets the real answer.
The protocol is neutral about safety — a tool can do anything you program. Which is why the interesting decision is what you choose to expose. We expose only reads.
Build the server
I'll use Python and FastMCP, where a decorated function becomes a tool. Two tools is enough to be genuinely useful: a general Prometheus query, and a friendly "are my targets up?" summary.
import os, requests
from fastmcp import FastMCP
PROM_URL = os.environ.get("PROM_URL", "http://10.0.0.104:9090")
mcp = FastMCP("homelab-readonly")
@mcp.tool
def prometheus_query(promql: str) -> dict:
"""Run a read-only Prometheus instant query."""
r = requests.get(f"{PROM_URL}/api/v1/query",
params={"query": promql}, timeout=10)
r.raise_for_status()
data = r.json()["data"]["result"]
return {"query": promql, "series": len(data),
"sample": [{"metric": s["metric"], "value": s["value"][1]}
for s in data[:5]]}
@mcp.tool
def cluster_targets_up() -> dict:
"""How many scrape targets are up vs down (read-only)."""
r = requests.get(f"{PROM_URL}/api/v1/query",
params={"query": "up"}, timeout=10)
r.raise_for_status()
res = r.json()["data"]["result"]
up = sum(1 for s in res if s["value"][1] == "1")
return {"targets_total": len(res), "targets_up": up,
"targets_down": len(res) - up}
if __name__ == "__main__":
mcp.run() # stdio transport by default
Notice what's not here: no reboot, no delete, no create. The tool surface is the security boundary.
FastMCP ships an in-memory client, so a five-line script exercises the real protocol. This is unedited output from my cluster:
tools/list -> ['prometheus_query', 'cluster_targets_up']
call cluster_targets_up -> {'targets_total': 27, 'targets_up': 27, 'targets_down': 0}
call prometheus_query('count(smartctl_device_smart_status)')
-> {'series': 1, 'sample': [{'value': '8'}]}
Twenty-seven scrape targets, all up; eight disks reporting SMART. The model asked; the cluster answered; nothing changed.
Read-only by design — four layers
"Read-only" isn't one setting; it's a posture you build in layers, so a mistake at any single layer can't hand an AI the keys:
-
Read-only credentials — a Proxmox token bound to the built-in
PVEAuditorrole; a query-only DB user. - Read tools only — no write/delete tool is ever registered.
- Bounded inputs — instant queries only, no admin endpoints.
- Private scope — localhost / Tailscale, never the public internet.
The credential is the real lock. The single most important choice is the read-only credential. In Proxmox that's a token bound to
PVEAuditor— it can read cluster state and nothing else. Even if you later fat-finger a write tool into the server, an audit token has no permission to carry it out.
Connect it to Claude
Register the server in your MCP client (Claude Desktop or Claude Code):
{
"mcpServers": {
"homelab-readonly": {
"command": "python",
"args": ["/opt/homelab-mcp/server.py"],
"env": { "PROM_URL": "http://10.0.0.104:9090" }
}
}
}
Now you can ask, in plain English, "are all my Prometheus targets up?" and the assistant calls cluster_targets_up and tells you. I keep mine reachable only over Tailscale — available from my laptop or phone, never from the open internet.
The flourish: a fully self-hosted loop
Pair this with a local Ollama cluster behind a load-balancing endpoint and the whole loop is yours: a local LLM calling a read-only tool server to answer questions about your cluster — no cloud in the path, and no way for any of it to change a thing.
Read the full, updated versions
I keep the maintained versions of this and the surrounding series on my homelab site, documented from a working 4-node Proxmox cluster:
- Build a Read-Only Homelab MCP Server (full guide)
- Load-Balance a Multi-Node Ollama Cluster With Olla
- Give Your Local AI Private Web Search With SearXNG
Originally published on peira.dev.
Top comments (1)
The token permission boundary is the most reliable layer here. If the underlying credential has no write grants, the model can hallucinate all the mutation calls it wants and the API gateway drops them cold.
The main edge case I ran into with raw query passthroughs like prometheus_query(promql: str) was high-cardinality queries. An agent trying to inspect an alert will sometimes construct broad regex lookups or wide subqueries across all metrics. On a smaller homelab node, that can peg the TSDB memory and stall Prometheus entirely. Clamping the API request timeout and passing a strict query.timeout parameter in the requests call keeps the monitoring box responsive when the agent starts exploring.