Add self-maintaining Athena platform context MCP
This commit is contained in:
@@ -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
@@ -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}"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user