#!/usr/bin/env python3 """ Mission Control 2.0 — Stack-Management MCP Server (für Hermes). Gibt dem Hermes-Agenten Werkzeuge, um den lokalen LLM-Stack SELBST zu steuern: Modelle ansehen/entdecken/installieren/löschen, Rollen & Routing setzen, Modelle laden/entladen, Backups, Dienste prüfen/neu starten, Updates prüfen/anwenden, System-Status lesen. Spricht die MC-2-REST-API (/api/*). Damit ist Hermes echte Control-Plane des Stacks (Plan: „MC-Mgmt-MCP, damit Hermes den Stack selbst steuert"). Läuft typischerweise lokal auf der Box neben Hermes. Tool-Beschreibungen bewusst als GUARDS formuliert (konditional, nicht imperativ), damit kleine Modelle nicht in Aufruf-Schleifen laufen. Env: MC_URL (default http://127.0.0.1:9001), MC_TOKEN (optional). Install: pip install mcp httpx """ import json import os import httpx from mcp.server.fastmcp import FastMCP MC_URL = os.environ.get("MC_URL", "http://127.0.0.1:9001").rstrip("/") MC_TOKEN = os.environ.get("MC_TOKEN", "") mcp = FastMCP("mission-control-stack") def _h() -> dict: return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {} def _get(path: str, **params): r = httpx.get(f"{MC_URL}{path}", headers=_h(), params=params, timeout=30) r.raise_for_status() return r.json() def _post(path: str, data: dict | None = None): r = httpx.post(f"{MC_URL}{path}", headers=_h(), json=data or {}, timeout=120) r.raise_for_status() return r.json() def _put(path: str, data: dict): r = httpx.put(f"{MC_URL}{path}", headers=_h(), json=data, timeout=20) r.raise_for_status() return r.json() def _delete(path: str): r = httpx.delete(f"{MC_URL}{path}", headers=_h(), timeout=30) r.raise_for_status() return r.json() # ── Modelle: ansehen / entdecken ──────────────────────────────────────────── @mcp.tool() def list_models() -> str: """Listet die in llama-swap konfigurierten Modelle (Name, Rolle, Kontext, Fähigkeiten) und welche gerade geladen sind.""" data = _get("/api/models") running = set(data.get("running") or []) lines = [] for m in data.get("models", []): warm = " [LÄUFT]" if m["name"] in running else "" lines.append( f"- {m['name']}{warm} (Rolle: {m.get('role') or '—'}, ctx: {m.get('ctx')}, " f"tools: {m['capabilities']['tools']}, MoE: {m['capabilities']['moe']})" ) return "\n".join(lines) or "Keine Modelle konfiguriert." @mcp.tool() def discover_models() -> str: """Zeigt die aktuell besten Modelle je Kategorie (live von HuggingFace, Hardware-Fit). Nutze dies, bevor du ein neues Modell vorschlägst/installierst.""" data = _get("/api/discover") out = [f"System-RAM: {data.get('sys_ram_gb')} GB"] for c in data.get("categories", []): rec = c.get("recommended") or "—" out.append(f"\n## {c['title']} (beste Wahl: {rec})") for m in c["models"][:3]: out.append(f" - {m['repo']} · {m['fit']['text']} (~{m['fit']['req_gb']} GB)") return "\n".join(out) @mcp.tool() def hf_search(q: str) -> str: """Sucht GGUF-Modelle auf HuggingFace nach Stichwort. Nutze dies, wenn du ein konkretes Modell finden willst, das nicht in discover_models auftaucht.""" res = _get("/api/hf/search", q=q).get("results", []) if not res: return f"Keine HF-Treffer für '{q}'." return json.dumps(res[:15], ensure_ascii=False, indent=2) @mcp.tool() def hf_quants(repo: str) -> str: """Listet die verfügbaren Quantisierungen (Q4_K_M, Q8_0, …) eines HF-Repos. Nutze dies VOR install_model, um die passende Quant-Stufe zu wählen.""" d = _get("/api/hf/quants", repo=repo) return f"Repo {d['repo']} — Quants: " + ", ".join(d.get("quants", [])) or "Keine GGUF-Quants gefunden." # ── Modelle: installieren / löschen / laden ───────────────────────────────── @mcp.tool() def install_model(repo: str, role: str = "", quant: str = "Q4_K_M", ctx: int = 0, jinja: bool = False) -> str: """Lädt ein GGUF-Modell von HuggingFace (Hintergrund-Job) UND trägt es sofort in llama-swap ein. Akzeptiert volle HF-URL ODER org/repo. ctx=0 → setup-bewusst automatisch. jinja=True für Tool-Calling-Modelle (Agent-Hirne). role z.B. fast/heavy/coder/vision. Gibt eine job_id zurück — Fortschritt via list_jobs prüfen.""" body = {"repo": repo, "quant": quant, "jinja": jinja} if role: body["role"] = role if ctx > 0: body["ctx"] = ctx r = _post("/api/models/install", body) return (f"Installation gestartet: model_id={r.get('model_id')}, job_id={r.get('job_id')}, " f"{r.get('files')} Datei(en), ~{round((r.get('total_bytes') or 0)/1e9, 1)} GB. " f"Fortschritt mit list_jobs prüfen.") @mcp.tool() def list_jobs() -> str: """Zeigt laufende/abgeschlossene Hintergrund-Jobs (v.a. Modell-Downloads) mit Fortschritt.""" jobs = _get("/api/jobs").get("jobs", []) if not jobs: return "Keine Jobs." out = [] for j in jobs: prog = j.get("progress") p = f" {round(prog*100)}%" if isinstance(prog, (int, float)) else "" out.append(f"- [{j.get('id', '?')[:8]}] {j.get('label', '?')} — {j.get('status', '?')}{p}") return "\n".join(out) @mcp.tool() def cancel_job(job_id: str) -> str: """Bricht einen laufenden Job (z.B. Download) ab.""" ok = _post(f"/api/jobs/{job_id}/cancel").get("ok") return "Job abgebrochen." if ok else "Job nicht gefunden / nicht abbrechbar." @mcp.tool() def delete_model(model_id: str) -> str: """Entfernt ein Modell aus llama-swap (Eintrag + Dateien). NUR wenn der Nutzer es will oder es eindeutig veraltet/ersetzt ist — vorher list_models prüfen.""" _delete(f"/api/models/{model_id}") return f"Modell '{model_id}' gelöscht." @mcp.tool() def load_model(model_id: str) -> str: """Lädt ein Modell aktiv in den Speicher (warm). Nützlich, um Latenz beim ersten echten Request zu vermeiden.""" return "Geladen." if _post(f"/api/models/{model_id}/load").get("ok") else "Laden fehlgeschlagen." @mcp.tool() def unload_model(model_id: str = "") -> str: """Entlädt ein Modell aus dem Speicher (gibt RAM/GTT frei). Leer = ALLE entladen.""" path = f"/api/models/{model_id}/unload" if model_id else "/api/models/unload" return "Entladen." if _post(path).get("ok") else "Entladen fehlgeschlagen." # ── Rollen & Routing ──────────────────────────────────────────────────────── @mcp.tool() def set_model_role(model_id: str, role: str = "") -> str: """Setzt/entfernt den Rollen-Alias eines Modells (fast/heavy/coder/vision/hermes/…). Leer = Rolle entfernen. So bestimmst du z.B. welches Modell 'heavy' ist.""" _post(f"/api/models/{model_id}/role", {"role": role or None}) return f"Rolle für '{model_id}': {role or '— (entfernt)'}" @mcp.tool() def set_route(name: str, target_alias: str) -> str: """Setzt das Gateway-Routing: Gateway-Modellname (fast/heavy/auto/…) → llama-swap-Alias.""" _put("/api/routing/route", {"name": name, "target_alias": target_alias}) return f"Routing gesetzt: {name} → {target_alias}" @mcp.tool() def routing_overview() -> str: """Zeigt das aktuelle Gateway-Routing + Fallbacks.""" return json.dumps(_get("/api/routing"), ensure_ascii=False, indent=2) @mcp.tool() def register_model(model_path: str, role: str = "", ctx: int = 8192, jinja: bool = False) -> str: """Trägt ein bereits heruntergeladenes GGUF als llama-swap-Modell ein (cmd + Rollen-Alias). role z.B. fast/heavy/coder/vision/hermes. jinja=True für Tool-Calling (Agent-Hirn). Für Download+Eintrag in einem Schritt nutze install_model.""" res = _post("/api/models/register", {"model_path": model_path, "role": role or None, "ctx": ctx, "jinja": jinja}) return f"Eingetragen als '{res.get('model_id')}'." # ── System / Status / Wartung ─────────────────────────────────────────────── @mcp.tool() def system_status() -> str: """Live-Auslastung der Box (CPU/RAM/GPU/Disk).""" s = _get("/api/system/status") g = s.get("gpu") or {} return (f"CPU {s['cpu']['percent']}% · RAM {s['ram']['percent']}% · " f"GPU {g.get('busy_percent', '—')}% · Disk {(s.get('disk') or {}).get('percent', '—')}%") @mcp.tool() def list_services() -> str: """Erreichbarkeit aller Stack-Dienste (Engine, Gateway, Hermes, Mem0, Voice).""" data = _get("/api/system/services") return "\n".join( f"{'✅' if s['ok'] else '❌'} {s['name']} ({s['url']})" for s in data.get("services", []) ) or "Keine Dienste." @mcp.tool() def restart_service(service: str) -> str: """Startet einen erlaubten Dienst neu. Erlaubt: llama-swap (Engine) | mission-control-2 | hermes-gateway | hermes-terminal | mem0-service | voice-service. Der Engine-Neustart (llama-swap = System-Dienst) kann das Box-Passwort verlangen, wenn kein NOPASSWD gesetzt ist.""" res = _post("/api/system/restart", {"service": service}) return f"Restart {service}: {'ok' if res.get('ok') else 'Fehler — ' + res.get('err', '')}" @mcp.tool() def backup_now() -> str: """Erstellt SOFORT ein Voll-Backup (mem0 + Configs + Secrets). Nutze dies vor riskanten Änderungen oder wenn der Nutzer ein Backup will.""" r = _post("/api/system/backup") return f"Backup erstellt: {json.dumps(r, ensure_ascii=False)}" @mcp.tool() def list_backups() -> str: """Listet vorhandene Backups (Datei, Zeit, Größe).""" bks = _get("/api/system/backups").get("backups", []) if not bks: return "Keine Backups." return json.dumps(bks, ensure_ascii=False, indent=2) @mcp.tool() def token_stats() -> str: """Token-Verbrauch + Cloud-Ersparnis (was die lokale Nutzung gegenüber Cloud-APIs spart).""" return json.dumps(_get("/api/system/token-stats"), ensure_ascii=False, indent=2) @mcp.tool() def check_updates() -> str: """Prüft, ob Updates für OS / Engine (llama.cpp) / Router (llama-swap) / Hermes anstehen.""" return json.dumps(_get("/api/maintenance/updates"), ensure_ascii=False, indent=2) @mcp.tool() def apply_update(kind: str) -> str: """Wendet ein Update an. kind: os | engine | swap | hermes. NUR wenn der Nutzer es will oder check_updates ein anstehendes Update zeigt. Kann den jeweiligen Dienst kurz neu starten.""" kind = kind.lower().strip() routes = {"os": "/api/maintenance/os-update", "engine": "/api/maintenance/engine-update", "swap": "/api/maintenance/swap-update", "hermes": "/api/maintenance/hermes-update"} if kind not in routes: return "Unbekannte Update-Art. Erlaubt: os | engine | swap | hermes." return json.dumps(_post(routes[kind]), ensure_ascii=False, indent=2) @mcp.tool() def get_ci_status() -> str: """Prüft den letzten CI-Build-Status auf der AI-Box via Gitea API. Nützlich, um nach einem Push zu verifizieren, ob das Frontend und Backend grün gebaut haben.""" token = "" # 1. Fallback: Versuch via git credential manager (zukunftsfähig & plattformübergreifend) try: import subprocess p = subprocess.run( ["git", "credential", "fill"], input=b"protocol=https\nhost=git.tobisniceshomelab.ddnsfree.com\n", capture_output=True, check=True ) out = p.stdout.decode(errors="replace") import re p_match = re.search(r"password=(.+)", out) if p_match: token = p_match.group(1).strip() except Exception: pass # 2. Fallback: Datei (hauptsächlich auf der AI-Box genutzt) if not token: token_path = os.path.expanduser("~/.git-credentials") try: with open(token_path, "r") as f: creds = f.read() import re m = re.search(r'https://[^:]+:([^@]+)@git\.tobisniceshomelab', creds) token = m.group(1) if m else "" except Exception: token = "" if not token: return "❌ Gitea Token konnte nicht via 'git credential' oder ~/.git-credentials geladen werden. CI Status kann nicht geprüft werden." # Gitea URL von der Box aus intern erreichbar repo_url = "http://192.168.178.153:3000/api/v1/repos/Hitonabi/mission-control-v2/actions/runs?limit=1" try: r = httpx.get(repo_url, headers={"Authorization": f"token {token}"}, timeout=10) r.raise_for_status() runs = r.json() if not runs: return "Keine CI-Runs gefunden." run = runs[0] status = run.get("status") name = run.get("name") sha = run.get("head_sha", "")[:7] if status == "success": return f"✅ CI-Build ({name} @ {sha}) war ERFOLGREICH." elif status == "failure": return f"❌ CI-Build ({name} @ {sha}) ist FEHLGESCHLAGEN. Bitte in Gitea prüfen!" elif status in ["running", "pending"]: return f"⏳ CI-Build ({name} @ {sha}) LÄUFT GERADE ({status})." else: return f"ℹ️ CI-Build ({name} @ {sha}) Status: {status}" except Exception as e: return f"❌ Fehler bei der Gitea-API Abfrage: {e!s}" # ── Erinnerungen & Routinen (A3): die Box sagt dem Commander zur Zeit aktiv Bescheid ───────── @mcp.tool() def reminder_create(text: str, when: str, repeat: str = "") -> str: """Legt eine Erinnerung an: zur angegebenen Zeit sagt die Box dem Commander den Text AKTIV an (Lucy spricht + Telegram). NUR nutzen, wenn der Commander ausdrücklich erinnert werden will ('erinner mich', 'sag mir um', 'jeden Morgen'). when = ISO-8601 mit Datum UND Uhrzeit in der Lokalzeit des Commanders, z.B. 2026-07-04T09:00. Relative Angaben ('in 20 Minuten', 'morgen früh') selbst aus der aktuellen Zeit im Kontext umrechnen. repeat: '' einmalig | daily (täglich) | weekdays (Mo–Fr) | weekly (wöchentlich). text = die Ansage selbst, kurz und direkt (ohne 'Erinnerung:' davor — das ergänzt die Box).""" try: item = _post("/api/reminders", {"text": text, "when": when, "repeat": repeat})["item"] except httpx.HTTPStatusError as e: return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}" rep = {"daily": " (täglich)", "weekdays": " (werktags)", "weekly": " (wöchentlich)"}.get(item["repeat"], "") return f"Erinnerung #{item['id']} angelegt: {item['when_local']}{rep} — „{item['text']}“" @mcp.tool() def reminder_list() -> str: """Zeigt alle anstehenden Erinnerungen/Routinen (Nummer, Zeit, Text). Vor dem Löschen hiermit die Nummer ermitteln.""" items = _get("/api/reminders")["items"] if not items: return "Keine Erinnerungen angelegt." rep = {"daily": " · täglich", "weekdays": " · werktags", "weekly": " · wöchentlich"} return "\n".join(f"#{i['id']} · {i['when_local']}{rep.get(i['repeat'], '')} — {i['text']}" for i in items) @mcp.tool() def reminder_delete(reminder_id: int) -> str: """Löscht eine Erinnerung/Routine endgültig (Nummer aus reminder_list). NUR auf ausdrücklichen Wunsch des Commanders.""" try: gone = _delete(f"/api/reminders/{reminder_id}")["deleted"] except httpx.HTTPStatusError as e: return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}" return f"Erinnerung #{gone['id']} gelöscht ({gone['when_local']} — „{gone['text']}“)." @mcp.tool() def service_logs(service: str, lines: int = 100) -> str: """Liest die letzten Log-Zeilen eines Dienstes (Diagnose). service z.B. hermes-gateway, mission-control-2, voice-service.""" return json.dumps(_get("/api/maintenance/logs", service=service, lines=lines), ensure_ascii=False, indent=2) if __name__ == "__main__": mcp.run()