Simplify Athena stack and recovery
This commit is contained in:
@@ -64,7 +64,7 @@ else
|
||||
fi
|
||||
|
||||
for optional in mike-ai-mcp-web mike-ai-mcp-homeassistant mike-ai-mcp-arr \
|
||||
mike-ai-mcp-github mike-ai-mcp-platform-context mike-ai-mcp-athena-operator; do
|
||||
mike-ai-mcp-github mike-ai-mcp-athena-operator mike-ai-backup; do
|
||||
if container_healthy "$optional"; then
|
||||
pass "$optional aktiv"
|
||||
else
|
||||
@@ -78,17 +78,6 @@ else
|
||||
pass "kein GraphQL-basierter Unraid-MCP vorhanden"
|
||||
fi
|
||||
|
||||
if [[ -s /var/lib/mike-ai-platform-context/runtime.json ]]; then
|
||||
snapshot_age=$(( $(date +%s) - $(stat -c %Y /var/lib/mike-ai-platform-context/runtime.json) ))
|
||||
if (( snapshot_age <= 180 )); then
|
||||
pass "Platform-Kontext-Snapshot aktuell (${snapshot_age}s)"
|
||||
else
|
||||
fail "Platform-Kontext-Snapshot veraltet (${snapshot_age}s)"
|
||||
fi
|
||||
else
|
||||
fail "Platform-Kontext-Snapshot fehlt"
|
||||
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
|
||||
|
||||
@@ -84,7 +84,6 @@ start_proxy 8642 hermes:8642
|
||||
|
||||
# MCP endpoints. Optional services keep their listener even while stopped and
|
||||
# begin working automatically as soon as their container is started.
|
||||
start_proxy 8201 mcp-platform-context:8000
|
||||
start_proxy 8202 mcp-athena-operator:8000
|
||||
# Portable general web MCP for Pi, Hermes and other clients. OpenWebUI uses
|
||||
# its native broad search by default; both paths are site-agnostic.
|
||||
|
||||
@@ -77,20 +77,16 @@ agent:
|
||||
max_web_searches: 8
|
||||
max_subagents: 8
|
||||
|
||||
# Compact before a tool-heavy session can grow beyond the selected model's
|
||||
# usable window. Large old tool results are pruned without an LLM call first;
|
||||
# lean tail retention avoids several expensive back-to-back summary passes.
|
||||
# Keep ample room for long agent work. Compression starts at 82% of whichever
|
||||
# router profile is selected and retains a useful 35% instead of collapsing a
|
||||
# large conversation to a tiny summary.
|
||||
compression:
|
||||
enabled: true
|
||||
progress_notices: true
|
||||
threshold: 0.65
|
||||
# A common absolute ceiling keeps all router profiles responsive. It also
|
||||
# prevents a long Medium/Large/Ultra chat from becoming impossible to move
|
||||
# back to Fast later.
|
||||
threshold_tokens: 60000
|
||||
target_ratio: 0.15
|
||||
threshold: 0.82
|
||||
target_ratio: 0.35
|
||||
tail_mode: "lean"
|
||||
protect_last_n: 8
|
||||
protect_last_n: 12
|
||||
protect_first_n: 0
|
||||
proactive_prune_tokens: 50000
|
||||
proactive_prune_min_result_chars: 4000
|
||||
@@ -158,51 +154,6 @@ timeouts:
|
||||
concurrent_batch: 900
|
||||
sequential_call: 900
|
||||
|
||||
mcp_servers:
|
||||
athena-platform:
|
||||
url: "http://mcp-platform-context:8000/mcp"
|
||||
timeout: 180
|
||||
connect_timeout: 30
|
||||
supports_parallel_tool_calls: false
|
||||
athena-operator:
|
||||
url: "http://mcp-athena-operator:8000/mcp"
|
||||
timeout: 900
|
||||
connect_timeout: 30
|
||||
supports_parallel_tool_calls: false
|
||||
web-general:
|
||||
url: "http://tinysearch:8000/mcp"
|
||||
timeout: 180
|
||||
connect_timeout: 30
|
||||
supports_parallel_tool_calls: false
|
||||
github:
|
||||
url: "http://mcp-github:8000/mcp"
|
||||
timeout: 300
|
||||
connect_timeout: 30
|
||||
supports_parallel_tool_calls: false
|
||||
homeassistant-admin:
|
||||
url: "http://mcp-homeassistant:8000/mcp"
|
||||
timeout: 300
|
||||
connect_timeout: 30
|
||||
supports_parallel_tool_calls: false
|
||||
arr:
|
||||
url: "http://mcp-arr:8000/mcp"
|
||||
timeout: 600
|
||||
connect_timeout: 30
|
||||
supports_parallel_tool_calls: false
|
||||
navidrome:
|
||||
url: "http://mike-ai-mcp-navidrome:3000/mcp"
|
||||
timeout: 300
|
||||
connect_timeout: 30
|
||||
supports_parallel_tool_calls: false
|
||||
deemix:
|
||||
url: "http://mcp-deemix:8000/mcp"
|
||||
timeout: 300
|
||||
connect_timeout: 30
|
||||
supports_parallel_tool_calls: false
|
||||
unraid:
|
||||
url: "${MUA_MCP_URL}"
|
||||
headers:
|
||||
Authorization: "Bearer ${MUA_MCP_BEARER_TOKEN}"
|
||||
timeout: 900
|
||||
connect_timeout: 30
|
||||
supports_parallel_tool_calls: false
|
||||
# BEGIN MANAGED MCP SERVERS
|
||||
mcp_servers: {}
|
||||
# END MANAGED MCP SERVERS
|
||||
|
||||
@@ -78,6 +78,9 @@ if placeholder not in text:
|
||||
raise SystemExit("ROUTER_API_KEY placeholder missing from managed Hermes config")
|
||||
path.write_text(text.replace(placeholder, os.environ["ROUTER_API_KEY"], 1))
|
||||
PY
|
||||
python3 "$STACK_DIR/platform/mcp/sync-clients.py" \
|
||||
--registry "$STACK_DIR/config/mcp-registry.json" \
|
||||
--hermes "$HERMES_DATA_DIR/config.yaml"
|
||||
install -m 0600 "$STACK_DIR/platform/hermes/SOUL.md" "$HERMES_DATA_DIR/SOUL.md"
|
||||
chown -R 10000:10000 "$HERMES_DATA_DIR"
|
||||
"$STACK_DIR/platform/hermes/install-skills.sh"
|
||||
|
||||
@@ -30,8 +30,10 @@ create_profile() {
|
||||
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 auxiliary.title_generation.enabled false
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.threshold_tokens 60000
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.protect_last_n 8
|
||||
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.target_ratio 0.35
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.protect_last_n 12
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.protect_first_n 0
|
||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set platform_toolsets.cli \
|
||||
'["web","terminal","file","skills","todo","memory","vision","tts"]'
|
||||
@@ -80,6 +82,12 @@ for path in paths:
|
||||
path.write_text(text)
|
||||
PY
|
||||
|
||||
sync_args=(--registry "${STACK_DIR:-/opt/mike-ai/stack}/config/mcp-registry.json")
|
||||
while IFS= read -r config; do
|
||||
sync_args+=(--hermes "$config")
|
||||
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[@]}"
|
||||
|
||||
"${STACK_DIR:-/opt/mike-ai/stack}/platform/hermes/install-skills.sh"
|
||||
docker exec "$HERMES_CONTAINER" hermes profile list
|
||||
printf 'HERMES_PROFILES_OK\n'
|
||||
|
||||
@@ -4,22 +4,22 @@ description: Understand, operate and extend the Athena AI host.
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
version: 1.0.0
|
||||
version: 2.0.0
|
||||
author: Michael Roll
|
||||
platforms: [linux]
|
||||
tags: [athena, docker, mcp, models, recovery]
|
||||
tags: [athena, docker, mcp, models, backup]
|
||||
---
|
||||
|
||||
# Athena Operator
|
||||
|
||||
Use this skill for work on Athena itself: Docker, MCPs, models, profiles,
|
||||
Hermes, OpenWebUI, TTS, STT, image generation, Git and recovery.
|
||||
Hermes, OpenWebUI, TTS, STT, image generation, Git and backups.
|
||||
|
||||
## Start
|
||||
|
||||
1. Read `ATHENA.md` through Athena Platform Context. It is the normal source
|
||||
of architectural truth.
|
||||
2. Call `athena_operator_inspect` once for the affected area.
|
||||
1. Call `athena_operator_inspect` with `subject=guide`; it returns the current
|
||||
`ATHENA.md` as the architectural truth.
|
||||
2. Inspect the affected live area only if needed.
|
||||
3. Search for the concrete source file, then read only the required lines.
|
||||
|
||||
Do not rediscover the complete platform for every task. Do not read entire
|
||||
@@ -32,8 +32,9 @@ apply the smallest durable change. The operator owns the Git worktree and
|
||||
deployment access; do not clone another repository or request another SSH key.
|
||||
|
||||
Afterwards run focused checks, verify the affected service, commit and push.
|
||||
Create a recovery kit after the complete change works, not after every
|
||||
intermediate edit.
|
||||
The scheduled Docker-data backup is automatic. After storage-affecting work,
|
||||
run one manual backup and verify its archive instead of building a special
|
||||
recovery kit.
|
||||
|
||||
For a new MCP, normally change only its server code, Dockerfile, MCP Compose
|
||||
service, env example, client registration and a focused test. Reuse an existing
|
||||
@@ -47,7 +48,7 @@ backend instead of installing a duplicate service.
|
||||
- Prefer specialist MCPs for Home Assistant, Unraid, ARR, Navidrome and other
|
||||
external systems.
|
||||
- A healthy container is not proof; perform one bounded functional check.
|
||||
- Never claim a write, deploy, commit, push or recovery succeeded without its
|
||||
- Never claim a write, deploy, commit, push or backup succeeded without its
|
||||
actual result.
|
||||
|
||||
## Remote-host boundary
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
FROM python:3.13-slim@sha256:ffb752e139c0a19692a43af8d8523b274222dd68eebad5d583b45c2201c6e30a
|
||||
|
||||
ARG MCP_PROXY_VERSION=0.12.0
|
||||
RUN pip install --no-cache-dir "mcp-proxy==${MCP_PROXY_VERSION}" "mcp==1.29.0"
|
||||
|
||||
RUN useradd --system --uid 10001 --create-home --home-dir /app mcp
|
||||
COPY platform_context_mcp.py /app/platform_context_mcp.py
|
||||
RUN chown -R 10001:10001 /app
|
||||
|
||||
USER 10001:10001
|
||||
WORKDIR /app
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["mcp-proxy", "--host", "0.0.0.0", "--port", "8000", "--stateless", "--"]
|
||||
CMD ["python", "/app/platform_context_mcp.py"]
|
||||
@@ -1,342 +0,0 @@
|
||||
# Zentrale MCP-Werkzeugebene
|
||||
|
||||
MCP-Werkzeuge sind **keine llama.cpp-Startparameter**. Sie laufen als kleine,
|
||||
voneinander getrennte Container und werden von OpenWebUI, Hermes oder einem
|
||||
anderen MCP-Client gezielt ausgewählt. Das hält Tool-Schemas aus normalen
|
||||
Prompts heraus, verhindert den früher beobachteten Kontextverbrauch von über
|
||||
200.000 Tokens und macht Werkzeuge unabhängig vom geladenen Modellprofil.
|
||||
|
||||
## Container
|
||||
|
||||
| Container | Endpunkt im Netz `mike-ai-tools` | Zweck | Standard |
|
||||
|---|---|---|---|
|
||||
| `mcp-platform-context` | `http://mike-ai-mcp-platform-context:8000/mcp` | kurze read-only Athena-Auskunft und begrenzter Snapshot | an |
|
||||
| `mcp-athena-operator` | `http://mike-ai-mcp-athena-operator:8000/mcp` | vollständiger Betrieb plus breites begrenztes Terminal | an |
|
||||
| `tinysearch` | `http://tinysearch:8000/mcp` | allgemeine portable Websuche und Seitenabruf | an |
|
||||
| `mcp-web` | `http://mike-ai-mcp-web:8000/mcp` | frühere spezialisierte Web-Fassade | nur Profil `legacy-web` |
|
||||
| `mcp-homeassistant` | `http://mike-ai-mcp-homeassistant:8000/mcp` | Relay zum nativen HA-MCP; Token bleibt serverseitig | Profil `homeassistant` |
|
||||
| `mcp-arr` | `http://mike-ai-mcp-arr:8000/mcp` | Sonarr/Radarr/Prowlarr mit serverseitiger Policy | Profil `arr` |
|
||||
| `mcp-navidrome` | `http://mike-ai-mcp-navidrome:3000/mcp` | Navidrome-Bibliothek, Suche, Playlists, Favoriten und Hörverlauf | Profil `navidrome` |
|
||||
| `mcp-github` | `http://mike-ai-mcp-github:8000/mcp` | offizieller GitHub-MCP, auf drei kleine Repository-Lesewerkzeuge begrenzt | Profil `github` |
|
||||
| `mcp-unraid-ssh` | `http://mike-ai-mcp-unraid-ssh:8000/mcp` | erweiterte Diagnose über einen erzwungenen SSH-Befehl | optional (`extended`) |
|
||||
|
||||
Unraid wird produktiv ausschließlich über das auf dem HomeServer laufende
|
||||
MUA-Plugin (`http://192.168.1.2:3002/mcp`) angebunden. Open WebUI führt davon
|
||||
zwei Ansichten: `mua-readonly-local` für automatische Diagnose und `mua` für
|
||||
bewusst aktivierte Verwaltungsaktionen. Ein GraphQL-basierter Unraid-MCP ist
|
||||
nicht Bestandteil des Stacks.
|
||||
|
||||
Die drei Websuch-Container verwenden `AI_DNS` aus
|
||||
`/etc/mike-ai/stack.env`. Der Web-MCP hängt zusätzlich am getrennten
|
||||
`mike-ai-tools-egress`-Netz, weil er gefundene öffentliche Seiten nach der
|
||||
SSRF-Prüfung selbst abrufen muss. Ohne diese beiden Einstellungen kann die
|
||||
Werkzeugauswahl korrekt wirken, während alle Suchmaschinen und Seitenabrufe
|
||||
gleichzeitig fehlschlagen.
|
||||
|
||||
Der Platform Context MCP hat keinen Docker-Socket, keine Shell, keinen Egress
|
||||
und keine Secrets. Sein aktueller Zustand stammt aus einem fest programmierten
|
||||
Host-Snapshot. Er liefert `ATHENA.md` sowie kleine, begrenzte Such- und
|
||||
Leseausschnitte. Vollständige Beschreibung:
|
||||
[`docs/PLATFORM_CONTEXT_MCP.md`](../../docs/PLATFORM_CONTEXT_MCP.md).
|
||||
|
||||
Der Athena Operator MCP ist die einzige Bedienebene für Arbeiten an der lokalen
|
||||
KI-Plattform. Seine sechs sichtbaren Werkzeuge sind Inspect, Suche, begrenztes
|
||||
Lesen, Terminal, direkte Änderung und Jobstatus. Qwen kann damit MCPs und
|
||||
Docker-Dienste bauen/deployen, Modelle laden, Benchmarks starten, Profile und
|
||||
OpenWebUI pflegen, Git veröffentlichen und Recovery erzeugen. Zusätzlich bietet er ein breites, ausgabebegrenztes Terminal für
|
||||
unvorhergesehene Docker-, Datei-, Git-, HTTP-, Modell- und Remote-SSH-Aufgaben.
|
||||
Die MCP-Fassade sieht nur einen lokalen Unix-Socket; Root-Rechte verbleiben im
|
||||
Executor. Strombefehle und Änderungen an Athenas SSH, LAN, WireGuard, Firewall,
|
||||
Boot, Kernel, Mounts und Partitionen werden serverseitig blockiert.
|
||||
|
||||
Für Git-Publishing besitzt Athena ein eigenes Schlüsselpaar unter
|
||||
`/etc/mike-ai/athena-operator-git{,.pub}`. Nur der öffentliche Schlüssel wird
|
||||
in Gitea als schreibberechtigter Deploy-Key für `AI-Profile-Router` hinterlegt.
|
||||
Der private Schlüssel verlässt Athena nicht und wird weder an den MCP-Container
|
||||
noch an das Modell ausgegeben.
|
||||
Der Gitea-Endpunkt ist als `ssh://...:33/...` konfiguriert; sein auf dem
|
||||
Administrator-Mac verifizierter Ed25519-Hostschlüssel ist in
|
||||
`config/athena-operator-known-hosts` fest gebunden. Ein unerwarteter
|
||||
Hostschlüsselwechsel stoppt Git-Zugriffe, statt ihn still zu akzeptieren.
|
||||
|
||||
TinySearch bleibt als Ganzes read-only. Nur das flüchtige tmpfs-Verzeichnis
|
||||
`/home/tinysearch/.crawl4ai` ist beschreibbar, weil Crawl4AI dort seinen
|
||||
temporären Browser- und Sitzungszustand erzeugt. Es wird bei jedem
|
||||
Container-Neustart vollständig verworfen.
|
||||
|
||||
TinySearch 0.6.1 ist der allgemeine portable Web-MCP. Auf VPN-Port 8203 können Hermes,
|
||||
Pi und andere Clients seine vier Upstream-Werkzeuge direkt nutzen. SearXNG ist
|
||||
der Such-Backenddienst. Die historische eigene Web-Fassade ist nur Rollback.
|
||||
|
||||
Die fünf Open-WebUI-Profile Fast, Medium, Large, Ultra und Uncensored halten für
|
||||
allgemeine öffentliche Recherche Open WebUIs native Werkzeuge `search_web` und
|
||||
`fetch_url` verfügbar. Neue Websites benötigen keine neue Selector-Regel.
|
||||
|
||||
Ein gemeinsamer Systemhinweis der fünf Profile verlangt Webprüfung bei
|
||||
aktuellen, veränderlichen oder wesentlich unsicheren Tatsachen. Stabiles
|
||||
Allgemeinwissen soll ohne unnötige Suche beantwortet werden. Die Anweisung
|
||||
fordert gezielte statt wiederholter Synonymsuchen, Quellenlinks, transparente
|
||||
Unsicherheit und behandelt Webseiteninhalte grundsätzlich als nicht
|
||||
vertrauenswürdige Daten statt als Anweisungen.
|
||||
|
||||
## Entscheidungshilfe für das Modell
|
||||
|
||||
Die Server- und Werkzeugbeschreibungen grenzen die Zuständigkeiten voneinander
|
||||
ab. Das Modell beginnt mit den breitesten geeigneten Grundfähigkeiten und nutzt
|
||||
Fach-MCPs dort, wo strukturierte Daten oder Aktionen benötigt werden:
|
||||
|
||||
| Aufgabe | Werkzeugserver | Nicht zusätzlich verwenden |
|
||||
|---|---|---|
|
||||
| Aktuelle öffentliche Informationen, Quellen, Hugging Face, Produkte | Web | HA, ARR, Unraid |
|
||||
| GitHub-Repository finden, README/Quellcode/API-Routen gezielt lesen | GitHub Repository | Web, HA, ARR |
|
||||
| Entitäten, Zustände, Historie, Automationen und Dashboards | Home Assistant | Web, Unraid |
|
||||
| Serien, Filme, fehlende Episoden und Indexer-Releases | Sonarr und Radarr | Web |
|
||||
| Persönliche Musikbibliothek, Titel, Alben, Künstler und Playlists | Navidrome | Web, ARR |
|
||||
| Lesende NAS-, Docker-, Array-, Netzwerk- und Logdiagnose | MUA · Unraid-Diagnose (read-only) | MUA-Verwaltung |
|
||||
| Athena-KI-Plattform entwickeln, testen, deployen, Modelle/Git/Recovery pflegen | Athena Operator | Platform Context für reine Architekturauskunft |
|
||||
| Ausdrücklich benötigte MUA-Verwaltungsaktion | MUA | Unraid-Diagnose nicht parallel |
|
||||
|
||||
Ein leeres Ergebnis ist kein Grund, dieselbe Frage über mehrere unpassende
|
||||
Werkzeuge oder leicht veränderte Suchbegriffe erneut auszuführen. Das Modell
|
||||
soll die Grenze transparent nennen und gezielt nachfragen, wenn eine Freigabe
|
||||
oder ein anderes Werkzeug benötigt wird.
|
||||
|
||||
## Sicherheitsmodell
|
||||
|
||||
- Kein MCP-Port wird auf der physischen Universitätsadresse veröffentlicht.
|
||||
Über Athenas WireGuard-Adresse sind die Fach-MCPs direkt auf den in
|
||||
`docs/VPN_SERVICE_PORTS.md` dokumentierten Ports erreichbar.
|
||||
- Nur Clients im privaten Docker-Netz `mike-ai-tools` erreichen die Endpunkte.
|
||||
- Secrets bleiben in Dateien unter `/etc/mike-ai` und werden read-only
|
||||
eingehängt. Sie gehören weder in Git noch in OpenWebUI-Tooldefinitionen.
|
||||
- Jeder Container ist read-only, verliert Linux-Capabilities und hat
|
||||
`no-new-privileges`.
|
||||
- Der SSH-basierte Unraid-Container ist nicht Teil des Standardstarts.
|
||||
- Das allgemeine Terminal ist Bestandteil des Athena Operators auf Port 8202;
|
||||
ein zweiter Shell-MCP ist nicht erforderlich.
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
sudo platform/mcp/install-tools.sh
|
||||
```
|
||||
|
||||
Der Grundstart enthält Plattformwissen, den Athena Operator und das allgemeine
|
||||
TinySearch-Webwerkzeug. Bereits konfigurierte Fachbereiche werden explizit
|
||||
ergänzt:
|
||||
|
||||
Das Skript erkennt vorhandene Secret-Dateien und aktiviert dadurch automatisch
|
||||
`homeassistant`, `arr`, `navidrome` und `github`. Ohne Fach-Secrets bleiben die
|
||||
secretfreien Grunddienste aktiv.
|
||||
|
||||
Für den derzeit migrierten Container kann der Name `Open-WebUI` lauten. Der
|
||||
Netzwerkbefehl ist idempotent zu behandeln.
|
||||
|
||||
Die lokale Installation benötigt die vorhandenen Secret-Dateien:
|
||||
|
||||
```text
|
||||
/etc/mike-ai/homeassistant-admin-mcp.env
|
||||
/etc/mike-ai/arr-mcp.env
|
||||
/etc/mike-ai/navidrome-mcp.env
|
||||
/etc/mike-ai/github-mcp.env
|
||||
/etc/mike-ai/mua-mcp.env
|
||||
```
|
||||
|
||||
`mua-mcp.env` enthält ausschließlich MUA-Endpunkt und Bearer-Token. Der
|
||||
Installer legt daraus die vollständige MUA-Verbindung und eine strikt auf
|
||||
Lesewerkzeuge begrenzte automatische Ansicht an. Die Datei ist root-only
|
||||
(Modus `0600`) und wird nur verschlüsselt im Recovery-Bundle gesichert.
|
||||
|
||||
Die erweiterte Unraid-Diagnose benötigt zusätzlich die Konfigurationsdatei,
|
||||
den eingeschränkten Schlüssel und die bekannte Hostsignatur. Sie wird nur mit
|
||||
`--profile extended` gestartet.
|
||||
|
||||
TinySearch speichert sein lokales Embedding-Modell in einem Docker-Volume.
|
||||
Nach einer Erstinstallation wird das Modell einmalig im Container mit
|
||||
`tinysearch setup` geladen. Das Volume bleibt bei Containerupdates erhalten.
|
||||
|
||||
## Navidrome
|
||||
|
||||
Der Navidrome-MCP basiert auf `Blakeem/Navidrome-MCP` 2.2.0; das amd64-Image
|
||||
ist per OCI-Digest festgeschrieben. Ein kleiner Build-Patch ergänzt bei zwei
|
||||
Internetradio-URL-Schemas das von llama.cpp verlangte abschließende `$`;
|
||||
Verhalten und API-Aufrufe bleiben unverändert. Der Container veröffentlicht keinen
|
||||
Host-Port, besitzt keinen Dateizugriff auf die Musikbibliothek und enthält
|
||||
bewusst kein `mpv`. Er kann daher nicht auf Athena selbst Musik wiedergeben.
|
||||
|
||||
Navidrome sollte einen eigenen normalen Benutzer `mcp` erhalten. Dessen
|
||||
Zugangsdaten liegen ausschließlich in der root-only Datei
|
||||
`/etc/mike-ai/navidrome-mcp.env`; die Vorlage steht unter
|
||||
`config/navidrome-mcp.env.example`. Anschließend genügt:
|
||||
|
||||
```bash
|
||||
sudo install -m 0600 config/navidrome-mcp.env.example /etc/mike-ai/navidrome-mcp.env
|
||||
sudoedit /etc/mike-ai/navidrome-mcp.env
|
||||
sudo platform/mcp/install-tools.sh
|
||||
sudo platform/openwebui/install-filters.sh
|
||||
```
|
||||
|
||||
Der Upstream-Server stellt ohne Playback noch immer über 40 Werkzeuge bereit.
|
||||
Darum wird Navidrome **nicht** als Standardwerkzeug an jedes Modellprofil
|
||||
gehängt. Es wird in OpenWebUI nur für konkrete Musikaufgaben ausgewählt und
|
||||
danach wieder ausgeschaltet. Schreibende Funktionen wie Playlist-Änderungen,
|
||||
Favoriten und Bewertungen wirken unmittelbar im Konto des MCP-Benutzers.
|
||||
|
||||
Optional aktiviert `LASTFM_API_KEY` in derselben Secret-Datei sieben öffentliche
|
||||
Empfehlungswerkzeuge für ähnliche Künstler/Titel, Trends und ergänzende
|
||||
Metadaten. Das Last.fm Shared Secret ist dafür nicht erforderlich und wird
|
||||
nicht gespeichert. Die Integration greift damit weder auf das persönliche
|
||||
Last.fm-Profil noch auf dessen Hörverlauf zu.
|
||||
|
||||
## GitHub
|
||||
|
||||
Der GitHub-Container verwendet unverändert den offiziellen
|
||||
`github/github-mcp-server` 1.10.1. Da dessen lokaler Container stdio spricht,
|
||||
wandelt `mcp-proxy` 0.12.0 ausschließlich den Transport in Streamable HTTP für
|
||||
Open WebUI und weitere interne Clients um. Basisimage und GitHub-Image sind per
|
||||
OCI-Digest festgeschrieben; die Brücke implementiert keine GitHub-Operationen.
|
||||
|
||||
`mcp-proxy` läuft bewusst **stateless**. Die zuerst getestete Supergateway-
|
||||
Brücke war mit Open WebUIs Python-MCP-Client nicht zuverlässig kompatibel: Im
|
||||
stateful Betrieb konnten Sitzungen ablaufen; im stateless Betrieb beantwortete
|
||||
sie die reguläre `notifications/initialized`-Nachricht mit HTTP 400. Beides
|
||||
führte trotz gesundem GitHub-Server und gültigem Token zu
|
||||
`Failed to connect to MCP server 'github-local'`. Der jetzt verwendete Proxy
|
||||
ist derselbe Transport, der sich bereits beim Athena Platform Context MCP
|
||||
bewährt hat.
|
||||
|
||||
Die Brücke wird mit `--pass-environment` gestartet. Ohne diese ausdrückliche
|
||||
Option sieht zwar der Proxy-Prozess den per Docker-Envfile injizierten PAT, der
|
||||
von ihm gestartete GitHub-stdio-Unterprozess jedoch nicht; der offizielle
|
||||
Server fällt dann irreführend auf die interaktive GitHub-Geräteanmeldung
|
||||
zurück. Der Token bleibt dabei eine Umgebungsvariable und erscheint weder in
|
||||
Kommandozeile noch Image, Log oder Open-WebUI-Konfiguration.
|
||||
|
||||
Dem Modell werden ausschließlich `search_repositories`, `get_file_contents`
|
||||
und `search_code` angeboten. Rekursive Komplettbäume wurden entfernt, nachdem
|
||||
ein einzelner Aufruf mehr als 100.000 Zeichen erzeugte und die Antwort
|
||||
verdrängte. Der offizielle Server wird
|
||||
zusätzlich explizit mit `--read-only` gestartet; die Umgebungsvariablen im
|
||||
Compose-Stack bleiben als zweite, deklarative Sicherung erhalten. Damit sind
|
||||
Schreiboperationen auch serverseitig ausgeschlossen. Der Container
|
||||
veröffentlicht keinen Host-Port und speichert den Token nicht in Open WebUI.
|
||||
|
||||
Einrichtung:
|
||||
|
||||
```bash
|
||||
sudo install -m 0600 config/github-mcp.env.example /etc/mike-ai/github-mcp.env
|
||||
sudoedit /etc/mike-ai/github-mcp.env
|
||||
sudo platform/mcp/install-tools.sh
|
||||
sudo platform/openwebui/install-filters.sh
|
||||
```
|
||||
|
||||
Der Token muss eigens für Athena erzeugt werden und ausschließlich lesenden
|
||||
Zugriff auf die tatsächlich benötigten Repositories erhalten. Die drei
|
||||
begrenzten Werkzeuge werden bei vorhandener Secret-Datei an die fünf
|
||||
MikeAI-Profile geheftet. Dadurch kann das Modell Repositoryfragen selbständig
|
||||
prüfen, ohne den großen GitHub-Standardwerkzeugkatalog in den Kontext zu laden.
|
||||
|
||||
## Client-Auswahl
|
||||
|
||||
Große Fachwerkzeuge werden nicht pauschal an jedes Modell gehängt. Nur die
|
||||
native OpenWebUI-Websuche und die drei GitHub-Lesewerkzeuge sind allgemein verfügbar.
|
||||
Für Home-Assistant-Fragen wird HA ausgewählt, für Medien ARR und für die NAS
|
||||
Unraid. Weitere Werkzeuge werden nur aktiviert, wenn die Aufgabe tatsächlich
|
||||
mehrere Bereiche verbindet.
|
||||
|
||||
Schreibende Aktionen bleiben hinter der jeweiligen serverseitigen Policy und
|
||||
einem Vorschau-/Bestätigungsablauf. Ein Client-Schalter allein darf niemals
|
||||
eine read-only Policy aufheben.
|
||||
|
||||
## Home Assistant: abgesicherter YAML-Zugang
|
||||
|
||||
Die Referenzinstallation überlagert im nativen `czechbol/hass-mcp` das Werkzeug
|
||||
`ha_yaml_config` mit
|
||||
`patches/hass_mcp/yaml_config.py`. Es erlaubt strukturierte Zugriffe nur auf
|
||||
`automations.yaml`, `scripts.yaml` und `scenes.yaml`. Für auskommentierte Blöcke
|
||||
stehen begrenztes `read_source` und `find_source` zur Verfügung;
|
||||
`configuration.yaml` darf dabei ebenfalls gelesen werden. Beliebige Pfade und
|
||||
`secrets.yaml` sind konstruktiv ausgeschlossen. Verdächtige Inline-Zugangsdaten
|
||||
und selbst Namen von `!secret`-Referenzen werden in Rohtextantworten maskiert.
|
||||
|
||||
Jede Änderung arbeitet zweistufig: Vorschau mit einmaligem Ticket, anschließend
|
||||
derselbe unveränderte Aufruf mit `confirm=true` nach ausdrücklicher Zustimmung.
|
||||
Vor dem atomaren Schreiben wird eine lokale Sicherung erzeugt. Danach läuft die
|
||||
vollständige Home-Assistant-Konfigurationsprüfung; bei Fehlern oder gescheitertem
|
||||
Reload wird automatisch zurückgerollt. `configuration.yaml` wird nicht live neu
|
||||
geladen und meldet deshalb nach einer erfolgreichen Änderung
|
||||
`restart_required=true`.
|
||||
|
||||
Installation auf dem Host, der das Home-Assistant-Konfigurationsverzeichnis
|
||||
besitzt:
|
||||
|
||||
```bash
|
||||
sudo platform/mcp/install-hass-mcp-yaml-guard.sh \
|
||||
/mnt/user/appdata/HomeAssistant/config
|
||||
```
|
||||
|
||||
Danach Home Assistant kontrolliert neu starten und den Werkzeugkatalog prüfen.
|
||||
Der Installer aktiviert **nicht** pauschal Schreibrechte: In Home Assistant unter
|
||||
**Einstellungen → Geräte & Dienste → Native MCP for Home Assistant → Konfigurieren**
|
||||
muss `Allow write tools` bewusst eingeschaltet werden. Das gibt auch anderen
|
||||
nicht-destruktiven Schreibwerkzeugen dieser Integration Zugriff und sollte daher
|
||||
nur zusammen mit sichtbarer Tool-Freigabe im Client aktiviert werden.
|
||||
|
||||
## Sonarr: sichere Episodensuche
|
||||
|
||||
Der lokale Sonarr-Patch stellt bewusst keine freie Sonarr-Command-API bereit.
|
||||
Der erlaubte Schreibablauf ist eng auf fehlende Episoden begrenzt:
|
||||
|
||||
1. `preview_episode_search` bekommt Serien-ID, Staffel und die exakten
|
||||
Episodennummern. Es liest Sonarr-Metadaten, entfernt bereits vorhandene
|
||||
Episoden und erzeugt eine konkrete Vorschau samt kurzlebigem Ticket.
|
||||
2. Der Client zeigt diese Vorschau unverändert an. Ohne ausdrückliche
|
||||
Benutzerfreigabe endet der Ablauf hier.
|
||||
3. `start_episode_search` akzeptiert nur denselben Umfang, `confirm=true` und
|
||||
das passende Ticket. Erst dann startet Sonarr eine `EpisodeSearch` über die
|
||||
dort konfigurierten Indexer.
|
||||
|
||||
`EpisodeSearch` ist keine bloße Ergebnisvorschau: Sonarr kann dabei sofort das
|
||||
beste akzeptierte Release pro Episode an den Download-Client übergeben. Das
|
||||
gilt auch für explizit ausgewählte, momentan nicht überwachte Episoden;
|
||||
Monitoring steuert vor allem die spätere automatische/RSS-Verarbeitung.
|
||||
|
||||
Der Ablauf ändert weder Serien- noch Staffel-Monitoring und erlaubt weder
|
||||
beliebige Commands noch direkte URL-Downloads. Tickets gelten zehn Minuten,
|
||||
sind einmalig und an genau die angezeigte Auswahl gebunden.
|
||||
|
||||
### Sonarr: ein konkretes Release oder Staffelpaket laden
|
||||
|
||||
Eine automatische Episodensuche ist **kein Ersatz** für die Auswahl eines
|
||||
bestimmten Releases. Wenn ein Benutzer etwa ausdrücklich ein FuN-Staffelpaket
|
||||
verlangt, gilt stattdessen dieser Ablauf:
|
||||
|
||||
1. `search_releases` sucht ausschließlich über Sonarrs konfigurierte Indexer.
|
||||
Für Gruppen- oder Staffelpaketfragen werden `release_group` und
|
||||
`season_pack_only=true` direkt gesetzt; vorhandene Bibliotheksdateien sind
|
||||
kein Beleg dafür, was aktuell auf den Indexern verfügbar ist.
|
||||
2. `preview_release_grab` bekommt Serien-ID, Staffel und die **exakte GUID** des
|
||||
ausgewählten Suchergebnisses. Sonarr wird erneut abgefragt; Titel, Größe,
|
||||
Indexer, Ablehnungsgründe und vorhandene Episodendateien werden angezeigt.
|
||||
3. Erst nach ausdrücklicher Freigabe darf `grab_release` mit demselben Umfang,
|
||||
`confirm=true` und dem kurzlebigen Ticket aufgerufen werden. Es übergibt
|
||||
exakt dieses Release an Sonarrs konfigurierten Download-Client.
|
||||
|
||||
Hat Sonarr das Release abgelehnt oder `downloadAllowed=false` gemeldet, muss
|
||||
bereits die Vorschau nach gesonderter Zustimmung `force=true` enthalten. Das
|
||||
Ticket ist auch daran gebunden. Der MCP löscht keine vorhandenen Dateien und
|
||||
verspricht keine Überschreibung: Ob eine vorhandene Episode nach dem Download
|
||||
ersetzt wird, entscheiden Sonarrs Qualitätsprofil-, Upgrade- und Importregeln.
|
||||
|
||||
## Deemix MCP
|
||||
|
||||
`mcp-deemix` steuert ausschließlich die bereits vorhandene Deemix-Instanz auf
|
||||
Unraid unter `192.168.1.2:6595`. Es installiert kein zweites Deemix. Die
|
||||
root-only Datei `/etc/mike-ai/deemix-mcp.env` aktiviert das Compose-Profil.
|
||||
Hermes erreicht den Dienst intern als `http://mcp-deemix:8000/mcp`, OpenWebUI
|
||||
als `http://mike-ai-mcp-deemix:8000/mcp`. Im Single-User-Modus übernimmt der
|
||||
MCP die in Deemix gespeicherte Anmeldung nur kurzzeitig im Arbeitsspeicher;
|
||||
Secrets werden nicht in Tool-Ausgaben zurückgegeben. Nach Änderungen immer
|
||||
Handshake, `deemix_status`, eine begrenzte Suche und eine unveränderte Queue
|
||||
prüfen. Reinstall: Env-Beispiel nach `/etc/mike-ai/deemix-mcp.env` kopieren,
|
||||
Modus `0600` setzen und `platform/mcp/install-tools.sh` ausführen.
|
||||
@@ -10,7 +10,7 @@ import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
VERSION = "3.0.0"
|
||||
VERSION = "3.1.0"
|
||||
SOCKET_PATH = os.environ.get("ATHENA_OPERATOR_SOCKET", "/operator/operator.sock")
|
||||
|
||||
if hasattr(sys.stdin, "reconfigure"):
|
||||
@@ -22,11 +22,11 @@ if hasattr(sys.stdout, "reconfigure"):
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "athena_operator_inspect",
|
||||
"description": "START HERE once for Athena work. Inspect the requested live area without changing it.",
|
||||
"description": "START HERE with subject=guide. Returns ATHENA.md or one compact live area without changing it.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subject": {"type": "string", "enum": ["overview", "git_status", "containers", "models", "jobs"], "default": "overview"},
|
||||
"subject": {"type": "string", "enum": ["guide", "overview", "git_status", "containers", "models", "jobs"], "default": "guide"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
@@ -82,7 +82,7 @@ TOOLS = [
|
||||
"description": (
|
||||
"Apply one durable change that the user has requested. Operations: patch_update, "
|
||||
"file_update, mcp_release, run_checks, compose_deploy, container_action, "
|
||||
"openwebui_sync, git_publish, model_download, benchmark, recovery. Use a small "
|
||||
"openwebui_sync, git_publish, model_download, benchmark, backup. Use a small "
|
||||
"patch for edits and file_update only for a new or intentionally replaced file. "
|
||||
"mcp_release can perform the complete MCP build/test/deploy/publish workflow."
|
||||
),
|
||||
@@ -91,7 +91,7 @@ TOOLS = [
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["patch_update", "file_update", "mcp_release", "run_checks", "compose_deploy", "container_action", "openwebui_sync", "git_publish", "model_download", "benchmark", "recovery"],
|
||||
"enum": ["patch_update", "file_update", "mcp_release", "run_checks", "compose_deploy", "container_action", "openwebui_sync", "git_publish", "model_download", "benchmark", "backup"],
|
||||
},
|
||||
"payload": {"type": "object"},
|
||||
},
|
||||
|
||||
+10
-46
@@ -1,5 +1,3 @@
|
||||
name: mike-ai-tools
|
||||
|
||||
x-tool-common: &tool-common
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
@@ -28,7 +26,7 @@ services:
|
||||
# needs both the private tool network and the explicitly separated egress
|
||||
# network; keeping it on `tools` only makes search discovery work while
|
||||
# every page fetch fails.
|
||||
networks: [tools, egress]
|
||||
networks: [tools, tools-egress]
|
||||
dns: ["${AI_DNS:-1.1.1.1}"]
|
||||
environment:
|
||||
TINYSEARCH_MCP_URL: http://tinysearch:8000/mcp
|
||||
@@ -52,7 +50,7 @@ services:
|
||||
dns: ["${AI_DNS:-1.1.1.1}"]
|
||||
volumes:
|
||||
- ${SEARXNG_SETTINGS_FILE:-../web-search/searxng-settings.example.yml}:/etc/searxng/settings.yml:ro
|
||||
networks: [tools, egress]
|
||||
networks: [tools, tools-egress]
|
||||
|
||||
tinysearch:
|
||||
<<: *tool-common
|
||||
@@ -80,7 +78,7 @@ services:
|
||||
SEARXNG_URL: http://searxng:8080/search
|
||||
depends_on: [searxng]
|
||||
cap_add: [SETUID, SETGID, CHOWN]
|
||||
networks: [tools, egress]
|
||||
networks: [tools, tools-egress]
|
||||
# The image's built-in `tinysearch doctor` also requires a writable
|
||||
# configuration directory, although normal server operation does not.
|
||||
# Check the service socket instead so read-only hardening remains intact.
|
||||
@@ -108,7 +106,7 @@ services:
|
||||
volumes:
|
||||
- ${HA_ENV_FILE:-/etc/mike-ai/homeassistant-admin-mcp.env}:/run/secrets/homeassistant.env:ro
|
||||
cap_add: [CHOWN, SETUID, SETGID]
|
||||
networks: [tools, egress]
|
||||
networks: [tools, tools-egress]
|
||||
|
||||
mcp-arr:
|
||||
<<: *tool-common
|
||||
@@ -127,7 +125,7 @@ services:
|
||||
# Upstream's generic "Execute any Radarr API action" text gives small
|
||||
# models no routing boundary. This overlay changes guidance only.
|
||||
- ${ARR_RADARR_PATCH:-./patches/mcp_radarr.py}:/usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_radarr.py:ro
|
||||
networks: [tools, egress]
|
||||
networks: [tools, tools-egress]
|
||||
|
||||
mcp-navidrome:
|
||||
<<: *tool-common
|
||||
@@ -153,7 +151,7 @@ services:
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=64m
|
||||
- /config:rw,noexec,nosuid,nodev,size=4m,mode=0700
|
||||
networks: [tools, egress]
|
||||
networks: [tools, tools-egress]
|
||||
|
||||
mcp-deemix:
|
||||
<<: *tool-common
|
||||
@@ -167,30 +165,7 @@ services:
|
||||
- ${DEEMIX_MCP_ENV_FILE:-/etc/mike-ai/deemix-mcp.env}
|
||||
environment:
|
||||
PORT: "8000"
|
||||
networks: [tools, egress]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
mcp-platform-context:
|
||||
<<: *tool-common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.platform-context
|
||||
image: mike-ai/mcp-platform-context:2.0.0
|
||||
container_name: mike-ai-mcp-platform-context
|
||||
environment:
|
||||
ATHENA_REPO_ROOT: /knowledge/repo
|
||||
ATHENA_RUNTIME_FILE: /runtime/runtime.json
|
||||
volumes:
|
||||
- ${PLATFORM_STACK_DIR:-/opt/mike-ai/stack}:/knowledge/repo:ro
|
||||
- ${PLATFORM_CONTEXT_RUNTIME_DIR:-/var/lib/mike-ai-platform-context}:/runtime:ro
|
||||
# Documentation is strictly read-only. Runtime inspection and all changes
|
||||
# belong to the Athena Operator instead of a second maintenance workflow.
|
||||
networks: [tools]
|
||||
networks: [tools, tools-egress]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
|
||||
interval: 30s
|
||||
@@ -203,7 +178,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.athena-operator
|
||||
image: mike-ai/mcp-athena-operator:3.0.0
|
||||
image: mike-ai/mcp-athena-operator:3.1.0
|
||||
container_name: mike-ai-mcp-athena-operator
|
||||
environment:
|
||||
ATHENA_OPERATOR_SOCKET: /operator/operator.sock
|
||||
@@ -235,7 +210,7 @@ services:
|
||||
# for broader toolsets. The token itself must also remain read-only.
|
||||
GITHUB_TOOLS: search_repositories,get_file_contents,search_code
|
||||
GITHUB_READ_ONLY: "1"
|
||||
networks: [tools, egress]
|
||||
networks: [tools, tools-egress]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
|
||||
interval: 30s
|
||||
@@ -259,18 +234,7 @@ services:
|
||||
- ${UNRAID_SSH_KEY:-/etc/mike-ai/keys/unraid_root}:/etc/mike-ai/keys/unraid_root:ro
|
||||
- ${UNRAID_KNOWN_HOSTS:-/etc/mike-ai/ssh/known_hosts_unraid_ai}:/etc/mike-ai/ssh/known_hosts_unraid_ai:ro
|
||||
- unraid-audit:/var/log/mike-ai
|
||||
networks: [tools, egress]
|
||||
|
||||
networks:
|
||||
tools:
|
||||
name: mike-ai-tools
|
||||
internal: true
|
||||
ipam:
|
||||
config: [{subnet: 172.30.40.0/24}]
|
||||
egress:
|
||||
name: mike-ai-tools-egress
|
||||
ipam:
|
||||
config: [{subnet: 172.30.50.0/24}]
|
||||
networks: [tools, tools-egress]
|
||||
|
||||
volumes:
|
||||
tinysearch-models:
|
||||
|
||||
@@ -4,12 +4,13 @@ set -Eeuo pipefail
|
||||
[[ $EUID -eq 0 ]] || { echo "Bitte als root ausführen." >&2; exit 1; }
|
||||
|
||||
MCP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
STACK_DIR="$(cd "$MCP_DIR/../.." && pwd)"
|
||||
STACK_ENV=${STACK_ENV:-/etc/mike-ai/stack.env}
|
||||
COMPOSE=(docker compose)
|
||||
if [[ -s $STACK_ENV ]]; then
|
||||
COMPOSE+=(--env-file "$STACK_ENV")
|
||||
fi
|
||||
COMPOSE+=(-f "$MCP_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 ]] || {
|
||||
@@ -17,48 +18,44 @@ export SEARXNG_SETTINGS_FILE="${SEARXNG_SETTINGS_FILE:-$MCP_DIR/../web-search/se
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The read-only platform context MCP never receives the Docker socket. A
|
||||
# root-owned timer writes a bounded metadata snapshot instead.
|
||||
install -d -m 0755 /usr/local/libexec /var/lib/mike-ai-platform-context
|
||||
install -m 0755 "$MCP_DIR/platform-context-snapshot.py" \
|
||||
/usr/local/libexec/mike-ai-platform-context-snapshot
|
||||
install -m 0644 "$MCP_DIR/../systemd/mike-ai-platform-context-snapshot.service" \
|
||||
/etc/systemd/system/mike-ai-platform-context-snapshot.service
|
||||
install -m 0644 "$MCP_DIR/../systemd/mike-ai-platform-context-snapshot.timer" \
|
||||
/etc/systemd/system/mike-ai-platform-context-snapshot.timer
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now mike-ai-platform-context-snapshot.timer
|
||||
systemctl start mike-ai-platform-context-snapshot.service
|
||||
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 inspect mike-ai-tools-egress >/dev/null 2>&1 || \
|
||||
docker network create --subnet 172.30.50.0/24 mike-ai-tools-egress >/dev/null
|
||||
|
||||
# One user-facing Athena Operator MCP controls the local AI platform through a
|
||||
# root-side executor. The facade exposes six bounded tools and no Docker socket
|
||||
# or host paths to its unprivileged container.
|
||||
# One administrative MCP exposes ATHENA.md plus the bounded host operator.
|
||||
"$MCP_DIR/../operator/install-operator.sh"
|
||||
|
||||
profiles=()
|
||||
services=(searxng tinysearch mcp-athena-operator)
|
||||
if [[ -s /etc/mike-ai/homeassistant-admin-mcp.env ]]; then
|
||||
profiles+=(--profile homeassistant)
|
||||
services+=(mcp-homeassistant)
|
||||
else
|
||||
echo "Home Assistant bleibt aus: Secret-Datei fehlt."
|
||||
fi
|
||||
if [[ -s /etc/mike-ai/arr-mcp.env ]]; then
|
||||
profiles+=(--profile arr)
|
||||
services+=(mcp-arr)
|
||||
else
|
||||
echo "ARR bleibt aus: Secret-Datei fehlt."
|
||||
fi
|
||||
if [[ -s /etc/mike-ai/navidrome-mcp.env ]]; then
|
||||
profiles+=(--profile navidrome)
|
||||
services+=(mcp-navidrome)
|
||||
else
|
||||
echo "Navidrome bleibt aus: Secret-Datei fehlt."
|
||||
fi
|
||||
if [[ -s /etc/mike-ai/deemix-mcp.env ]]; then
|
||||
profiles+=(--profile deemix)
|
||||
services+=(mcp-deemix)
|
||||
else
|
||||
echo "Deemix MCP bleibt aus: Konfigurationsdatei fehlt."
|
||||
fi
|
||||
if [[ -s /etc/mike-ai/github-mcp.env ]] && \
|
||||
grep -Eq '^GITHUB_PERSONAL_ACCESS_TOKEN=.+$' /etc/mike-ai/github-mcp.env; then
|
||||
profiles+=(--profile github)
|
||||
services+=(mcp-github)
|
||||
else
|
||||
echo "GitHub bleibt aus: dedizierter Read-only-Token fehlt."
|
||||
fi
|
||||
@@ -81,7 +78,7 @@ if ! docker run --rm --entrypoint test \
|
||||
-c 'from tinysearch.services.onnx_bundle_service import ensure_onnx_bundle_sync; ensure_onnx_bundle_sync("fast")'
|
||||
fi
|
||||
|
||||
"${COMPOSE[@]}" "${profiles[@]}" up -d --build
|
||||
"${COMPOSE[@]}" "${profiles[@]}" up -d --build "${services[@]}"
|
||||
|
||||
for webui in mike-ai-open-webui Open-WebUI; do
|
||||
if docker container inspect "$webui" >/dev/null 2>&1; then
|
||||
|
||||
@@ -1,38 +1,150 @@
|
||||
"""Radarr action-routed MCP tool with model-oriented routing guidance."""
|
||||
"""Small, model-oriented Radarr tools built on the upstream API client."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from agent_utilities.mcp_utilities import dispatch, run_blocking
|
||||
from agent_utilities.mcp_utilities import dispatch, public_actions, run_blocking
|
||||
from fastmcp import FastMCP
|
||||
from pydantic import Field
|
||||
|
||||
from arr_mcp.auth import get_radarr_client
|
||||
|
||||
|
||||
BLOCKED_ACTIONS = {
|
||||
"request", "get_", "get_api", "get_content_path", "get_path",
|
||||
"get_login", "get_logout", "post_login", "post_system_restart",
|
||||
"post_system_shutdown",
|
||||
}
|
||||
|
||||
|
||||
def _safe_actions(client: Any) -> list[str]:
|
||||
"""Hide transport, authentication and service-power implementation methods."""
|
||||
return [name for name in public_actions(client) if name not in BLOCKED_ACTIONS]
|
||||
|
||||
|
||||
def _codec_aliases(value: str) -> set[str]:
|
||||
aliases = {
|
||||
"h264": {"h264", "x264", "avc"},
|
||||
"x264": {"h264", "x264", "avc"},
|
||||
"avc": {"h264", "x264", "avc"},
|
||||
"h265": {"h265", "x265", "hevc"},
|
||||
"x265": {"h265", "x265", "hevc"},
|
||||
"hevc": {"h265", "x265", "hevc"},
|
||||
}
|
||||
requested: set[str] = set()
|
||||
for item in value.split(","):
|
||||
key = item.strip().casefold()
|
||||
if key:
|
||||
requested.update(aliases.get(key, {key}))
|
||||
return requested
|
||||
|
||||
|
||||
def _compact_inventory(
|
||||
movies: list[dict[str, Any]], *, codecs: str = "", query: str = "",
|
||||
offset: int = 0, limit: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
wanted = _codec_aliases(codecs)
|
||||
needle = query.strip().casefold()
|
||||
rows: list[dict[str, Any]] = []
|
||||
for movie in movies:
|
||||
movie_file = movie.get("movieFile") or {}
|
||||
if not movie.get("hasFile") or not movie_file:
|
||||
continue
|
||||
media = movie_file.get("mediaInfo") or {}
|
||||
codec = str(media.get("videoCodec") or "unknown")
|
||||
if wanted and codec.casefold() not in wanted:
|
||||
continue
|
||||
title = str(movie.get("title") or "")
|
||||
if needle and needle not in title.casefold():
|
||||
continue
|
||||
quality = movie_file.get("quality") or {}
|
||||
quality_name = (quality.get("quality") or {}).get("name") if isinstance(quality, dict) else None
|
||||
size = int(movie_file.get("size") or 0)
|
||||
rows.append({
|
||||
"radarrId": movie.get("id"),
|
||||
"title": title,
|
||||
"year": movie.get("year"),
|
||||
"movieFileId": movie_file.get("id"),
|
||||
"relativePath": movie_file.get("relativePath"),
|
||||
"sizeBytes": size,
|
||||
"sizeGiB": round(size / 1073741824, 2),
|
||||
"quality": quality_name,
|
||||
"resolution": media.get("resolution"),
|
||||
"videoCodec": codec,
|
||||
"videoBitDepth": media.get("videoBitDepth"),
|
||||
"audioCodec": media.get("audioCodec"),
|
||||
"audioLanguages": media.get("audioLanguages"),
|
||||
"subtitles": media.get("subtitles"),
|
||||
})
|
||||
rows.sort(key=lambda row: (str(row["title"]).casefold(), row.get("year") or 0))
|
||||
total = len(rows)
|
||||
start = max(0, int(offset))
|
||||
count = min(500, max(1, int(limit)))
|
||||
selected = rows[start:start + count]
|
||||
return {
|
||||
"totalMatched": total,
|
||||
"offset": start,
|
||||
"returned": len(selected),
|
||||
"hasMore": start + len(selected) < total,
|
||||
"movies": selected,
|
||||
}
|
||||
|
||||
|
||||
def register_radarr_tools(mcp: FastMCP) -> None:
|
||||
@mcp.tool(tags={"radarr"})
|
||||
async def radarr_movie_codec_inventory(
|
||||
video_codecs: str = Field(
|
||||
default="",
|
||||
description="Optional comma-separated filter, e.g. h264, x264, h265, x265 or hevc.",
|
||||
),
|
||||
query: str = Field(default="", description="Optional case-insensitive title fragment."),
|
||||
offset: int = Field(default=0, ge=0),
|
||||
limit: int = Field(default=200, ge=1, le=500),
|
||||
) -> Any:
|
||||
"""Compact authoritative Radarr movie-file inventory. Use for codec, resolution, language and size questions instead of get_movie, raw API requests or filesystem scans. Results are valid bounded JSON without alternate titles, images, overviews or ratings."""
|
||||
client = get_radarr_client()
|
||||
response = await run_blocking(client.get_movie)
|
||||
movies = response.get("result", response) if isinstance(response, dict) else response
|
||||
if not isinstance(movies, list):
|
||||
raise RuntimeError("Radarr get_movie returned an unexpected response")
|
||||
return _compact_inventory(
|
||||
movies, codecs=video_codecs, query=query, offset=offset, limit=limit,
|
||||
)
|
||||
|
||||
@mcp.tool(tags={"radarr"})
|
||||
async def radarr_action(
|
||||
action: str = Field(
|
||||
description=(
|
||||
"Choose one Radarr operation. Common read choices include get_movie for the "
|
||||
"movie library and get_system_status/get_health for Radarr diagnostics. Use "
|
||||
"list_actions only when an unusual Radarr operation is genuinely required. "
|
||||
"Never guess a modifying action and never change Radarr without explicit user approval."
|
||||
"A named Radarr API operation. Prefer radarr_movie_codec_inventory for "
|
||||
"library/file/codec questions. Use list_actions once for unusual operations. "
|
||||
"Raw request, authentication and Radarr restart/shutdown methods are unavailable."
|
||||
)
|
||||
),
|
||||
params_json: str = Field(
|
||||
default="{}",
|
||||
description=(
|
||||
"JSON object encoded as a string containing only parameters required by the "
|
||||
"selected Radarr action. Use \"{}\" for actions without parameters; never "
|
||||
"invent movie IDs, paths, profile IDs or monitoring settings."
|
||||
),
|
||||
description="JSON object string with only the selected action's required parameters.",
|
||||
),
|
||||
) -> Any:
|
||||
"""USE ONLY for movies managed by Radarr: inspect the movie library, wanted/queue/history state, releases, profiles, or Radarr health. DO NOT use for TV episodes (use Sonarr), public-web research, media playback, filesystem copying, or direct downloads. Prefer read actions; any mutation requires explicit user approval."""
|
||||
"""Other Radarr operations for movies, queue, history, releases, profiles and health. TV episodes belong to Sonarr. Mutations require an explicit user request."""
|
||||
client = get_radarr_client()
|
||||
kwargs = {k: v for k, v in json.loads(params_json).items() if v is not None}
|
||||
actions = _safe_actions(client)
|
||||
if action in {"list_actions", "actions", "help", "capabilities"}:
|
||||
return {"service": "arr-radarr", "actions": actions}
|
||||
if action not in actions:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "unknown or unavailable Radarr action",
|
||||
"action": action,
|
||||
"retry": False,
|
||||
"hint": "Use list_actions once or radarr_movie_codec_inventory for file/codec questions.",
|
||||
}
|
||||
try:
|
||||
parsed = json.loads(params_json)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"params_json is not valid JSON: {exc.msg}") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("params_json must encode a JSON object")
|
||||
kwargs = {key: value for key, value in parsed.items() if value is not None}
|
||||
return await run_blocking(
|
||||
dispatch, client, action, kwargs, service="arr-radarr"
|
||||
)
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a bounded, payload-free Athena runtime snapshot for the context MCP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
OUTPUT = pathlib.Path("/var/lib/mike-ai-platform-context/runtime.json")
|
||||
STACK = pathlib.Path("/opt/mike-ai/stack")
|
||||
|
||||
|
||||
def command(*args: str) -> str:
|
||||
try:
|
||||
return subprocess.run(args, check=True, text=True, capture_output=True, timeout=15).stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
|
||||
|
||||
def docs_hash() -> str | None:
|
||||
digest = hashlib.sha256()
|
||||
files = sorted((STACK / "docs").glob("*.md"))
|
||||
if not files:
|
||||
return None
|
||||
for path in files:
|
||||
digest.update(path.name.encode())
|
||||
digest.update(b"\0")
|
||||
digest.update(path.read_bytes())
|
||||
digest.update(b"\0")
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def containers() -> list[dict[str, str]]:
|
||||
raw = command("docker", "ps", "--filter", "name=mike-ai-", "--format", "{{.Names}}|{{.Image}}|{{.Status}}")
|
||||
result = []
|
||||
for line in raw.splitlines():
|
||||
fields = line.split("|", 2)
|
||||
if len(fields) == 3:
|
||||
result.append({"name": fields[0], "image": fields[1], "status": fields[2]})
|
||||
return sorted(result, key=lambda item: item["name"])
|
||||
|
||||
|
||||
def gpus() -> list[dict[str, object]]:
|
||||
raw = command("nvidia-smi", "--query-gpu=uuid,name,memory.total,memory.used,driver_version", "--format=csv,noheader,nounits")
|
||||
result = []
|
||||
for line in raw.splitlines():
|
||||
fields = [field.strip() for field in line.split(",")]
|
||||
if len(fields) == 5:
|
||||
result.append({"uuid": fields[0], "name": fields[1], "memory_total_mib": int(fields[2]), "memory_used_mib": int(fields[3]), "driver": fields[4]})
|
||||
return result
|
||||
|
||||
|
||||
def filesystems() -> list[dict[str, object]]:
|
||||
raw = command("df", "-B1", "--output=target,fstype,size,used,avail,pcent", "/", "/data")
|
||||
result = []
|
||||
for line in raw.splitlines()[1:]:
|
||||
fields = line.split()
|
||||
if len(fields) == 6:
|
||||
result.append({"mount": fields[0], "fstype": fields[1], "size_bytes": int(fields[2]), "used_bytes": int(fields[3]), "available_bytes": int(fields[4]), "used_percent": fields[5]})
|
||||
return result
|
||||
|
||||
|
||||
def recovery_status() -> dict[str, object]:
|
||||
link = pathlib.Path("/data/mike-ai-recovery-kit")
|
||||
if not link.exists():
|
||||
return {"present": False}
|
||||
target = link.resolve()
|
||||
checksums = target / "SHA256SUMS"
|
||||
return {"present": True, "target": str(target), "modified_unix": int(target.stat().st_mtime), "checksums_present": checksums.is_file()}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
generated = int(time.time())
|
||||
active = [item["name"].removeprefix("mike-ai-llama-") for item in containers() if item["name"].startswith("mike-ai-llama-")]
|
||||
git_commit = command("git", "-C", str(STACK), "rev-parse", "HEAD")
|
||||
commit_file = STACK / ".mike-ai-source-commit"
|
||||
data = {
|
||||
"generated_unix": generated,
|
||||
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(generated)),
|
||||
"hostname": command("hostname"),
|
||||
"os_release": command("sh", "-c", ". /etc/os-release && printf '%s %s' \"$ID\" \"$VERSION_ID\""),
|
||||
"kernel": command("uname", "-r"),
|
||||
"uptime_seconds": float(pathlib.Path("/proc/uptime").read_text().split()[0]),
|
||||
"memory": {"summary": command("free", "-b", "--si").splitlines()[1] if command("free", "-b", "--si") else ""},
|
||||
"filesystems": filesystems(),
|
||||
"gpus": gpus(),
|
||||
"containers": containers(),
|
||||
"active_inference_profiles": active,
|
||||
"source_commit": git_commit or (commit_file.read_text().strip() if commit_file.is_file() else None),
|
||||
"documentation_tree_sha256": docs_hash(),
|
||||
"recovery_kit": recovery_status(),
|
||||
"privacy_scope": "No logs, prompts, chats, container environment values, file contents outside versioned docs, or secrets are collected.",
|
||||
}
|
||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".runtime.", dir=OUTPUT.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
os.chmod(temporary, 0o644)
|
||||
os.replace(temporary, OUTPUT)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,265 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small read-only Athena knowledge MCP.
|
||||
|
||||
The normal entry point is ATHENA.md. Large historical documentation remains
|
||||
available through bounded search/read tools but is never loaded automatically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
VERSION = "2.0.0"
|
||||
REPO_ROOT = Path(os.environ.get("ATHENA_REPO_ROOT", "/knowledge/repo")).resolve()
|
||||
RUNTIME_FILE = Path(os.environ.get("ATHENA_RUNTIME_FILE", "/runtime/runtime.json"))
|
||||
MAX_OVERVIEW_CHARS = 14_000
|
||||
MAX_READ_LINES = 160
|
||||
MAX_SEARCH_RESULTS = 8
|
||||
ALLOWED_SUFFIXES = {".md", ".json", ".yaml", ".yml", ".txt"}
|
||||
BLOCKED_PARTS = {".git", "secrets", "private", "credentials"}
|
||||
|
||||
if hasattr(sys.stdin, "reconfigure"):
|
||||
sys.stdin.reconfigure(encoding="utf-8", errors="replace")
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "athena_get_overview",
|
||||
"description": (
|
||||
"START HERE for Athena architecture or administration. Returns the compact, "
|
||||
"authoritative ATHENA.md. Do not read additional platform documents unless a "
|
||||
"specific unresolved question remains."
|
||||
),
|
||||
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"name": "athena_get_current_state",
|
||||
"description": "Return the compact generated runtime snapshot: active profile, containers, GPUs, source commit and recovery status.",
|
||||
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"name": "athena_get_external_services",
|
||||
"description": "List known services outside Athena so an existing Unraid or home-network backend is reused instead of duplicated.",
|
||||
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"name": "athena_search_reference",
|
||||
"description": (
|
||||
"Search ATHENA.md and documentation for one concrete term. Returns at most eight "
|
||||
"short excerpts. Use only when ATHENA.md did not answer the question."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string", "minLength": 2, "maxLength": 120}},
|
||||
"required": ["query"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "athena_read_reference",
|
||||
"description": (
|
||||
"Read a bounded line range from one known documentation file. Missing paths are "
|
||||
"reported as a normal not-found result and must not be retried by guessing."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "pattern": "^[A-Za-z0-9_.+/-]{1,200}$"},
|
||||
"start_line": {"type": "integer", "minimum": 1, "maximum": 1000000, "default": 1},
|
||||
"line_count": {"type": "integer", "minimum": 1, "maximum": MAX_READ_LINES, "default": 80},
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def result_error(message: str, **details: Any) -> dict[str, Any]:
|
||||
return {"ok": False, "error": message, "retry": False, **details}
|
||||
|
||||
|
||||
def safe_path(relative: str) -> Path | None:
|
||||
if not relative or relative.startswith("/"):
|
||||
return None
|
||||
candidate = Path(relative)
|
||||
if ".." in candidate.parts or any(part.lower() in BLOCKED_PARTS for part in candidate.parts):
|
||||
return None
|
||||
target = (REPO_ROOT / candidate).resolve(strict=False)
|
||||
try:
|
||||
target.relative_to(REPO_ROOT)
|
||||
except ValueError:
|
||||
return None
|
||||
if candidate.name != "ATHENA.md" and (not candidate.parts or candidate.parts[0] != "docs"):
|
||||
return None
|
||||
if target.suffix.lower() not in ALLOWED_SUFFIXES:
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
def read_text(path: Path, limit: int | None = None) -> str:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
return text if limit is None else text[:limit]
|
||||
|
||||
|
||||
def overview() -> dict[str, Any]:
|
||||
path = REPO_ROOT / "ATHENA.md"
|
||||
try:
|
||||
content = read_text(path, MAX_OVERVIEW_CHARS)
|
||||
except (OSError, PermissionError) as exc:
|
||||
return result_error("ATHENA.md is unavailable", path="ATHENA.md", detail=str(exc))
|
||||
return {"ok": True, "source": "ATHENA.md", "content": content, "truncated": path.stat().st_size > len(content.encode())}
|
||||
|
||||
|
||||
def current_state() -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(RUNTIME_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
return result_error("runtime snapshot is unavailable", detail=str(exc))
|
||||
containers = value.get("containers") or []
|
||||
return {
|
||||
"ok": True,
|
||||
"generated_at": value.get("generated_at"),
|
||||
"hostname": value.get("hostname"),
|
||||
"active_inference_profiles": value.get("active_inference_profiles") or [],
|
||||
"source_commit": value.get("source_commit"),
|
||||
"gpus": value.get("gpus") or [],
|
||||
"containers": containers,
|
||||
"container_count": len(containers),
|
||||
"recovery_kit": value.get("recovery_kit") or {"present": False},
|
||||
}
|
||||
|
||||
|
||||
def external_services() -> dict[str, Any]:
|
||||
path = REPO_ROOT / "config/service-catalog.json"
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
return result_error("service catalog is unavailable", detail=str(exc))
|
||||
services = []
|
||||
for item in value.get("services", []):
|
||||
services.append({key: item.get(key) for key in ("id", "name", "host", "address", "port", "protocol", "purpose") if item.get(key) is not None})
|
||||
return {"ok": True, "services": services, "count": len(services)}
|
||||
|
||||
|
||||
def reference_files() -> list[Path]:
|
||||
files = [REPO_ROOT / "ATHENA.md"]
|
||||
docs = REPO_ROOT / "docs"
|
||||
try:
|
||||
files.extend(sorted(path for path in docs.glob("*.md") if path.is_file()))
|
||||
except OSError:
|
||||
pass
|
||||
return files
|
||||
|
||||
|
||||
def search_reference(arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
query = str(arguments.get("query", "")).strip()
|
||||
if len(query) < 2:
|
||||
return result_error("query must contain at least two characters")
|
||||
pattern = re.compile(re.escape(query), re.IGNORECASE)
|
||||
matches: list[dict[str, Any]] = []
|
||||
for path in reference_files():
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
continue
|
||||
for number, line in enumerate(lines, 1):
|
||||
if pattern.search(line):
|
||||
matches.append({
|
||||
"path": str(path.relative_to(REPO_ROOT)),
|
||||
"line": number,
|
||||
"excerpt": line.strip()[:280],
|
||||
})
|
||||
if len(matches) >= MAX_SEARCH_RESULTS:
|
||||
return {"ok": True, "query": query, "matches": matches, "truncated": True}
|
||||
return {"ok": True, "query": query, "matches": matches, "truncated": False}
|
||||
|
||||
|
||||
def read_reference(arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
relative = str(arguments.get("path", ""))
|
||||
path = safe_path(relative)
|
||||
if path is None:
|
||||
return result_error("path is not an allowed documentation path", path=relative)
|
||||
if not path.is_file():
|
||||
return result_error("documentation file not found", path=relative)
|
||||
start = max(1, int(arguments.get("start_line", 1)))
|
||||
count = min(MAX_READ_LINES, max(1, int(arguments.get("line_count", 80))))
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError as exc:
|
||||
return result_error("documentation file is unreadable", path=relative, detail=str(exc))
|
||||
selected = lines[start - 1:start - 1 + count]
|
||||
return {
|
||||
"ok": True,
|
||||
"path": relative,
|
||||
"start_line": start,
|
||||
"end_line": start + len(selected) - 1 if selected else start - 1,
|
||||
"total_lines": len(lines),
|
||||
"content": "\n".join(selected),
|
||||
"truncated": start - 1 + len(selected) < len(lines),
|
||||
}
|
||||
|
||||
|
||||
def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
if name == "athena_get_overview":
|
||||
return overview()
|
||||
if name == "athena_get_current_state":
|
||||
return current_state()
|
||||
if name == "athena_get_external_services":
|
||||
return external_services()
|
||||
if name == "athena_search_reference":
|
||||
return search_reference(arguments)
|
||||
if name == "athena_read_reference":
|
||||
return read_reference(arguments)
|
||||
return result_error("unknown tool", tool=name)
|
||||
|
||||
|
||||
def emit(request_id: Any, result: Any = None, error: dict[str, Any] | None = None) -> None:
|
||||
message = {"jsonrpc": "2.0", "id": request_id}
|
||||
message["error" if error else "result"] = error or result
|
||||
sys.stdout.write(json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def handle(message: dict[str, Any]) -> None:
|
||||
method, request_id = message.get("method"), message.get("id")
|
||||
if method == "initialize":
|
||||
emit(request_id, {
|
||||
"protocolVersion": message.get("params", {}).get("protocolVersion", "2024-11-05"),
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
"serverInfo": {"name": "mike-ai-platform-context", "version": VERSION},
|
||||
})
|
||||
elif method == "tools/list":
|
||||
emit(request_id, {"tools": TOOLS})
|
||||
elif method == "tools/call":
|
||||
params = message.get("params") or {}
|
||||
value = call_tool(str(params.get("name", "")), params.get("arguments") or {})
|
||||
emit(request_id, {
|
||||
"content": [{"type": "text", "text": json.dumps(value, ensure_ascii=False, separators=(",", ":"))}],
|
||||
"structuredContent": value,
|
||||
"isError": False,
|
||||
})
|
||||
elif request_id is not None:
|
||||
emit(request_id, error={"code": -32601, "message": "method not found"})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
if line.strip():
|
||||
handle(json.loads(line))
|
||||
except Exception as exc:
|
||||
sys.stderr.write(f"MCP input error: {exc}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Hermes and OpenWebUI MCP registrations from one JSON registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
|
||||
BEGIN = "# BEGIN MANAGED MCP SERVERS"
|
||||
END = "# END MANAGED MCP SERVERS"
|
||||
|
||||
|
||||
def env_file(path: str) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
source = pathlib.Path(path)
|
||||
if not source.is_file():
|
||||
return values
|
||||
for raw in source.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||
return values
|
||||
|
||||
|
||||
def enabled(item: dict) -> bool:
|
||||
required = item.get("required_file")
|
||||
if required and not pathlib.Path(required).is_file():
|
||||
return False
|
||||
source = item.get("env_file")
|
||||
if source:
|
||||
values = env_file(source)
|
||||
return bool(values.get(item.get("url_env", ""))) and bool(values.get(item.get("key_env", "")))
|
||||
return True
|
||||
|
||||
|
||||
def resolved(item: dict) -> tuple[str, str]:
|
||||
if item.get("env_file"):
|
||||
values = env_file(item["env_file"])
|
||||
return values[item["url_env"]], values[item["key_env"]]
|
||||
return item["url"], ""
|
||||
|
||||
|
||||
def active(registry: pathlib.Path, client: str) -> list[dict]:
|
||||
document = json.loads(registry.read_text(encoding="utf-8"))
|
||||
if document.get("version") != 1 or not isinstance(document.get("servers"), list):
|
||||
raise SystemExit("Unsupported MCP registry schema")
|
||||
return [item for item in document["servers"] if client in item.get("clients", []) and enabled(item)]
|
||||
|
||||
|
||||
def yaml_quote(value: str) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def hermes_block(items: list[dict]) -> str:
|
||||
lines = [BEGIN, "mcp_servers:"]
|
||||
for item in items:
|
||||
url, key = resolved(item)
|
||||
lines.extend([
|
||||
f" {item.get('hermes_id', item['id'])}:",
|
||||
f" url: {yaml_quote(url)}",
|
||||
])
|
||||
if key:
|
||||
lines.extend([" headers:", f" Authorization: {yaml_quote('Bearer ' + key)}"])
|
||||
lines.extend([
|
||||
f" timeout: {int(item.get('timeout', 300))}",
|
||||
" connect_timeout: 30",
|
||||
" supports_parallel_tool_calls: false",
|
||||
])
|
||||
lines.append(END)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def update_hermes(path: pathlib.Path, block: str) -> None:
|
||||
if not path.is_file():
|
||||
return
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if BEGIN in text and END in text:
|
||||
prefix, rest = text.split(BEGIN, 1)
|
||||
_, suffix = rest.split(END, 1)
|
||||
text = prefix.rstrip() + "\n\n" + block + suffix.lstrip("\n")
|
||||
else:
|
||||
marker = "\nmcp_servers:"
|
||||
if marker in text:
|
||||
text = text.split(marker, 1)[0].rstrip() + "\n\n" + block
|
||||
else:
|
||||
text = text.rstrip() + "\n\n" + block
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def openwebui_connection(item: dict) -> dict:
|
||||
url, key = resolved(item)
|
||||
config = {"enable": True, "access_grants": []}
|
||||
if item.get("functions"):
|
||||
config["function_name_filter_list"] = item["functions"]
|
||||
return {
|
||||
"url": url, "path": "", "type": "mcp",
|
||||
"auth_type": item.get("auth_type", "none"), "headers": None,
|
||||
"key": key, "config": config,
|
||||
"info": {"id": item["id"], "name": item["name"], "description": item["description"]},
|
||||
}
|
||||
|
||||
|
||||
def update_openwebui(db: pathlib.Path, items: list[dict]) -> None:
|
||||
con = sqlite3.connect(db)
|
||||
now = int(time.time())
|
||||
row = con.execute("select value from config where key=?", ("tool_server.connections",)).fetchone()
|
||||
old = json.loads(row[0]) if row else []
|
||||
if not isinstance(old, list):
|
||||
raise SystemExit("Unexpected OpenWebUI tool_server.connections format")
|
||||
managed_ids = {
|
||||
"athena-platform", "athena-operator-local", "web-general-local", "github-local",
|
||||
"homeassistant-local", "arr-local", "navidrome-local", "deemix-local", "mua",
|
||||
"mua-readonly-local", "athena-terminal-local", "unraid-readonly-local", "web-local",
|
||||
}
|
||||
keep = [entry for entry in old if str((entry.get("info") or {}).get("id", "")) not in managed_ids]
|
||||
keep.extend(openwebui_connection(item) for item in items)
|
||||
with con:
|
||||
con.execute(
|
||||
"""insert into config (key,value,updated_at) values (?,?,?)
|
||||
on conflict(key) do update set value=excluded.value,updated_at=excluded.updated_at""",
|
||||
("tool_server.connections", json.dumps(keep, ensure_ascii=False), now),
|
||||
)
|
||||
con.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--registry", type=pathlib.Path, required=True)
|
||||
parser.add_argument("--hermes", type=pathlib.Path, action="append", default=[])
|
||||
parser.add_argument("--openwebui-db", type=pathlib.Path)
|
||||
args = parser.parse_args()
|
||||
if args.hermes:
|
||||
block = hermes_block(active(args.registry, "hermes"))
|
||||
for path in args.hermes:
|
||||
update_hermes(path, block)
|
||||
if args.openwebui_db:
|
||||
update_openwebui(args.openwebui_db, active(args.registry, "openwebui"))
|
||||
print("MCP_CLIENT_SYNC_OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -29,7 +29,7 @@ class Filter:
|
||||
"unraid": "server:mcp:mua-readonly-local",
|
||||
"unraid_admin": "server:mcp:mua",
|
||||
"navidrome": "server:mcp:navidrome-local",
|
||||
"platform": "server:mcp:athena-platform",
|
||||
"platform": "server:mcp:athena-operator-local",
|
||||
"operator": "server:mcp:athena-operator-local",
|
||||
"web": "server:mcp:web-general-local",
|
||||
}
|
||||
@@ -300,8 +300,7 @@ class Filter:
|
||||
(
|
||||
r"\bathena\b", r"\bmikeai\b", r"\bki[- ]host\b",
|
||||
r"\bai[- ]profile[- ]router\b", r"\bprofil[- ]router\b",
|
||||
r"\brecovery[- ](?:koffer|bundle|skript)\b",
|
||||
r"\b(?:disaster|bare metal)[- ]recovery\b",
|
||||
r"\b(?:backup|restore|wiederherstellung|neuinstallation)\b",
|
||||
r"\b(?:installations?|reinstall|setup)[- ]skript\b",
|
||||
r"\bplattform(?:wissen|dokumentation)?\b",
|
||||
),
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Synchronise OpenWebUI functions and MCPs from versioned sources.
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
CONTAINER=${OPENWEBUI_CONTAINER:-mike-ai-open-webui}
|
||||
VOLUME=${OPENWEBUI_VOLUME:-mike-ai_open-webui-data}
|
||||
FILTER_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/filters" && pwd)
|
||||
ACTION_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/actions" && pwd)
|
||||
MUA_MCP_ENV_FILE=${MUA_MCP_ENV_FILE:-/etc/mike-ai/mua-mcp.env}
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
FILTER_DIR="$ROOT/platform/openwebui/filters"
|
||||
ACTION_DIR="$ROOT/platform/openwebui/actions"
|
||||
|
||||
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
|
||||
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
|
||||
for file in reasoning_default_off.py thinking.py auto_tool_selector.py stability_guard.py secret_redaction.py spoken_tool_status.py local_performance_metrics.py; do
|
||||
[[ -s $FILTER_DIR/$file ]] || die "Filterdatei fehlt: $file"
|
||||
done
|
||||
[[ -s $ACTION_DIR/quick_actions.py ]] || die "Actiondatei fehlt: quick_actions.py"
|
||||
[[ -s $ACTION_DIR/quick_actions.py ]] || die "Actiondatei fehlt."
|
||||
|
||||
volume_path=$(docker volume inspect -f '{{.Mountpoint}}' "$VOLUME")
|
||||
db=$volume_path/webui.db
|
||||
@@ -24,136 +25,67 @@ if [[ $(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)
|
||||
was_running=true
|
||||
docker stop "$CONTAINER" >/dev/null
|
||||
fi
|
||||
restart_on_exit() {
|
||||
if [[ $was_running == true ]]; then
|
||||
docker start "$CONTAINER" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
restart_on_exit() { [[ $was_running == false ]] || docker start "$CONTAINER" >/dev/null 2>&1 || true; }
|
||||
trap restart_on_exit EXIT
|
||||
|
||||
stamp=$(date +%Y%m%d-%H%M%S)
|
||||
backup=$volume_path/webui.db.before-filter-install-$stamp
|
||||
backup=$volume_path/webui.db.before-managed-sync-$stamp
|
||||
cp -a "$db" "$backup"
|
||||
rm -f "$volume_path/webui.db-wal" "$volume_path/webui.db-shm"
|
||||
|
||||
navidrome_enabled=false
|
||||
[[ -s /etc/mike-ai/navidrome-mcp.env ]] && navidrome_enabled=true
|
||||
deemix_enabled=false
|
||||
[[ -s /etc/mike-ai/deemix-mcp.env ]] && deemix_enabled=true
|
||||
github_enabled=false
|
||||
if [[ -s /etc/mike-ai/github-mcp.env ]] && \
|
||||
grep -Eq '^GITHUB_PERSONAL_ACCESS_TOKEN=.+$' /etc/mike-ai/github-mcp.env; then
|
||||
github_enabled=true
|
||||
fi
|
||||
python3 - "$db" "$FILTER_DIR" "$ACTION_DIR" "${OPENWEBUI_FILTER_OWNER_ID:-}" "$navidrome_enabled" "$github_enabled" "$deemix_enabled" "$MUA_MCP_ENV_FILE" <<'PY'
|
||||
python3 - "$db" "$FILTER_DIR" "$ACTION_DIR" "${OPENWEBUI_FILTER_OWNER_ID:-}" <<'PY'
|
||||
import json
|
||||
import copy
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
|
||||
(
|
||||
db, filter_dir, action_dir, requested_owner, navidrome_enabled_raw,
|
||||
github_enabled_raw, deemix_enabled_raw, mua_mcp_env_file,
|
||||
) = sys.argv[1:]
|
||||
navidrome_enabled = navidrome_enabled_raw.lower() == "true"
|
||||
github_enabled = github_enabled_raw.lower() == "true"
|
||||
deemix_enabled = deemix_enabled_raw.lower() == "true"
|
||||
db, filter_dir, action_dir, owner = sys.argv[1:]
|
||||
con = sqlite3.connect(db)
|
||||
columns = {row[1] for row in con.execute("pragma table_info(function)")}
|
||||
required = {
|
||||
"id", "user_id", "name", "type", "content", "meta", "valves",
|
||||
"is_active", "is_global", "updated_at", "created_at",
|
||||
}
|
||||
required = {"id", "user_id", "name", "type", "content", "meta", "valves", "is_active", "is_global", "updated_at", "created_at"}
|
||||
if not required <= columns:
|
||||
raise SystemExit("Unbekanntes OpenWebUI-Function-Schema; keine Änderung vorgenommen.")
|
||||
config_columns = {row[1] for row in con.execute("pragma table_info(config)")}
|
||||
if not {"key", "value", "updated_at"} <= config_columns:
|
||||
raise SystemExit("Unbekanntes OpenWebUI-Config-Schema; keine Änderung vorgenommen.")
|
||||
|
||||
owner = requested_owner
|
||||
raise SystemExit("Unknown OpenWebUI function schema")
|
||||
if not owner:
|
||||
existing = con.execute(
|
||||
"select user_id from function where id in (?, ?, ?, ?, ?, ?, ?, ?) order by id limit 1",
|
||||
(
|
||||
"reasoning_default_off", "thinking", "auto_tool_selector", "stability_guard",
|
||||
"secret_redaction", "spoken_tool_status", "local_performance_metrics",
|
||||
"quick_actions",
|
||||
),
|
||||
).fetchone()
|
||||
existing = con.execute("select user_id from function where id='reasoning_default_off'").fetchone()
|
||||
if existing:
|
||||
owner = existing[0]
|
||||
if not owner:
|
||||
admins = con.execute("select id from user where role='admin'").fetchall()
|
||||
if len(admins) != 1:
|
||||
raise SystemExit(
|
||||
"Filter-Eigentümer ist nicht eindeutig; OPENWEBUI_FILTER_OWNER_ID setzen."
|
||||
)
|
||||
raise SystemExit("OPENWEBUI_FILTER_OWNER_ID is not unambiguous")
|
||||
owner = admins[0][0]
|
||||
|
||||
now = int(time.time())
|
||||
functions = [
|
||||
("reasoning_default_off", "Reasoning Default Off", "filter", 10, filter_dir, ""),
|
||||
("thinking", "Thinking", "filter", 20, filter_dir, ""),
|
||||
(
|
||||
"auto_tool_selector", "MikeAI Auto Tool Selector", "filter", 25, filter_dir,
|
||||
"Hält die allgemeine Websuche verfügbar und ergänzt automatisch alle fachlich passenden MCP-Domänen. "
|
||||
"Die Auswahl ist Komfort und keine Schranke oder Freigabe für schreibende Aktionen.",
|
||||
),
|
||||
("auto_tool_selector", "MikeAI Auto Tool Selector", "filter", 25, filter_dir, "Keeps general web search available and selects relevant MCP domains."),
|
||||
("stability_guard", "MikeAI Stability Guard", "filter", 30, filter_dir, ""),
|
||||
("secret_redaction", "MikeAI Secret Redaction", "filter", 40, filter_dir, ""),
|
||||
(
|
||||
"spoken_tool_status", "MikeAI Spoken Tool Status", "filter", 80, filter_dir,
|
||||
"Spielt bei aktiver automatischer Sprachausgabe einmalig eine kurze lokale Ansage, "
|
||||
"sobald ein echtes Werkzeug gestartet wird.",
|
||||
),
|
||||
("spoken_tool_status", "MikeAI Spoken Tool Status", "filter", 80, filter_dir, "Plays one short local status phrase when a real tool starts."),
|
||||
("local_performance_metrics", "MikeAI Local Performance Metrics", "filter", 90, filter_dir, ""),
|
||||
(
|
||||
"quick_actions", "MikeAI Quick Actions", "action", 100, action_dir,
|
||||
"Lokale, geprüfte Aktionen für Zusammenfassung, Diagnose, Quellen und Markdown.",
|
||||
),
|
||||
("quick_actions", "MikeAI Quick Actions", "action", 100, action_dir, "Local actions for summaries, diagnosis, sources and Markdown."),
|
||||
]
|
||||
with con:
|
||||
for function_id, name, function_type, priority, source_dir, description in functions:
|
||||
content = pathlib.Path(source_dir, f"{function_id}.py").read_text()
|
||||
for function_id, name, kind, priority, source_dir, description in functions:
|
||||
content = pathlib.Path(source_dir, function_id + ".py").read_text()
|
||||
con.execute(
|
||||
"""
|
||||
insert into function
|
||||
(id,user_id,name,type,content,meta,valves,is_active,is_global,updated_at,created_at)
|
||||
values (?,?,?,?,?,?,?,?,?,?,?)
|
||||
on conflict(id) do update set
|
||||
name=excluded.name,
|
||||
type=excluded.type,
|
||||
content=excluded.content,
|
||||
meta=excluded.meta,
|
||||
valves=excluded.valves,
|
||||
is_active=excluded.is_active,
|
||||
is_global=excluded.is_global,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
function_id, owner, name, function_type, content,
|
||||
json.dumps({"description": description}),
|
||||
json.dumps({"priority": priority}), True, True, now, now,
|
||||
),
|
||||
"""insert into function
|
||||
(id,user_id,name,type,content,meta,valves,is_active,is_global,updated_at,created_at)
|
||||
values (?,?,?,?,?,?,?,?,?,?,?)
|
||||
on conflict(id) do update set name=excluded.name,type=excluded.type,
|
||||
content=excluded.content,meta=excluded.meta,valves=excluded.valves,
|
||||
is_active=excluded.is_active,is_global=excluded.is_global,updated_at=excluded.updated_at""",
|
||||
(function_id, owner, name, kind, content, json.dumps({"description": description}),
|
||||
json.dumps({"priority": priority}), True, True, now, now),
|
||||
)
|
||||
con.execute(
|
||||
"""
|
||||
insert into config (key,value,updated_at) values (?,?,?)
|
||||
on conflict(key) do update set
|
||||
value=excluded.value,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
("task.follow_up.enable", "false", now),
|
||||
)
|
||||
for key, value in (
|
||||
("task.follow_up.enable", False),
|
||||
("audio.tts.engine", "openai"),
|
||||
("audio.tts.model", "piper"),
|
||||
("audio.tts.voice", "alloy"),
|
||||
("audio.tts.openai.api_base_url", "http://router:8081/v1"),
|
||||
# Reproduce the working keyless native web search after a fresh install
|
||||
# or database restore. No query or result content is stored here.
|
||||
("web.search.enable", True),
|
||||
("web.search.engine", "duckduckgo"),
|
||||
("web.search.ddgs_backend", "duckduckgo"),
|
||||
@@ -162,392 +94,17 @@ with con:
|
||||
("web.search.confirmation.enable", False),
|
||||
):
|
||||
con.execute(
|
||||
"""
|
||||
insert into config (key,value,updated_at) values (?,?,?)
|
||||
on conflict(key) do update set
|
||||
value=excluded.value,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
"""insert into config (key,value,updated_at) values (?,?,?)
|
||||
on conflict(key) do update set value=excluded.value,updated_at=excluded.updated_at""",
|
||||
(key, json.dumps(value), now),
|
||||
)
|
||||
|
||||
# Improve model-side tool selection without touching URLs, credentials,
|
||||
# access grants or enable flags from a restored Open WebUI database.
|
||||
row = con.execute(
|
||||
"select value from config where key=?",
|
||||
("tool_server.connections",),
|
||||
).fetchone()
|
||||
if row:
|
||||
connections = json.loads(row[0])
|
||||
if not isinstance(connections, list):
|
||||
raise SystemExit("Unbekanntes Format in tool_server.connections.")
|
||||
before_count = len(connections)
|
||||
connections = [
|
||||
connection for connection in connections
|
||||
if not (
|
||||
isinstance(connection, dict)
|
||||
and (
|
||||
str((connection.get("info") or {}).get("id", "")).lower()
|
||||
in {"athena-terminal-local", "unraid-readonly-local", "web-local"}
|
||||
or "mike-ai-mcp-athena-terminal" in str(connection.get("url", "")).lower()
|
||||
or "mike-ai-mcp-unraid-official" in str(connection.get("url", "")).lower()
|
||||
or "mike-ai-mcp-web" in str(connection.get("url", "")).lower()
|
||||
)
|
||||
)
|
||||
]
|
||||
descriptions = {
|
||||
"web-general-local": (
|
||||
"Allgemeines Web (TinySearch)",
|
||||
"Breite, portable Websuche und Seitenabruf für beliebige öffentliche Websites. "
|
||||
"In OpenWebUI ist native search_web/fetch_url standardmäßig verfügbar; dieser "
|
||||
"Upstream-MCP ist die portable Alternative für Hermes, Pi und manuelle Nutzung. "
|
||||
"Kurze, gezielte Resultate anfordern und niemals private Dateiinhalte senden.",
|
||||
),
|
||||
"homeassistant-local": (
|
||||
"Home Assistant (lokal)",
|
||||
"Für Home-Assistant-Entitäten, Zustände, Historie, Automationen, Dashboards, "
|
||||
"HA-Diagnose und freigegebene YAML-Dateien. YAML-Lesen ist begrenzt; Änderungen "
|
||||
"benötigen serverseitige Vorschau, explizite Freigabe, Sicherung und Validierung. "
|
||||
"Nicht für Unraid, Sonarr/Radarr oder allgemeine Websuche.",
|
||||
),
|
||||
"arr-local": (
|
||||
"Sonarr und Radarr (lokal)",
|
||||
"Nur für verwaltete Serien/Filme, fehlende Episoden, Queue und Suche über "
|
||||
"konfigurierte Indexer. Keine allgemeine Websuche; Schreibaktionen benötigen "
|
||||
"Vorschau und Freigabe.",
|
||||
),
|
||||
"mua-readonly-local": (
|
||||
"MUA · Unraid-Diagnose (read-only)",
|
||||
"Automatisch verwendbarer, serverseitig in Open WebUI auf reine Lese- und "
|
||||
"Diagnosewerkzeuge begrenzter MUA-Zugang. Für Containerbestand, Logs, System, "
|
||||
"Storage, Shares, kompakte Datei-/Medieninventare und Netzwerkstatus. "
|
||||
"Für Bibliotheksprüfungen unraid_files_inventory statt wiederholter "
|
||||
"ls/find-Aufrufe verwenden. Keine Start/Stop-, Installations-, "
|
||||
"Änderungs- oder freie Shell-Funktion. Bei einer ausdrücklich verlangten "
|
||||
"Änderung stellt die automatische Auswahl zusätzlich MUA-Verwaltung bereit.",
|
||||
),
|
||||
"mua": (
|
||||
"MUA (Unraid-Verwaltung)",
|
||||
"Nur für vom Benutzer in der aktuellen Nachricht ausdrücklich verlangte "
|
||||
"Unraid-Verwaltungsaktionen. Gemeinsam mit MUA read-only als geordnete "
|
||||
"Kette verwenden: Zustand prüfen, engste Änderung ausführen, Ergebnis "
|
||||
"read-only verifizieren. Für mehrere bestätigte Image-Updates immer den "
|
||||
"gebündelten, zustandserhaltenden Updateablauf verwenden.",
|
||||
),
|
||||
"navidrome-local": (
|
||||
"Navidrome (Musikbibliothek)",
|
||||
"Nur für die persönliche Navidrome-Musikbibliothek: Titel, Alben, Künstler, "
|
||||
"Playlists, Favoriten und Hörverlauf. Nicht für Sonarr/Radarr, allgemeine "
|
||||
"Websuche oder Audioausgabe auf dem KI-Host. Wegen des großen Werkzeugkatalogs "
|
||||
"nur bei Musikaufgaben aktivieren.",
|
||||
),
|
||||
"deemix-local": (
|
||||
"Deemix (bestehende Unraid-Instanz)",
|
||||
"Durchsucht Deezer über die vorhandene Deemix-Instanz auf Unraid und "
|
||||
"verwaltet deren Download-Queue. Keine zweite Deemix-Instanz. Schreibende "
|
||||
"Queue-Aktionen nur auf ausdrücklichen Benutzerauftrag; Status und Suche "
|
||||
"sind read-only.",
|
||||
),
|
||||
"github-local": (
|
||||
"GitHub Repository (offiziell, read-only)",
|
||||
"Für Repository-Suche, echte Datei-Inhalte und gezielte "
|
||||
"Code-Suche auf GitHub. Bei Fragen zu Implementierung, README, API-Routen oder "
|
||||
"Quellcode dieses Werkzeug statt allgemeiner Websuche verwenden. Keine Issues, "
|
||||
"Pull Requests, Actions, rekursiven Komplettbäume oder Schreibzugriffe. Bei einem "
|
||||
"konkreten Repository zuerst README beziehungsweise Wurzel einmal lesen, danach "
|
||||
"höchstens drei gezielte Code-Suchen und nur relevante Trefferdateien öffnen.",
|
||||
),
|
||||
"athena-operator-local": (
|
||||
"Athena Operator",
|
||||
"Zentrale Bedienebene für Athenas KI-Plattform: MCPs entwickeln und deployen, "
|
||||
"Docker-Dienste verwalten, Modelle laden und testen, Profile/OpenWebUI ändern, "
|
||||
"prüfen, dokumentieren, versionieren und Recovery erzeugen. Enthält außerdem ein "
|
||||
"breites, ausgabebegrenztes Terminal als Ausweg für neue Aufgaben einschließlich "
|
||||
"Docker, Git, HTTP und SSH zu konfigurierten Zielsystemen. Stromversorgung sowie "
|
||||
"Athenas SSH, LAN, WireGuard, Firewall, Boot, Kernel, Mounts und Partitionen bleiben "
|
||||
"blockiert, damit der entfernte Host erreichbar bleibt.",
|
||||
),
|
||||
}
|
||||
changed = len(connections) != before_count
|
||||
for connection in connections:
|
||||
if not isinstance(connection, dict):
|
||||
continue
|
||||
info = connection.get("info")
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
connection["info"] = info
|
||||
identity = str(info.get("id", "")).lower()
|
||||
url = str(connection.get("url", "")).lower()
|
||||
if identity in descriptions:
|
||||
match = identity
|
||||
elif "192.168.1.2:3002" in url:
|
||||
match = "mua"
|
||||
elif "tinysearch:8000" in url:
|
||||
match = "web-general-local"
|
||||
elif "mike-ai-mcp-homeassistant" in url:
|
||||
match = "homeassistant-local"
|
||||
elif "mike-ai-mcp-arr" in url:
|
||||
match = "arr-local"
|
||||
elif "mike-ai-mcp-navidrome" in url:
|
||||
match = "navidrome-local"
|
||||
elif "mike-ai-mcp-github" in url:
|
||||
match = "github-local"
|
||||
elif "mike-ai-mcp-deemix" in url:
|
||||
match = "deemix-local"
|
||||
elif "mike-ai-mcp-athena-operator" in url:
|
||||
match = "athena-operator-local"
|
||||
else:
|
||||
continue
|
||||
name, description = descriptions[match]
|
||||
if info.get("name") != name or info.get("description") != description:
|
||||
info["name"] = name
|
||||
info["description"] = description
|
||||
changed = True
|
||||
if match == "github-local":
|
||||
bounded_config = dict(connection.get("config") or {})
|
||||
bounded_config["enable"] = True
|
||||
bounded_config["function_name_filter_list"] = (
|
||||
"search_repositories,get_file_contents,search_code"
|
||||
)
|
||||
bounded_config.setdefault("access_grants", [])
|
||||
if connection.get("config") != bounded_config:
|
||||
connection["config"] = bounded_config
|
||||
changed = True
|
||||
# Clone the existing authenticated MUA connection into a second
|
||||
# OpenWebUI connection whose exposed function list is strictly
|
||||
# read-only. The bearer value remains in the database and is neither
|
||||
# printed nor copied into Git. Automatic routing uses only this clone;
|
||||
# the original MUA connection remains available for deliberate admin.
|
||||
mua_source = next(
|
||||
(
|
||||
connection for connection in connections
|
||||
if isinstance(connection, dict)
|
||||
and str((connection.get("info") or {}).get("id", "")).lower() == "mua"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if mua_source is None and pathlib.Path(mua_mcp_env_file).is_file():
|
||||
mua_env = {}
|
||||
for raw_line in pathlib.Path(mua_mcp_env_file).read_text().splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
mua_env[key.strip()] = value.strip().strip('"').strip("'")
|
||||
mua_url = mua_env.get("MUA_MCP_URL", "")
|
||||
mua_token = mua_env.get("MUA_MCP_BEARER_TOKEN", "")
|
||||
if mua_url and mua_token:
|
||||
name, description = descriptions["mua"]
|
||||
mua_source = {
|
||||
"url": mua_url,
|
||||
"path": "",
|
||||
"type": "mcp",
|
||||
"auth_type": "bearer",
|
||||
"headers": None,
|
||||
"key": mua_token,
|
||||
"config": {"enable": True, "access_grants": []},
|
||||
"info": {"id": "mua", "name": name, "description": description},
|
||||
}
|
||||
connections.append(mua_source)
|
||||
changed = True
|
||||
if mua_source is not None:
|
||||
readonly_functions = ",".join((
|
||||
"unraid_docker_list", "unraid_docker_inspect", "unraid_docker_logs",
|
||||
"unraid_docker_analyze_logs", "unraid_docker_processes",
|
||||
"unraid_docker_stats", "unraid_docker_info",
|
||||
"unraid_docker_update_status", "unraid_ca_search",
|
||||
"unraid_network_inventory", "unraid_network_list",
|
||||
"unraid_network_inspect", "unraid_network_host_state",
|
||||
"unraid_network_audit_tcp", "unraid_network_lan_probe",
|
||||
"unraid_system_health", "unraid_storage_status",
|
||||
"unraid_disk_health", "unraid_notifications_list",
|
||||
"unraid_shares_list", "unraid_share_inspect",
|
||||
"unraid_files_inventory",
|
||||
"unraid_system_connection_test", "unraid_system_shell_readonly",
|
||||
))
|
||||
readonly = copy.deepcopy(mua_source)
|
||||
readonly["config"] = {
|
||||
"enable": True,
|
||||
"function_name_filter_list": readonly_functions,
|
||||
"access_grants": [],
|
||||
}
|
||||
name, description = descriptions["mua-readonly-local"]
|
||||
readonly["info"] = {
|
||||
"id": "mua-readonly-local",
|
||||
"name": name,
|
||||
"description": description,
|
||||
}
|
||||
existing_index = next(
|
||||
(
|
||||
index for index, connection in enumerate(connections)
|
||||
if isinstance(connection, dict)
|
||||
and str((connection.get("info") or {}).get("id", "")).lower()
|
||||
== "mua-readonly-local"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing_index is None:
|
||||
connections.append(readonly)
|
||||
changed = True
|
||||
elif connections[existing_index] != readonly:
|
||||
connections[existing_index] = readonly
|
||||
changed = True
|
||||
if navidrome_enabled and not any(
|
||||
isinstance(connection, dict)
|
||||
and (
|
||||
str(connection.get("url", "")).lower()
|
||||
== "http://mike-ai-mcp-navidrome:3000/mcp"
|
||||
or str((connection.get("info") or {}).get("id", "")).lower()
|
||||
== "navidrome-local"
|
||||
)
|
||||
for connection in connections
|
||||
):
|
||||
name, description = descriptions["navidrome-local"]
|
||||
connections.append(
|
||||
{
|
||||
"url": "http://mike-ai-mcp-navidrome:3000/mcp",
|
||||
"path": "",
|
||||
"type": "mcp",
|
||||
"auth_type": "none",
|
||||
"headers": None,
|
||||
"key": "",
|
||||
"config": {"enable": True, "access_grants": []},
|
||||
"info": {
|
||||
"id": "navidrome-local",
|
||||
"name": name,
|
||||
"description": description,
|
||||
},
|
||||
}
|
||||
)
|
||||
changed = True
|
||||
if deemix_enabled and not any(
|
||||
isinstance(connection, dict)
|
||||
and (
|
||||
str(connection.get("url", "")).lower()
|
||||
== "http://mike-ai-mcp-deemix:8000/mcp"
|
||||
or str((connection.get("info") or {}).get("id", "")).lower()
|
||||
== "deemix-local"
|
||||
)
|
||||
for connection in connections
|
||||
):
|
||||
name, description = descriptions["deemix-local"]
|
||||
connections.append(
|
||||
{
|
||||
"url": "http://mike-ai-mcp-deemix:8000/mcp",
|
||||
"path": "",
|
||||
"type": "mcp",
|
||||
"auth_type": "none",
|
||||
"headers": None,
|
||||
"key": "",
|
||||
"config": {"enable": True, "access_grants": []},
|
||||
"info": {"id": "deemix-local", "name": name, "description": description},
|
||||
}
|
||||
)
|
||||
changed = True
|
||||
if not any(
|
||||
isinstance(connection, dict)
|
||||
and (
|
||||
str(connection.get("url", "")).lower() == "http://tinysearch:8000/mcp"
|
||||
or str((connection.get("info") or {}).get("id", "")).lower()
|
||||
== "web-general-local"
|
||||
)
|
||||
for connection in connections
|
||||
):
|
||||
name, description = descriptions["web-general-local"]
|
||||
connections.append(
|
||||
{
|
||||
"url": "http://tinysearch:8000/mcp",
|
||||
"path": "",
|
||||
"type": "mcp",
|
||||
"auth_type": "none",
|
||||
"headers": None,
|
||||
"key": "",
|
||||
"config": {"enable": True, "access_grants": []},
|
||||
"info": {
|
||||
"id": "web-general-local",
|
||||
"name": name,
|
||||
"description": description,
|
||||
},
|
||||
}
|
||||
)
|
||||
changed = True
|
||||
if not any(
|
||||
isinstance(connection, dict)
|
||||
and (
|
||||
str(connection.get("url", "")).lower()
|
||||
== "http://mike-ai-mcp-athena-operator:8000/mcp"
|
||||
or str((connection.get("info") or {}).get("id", "")).lower()
|
||||
== "athena-operator-local"
|
||||
)
|
||||
for connection in connections
|
||||
):
|
||||
name, description = descriptions["athena-operator-local"]
|
||||
connections.append(
|
||||
{
|
||||
"url": "http://mike-ai-mcp-athena-operator:8000/mcp",
|
||||
"path": "",
|
||||
"type": "mcp",
|
||||
"auth_type": "none",
|
||||
"headers": None,
|
||||
"key": "",
|
||||
"config": {"enable": True, "access_grants": []},
|
||||
"info": {
|
||||
"id": "athena-operator-local",
|
||||
"name": name,
|
||||
"description": description,
|
||||
},
|
||||
}
|
||||
)
|
||||
changed = True
|
||||
if github_enabled and not any(
|
||||
isinstance(connection, dict)
|
||||
and (
|
||||
str(connection.get("url", "")).lower()
|
||||
== "http://mike-ai-mcp-github:8000/mcp"
|
||||
or str((connection.get("info") or {}).get("id", "")).lower()
|
||||
== "github-local"
|
||||
)
|
||||
for connection in connections
|
||||
):
|
||||
name, description = descriptions["github-local"]
|
||||
connections.append(
|
||||
{
|
||||
"url": "http://mike-ai-mcp-github:8000/mcp",
|
||||
"path": "",
|
||||
"type": "mcp",
|
||||
"auth_type": "none",
|
||||
"headers": None,
|
||||
"key": "",
|
||||
"config": {"enable": True, "access_grants": []},
|
||||
"info": {
|
||||
"id": "github-local",
|
||||
"name": name,
|
||||
"description": description,
|
||||
},
|
||||
}
|
||||
)
|
||||
changed = True
|
||||
if changed:
|
||||
con.execute(
|
||||
"""
|
||||
insert into config (key,value,updated_at) values (?,?,?)
|
||||
on conflict(key) do update set
|
||||
value=excluded.value,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
"tool_server.connections",
|
||||
json.dumps(connections, ensure_ascii=False),
|
||||
now,
|
||||
),
|
||||
)
|
||||
print(
|
||||
"OpenWebUI konfiguriert: Default Off=10, Thinking=20, Auto Tool Selector=25, "
|
||||
"Stability Guard=30, Secret Redaction=40, Spoken Tool Status=80, Local Metrics=90, "
|
||||
"Quick Actions=100, Folgefragen=aus, Piper-TTS=aktiv, Werkzeugwahl=optimiert"
|
||||
)
|
||||
con.close()
|
||||
PY
|
||||
|
||||
python3 "$ROOT/platform/mcp/sync-clients.py" \
|
||||
--registry "$ROOT/config/mcp-registry.json" \
|
||||
--openwebui-db "$db"
|
||||
|
||||
if [[ $was_running == true ]]; then
|
||||
docker start "$CONTAINER" >/dev/null
|
||||
deadline=$((SECONDS + 180))
|
||||
@@ -557,4 +114,4 @@ if [[ $was_running == true ]]; then
|
||||
done
|
||||
fi
|
||||
trap - EXIT
|
||||
printf 'Datenbanksicherung: %s\n' "$backup"
|
||||
printf 'OPENWEBUI_SYNC_OK backup=%s\n' "$backup"
|
||||
|
||||
@@ -29,7 +29,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
VERSION = "3.0.0"
|
||||
VERSION = "3.1.0"
|
||||
STACK = Path(os.environ.get("ATHENA_OPERATOR_STACK", "/opt/mike-ai/stack")).resolve()
|
||||
REPOSITORY = Path(os.environ.get("ATHENA_OPERATOR_REPOSITORY", str(STACK))).resolve()
|
||||
STATE = Path(os.environ.get("ATHENA_OPERATOR_STATE", "/data/mike-ai-operator/state")).resolve()
|
||||
@@ -58,7 +58,7 @@ PROTECTED_CONTAINERS = {
|
||||
}
|
||||
ALLOWED_OPERATIONS = {
|
||||
"file_update", "patch_update", "mcp_release", "run_checks", "compose_deploy", "container_action",
|
||||
"openwebui_sync", "git_publish", "model_download", "benchmark", "recovery",
|
||||
"openwebui_sync", "git_publish", "model_download", "benchmark", "backup",
|
||||
}
|
||||
|
||||
HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
||||
@@ -70,7 +70,7 @@ ALLOWED_CHECKS = {
|
||||
# runtime environment. Validating without it reports required model/token
|
||||
# variables as missing even though the deployed stack is valid.
|
||||
"compose-main": ["docker", "compose", "--env-file", "/etc/mike-ai/stack.env", "-f", "compose.yaml", "config", "-q"],
|
||||
"compose-mcp": ["docker", "compose", "--env-file", "/etc/mike-ai/stack.env", "-f", "platform/mcp/compose.yaml", "config", "-q"],
|
||||
"compose-mcp": ["docker", "compose", "--env-file", "/etc/mike-ai/stack.env", "-f", "compose.yaml", "config", "-q"],
|
||||
}
|
||||
|
||||
# Athena is physically remote. The general terminal is intentionally broad,
|
||||
@@ -253,7 +253,7 @@ def ensure_repository() -> None:
|
||||
if not (REPOSITORY / ".git").is_dir():
|
||||
raise RuntimeError(
|
||||
f"canonical Git checkout is missing at {REPOSITORY}; "
|
||||
"restore /opt/mike-ai/stack with the documented recovery script"
|
||||
"restore /opt/mike-ai/stack from Git and run the documented restore command"
|
||||
)
|
||||
remote = os.environ.get("ATHENA_OPERATOR_GIT_REMOTE", "").strip()
|
||||
if remote:
|
||||
@@ -264,6 +264,10 @@ def ensure_repository() -> None:
|
||||
|
||||
def inspect(subject: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
ensure_repository()
|
||||
if subject == "guide":
|
||||
guide = REPOSITORY / "ATHENA.md"
|
||||
text = guide.read_text(encoding="utf-8", errors="replace")
|
||||
return {"guide": text, "source": "ATHENA.md", "sha256": sha(guide.read_bytes())}
|
||||
if subject == "overview":
|
||||
return {
|
||||
"version": VERSION,
|
||||
@@ -448,9 +452,7 @@ def normalise_operation(operation: str, payload: dict[str, Any]) -> tuple[dict[s
|
||||
checks = payload.get("checks") or ["operator-tests", "compose-mcp"]
|
||||
if not isinstance(checks, list) or not checks or any(name not in ALLOWED_CHECKS for name in checks):
|
||||
raise ValueError("unknown release check suite")
|
||||
compose_file = str(payload.get("compose_file", "platform/mcp/compose.yaml"))
|
||||
if compose_file != "platform/mcp/compose.yaml":
|
||||
raise ValueError("MCP releases must use platform/mcp/compose.yaml")
|
||||
compose_file = "compose.yaml"
|
||||
services = payload.get("services") or []
|
||||
if not isinstance(services, list) or not 1 <= len(services) <= 12 or any(not SAFE_NAME.fullmatch(str(x)) for x in services):
|
||||
raise ValueError("invalid MCP service list")
|
||||
@@ -461,23 +463,18 @@ def normalise_operation(operation: str, payload: dict[str, Any]) -> tuple[dict[s
|
||||
selected = [str(safe_relative(str(path))) for path in paths]
|
||||
if len(set(selected)) != len(selected) or not set(item["path"] for item in files).issubset(set(selected)):
|
||||
raise ValueError("release paths must be unique and include every patched file")
|
||||
label = str(payload.get("recovery_label", time.strftime("%Y%m%d-%H%M")))
|
||||
if not SAFE_NAME.fullmatch(label):
|
||||
raise ValueError("invalid recovery label")
|
||||
normal = {
|
||||
"files": files, "checks": checks, "compose_file": compose_file,
|
||||
"services": [str(x) for x in services], "build": bool(payload.get("build", True)),
|
||||
"openwebui_sync": bool(payload.get("openwebui_sync", True)),
|
||||
"hermes_sync": bool(payload.get("hermes_sync", False)),
|
||||
"message": message, "paths": selected,
|
||||
"create_recovery": bool(payload.get("create_recovery", True)), "recovery_label": label,
|
||||
}
|
||||
preview = (
|
||||
f"ONE MCP RELEASE\nServices: {', '.join(normal['services'])}\n"
|
||||
f"Checks: {', '.join(checks)}\nOpenWebUI sync: {normal['openwebui_sync']}\n"
|
||||
f"Hermes sync: {normal['hermes_sync']}\n"
|
||||
f"Selective commit: {message}\nPaths: {', '.join(selected)}\n"
|
||||
f"Recovery: {normal['create_recovery']} ({label})\n\n"
|
||||
f"Selective commit: {message}\nPaths: {', '.join(selected)}\n\n"
|
||||
+ "\n".join(part for part in (import_preview, patch_preview) if part)
|
||||
)
|
||||
return normal, preview
|
||||
@@ -488,7 +485,7 @@ def normalise_operation(operation: str, payload: dict[str, Any]) -> tuple[dict[s
|
||||
return {"checks": checks}, "Will run: " + ", ".join(checks)
|
||||
if operation == "compose_deploy":
|
||||
compose_file = str(payload.get("compose_file", ""))
|
||||
if compose_file not in {"compose.yaml", "platform/mcp/compose.yaml"}:
|
||||
if compose_file != "compose.yaml":
|
||||
raise ValueError("unsupported compose file")
|
||||
services = payload.get("services") or []
|
||||
if not isinstance(services, list) or not 1 <= len(services) <= 12 or any(not SAFE_NAME.fullmatch(str(x)) for x in services):
|
||||
@@ -544,11 +541,8 @@ def normalise_operation(operation: str, payload: dict[str, Any]) -> tuple[dict[s
|
||||
if not isinstance(args, list) or len(args) > 20 or any(not isinstance(x, str) or len(x) > 200 or re.search(r"[\x00\n\r]", x) for x in args):
|
||||
raise ValueError("invalid benchmark arguments")
|
||||
return {"script": str(script), "arguments": args}, f"Run versioned benchmark {script} with {args!r}"
|
||||
if operation == "recovery":
|
||||
label = str(payload.get("label", time.strftime("%Y%m%d")))
|
||||
if not SAFE_NAME.fullmatch(label):
|
||||
raise ValueError("invalid recovery label")
|
||||
return {"label": label}, f"Create encrypted recovery bundle and self-contained data kit: {label}"
|
||||
if operation == "backup":
|
||||
return {}, "Create one Docker-data backup now in /data/docker-backups."
|
||||
raise AssertionError(operation)
|
||||
|
||||
|
||||
@@ -631,30 +625,6 @@ def publish_paths(message: str, selected: list[str]) -> dict[str, Any]:
|
||||
return {"commit": head, "commit_output": commit, "push_output": pushed}
|
||||
|
||||
|
||||
def perform_recovery(label: str) -> dict[str, Any]:
|
||||
dirty = run(["git", "status", "--porcelain"], cwd=REPOSITORY, check=True)["output"].strip()
|
||||
if dirty:
|
||||
raise RuntimeError("publish repository changes before creating a recovery kit")
|
||||
head = run(["git", "rev-parse", "HEAD"], cwd=REPOSITORY, check=True)["output"].strip()
|
||||
marker = (STACK / ".mike-ai-source-commit").read_text().strip()
|
||||
if marker != head:
|
||||
raise RuntimeError("deployed source marker and operator repository HEAD differ")
|
||||
encrypted = Path(f"/data/athena-recovery-{label}.tar.age")
|
||||
source_bundle = Path(f"/data/athena-source-{label}.git.bundle")
|
||||
release = Path(f"/data/mike-ai-recovery-kit-{label}")
|
||||
for target in (encrypted, source_bundle, release):
|
||||
if target.exists():
|
||||
raise FileExistsError(target)
|
||||
git_bundle = run(["git", "bundle", "create", str(source_bundle), "--all"], cwd=REPOSITORY, timeout=1800, check=True)
|
||||
encrypted_result = run([str(STACK / "platform/recovery/create-recovery-bundle.sh"), str(encrypted)], timeout=7200, check=True)
|
||||
identity = Path("/data/mike-ai-recovery-kit/recovery.agekey")
|
||||
if not identity.is_file():
|
||||
raise RuntimeError("existing recovery identity is unavailable")
|
||||
kit_result = run([str(STACK / "platform/recovery/create-self-contained-data-kit.sh"), str(encrypted), str(identity), str(source_bundle), str(release)], timeout=7200, check=True)
|
||||
verify = run(["sha256sum", "-c", "SHA256SUMS"], cwd=release, timeout=1800, check=True)
|
||||
return {"commit": head, "recovery_bundle": str(encrypted), "source_bundle": str(source_bundle), "self_contained_kit": str(release), "git_bundle": git_bundle, "encrypted_bundle": encrypted_result, "kit": kit_result, "verification": verify}
|
||||
|
||||
|
||||
def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if operation in {"file_update", "patch_update"}:
|
||||
backup = STATE / "backups" / f"{now()}-{ticket}"
|
||||
@@ -689,8 +659,7 @@ def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> d
|
||||
hermes_synced = True
|
||||
publication = publish_paths(payload["message"], payload["paths"])
|
||||
published = True
|
||||
recovery = perform_recovery(payload["recovery_label"]) if payload["create_recovery"] else None
|
||||
return {"changed": changed, "checks": checks, "deploy": deploy, "openwebui_sync": sync, "hermes_sync": hermes_sync, "publication": publication, "recovery": recovery, "containers": run(["docker", "ps", "--format", "{{.Names}}\t{{.Status}}"])}
|
||||
return {"changed": changed, "checks": checks, "deploy": deploy, "openwebui_sync": sync, "hermes_sync": hermes_sync, "publication": publication, "containers": run(["docker", "ps", "--format", "{{.Names}}\t{{.Status}}"])}
|
||||
except Exception:
|
||||
if not published:
|
||||
restore_files(payload["files"], backup)
|
||||
@@ -750,10 +719,14 @@ def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> d
|
||||
interpreter = "python3" if script.suffix == ".py" else "bash"
|
||||
return run([interpreter, str(script), *payload["arguments"]], cwd=REPOSITORY, timeout=86400)
|
||||
return start_job(ticket, operation, benchmark)
|
||||
if operation == "recovery":
|
||||
def recovery():
|
||||
return perform_recovery(payload["label"])
|
||||
return start_job(ticket, operation, recovery)
|
||||
if operation == "backup":
|
||||
def backup():
|
||||
result = run(["docker", "exec", "mike-ai-backup", "backup"], timeout=7200, check=True)
|
||||
latest = Path("/data/docker-backups/athena-latest.tar.gz")
|
||||
if not latest.is_file():
|
||||
raise RuntimeError("backup completed without latest archive")
|
||||
return {"backup": result, "archive": str(latest), "bytes": latest.stat().st_size}
|
||||
return start_job(ticket, operation, backup)
|
||||
raise AssertionError(operation)
|
||||
|
||||
|
||||
@@ -783,7 +756,7 @@ def execute(arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
json_write(completed, record)
|
||||
path.unlink()
|
||||
audit("executed", ticket=ticket, operation=record["operation"], binding=record["binding"])
|
||||
return {"ticket": ticket, "operation": record["operation"], "result": result, "instruction": "Verify health and Git/recovery state before declaring the work complete."}
|
||||
return {"ticket": ticket, "operation": record["operation"], "result": result, "instruction": "Verify service health and Git state before declaring the work complete."}
|
||||
except Exception:
|
||||
record["status"] = "failed"
|
||||
json_write(path, record)
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
OUTPUT=${1:-}
|
||||
RECIPIENT_FILE=${AGE_RECIPIENT_FILE:-/etc/mike-ai/recovery.age-recipient}
|
||||
OPENWEBUI_VOLUME=${OPENWEBUI_VOLUME:-mike-ai_open-webui-data}
|
||||
OPENWEBUI_CONTAINER=${OPENWEBUI_CONTAINER:-mike-ai-open-webui}
|
||||
STACK_DIR=${STACK_DIR:-/opt/mike-ai/stack}
|
||||
|
||||
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
|
||||
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
|
||||
[[ -n $OUTPUT ]] || die "Aufruf: $0 /sicheres/offhost-ziel/athena-recovery-YYYYMMDD.tar.age"
|
||||
[[ -s $RECIPIENT_FILE ]] || die "Age-Empfängerdatei fehlt: $RECIPIENT_FILE"
|
||||
command -v age >/dev/null || die "age ist nicht installiert."
|
||||
command -v docker >/dev/null || die "Docker ist nicht installiert."
|
||||
|
||||
recipient=$(awk '/^age1[[:alnum:]]+$/ {print; exit}' "$RECIPIENT_FILE")
|
||||
[[ -n $recipient ]] || die "Keine gültige öffentliche age-Adresse gefunden."
|
||||
install -d -m 0700 "$(dirname "$OUTPUT")"
|
||||
[[ ! -e $OUTPUT ]] || die "Zieldatei existiert bereits: $OUTPUT"
|
||||
|
||||
stage=$(mktemp -d /tmp/mike-ai-recovery.XXXXXX)
|
||||
sqlite_snapshot=""
|
||||
cleanup() {
|
||||
[[ -z $sqlite_snapshot ]] || rm -f -- "$sqlite_snapshot"
|
||||
rm -rf "$stage"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
mkdir -p "$stage/rootfs" "$stage/payload"
|
||||
|
||||
for source in \
|
||||
/etc/mike-ai \
|
||||
/root/mike-ai-install.env \
|
||||
/opt/mike-ai/stack/docs \
|
||||
/data/mike-ai-platform-context \
|
||||
/data/hermes \
|
||||
/data/hermes-webui/state; do
|
||||
[[ -e $source ]] || continue
|
||||
rsync -aR "$source" "$stage/rootfs/"
|
||||
done
|
||||
|
||||
tar -C "$stage/rootfs" -czf "$stage/payload/host-config.tar.gz" .
|
||||
volume_path=$(docker volume inspect -f '{{.Mountpoint}}' "$OPENWEBUI_VOLUME")
|
||||
[[ -s $volume_path/webui.db ]] || die "OpenWebUI-Datenbank fehlt oder ist leer."
|
||||
|
||||
# OpenWebUI uses SQLite. The online backup API creates a transactionally
|
||||
# consistent snapshot while the service remains available. The archive omits
|
||||
# the live DB/WAL/SHM and stores that snapshot under the canonical DB name.
|
||||
[[ $(docker inspect -f '{{.State.Running}}' "$OPENWEBUI_CONTAINER" 2>/dev/null || true) == true ]] || \
|
||||
die "OpenWebUI läuft nicht; Online-Datenbanksicherung nicht möglich."
|
||||
sqlite_snapshot="$volume_path/.mike-ai-recovery-webui.db"
|
||||
rm -f -- "$sqlite_snapshot"
|
||||
docker exec -i "$OPENWEBUI_CONTAINER" python - <<'PY'
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
source = "/app/backend/data/webui.db"
|
||||
snapshot = "/app/backend/data/.mike-ai-recovery-webui.db"
|
||||
if os.path.exists(snapshot):
|
||||
os.unlink(snapshot)
|
||||
with sqlite3.connect(source) as src, sqlite3.connect(snapshot) as dst:
|
||||
src.backup(dst)
|
||||
with sqlite3.connect(snapshot) as check:
|
||||
result = check.execute("PRAGMA integrity_check").fetchone()
|
||||
if not result or result[0] != "ok":
|
||||
raise SystemExit("SQLite integrity_check failed")
|
||||
PY
|
||||
[[ -s $sqlite_snapshot ]] || die "Konsistenter OpenWebUI-Snapshot wurde nicht erzeugt."
|
||||
tar -C "$volume_path" \
|
||||
--exclude='./webui.db' \
|
||||
--exclude='./webui.db-wal' \
|
||||
--exclude='./webui.db-shm' \
|
||||
--transform='s#\.mike-ai-recovery-webui\.db#webui.db#' \
|
||||
-czf "$stage/payload/openwebui-data.tar.gz" .
|
||||
tar -tzf "$stage/payload/openwebui-data.tar.gz" ./webui.db >/dev/null 2>&1 || \
|
||||
die "OpenWebUI-Archiv enthält den konsistenten Datenbanksnapshot nicht."
|
||||
|
||||
source_commit=unknown
|
||||
[[ ! -s $STACK_DIR/.mike-ai-source-commit ]] || source_commit=$(<"$STACK_DIR/.mike-ai-source-commit")
|
||||
cat >"$stage/payload/METADATA" <<EOF
|
||||
created_utc=$(date -u +%FT%TZ)
|
||||
hostname=$(hostname)
|
||||
source_commit=$source_commit
|
||||
openwebui_volume=$OPENWEBUI_VOLUME
|
||||
EOF
|
||||
(
|
||||
cd "$stage/payload"
|
||||
sha256sum host-config.tar.gz openwebui-data.tar.gz METADATA >SHA256SUMS
|
||||
tar -czf "$stage/bundle.tar.gz" \
|
||||
host-config.tar.gz openwebui-data.tar.gz METADATA SHA256SUMS
|
||||
)
|
||||
|
||||
age -r "$recipient" -o "$OUTPUT.partial" "$stage/bundle.tar.gz"
|
||||
mv "$OUTPUT.partial" "$OUTPUT"
|
||||
chmod 0600 "$OUTPUT"
|
||||
printf 'RECOVERY_BUNDLE_OK %s\n' "$OUTPUT"
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
RECOVERY_BUNDLE=${1:-}
|
||||
AGE_IDENTITY=${2:-}
|
||||
SOURCE_BUNDLE=${3:-}
|
||||
RELEASE_DIR=${4:-}
|
||||
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
|
||||
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
|
||||
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
|
||||
[[ -s $RECOVERY_BUNDLE ]] || die "Recovery-Bundle fehlt."
|
||||
[[ -s $AGE_IDENTITY ]] || die "age-Identität fehlt."
|
||||
[[ -s $SOURCE_BUNDLE ]] || die "Git-Quellbundle fehlt."
|
||||
[[ -n $RELEASE_DIR && $RELEASE_DIR == /data/* ]] || \
|
||||
die "Release-Ziel muss ein eindeutiger Pfad unter /data sein."
|
||||
[[ ! -e $RELEASE_DIR ]] || die "Release-Ziel existiert bereits."
|
||||
command -v age >/dev/null || die "age fehlt."
|
||||
command -v git >/dev/null || die "git fehlt."
|
||||
|
||||
stage=$(mktemp -d /tmp/mike-ai-data-kit.XXXXXX)
|
||||
trap 'rm -rf "$stage"' EXIT
|
||||
age -d -i "$AGE_IDENTITY" -o "$stage/bundle.tar.gz" "$RECOVERY_BUNDLE"
|
||||
tar -C "$stage" -xzf "$stage/bundle.tar.gz" METADATA SHA256SUMS \
|
||||
host-config.tar.gz openwebui-data.tar.gz
|
||||
(cd "$stage" && sha256sum -c SHA256SUMS)
|
||||
commit=$(sed -n 's/^source_commit=//p' "$stage/METADATA" | head -n 1)
|
||||
[[ $commit =~ ^[0-9a-f]{40}$ ]] || die "Recovery-Commit in METADATA ist ungültig."
|
||||
git clone -q "$SOURCE_BUNDLE" "$stage/source-check"
|
||||
git -C "$stage/source-check" cat-file -e "$commit^{commit}"
|
||||
|
||||
install -d -m 0700 "$RELEASE_DIR"
|
||||
install -m 0600 "$RECOVERY_BUNDLE" "$RELEASE_DIR/recovery.tar.age"
|
||||
install -m 0600 "$AGE_IDENTITY" "$RELEASE_DIR/recovery.agekey"
|
||||
install -m 0600 "$SOURCE_BUNDLE" "$RELEASE_DIR/source.git.bundle"
|
||||
install -m 0700 "$ROOT_DIR/platform/recovery/reinstall-from-data.sh" \
|
||||
"$RELEASE_DIR/reinstall-athena.sh"
|
||||
cat >"$RELEASE_DIR/kit.env" <<EOF
|
||||
RECOVERY_COMMIT=$commit
|
||||
RECOVERY_BUNDLE=recovery.tar.age
|
||||
SOURCE_BUNDLE=source.git.bundle
|
||||
AGE_IDENTITY=recovery.agekey
|
||||
EOF
|
||||
cat >"$RELEASE_DIR/README.txt" <<'EOF'
|
||||
ATHENA SELF-CONTAINED DATA-DISK RECOVERY
|
||||
|
||||
Auf einem frischen Debian die bestehende Data-SSD unter /data einhängen und
|
||||
als root ausführen:
|
||||
|
||||
/data/mike-ai-recovery-kit/reinstall-athena.sh
|
||||
|
||||
Das Skript prüft alle Dateien, stellt einen persistenten /data-Mount her,
|
||||
installiert minimale Werkzeuge und rekonstruiert den vollständigen MikeAI-
|
||||
Stack. Erforderliche Treiber-Neustarts werden höchstens dreimal automatisch
|
||||
fortgesetzt.
|
||||
|
||||
SICHERHEIT: Dieses Kit enthält den Entschlüsselungsschlüssel. Wer die Data-SSD
|
||||
lesen kann, kann daher auch die enthaltenen Infrastruktur-Secrets entschlüsseln.
|
||||
EOF
|
||||
chmod 0600 "$RELEASE_DIR/kit.env" "$RELEASE_DIR/README.txt"
|
||||
(
|
||||
cd "$RELEASE_DIR"
|
||||
sha256sum recovery.tar.age recovery.agekey source.git.bundle \
|
||||
reinstall-athena.sh kit.env README.txt >SHA256SUMS
|
||||
)
|
||||
chmod 0600 "$RELEASE_DIR/SHA256SUMS"
|
||||
|
||||
link=/data/mike-ai-recovery-kit
|
||||
[[ ! -e $link || -L $link ]] || die "$link existiert und ist kein verwalteter Symlink."
|
||||
ln -sfn "$(basename "$RELEASE_DIR")" "$link"
|
||||
printf 'SELF_CONTAINED_DATA_KIT_OK %s -> %s\n' "$link" "$RELEASE_DIR"
|
||||
@@ -1,100 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Self-contained Athena reinstall entry point. This file is copied into a
|
||||
# root-only recovery kit on /data; it is not run during normal installation.
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
KIT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
CONFIG="$KIT_DIR/kit.env"
|
||||
WORK_DIR=/var/lib/mike-ai-data-reinstall
|
||||
SERVICE=/etc/systemd/system/mike-ai-data-reinstall.service
|
||||
|
||||
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
|
||||
log() { printf '\n==> %s\n' "$*"; }
|
||||
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
|
||||
[[ -s $CONFIG ]] || die "kit.env fehlt im Recovery-Kit."
|
||||
# shellcheck disable=SC1090
|
||||
source "$CONFIG"
|
||||
|
||||
required=(RECOVERY_COMMIT RECOVERY_BUNDLE SOURCE_BUNDLE AGE_IDENTITY)
|
||||
for name in "${required[@]}"; do
|
||||
[[ -n ${!name:-} ]] || die "Pflichtwert $name fehlt."
|
||||
done
|
||||
for file in "$RECOVERY_BUNDLE" "$SOURCE_BUNDLE" "$AGE_IDENTITY"; do
|
||||
[[ -s $KIT_DIR/$file ]] || die "Recovery-Datei fehlt: $file"
|
||||
done
|
||||
|
||||
log "Recovery-Kit kryptografisch prüfen"
|
||||
(cd "$KIT_DIR" && sha256sum -c SHA256SUMS)
|
||||
|
||||
log "Persistenten Mount für die Data-SSD sicherstellen"
|
||||
[[ $(findmnt -n -o TARGET --target "$KIT_DIR") == /data ]] || \
|
||||
die "Recovery-Kit liegt nicht auf dem eingehängten /data-Dateisystem."
|
||||
if ! findmnt -s -n -o TARGET | grep -qx /data; then
|
||||
source_device=$(findmnt -n -o SOURCE --target "$KIT_DIR")
|
||||
source_uuid=$(blkid -s UUID -o value "$source_device")
|
||||
source_type=$(findmnt -n -o FSTYPE --target "$KIT_DIR")
|
||||
[[ -n $source_uuid && -n $source_type ]] || \
|
||||
die "UUID oder Dateisystemtyp der Data-SSD konnte nicht bestimmt werden."
|
||||
printf 'UUID=%s /data %s defaults,nofail,x-systemd.device-timeout=30 0 2\n' \
|
||||
"$source_uuid" "$source_type" >>/etc/fstab
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
|
||||
log "Minimale Wiederherstellungswerkzeuge installieren"
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
age ca-certificates git rsync
|
||||
|
||||
install -d -m 0700 "$WORK_DIR"
|
||||
if [[ ! -d $WORK_DIR/repository/.git ]]; then
|
||||
git clone "$KIT_DIR/$SOURCE_BUNDLE" "$WORK_DIR/repository"
|
||||
fi
|
||||
git -C "$WORK_DIR/repository" checkout --detach "$RECOVERY_COMMIT"
|
||||
[[ $(git -C "$WORK_DIR/repository" rev-parse HEAD) == "$RECOVERY_COMMIT" ]] || \
|
||||
die "Der geforderte Recovery-Commit ist nicht ausgecheckt."
|
||||
|
||||
attempt=0
|
||||
[[ ! -s $WORK_DIR/attempt ]] || attempt=$(<"$WORK_DIR/attempt")
|
||||
(( attempt += 1 ))
|
||||
printf '%s\n' "$attempt" >"$WORK_DIR/attempt"
|
||||
(( attempt <= 3 )) || die "Mehr als drei automatische Recovery-Versuche; Abbruch gegen Bootschleife."
|
||||
|
||||
log "Bare-Metal-Wiederherstellung ausführen (Versuch $attempt/3)"
|
||||
set +e
|
||||
"$WORK_DIR/repository/platform/recovery/restore-recovery-bundle.sh" \
|
||||
"$KIT_DIR/$RECOVERY_BUNDLE" "$KIT_DIR/$AGE_IDENTITY"
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
if [[ $status == 20 || $status == 21 ]]; then
|
||||
log "Ein kontrollierter Treiber-/Netzwerk-Neustart ist erforderlich"
|
||||
cat >"$SERVICE" <<EOF
|
||||
[Unit]
|
||||
Description=Resume MikeAI data-disk disaster recovery
|
||||
After=network-online.target local-fs.target
|
||||
Wants=network-online.target
|
||||
RequiresMountsFor=/data
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=$KIT_DIR/reinstall-athena.sh --resume
|
||||
StandardOutput=append:/var/log/mike-ai-data-reinstall.log
|
||||
StandardError=append:/var/log/mike-ai-data-reinstall.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable mike-ai-data-reinstall.service
|
||||
sync
|
||||
systemctl reboot
|
||||
exit 0
|
||||
fi
|
||||
[[ $status == 0 ]] || die "Wiederherstellung ist mit Status $status fehlgeschlagen."
|
||||
|
||||
systemctl disable mike-ai-data-reinstall.service >/dev/null 2>&1 || true
|
||||
rm -f "$SERVICE" "$WORK_DIR/attempt"
|
||||
systemctl daemon-reload
|
||||
printf '\nDATA_DISK_REINSTALL_OK\n'
|
||||
printf 'OpenWebUI, Router, Modelle und MCPs wurden wiederhergestellt.\n'
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
BUNDLE=${1:-}
|
||||
IDENTITY=${2:-}
|
||||
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
OPENWEBUI_VOLUME=${OPENWEBUI_VOLUME:-mike-ai_open-webui-data}
|
||||
|
||||
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
|
||||
log() { printf '\n==> %s\n' "$*"; }
|
||||
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
|
||||
[[ -s $BUNDLE ]] || die "Recovery-Bundle fehlt."
|
||||
[[ -s $IDENTITY ]] || die "Age-Identität fehlt."
|
||||
|
||||
if ! command -v age >/dev/null || ! command -v rsync >/dev/null; then
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends age rsync
|
||||
fi
|
||||
|
||||
stage=$(mktemp -d /tmp/mike-ai-restore.XXXXXX)
|
||||
trap 'rm -rf "$stage"' EXIT
|
||||
age -d -i "$IDENTITY" -o "$stage/bundle.tar.gz" "$BUNDLE"
|
||||
tar -C "$stage" -xzf "$stage/bundle.tar.gz"
|
||||
(
|
||||
cd "$stage"
|
||||
sha256sum -c SHA256SUMS
|
||||
)
|
||||
tar -tzf "$stage/openwebui-data.tar.gz" ./webui.db >/dev/null 2>&1 || \
|
||||
die "OpenWebUI-Archiv enthält keine Datenbank."
|
||||
|
||||
recorded_commit=$(sed -n 's/^source_commit=//p' "$stage/METADATA" | head -n 1)
|
||||
current_commit=$(git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || true)
|
||||
if [[ -n $recorded_commit && $recorded_commit != unknown && \
|
||||
$current_commit != "$recorded_commit" ]]; then
|
||||
die "Repository-Commit stimmt nicht mit dem Backup überein: erwartet $recorded_commit"
|
||||
fi
|
||||
|
||||
log "Root-only Konfiguration und freigegebene Secrets wiederherstellen"
|
||||
mkdir -p "$stage/rootfs"
|
||||
tar -C "$stage/rootfs" -xzf "$stage/host-config.tar.gz"
|
||||
[[ -s $stage/rootfs/root/mike-ai-install.env ]] || \
|
||||
die "Installationskonfiguration fehlt im Bundle."
|
||||
install -d -m 0700 /etc/mike-ai
|
||||
rsync -a "$stage/rootfs/etc/mike-ai/" /etc/mike-ai/
|
||||
install -m 0600 "$stage/rootfs/root/mike-ai-install.env" /root/mike-ai-install.env
|
||||
|
||||
log "Reproduzierbaren Host-Installer ausführen"
|
||||
set +e
|
||||
"$ROOT_DIR/install.sh" --config /root/mike-ai-install.env
|
||||
status=$?
|
||||
set -e
|
||||
if [[ $status == 20 || $status == 21 ]]; then
|
||||
printf 'REBOOT_REQUIRED code=%s\n' "$status"
|
||||
printf 'Nach dem Neustart denselben Restore-Befehl erneut ausführen.\n'
|
||||
exit "$status"
|
||||
fi
|
||||
[[ $status == 0 ]] || die "Host-Installer ist mit Status $status fehlgeschlagen."
|
||||
|
||||
log "Gesicherten Platform-Kontext und lokale Dokumentationspflege wiederherstellen"
|
||||
if [[ -d $stage/rootfs/opt/mike-ai/stack/docs ]]; then
|
||||
rsync -a "$stage/rootfs/opt/mike-ai/stack/docs/" /opt/mike-ai/stack/docs/
|
||||
fi
|
||||
if [[ -d $stage/rootfs/data/mike-ai-platform-context ]]; then
|
||||
install -d -o 10001 -g 10001 -m 0750 /data/mike-ai-platform-context
|
||||
rsync -a "$stage/rootfs/data/mike-ai-platform-context/" /data/mike-ai-platform-context/
|
||||
chown -R 10001:10001 /data/mike-ai-platform-context
|
||||
fi
|
||||
|
||||
log "OpenWebUI-Zustand atomar wiederherstellen"
|
||||
volume_path=$(docker volume inspect -f '{{.Mountpoint}}' "$OPENWEBUI_VOLUME")
|
||||
[[ -d $volume_path && $volume_path == /* && $volume_path != / && \
|
||||
$volume_path != /data && $volume_path != /var && \
|
||||
$volume_path != /var/lib && $volume_path != /var/lib/docker ]] || \
|
||||
die "Unsicherer Docker-Volume-Pfad: $volume_path"
|
||||
fallback=/data/openwebui-before-disaster-restore-$(date +%Y%m%d-%H%M%S).tar.gz
|
||||
docker stop mike-ai-open-webui >/dev/null 2>&1 || true
|
||||
tar -C "$volume_path" -czf "$fallback" .
|
||||
find "$volume_path" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
|
||||
tar -C "$volume_path" -xzf "$stage/openwebui-data.tar.gz"
|
||||
docker start mike-ai-open-webui >/dev/null
|
||||
|
||||
deadline=$((SECONDS + 240))
|
||||
until [[ $(docker inspect -f '{{.State.Health.Status}}' mike-ai-open-webui 2>/dev/null || true) == healthy ]]; do
|
||||
(( SECONDS < deadline )) || die "OpenWebUI wurde nicht rechtzeitig gesund."
|
||||
sleep 3
|
||||
done
|
||||
|
||||
log "Versionierte Modelle, Filter und Tool-Verbindungen nachziehen"
|
||||
"$ROOT_DIR/platform/openwebui/install-models.sh"
|
||||
"$ROOT_DIR/platform/openwebui/install-filters.sh"
|
||||
"$ROOT_DIR/platform/mcp/install-tools.sh"
|
||||
if [[ -s /etc/mike-ai/navidrome-mcp.env ]]; then
|
||||
"$ROOT_DIR/platform/mcp/verify-navidrome.sh"
|
||||
fi
|
||||
"$ROOT_DIR/dev/verify_mcp_catalogs.sh"
|
||||
|
||||
printf 'BARE_METAL_RECOVERY_OK\n'
|
||||
printf 'Rückfallsicherung des leeren OpenWebUI-Stands: %s\n' "$fallback"
|
||||
@@ -1,16 +0,0 @@
|
||||
[Unit]
|
||||
Description=Create bounded MikeAI platform context snapshot
|
||||
After=docker.service local-fs.target
|
||||
RequiresMountsFor=/data
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/libexec/mike-ai-platform-context-snapshot
|
||||
User=root
|
||||
Group=root
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/mike-ai-platform-context
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
[Unit]
|
||||
Description=Refresh bounded MikeAI platform context snapshot
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30s
|
||||
OnUnitActiveSec=60s
|
||||
AccuracySec=10s
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Reference in New Issue
Block a user