Files

327 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)
# ── 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 (MoFr) | 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()