""" 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, )