simplify Athena runtime and centralize MCP management

This commit is contained in:
Mikei386
2026-08-26 09:57:38 +02:00
parent 104c9904a5
commit ab671a6396
31 changed files with 918 additions and 419 deletions
+15 -24
View File
@@ -13,9 +13,9 @@ Fehlersuche sie benötigt.
- Lokale Konfiguration und Secrets: `/etc/mike-ai` (niemals in Git). - Lokale Konfiguration und Secrets: `/etc/mike-ai` (niemals in Git).
- Benutzerzugriff auf KI-Dienste: über WireGuard, nicht über das Uni-LAN. - Benutzerzugriff auf KI-Dienste: über WireGuard, nicht über das Uni-LAN.
- OpenAI-kompatible Modell-API: Profile Router auf Port 8081. - OpenAI-kompatible Modell-API: Profile Router auf Port 8081.
- Oberfläche auf Athena: OpenWebUI. Der offizielle Hermes Agent läuft auf - Oberfläche und Agent: Hermes auf Unraid unter
Unraid unter `/mnt/nvme-storage/appdata/Hermes-Agent` und verwendet Athenas `/mnt/nvme-storage/appdata/Hermes-Agent`. Athena stellt dafür nur die
Router als Modell-Backend. OpenAI-kompatible Router-API bereit. OpenWebUI ist abgeschaltetes Rückfallnetz.
- Inferenz: genau ein aktives llama.cpp-Textprofil; der Router wechselt bei - Inferenz: genau ein aktives llama.cpp-Textprofil; der Router wechselt bei
Bedarf zwischen Fast, Medium, Large, Ultra und Uncensored. Bedarf zwischen Fast, Medium, Large, Ultra und Uncensored.
@@ -35,16 +35,11 @@ Migration höchstens als gekennzeichnetes Archiv existieren.
## Container-Prinzip ## Container-Prinzip
Ein eigener Container ist sinnvoll, wenn ein Dienst eigene Abhängigkeiten, Athena betreibt nur Inferenz, Router, Sprache/Bild, Backup und den
Zugangsdaten oder eine eigene Fehlergrenze hat. Deshalb bleiben die fachlichen hostgebundenen Athena-Operator. Portable Fach-MCPs laufen als getrennte
MCPs getrennt, beispielsweise Home Assistant, Unraid, ARR, Navidrome, Deemix, Unterprozesse im **einen MCPHub-Container auf Unraid**. Sie bleiben unter
Web und SSH. Es gibt jedoch keine zusätzlichen MCPs für einzelne `/mcp/NAME` einzeln sichtbar und abschaltbar, brauchen aber nicht je einen
Zwischenschritte einer Installation. Docker-Container. `config/mcp-registry.json` ist die einzige Serverliste.
Hermes auf Unraid ist die primäre Oberfläche für längere administrative und
agentische Aufgaben. OpenWebUI auf Athena erhält weiterhin die Werkzeuge, die
für kurze Abfragen sinnvoll sind. Ein neuer MCP muss nicht automatisch in jede
Oberfläche eingebunden werden; das richtet sich nach dem Auftrag.
## Standardablauf für Änderungen ## Standardablauf für Änderungen
@@ -63,18 +58,14 @@ Werkzeugaufruf wird höchstens einmal wiederholt.
## Neuer MCP ## Neuer MCP
Für einen neuen MCP sind gewöhnlich nur diese Teile nötig: Portable MCPs werden nach dem Skill `mcphub-deployer` in das reproduzierbare
MCPHub-Image eingebaut. Dazu gehören gepinnte Quelle, genau ein Registry-Eintrag,
Secret-Datei nur im Appdata, Build, Handshake und eine read-only-Probe. Danach
wird ausschließlich MCPHub über Unraid DockerMan neu erstellt. Router, Qwen,
WireGuard und andere Container werden nicht neu gestartet.
1. Servercode und Dockerfile unter `platform/mcp/`. Nur ein Werkzeug, das Athenas Host selbst verwalten muss, gehört in den
2. Ein Service im eingebundenen `platform/mcp/compose.yaml`. Athena-Operator. Es wird kein zweiter allgemeiner Terminal- oder Doku-MCP gebaut.
3. Eine Env-Beispieldatei unter `config/`; echte Werte nach `/etc/mike-ai`.
4. Genau ein Eintrag in `config/mcp-registry.json`; daraus werden Hermes und
OpenWebUI automatisch erzeugt.
5. Ein kleiner Test sowie ein kurzer Eintrag in dieser Datei oder in der
Komponentenübersicht, falls wirklich zusätzliche Erklärung nötig ist.
Dann wird nur der neue MCP gebaut und gestartet. Router, Qwen, WireGuard,
Hermes und der komplette Stack werden nicht pauschal neu gestartet.
## Temporär oder dauerhaft ## Temporär oder dauerhaft
+15 -14
View File
@@ -1,9 +1,8 @@
# Athena AI # Athena AI
Ein reproduzierbarer Docker-Stack für Athenas lokale KI. Athena stellt Router, Ein reproduzierbarer Docker-Stack für Athenas lokale Inferenz. Athena stellt
llama.cpp-Profile, OpenWebUI, Sprache und Bildgenerierung bereit. Der offizielle Router, llama.cpp-Profile, Sprache und Bildgenerierung bereit. Der offizielle
Hermes Agent und die portablen Werkzeuge laufen auf Unraid und werden dort Hermes Agent und MCPHub laufen auf Unraid und werden dort mit Appdata gesichert.
zusammen mit dem übrigen Appdata gesichert.
## Aufbau ## Aufbau
@@ -21,11 +20,10 @@ zusammen mit dem übrigen Appdata gesichert.
- Home Assistant, ARR, Unraid, Navidrome, Deemix und GitHub laufen gemeinsam im - Home Assistant, ARR, Unraid, Navidrome, Deemix und GitHub laufen gemeinsam im
MCPHub-Container auf Unraid, bleiben aber als getrennte MCP-Server unter MCPHub-Container auf Unraid, bleiben aber als getrennte MCP-Server unter
`/mcp/NAME` sichtbar, abschaltbar und unabhängig für Clients freigebbar. `/mcp/NAME` sichtbar, abschaltbar und unabhängig für Clients freigebbar.
- SearXNG/TinySearch dürfen als eigene Such-Backends laufen. Der eigentliche - Hermes verwendet für allgemeine Recherche seine integrierten Webwerkzeuge;
Web-MCP-Adapter zieht ebenfalls in den MCPHub, sobald der Backend-Pfad aus dem der frühere Athena-Webadapter wird nicht mehr gestartet.
Unraid-Netz verfügbar ist. - `config/mcp-registry.json` ist die einzige Liste der MCPHub-Server und ihrer
- `config/mcp-registry.json` ist die einzige Liste der MCPs für Hermes und Client-Registrierungen. `platform/mcp/sync-clients.py` erzeugt Hermes daraus.
OpenWebUI. `platform/mcp/sync-clients.py` erzeugt beide Registrierungen.
- Modelle und Athena-Backups liegen auf `/data`. Hermes liegt vollständig unter - Modelle und Athena-Backups liegen auf `/data`. Hermes liegt vollständig unter
`/mnt/nvme-storage/appdata/Hermes-Agent`; MCPHub-Zustand, Client-Schlüssel und `/mnt/nvme-storage/appdata/Hermes-Agent`; MCPHub-Zustand, Client-Schlüssel und
MCP-Zugänge liegen unter `/mnt/nvme-storage/appdata/MCPHub`. MCP-Zugänge liegen unter `/mnt/nvme-storage/appdata/MCPHub`.
@@ -43,7 +41,7 @@ sudo ./install.sh --config config/install.env
``` ```
Das Skript installiert Docker und NVIDIA-Unterstützung, lädt die konfigurierten Das Skript installiert Docker und NVIDIA-Unterstützung, lädt die konfigurierten
Modelle, baut den Stack und startet die benötigten Profile und MCPs. Modelle und startet ausschließlich Athenas Inferenz-Kern plus Operator.
## Bedienung ## Bedienung
@@ -51,14 +49,16 @@ Modelle, baut den Stack und startet die benötigten Profile und MCPs.
# Gesamten Stack anzeigen # Gesamten Stack anzeigen
docker compose --env-file /etc/mike-ai/stack.env ps docker compose --env-file /etc/mike-ai/stack.env ps
# Eine gezielte Athena-Komponente ausrollen # Erst anzeigen, dann eine gezielte Komponente ohne Nebenwirkungen ausrollen
docker compose --env-file /etc/mike-ai/stack.env up -d --build router ./manage.sh --dry-run deploy router
./manage.sh deploy router
# Sofortiges Datenbackup zusätzlich zum Fünf-Stunden-Zeitplan # Sofortiges Datenbackup zusätzlich zum Fünf-Stunden-Zeitplan
docker exec mike-ai-backup backup docker exec mike-ai-backup backup
```
OpenWebUI: `http://<WireGuard-IP>:8080` # Kurzer read-only Ende-zu-Ende-Test nach jedem Release
sudo ./smoke-test.sh
```
Router-API: `http://<WireGuard-IP>:8081/v1` Router-API: `http://<WireGuard-IP>:8081/v1`
@@ -81,6 +81,7 @@ Details, Prüfschritte und der exakte Sicherungsumfang stehen in
- [`ATHENA.md`](ATHENA.md) – kurze Maschinen- und Operatoranleitung - [`ATHENA.md`](ATHENA.md) – kurze Maschinen- und Operatoranleitung
- [`docs/STANDARD_PROFILE_MATRIX.md`](docs/STANDARD_PROFILE_MATRIX.md) – Profile und Messwerte - [`docs/STANDARD_PROFILE_MATRIX.md`](docs/STANDARD_PROFILE_MATRIX.md) – Profile und Messwerte
- [`docs/MCP_SERVERS.md`](docs/MCP_SERVERS.md) – automatisch erzeugte MCP-Liste
- [`docs/RECOVERY.md`](docs/RECOVERY.md) – Backup und Neuaufbau - [`docs/RECOVERY.md`](docs/RECOVERY.md) – Backup und Neuaufbau
Die MCPHub-Installation, Endpunkte und der schrittweise Rückbau der alten Die MCPHub-Installation, Endpunkte und der schrittweise Rückbau der alten
-2
View File
@@ -928,11 +928,9 @@ services:
- /data/docker-backups:/archive - /data/docker-backups:/archive
- /etc/mike-ai:/backup/etc-mike-ai:ro - /etc/mike-ai:/backup/etc-mike-ai:ro
- /opt/mike-ai/stack:/backup/stack:ro - /opt/mike-ai/stack:/backup/stack:ro
- open-webui-data:/backup/volumes/open-webui-data:ro
- piper-data:/backup/volumes/piper-data:ro - piper-data:/backup/volumes/piper-data:ro
- router-state:/backup/volumes/router-state:ro - router-state:/backup/volumes/router-state:ro
- router-images:/backup/volumes/router-images:ro - router-images:/backup/volumes/router-images:ro
- tinysearch-models:/backup/volumes/tinysearch-models:ro
security_opt: ["no-new-privileges:true"] security_opt: ["no-new-privileges:true"]
networks: networks:
+79 -16
View File
@@ -11,16 +11,13 @@
"env_file": "/etc/mike-ai/mcphub-client.env", "env_file": "/etc/mike-ai/mcphub-client.env",
"key_env": "MCPHUB_BEARER_TOKEN", "key_env": "MCPHUB_BEARER_TOKEN",
"auth_type": "bearer", "auth_type": "bearer",
"timeout": 900 "timeout": 900,
}, "hub": {
{ "type": "streamable-http",
"id": "web-general-local", "url": "http://192.168.1.212:8202/mcp",
"hermes_id": "web-general", "owner": "admin",
"name": "Allgemeines Web (TinySearch)", "enabled": true
"description": "Breite Websuche und Seitenabruf für beliebige öffentliche Websites. Kurz und gezielt suchen; keine vollständigen Websites rekursiv einlesen.", }
"url": "http://tinysearch:8000/mcp",
"clients": ["hermes", "openwebui"],
"timeout": 180
}, },
{ {
"id": "github-local", "id": "github-local",
@@ -33,7 +30,13 @@
"key_env": "MCPHUB_BEARER_TOKEN", "key_env": "MCPHUB_BEARER_TOKEN",
"auth_type": "bearer", "auth_type": "bearer",
"timeout": 300, "timeout": 300,
"functions": "github-search_repositories,github-get_file_contents,github-search_code" "functions": "github-search_repositories,github-get_file_contents,github-search_code",
"hub": {
"type": "stdio",
"command": "/usr/local/bin/run-with-env",
"args": ["/run/secrets/mcphub/github.env", "--", "/usr/local/bin/github-mcp-server", "stdio", "--read-only", "--tools", "search_repositories,get_file_contents,search_code"],
"enabled": true
}
}, },
{ {
"id": "homeassistant-local", "id": "homeassistant-local",
@@ -45,7 +48,15 @@
"env_file": "/etc/mike-ai/mcphub-client.env", "env_file": "/etc/mike-ai/mcphub-client.env",
"key_env": "MCPHUB_BEARER_TOKEN", "key_env": "MCPHUB_BEARER_TOKEN",
"auth_type": "bearer", "auth_type": "bearer",
"timeout": 300 "timeout": 300,
"hub": {
"type": "streamable-http",
"secret_file": "homeassistant.env",
"url": "${HASS_URL}/api/hass_mcp",
"headers": {"Authorization": "Bearer ${HASS_TOKEN}"},
"owner": "admin",
"enabled": true
}
}, },
{ {
"id": "arr-local", "id": "arr-local",
@@ -57,7 +68,13 @@
"env_file": "/etc/mike-ai/mcphub-client.env", "env_file": "/etc/mike-ai/mcphub-client.env",
"key_env": "MCPHUB_BEARER_TOKEN", "key_env": "MCPHUB_BEARER_TOKEN",
"auth_type": "bearer", "auth_type": "bearer",
"timeout": 600 "timeout": 600,
"hub": {
"type": "stdio",
"command": "/usr/local/bin/run-with-env",
"args": ["/run/secrets/mcphub/arr.env", "--", "arr-mcp", "--transport", "stdio", "--auth-type", "none"],
"enabled": true
}
}, },
{ {
"id": "navidrome-local", "id": "navidrome-local",
@@ -69,7 +86,14 @@
"env_file": "/etc/mike-ai/mcphub-client.env", "env_file": "/etc/mike-ai/mcphub-client.env",
"key_env": "MCPHUB_BEARER_TOKEN", "key_env": "MCPHUB_BEARER_TOKEN",
"auth_type": "bearer", "auth_type": "bearer",
"timeout": 300 "timeout": 300,
"hub": {
"type": "stdio",
"command": "/usr/local/bin/run-with-env",
"args": ["/run/secrets/mcphub/navidrome.env", "--", "node", "/opt/casaderoll/navidrome/dist/index.js"],
"env": {"MCP_TRANSPORT": "stdio", "MCP_HTTP_EXPOSE": "false", "WEBUI_ENABLED": "false"},
"enabled": true
}
}, },
{ {
"id": "deemix-local", "id": "deemix-local",
@@ -81,7 +105,14 @@
"env_file": "/etc/mike-ai/mcphub-client.env", "env_file": "/etc/mike-ai/mcphub-client.env",
"key_env": "MCPHUB_BEARER_TOKEN", "key_env": "MCPHUB_BEARER_TOKEN",
"auth_type": "bearer", "auth_type": "bearer",
"timeout": 300 "timeout": 300,
"hub": {
"type": "stdio",
"command": "/usr/local/bin/run-with-env",
"args": ["/run/secrets/mcphub/deemix.env", "--", "python3", "/opt/casaderoll/mcps/deemix_mcp.py"],
"env": {"MCP_TRANSPORT": "stdio"},
"enabled": true
}
}, },
{ {
"id": "mua", "id": "mua",
@@ -93,7 +124,39 @@
"env_file": "/etc/mike-ai/mcphub-client.env", "env_file": "/etc/mike-ai/mcphub-client.env",
"auth_type": "bearer", "auth_type": "bearer",
"clients": ["hermes", "openwebui"], "clients": ["hermes", "openwebui"],
"timeout": 900 "timeout": 900,
"hub": {
"type": "streamable-http",
"secret_file": "mua.env",
"url": "${MUA_MCP_URL}",
"headers": {"Authorization": "Bearer ${MUA_MCP_BEARER_TOKEN}"},
"owner": "admin",
"enabled": true
}
},
{
"id": "fritzbox-local",
"hermes_id": "fritzbox",
"name": "FRITZ!Box",
"description": "FRITZ!Box-Status, WAN/Glasfaser, Netzwerkgeräte, WLAN und Telefonie. Zuerst die aktive WAN-Verbindung ermitteln; Änderungen nur auf ausdrücklichen Auftrag.",
"url": "http://192.168.1.2:8787/mcp/fritzbox",
"key_env": "MCPHUB_BEARER_TOKEN",
"env_file": "/etc/mike-ai/mcphub-client.env",
"auth_type": "bearer",
"clients": ["hermes", "openwebui"],
"timeout": 300,
"tool_include": [
"fritzbox-list_services",
"fritzbox-list_actions",
"fritzbox-describe_action",
"fritzbox-call_action"
],
"hub": {
"type": "stdio",
"command": "/usr/local/bin/run-with-env",
"args": ["/run/secrets/mcphub/fritzbox.env", "--", "/opt/casaderoll/fritz-mcp"],
"enabled": true
}
}, },
{ {
"id": "mua-readonly-local", "id": "mua-readonly-local",
+62
View File
@@ -0,0 +1,62 @@
{
"version": 1,
"default_profile": "medium",
"max_output_tokens": 8192,
"profiles": [
{
"id": "fast",
"alias": "qwen-fast",
"context": 76800,
"model_env": "FAST_MODEL_FILE",
"model_family": "Qwen3.8-27B IQ4 Mix",
"gpu_split": "5080 only",
"vision": true,
"mtp": 2,
"description": "Schnelles Profil für kurze Chats und zügige Werkzeugaufgaben."
},
{
"id": "medium",
"alias": "qwen-medium",
"context": 160000,
"model_env": "MEDIUM_MODEL_FILE",
"model_family": "Qwen3.8-27B IQ4 XS Pure",
"gpu_split": "90:10",
"vision": true,
"mtp": 3,
"description": "Ausgewogenes Standardprofil für Alltag und lange agentische Aufgaben."
},
{
"id": "large",
"alias": "qwen-large",
"context": 192000,
"model_env": "LARGE_MODEL_FILE",
"model_family": "Qwen3.8-27B IQ4 XS Pure",
"gpu_split": "86:14",
"vision": true,
"mtp": 3,
"description": "Großes Profil für umfangreiche Dokumente und lange technische Arbeiten."
},
{
"id": "ultra",
"alias": "qwen-ultra",
"context": 262144,
"model_env": "ULTRA_MODEL_FILE",
"model_family": "Qwen3.8-27B IQ4 XS Pure",
"gpu_split": "80:20",
"vision": false,
"mtp": 2,
"description": "Maximaler Textkontext; bewusst ohne Vision-Projektor."
},
{
"id": "uncensored",
"alias": "qwen-uncensored",
"context": 80000,
"model_env": "UNCENSORED_MODEL_FILE",
"model_family": "Qwen3.8-27B Abliterated Q4_K_M",
"gpu_split": "90:10",
"vision": true,
"mtp": 2,
"description": "Weniger restriktives Spezialprofil; Werkzeugrechte bleiben unverändert."
}
]
}
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<Container version="2"> <Container version="2">
<Name>MCPHub</Name> <Name>MCPHub</Name>
<Repository>casaderoll/mcphub:1.0.0</Repository> <Repository>casaderoll/mcphub:1.1.1</Repository>
<Registry>https://hub.docker.com/r/samanhappy/mcphub</Registry> <Registry>https://hub.docker.com/r/samanhappy/mcphub</Registry>
<Network>bridge</Network> <Network>bridge</Network>
<MyIP/> <MyIP/>
@@ -11,7 +11,7 @@
<Support>https://github.com/samanhappy/mcphub/issues</Support> <Support>https://github.com/samanhappy/mcphub/issues</Support>
<Project>https://github.com/samanhappy/mcphub</Project> <Project>https://github.com/samanhappy/mcphub</Project>
<ReadMe>https://github.com/samanhappy/mcphub#readme</ReadMe> <ReadMe>https://github.com/samanhappy/mcphub#readme</ReadMe>
<Overview>Zentrale MCP-Verwaltung mit Weboberfläche. Das lokale CasaDeRoll-Image basiert reproduzierbar auf MCPHub 1.0.32 und enthält die versionierten ARR-, Deemix-, Navidrome-, GitHub- und Web-MCP-Laufzeiten. Home Assistant und MUA werden als vorhandene HTTP-MCPs eingebunden. Einzelne Server bleiben unter /mcp/NAME getrennt sichtbar und schaltbar.&#13; <Overview>Zentrale MCP-Verwaltung mit Weboberfläche. Das lokale CasaDeRoll-Image basiert reproduzierbar auf MCPHub 1.0.32 und enthält die versionierten ARR-, Deemix-, Navidrome-, GitHub- und FritzBox-Laufzeiten. Home Assistant, MUA und der Athena Operator werden als vorhandene HTTP-MCPs eingebunden. Einzelne Server bleiben unter /mcp/NAME getrennt sichtbar und schaltbar. Hermes nutzt für allgemeine Webrecherche seine eingebauten Werkzeuge.&#13;
&#13; &#13;
Weboberfläche: http://[IP]:[PORT:3000]/&#13; Weboberfläche: http://[IP]:[PORT:3000]/&#13;
Benutzer beim ersten Start: admin&#13; Benutzer beim ersten Start: admin&#13;
+9 -3
View File
@@ -99,6 +99,9 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Connection", "close") self.send_header("Connection", "close")
self.end_headers() self.end_headers()
model = body.get("model") model = body.get("model")
delay = body.get("mock_stream_delay", 0.05)
if not isinstance(delay, (int, float)) or delay < 0 or delay > 10:
delay = 0.05
for tok in ["Mock-", "Streaming", "-Antwort", f" ({model})"]: for tok in ["Mock-", "Streaming", "-Antwort", f" ({model})"]:
chunk = { chunk = {
"id": "chatcmpl-mock", "id": "chatcmpl-mock",
@@ -109,9 +112,12 @@ class Handler(BaseHTTPRequestHandler):
} }
self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode()) self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
self.wfile.flush() self.wfile.flush()
time.sleep(0.05) time.sleep(delay)
self.wfile.write(b"data: [DONE]\n\n") try:
self.wfile.flush() self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
pass
def _json(self, code: int, payload: dict) -> None: def _json(self, code: int, payload: dict) -> None:
body = json.dumps(payload).encode() body = json.dumps(payload).encode()
+28
View File
@@ -183,6 +183,34 @@ echo "$RESP"
echo "$RESP" | grep -q "data: " && echo "$RESP" | grep -q "\[DONE\]" \ echo "$RESP" | grep -q "data: " && echo "$RESP" | grep -q "\[DONE\]" \
&& ok "SSE-Stream mit [DONE] erhalten" || bad "Streaming" && ok "SSE-Stream mit [DONE] erhalten" || bad "Streaming"
echo "== Test 4b: Client-Abbruch gibt den Router-Slot frei"
command curl -H "Authorization: Bearer $TEST_ROUTER_KEY" -sfN \
"$BASE/v1/chat/completions" -H "Content-Type: application/json" \
-d '{"model":"qwen-fast","stream":true,"mock_stream_delay":2,"messages":[{"role":"user","content":"Abbruch"}]}' \
>/tmp/aborted-stream.txt 2>/dev/null &
ABORT_PID=$!
sleep 0.4
kill "$ABORT_PID" 2>/dev/null || true
wait "$ABORT_PID" 2>/dev/null || true
for _ in $(seq 1 30); do
ACTIVE=$(curl -sf "$BASE/status" | python3 -c 'import json,sys; print(json.load(sys.stdin)["qwen"]["active_chats"])')
[[ $ACTIVE == 0 ]] && break
sleep 0.1
done
CODE=$(curl -s -o /tmp/after-abort.json -w "%{http_code}" "$BASE/v1/chat/completions" \
-H "Content-Type: application/json" \
-d '{"model":"qwen-fast","messages":[{"role":"user","content":"noch frei?"}]}')
[[ ${ACTIVE:-1} == 0 && $CODE == 200 ]] \
&& ok "abgebrochener Stream gibt Lease frei; Folgerequest erfolgreich" \
|| bad "Stream-Abbruch hinterließ active_chats=${ACTIVE:-?}, HTTP $CODE"
if [[ ${ROUTER_TEST_QUICK:-0} == 1 ]]; then
echo
echo "== Schnellergebnis: $PASS bestanden, $FAIL fehlgeschlagen =="
[[ $FAIL -eq 0 ]]
exit
fi
# --- 5. Tool Calls ----------------------------------------------------------------- # --- 5. Tool Calls -----------------------------------------------------------------
echo "== Test 5: Tool Calls" echo "== Test 5: Tool Calls"
RESP=$(curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \ RESP=$(curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \
+35
View File
@@ -66,6 +66,41 @@ class RegistryTests(unittest.TestCase):
con.close() con.close()
self.assertEqual(ids, ["unmanaged", "one"]) self.assertEqual(ids, ["unmanaged", "one"])
def test_production_registry_has_unique_ids_and_fritzbox(self):
document = json.loads((ROOT / "config/mcp-registry.json").read_text())
ids = [item["id"] for item in document["servers"]]
hermes_ids = [
item.get("hermes_id", item["id"])
for item in document["servers"] if "hermes" in item.get("clients", [])
]
self.assertEqual(len(ids), len(set(ids)))
self.assertEqual(len(hermes_ids), len(set(hermes_ids)))
fritz = next(item for item in document["servers"] if item["id"] == "fritzbox-local")
self.assertEqual(fritz["hub"]["type"], "stdio")
self.assertIn("fritz-mcp", fritz["hub"]["args"][-1])
self.assertEqual(len(fritz["tool_include"]), 4)
def test_hermes_tool_filter_is_generated(self):
item = {
"id": "wide", "name": "Wide", "description": "Test",
"url": "http://wide/mcp", "clients": ["hermes"],
"tool_include": ["list", "describe", "call"],
}
block = self.module.hermes_block([item])
self.assertIn(" tools:\n include:", block)
self.assertIn(' - "describe"', block)
def test_raw_mcphub_token_can_replace_host_specific_env_file(self):
item = {
"id": "hub", "name": "Hub", "description": "Test",
"url": "http://hub/mcp/test", "clients": ["hermes"],
"env_file": "/missing/client.env",
"key_env": "MCPHUB_BEARER_TOKEN",
}
self.module.CLIENT_TOKEN = "local-token"
self.assertTrue(self.module.enabled(item))
self.assertEqual(self.module.resolved(item), ("http://hub/mcp/test", "local-token"))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Regression tests for declarative, update-safe MCPHub settings."""
from __future__ import annotations
import importlib.util
import json
import os
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).parents[1]
SOURCE = ROOT / "platform/mcphub/configure-settings.py"
def load_module():
spec = importlib.util.spec_from_file_location("configure_settings", SOURCE)
module = importlib.util.module_from_spec(spec)
assert spec.loader
spec.loader.exec_module(module)
return module
class MCPHubSettingsTests(unittest.TestCase):
def setUp(self):
self.module = load_module()
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.secrets = self.root / "secrets"
self.secrets.mkdir()
(self.secrets / "remote.env").write_text(
"REMOTE_URL=http://example.test/mcp\nTOKEN=secret-value\n",
encoding="utf-8",
)
self.registry = self.root / "registry.json"
self.registry.write_text(json.dumps({
"version": 1,
"servers": [
{
"id": "remote-local",
"hermes_id": "remote",
"hub": {
"type": "streamable-http",
"secret_file": "remote.env",
"url": "${REMOTE_URL}",
"headers": {"Authorization": "Bearer ${TOKEN}"},
"enabled": True,
},
},
{"id": "client-only", "url": "http://unused/mcp"},
],
}), encoding="utf-8")
def tearDown(self):
self.temp.cleanup()
def test_registry_renders_only_hub_servers_and_expands_secrets(self):
servers = self.module.registry_servers(self.registry, self.secrets, {})
self.assertEqual(list(servers), ["remote"])
self.assertEqual(servers["remote"]["url"], "http://example.test/mcp")
self.assertEqual(
servers["remote"]["headers"]["Authorization"],
"Bearer secret-value",
)
def test_existing_enabled_toggle_survives_reconciliation(self):
servers = self.module.registry_servers(
self.registry, self.secrets, {"remote": {"enabled": False}}
)
self.assertFalse(servers["remote"]["enabled"])
def test_missing_secret_fails_closed(self):
os.unlink(self.secrets / "remote.env")
with self.assertRaises(SystemExit):
self.module.registry_servers(self.registry, self.secrets, {})
if __name__ == "__main__":
unittest.main()
+16
View File
@@ -0,0 +1,16 @@
# MCP-Server
Diese Datei wird aus `config/mcp-registry.json` erzeugt. Änderungen gehören nur in die JSON-Registry.
| Server | Hermes-ID | Endpunkt | Clients | Werkzeuge |
|---|---|---|---|---:|
| Athena Operator | `athena-operator` | `http://192.168.1.2:8787/mcp/athena-operator` | hermes, openwebui | alle |
| GitHub (offiziell, read-only) | `github` | `http://192.168.1.2:8787/mcp/github` | hermes, openwebui | alle |
| Home Assistant | `homeassistant-admin` | `http://192.168.1.2:8787/mcp/homeassistant` | hermes, openwebui | alle |
| Sonarr und Radarr | `arr` | `http://192.168.1.2:8787/mcp/arr` | hermes, openwebui | alle |
| Navidrome | `navidrome` | `http://192.168.1.2:8787/mcp/navidrome` | hermes, openwebui | alle |
| Deemix | `deemix` | `http://192.168.1.2:8787/mcp/deemix` | hermes, openwebui | alle |
| MUA (Unraid-Verwaltung) | `unraid` | `http://192.168.1.2:8787/mcp/unraid` | hermes, openwebui | alle |
| FRITZ!Box | `fritzbox` | `http://192.168.1.2:8787/mcp/fritzbox` | hermes, openwebui | 4 |
Allgemeine Webrecherche ist ein eingebautes Hermes-Werkzeug und kein MCPHub-Server.
+3 -7
View File
@@ -3,18 +3,14 @@
## Was automatisch gesichert wird ## Was automatisch gesichert wird
Der Container `mike-ai-backup` erstellt alle fünf Stunden ein komprimiertes Der Container `mike-ai-backup` erstellt alle fünf Stunden ein komprimiertes
Archiv unter `/data/docker-backups` und behält 14 Tage. Während der kurzen Archiv unter `/data/docker-backups` und behält 14 Tage. Netzwerk, WireGuard,
Sicherung wird nur OpenWebUI angehalten, damit seine SQLite-Datenbank konsistent Router und Qwen bleiben dabei erreichbar. Hermes läuft unabhängig auf Unraid.
ist. Netzwerk, WireGuard, Router und Qwen bleiben erreichbar. Hermes läuft
unabhängig davon auf Unraid weiter.
Enthalten sind: Enthalten sind:
- `/etc/mike-ai` mit lokalen Konfigurationen und Secrets - `/etc/mike-ai` mit lokalen Konfigurationen und Secrets
- OpenWebUI-Daten
- Router-Zustand und Router-Bildablage - Router-Zustand und Router-Bildablage
- Piper-Daten - Piper-Daten
- TinySearch-Modellcache
- ein Quellbaum-Snapshot als zusätzliche Bequemlichkeit - ein Quellbaum-Snapshot als zusätzliche Bequemlichkeit
Nicht kopiert werden `/data/models`, die nur noch als Rückfall vorhandene Kopie Nicht kopiert werden `/data/models`, die nur noch als Rückfall vorhandene Kopie
@@ -84,7 +80,7 @@ docker compose --env-file /etc/mike-ai/stack.env ps
test -s /data/docker-backups/athena-latest.tar.gz test -s /data/docker-backups/athena-latest.tar.gz
``` ```
Danach einen OpenWebUI-Login, einen Router-Request und je einen read-only Danach einen Router-Request, einen Hermes-Zweiturn-Chat und je einen read-only
MCP-Aufruf über `http://UNRAID-IP:8787/mcp/NAME` testen. Alte Recovery-Koffer MCP-Aufruf über `http://UNRAID-IP:8787/mcp/NAME` testen. Alte Recovery-Koffer
sind für Neuinstallationen nicht mehr erforderlich; Git, Athenas Datenbackup sind für Neuinstallationen nicht mehr erforderlich; Git, Athenas Datenbackup
und das Unraid-Appdata-Backup bilden die Wiederherstellung. und das Unraid-Appdata-Backup bilden die Wiederherstellung.
+16 -45
View File
@@ -1,50 +1,21 @@
# Verbindliche Standard-Profilmatrix # Profilmatrix
Stand: 22. August 2026. Diese fünf Profile sind die Produktionsmatrix für Diese Datei wird aus `config/profile-matrix.json` erzeugt. Änderungen gehören nur in die JSON-Matrix.
Athena. Änderungen gelten erst nach einem vergleichbaren synthetischen Test
und einer bewussten Aktualisierung dieser Datei.
| Profil | Virtuelles Modell | GGUF | Kontext | GPUs / Split | MTP | Vision | gemessene kurze Ausgabe | Standardprofil: **medium** · globales Ausgabelimit: **8192 Token**
|---|---|---|---:|---|---:|---|---:|
| Fast | `qwen-fast` | IQ4-MIX | 76.800 | RTX 5080 | 2 | ja, Projektor auf RTX 3060 | 85,5 Tok/s |
| **Medium (Default)** | `qwen-medium` | IQ4_XS Pure | 160.000 | RTX 5080 + RTX 3060, 90:10 | 3 | ja, Projektor auf RTX 3060 | 77,2 Tok/s |
| Large | `qwen-large` | IQ4_XS Pure | 192.000 | RTX 5080 + RTX 3060, 86:14 | 3 | ja, Projektor auf RTX 3060 | 75,3 Tok/s |
| Ultra | `qwen-ultra` | IQ4_XS Pure | 262.144 | RTX 5080 + RTX 3060, 80:20 | 2 | nein, text-only | 68,2 Tok/s |
| Uncensored | `qwen-uncensored` | Abliterated Q4_K_M | 80.000 | RTX 5080 + RTX 3060, 90:10 | 2 | ja, eigener Projektor auf RTX 3060 | 52,2 Tok/s |
## Standardverhalten | Profil | API-Alias | Kontext | Modell | GPU-Verteilung | Vision | MTP |
|---|---|---:|---|---|---|---:|
| fast | `qwen-fast` | 76,800 | Qwen3.8-27B IQ4 Mix | 5080 only | ja | 2 |
| medium | `qwen-medium` | 160,000 | Qwen3.8-27B IQ4 XS Pure | 90:10 | ja | 3 |
| large | `qwen-large` | 192,000 | Qwen3.8-27B IQ4 XS Pure | 86:14 | ja | 3 |
| ultra | `qwen-ultra` | 262,144 | Qwen3.8-27B IQ4 XS Pure | 80:20 | nein | 2 |
| uncensored | `qwen-uncensored` | 80,000 | Qwen3.8-27B Abliterated Q4_K_M | 90:10 | ja | 2 |
- Medium ist nach Neuinstallation und bewusstem Plattformstart das aktive ## Zweck
Standardprofil.
- Open WebUI erhält `mikeai-medium` als Standardmodell.
- Im OpenWebUI-Arbeitsbereich ist dieses auf `qwen-medium` basierende Preset
`mikeai-medium` die sichtbare Standardauswahl; die fünf rohen `qwen-*`-Aliase
bleiben ausgeblendet.
- Ein explizit gewähltes anderes Modell löst den zugehörigen Containerwechsel
aus; es ist immer nur ein Inferenzcontainer aktiv.
- Ultra reserviert den verfügbaren Speicher für nativen 256K-Textkontext und
lädt deshalb keinen Vision-Projektor.
- Uncensored ist ein bewusst gewähltes Spezialprofil mit reduzierter
Verweigerungsneigung. Es ändert weder Router-Authentifizierung noch
Tool-Rechte, Bestätigungsregeln oder Secret-Filter und ist nicht Default.
- Das gesonderte Experimentalprofil gehört nicht zur Benutzer-Matrix und wird
in Open WebUI nicht als reguläres Modell angeboten.
## Hermes-Kontextpflege - **fast**: Schnelles Profil für kurze Chats und zügige Werkzeugaufgaben.
- **medium**: Ausgewogenes Standardprofil für Alltag und lange agentische Aufgaben.
Hermes verwendet das jeweils ausgewählte 27B-Profil auch für die - **large**: Großes Profil für umfangreiche Dokumente und lange technische Arbeiten.
Kontextzusammenfassung. Micro-Compaction übernimmt alle fünf abgeschlossenen - **ultra**: Maximaler Textkontext; bewusst ohne Vision-Projektor.
Turns einen älteren Assistenten-/Werkzeugabschnitt in die laufende - **uncensored**: Weniger restriktives Spezialprofil; Werkzeugrechte bleiben unverändert.
Zusammenfassung. Die normale schwellenbasierte Kompression bleibt als
Rückfallebene aktiv. Ein separates kleines Kompressionsmodell wird nicht
installiert, weil 2B/4B-Tests exakte technische Zustände verlieren konnten.
## Nachweise
- Fast 76,8K: synthetischer Referenzlauf, 85,50 Tok/s.
- Medium 160K: Pure 90:10 mit MTP3, 77,22 Tok/s.
- Large 192K: Pure 86:14 mit MTP3, 75,28 Tok/s.
- Ultra 256K: Pure 80:20 mit MTP2, 68,19 Tok/s; 220.190 Tokens
erfolgreich verarbeitet und Sentinel korrekt wiedergefunden.
- Uncensored 80K: Abliterated Q4_K_M 90:10 mit MTP2, 52,2 Tok/s; 60K-Fülltest,
Vision und synthetischer Logik-/Sicherheitslauf bestanden.
+4 -17
View File
@@ -485,16 +485,14 @@ build_and_start() {
"from huggingface_hub import snapshot_download; snapshot_download('black-forest-labs/FLUX.2-klein-4B', revision='303481f0390afb112393f9d77e8f0be72fcefeb7', local_dir='/download')" "from huggingface_hub import snapshot_download; snapshot_download('black-forest-labs/FLUX.2-klein-4B', revision='303481f0390afb112393f9d77e8f0be72fcefeb7', local_dir='/download')"
chmod -R a-w "${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}" chmod -R a-w "${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}"
fi fi
# Creates the shared internal tools network before Open WebUI is created. # Creates the tools network and deploys the only host-bound MCP: Operator.
# Web search always starts; HA/ARR/Unraid only start when their root-only # Portable MCPs and Hermes live on Unraid and are restored through Appdata.
# secret files and required local artifacts are present.
"$STACK_DIR/platform/mcp/install-tools.sh" "$STACK_DIR/platform/mcp/install-tools.sh"
"$STACK_DIR/platform/hermes/install-hermes.sh"
docker compose --env-file "$SECRETS_DIR/stack.env" --profile inference create \ docker compose --env-file "$SECRETS_DIR/stack.env" --profile inference create \
llama-fast llama-medium llama-large llama-ultra llama-experimental llama-fast llama-medium llama-large llama-ultra llama-experimental
docker compose --env-file "$SECRETS_DIR/stack.env" --profile image create flux-worker docker compose --env-file "$SECRETS_DIR/stack.env" --profile image create flux-worker
docker compose --env-file "$SECRETS_DIR/stack.env" up -d --build \ docker compose --env-file "$SECRETS_DIR/stack.env" up -d --build \
xtts piper tts-gateway profile-controller router open-webui hermes wireguard-gateway xtts piper tts-gateway profile-controller router backup
if [[ ${WIREGUARD_MODE:-container} == container ]]; then if [[ ${WIREGUARD_MODE:-container} == container ]]; then
systemctl restart mike-ai-container-vpn-guard.service systemctl restart mike-ai-container-vpn-guard.service
fi fi
@@ -541,16 +539,6 @@ PY
grep -Fxq 'INSTALL_READINESS_OK' <<<"$activation_output" || \ grep -Fxq 'INSTALL_READINESS_OK' <<<"$activation_output" || \
die "Medium-Standardprofil lieferte keinen bestätigten Readiness-Marker" die "Medium-Standardprofil lieferte keinen bestätigten Readiness-Marker"
log "Hermes-Profile aus der Standardmatrix anlegen"
"$STACK_DIR/platform/hermes/install-profiles.sh"
log "OpenWebUI-Filter und MCP-Registry synchronisieren"
"$STACK_DIR/platform/openwebui/install-filters.sh"
if [[ ${INSTALL_HERMES_WEBUI:-true} == true ]]; then
log "Entfernbare Hermes Community-WebUI installieren"
"$STACK_DIR/platform/hermes/install-webui.sh"
fi
} }
hostnamectl set-hostname "$AI_HOSTNAME" hostnamectl set-hostname "$AI_HOSTNAME"
@@ -578,9 +566,8 @@ else
vpn_address=$AI_BIND_ADDRESS vpn_address=$AI_BIND_ADDRESS
fi fi
cat <<EOF cat <<EOF
OpenWebUI: http://${vpn_address}:8080
Router-API: http://${vpn_address}:8081 Router-API: http://${vpn_address}:8081
Hermes: http://${vpn_address}:9119 Hermes läuft mit MCPHub auf Unraid und nutzt diese Router-API.
Die geheimen Schlüssel liegen ausschließlich unter $SECRETS_DIR (0600). Die geheimen Schlüssel liegen ausschließlich unter $SECRETS_DIR (0600).
Im Container-Modus stammen Peer, Adresse und Heimrouten vollständig aus dem Im Container-Modus stammen Peer, Adresse und Heimrouten vollständig aus dem
Executable
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Small, predictable operator entrypoint for Athena's Docker stack.
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE=${STACK_ENV:-/etc/mike-ai/stack.env}
DRY_RUN=false
usage() {
cat <<'EOF'
Usage:
./manage.sh [--dry-run] validate
./manage.sh [--dry-run] deploy core
./manage.sh [--dry-run] deploy SERVICE [SERVICE ...]
./manage.sh [--dry-run] stop-legacy
`deploy SERVICE` deliberately uses --no-deps. It never recreates unrelated
containers. `deploy core` is the complete Athena runtime and may start missing
dependencies, but does not start OpenWebUI, Hermes or portable Fach-MCPs.
EOF
}
if [[ ${1:-} == --dry-run ]]; then
DRY_RUN=true
shift
fi
run() {
printf '+ '
printf '%q ' "$@"
printf '\n'
$DRY_RUN || "$@"
}
compose=(docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/compose.yaml")
command=${1:-}
shift || true
case "$command" in
validate)
run python3 "$ROOT_DIR/platform/scripts/sync-profile-matrix.py" --check
run python3 "$ROOT_DIR/platform/scripts/render-mcp-registry.py" --check
run "${compose[@]}" config --quiet
;;
deploy)
[[ $# -gt 0 ]] || { usage >&2; exit 2; }
run python3 "$ROOT_DIR/platform/scripts/sync-profile-matrix.py" --check
run python3 "$ROOT_DIR/platform/scripts/render-mcp-registry.py" --check
if [[ $1 == core ]]; then
shift
[[ $# -eq 0 ]] || { echo "core akzeptiert keine weiteren Services" >&2; exit 2; }
run "$ROOT_DIR/platform/mcp/install-tools.sh"
run "${compose[@]}" up -d --build \
wireguard-gateway piper xtts tts-gateway profile-controller router backup
else
run "${compose[@]}" up -d --build --no-deps "$@"
fi
;;
stop-legacy)
# Reversible cleanup: stop only obsolete Athena frontends/tool backends.
for name in mike-ai-open-webui mike-ai-hermes mike-ai-hermes-webui \
mike-ai-hermes-webui-vpn-proxy mike-ai-mcp-web mike-ai-tools-searxng \
mike-ai-tools-tinysearch mike-ai-mcp-platform-context; do
if docker inspect "$name" >/dev/null 2>&1; then
run docker stop "$name"
fi
done
;;
-h|--help|"") usage ;;
*) echo "Unbekannter Befehl: $command" >&2; usage >&2; exit 2 ;;
esac
+4 -100
View File
@@ -1,102 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -uo pipefail # Compatibility entrypoint: there is only one authoritative release check.
set -Eeuo pipefail
PASS=0 ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
WARN=0 exec "$ROOT_DIR/smoke-test.sh" "$@"
FAIL=0
pass() { printf 'PASS %s\n' "$*"; PASS=$((PASS + 1)); }
warn() { printf 'WARN %s\n' "$*"; WARN=$((WARN + 1)); }
fail() { printf 'FAIL %s\n' "$*"; FAIL=$((FAIL + 1)); }
echo "== Local AI platform verification =="
ROOT_USE="$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')"
if [[ -n "$ROOT_USE" && "$ROOT_USE" -lt 85 ]]; then
pass "Systempartition bei ${ROOT_USE}%"
else
fail "Systempartition bei ${ROOT_USE:-unbekannt}% (Ziel: unter 85%)"
fi
if command -v nvidia-smi >/dev/null 2>&1; then
GPU_LIST="$(nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null)"
GPU_COUNT="$(printf '%s\n' "$GPU_LIST" | sed '/^[[:space:]]*$/d' | wc -l)"
if [[ "$GPU_COUNT" -ge 2 ]]; then
pass "beide GPUs erkannt: $(printf '%s' "$GPU_LIST" | paste -sd ' | ' -)"
elif [[ "$GPU_COUNT" -eq 1 ]]; then
fail "nur eine von zwei erwarteten GPUs erkannt: $GPU_LIST"
else
fail "nvidia-smi liefert keine GPU"
fi
else
fail "nvidia-smi fehlt"
fi
if [[ -e /sys/class/net/lan0 ]] && \
[[ "$(< /sys/class/net/lan0/address)" == "58:11:22:bb:ad:0c" ]] && \
[[ "$(< /sys/class/net/lan0/operstate)" == "up" ]]; then
pass "stabiles LAN-Interface lan0 aktiv (58:11:22:bb:ad:0c)"
else
fail "stabiles LAN-Interface lan0 fehlt, ist down oder hat die falsche MAC"
fi
container_healthy() {
local name=$1 state health
state="$(docker inspect --format '{{.State.Status}}' "$name" 2>/dev/null || true)"
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$name" 2>/dev/null || true)"
[[ $state == running && ( -z $health || $health == healthy ) ]]
}
for container in mike-ai-profile-controller mike-ai-router mike-ai-xtts \
mike-ai-piper mike-ai-tts-gateway mike-ai-open-webui; do
if container_healthy "$container"; then
pass "$container gesund"
else
fail "$container fehlt oder ist nicht gesund"
fi
done
ACTIVE_LLAMA="$(docker ps --format '{{.Names}}' | grep -Ec '^mike-ai-llama-(fast|medium|large|ultra|uncensored|experimental)$' || true)"
if [[ $ACTIVE_LLAMA -eq 1 ]]; then
pass "exakt ein llama.cpp-Profil aktiv"
else
fail "$ACTIVE_LLAMA llama.cpp-Profile aktiv (erwartet: 1)"
fi
for optional in mike-ai-mcp-web mike-ai-mcp-homeassistant mike-ai-mcp-arr \
mike-ai-mcp-github mike-ai-mcp-athena-operator mike-ai-backup; do
if container_healthy "$optional"; then
pass "$optional aktiv"
else
warn "$optional nicht aktiv oder nicht installiert"
fi
done
if docker ps -a --format '{{.Names}}' | grep -qx 'mike-ai-mcp-unraid-official'; then
fail "veralteter GraphQL-basierter Unraid-MCP ist noch vorhanden"
else
pass "kein GraphQL-basierter Unraid-MCP vorhanden"
fi
if docker exec mike-ai-router python -c \
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8081/health', timeout=3)" \
>/dev/null 2>&1; then
pass "Router-Liveness intern erreichbar"
else
fail "Router-Liveness intern nicht erreichbar"
fi
if systemctl list-unit-files --no-legend 2>/dev/null | grep -Eq '(vision-rx|whisper-rx|granite-rx).*enabled'; then
warn "Aktivierte RX-Altlast gefunden"
else
pass "Keine aktivierte RX-Altlast"
fi
if systemctl list-unit-files --no-legend 2>/dev/null | grep -E '(benchmark|race).*enabled' >/dev/null; then
warn "Automatisch aktivierter Benchmark-/Race-Dienst gefunden"
else
pass "Keine automatisch aktivierten Benchmarks"
fi
printf '\nErgebnis: %d PASS, %d WARN, %d FAIL\n' "$PASS" "$WARN" "$FAIL"
[[ "$FAIL" -eq 0 ]]
+15 -6
View File
@@ -59,7 +59,7 @@ agent:
# Keep enough deliberation for tool choice while avoiding the provider's # Keep enough deliberation for tool choice while avoiding the provider's
# unbounded `auto` reasoning mode on ordinary turns. Users can still raise it # unbounded `auto` reasoning mode on ordinary turns. Users can still raise it
# per session with /reasoning. # per session with /reasoning.
reasoning_effort: "minimal" reasoning_effort: "low"
gateway_timeout: 3600 gateway_timeout: 3600
session_stall_timeout: 600 session_stall_timeout: 600
tool_loop_guardrails: tool_loop_guardrails:
@@ -77,7 +77,9 @@ agent:
max_web_searches: 8 max_web_searches: 8
max_subagents: 8 max_subagents: 8
# Keep ample room for long agent work. Compression starts at 82% of whichever # Keep ample room for long agent work. Compression starts late; deterministic
# tool-result pruning and rolling micro-compaction keep it from getting there
# during normal jobs.
# router profile is selected. Keep the result compact enough that a local # router profile is selected. Keep the result compact enough that a local
# model does not spend many minutes generating the handoff. # model does not spend many minutes generating the handoff.
compression: compression:
@@ -89,19 +91,26 @@ compression:
micro_compact_every_n_turns: 5 micro_compact_every_n_turns: 5
micro_compact_defrag_threshold_tokens: 2000 micro_compact_defrag_threshold_tokens: 2000
progress_notices: true progress_notices: true
threshold: 0.82 threshold: 0.95
target_ratio: 0.15 target_ratio: 0.15
max_attempts: 1
tail_mode: "lean" tail_mode: "lean"
protect_last_n: 20 protect_last_n: 20
protect_first_n: 0 protect_first_n: 0
proactive_prune_tokens: 50000 proactive_prune_tokens: 32000
proactive_prune_min_result_chars: 4000 proactive_prune_min_result_chars: 2000
proactive_prune_min_reclaim_tokens: 4096 proactive_prune_min_reclaim_tokens: 2048
context_timeout_seconds: 45
# A failed local summarizer must not block an interactive client for ten # A failed local summarizer must not block an interactive client for ten
# minutes. Continue without dropping messages after two minutes. # minutes. Continue without dropping messages after two minutes.
context_total_ceiling_seconds: 120 context_total_ceiling_seconds: 120
auxiliary: auxiliary:
compression:
provider: "main"
reasoning_effort: "none"
timeout: 120
max_concurrency: 1
# Session names are cosmetic and used to create a second concurrent LLM # Session names are cosmetic and used to create a second concurrent LLM
# request after every first reply. On a single inference slot this blocks the # request after every first reply. On a single inference slot this blocks the
# actual chat, so keep the original timestamp/session id instead. # actual chat, so keep the original timestamp/session id instead.
+42 -49
View File
@@ -1,23 +1,26 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
HERMES_CONTAINER=${HERMES_CONTAINER:-mike-ai-hermes} HERMES_CONTAINER=${HERMES_CONTAINER:-Hermes-Agent}
SECRETS_DIR=${SECRETS_DIR:-/etc/mike-ai} SECRETS_DIR=${SECRETS_DIR:-/etc/mike-ai}
HERMES_DATA_DIR=${HERMES_DATA_DIR:-/data/hermes} HERMES_DATA_DIR=${HERMES_DATA_DIR:-/mnt/nvme-storage/appdata/Hermes-Agent}
STACK_DIR=${STACK_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}
PROFILE_MATRIX=${PROFILE_MATRIX:-$STACK_DIR/config/profile-matrix.json}
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; } die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
docker inspect "$HERMES_CONTAINER" >/dev/null 2>&1 || \ docker inspect "$HERMES_CONTAINER" >/dev/null 2>&1 || \
die "Hermes-Container fehlt: $HERMES_CONTAINER" die "Hermes-Container fehlt: $HERMES_CONTAINER"
[[ -s $PROFILE_MATRIX ]] || die "Profilmatrix fehlt: $PROFILE_MATRIX"
deadline=$((SECONDS + 180)) deadline=$((SECONDS + 180))
until [[ $(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \ until [[ $(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \
"$HERMES_CONTAINER" 2>/dev/null || true) == healthy ]]; do "$HERMES_CONTAINER" 2>/dev/null || true) =~ ^(healthy|running)$ ]]; do
(( SECONDS < deadline )) || die "Hermes wurde nicht rechtzeitig gesund." (( SECONDS < deadline )) || die "Hermes wurde nicht rechtzeitig gesund."
sleep 2 sleep 2
done done
create_profile() { create_profile() {
local name=$1 model=$2 context=$3 description=$4 local name=$1 model=$2 context=$3 description=$4 max_tokens=$5
if ! docker exec "$HERMES_CONTAINER" hermes profile show "$name" >/dev/null 2>&1; then if ! docker exec "$HERMES_CONTAINER" hermes profile show "$name" >/dev/null 2>&1; then
docker exec "$HERMES_CONTAINER" hermes profile create "$name" \ docker exec "$HERMES_CONTAINER" hermes profile create "$name" \
--clone-from default --description "$description" --clone-from default --description "$description"
@@ -26,23 +29,32 @@ create_profile() {
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set model.context_length "$context" docker exec "$HERMES_CONTAINER" hermes -p "$name" config set model.context_length "$context"
# These are platform-wide latency and loop safeguards, not model-specific # These are platform-wide latency and loop safeguards, not model-specific
# tuning. Enforce them on old profiles as well as newly cloned profiles. # tuning. Enforce them on old profiles as well as newly cloned profiles.
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set model.max_tokens 8192 docker exec "$HERMES_CONTAINER" hermes -p "$name" config set model.max_tokens "$max_tokens"
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set agent.max_turns 64 docker exec "$HERMES_CONTAINER" hermes -p "$name" config set agent.max_turns 64
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set agent.reasoning_effort minimal docker exec "$HERMES_CONTAINER" hermes -p "$name" config set agent.reasoning_effort low
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set display.show_reasoning false docker exec "$HERMES_CONTAINER" hermes -p "$name" config set display.show_reasoning false
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set auxiliary.title_generation.enabled false docker exec "$HERMES_CONTAINER" hermes -p "$name" config set auxiliary.title_generation.enabled false
docker exec "$HERMES_CONTAINER" hermes -p "$name" config unset compression.threshold_tokens || true docker exec "$HERMES_CONTAINER" hermes -p "$name" config unset compression.threshold_tokens || true
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.threshold 0.82 docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.threshold 0.95
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.target_ratio 0.15 docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.target_ratio 0.15
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.max_attempts 1
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.context_timeout_seconds 45
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.context_total_ceiling_seconds 120 docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.context_total_ceiling_seconds 120
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.protect_last_n 20 docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.protect_last_n 20
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.protect_first_n 0 docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.protect_first_n 0
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.micro_compact true docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.micro_compact true
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.micro_compact_every_n_turns 5 docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.micro_compact_every_n_turns 5
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.micro_compact_defrag_threshold_tokens 2000 docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.micro_compact_defrag_threshold_tokens 2000
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.proactive_prune_tokens 32000
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.proactive_prune_min_result_chars 2000
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.proactive_prune_min_reclaim_tokens 2048
# The selected 27B profile performs production compaction. A separate small # The selected 27B profile performs production compaction. A separate small
# compressor was removed after it lost exact technical state in benchmarks. # compressor was removed after it lost exact technical state in benchmarks.
docker exec "$HERMES_CONTAINER" hermes -p "$name" config unset auxiliary.compression || true docker exec "$HERMES_CONTAINER" hermes -p "$name" config unset auxiliary.compression || true
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set auxiliary.compression.provider main
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set auxiliary.compression.reasoning_effort none
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set auxiliary.compression.timeout 120
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set auxiliary.compression.max_concurrency 1
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set platform_toolsets.cli \ docker exec "$HERMES_CONTAINER" hermes -p "$name" config set platform_toolsets.cli \
'["web","terminal","file","skills","todo","memory","vision","tts"]' '["web","terminal","file","skills","todo","memory","vision","tts"]'
[[ $(docker exec "$HERMES_CONTAINER" hermes -p "$name" config get model.default) == "$model" ]] || \ [[ $(docker exec "$HERMES_CONTAINER" hermes -p "$name" config get model.default) == "$model" ]] || \
@@ -51,51 +63,32 @@ create_profile() {
die "Kontext von Profil $name konnte nicht verifiziert werden." die "Kontext von Profil $name konnte nicht verifiziert werden."
} }
create_profile fast qwen-fast 76800 \ while IFS=$'\t' read -r name model context description max_tokens; do
"Schnelles Qwen3.8-27B-Profil mit 76,8K Kontext fuer kurze Chats und schnelle Aufgaben." create_profile "$name" "$model" "$context" "$description" "$max_tokens"
create_profile medium qwen-medium 160000 \ done < <(jq -r --argjson max "$(jq '.max_output_tokens' "$PROFILE_MATRIX")" \
"Ausgewogenes Qwen3.8-27B-Standardprofil mit 160K Kontext fuer Alltag und agentische Aufgaben." '.profiles[] | [.id,.alias,(.context|tostring),(.description|gsub("[\\t\\n]";" ")),($max|tostring)] | @tsv' \
create_profile large qwen-large 192000 \ "$PROFILE_MATRIX")
"Grosses Qwen3.8-27B-Profil mit 192K Kontext fuer umfangreiche Dokumente und lange Aufgaben."
create_profile ultra qwen-ultra 262144 \
"Maximales Qwen3.8-27B-Profil mit 262K Kontext fuer sehr grosse Kontexte; langsamer als die Standardprofile."
create_profile uncensored qwen-uncensored 80000 \
"Unzensiertes Qwen3.8-27B-Profil mit 80K Kontext fuer spezielle Anfragen."
# Existing profiles may predate managed secret rendering and therefore contain # The Unraid host deliberately has no system Python. Run the small declarative
# the literal ${ROUTER_API_KEY}. Repair only that exact placeholder; never log # client renderer in the already version-pinned MCPHub image instead of adding
# or commit the secret itself. # host dependencies.
[[ -s $SECRETS_DIR/router-api-key ]] || die "Router-API-Key fehlt." sync_args=(--registry /stack/config/mcp-registry.json)
router_key=$(<"$SECRETS_DIR/router-api-key") mcphub_token=${MCPHUB_TOKEN_FILE:-/mnt/nvme-storage/appdata/MCPHub/client-token}
ROUTER_API_KEY="$router_key" HERMES_DATA_DIR="$HERMES_DATA_DIR" python3 <<'PY' token_mount=()
import os if [[ -s $mcphub_token ]]; then
import pathlib token_mount=(-v "$mcphub_token:/run/input/mcphub-token:ro")
sync_args+=(--mcphub-token-file /run/input/mcphub-token)
root = pathlib.Path(os.environ["HERMES_DATA_DIR"]) / "profiles" fi
placeholder = "${ROUTER_API_KEY}"
paths = [pathlib.Path(os.environ["HERMES_DATA_DIR"]) / "config.yaml"]
paths.extend(sorted(root.glob("*/config.yaml")))
for path in paths:
if not path.exists():
continue
text = path.read_text()
if placeholder in text:
text = text.replace(placeholder, os.environ["ROUTER_API_KEY"], 1)
# Avoid collision with Hermes' disabled built-in `homeassistant` toolset.
# The collision filters the healthy external MCP out of agent snapshots.
text = text.replace(
"\n homeassistant:\n url: http://mcp-homeassistant:8000/mcp\n",
"\n homeassistant-admin:\n url: http://mcp-homeassistant:8000/mcp\n",
)
path.write_text(text)
PY
sync_args=(--registry "${STACK_DIR:-/opt/mike-ai/stack}/config/mcp-registry.json")
while IFS= read -r config; do while IFS= read -r config; do
sync_args+=(--hermes "$config") sync_args+=(--hermes "/hermes/${config#"$HERMES_DATA_DIR"/}")
done < <(find "$HERMES_DATA_DIR" -name config.yaml -type f -print) done < <(find "$HERMES_DATA_DIR" -name config.yaml -type f -print)
python3 "${STACK_DIR:-/opt/mike-ai/stack}/platform/mcp/sync-clients.py" "${sync_args[@]}" docker run --rm --entrypoint python \
-v "$STACK_DIR:/stack:ro" \
-v "$HERMES_DATA_DIR:/hermes:rw" \
"${token_mount[@]}" \
casaderoll/mcphub:1.1.0 \
/stack/platform/mcp/sync-clients.py "${sync_args[@]}"
"${STACK_DIR:-/opt/mike-ai/stack}/platform/hermes/install-skills.sh" "$STACK_DIR/platform/hermes/install-skills.sh"
docker exec "$HERMES_CONTAINER" hermes profile list docker exec "$HERMES_CONTAINER" hermes profile list
printf 'HERMES_PROFILES_OK\n' printf 'HERMES_PROFILES_OK\n'
+24 -16
View File
@@ -1,31 +1,39 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
STACK_DIR=${STACK_DIR:-/opt/mike-ai/stack} STACK_DIR=${STACK_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}
HERMES_DATA_DIR=${HERMES_DATA_DIR:-/data/hermes} HERMES_DATA_DIR=${HERMES_DATA_DIR:-/mnt/nvme-storage/appdata/Hermes-Agent}
SKILL_SOURCE=$STACK_DIR/platform/hermes/skills/athena-operator/SKILL.md PROFILE_MATRIX=${PROFILE_MATRIX:-$STACK_DIR/config/profile-matrix.json}
PROFILES=(fast medium large ultra uncensored) SKILL_ROOT=$STACK_DIR/platform/hermes/skills
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; } die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen." [[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
[[ -s $SKILL_SOURCE ]] || die "Skill-Quelle fehlt: $SKILL_SOURCE" [[ -s $PROFILE_MATRIX ]] || die "Profilmatrix fehlt: $PROFILE_MATRIX"
grep -Fxq -- 'name: athena-operator' "$SKILL_SOURCE" || \
die "Skill-Quelle hat kein gültiges Athena-Operator-Frontmatter."
install_skill() { install_skill() {
local root=$1 target=$1/platform/athena-operator/SKILL.md local source=$1 root=$2 name target
name=${source%/SKILL.md}
name=${name##*/}
target=$root/platform/$name/SKILL.md
grep -Fxq -- "name: $name" "$source" || \
die "Skill-Quelle hat kein gültiges Frontmatter: $source"
install -d -o 10000 -g 10000 -m 0750 "${target%/*}" install -d -o 10000 -g 10000 -m 0750 "${target%/*}"
if [[ -s $target ]] && ! cmp -s "$SKILL_SOURCE" "$target"; then if [[ -s $target ]] && ! cmp -s "$source" "$target"; then
cp -a "$target" "$target.before-managed-update-$(date +%Y%m%d-%H%M%S)" cp -a "$target" "$target.before-managed-update-$(date +%Y%m%d-%H%M%S)"
fi fi
install -o 10000 -g 10000 -m 0640 "$SKILL_SOURCE" "$target" install -o 10000 -g 10000 -m 0640 "$source" "$target"
cmp -s "$SKILL_SOURCE" "$target" || die "Skill-Synchronisierung fehlgeschlagen: $target" cmp -s "$source" "$target" || die "Skill-Synchronisierung fehlgeschlagen: $target"
} }
install_skill "$HERMES_DATA_DIR/skills" mapfile -t profiles < <(jq -r '.profiles[].id' "$PROFILE_MATRIX")
for profile in "${PROFILES[@]}"; do
[[ -d $HERMES_DATA_DIR/profiles/$profile ]] || continue for source in "$SKILL_ROOT"/*/SKILL.md; do
install_skill "$HERMES_DATA_DIR/profiles/$profile/skills" [[ -s $source ]] || continue
install_skill "$source" "$HERMES_DATA_DIR/skills"
for profile in "${profiles[@]}"; do
[[ -d $HERMES_DATA_DIR/profiles/$profile ]] || continue
install_skill "$source" "$HERMES_DATA_DIR/profiles/$profile/skills"
done
done done
printf 'HERMES_ATHENA_OPERATOR_SKILL_OK\n' printf 'HERMES_SKILLS_OK\n'
@@ -16,10 +16,10 @@ Use these paths directly. Do not search the filesystem for alternatives.
- Host: Unraid `192.168.1.2` - Host: Unraid `192.168.1.2`
- Container: `MCPHub` - Container: `MCPHub`
- UI/base URL: `http://192.168.1.2:8787` - UI/base URL: `http://192.168.1.2:8787`
- Operational source/build tree: `/mnt/nvme-storage/appdata/MCPHub/build` - Operational source/build tree: `/mnt/nvme-storage/appdata/MCPHub/build/repo`
- Dockerfile: `platform/mcphub/Dockerfile` below that tree - Dockerfile: `platform/mcphub/Dockerfile` below that tree
- Server declaration: `platform/mcphub/configure-settings.py` - Single server and client registry: `config/mcp-registry.json`
- Client registry: `config/mcp-registry.json` - Registry renderer: `platform/mcphub/configure-settings.py` (normally unchanged)
- Unraid template: `config/unraid-templates/my-MCPHub.xml` - Unraid template: `config/unraid-templates/my-MCPHub.xml`
- Persistent state: `/mnt/nvme-storage/appdata/MCPHub` - Persistent state: `/mnt/nvme-storage/appdata/MCPHub`
- Secrets: `/mnt/nvme-storage/appdata/MCPHub/secrets/<server>.env`, mode `0600` - Secrets: `/mnt/nvme-storage/appdata/MCPHub/secrets/<server>.env`, mode `0600`
@@ -91,8 +91,8 @@ Modify only the necessary fixed production files. Rules:
- Put credentials only in the matching secret file with mode `0600`. - Put credentials only in the matching secret file with mode `0600`.
- Never print, log, commit, summarize, or return a secret. - Never print, log, commit, summarize, or return a secret.
- Use `/usr/local/bin/run-with-env` for secret-backed stdio servers. - Use `/usr/local/bin/run-with-env` for secret-backed stdio servers.
- Declare servers in `configure-settings.py`; do not manually treat - Declare servers only in `config/mcp-registry.json`; do not hard-code a server
`mcp_settings.json` as the source of truth. in `configure-settings.py` and do not edit `mcp_settings.json` manually.
- Preserve existing users, bearer keys, prompts, resources, enabled states, - Preserve existing users, bearer keys, prompts, resources, enabled states,
and per-tool toggles. and per-tool toggles.
- Build a new image tag. Never overwrite the tag currently running. - Build a new image tag. Never overwrite the tag currently running.
@@ -104,7 +104,7 @@ unfiltered large server to clients.
### 5. Deploy without collateral changes ### 5. Deploy without collateral changes
Build from `/mnt/nvme-storage/appdata/MCPHub/build`, then recreate only Build from `/mnt/nvme-storage/appdata/MCPHub/build/repo`, then recreate only
`MCPHub` through Unraid DockerMan so it stays a managed Unraid container. `MCPHub` through Unraid DockerMan so it stays a managed Unraid container.
Preserve all Appdata and mounts. Preserve all Appdata and mounts.
+1 -33
View File
@@ -11,13 +11,6 @@ if [[ -s $STACK_ENV ]]; then
COMPOSE+=(--env-file "$STACK_ENV") COMPOSE+=(--env-file "$STACK_ENV")
fi fi
COMPOSE+=(-f "$STACK_DIR/compose.yaml") COMPOSE+=(-f "$STACK_DIR/compose.yaml")
export SEARXNG_SETTINGS_FILE="${SEARXNG_SETTINGS_FILE:-$MCP_DIR/../web-search/searxng-settings.yml}"
[[ -s $SEARXNG_SETTINGS_FILE ]] || {
echo "SearXNG-Konfiguration fehlt: $SEARXNG_SETTINGS_FILE" >&2
exit 1
}
docker network inspect mike-ai-tools >/dev/null 2>&1 || \ docker network inspect mike-ai-tools >/dev/null 2>&1 || \
docker network create --internal --subnet 172.30.40.0/24 mike-ai-tools >/dev/null docker network create --internal --subnet 172.30.40.0/24 mike-ai-tools >/dev/null
docker network inspect mike-ai-tools-egress >/dev/null 2>&1 || \ docker network inspect mike-ai-tools-egress >/dev/null 2>&1 || \
@@ -26,36 +19,11 @@ docker network inspect mike-ai-tools-egress >/dev/null 2>&1 || \
# One administrative MCP exposes ATHENA.md plus the bounded host operator. # One administrative MCP exposes ATHENA.md plus the bounded host operator.
"$MCP_DIR/../operator/install-operator.sh" "$MCP_DIR/../operator/install-operator.sh"
services=(searxng tinysearch mcp-athena-operator) services=(mcp-athena-operator)
# Portable Fach-MCPs laufen zentral im MCPHub auf Unraid. Ihre Compose-Blöcke # Portable Fach-MCPs laufen zentral im MCPHub auf Unraid. Ihre Compose-Blöcke
# bleiben vorläufig als explizite Rollback-Profile erhalten, werden bei einer # bleiben vorläufig als explizite Rollback-Profile erhalten, werden bei einer
# normalen Athena-Installation aber weder gebaut noch gestartet. # normalen Athena-Installation aber weder gebaut noch gestartet.
echo "ARR, Deemix, GitHub, Home Assistant und Navidrome werden über MCPHub auf Unraid bereitgestellt." echo "ARR, Deemix, GitHub, Home Assistant und Navidrome werden über MCPHub auf Unraid bereitgestellt."
# TinySearch keeps the embedding bundle outside the container. Download it
# once on a fresh host; subsequent rebuilds reuse the named volume.
#
# Do not call `tinysearch setup` here. The pinned container image already
# contains a complete Playwright/Chromium installation, while that command
# unconditionally tries to install Chromium again. On IPv4-only hosts this
# redundant download can hang indefinitely. Prepare only the persistent ONNX
# bundle that is actually absent on a fresh installation.
docker volume create mike-ai-tools_tinysearch-models >/dev/null
tiny_image="marcellm01/tinysearch@sha256:7a7d0585f5000f462e699e42b97409715826a9e2edcd09a166afa93a4b7cba31"
if ! docker run --rm --entrypoint test \
-v mike-ai-tools_tinysearch-models:/data/models "$tiny_image" \
-f /data/models/all-minilm-l6-v2-onnx/model.onnx; then
echo "TinySearch-Modell wird einmalig geladen."
docker run --rm --entrypoint python \
-v mike-ai-tools_tinysearch-models:/data/models "$tiny_image" \
-c 'from tinysearch.services.onnx_bundle_service import ensure_onnx_bundle_sync; ensure_onnx_bundle_sync("fast")'
fi
"${COMPOSE[@]}" up -d --build "${services[@]}" "${COMPOSE[@]}" up -d --build "${services[@]}"
for webui in mike-ai-open-webui Open-WebUI; do
if docker container inspect "$webui" >/dev/null 2>&1; then
docker network connect mike-ai-tools "$webui" 2>/dev/null || true
fi
done
"${COMPOSE[@]}" ps "${COMPOSE[@]}" ps
+25 -1
View File
@@ -13,6 +13,7 @@ import time
BEGIN = "# BEGIN MANAGED MCP SERVERS" BEGIN = "# BEGIN MANAGED MCP SERVERS"
END = "# END MANAGED MCP SERVERS" END = "# END MANAGED MCP SERVERS"
CLIENT_TOKEN = ""
def env_file(path: str) -> dict[str, str]: def env_file(path: str) -> dict[str, str]:
@@ -37,7 +38,10 @@ def enabled(item: dict) -> bool:
if source: if source:
values = env_file(source) values = env_file(source)
url_ready = bool(item.get("url")) or bool(values.get(item.get("url_env", ""))) url_ready = bool(item.get("url")) or bool(values.get(item.get("url_env", "")))
key_ready = not item.get("key_env") or bool(values.get(item["key_env"])) key_ready = (not item.get("key_env")
or bool(values.get(item["key_env"]))
or (item.get("key_env") == "MCPHUB_BEARER_TOKEN"
and bool(CLIENT_TOKEN)))
return url_ready and key_ready return url_ready and key_ready
return True return True
@@ -47,6 +51,8 @@ def resolved(item: dict) -> tuple[str, str]:
values = env_file(item["env_file"]) values = env_file(item["env_file"])
url = item.get("url") or values[item["url_env"]] url = item.get("url") or values[item["url_env"]]
key = values.get(item.get("key_env", ""), "") key = values.get(item.get("key_env", ""), "")
if not key and item.get("key_env") == "MCPHUB_BEARER_TOKEN":
key = CLIENT_TOKEN
return url, key return url, key
return item["url"], "" return item["url"], ""
@@ -72,6 +78,16 @@ def hermes_block(items: list[dict]) -> str:
]) ])
if key: if key:
lines.extend([" headers:", f" Authorization: {yaml_quote('Bearer ' + key)}"]) lines.extend([" headers:", f" Authorization: {yaml_quote('Bearer ' + key)}"])
if "tool_include" in item:
lines.append(" tools:")
lines.append(" include:")
for tool in item["tool_include"]:
lines.append(f" - {yaml_quote(str(tool))}")
elif "tool_exclude" in item:
lines.append(" tools:")
lines.append(" exclude:")
for tool in item["tool_exclude"]:
lines.append(f" - {yaml_quote(str(tool))}")
lines.extend([ lines.extend([
f" timeout: {int(item.get('timeout', 300))}", f" timeout: {int(item.get('timeout', 300))}",
" connect_timeout: 30", " connect_timeout: 30",
@@ -103,6 +119,8 @@ def openwebui_connection(item: dict) -> dict:
config = {"enable": True, "access_grants": []} config = {"enable": True, "access_grants": []}
if item.get("functions"): if item.get("functions"):
config["function_name_filter_list"] = item["functions"] config["function_name_filter_list"] = item["functions"]
elif item.get("tool_include"):
config["function_name_filter_list"] = ",".join(item["tool_include"])
return { return {
"url": url, "path": "", "type": "mcp", "url": url, "path": "", "type": "mcp",
"auth_type": item.get("auth_type", "none"), "headers": None, "auth_type": item.get("auth_type", "none"), "headers": None,
@@ -135,11 +153,17 @@ def update_openwebui(db: pathlib.Path, items: list[dict]) -> None:
def main() -> None: def main() -> None:
global CLIENT_TOKEN
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--registry", type=pathlib.Path, required=True) parser.add_argument("--registry", type=pathlib.Path, required=True)
parser.add_argument("--hermes", type=pathlib.Path, action="append", default=[]) parser.add_argument("--hermes", type=pathlib.Path, action="append", default=[])
parser.add_argument("--openwebui-db", type=pathlib.Path) parser.add_argument("--openwebui-db", type=pathlib.Path)
parser.add_argument("--mcphub-token-file", type=pathlib.Path)
args = parser.parse_args() args = parser.parse_args()
if args.mcphub_token_file:
CLIENT_TOKEN = args.mcphub_token_file.read_text(encoding="utf-8").strip()
if not CLIENT_TOKEN:
raise SystemExit("MCPHub token file is empty")
if args.hermes: if args.hermes:
block = hermes_block(active(args.registry, "hermes")) block = hermes_block(active(args.registry, "hermes"))
for path in args.hermes: for path in args.hermes:
+14 -3
View File
@@ -2,11 +2,13 @@ FROM ghcr.io/github/github-mcp-server@sha256:1817b57d43916532dc002bdc5f344d639bd
FROM ghcr.io/blakeem/navidrome-mcp:2.2.0@sha256:047f911a5a8f7cc8f185bb4d6e7ca6c435542edefff4694a00c2f718ab0ee7f5 AS navidrome FROM ghcr.io/blakeem/navidrome-mcp:2.2.0@sha256:047f911a5a8f7cc8f185bb4d6e7ca6c435542edefff4694a00c2f718ab0ee7f5 AS navidrome
FROM samanhappy/mcphub:1.0.32 FROM samanhappy/mcphub:1.0.32@sha256:df34df85e639743d0bf4b64182fc2f47586bf544beb08c85a5e5d693891daad3
ARG MCP_VERSION=1.29.0 ARG MCP_VERSION=1.29.0
ARG ARR_MCP_VERSION=1.0.1 ARG ARR_MCP_VERSION=1.0.1
ARG YT_DLP_VERSION=2026.7.4 ARG YT_DLP_VERSION=2026.7.4
ARG FRITZ_MCP_VERSION=0.8.0
ARG FRITZ_MCP_SHA256=47f4e2b5595a522aeda4aed9f5c65a57e500c3589c9f4262b8fbf72bd8c72bd4
USER root USER root
@@ -18,13 +20,21 @@ RUN python3 -m pip install --no-cache-dir \
"arr-mcp[mcp]==${ARR_MCP_VERSION}" \ "arr-mcp[mcp]==${ARR_MCP_VERSION}" \
"yt-dlp==${YT_DLP_VERSION}" "yt-dlp==${YT_DLP_VERSION}"
# Released, checksum-pinned Go binary. Keeping the download in the reproducible
# image build prevents MCPHub upgrades from silently dropping the Fritz tools.
RUN mkdir -p /opt/casaderoll \
&& python3 -c 'import sys,urllib.request; v=sys.argv[1]; urllib.request.urlretrieve(f"https://github.com/kambriso/fritzbox-mcp-server/releases/download/v{v}/fritz-mcp-linux-amd64", "/opt/casaderoll/fritz-mcp")' "${FRITZ_MCP_VERSION}" \
&& echo "${FRITZ_MCP_SHA256} /opt/casaderoll/fritz-mcp" | sha256sum -c - \
&& chmod 0755 /opt/casaderoll/fritz-mcp
COPY --from=github /server/github-mcp-server /usr/local/bin/github-mcp-server COPY --from=github /server/github-mcp-server /usr/local/bin/github-mcp-server
COPY --from=navidrome /app /opt/casaderoll/navidrome COPY --from=navidrome /app /opt/casaderoll/navidrome
COPY platform/mcp/deemix_mcp.py /opt/casaderoll/mcps/deemix_mcp.py COPY platform/mcp/deemix_mcp.py /opt/casaderoll/mcps/deemix_mcp.py
COPY platform/web-search/web_search_mcp.py /opt/casaderoll/mcps/web_search_mcp.py
COPY platform/mcp/patches/mcp_sonarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_sonarr.py COPY platform/mcp/patches/mcp_sonarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_sonarr.py
COPY platform/mcp/patches/mcp_radarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_radarr.py COPY platform/mcp/patches/mcp_radarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_radarr.py
COPY config/mcp-registry.json /opt/casaderoll/config/mcp-registry.json
COPY platform/mcphub/configure-settings.py /opt/casaderoll/configure-settings.py
COPY platform/mcphub/run-with-env.py /usr/local/bin/run-with-env COPY platform/mcphub/run-with-env.py /usr/local/bin/run-with-env
COPY platform/mcphub/casaderoll-entrypoint.sh /usr/local/bin/casaderoll-mcphub-entrypoint COPY platform/mcphub/casaderoll-entrypoint.sh /usr/local/bin/casaderoll-mcphub-entrypoint
@@ -36,7 +46,8 @@ RUN node -e 'const fs=require("node:fs"); const p="/opt/casaderoll/navidrome/dis
ENV MCPHUB_SETTING_PATH=/app/data/ \ ENV MCPHUB_SETTING_PATH=/app/data/ \
REQUEST_TIMEOUT=120000 \ REQUEST_TIMEOUT=120000 \
NODE_ENV=production NODE_ENV=production \
FRITZ_MCP_VERSION=${FRITZ_MCP_VERSION}
ENTRYPOINT ["/usr/local/bin/casaderoll-mcphub-entrypoint"] ENTRYPOINT ["/usr/local/bin/casaderoll-mcphub-entrypoint"]
CMD ["/usr/local/bin/entrypoint.sh", "pnpm", "start"] CMD ["/usr/local/bin/entrypoint.sh", "pnpm", "start"]
+6 -6
View File
@@ -9,8 +9,8 @@ sie sich einen Docker-Container und ein Appdata-Backup teilen.
- ARR, Deemix, Navidrome und GitHub laufen als lokale stdio-Unterprozesse. - ARR, Deemix, Navidrome und GitHub laufen als lokale stdio-Unterprozesse.
- Home Assistant und MUA/Unraid sind vorhandene HTTP-MCP-Endpunkte und werden - Home Assistant und MUA/Unraid sind vorhandene HTTP-MCP-Endpunkte und werden
vom Hub direkt weitergereicht. vom Hub direkt weitergereicht.
- Der Web-Adapter kann hier laufen; SearXNG/TinySearch dürfen getrennte - Allgemeine Webrecherche bleibt ein eingebautes Hermes-Werkzeug. Der alte
Backend-Dienste bleiben. Athena-Webadapter sowie SearXNG/TinySearch gehören nicht zum MCPHub-Image.
- Athenas administrativer Operator ist hostgebunden und bleibt auf Athena. - Athenas administrativer Operator ist hostgebunden und bleibt auf Athena.
MCPHub reicht den vorhandenen, nur über WireGuard erreichbaren HTTP-Endpunkt MCPHub reicht den vorhandenen, nur über WireGuard erreichbaren HTTP-Endpunkt
`http://192.168.1.212:8202/mcp` als `/mcp/athena-operator` weiter. Dadurch `http://192.168.1.212:8202/mcp` als `/mcp/athena-operator` weiter. Dadurch
@@ -64,7 +64,7 @@ read-only-Aufruf prüfen und erst danach Clients auf
`http://UNRAID-IP:8787/mcp/{server}` umstellen. Der alte Athena-Container wird `http://UNRAID-IP:8787/mcp/{server}` umstellen. Der alte Athena-Container wird
erst gestoppt, wenn Hermes und OpenWebUI nachweislich über MCPHub funktionieren. erst gestoppt, wenn Hermes und OpenWebUI nachweislich über MCPHub funktionieren.
Aktueller Stand: Athena Operator, ARR, Deemix, Navidrome, GitHub, Home Assistant Aktueller Stand: Athena Operator, ARR, Deemix, Navidrome, GitHub, Home Assistant,
und MUA/Unraid sind auf MCPHub registriert. Alte portable Athena-MCP-Container MUA/Unraid und FRITZ!Box sind auf MCPHub registriert. Alte portable
bleiben vorläufig als ausgeschaltetes Rückfallnetz bestehen. Der Web-Adapter Athena-MCP-Container bleiben ausgeschaltet als kurzfristiges Rückfallnetz
ist der letzte noch offene Migrationspunkt. bestehen. Der frühere Webadapter ist nicht mehr Bestandteil des Images.
+9
View File
@@ -18,4 +18,13 @@ fi
JWT_SECRET=$(cat "$jwt_file") JWT_SECRET=$(cat "$jwt_file")
export JWT_SECRET export JWT_SECRET
# Reconcile the persistent MCPHub state with the versioned registry on every
# start. Existing users, bearer tokens and per-server enabled flags survive.
# This makes image upgrades reproducible instead of relying on manual edits in
# MCPHub's database/UI.
settings_file="$state_dir/mcp_settings.json"
python3 /opt/casaderoll/configure-settings.py \
"$settings_file" /run/secrets/mcphub \
--registry /opt/casaderoll/config/mcp-registry.json
exec "$@" exec "$@"
+53 -58
View File
@@ -11,6 +11,7 @@ import argparse
import json import json
import os import os
import pathlib import pathlib
import re
import secrets import secrets
import tempfile import tempfile
import uuid import uuid
@@ -34,75 +35,70 @@ def env_file(path: pathlib.Path) -> dict[str, str]:
return values return values
def required(values: dict[str, str], key: str, source: pathlib.Path) -> str: PLACEHOLDER = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
value = values.get(key, "").strip()
if not value:
raise SystemExit(f"{key} is missing in {source}") def expand(value: object, values: dict[str, str], source: pathlib.Path) -> object:
"""Resolve secret placeholders without ever logging their values."""
if isinstance(value, str):
def replace(match: re.Match[str]) -> str:
key = match.group(1)
resolved = values.get(key, "").strip()
if not resolved:
raise SystemExit(f"{key} is missing in {source}")
return resolved
return PLACEHOLDER.sub(replace, value)
if isinstance(value, list):
return [expand(item, values, source) for item in value]
if isinstance(value, dict):
return {key: expand(item, values, source) for key, item in value.items()}
return value return value
def registry_servers(registry: pathlib.Path, secrets_dir: pathlib.Path,
existing: dict[str, object]) -> dict[str, object]:
document = json.loads(registry.read_text(encoding="utf-8"))
if document.get("version") != 1:
raise SystemExit("Unsupported MCP registry schema")
result: dict[str, object] = {}
for item in document.get("servers", []):
spec = item.get("hub")
if not isinstance(spec, dict):
continue
server_id = str(item.get("hermes_id") or item["id"])
spec = dict(spec)
secret_name = str(spec.pop("secret_file", ""))
secret_path = secrets_dir / secret_name if secret_name else secrets_dir
values = env_file(secret_path) if secret_name else {}
rendered = expand(spec, values, secret_path)
if isinstance(rendered, dict) and isinstance(rendered.get("url"), str):
rendered["url"] = re.sub(r"(?<!:)//+", "/", rendered["url"])
previous = existing.get(server_id)
if isinstance(previous, dict) and "enabled" in previous:
rendered["enabled"] = bool(previous["enabled"])
result[server_id] = rendered
return result
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("settings", type=pathlib.Path) parser.add_argument("settings", type=pathlib.Path)
parser.add_argument("secrets", type=pathlib.Path) parser.add_argument("secrets", type=pathlib.Path)
parser.add_argument(
"--registry", type=pathlib.Path,
default=pathlib.Path("/opt/casaderoll/config/mcp-registry.json"),
)
parser.add_argument("--web-backend", default="", help="TinySearch MCP URL; empty keeps web disabled") parser.add_argument("--web-backend", default="", help="TinySearch MCP URL; empty keeps web disabled")
parser.add_argument("--searxng", default="", help="SearXNG base URL") parser.add_argument("--searxng", default="", help="SearXNG base URL")
args = parser.parse_args() args = parser.parse_args()
args.settings.parent.mkdir(parents=True, exist_ok=True)
settings = json.loads(args.settings.read_text(encoding="utf-8")) if args.settings.exists() else {} settings = json.loads(args.settings.read_text(encoding="utf-8")) if args.settings.exists() else {}
ha_path = args.secrets / "homeassistant.env" servers = registry_servers(
mua_path = args.secrets / "mua.env" args.registry,
ha = env_file(ha_path) args.secrets,
mua = env_file(mua_path) settings.get("mcpServers", {}) if isinstance(settings.get("mcpServers"), dict) else {},
)
servers: dict[str, object] = {
"athena-operator": {
"type": "streamable-http",
"url": "http://192.168.1.212:8202/mcp",
"owner": "admin",
"enabled": True,
},
"arr": {
"type": "stdio",
"command": "/usr/local/bin/run-with-env",
"args": ["/run/secrets/mcphub/arr.env", "--", "arr-mcp", "--transport", "stdio", "--auth-type", "none"],
"enabled": True,
},
"deemix": {
"type": "stdio",
"command": "/usr/local/bin/run-with-env",
"args": ["/run/secrets/mcphub/deemix.env", "--", "python3", "/opt/casaderoll/mcps/deemix_mcp.py"],
"env": {"MCP_TRANSPORT": "stdio"},
"enabled": True,
},
"navidrome": {
"type": "stdio",
"command": "/usr/local/bin/run-with-env",
"args": ["/run/secrets/mcphub/navidrome.env", "--", "node", "/opt/casaderoll/navidrome/dist/index.js"],
"env": {"MCP_TRANSPORT": "stdio", "MCP_HTTP_EXPOSE": "false", "WEBUI_ENABLED": "false"},
"enabled": True,
},
"github": {
"type": "stdio",
"command": "/usr/local/bin/run-with-env",
"args": ["/run/secrets/mcphub/github.env", "--", "/usr/local/bin/github-mcp-server", "stdio", "--read-only", "--tools", "search_repositories,get_file_contents,search_code"],
"enabled": True,
},
"homeassistant": {
"type": "streamable-http",
"url": required(ha, "HASS_URL", ha_path).rstrip("/") + "/api/hass_mcp",
"headers": {"Authorization": "Bearer " + required(ha, "HASS_TOKEN", ha_path)},
"owner": "admin",
"enabled": True,
},
"unraid": {
"type": "streamable-http",
"url": required(mua, "MUA_MCP_URL", mua_path),
"headers": {"Authorization": "Bearer " + required(mua, "MUA_MCP_BEARER_TOKEN", mua_path)},
"owner": "admin",
"enabled": True,
},
}
if args.web_backend: if args.web_backend:
servers["web"] = { servers["web"] = {
"type": "stdio", "type": "stdio",
@@ -144,7 +140,6 @@ def main() -> None:
system = settings.setdefault("systemConfig", {}) system = settings.setdefault("systemConfig", {})
system.setdefault("routing", {})["skipAuth"] = False system.setdefault("routing", {})["skipAuth"] = False
args.settings.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".mcp-settings-", dir=args.settings.parent) fd, temporary = tempfile.mkstemp(prefix=".mcp-settings-", dir=args.settings.parent)
try: try:
with os.fdopen(fd, "w", encoding="utf-8") as handle: with os.fdopen(fd, "w", encoding="utf-8") as handle:
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Render the human-readable MCP list from the single JSON registry."""
from __future__ import annotations
import argparse
import json
import pathlib
ROOT = pathlib.Path(__file__).resolve().parents[2]
REGISTRY = ROOT / "config" / "mcp-registry.json"
OUTPUT = ROOT / "docs" / "MCP_SERVERS.md"
def render() -> str:
document = json.loads(REGISTRY.read_text(encoding="utf-8"))
rows = []
for item in document["servers"]:
if not item.get("hub"):
continue
clients = ", ".join(item.get("clients", [])) or "–"
selection = str(len(item["tool_include"])) if item.get("tool_include") else "alle"
rows.append(
f"| {item['name']} | `{item['hermes_id']}` | `{item['url']}` | "
f"{clients} | {selection} |"
)
return "\n".join([
"# MCP-Server",
"",
"Diese Datei wird aus `config/mcp-registry.json` erzeugt. Änderungen gehören nur in die JSON-Registry.",
"",
"| Server | Hermes-ID | Endpunkt | Clients | Werkzeuge |",
"|---|---|---|---|---:|",
*rows,
"",
"Allgemeine Webrecherche ist ein eingebautes Hermes-Werkzeug und kein MCPHub-Server.",
"",
])
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
expected = render()
if args.check:
if not OUTPUT.is_file() or OUTPUT.read_text(encoding="utf-8") != expected:
raise SystemExit("MCP registry documentation is out of date")
print("MCP_REGISTRY_OK")
return
OUTPUT.write_text(expected, encoding="utf-8")
print("MCP_REGISTRY_RENDERED")
if __name__ == "__main__":
main()
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Render and verify all client-facing profile data from one matrix."""
from __future__ import annotations
import argparse
import json
import pathlib
import re
def load(path: pathlib.Path) -> dict:
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("version") != 1 or not isinstance(data.get("profiles"), list):
raise SystemExit("Unsupported profile matrix schema")
ids: set[str] = set()
aliases: set[str] = set()
for item in data["profiles"]:
required = {"id", "alias", "context", "model_env", "description"}
missing = required - item.keys()
if missing:
raise SystemExit(f"Profile entry missing: {sorted(missing)}")
if item["id"] in ids or item["alias"] in aliases:
raise SystemExit(f"Duplicate profile or alias: {item['id']}")
if not isinstance(item["context"], int) or item["context"] < 8192:
raise SystemExit(f"Invalid context for {item['id']}")
ids.add(item["id"])
aliases.add(item["alias"])
if data.get("default_profile") not in ids:
raise SystemExit("default_profile is not defined")
return data
def write_if_changed(path: pathlib.Path, content: str) -> None:
if path.exists() and path.read_text(encoding="utf-8") == content:
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def render_router(data: dict) -> str:
payload = {
"profiles": {
item["id"]: {
"context": item["context"],
"model_alias": item["alias"],
}
for item in data["profiles"]
}
}
return json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
def render_docs(data: dict) -> str:
lines = [
"# Profilmatrix",
"",
"Diese Datei wird aus `config/profile-matrix.json` erzeugt. Änderungen gehören nur in die JSON-Matrix.",
"",
f"Standardprofil: **{data['default_profile']}** · globales Ausgabelimit: **{data['max_output_tokens']} Token**",
"",
"| Profil | API-Alias | Kontext | Modell | GPU-Verteilung | Vision | MTP |",
"|---|---|---:|---|---|---|---:|",
]
for item in data["profiles"]:
lines.append(
f"| {item['id']} | `{item['alias']}` | {item['context']:,} | "
f"{item.get('model_family', '')} | {item.get('gpu_split', '')} | "
f"{'ja' if item.get('vision') else 'nein'} | {item.get('mtp', '')} |"
)
lines.extend(["", "## Zweck", ""])
for item in data["profiles"]:
lines.append(f"- **{item['id']}**: {item['description']}")
return "\n".join(lines) + "\n"
def verify_compose(data: dict, compose: pathlib.Path) -> None:
text = compose.read_text(encoding="utf-8")
expected_cap = f'MAX_GENERATION_TOKENS: "{int(data["max_output_tokens"])}"'
if expected_cap not in text:
raise SystemExit("Router output-token cap drift")
for item in data["profiles"]:
block_match = re.search(
rf"(?ms)^ llama-{re.escape(item['id'])}:\n(?P<body>.*?)(?=^ [a-zA-Z0-9_-]+:|\Z)",
text,
)
if not block_match:
raise SystemExit(f"Compose service llama-{item['id']} is missing")
block = block_match.group("body")
if f"- {item['alias']}" not in block:
raise SystemExit(f"Compose alias drift for {item['id']}")
env_prefix = item["id"].upper()
if f"${{{env_prefix}_CONTEXT:-{item['context']}}}" not in block:
raise SystemExit(f"Compose context drift for {item['id']}")
def main() -> None:
root = pathlib.Path(__file__).resolve().parents[2]
parser = argparse.ArgumentParser()
parser.add_argument("--matrix", type=pathlib.Path,
default=root / "config/profile-matrix.json")
parser.add_argument("--router", type=pathlib.Path,
default=root / "router/router_profiles.json")
parser.add_argument("--docs", type=pathlib.Path,
default=root / "docs/STANDARD_PROFILE_MATRIX.md")
parser.add_argument("--compose", type=pathlib.Path,
default=root / "compose.yaml")
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
data = load(args.matrix)
router = render_router(data)
docs = render_docs(data)
verify_compose(data, args.compose)
if args.check:
if args.router.read_text(encoding="utf-8") != router:
raise SystemExit("router_profiles.json is not generated from the matrix")
if args.docs.read_text(encoding="utf-8") != docs:
raise SystemExit("profile documentation is not generated from the matrix")
else:
write_if_changed(args.router, router)
write_if_changed(args.docs, docs)
print("PROFILE_MATRIX_OK")
if __name__ == "__main__":
main()
+2 -11
View File
@@ -23,8 +23,7 @@ tar -xzf "$ARCHIVE" -C "$work"
[[ -d $work/volumes ]] || die "Backup enthält keine Docker-Volumes." [[ -d $work/volumes ]] || die "Backup enthält keine Docker-Volumes."
# Stop only users of the restored volumes. WireGuard, SSH and networking stay up. # Stop only users of the restored volumes. WireGuard, SSH and networking stay up.
for container in mike-ai-open-webui mike-ai-router mike-ai-profile-controller \ for container in mike-ai-router mike-ai-profile-controller mike-ai-piper; do
mike-ai-piper mike-ai-tools-tinysearch; do
if [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || true) == true ]]; then if [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || true) == true ]]; then
docker stop "$container" >/dev/null docker stop "$container" >/dev/null
fi fi
@@ -42,19 +41,11 @@ restore_volume() {
rsync -a --delete "$source/" "$mountpoint/" rsync -a --delete "$source/" "$mountpoint/"
} }
restore_volume mike-ai_open-webui-data "$work/volumes/open-webui-data"
restore_volume mike-ai_piper-data "$work/volumes/piper-data" restore_volume mike-ai_piper-data "$work/volumes/piper-data"
restore_volume mike-ai_router-state "$work/volumes/router-state" restore_volume mike-ai_router-state "$work/volumes/router-state"
restore_volume mike-ai_router-images "$work/volumes/router-images" restore_volume mike-ai_router-images "$work/volumes/router-images"
restore_volume mike-ai-tools_tinysearch-models "$work/volumes/tinysearch-models"
cd "$ROOT_DIR" cd "$ROOT_DIR"
docker compose --env-file /etc/mike-ai/stack.env \ ./manage.sh deploy core
--profile homeassistant --profile arr --profile navidrome --profile deemix --profile github \
up -d
python3 platform/mcp/sync-clients.py \
--registry config/mcp-registry.json \
--hermes /data/hermes/config.yaml \
--openwebui-db "$(docker volume inspect -f '{{.Mountpoint}}' mike-ai_open-webui-data)/webui.db"
printf 'ATHENA_RESTORE_OK %s\n' "$ARCHIVE" printf 'ATHENA_RESTORE_OK %s\n' "$ARCHIVE"
+16
View File
@@ -62,6 +62,7 @@ import logging
import os import os
import queue import queue
import re import re
import select
import socket import socket
import subprocess import subprocess
import sys import sys
@@ -2098,6 +2099,21 @@ class Handler(BaseHTTPRequestHandler):
chunk = resp.read1(16384) chunk = resp.read1(16384)
if not chunk: if not chunk:
break break
# A closed downstream socket may still accept one small write
# into the kernel buffer. Detect the peer FIN before writing so
# slow upstream streams are cancelled promptly and do not keep
# an inference lease occupied until generation completes.
try:
readable, _, _ = select.select([self.connection], [], [], 0)
if readable:
flags = socket.MSG_PEEK | getattr(socket, "MSG_DONTWAIT", 0)
if self.connection.recv(1, flags) == b"":
log.info("Client trennte Streaming-Verbindung; Upstream wird abgebrochen")
break
except (BlockingIOError, InterruptedError):
pass
except OSError:
break
self.wfile.write(chunk) self.wfile.write(chunk)
self.wfile.flush() self.wfile.flush()
except (OSError, http.client.HTTPException) as e: except (OSError, http.client.HTTPException) as e:
Executable
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Short, read-only end-to-end test for every Athena release.
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE=${STACK_ENV:-/etc/mike-ai/stack.env}
pass() { printf 'PASS %s\n' "$*"; }
fail() { printf 'FAIL %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || fail "Bitte als root ausfuehren."
command -v docker >/dev/null || fail "Docker fehlt."
[[ -s $ENV_FILE ]] || fail "Stack-Konfiguration fehlt: $ENV_FILE"
cd "$ROOT_DIR"
./manage.sh validate >/dev/null
pass "Profilmatrix, MCP-Liste und Compose-Konfiguration stimmen"
healthy() {
local name=$1 state health
state=$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null || true)
health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$name" 2>/dev/null || true)
[[ $state == running && ( -z $health || $health == healthy ) ]]
}
for name in \
mike-ai-wireguard-gateway \
mike-ai-profile-controller \
mike-ai-router \
mike-ai-piper \
mike-ai-xtts \
mike-ai-tts-gateway \
mike-ai-mcp-athena-operator \
mike-ai-backup; do
healthy "$name" || fail "$name fehlt oder ist nicht gesund"
done
pass "Athena-Kerndienste sind gesund"
active_llama=$(docker ps --format '{{.Names}}' | \
grep -Ec '^mike-ai-llama-(fast|medium|large|ultra|uncensored)$' || true)
[[ $active_llama -eq 1 ]] || fail "$active_llama aktive llama-Profile (erwartet: 1)"
pass "Genau ein llama.cpp-Profil ist aktiv"
for legacy in \
mike-ai-open-webui \
mike-ai-hermes \
mike-ai-hermes-webui \
mike-ai-hermes-webui-vpn-proxy \
mike-ai-mcp-web \
mike-ai-tools-searxng \
mike-ai-tools-tinysearch \
mike-ai-mcp-platform-context; do
[[ $(docker inspect -f '{{.State.Running}}' "$legacy" 2>/dev/null || true) != true ]] || \
fail "Altlast laeuft noch: $legacy"
done
pass "OpenWebUI, alte Hermes-Dienste und migrierte MCPs sind aus"
docker exec mike-ai-router python - <<'PY' >/dev/null
import json
import os
import urllib.request
key = os.environ["ROUTER_API_KEY"]
for path in ("/health", "/v1/models"):
request = urllib.request.Request(
"http://127.0.0.1:8081" + path,
headers={"Authorization": "Bearer " + key},
)
with urllib.request.urlopen(request, timeout=10) as response:
body = response.read()
if response.status != 200:
raise SystemExit(f"{path}: HTTP {response.status}")
if path.endswith("models") and not json.loads(body).get("data"):
raise SystemExit("Router meldet keine Modelle")
PY
pass "Router-Liveness und OpenAI-Modellliste funktionieren"
latest=/data/docker-backups/athena-latest.tar.gz
[[ -s $latest ]] || fail "Kein gueltiges Athena-Backup: $latest"
pass "Automatisches Athena-Backup ist vorhanden"
printf '\nATHENA_E2E_OK\n'