From 463aa300ee3866fea891db2124dd6911bb626d07 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 28 Mar 2026 12:28:01 +0100 Subject: [PATCH] feat: add mcp-homelab jarvis agent --- clusters/homelab/apps/mcp-homelab.yaml | 21 ++ .../homelab/apps/mcp-homelab/deployment.yaml | 105 ++++++ .../apps/mcp-homelab/kustomization.yaml | 8 + .../homelab/apps/mcp-homelab/namespace.yaml | 4 + .../homelab/apps/mcp-homelab/secret.sops.yaml | 43 +++ .../homelab/apps/mcp-homelab/service.yaml | 13 + .../apps/mcp-homelab/serviceaccount.yaml | 60 ++++ mcp-homelab/Dockerfile | 23 ++ mcp-homelab/requirements.txt | 2 + mcp-homelab/server.py | 327 ++++++++++++++++++ 10 files changed, 606 insertions(+) create mode 100644 clusters/homelab/apps/mcp-homelab.yaml create mode 100644 clusters/homelab/apps/mcp-homelab/deployment.yaml create mode 100644 clusters/homelab/apps/mcp-homelab/kustomization.yaml create mode 100644 clusters/homelab/apps/mcp-homelab/namespace.yaml create mode 100644 clusters/homelab/apps/mcp-homelab/secret.sops.yaml create mode 100644 clusters/homelab/apps/mcp-homelab/service.yaml create mode 100644 clusters/homelab/apps/mcp-homelab/serviceaccount.yaml create mode 100644 mcp-homelab/Dockerfile create mode 100644 mcp-homelab/requirements.txt create mode 100644 mcp-homelab/server.py diff --git a/clusters/homelab/apps/mcp-homelab.yaml b/clusters/homelab/apps/mcp-homelab.yaml new file mode 100644 index 0000000..a6a0169 --- /dev/null +++ b/clusters/homelab/apps/mcp-homelab.yaml @@ -0,0 +1,21 @@ +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: mcp-homelab + namespace: flux-system +spec: + interval: 10m + path: ./clusters/homelab/apps/mcp-homelab + prune: true + sourceRef: + kind: GitRepository + name: homelab # dein bestehendes GitRepository-Objekt + decryption: + provider: sops + secretRef: + name: sops-age # dein bestehendes SOPS-Secret + healthChecks: + - apiVersion: apps/v1 + kind: Deployment + name: mcp-homelab + namespace: mcp-homelab diff --git a/clusters/homelab/apps/mcp-homelab/deployment.yaml b/clusters/homelab/apps/mcp-homelab/deployment.yaml new file mode 100644 index 0000000..3059abb --- /dev/null +++ b/clusters/homelab/apps/mcp-homelab/deployment.yaml @@ -0,0 +1,105 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mcp-homelab + namespace: mcp-homelab +spec: + replicas: 1 + selector: + matchLabels: + app: mcp-homelab + template: + metadata: + labels: + app: mcp-homelab + spec: + serviceAccountName: mcp-homelab + imagePullSecrets: + - name: gitea-registry + + volumes: + - name: obsidian-repo + emptyDir: {} + + initContainers: + - name: git-clone + image: alpine/git:latest + command: + - git + - clone + - --depth=1 + - $(OBSIDIAN_GIT_URL) + - /obsidian + volumeMounts: + - name: obsidian-repo + mountPath: /obsidian + env: + - name: OBSIDIAN_GIT_URL + valueFrom: + secretKeyRef: + name: mcp-homelab-secret + key: obsidian-git-url + + containers: + - name: mcp-server + image: gittea.net-scope.org/admin/mcp-homelab:latest + imagePullPolicy: Always + ports: + - containerPort: 8000 + name: http + env: + - name: POSTGRES_DSN_MAP + valueFrom: + secretKeyRef: + name: mcp-homelab-secret + key: postgres-dsn-map + - name: OBSIDIAN_ROOT + value: /obsidian + volumeMounts: + - name: obsidian-repo + mountPath: /obsidian + readOnly: true + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + readinessProbe: + httpGet: + path: /mcp + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /mcp + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 30 + + - name: git-sync + image: registry.k8s.io/git-sync/git-sync:v4.3.0 + args: + - --repo=$(OBSIDIAN_GIT_URL) + - --root=/obsidian + - --link=current + - --period=5m + - --depth=1 + env: + - name: OBSIDIAN_GIT_URL + valueFrom: + secretKeyRef: + name: mcp-homelab-secret + key: obsidian-git-url + volumeMounts: + - name: obsidian-repo + mountPath: /obsidian + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 100m + memory: 64Mi diff --git a/clusters/homelab/apps/mcp-homelab/kustomization.yaml b/clusters/homelab/apps/mcp-homelab/kustomization.yaml new file mode 100644 index 0000000..ab91ce7 --- /dev/null +++ b/clusters/homelab/apps/mcp-homelab/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - serviceaccount.yaml + - secret.sops.yaml + - deployment.yaml + - service.yaml diff --git a/clusters/homelab/apps/mcp-homelab/namespace.yaml b/clusters/homelab/apps/mcp-homelab/namespace.yaml new file mode 100644 index 0000000..d61a224 --- /dev/null +++ b/clusters/homelab/apps/mcp-homelab/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: mcp-homelab diff --git a/clusters/homelab/apps/mcp-homelab/secret.sops.yaml b/clusters/homelab/apps/mcp-homelab/secret.sops.yaml new file mode 100644 index 0000000..3cbb7ce --- /dev/null +++ b/clusters/homelab/apps/mcp-homelab/secret.sops.yaml @@ -0,0 +1,43 @@ +#ENC[AES256_GCM,data:aWH6HGsACsPR4CNnd0bbOTQNLHKajxgyv9xILSxqycXR8NlX2y95QjVUwITdQsExm4UewEU8w/o1sZbNzJyELBCWmpKx,iv:wh9B0u2QKDacUYuF34idj2gZPo5fbKPi0jiWgUNV54U=,tag:j55j8+GjdcJ4F2m7NgzsFg==,type:comment] +# +#ENC[AES256_GCM,data:y4Vv8sYtYjBsln103TfImseoAaFavvY2ZRCMPKykL7wPLk/j24MYyqxUjYD8aI8eGdN/7Br1Bmg=,iv:zeblUfh1qqtfA0Mo4nQTQI3iAtkHLKpaBeLlu/WGoek=,tag:V6MEyn0So0dxZekGHtYK6Q==,type:comment] +#ENC[AES256_GCM,data:IXxQwsbZCfu+1q8hWGs2TZQMv3b8gJN5aoNnsxC5CLIo5Azo1+Uai0xhXCnlsfHMaKyWA4D5IRtbY5tNrVN1QTDYZULpBOvc8z6TThac8LxeLK/4mBGk5Ze0,iv:sb9blCNvZaY+JVsm3xVlUI8uiA3QEylZtA3PsP4hupA=,tag:aEoT4P8l1cuBEeWdpycMQA==,type:comment] +#ENC[AES256_GCM,data:ofN+IuJiOkb6eacB85fc6EA7jdaR0YgBx3mpRmzsSQ67uaw5tcm5WXeOu0y1fG4OS2oO1KYgkybBTM1axXGSpwa314KCKqmzOy0PJQ==,iv:eJ7fJuD+kFlaoelEZ1Zxvx7tk3jS5u0GfciNP8LGCKA=,tag:Hxb2sKw25jr9nECsekE3Ig==,type:comment] +# +#ENC[AES256_GCM,data:m7EBfqgMmhq/ck9mhaWNeo93iV9U55f+EvffAO+Quj8ll1PRI33+kr1UrssaMLd6ALk54WoSuAKUz49OnAjyBBKXzW4=,iv:/jonyVWRMeb0ws2xTY0vco3UZA+KUuraJv887bScpw8=,tag:5Oi7dwzVV7OdFlj0LD2xgQ==,type:comment] +#ENC[AES256_GCM,data:sjctJ1bxGUcFcSleSU6SATbDFuI8PG5/nYijCGr1/ZTaD4ajXyeqnVVFHp+aVrzZJAsf68P897qa1EuB/w04caljZnwC/Xfh,iv:FbjN464MOM4k1YCQNOArIzcBdY4w6P3Lv/+9TS0gmeE=,tag:7TcXsQ3uyeRcdeDB+CqxQw==,type:comment] +# +#ENC[AES256_GCM,data:qiJH+xqbWrDYrioVEqp19/2Z2k29iYpFgs0YWhaoDZNL027S5nxGN8mkAvG55fUayrBiARHL,iv:fGcGYKp5rSIL2W5PK+o1xT3ggkyiRxKpEvoJd2OT2XI=,tag:G3tMfzKlXG9otAfAZD4y9A==,type:comment] +#ENC[AES256_GCM,data:sDfPM45xav786JSBDfJGoW46DeQFFDR1+/Qx7LgGbIZ9PtG5YdBpab932QJjTIpOaGB/U8cfI0jN3xsJkrMDLWHcJf7zW3bxw2JJDifu5g==,iv:P4XkdXbjSyYFSkXcIsxxKrmh3Ri6A5RB92z+ubb48NQ=,tag:KMikrrlbpC0Je0jXqEDNcQ==,type:comment] +#ENC[AES256_GCM,data:bEbQxtuwW7otEgParlh1VuVA/MUCULaRY0vt3f1hGWU5rf+4ngguBY0toE7OQnsGxc8C/OwGVAVS0AJGkJs2wPIyJv6lYyoWpL+TcOAzbSrQdPAfj+Ch7Wm5V0Ws,iv:MqakFV6hsFwJazhSuy7WaQjJd5n3ULUm4ja3Jt6nDh8=,tag:0V85t0TEstuIH9jYuwGPhA==,type:comment] +apiVersion: ENC[AES256_GCM,data:lmI=,iv:AtOXGIjF5WlitRyc6rBf6AWbcxZoiD9h4FUzpkAMEBs=,tag:7DgvZCrwLMV/ftO7/ITqlQ==,type:str] +kind: ENC[AES256_GCM,data:jjAxX6/S,iv:Z16uZIVpDXWeW1Tb5x1LX5a4gEoOqqK147j+w2P1J6I=,tag:OalEXJUr8bKR2amkXvumSA==,type:str] +metadata: + name: ENC[AES256_GCM,data:FaqxQ0xe4PsKz8XXVvJecR3f,iv:EDMG+9c1+MTzcepVSQMr9V/+3r448hcm5VvljY/pkts=,tag:UD+dGESPftkjmdtHE4nYvg==,type:str] + namespace: ENC[AES256_GCM,data:qfrKbojLx2r80Vg=,iv:yCHoANU0YMCVk+7XyaZaJ4XmcnuDeAmRpvZ+/tVBwsY=,tag:VoCDjBYdLwO0UYHIrwpQXg==,type:str] +type: ENC[AES256_GCM,data:Wm3M3d+C,iv:DNE5u1Me8cDKFvLBuHYfhO4lWUaEIapCSEIa5HGcN2g=,tag:oMOlPocK1f/fXaFFsU5VQQ==,type:str] +stringData: + postgres-dsn-map: ENC[AES256_GCM,data:QhZ9oPJKPdo1JejgYdCRioRYSnMG2rPeFK7FiNHMyxwAkTE1Eo+w7KQSyg23FaZjlOiqQLsuhDOcxsOQQHU96tH/TxaFYyyZQHpE2RmY3uPhxtwxkA==,iv:yfl68UoTsDrefPqja99csaZe6EyusxvT76gcd7Xuha8=,tag:BhlDuxCWMGFNpdVqiU+E9g==,type:str] + obsidian-git-url: ENC[AES256_GCM,data:7XjpAD6J+SHocyGzB8OTOYSCHoiX/vk13FJzBr98dmypti8jgscjzm4/ra684UYgtwmjWCxB++msOQaHA+Rem4Veo5cUxGCpewUTJfGCzaEM,iv:EEmVyQrydUUT2PjhYve3Ne7P5HLnv5HReF4a31FYyOk=,tag:xkiHplMffn8md4FqSsuh4g==,type:str] +data: + .dockerconfigjson: ENC[AES256_GCM,data:hTR/Nu6pn5tYukkA2DL6LTzF7xHxK8JhozikA2A69F2BdAOBoR5qs9VhcCBge1g3rYlJhcipxXpyh49nsNDKw4nmElmsdxwqtLts784i5QqteUib04+M6rXgL8ims4nFif2hBKO7KdSOQZG3ifVWWcKssO6lag/R8U6Br8EwsH3S/qMzi3C6wLsxbXiJ14D3f8efXA==,iv:YXXI9OvNCJZx0JUFWd92z5qomtaYjojwiwGtrdQswnE=,tag:fwaV+FzZYYsYA3LJXVLTNw==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age1rydfcalt7pu4jnmh298lgjlasxyjldyaz3xyssk7v54lpu30fv0qna96ea + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUMFJIY2J3NU5STExjOFY3 + MTVTTkU2L2tkSUNla0V6RjN4dTBKTC95cXgwCllzRFVnTHR5MWEzN21mbkVwRGNX + ZW5md3VBaFpOQmlTbXZMS290TUVNdHMKLS0tIGY4ckR0VVQ5anVzYWNKcDBVY2VM + djNWcjVrQkcrOG5SbVAyRjI4RU5HUFUKBFiAp/eh9mUvM7LitqK5A/iOyDSr6+93 + nJXpIm3gQg8sTXauycMWU7WJ/nSa4UHVDVMr7FZxRIcGJdHm8Iz5mg== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-03-28T10:42:22Z" + mac: ENC[AES256_GCM,data:TKsn8y9wTo1sWIbwxH/s6PFB5HbQxlnTCz1m9KHpJyWwrsUpFhCM7VCoIJWBZDTnzuNK7xy/rc5kBgXI52lADywA6KZdzYBAvSwKIEBN/cccIu3SKnrRxbr9xP3uTZd2LCiqx4Nf9pRwU0bBkTerCt54FZ/YY5Tw1CBbrL7KtR8=,iv:LLM9NNPDnmg/xLTcGkM04SMLisRLFd/ksMJ/JQHFo50=,tag:3yXGjUl3wR66kgFgsYwHAA==,type:str] + pgp: [] + unencrypted_suffix: _unencrypted + version: 3.9.4 diff --git a/clusters/homelab/apps/mcp-homelab/service.yaml b/clusters/homelab/apps/mcp-homelab/service.yaml new file mode 100644 index 0000000..8f62d1c --- /dev/null +++ b/clusters/homelab/apps/mcp-homelab/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: mcp-homelab + namespace: mcp-homelab +spec: + selector: + app: mcp-homelab + ports: + - name: http + port: 8000 + targetPort: 8000 + type: ClusterIP diff --git a/clusters/homelab/apps/mcp-homelab/serviceaccount.yaml b/clusters/homelab/apps/mcp-homelab/serviceaccount.yaml new file mode 100644 index 0000000..f8128af --- /dev/null +++ b/clusters/homelab/apps/mcp-homelab/serviceaccount.yaml @@ -0,0 +1,60 @@ +# ServiceAccount fuer den MCP-Server Pod +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mcp-homelab + namespace: mcp-homelab +--- +# ClusterRole: ausschliesslich lesender Zugriff auf erlaubte Ressourcen. +# Bewusst restriktiver als "view" ClusterRole — keine Secrets, keine RBAC-Objekte. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: mcp-homelab-reader +rules: + - apiGroups: [""] + resources: + - pods + - pods/log + - services + - nodes + - events + - configmaps + - persistentvolumeclaims + - namespaces + verbs: ["get", "list", "watch"] + - apiGroups: ["apps"] + resources: + - deployments + - replicasets + - statefulsets + - daemonsets + verbs: ["get", "list", "watch"] + - apiGroups: ["kustomize.toolkit.fluxcd.io"] + resources: ["kustomizations"] + verbs: ["get", "list", "watch"] + - apiGroups: ["helm.toolkit.fluxcd.io"] + resources: ["helmreleases"] + verbs: ["get", "list", "watch"] + - apiGroups: ["source.toolkit.fluxcd.io"] + resources: + - gitrepositories + - helmrepositories + - imagerepositories + verbs: ["get", "list", "watch"] + - apiGroups: ["postgresql.cnpg.io"] + resources: ["clusters"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: mcp-homelab-reader +subjects: + - kind: ServiceAccount + name: mcp-homelab + namespace: mcp-homelab +roleRef: + kind: ClusterRole + name: mcp-homelab-reader + apiGroup: rbac.authorization.k8s.io diff --git a/mcp-homelab/Dockerfile b/mcp-homelab/Dockerfile new file mode 100644 index 0000000..5782faa --- /dev/null +++ b/mcp-homelab/Dockerfile @@ -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"] diff --git a/mcp-homelab/requirements.txt b/mcp-homelab/requirements.txt new file mode 100644 index 0000000..9bfbf45 --- /dev/null +++ b/mcp-homelab/requirements.txt @@ -0,0 +1,2 @@ +fastmcp>=3.1.1 +psycopg2-binary>=2.9.9 diff --git a/mcp-homelab/server.py b/mcp-homelab/server.py new file mode 100644 index 0000000..e729f88 --- /dev/null +++ b/mcp-homelab/server.py @@ -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, + )