Add self-maintaining Athena platform context MCP

This commit is contained in:
Mikei386
2026-08-23 17:31:05 +02:00
parent 64d36ad838
commit 3dbb66037d
22 changed files with 1071 additions and 9 deletions
+3 -2
View File
@@ -18,8 +18,8 @@ WireGuard-Isolation.
80:20); etwa 68 Token/s und erfolgreicher 220K-Prompt-Fülltest
- Open WebUI als einzige normale Oberfläche
- SearXNG/Web-MCP ohne externen API-Schlüssel
- zentrale MCP-Werkzeugebene: getrennte Container für Web, GitHub, HA, ARR,
Unraid, Navidrome und Sandbox, gemeinsam nutzbar durch Open WebUI und andere
- zentrale MCP-Werkzeugebene: getrennte Container für Athena-Plattformwissen,
Web, GitHub, HA, ARR, Unraid, Navidrome und Sandbox, gemeinsam nutzbar durch Open WebUI und andere
Clients
- KI-Dienste ausschließlich über den containerisierten WireGuard-Gateway erreichbar
- KI-Ausgangsverkehr über das Heimnetz, bei Tunnelausfall fail-closed
@@ -80,6 +80,7 @@ keine Modell-Tokens und verraten dem Modell keine zusätzlichen Daten.
- [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md) – kurze Gesamtsicht
- [`docs/QWEN_OPERATOR_CONTEXT.md`](docs/QWEN_OPERATOR_CONTEXT.md) – ausführliches Kontextpaket für das lokale Operator-Modell
- [`docs/PLATFORM_CONTEXT_MCP.md`](docs/PLATFORM_CONTEXT_MCP.md) – profilunabhängiges Plattformwissen und kontrollierte Dokumentationspflege
- [`config/operator-system-prompt.txt`](config/operator-system-prompt.txt) – knapper System-Prompt für ein getrenntes Operator-Profil
- [Roadmap für den neuen Host](docs/NEW_HOST_ROADMAP.md)
+1 -1
View File
@@ -747,7 +747,7 @@ services:
# Seed native MCP connections on a fresh Open WebUI database. Secrets
# stay inside the tool containers, so these internal URLs need no keys.
TOOL_SERVER_CONNECTIONS: >-
[{"url":"http://mike-ai-mcp-web:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"web-local","name":"Web (öffentlich, read-only)","description":"Für aktuelle öffentliche Internetdaten, Quellenprüfung, GitHub/Hugging Face und Produktsuche. Nicht für Home Assistant, Medienverwaltung oder NAS-Diagnose."}},{"url":"http://mike-ai-mcp-homeassistant:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"homeassistant-local","name":"Home Assistant (lokal)","description":"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."}},{"url":"http://mike-ai-mcp-arr:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"arr-local","name":"Sonarr und Radarr (lokal)","description":"Nur für verwaltete Serien/Filme, fehlende Episoden, Queue und Suche über konfigurierte Indexer. Keine allgemeine Websuche; Schreibaktionen benötigen Vorschau und Freigabe."}},{"url":"http://mike-ai-mcp-navidrome:3000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"navidrome-local","name":"Navidrome (Musikbibliothek)","description":"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."}},{"url":"http://mike-ai-mcp-unraid-official:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"unraid-readonly-local","name":"Unraid (Systemdiagnose)","description":"Nur für Unraid-Host, Array, Datenträger, Docker-Container, Shares, Netzwerk, UPS und Systemlogs. Nicht für Home Assistant oder Medieninhalte; Standardzugriff read-only."}}]
[{"url":"http://mike-ai-mcp-platform-context:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"athena-platform","name":"Athena Plattformwissen","description":"Zuerst aktivieren und verwenden, wenn an Athena/MikeAI, Modellen, Profilen, Router, OpenWebUI, MCPs, TTS/STT, Vision, Netzwerk oder Recovery gearbeitet wird. Liefert versionierte Dokumentation und einen begrenzten aktuellen Systemstand. Dokumentationspflege nur über Vorschau und ausdrückliche Freigabe; keine Container-, Shell-, Netzwerk-, Git- oder Secretrechte."}},{"url":"http://mike-ai-mcp-web:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"web-local","name":"Web (öffentlich, read-only)","description":"Für aktuelle öffentliche Internetdaten, Quellenprüfung, Hugging Face und Produktsuche. Für GitHub-Quellcode den offiziellen GitHub-MCP verwenden; nicht für Home Assistant, Medienverwaltung oder NAS-Diagnose."}},{"url":"http://mike-ai-mcp-github:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"github-local","name":"GitHub (offiziell, read-only)","description":"Für GitHub-Repositories, Quellcode, README-Dateien, Verzeichnisbäume und Code-Suche. Strikt read-only mit genau vier Werkzeugen; nicht für allgemeine Webrecherche oder Änderungen an Repositories."}},{"url":"http://mike-ai-mcp-homeassistant:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"homeassistant-local","name":"Home Assistant (lokal)","description":"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."}},{"url":"http://mike-ai-mcp-arr:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"arr-local","name":"Sonarr und Radarr (lokal)","description":"Nur für verwaltete Serien/Filme, fehlende Episoden, Queue und Suche über konfigurierte Indexer. Keine allgemeine Websuche; Schreibaktionen benötigen Vorschau und Freigabe."}},{"url":"http://mike-ai-mcp-navidrome:3000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"navidrome-local","name":"Navidrome (Musikbibliothek)","description":"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."}},{"url":"http://mike-ai-mcp-unraid-official:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"unraid-readonly-local","name":"Unraid (Systemdiagnose)","description":"Nur für Unraid-Host, Array, Datenträger, Docker-Container, Shares, Netzwerk, UPS und Systemlogs. Nicht für Home Assistant oder Medieninhalte; Standardzugriff read-only."}}]
DO_NOT_TRACK: "true"
SCARF_NO_ANALYTICS: "true"
dns: ["${AI_DNS:-1.1.1.1}"]
+7
View File
@@ -16,6 +16,13 @@ installer and configuration sources, (4) other platform documentation, and
(5) old chat statements only as unverified hints. Stop before changing anything
when runtime and documentation conflict.
When the Athena Platform Context MCP is enabled, start Athena/MikeAI work with
athena_get_overview and use its bounded search/read/current-state tools before
planning. Its documentation apply tool is allowed only after showing the exact
proposal and receiving explicit user approval. A local docs update is not
complete until Git commit/push and the refreshed recovery kit are separately
verified.
Athena is physically remote and normally has no KVM or on-site recovery. Never
shut down, reboot, power off, alter SSH, lan0, firewall, routing, WireGuard,
kernel, NVIDIA drivers, initramfs, bootloader, filesystems, partitions, mounts,
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
import importlib.util
import json
import os
import tempfile
from pathlib import Path
def load_module(root: Path, docs: Path, runtime: Path, state: Path):
os.environ.update({
"ATHENA_REPO_ROOT": str(root),
"ATHENA_DOCS_ROOT": str(docs),
"ATHENA_RUNTIME_FILE": str(runtime),
"ATHENA_CONTEXT_STATE": str(state),
"ATHENA_DOC_WRITE_MODE": "enabled",
})
source = Path(__file__).parents[1] / "platform/mcp/platform_context_mcp.py"
spec = importlib.util.spec_from_file_location("platform_context_mcp_test", source)
module = importlib.util.module_from_spec(spec)
assert spec.loader
spec.loader.exec_module(module)
return module
def main():
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
repo = base / "repo"
docs = base / "docs"
state = base / "state"
runtime = base / "runtime.json"
(repo / "docs").mkdir(parents=True)
docs.mkdir()
(repo / "docs/PLATFORM_OVERVIEW.md").write_text("# Athena\nRouter and recovery.\n")
(repo / "docs/OPERATIONS.md").write_text("# Operations\nUse bounded tools.\n")
(docs / "PLATFORM_OVERVIEW.md").write_text("# Athena\nRouter and recovery.\n")
runtime.write_text(json.dumps({"generated_unix": 4102444800, "generated_at": "2100-01-01T00:00:00Z", "source_commit": "abc", "containers": []}))
m = load_module(repo, docs, runtime, state)
assert len(m.TOOLS) == 9
assert m.overview()["source"] == "docs/PLATFORM_OVERVIEW.md"
assert m.current_state()["available"] is True
assert m.search_knowledge({"query": "recovery", "max_results": 3})["count"] >= 1
assert "Operations" in m.read_source({"path": "docs/OPERATIONS.md"})["content"]
try:
m.safe_repo_path("config/secret.env")
raise AssertionError("secret path was accepted")
except ValueError:
pass
proposal = m.prepare_update({
"summary": "Update overview test",
"evidence": "Verified synthetic test state",
"updates": [{"path": "docs/PLATFORM_OVERVIEW.md", "content": "# Athena\nUpdated safely.\n"}],
})
assert (docs / "PLATFORM_OVERVIEW.md").read_text().endswith("recovery.\n")
result = m.apply_update({"proposal_id": proposal["proposal_id"], "confirmation": proposal["required_confirmation"]})
assert result["documentation_applied"] is True
assert result["git_commit_complete"] is False
assert (docs / "PLATFORM_OVERVIEW.md").read_text().endswith("safely.\n")
assert m.maintenance_status()["latest_applied_documentation_change"]
for tool in m.TOOLS:
for prop in tool["inputSchema"].get("properties", {}).values():
pattern = prop.get("pattern")
if pattern:
assert pattern.startswith("^") and pattern.endswith("$")
print("PLATFORM_CONTEXT_MCP_TEST_OK")
if __name__ == "__main__":
main()
+2
View File
@@ -145,6 +145,7 @@ Allzweck-MCP mit sämtlichen Zugangsdaten.
```text
Open WebUI ── internes Netz ───────────┬── web-mcp
├── platform-context-mcp
├── home-assistant-mcp
├── arr-mcp
├── github-mcp-read
@@ -158,6 +159,7 @@ weitere MCP-Clients ──────┴── mcp-gateway (später) ── das
| Container | Werkzeugbereich | Standardrecht |
|---|---|---|
| `web-mcp` | Websuche, Seitenabruf, Hugging Face und öffentliche Quellen | nur lesen |
| `platform-context-mcp` | Architektur, Quellen, Snapshot und Docs-Pflege | kein Docker-Socket; Docs nur Preview/Approval |
| `github-mcp-read` | Repositorysuche, Baum, Dateiinhalt und Code-Suche | vier Tools, strikt nur lesen |
| `home-assistant-mcp-read` | Entities, Bereiche, Historie, Diagnose | nur lesen |
| `home-assistant-mcp-write` | kontrollierte HA-Änderungen | Preview/Approval |
+1
View File
@@ -12,6 +12,7 @@
| ARR-MCP | `arr-mcp` 1.0.1 plus dokumentierter Sonarr-Patch | eigener optionaler Container | optional |
| Navidrome-MCP | Blakeem/Navidrome-MCP 2.2.0, Image per OCI-Digest | eigener optionaler Container ohne mpv | optional |
| GitHub-MCP | offizieller `github/github-mcp-server` 1.10.1, vier read-only Werkzeuge | eigener optionaler Container hinter Streamable-HTTP-Brücke | optional |
| Platform Context MCP | Athena-/MikeAI-Wissen, begrenzter Laufzeitsnapshot und kontrollierte Dokumentationspflege | eigener Container ohne Docker-Socket, Shell, Egress oder Secrets | Kern |
| Operator-Kontext | `docs/QWEN_OPERATOR_CONTEXT.md` plus `config/operator-system-prompt.txt` | versionierte Selbstbeschreibung und Sicherheitsregeln für Qwen | Kern |
| Unraid-MCP | lokales `runraid`-Binary | eigener optionaler Container | optional |
| Whisper | ggml-org/whisper.cpp | Service im Router-Deploy | optional |
+11 -4
View File
@@ -150,12 +150,14 @@ Der isolierte Eignungs- und Ausfalltest ist in
- TinySearch ausschließlich im internen Docker-Netz, ohne Host-Port
- lokale ONNX-Embeddings
- kompakte Web-MCP-Fassade mit vier Werkzeugen
- strukturierte API-Pfade für GitHub und Hugging Face
- strukturierter API-Pfad für Hugging Face; GitHub-Quellcode läuft über den
getrennten offiziellen GitHub-MCP
## MCP-Referenz
Aktuell existieren funktionale Adapter für:
- Athena-Plattformwissen, Laufzeitsnapshot und kontrollierte Docs-Pflege
- Websuche
- Home Assistant
- Sonarr/Radarr
@@ -164,9 +166,14 @@ Aktuell existieren funktionale Adapter für:
- Unraid read-only
- eigener Unraid-Administrationsserver
Der GitHub-Container und sein Streamable-HTTP-Handshake sind verifiziert. Er
startet erst produktiv, wenn `/etc/mike-ai/github-mcp.env` einen dedizierten
Read-only-Token enthält; ein leerer Platzhalter aktiviert den Dienst nicht.
Der GitHub-Container läuft produktiv. Token-Datei, interner
Streamable-HTTP-Handshake, fehlende Host-Portfreigabe und exakt vier
read-only Werkzeuge wurden am 23. August 2026 verifiziert.
Der Platform Context MCP läuft ohne Docker-Socket, Shell, Egress oder Secrets.
Ein root-eigener Minutentimer erzeugt nur einen begrenzten Laufzeitsnapshot.
Der Schreibpfad ist auf `docs/*.md`, Vorschau, ausdrückliche Freigabe, atomare
Sicherung und sichtbare Git-/Recovery-Nacharbeit begrenzt.
Der frühere allgemeine Shell-MCP und doppelte, schreibende Werkzeuge gehören
nicht zum Sicherheitsziel und werden nicht ungeprüft wiederhergestellt.
+4
View File
@@ -72,6 +72,10 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
- [ ] kein Secret erscheint in Toolantworten oder Logs
- [ ] `PLATFORM_OVERVIEW.md`, `QWEN_OPERATOR_CONTEXT.md` und der Operator-
System-Prompt entsprechen dem wiederhergestellten Stand
- [ ] Platform-Context-Snapshot aktuell; offene Vorschläge und angewandte
Dokumentationsänderungen mit `athena_get_maintenance_status` geprüft
- [ ] lokales Dokumentations-Overlay ist auch im privaten Git enthalten und
der Recovery-Koffer wurde danach neu erzeugt
## Phase E – Vision, Bild und Sprache
+106
View File
@@ -0,0 +1,106 @@
# Athena Platform Context MCP
Stand: 23. August 2026
## Zweck
`mike-ai-mcp-platform-context` gibt jedem MCP-fähigen Client dasselbe
versionierte Wissen über Athena und MikeAI. Dadurch kann in Open WebUI zwischen
Fast, Medium, Large und Ultra gewechselt werden, ohne den vollständigen
Operator-Kontext in jeden Prompt zu kopieren.
Der MCP ist zugleich das kontrollierte Pflegefenster für seine eigene
Dokumentation. Er ist **kein** allgemeiner Athena-Administrator und erhält
weder Docker-Socket noch Shell, Netzwerkzugang, Git-Schlüssel oder Secrets.
## Werkzeuge
| Werkzeug | Wirkung |
|---|---|
| `athena_get_overview` | kurze Architektur und Quellenhierarchie |
| `athena_get_current_state` | begrenzter aktueller Snapshot ohne Nutzdaten |
| `athena_search_knowledge` | Suche in Dokumentation und versionierten Quellen |
| `athena_read_source` | begrenzter Ausschnitt einer ausgewählten Textdatei |
| `athena_get_change_workflow` | verbindlicher Ablauf je Änderungstyp |
| `athena_prepare_documentation_update` | erzeugt nur eine prüfbare Vorschau |
| `athena_apply_documentation_update` | schreibt nach Freigabe ausschließlich `docs/*.md` |
| `athena_get_maintenance_status` | zeigt offene Git-/Recovery-Schulden |
| `athena_close_maintenance_record` | schließt Schulden erst nach geprüftem Git-Deploy und neuerem Recovery-Koffer |
## Aktueller Zustand ohne Docker-Socket
`mike-ai-platform-context-snapshot.timer` startet jede Minute einen kurzen,
fest programmierten Host-Snapshot. Er erfasst ausschließlich:
- Hostname, Debian-/Kernel-Version und Uptime
- grobe RAM- und Dateisystembelegung
- GPU-Name, UUID, VRAM-Belegung und Treiberversion
- Name, Image und Status der laufenden `mike-ai-*`-Container
- aktives Inferenzprofil
- installierten Quellcommit, Hash des Dokumentationsbaums und Status des
Recovery-Koffers
Nicht erfasst werden Logs, Prompts, Chats, Toolinhalte, Container-Umgebungen,
Dateiinhalte außerhalb der versionierten Dokumentation oder Secretwerte. Der
Container liest nur die erzeugte JSON-Datei. Ein Snapshot älter als drei
Minuten gilt als veraltet.
## Dokumentationspflege
Die Pflege ist absichtlich zweistufig:
1. Qwen prüft Laufzeit und Quellen und ruft
`athena_prepare_documentation_update` auf.
2. Das Werkzeug speichert einen Vorschlag unter
`/data/mike-ai-platform-context/pending` und liefert ID, Hashes und die
genaue Freigabezeichenfolge zurück. Noch wurde nichts geändert.
3. Qwen zeigt den Vorschlag dem Benutzer und beendet die autonome Werkzeugkette.
4. Erst nach ausdrücklicher Freigabe darf
`athena_apply_documentation_update` mit `APPLY <proposal-id>` aufgerufen
werden.
5. Vorherige Dateien werden unter
`/data/mike-ai-platform-context/backups` gesichert, neue Inhalte atomar
geschrieben und unter `applied` protokolliert.
Der Server akzeptiert nur einfache Markdown-Dateien direkt unter `docs/`.
Code, Compose, Profile, Installer, Netzwerke, Services, Git und Secrets können
über diesen Schreibweg nicht verändert werden.
## Git und Recovery
Die kanonische Quelle ist der private Gitea-Stand
`git@192.168.1.2:michael/AI-Profile-Router.git`, Branch `main`. Das
Installationsverzeichnis `/opt/mike-ai/stack` ist eine ausgerollte Kopie und
kein Git-Working-Tree; `.mike-ai-source-commit` benennt den ausgerollten
Commit. Der offizielle GitHub-MCP ist read-only und kann dieses private
Gitea-Repository weder ändern noch pushen. Dafür ist ein getrennt
autorisiertes Git-Werkzeug beziehungsweise ein administrativer Git-Workflow
notwendig.
Eine angewandte Dokumentationspflege ist erst vollständig abgeschlossen, wenn
getrennte, dafür autorisierte Werkzeuge Folgendes bestätigt haben:
1. dieselbe Änderung ist im privaten Quellrepository geprüft, committed und
gepusht;
2. der Commit wurde nach Athena ausgerollt und `.mike-ai-source-commit` stimmt;
3. ein neues verschlüsseltes Recovery-Bundle und ein neues
`/data/mike-ai-recovery-kit` wurden erzeugt und geprüft.
Der Context MCP meldet diese Punkte nach jeder Anwendung ausdrücklich als
offen. Er darf sie nicht selbst als erledigt markieren. Lokale Vorschläge,
Backups und Dokumentations-Overlays werden im verschlüsselten Recovery-Bundle
mitgesichert, sodass ungepushte Dokumentationspflege bei einem SSD-Ausfall
nicht vollständig verloren geht. Das ersetzt keinen Git-Commit.
## Verwendung in Open WebUI
Das Werkzeug `Athena Plattformwissen` wird nur bei Arbeiten an Athena/MikeAI
aktiviert. Ein geeigneter Startauftrag lautet:
> Nutze zuerst das Athena-Plattformwissen. Prüfe den aktuellen Zustand und die
> relevanten Quellen. Plane danach die gewünschte Änderung mit Rückweg. Nimm
> keine risikoreiche Aktion und keine Dokumentationsanwendung ohne meine
> ausdrückliche Freigabe vor.
Das funktioniert unabhängig vom gewählten Textprofil. Für normale Gespräche
bleibt der MCP deaktiviert und verbraucht damit keinen Werkzeugkontext.
+8
View File
@@ -12,6 +12,13 @@ Router stellt eine OpenAI-kompatible API bereit und schaltet zwischen mehreren
reproduzierbaren llama.cpp-Profilen um. Fachwerkzeuge laufen als getrennte MCP-
Container; Zugangsdaten gelangen weder in llama.cpp noch in Modellprompts.
Der zuschaltbare `mike-ai-mcp-platform-context` stellt allen Textprofilen das
gleiche versionierte Plattformwissen zur Verfügung. Ein begrenzter
Host-Snapshot ersetzt einen Docker-Socket. Dokumentationsänderungen laufen nur
über Vorschau, ausdrückliche Freigabe und atomare Sicherung; Git und Recovery
bleiben getrennte, nachzuweisende Abschlussarbeiten. Details stehen in
`PLATFORM_CONTEXT_MCP.md`.
## Hardware
- Debian 13 `trixie`, Kernel 6.12
@@ -42,6 +49,7 @@ Open WebUI ---> Profile Router ---> Profile Controller ---> genau ein llama.cpp-
| +-- TTS-Gateway -> XTTS-v2 -> Piper-Fallback
|
+-- internes MCP-Netz
+-- Athena Plattformwissen
+-- Web
+-- GitHub Repository read-only
+-- Home Assistant
+15
View File
@@ -29,6 +29,14 @@ Priorität der Informationsquellen:
Bei einem Widerspruch stoppst du vor jeder Änderung, benennst die Abweichung und
klärst, ob Laufzeit oder Dokumentation korrigiert werden soll.
Wenn der zuschaltbare MCP `Athena Plattformwissen` verfügbar ist, beginne
Athena-/MikeAI-Aufgaben mit `athena_get_overview` und nutze danach gezielt
`athena_get_current_state`, `athena_search_knowledge` und
`athena_read_source`. Der MCP ersetzt nicht die Fachwerkzeuge. Sein
Dokumentations-Schreibweg darf erst nach Vorschau und ausdrücklicher Freigabe
verwendet werden. Eine lokale Dokumentationsänderung ist ohne separaten
Git-Commit/Push und erneuerten Recovery-Koffer nicht abgeschlossen.
## 2. Auftrag und Einsatzumgebung
MikeAI stellt lokal Inferenz, multimodale Bildanalyse, Bildgenerierung,
@@ -81,6 +89,12 @@ Inferenzcontainer eingehängt. Dauerhafte
Änderungen gehören zuerst in das private Repository `AI-Profile-Router`, nicht
nur in einen laufenden Container. Die Hauptbestandteile sind:
Die kanonische Git-Quelle ist der private Gitea-Branch `main` unter
`git@192.168.1.2:michael/AI-Profile-Router.git`. `/opt/mike-ai/stack` ist kein
Working Tree; `.mike-ai-source-commit` bezeichnet den ausgerollten Stand. Der
offizielle GitHub-MCP ist strikt read-only und kann Gitea nicht pflegen. Für
Commit und Push ist daher ein getrennt autorisierter Git-Arbeitsweg nötig.
- Open WebUI als Benutzeroberfläche und Speicher für Arbeitsbereichsmodelle,
Filter, Aktionen und Chats
- Profile Router als OpenAI-kompatible API und zentrale Medien-/Profilfassade
@@ -211,6 +225,7 @@ Netzzugriff. Kein MCP-Port wird am Host veröffentlicht.
| Bereich | Aufgabe | Rechte |
|---|---|---|
| Athena-Plattform | Architektur, Quellen, Laufzeitsnapshot, Dokumentationspflege | Lesen; Markdown nur Preview/Approval |
| Web | aktuelle öffentliche Recherche über SearXNG/TinySearch | read-only |
| GitHub | Repositorysuche, Baum, Dateiinhalt, Code-Suche | strikt read-only, vier Tools |
| Home Assistant | Zustände, Historie, Diagnose, begrenzte YAML-Abläufe | Lesen; Schreiben nur Preview/Approval |
+12 -1
View File
@@ -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-unraid-official; do
mike-ai-mcp-github mike-ai-mcp-platform-context mike-ai-mcp-unraid-official; do
if container_healthy "$optional"; then
pass "$optional aktiv"
else
@@ -72,6 +72,17 @@ for optional in mike-ai-mcp-web mike-ai-mcp-homeassistant mike-ai-mcp-arr \
fi
done
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
+14
View File
@@ -0,0 +1,14 @@
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"]
+7
View File
@@ -10,6 +10,7 @@ Prompts heraus, verhindert den früher beobachteten Kontextverbrauch von über
| Container | Endpunkt im Netz `mike-ai-tools` | Zweck | Standard |
|---|---|---|---|
| `mcp-platform-context` | `http://mike-ai-mcp-platform-context:8000/mcp` | Athena-Wissen, begrenzter Snapshot und kontrollierte Docs-Pflege | an |
| `mcp-web` | `http://mike-ai-mcp-web:8000/mcp` | kompakte Websuche und Quellenvergleich | an |
| `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` |
@@ -25,6 +26,12 @@ 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. Dokumentationspflege ist auf `docs/*.md` und einen zweistufigen
Preview/Approval-Ablauf begrenzt. Vollständige Beschreibung:
[`docs/PLATFORM_CONTEXT_MCP.md`](../../docs/PLATFORM_CONTEXT_MCP.md).
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
+29
View File
@@ -137,6 +137,35 @@ services:
- /config:rw,noexec,nosuid,nodev,size=4m,mode=0700
networks: [tools, egress]
mcp-platform-context:
<<: *tool-common
build:
context: .
dockerfile: Dockerfile.platform-context
image: mike-ai/mcp-platform-context:1.0.0
container_name: mike-ai-mcp-platform-context
environment:
ATHENA_REPO_ROOT: /knowledge/repo
ATHENA_DOCS_ROOT: /workspace/docs
ATHENA_RUNTIME_FILE: /runtime/runtime.json
ATHENA_CONTEXT_STATE: /state
# Writes remain confined to docs/*.md and require a prepared proposal
# plus its exact confirmation string. The MCP cannot change code,
# containers, networking, secrets, Git or recovery bundles.
ATHENA_DOC_WRITE_MODE: enabled
volumes:
- ${PLATFORM_STACK_DIR:-/opt/mike-ai/stack}:/knowledge/repo:ro
- ${PLATFORM_DOCS_DIR:-/opt/mike-ai/stack/docs}:/workspace/docs:rw
- ${PLATFORM_CONTEXT_RUNTIME_DIR:-/var/lib/mike-ai-platform-context}:/runtime:ro
- ${PLATFORM_CONTEXT_STATE_DIR:-/data/mike-ai-platform-context}:/state:rw
networks: [tools]
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-github:
<<: *tool-common
build:
+17
View File
@@ -17,6 +17,23 @@ export SEARXNG_SETTINGS_FILE="${SEARXNG_SETTINGS_FILE:-$MCP_DIR/../web-search/se
exit 1
}
# The platform context MCP never receives the Docker socket. A root-owned
# timer writes a bounded metadata snapshot instead. Only documentation files
# and the dedicated state directory are writable by the unprivileged MCP uid.
install -d -m 0755 /usr/local/libexec /var/lib/mike-ai-platform-context
install -d -o 10001 -g 10001 -m 0750 /data/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
find /opt/mike-ai/stack/docs -type d -exec chown root:10001 {} + -exec chmod 0775 {} +
find /opt/mike-ai/stack/docs -type f -name '*.md' -exec chown root:10001 {} + -exec chmod 0664 {} +
systemctl daemon-reload
systemctl enable --now mike-ai-platform-context-snapshot.timer
systemctl start mike-ai-platform-context-snapshot.service
profiles=()
if [[ -s /etc/mike-ai/homeassistant-admin-mcp.env ]]; then
profiles+=(--profile homeassistant)
+113
View File
@@ -0,0 +1,113 @@
#!/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-")]
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": 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()
+609
View File
@@ -0,0 +1,609 @@
#!/usr/bin/env python3
"""Bounded platform knowledge and documentation-maintenance MCP for Athena.
This service deliberately has no Docker socket, shell tool, network egress or
secret mounts. Live data is supplied by a root-owned, fixed-command snapshot
timer. Canonical documentation may only be changed through a preview/apply
workflow and only below docs/.
"""
from __future__ import annotations
import hashlib
import difflib
import calendar
import json
import os
import re
import sys
import tempfile
import time
import uuid
from pathlib import Path
from typing import Any
SERVER_VERSION = "1.0.0"
REPO_ROOT = Path(os.environ.get("ATHENA_REPO_ROOT", "/knowledge/repo"))
DOCS_ROOT = Path(os.environ.get("ATHENA_DOCS_ROOT", "/workspace/docs"))
RUNTIME_FILE = Path(os.environ.get("ATHENA_RUNTIME_FILE", "/runtime/runtime.json"))
STATE_ROOT = Path(os.environ.get("ATHENA_CONTEXT_STATE", "/state"))
WRITE_MODE = os.environ.get("ATHENA_DOC_WRITE_MODE", "proposal-only")
MAX_DOCUMENT_CHARS = 24000
MAX_SEARCH_RESULTS = 8
MAX_UPDATE_CHARS = 120000
ALLOWED_TEXT_SUFFIXES = {".md", ".txt", ".yaml", ".yml", ".json", ".py", ".sh", ".service", ".timer", ".conf", ".example"}
EXCLUDED_PARTS = {".git", "__pycache__", "xtts-test-audio", ".venv", "node_modules"}
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": (
"USE FIRST when a request concerns Athena, MikeAI, its models, profiles, GPUs, "
"OpenWebUI, router, MCPs, TTS/STT, Vision, networking or recovery. Returns the "
"short authoritative architecture overview plus snapshot freshness. This is "
"read-only and contains no secrets. Runtime claims still require "
"athena_get_current_state or the relevant specialist MCP."
),
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
},
{
"name": "athena_get_current_state",
"description": (
"USE for the current bounded Athena runtime inventory: host, filesystems, GPUs, "
"active MikeAI containers, active inference profile, source commit and recovery "
"freshness. The snapshot contains no logs, prompts, chats, environment values or "
"secrets. If stale, state that explicitly. For detailed service diagnosis use the "
"specialist management tool instead of guessing."
),
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
},
{
"name": "athena_search_knowledge",
"description": (
"USE to find the relevant MikeAI documentation, Compose definition, installer, "
"profile, runbook or source file before planning a platform change. Returns bounded "
"matching excerpts and paths. Do not repeatedly rephrase the same search; follow up "
"with athena_read_source for the selected file."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "minLength": 2, "maxLength": 300},
"max_results": {"type": "integer", "minimum": 1, "maximum": 8, "default": 5},
},
"required": ["query"],
"additionalProperties": False,
},
},
{
"name": "athena_read_source",
"description": (
"USE after athena_search_knowledge to read a bounded section of one versioned "
"MikeAI source or documentation file. Secret files, .git and binary artifacts are "
"not accessible. Paths are relative to the repository, for example "
"docs/OPERATIONS.md or platform/mcp/compose.yaml."
),
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "minLength": 3, "maxLength": 240},
"start_line": {"type": "integer", "minimum": 1, "default": 1},
"max_lines": {"type": "integer", "minimum": 1, "maximum": 300, "default": 160},
},
"required": ["path"],
"additionalProperties": False,
},
},
{
"name": "athena_get_change_workflow",
"description": (
"USE before adding or replacing a model, MCP, TTS/STT, Vision/image service, "
"OpenWebUI integration, network component or recovery behavior. Returns the source "
"files, safety gates, validation steps, documentation duties, Git duties and "
"recovery duties for that change type. It performs no change."
),
"inputSchema": {
"type": "object",
"properties": {
"change_type": {
"type": "string",
"enum": ["mcp", "model", "profile", "tts", "stt", "vision", "image", "openwebui", "network", "recovery", "other"],
}
},
"required": ["change_type"],
"additionalProperties": False,
},
},
{
"name": "athena_prepare_documentation_update",
"description": (
"USE only after a real platform change or verified documentation drift. Creates a "
"reviewable proposal; it does not alter canonical documentation. Each update must "
"target an existing or new Markdown file below docs/. Include only verified facts, "
"never secrets, prompts, chats or private content. After preview, wait for explicit "
"user approval before calling athena_apply_documentation_update."
),
"inputSchema": {
"type": "object",
"properties": {
"summary": {"type": "string", "minLength": 5, "maxLength": 500},
"evidence": {"type": "string", "minLength": 5, "maxLength": 2000},
"updates": {
"type": "array",
"minItems": 1,
"maxItems": 6,
"items": {
"type": "object",
"properties": {
"path": {"type": "string", "pattern": "^docs/[A-Za-z0-9_.-]+\\.md$"},
"content": {"type": "string", "minLength": 1, "maxLength": 120000},
},
"required": ["path", "content"],
"additionalProperties": False,
},
},
},
"required": ["summary", "evidence", "updates"],
"additionalProperties": False,
},
},
{
"name": "athena_apply_documentation_update",
"description": (
"WRITE TOOL. Use only after the user explicitly approved the exact proposal in the "
"current conversation. Applies an already prepared proposal atomically below docs/, "
"backs up prior files and appends an audit record. It cannot change code, Compose, "
"services, secrets, Git or recovery bundles. The result always lists required Git "
"commit/push and recovery refresh work; never claim those are complete unless their "
"separate tools verify them."
),
"inputSchema": {
"type": "object",
"properties": {
"proposal_id": {"type": "string", "pattern": "^[a-f0-9]{32}$"},
"confirmation": {"type": "string", "minLength": 38, "maxLength": 64},
},
"required": ["proposal_id", "confirmation"],
"additionalProperties": False,
},
},
{
"name": "athena_get_maintenance_status",
"description": (
"USE after documentation or platform work. Reports pending documentation proposals, "
"applied documentation changes awaiting Git/recovery handling, source commit and "
"recovery-kit freshness. It never commits, pushes or rebuilds recovery automatically."
),
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
},
{
"name": "athena_close_maintenance_record",
"description": (
"WRITE TOOL for maintenance metadata only. Use after separate tools have verified "
"that the documentation change was committed/pushed, deployed to Athena and followed "
"by a newer recovery kit. The server checks source commit and recovery timestamp before "
"moving the record to resolved. It changes no documentation, Git or recovery data."
),
"inputSchema": {
"type": "object",
"properties": {
"proposal_id": {"type": "string", "pattern": "^[a-f0-9]{32}$"},
"git_commit": {"type": "string", "pattern": "^[a-f0-9]{40}$"},
"verification": {"type": "string", "minLength": 10, "maxLength": 1000},
"confirmation": {"type": "string", "minLength": 38, "maxLength": 64},
},
"required": ["proposal_id", "git_commit", "verification", "confirmation"],
"additionalProperties": False,
},
},
]
def now_iso() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def json_text(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def allowed_text_file(path: Path) -> bool:
return (
path.suffix.lower() in ALLOWED_TEXT_SUFFIXES
or path.name.startswith("Dockerfile")
or path.name in {"LLAMA_CPP_COMMIT"}
)
def safe_repo_path(relative: str) -> Path:
if not relative or relative.startswith("/") or "\\" in relative:
raise ValueError("path must be repository-relative")
parts = Path(relative).parts
if ".." in parts or any(part in EXCLUDED_PARTS for part in parts):
raise ValueError("path is outside the allowed source tree")
lowered = relative.lower()
if any(token in lowered for token in ("secret", "authorized_keys", ".env", "agekey")):
raise ValueError("secret-bearing paths are not exposed")
path = (REPO_ROOT / relative).resolve()
root = REPO_ROOT.resolve()
if root not in path.parents and path != root:
raise ValueError("path escapes repository")
if not path.is_file() or not allowed_text_file(path):
raise ValueError("path is not an allowed text source")
return path
def safe_doc_path(relative: str) -> Path:
match = re.fullmatch(r"docs/([A-Za-z0-9_.-]+\.md)", relative)
if not match:
raise ValueError("documentation updates are limited to docs/*.md")
path = (DOCS_ROOT / match.group(1)).resolve()
root = DOCS_ROOT.resolve()
if root not in path.parents:
raise ValueError("documentation path escapes docs root")
if path.exists() and path.is_symlink():
raise ValueError("symbolic links are not writable")
return path
def read_runtime() -> dict[str, Any]:
try:
data = json.loads(RUNTIME_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return {"available": False, "error": str(exc), "instruction": "Do not infer current runtime state."}
generated = int(data.get("generated_unix", 0))
age = max(0, int(time.time()) - generated) if generated else None
data["available"] = True
data["age_seconds"] = age
data["stale"] = age is None or age > 180
return data
def overview() -> dict[str, Any]:
path = REPO_ROOT / "docs/PLATFORM_OVERVIEW.md"
text = path.read_text(encoding="utf-8")[:MAX_DOCUMENT_CHARS]
runtime = read_runtime()
return {
"source": "docs/PLATFORM_OVERVIEW.md",
"source_hierarchy": [
"current specialist-tool evidence",
"bounded Athena runtime snapshot",
"CURRENT_REFERENCE.md and STANDARD_PROFILE_MATRIX.md",
"versioned source and runbooks",
"chat memory only as an unverified hint",
],
"runtime_snapshot": {k: runtime.get(k) for k in ("available", "generated_at", "age_seconds", "stale", "source_commit")},
"content": text,
"instruction": "Search or read the relevant source before proposing a change; verify mutable claims with a current tool.",
}
def current_state() -> dict[str, Any]:
data = read_runtime()
data["scope"] = "bounded metadata only; no logs, prompts, chats, environment values or secrets"
if data.get("stale"):
data["instruction"] = "Snapshot is stale. Do not claim current service state until a specialist tool verifies it."
return data
def candidate_files() -> list[Path]:
files: list[Path] = []
for path in REPO_ROOT.rglob("*"):
try:
rel = path.relative_to(REPO_ROOT)
except ValueError:
continue
if not path.is_file() or any(part in EXCLUDED_PARTS for part in rel.parts):
continue
if not allowed_text_file(path):
continue
lowered = str(rel).lower()
if any(token in lowered for token in ("secret", "authorized_keys", ".env", "agekey")):
continue
files.append(path)
return files
def search_knowledge(arguments: dict[str, Any]) -> dict[str, Any]:
query = str(arguments.get("query", "")).strip()
if len(query) < 2:
raise ValueError("query is too short")
limit = max(1, min(MAX_SEARCH_RESULTS, int(arguments.get("max_results", 5))))
terms = [term for term in re.findall(r"[a-zA-Z0-9_.-]{2,}", query.lower()) if term]
scored: list[tuple[int, str, int, str]] = []
for path in candidate_files():
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
rel = str(path.relative_to(REPO_ROOT))
for index, line in enumerate(lines):
lower = line.lower()
score = sum(3 if term in rel.lower() else 1 for term in terms if term in lower or term in rel.lower())
if score:
excerpt = "\n".join(lines[max(0, index - 2): min(len(lines), index + 4)])[:1800]
scored.append((score, rel, index + 1, excerpt))
scored.sort(key=lambda item: (-item[0], item[1], item[2]))
seen: set[tuple[str, int]] = set()
results = []
for score, rel, line, excerpt in scored:
key = (rel, line // 20)
if key in seen:
continue
seen.add(key)
results.append({"path": rel, "line": line, "score": score, "excerpt": excerpt})
if len(results) >= limit:
break
return {"query": query, "count": len(results), "results": results, "instruction": "Read selected sources; do not treat search excerpts as current runtime proof."}
def read_source(arguments: dict[str, Any]) -> dict[str, Any]:
relative = str(arguments.get("path", ""))
path = safe_repo_path(relative)
start = max(1, int(arguments.get("start_line", 1)))
max_lines = max(1, min(300, int(arguments.get("max_lines", 160))))
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
selected = lines[start - 1:start - 1 + max_lines]
content = "\n".join(f"{start + i}: {line}" for i, line in enumerate(selected))
return {"path": relative, "start_line": start, "end_line": start + len(selected) - 1, "total_lines": len(lines), "truncated": start - 1 + len(selected) < len(lines), "content": content[:MAX_DOCUMENT_CHARS]}
WORKFLOWS = {
"mcp": ["platform/mcp/compose.yaml", "platform/mcp/README.md", "compose.yaml", "docs/COMPONENTS.md", "docs/SECURITY.md", "docs/QWEN_OPERATOR_CONTEXT.md"],
"model": ["config/install.env.example", "platform/models/manifest.example.yaml", "platform/profiles/", "docs/STANDARD_PROFILE_MATRIX.md", "docs/QWEN_OPERATOR_CONTEXT.md"],
"profile": ["platform/profiles/", "router/router_profiles.json", "platform/openwebui/install-models.sh", "docs/STANDARD_PROFILE_MATRIX.md"],
"tts": ["compose.yaml", "router/xtts_worker.py", "platform/scripts/rollback-tts-production.sh", "docs/XTTS_EVALUATION_2026-08-23.md"],
"stt": ["compose.yaml", "router/stt_worker.py", "docs/COMPONENTS.md"],
"vision": ["compose.yaml", "router/ai_profile_router.py", "docs/STANDARD_PROFILE_MATRIX.md"],
"image": ["compose.yaml", "router/image_worker.py", "docs/OPERATIONS.md"],
"openwebui": ["compose.yaml", "platform/openwebui/", "docs/OPERATIONS.md", "docs/DISASTER_RECOVERY.md"],
"network": ["compose.yaml", "platform/host/", "docs/SECURITY.md", "docs/WIREGUARD_HOME_PEER.md", "docs/EMERGENCY_UNI_ACCESS.md"],
"recovery": ["platform/recovery/", "docs/BARE_METAL_RECOVERY.md", "docs/DISASTER_RECOVERY.md", "docs/RECOVERY_REQUIREMENTS.md"],
"other": ["docs/PLATFORM_OVERVIEW.md", "docs/QWEN_OPERATOR_CONTEXT.md", "docs/OPERATIONS.md"],
}
def change_workflow(arguments: dict[str, Any]) -> dict[str, Any]:
kind = str(arguments.get("change_type", "other"))
if kind not in WORKFLOWS:
raise ValueError("unsupported change_type")
return {
"change_type": kind,
"read_first": WORKFLOWS[kind],
"mandatory_sequence": [
"Capture current state with the narrowest specialist tool.",
"Read relevant versioned sources and identify documentation drift.",
"Define rollback and protect SSH, LAN, WireGuard and the active inference path.",
"Change source-of-truth files, not only a running container.",
"Validate syntax/configuration and run a bounded synthetic test.",
"Verify service health and remote reachability without reading chats or private payloads.",
"Update PLATFORM_OVERVIEW/CURRENT_REFERENCE/QWEN_OPERATOR_CONTEXT and the affected runbook.",
"Commit and push the private Git repository using a separate authorized Git tool.",
"Create and verify a new encrypted recovery bundle and self-contained data-disk kit.",
],
"hard_boundaries": [
"This context MCP does not modify services, Docker, networking, models or secrets.",
"No shutdown, reboot, kernel/driver, SSH, firewall or VPN change without exact user approval and rollback.",
"Never claim Git or recovery is current until separately verified.",
],
}
def prepare_update(arguments: dict[str, Any]) -> dict[str, Any]:
summary = str(arguments.get("summary", "")).strip()
evidence = str(arguments.get("evidence", "")).strip()
updates = arguments.get("updates")
if len(summary) < 5 or len(evidence) < 5 or not isinstance(updates, list) or not updates:
raise ValueError("summary, evidence and at least one update are required")
normalized = []
total = 0
for update in updates[:6]:
relative = str(update.get("path", ""))
safe_doc_path(relative)
content = str(update.get("content", ""))
if not content or len(content) > MAX_UPDATE_CHARS:
raise ValueError("invalid documentation content size")
if re.search(r"(?i)(BEGIN [A-Z ]*PRIVATE KEY|github_pat_[A-Za-z0-9_]+|GITHUB_PERSONAL_ACCESS_TOKEN\s*=\s*\S+)", content):
raise ValueError("probable secret material detected")
total += len(content)
if total > MAX_UPDATE_CHARS * 2:
raise ValueError("proposal is too large")
target = safe_doc_path(relative)
previous = target.read_text(encoding="utf-8") if target.exists() else ""
diff = "\n".join(difflib.unified_diff(previous.splitlines(), content.splitlines(), fromfile=f"a/{relative}", tofile=f"b/{relative}", lineterm=""))
normalized.append({"path": relative, "content": content, "before_sha256": hashlib.sha256(previous.encode()).hexdigest(), "after_sha256": hashlib.sha256(content.encode()).hexdigest(), "before_chars": len(previous), "after_chars": len(content), "diff_preview": diff[:12000]})
proposal_id = uuid.uuid4().hex
proposal = {"proposal_id": proposal_id, "created_at": now_iso(), "summary": summary, "evidence": evidence, "updates": normalized, "status": "pending"}
pending = STATE_ROOT / "pending"
pending.mkdir(parents=True, exist_ok=True)
(pending / f"{proposal_id}.json").write_text(json.dumps(proposal, ensure_ascii=False, indent=2), encoding="utf-8")
return {"proposal_id": proposal_id, "summary": summary, "files": [{k: item[k] for k in ("path", "before_sha256", "after_sha256", "before_chars", "after_chars", "diff_preview")} for item in normalized], "canonical_files_changed": False, "required_confirmation": f"APPLY {proposal_id}", "instruction": "Show this proposal to the user and wait for explicit approval. Do not call apply in the same autonomous tool sequence."}
def apply_update(arguments: dict[str, Any]) -> dict[str, Any]:
proposal_id = str(arguments.get("proposal_id", ""))
confirmation = str(arguments.get("confirmation", ""))
if not re.fullmatch(r"[a-f0-9]{32}", proposal_id):
raise ValueError("invalid proposal_id")
if confirmation != f"APPLY {proposal_id}":
raise ValueError("confirmation does not match the exact proposal")
if WRITE_MODE != "enabled":
raise PermissionError("documentation writes are in proposal-only mode")
proposal_path = STATE_ROOT / "pending" / f"{proposal_id}.json"
if not proposal_path.is_file():
raise ValueError("proposal not found or already applied")
proposal = json.loads(proposal_path.read_text(encoding="utf-8"))
backup_root = STATE_ROOT / "backups" / f"{int(time.time())}-{proposal_id}"
backup_root.mkdir(parents=True, exist_ok=False)
changed = []
for item in proposal["updates"]:
target = safe_doc_path(item["path"])
current = target.read_text(encoding="utf-8") if target.exists() else ""
current_hash = hashlib.sha256(current.encode()).hexdigest()
if current_hash != item["before_sha256"]:
raise RuntimeError(f"documentation drift after preview: {item['path']}")
if target.exists():
(backup_root / target.name).write_text(current, encoding="utf-8")
target.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(item["content"])
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, 0o664)
os.replace(temporary, target)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
changed.append(item["path"])
applied = STATE_ROOT / "applied"
applied.mkdir(parents=True, exist_ok=True)
proposal["status"] = "applied_docs_only"
proposal["applied_at"] = now_iso()
proposal["backup_dir"] = str(backup_root)
destination = applied / proposal_path.name
destination.write_text(json.dumps(proposal, ensure_ascii=False, indent=2), encoding="utf-8")
proposal_path.unlink()
return {
"documentation_applied": True,
"changed_files": changed,
"backup_dir": str(backup_root),
"git_commit_complete": False,
"git_push_complete": False,
"recovery_refresh_complete": False,
"required_next_steps": [
"Use an authorized Git tool to apply the same documentation change to the private source repository, review diff, commit and push.",
"Deploy the committed source back to Athena so .mike-ai-source-commit matches.",
"Create and verify a new encrypted recovery bundle and self-contained /data recovery kit.",
"Run athena_get_maintenance_status and the platform verification checklist.",
],
"instruction": "Do not say the platform is fully documented or recoverable until all three false fields are separately verified.",
}
def maintenance_status() -> dict[str, Any]:
pending_dir = STATE_ROOT / "pending"
applied_dir = STATE_ROOT / "applied"
pending = sorted(path.stem for path in pending_dir.glob("*.json")) if pending_dir.exists() else []
applied = sorted(applied_dir.glob("*.json"), key=lambda path: path.stat().st_mtime, reverse=True) if applied_dir.exists() else []
runtime = read_runtime()
latest_applied = None
if applied:
data = json.loads(applied[0].read_text(encoding="utf-8"))
latest_applied = {"proposal_id": data.get("proposal_id"), "summary": data.get("summary"), "applied_at": data.get("applied_at"), "status": data.get("status")}
return {
"pending_proposals": pending,
"latest_applied_documentation_change": latest_applied,
"source_commit": runtime.get("source_commit"),
"documentation_tree_sha256": runtime.get("documentation_tree_sha256"),
"recovery_kit": runtime.get("recovery_kit"),
"attention_required": bool(pending or latest_applied),
"instruction": "Applied records mean Git and recovery may still be stale; verify them with their dedicated workflow before clearing the maintenance debt.",
}
def close_maintenance(arguments: dict[str, Any]) -> dict[str, Any]:
proposal_id = str(arguments.get("proposal_id", ""))
git_commit = str(arguments.get("git_commit", ""))
confirmation = str(arguments.get("confirmation", ""))
verification = str(arguments.get("verification", "")).strip()
if not re.fullmatch(r"[a-f0-9]{32}", proposal_id):
raise ValueError("invalid proposal_id")
if not re.fullmatch(r"[a-f0-9]{40}", git_commit):
raise ValueError("invalid git_commit")
if confirmation != f"CLOSE {proposal_id}":
raise ValueError("confirmation does not match the exact record")
if len(verification) < 10:
raise ValueError("verification summary is required")
record = STATE_ROOT / "applied" / f"{proposal_id}.json"
if not record.is_file():
raise ValueError("applied maintenance record not found")
data = json.loads(record.read_text(encoding="utf-8"))
runtime = read_runtime()
if runtime.get("stale"):
raise RuntimeError("runtime snapshot is stale")
if runtime.get("source_commit") != git_commit:
raise RuntimeError("deployed source commit does not match the verified Git commit")
applied_at = int(calendar.timegm(time.strptime(data["applied_at"], "%Y-%m-%dT%H:%M:%SZ")))
recovery = runtime.get("recovery_kit") or {}
if not recovery.get("present") or int(recovery.get("modified_unix", 0)) <= applied_at:
raise RuntimeError("recovery kit is absent or older than the documentation change")
data.update({"status": "resolved", "resolved_at": now_iso(), "git_commit": git_commit, "verification": verification, "recovery_kit": recovery})
resolved = STATE_ROOT / "resolved"
resolved.mkdir(parents=True, exist_ok=True)
destination = resolved / record.name
destination.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
record.unlink()
return {"resolved": True, "proposal_id": proposal_id, "git_commit": git_commit, "recovery_kit": recovery.get("target"), "instruction": "Maintenance debt is closed because deployed Git and a newer recovery kit were both verified."}
def call_tool(name: str, arguments: dict[str, Any]) -> str:
if name == "athena_get_overview":
result = overview()
elif name == "athena_get_current_state":
result = current_state()
elif name == "athena_search_knowledge":
result = search_knowledge(arguments)
elif name == "athena_read_source":
result = read_source(arguments)
elif name == "athena_get_change_workflow":
result = change_workflow(arguments)
elif name == "athena_prepare_documentation_update":
result = prepare_update(arguments)
elif name == "athena_apply_documentation_update":
result = apply_update(arguments)
elif name == "athena_get_maintenance_status":
result = maintenance_status()
elif name == "athena_close_maintenance_record":
result = close_maintenance(arguments)
else:
raise ValueError(f"unknown tool: {name}")
return json_text(result)
def response(request_id: Any, result: Any = None, error: dict[str, Any] | None = None) -> None:
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id}
payload["error" if error is not None else "result"] = error if error is not None else result
sys.stdout.write(json_text(payload) + "\n")
sys.stdout.flush()
def handle(message: dict[str, Any]) -> None:
method = message.get("method")
request_id = message.get("id")
if method == "initialize":
response(request_id, {"protocolVersion": message.get("params", {}).get("protocolVersion", "2024-11-05"), "capabilities": {"tools": {"listChanged": False}}, "serverInfo": {"name": "mike-ai-platform-context", "version": SERVER_VERSION}})
elif method == "tools/list":
response(request_id, {"tools": TOOLS})
elif method == "tools/call":
params = message.get("params", {})
try:
text = call_tool(str(params.get("name", "")), params.get("arguments") or {})
response(request_id, {"content": [{"type": "text", "text": text}], "structuredContent": json.loads(text), "isError": False})
except Exception as exc:
response(request_id, {"content": [{"type": "text", "text": f"ERROR: {exc}"}], "isError": True})
elif request_id is not None:
response(request_id, error={"code": -32601, "message": f"Method not found: {method}"})
def main() -> None:
STATE_ROOT.mkdir(parents=True, exist_ok=True)
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()
+3 -1
View File
@@ -32,7 +32,9 @@ mkdir -p "$stage/rootfs" "$stage/payload"
for source in \
/etc/mike-ai \
/root/mike-ai-install.env \
/usr/local/bin/runraid; do
/usr/local/bin/runraid \
/opt/mike-ai/stack/docs \
/data/mike-ai-platform-context; do
[[ -e $source ]] || continue
rsync -aR "$source" "$stage/rootfs/"
done
@@ -60,6 +60,16 @@ if [[ $status == 20 || $status == 21 ]]; then
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 != / && \
@@ -0,0 +1,16 @@
[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
@@ -0,0 +1,11 @@
[Unit]
Description=Refresh bounded MikeAI platform context snapshot
[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
AccuracySec=10s
Persistent=true
[Install]
WantedBy=timers.target