feat: add mcp-homelab jarvis agent
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# kubectl installieren (gleiche Version wie dein Cluster)
|
||||
ARG KUBECTL_VERSION=v1.32.0
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \
|
||||
-o /usr/local/bin/kubectl \
|
||||
&& chmod +x /usr/local/bin/kubectl \
|
||||
&& apt-get remove -y curl && apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY server.py .
|
||||
|
||||
# Non-root User fuer Security
|
||||
RUN useradd -u 1000 -m mcp
|
||||
USER 1000
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["python", "server.py"]
|
||||
@@ -0,0 +1,2 @@
|
||||
fastmcp>=3.1.1
|
||||
psycopg2-binary>=2.9.9
|
||||
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
mcp-homelab: Minimaler, auditierter MCP-Server fuer den Homelab-Stack.
|
||||
|
||||
Tools:
|
||||
- kubectl_get : read-only Kubernetes-Ressourcen
|
||||
- kubectl_logs : Pod-Logs lesen
|
||||
- kubectl_events : Namespace-Events lesen
|
||||
- query_postgres : read-only SQL auf beliebiger CNPG-DB
|
||||
- read_obsidian : .md-Datei aus dem Obsidian-Repo lesen
|
||||
- list_obsidian : Obsidian-Verzeichnis auflisten
|
||||
- flux_status : Flux-Kustomizations und HelmReleases pruefen
|
||||
|
||||
Transport: Streamable HTTP (production-empfohlen, stateless fuer k8s)
|
||||
Port: 8000, Endpunkt: /mcp
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from fastmcp import FastMCP
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("mcp-homelab")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Konfiguration aus Environment (via Kubernetes Secret)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Postgres-DSNs pro Datenbank, kommagetrennt als KEY=DSN Paare
|
||||
# z.B. "garmin=postgresql://ro:pw@garmin-db-ro.garmin:5432/garmin,n8n=..."
|
||||
_RAW_DBS = os.environ.get("POSTGRES_DSN_MAP", "")
|
||||
POSTGRES_DSN_MAP: dict[str, str] = {}
|
||||
for entry in _RAW_DBS.split(","):
|
||||
entry = entry.strip()
|
||||
if "=" in entry:
|
||||
key, dsn = entry.split("=", 1)
|
||||
POSTGRES_DSN_MAP[key.strip()] = dsn.strip()
|
||||
|
||||
# Obsidian-Repo: per NFS oder git-sync gemountetes Verzeichnis
|
||||
OBSIDIAN_ROOT = Path(os.environ.get("OBSIDIAN_ROOT", "/obsidian"))
|
||||
|
||||
# Kubeconfig wird durch den ServiceAccount automatisch gemountet
|
||||
KUBECTL = ["kubectl"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sicherheits-Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ALLOWED_K8S_RESOURCES = {
|
||||
"pods", "pod", "po",
|
||||
"deployments", "deployment", "deploy",
|
||||
"services", "service", "svc",
|
||||
"events", "event",
|
||||
"nodes", "node",
|
||||
"configmaps", "configmap", "cm",
|
||||
"persistentvolumeclaims", "pvc",
|
||||
"helmreleases", "kustomizations",
|
||||
"clusters", # CNPG
|
||||
"postgresclusters", # CNPG
|
||||
}
|
||||
|
||||
_ALLOWED_FLUX_RESOURCES = {
|
||||
"kustomizations", "helmreleases", "gitrepositories",
|
||||
"helmrepositories", "imagerepositories", "imageupdateautomations",
|
||||
}
|
||||
|
||||
|
||||
def _safe_resource(resource: str) -> str:
|
||||
"""Prueft ob eine Kubernetes-Ressource in der Allowlist ist."""
|
||||
r = resource.lower().split("/")[0] # z.B. "pods/mypod" → "pods"
|
||||
if r not in _ALLOWED_K8S_RESOURCES:
|
||||
raise ValueError(f"Ressource '{resource}' nicht erlaubt. Erlaubt: {sorted(_ALLOWED_K8S_RESOURCES)}")
|
||||
return resource
|
||||
|
||||
|
||||
def _safe_sql(sql: str) -> str:
|
||||
"""Erlaubt nur SELECT-Statements."""
|
||||
stripped = sql.strip().upper()
|
||||
if not stripped.startswith("SELECT"):
|
||||
raise ValueError("Nur SELECT-Queries erlaubt.")
|
||||
# Blockiere gefaehrliche Keywords auch innerhalb von SELECT
|
||||
for kw in ("; DROP", "; DELETE", "; UPDATE", "; INSERT", "; TRUNCATE"):
|
||||
if kw in stripped:
|
||||
raise ValueError(f"Unerlaubtes Keyword im Query: {kw.strip()}")
|
||||
return sql
|
||||
|
||||
|
||||
def _safe_obsidian_path(path: str) -> Path:
|
||||
"""Verhindert Path-Traversal ausserhalb von OBSIDIAN_ROOT."""
|
||||
target = (OBSIDIAN_ROOT / path).resolve()
|
||||
if not str(target).startswith(str(OBSIDIAN_ROOT.resolve())):
|
||||
raise ValueError("Path traversal nicht erlaubt.")
|
||||
return target
|
||||
|
||||
|
||||
def _run_kubectl(*args: str, timeout: int = 15) -> str:
|
||||
"""Fuehrt kubectl aus und gibt stdout zurueck. Wirft bei Fehler."""
|
||||
cmd = KUBECTL + list(args)
|
||||
log.info("kubectl: %s", " ".join(cmd))
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"kubectl Fehler: {result.stderr.strip()}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP-Server
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
mcp = FastMCP(
|
||||
"homelab",
|
||||
instructions=(
|
||||
"Du hast read-only Zugriff auf den Homelab-Kubernetes-Cluster, "
|
||||
"PostgreSQL-Datenbanken und das Obsidian-Notiz-Repository. "
|
||||
"Schreibe NIE Kubernetes-Ressourcen. "
|
||||
"Schreibe NIE in die Datenbank. "
|
||||
"Schreibe NIE in das Obsidian-Repository. "
|
||||
"Alle Aenderungen am Cluster laufen ausschliesslich ueber Git-PRs."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kubernetes Tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def kubectl_get(resource: str, namespace: str = "default", name: str = "") -> str:
|
||||
"""
|
||||
Liest Kubernetes-Ressourcen (read-only).
|
||||
|
||||
Args:
|
||||
resource: Ressourcentyp, z.B. 'pods', 'deployments', 'services', 'nodes'
|
||||
namespace: Kubernetes-Namespace (default: 'default'). Fuer alle: '--all-namespaces'
|
||||
name: Optionaler Name einer spezifischen Ressource
|
||||
"""
|
||||
_safe_resource(resource)
|
||||
args = ["get", resource]
|
||||
if name:
|
||||
args.append(name)
|
||||
if namespace == "--all-namespaces":
|
||||
args.append("--all-namespaces")
|
||||
else:
|
||||
args += ["-n", namespace]
|
||||
args += ["-o", "json"]
|
||||
raw = _run_kubectl(*args)
|
||||
# Komprimiere grosse Antworten auf das Wesentliche
|
||||
data = json.loads(raw)
|
||||
if "items" in data:
|
||||
log.info("kubectl_get: %d Eintraege zurueckgegeben", len(data["items"]))
|
||||
return raw
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def kubectl_logs(pod: str, namespace: str = "default", tail: int = 100, container: str = "") -> str:
|
||||
"""
|
||||
Liest die letzten Logzeilen eines Pods.
|
||||
|
||||
Args:
|
||||
pod: Pod-Name
|
||||
namespace: Kubernetes-Namespace
|
||||
tail: Anzahl der letzten Zeilen (max 500)
|
||||
container: Optionaler Container-Name bei Multi-Container-Pods
|
||||
"""
|
||||
tail = min(tail, 500) # Hard-Limit
|
||||
args = ["logs", pod, "-n", namespace, f"--tail={tail}"]
|
||||
if container:
|
||||
args += ["-c", container]
|
||||
return _run_kubectl(*args)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def kubectl_events(namespace: str = "default", field_selector: str = "") -> str:
|
||||
"""
|
||||
Liest Kubernetes-Events eines Namespace, sortiert nach Zeit.
|
||||
|
||||
Args:
|
||||
namespace: Kubernetes-Namespace. Fuer alle: '--all-namespaces'
|
||||
field_selector: Optionaler Kubernetes field-selector, z.B. 'reason=BackOff'
|
||||
"""
|
||||
args = ["get", "events", "--sort-by=.lastTimestamp"]
|
||||
if namespace == "--all-namespaces":
|
||||
args.append("--all-namespaces")
|
||||
else:
|
||||
args += ["-n", namespace]
|
||||
if field_selector:
|
||||
args += [f"--field-selector={field_selector}"]
|
||||
args += ["-o", "json"]
|
||||
return _run_kubectl(*args)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def flux_status(namespace: str = "flux-system") -> str:
|
||||
"""
|
||||
Zeigt den Status aller Flux-Kustomizations und HelmReleases.
|
||||
|
||||
Args:
|
||||
namespace: Flux-Namespace (default: flux-system)
|
||||
"""
|
||||
results = {}
|
||||
for res in ["kustomizations", "helmreleases"]:
|
||||
try:
|
||||
raw = _run_kubectl("get", res, "-n", namespace, "-o", "json")
|
||||
data = json.loads(raw)
|
||||
results[res] = [
|
||||
{
|
||||
"name": item["metadata"]["name"],
|
||||
"ready": next(
|
||||
(c["status"] for c in item.get("status", {}).get("conditions", []) if c["type"] == "Ready"),
|
||||
"Unknown",
|
||||
),
|
||||
"message": next(
|
||||
(c["message"] for c in item.get("status", {}).get("conditions", []) if c["type"] == "Ready"),
|
||||
"",
|
||||
),
|
||||
}
|
||||
for item in data.get("items", [])
|
||||
]
|
||||
except Exception as e:
|
||||
results[res] = f"Fehler: {e}"
|
||||
return json.dumps(results, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL Tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def query_postgres(database: str, sql: str) -> str:
|
||||
"""
|
||||
Fuehrt ein read-only SQL-SELECT auf einer CNPG-Datenbank aus.
|
||||
|
||||
Args:
|
||||
database: Datenbankname aus der konfigurierten Liste (z.B. 'garmin', 'n8n')
|
||||
sql: SELECT-Query (nur lesend erlaubt)
|
||||
"""
|
||||
if database not in POSTGRES_DSN_MAP:
|
||||
available = list(POSTGRES_DSN_MAP.keys())
|
||||
raise ValueError(f"Datenbank '{database}' nicht konfiguriert. Verfuegbar: {available}")
|
||||
|
||||
_safe_sql(sql)
|
||||
log.info("query_postgres db=%s sql=%s", database, sql[:120])
|
||||
|
||||
dsn = POSTGRES_DSN_MAP[database]
|
||||
conn = psycopg2.connect(dsn)
|
||||
conn.set_session(readonly=True, autocommit=True)
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(sql)
|
||||
rows = cur.fetchmany(200) # Hard-Limit: max 200 Zeilen
|
||||
return json.dumps([dict(r) for r in rows], default=str, ensure_ascii=False, indent=2)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_postgres_databases() -> str:
|
||||
"""Gibt die konfigurierten Datenbanknamen zurueck (ohne DSNs/Credentials)."""
|
||||
return json.dumps(list(POSTGRES_DSN_MAP.keys()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Obsidian Tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def read_obsidian(path: str) -> str:
|
||||
"""
|
||||
Liest eine Markdown-Datei aus dem Obsidian-Repository.
|
||||
|
||||
Args:
|
||||
path: Relativer Pfad zur .md-Datei, z.B. 'Projects/Homelab.md'
|
||||
"""
|
||||
target = _safe_obsidian_path(path)
|
||||
if not target.exists():
|
||||
raise FileNotFoundError(f"Datei nicht gefunden: {path}")
|
||||
if target.suffix.lower() not in (".md", ".txt", ".yaml", ".yml"):
|
||||
raise ValueError("Nur .md, .txt, .yaml Dateien erlaubt.")
|
||||
content = target.read_text(encoding="utf-8")
|
||||
# Begrenze auf ~50k Zeichen um Kontext nicht zu sprengen
|
||||
if len(content) > 50_000:
|
||||
content = content[:50_000] + "\n\n[... Datei gekuerzt ...]"
|
||||
return content
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_obsidian(directory: str = "") -> str:
|
||||
"""
|
||||
Listet .md-Dateien im Obsidian-Repository auf.
|
||||
|
||||
Args:
|
||||
directory: Optionales Unterverzeichnis, z.B. 'Projects'. Leer = Root.
|
||||
"""
|
||||
base = _safe_obsidian_path(directory) if directory else OBSIDIAN_ROOT.resolve()
|
||||
if not base.is_dir():
|
||||
raise NotADirectoryError(f"Kein Verzeichnis: {directory}")
|
||||
|
||||
files = []
|
||||
for p in sorted(base.rglob("*.md")):
|
||||
rel = p.relative_to(OBSIDIAN_ROOT)
|
||||
files.append(str(rel))
|
||||
if len(files) >= 500: # Hard-Limit
|
||||
files.append("... (weitere Dateien vorhanden)")
|
||||
break
|
||||
return json.dumps(files, ensure_ascii=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entrypoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Streamable HTTP: stateless fuer Kubernetes (kein sticky-session Problem)
|
||||
# Port 8000, Endpunkt /mcp
|
||||
mcp.run(
|
||||
transport="streamable-http",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
path="/mcp",
|
||||
stateless_http=True,
|
||||
)
|
||||
Reference in New Issue
Block a user