feat: add mcp-homelab jarvis agent
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- serviceaccount.yaml
|
||||||
|
- secret.sops.yaml
|
||||||
|
- deployment.yaml
|
||||||
|
- service.yaml
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: mcp-homelab
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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