Kubectl als MCP-Tool: Kubernetes Cluster Diagnose mit lokalem LLM
Im Beitrag Kubernetes Ops Hilfe mit lokalem LLM hat ein handgeschriebener OpenAI-kompatibler Proxy kubectl als Tool für ein lokales Sprachmodell verfügbar gemacht.
Dabei wurde Open WebUI als Chat-Oberfläche genutzt, so dass eine interaktive Diagnose möglich wurde.
llama.cpp bringt inzwischen zwei Dinge von Haus aus mit, die einen alternativen Aufbau möglich machen:
Eine eigene, in llama-server eingebettete Chat-Oberfläche und einen MCP-Client, der Werkzeuge über das Model Context Protocol (MCP) anspricht.
Dieser Beitrag zeigt, wie sich kubectl so über einen minimalen, selbst geschriebenen MCP-Server anbinden lässt, bewusst ohne auf ein fertiges MCP-Projekt zurückzugreifen, um die Prinzipien von MCP sichtbar zu machen.
Im Ordner sample liegt dazu ein vollständiges, lauffähiges Docker-Compose-Setup.
Für den produktiven Einsatz verweist der Beitrag am Ende auf etablierte Alternativen.
Architektur
Mit nur zwei Containern steht die benötigte Infrastruktur bereit:
-
llm:llama-server, stellt Modell und Chat-Oberfläche bereit. -
mcp-kubectl: ein selbst geschriebener MCP-Server, der genau ein Tool anbietet:run_kubectl.
services:
llm:
image: ghcr.io/ggml-org/llama.cpp:server
ports:
- "8080:8080"
volumes:
- ~/.llama:/models
command: >
--host 0.0.0.0 --port 8080 --ctx-size 8192
--jinja
-m /models/Qwen3-4B-Q4_K_M.gguf
mcp-kubectl:
build:
context: ./mcp-kubectl
network_mode: host
environment:
KUBECONFIG: /kube/config
volumes:
- ./kubeconfig:/kube/config:ro
--jinja aktiviert das Chat-Template des Modells, Voraussetzung für Tool-Calling.
mcp-kubectl läuft mit network_mode: host statt im isolierten Compose-Netzwerk:
Die Chat-Oberfläche läuft im Browser und muss mcp-kubectl direkt ansprechen können, ein interner Compose-Netzwerkname wie mcp-kubectl wäre aus dem Browser heraus nicht auflösbar.
Zwar bringt llama-server einen eigenen CORS-Proxy mit (--webui-mcp-proxy), das zugehörige Umschalten auf "use llama-server proxy" lässt sich in der WebUI aber erst nachträglich beim Bearbeiten einer bereits angelegten Verbindung aktivieren, nicht schon beim ersten Anlegen, weshalb dieses Beispiel direkt auf CORS-Header am MCP-Server setzt.
Durch network_mode: host ist der Server stattdessen ganz normal unter dem Hostnamen, auf dem es läuft, z.B. http://localhost:8090, erreichbar.
Das wäre durch ein normales Port-Binding auch zu erreichen, aber manchmal braucht kubectl im Container dieselbe Netzwerksicht wie der Host:
Lokale Cluster wie minikube oder kind betreiben ihren API-Server in einem eigenen, vom Compose-Netzwerk isolierten Docker-Netzwerk und wären aus einem regulären Container heraus gar nicht erreichbar.
kubectl get pods würde dort in den in run_kubectl gesetzten Timeout laufen, statt den Cluster zu erreichen.
(network_mode: host funktioniert nur unter Linux, unter Docker Desktop für Mac oder Windows braucht mcp-kubectl stattdessen eine explizite Verbindung zum Netzwerk des jeweiligen Clusters, z. B. per docker network connect.)
MCP-Server für kubectl
Ein MCP-Server der über einen Streamable-HTTP-Transport angebunden wird, ist relativ simpel: Es muss lediglich ein einzelner HTTP-Endpunkt, der JSON-RPC-2.0-Nachrichten per POST entgegennimmt, bereitgestellt werden. Für einen Tool-Aufruf werden vier Nachrichtentypen benötigt:
-
initialize– Client und Server handeln Protokollversion und Fähigkeiten aus. -
notifications/initialized– Bestätigung des Clients, dass die Initialisierung abgeschlossen ist. -
tools/list– der Server meldet, welche Werkzeuge er anbietet, inklusive JSON-Schema der Parameter. -
tools/call– der Client ruft ein konkretes Werkzeug mit Argumenten auf.
Ein minimaler Server für genau diese vier Nachrichten könnte mit Python und FastAPI so aussehen:
import shlex
import subprocess
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
ALLOWED_SUBCOMMANDS = {"get", "describe", "logs", "top", "events"}
TOOL = {
"name": "run_kubectl",
"description": "Run a read-only kubectl command (get, describe, logs, top, events).",
"inputSchema": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Full kubectl command, e.g. 'kubectl get pods -A'",
}
},
"required": ["command"],
},
}
def run_kubectl(command: str) -> str:
try:
args = shlex.split(command)
except ValueError as e:
return f"Error: cannot parse command ({e})"
if len(args) < 2 or args[0] != "kubectl" or args[1] not in ALLOWED_SUBCOMMANDS:
return f"Error: only {', '.join(sorted(ALLOWED_SUBCOMMANDS))} are allowed"
try:
result = subprocess.run(args, capture_output=True, text=True, timeout=15)
except subprocess.TimeoutExpired:
return "Error: kubectl command timed out after 15s, is the cluster reachable from this container?"
return result.stdout if result.returncode == 0 else f"Error:\n{result.stderr}"
@app.post("/mcp")
async def mcp_endpoint(request: Request):
msg = await request.json()
method = msg.get("method")
if method == "initialize":
return {
"jsonrpc": "2.0",
"id": msg["id"],
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {"tools": {}},
"serverInfo": {"name": "kubectl-mcp-demo", "version": "0.1.0"},
},
}
if method == "notifications/initialized":
return Response(status_code=202)
if method == "tools/list":
return {"jsonrpc": "2.0", "id": msg["id"], "result": {"tools": [TOOL]}}
if method == "tools/call":
if msg["params"]["name"] != "run_kubectl":
return {
"jsonrpc": "2.0",
"id": msg["id"],
"error": {"code": -32602, "message": "Unknown tool"},
}
output = run_kubectl(msg["params"]["arguments"]["command"])
return {
"jsonrpc": "2.0",
"id": msg["id"],
"result": {"content": [{"type": "text", "text": output}], "isError": False},
}
return {
"jsonrpc": "2.0",
"id": msg.get("id"),
"error": {"code": -32601, "message": f"Method not found: {method}"},
}
Der Server ist zur Demonstration auf das Nötigste reduziert:
Session-Verwaltung über den Mcp-Session-Id-Header und SSE-Streaming sind laut Spezifikation optional und fehlen hier komplett, eine einzelne JSON-Antwort pro Anfrage ist in dieser Implementierung möglich.
Auch die Unterscheidung zwischen Requests und Notifications ist pragmatisch auf notifications/initialized festgelegt, statt generisch über das Fehlen eines id-Felds erkannt zu werden.
Einen GET-Aufruf auf /mcp beantwortet FastAPI automatisch mit 405 Method Not Allowed, weil nur eine POST-Route existiert.
Genau das sieht die Spezifikation für einen Server vor, der keinen serverseitigen SSE-Stream anbietet.
llama.cpp mit dem MCP-Server verbinden
Vor dem ersten Start lädt sample/download.sh das Modell nach ~/.llama, unter sample/kubeconfig wird die kube-config des Zielclusters erwartet.
Anschließend startet docker compose up --build im Ordner sample beide Container.
Die Chat-Oberfläche ist danach unter http://localhost:8080 erreichbar.
In den Einstellungen lässt sich unter den MCP-Servern ein neuer Eintrag anlegen:
-
Name:
kubectl -
Server-URL:
http://localhost:8090/mcp -
Transport: Streamable HTTP (Standard)
Die Einbindung wird im Browser UI dann entsprechend angezeigt.
Ist das Tool verbunden, kann eine Frage wie "Welche Pods laufen aktuell nicht stabil?" gestellt werden.
Das Modell entscheidet sich für einen tools/call an run_kubectl, etwa mit kubectl get pods --all-namespaces --field-selector=status.phase!=Running, und fasst die Ausgabe anschließend zusammen.
Welches konkrete Kommando das Modell wählt, hängt von Modell und Prompt ab.
Sicherheitsaspekte
Im vorherigen Beitrag hat die Prüfung if not command.startswith("kubectl") in Kombination mit subprocess.run(command, shell=True, …) eine Lücke offengelassen:
Ein Befehl wie kubectl get pods; cat /kube/config hätte die Prüfung bestanden, weil die Shell das Semikolon trotzdem ausgewertet hätte.
Der Server hier arbeitet mit shlex.split() und subprocess.run(args, …) ohne shell=True, wodurch Shell-Metazeichen wie ;, ` oder $() nicht mehr interpretiert werden.
Zusätzlich ist die Menge erlaubter Subcommands auf lesende Operationen eingeschränkt.
Komplett sicher ist das trotzdem nicht:
kubectl get secret -o yaml fällt unter das erlaubte get und legt trotzdem Secret-Inhalte offen.
Eine wirklich sichere Lösung bräuchte entweder eine feinere Filterung auf Ressourcentyp- und Feldebene oder, besser, ein kube-config mit einem RBAC-eingeschränkten ServiceAccount, der von vornherein nur die Rechte hat, die der Anwendungsfall erfordert.
Auch allow_origins=["*"] in der CORSMiddleware ist eine bewusste Vereinfachung für dieses lokale Beispiel.
Die MCP-Spezifikation verlangt von einem Server über Streamable HTTP explizit, den Origin-Header zu validieren, um DNS-Rebinding-Angriffe zu verhindern, bei denen eine im Browser geöffnete, fremde Website versucht, den lokal erreichbaren MCP-Server anzusprechen.
Für den produktiven Einsatz gehört hier eine feste Allowlist an erlaubten Origins hin, statt eines Wildcards.
network_mode: host weitet die Angriffsfläche zusätzlich aus: Der Container teilt sich den kompletten Netzwerk-Namespace des Hosts, nicht nur den Zugriff auf den Cluster.
Für ein Beispiel zum Ausprobieren auf der eigenen Maschine ist das akzeptabel, für einen produktiven Server wäre eine gezielte Netzwerkverbindung zum jeweiligen Cluster-Netzwerk vorzuziehen.
Auf der anderen Seite werden Tool Aufrufe in aktuellen UIs zunächst aufgehalten und eine explizite Genehmigung des Users eingeholt, bevor es tatsächlich zur Ausführung kommt.
Dabei ist, wie so oft, natürlich der Mensch das schwächste Glied der Kette und neigt schnell dazu, alles zu genehmigen, wenn die Arbeit dadurch schneller erledigt wird.
Fazit
Der eigene MCP-Server zeigt an einem einzigen Tool, wie das Model Context Protoocl genutzt werden kann, um eigene Werkzeuge bereitzustellen.
Für den produktiven Einsatz ist das, genau wie der Proxy-Agent aus dem vorherigen Beitrag, eher zum Verständnis der Prinzipien gedacht als eine empfehlenswerte Zielarchitektur darzustellen.
Wer kubectl oder allgemein Kubernetes produktiv an ein LLM anbinden möchte, findet dafür bereits ausgereifte Implementierungen:
-
kubernetes-mcp-server spricht direkt mit der Kubernetes-API, statt intern kubectl per Subprozess aufzurufen.
-
kagent (Solo.io, CNCF Sandbox) baut eine ganze Agenten-Laufzeit für Kubernetes auf MCP als Integrationsschicht auf.
-
k8sgpt (CNCF Sandbox) beantwortet Fragen zum Clusterzustand mit vordefinierten Analyzern statt mit freier Kommandoausführung und lässt sich über LocalAI oder Ollama an dasselbe lokale Modell anbinden.
Zu den Themen Kubernetes und lokale KI-Integration bieten wir sowohl Beratung, Entwicklungsunterstützung als auch passende Schulungen an:
Auch für Ihren individuellen Bedarf können wir Workshops und Schulungen anbieten. Sprechen Sie uns gerne an.