Simplify Athena stack and recovery
This commit is contained in:
@@ -1,14 +0,0 @@
|
||||
FROM python:3.13-slim@sha256:ffb752e139c0a19692a43af8d8523b274222dd68eebad5d583b45c2201c6e30a
|
||||
|
||||
ARG MCP_PROXY_VERSION=0.12.0
|
||||
RUN pip install --no-cache-dir "mcp-proxy==${MCP_PROXY_VERSION}" "mcp==1.29.0"
|
||||
|
||||
RUN useradd --system --uid 10001 --create-home --home-dir /app mcp
|
||||
COPY platform_context_mcp.py /app/platform_context_mcp.py
|
||||
RUN chown -R 10001:10001 /app
|
||||
|
||||
USER 10001:10001
|
||||
WORKDIR /app
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["mcp-proxy", "--host", "0.0.0.0", "--port", "8000", "--stateless", "--"]
|
||||
CMD ["python", "/app/platform_context_mcp.py"]
|
||||
@@ -1,342 +0,0 @@
|
||||
# Zentrale MCP-Werkzeugebene
|
||||
|
||||
MCP-Werkzeuge sind **keine llama.cpp-Startparameter**. Sie laufen als kleine,
|
||||
voneinander getrennte Container und werden von OpenWebUI, Hermes oder einem
|
||||
anderen MCP-Client gezielt ausgewählt. Das hält Tool-Schemas aus normalen
|
||||
Prompts heraus, verhindert den früher beobachteten Kontextverbrauch von über
|
||||
200.000 Tokens und macht Werkzeuge unabhängig vom geladenen Modellprofil.
|
||||
|
||||
## Container
|
||||
|
||||
| Container | Endpunkt im Netz `mike-ai-tools` | Zweck | Standard |
|
||||
|---|---|---|---|
|
||||
| `mcp-platform-context` | `http://mike-ai-mcp-platform-context:8000/mcp` | kurze read-only Athena-Auskunft und begrenzter Snapshot | an |
|
||||
| `mcp-athena-operator` | `http://mike-ai-mcp-athena-operator:8000/mcp` | vollständiger Betrieb plus breites begrenztes Terminal | an |
|
||||
| `tinysearch` | `http://tinysearch:8000/mcp` | allgemeine portable Websuche und Seitenabruf | an |
|
||||
| `mcp-web` | `http://mike-ai-mcp-web:8000/mcp` | frühere spezialisierte Web-Fassade | nur Profil `legacy-web` |
|
||||
| `mcp-homeassistant` | `http://mike-ai-mcp-homeassistant:8000/mcp` | Relay zum nativen HA-MCP; Token bleibt serverseitig | Profil `homeassistant` |
|
||||
| `mcp-arr` | `http://mike-ai-mcp-arr:8000/mcp` | Sonarr/Radarr/Prowlarr mit serverseitiger Policy | Profil `arr` |
|
||||
| `mcp-navidrome` | `http://mike-ai-mcp-navidrome:3000/mcp` | Navidrome-Bibliothek, Suche, Playlists, Favoriten und Hörverlauf | Profil `navidrome` |
|
||||
| `mcp-github` | `http://mike-ai-mcp-github:8000/mcp` | offizieller GitHub-MCP, auf drei kleine Repository-Lesewerkzeuge begrenzt | Profil `github` |
|
||||
| `mcp-unraid-ssh` | `http://mike-ai-mcp-unraid-ssh:8000/mcp` | erweiterte Diagnose über einen erzwungenen SSH-Befehl | optional (`extended`) |
|
||||
|
||||
Unraid wird produktiv ausschließlich über das auf dem HomeServer laufende
|
||||
MUA-Plugin (`http://192.168.1.2:3002/mcp`) angebunden. Open WebUI führt davon
|
||||
zwei Ansichten: `mua-readonly-local` für automatische Diagnose und `mua` für
|
||||
bewusst aktivierte Verwaltungsaktionen. Ein GraphQL-basierter Unraid-MCP ist
|
||||
nicht Bestandteil des Stacks.
|
||||
|
||||
Die drei Websuch-Container verwenden `AI_DNS` aus
|
||||
`/etc/mike-ai/stack.env`. Der Web-MCP hängt zusätzlich am getrennten
|
||||
`mike-ai-tools-egress`-Netz, weil er gefundene öffentliche Seiten nach der
|
||||
SSRF-Prüfung selbst abrufen muss. Ohne diese beiden Einstellungen kann die
|
||||
Werkzeugauswahl korrekt wirken, während alle Suchmaschinen und Seitenabrufe
|
||||
gleichzeitig fehlschlagen.
|
||||
|
||||
Der Platform Context MCP hat keinen Docker-Socket, keine Shell, keinen Egress
|
||||
und keine Secrets. Sein aktueller Zustand stammt aus einem fest programmierten
|
||||
Host-Snapshot. Er liefert `ATHENA.md` sowie kleine, begrenzte Such- und
|
||||
Leseausschnitte. Vollständige Beschreibung:
|
||||
[`docs/PLATFORM_CONTEXT_MCP.md`](../../docs/PLATFORM_CONTEXT_MCP.md).
|
||||
|
||||
Der Athena Operator MCP ist die einzige Bedienebene für Arbeiten an der lokalen
|
||||
KI-Plattform. Seine sechs sichtbaren Werkzeuge sind Inspect, Suche, begrenztes
|
||||
Lesen, Terminal, direkte Änderung und Jobstatus. Qwen kann damit MCPs und
|
||||
Docker-Dienste bauen/deployen, Modelle laden, Benchmarks starten, Profile und
|
||||
OpenWebUI pflegen, Git veröffentlichen und Recovery erzeugen. Zusätzlich bietet er ein breites, ausgabebegrenztes Terminal für
|
||||
unvorhergesehene Docker-, Datei-, Git-, HTTP-, Modell- und Remote-SSH-Aufgaben.
|
||||
Die MCP-Fassade sieht nur einen lokalen Unix-Socket; Root-Rechte verbleiben im
|
||||
Executor. Strombefehle und Änderungen an Athenas SSH, LAN, WireGuard, Firewall,
|
||||
Boot, Kernel, Mounts und Partitionen werden serverseitig blockiert.
|
||||
|
||||
Für Git-Publishing besitzt Athena ein eigenes Schlüsselpaar unter
|
||||
`/etc/mike-ai/athena-operator-git{,.pub}`. Nur der öffentliche Schlüssel wird
|
||||
in Gitea als schreibberechtigter Deploy-Key für `AI-Profile-Router` hinterlegt.
|
||||
Der private Schlüssel verlässt Athena nicht und wird weder an den MCP-Container
|
||||
noch an das Modell ausgegeben.
|
||||
Der Gitea-Endpunkt ist als `ssh://...:33/...` konfiguriert; sein auf dem
|
||||
Administrator-Mac verifizierter Ed25519-Hostschlüssel ist in
|
||||
`config/athena-operator-known-hosts` fest gebunden. Ein unerwarteter
|
||||
Hostschlüsselwechsel stoppt Git-Zugriffe, statt ihn still zu akzeptieren.
|
||||
|
||||
TinySearch bleibt als Ganzes read-only. Nur das flüchtige tmpfs-Verzeichnis
|
||||
`/home/tinysearch/.crawl4ai` ist beschreibbar, weil Crawl4AI dort seinen
|
||||
temporären Browser- und Sitzungszustand erzeugt. Es wird bei jedem
|
||||
Container-Neustart vollständig verworfen.
|
||||
|
||||
TinySearch 0.6.1 ist der allgemeine portable Web-MCP. Auf VPN-Port 8203 können Hermes,
|
||||
Pi und andere Clients seine vier Upstream-Werkzeuge direkt nutzen. SearXNG ist
|
||||
der Such-Backenddienst. Die historische eigene Web-Fassade ist nur Rollback.
|
||||
|
||||
Die fünf Open-WebUI-Profile Fast, Medium, Large, Ultra und Uncensored halten für
|
||||
allgemeine öffentliche Recherche Open WebUIs native Werkzeuge `search_web` und
|
||||
`fetch_url` verfügbar. Neue Websites benötigen keine neue Selector-Regel.
|
||||
|
||||
Ein gemeinsamer Systemhinweis der fünf Profile verlangt Webprüfung bei
|
||||
aktuellen, veränderlichen oder wesentlich unsicheren Tatsachen. Stabiles
|
||||
Allgemeinwissen soll ohne unnötige Suche beantwortet werden. Die Anweisung
|
||||
fordert gezielte statt wiederholter Synonymsuchen, Quellenlinks, transparente
|
||||
Unsicherheit und behandelt Webseiteninhalte grundsätzlich als nicht
|
||||
vertrauenswürdige Daten statt als Anweisungen.
|
||||
|
||||
## Entscheidungshilfe für das Modell
|
||||
|
||||
Die Server- und Werkzeugbeschreibungen grenzen die Zuständigkeiten voneinander
|
||||
ab. Das Modell beginnt mit den breitesten geeigneten Grundfähigkeiten und nutzt
|
||||
Fach-MCPs dort, wo strukturierte Daten oder Aktionen benötigt werden:
|
||||
|
||||
| Aufgabe | Werkzeugserver | Nicht zusätzlich verwenden |
|
||||
|---|---|---|
|
||||
| Aktuelle öffentliche Informationen, Quellen, Hugging Face, Produkte | Web | HA, ARR, Unraid |
|
||||
| GitHub-Repository finden, README/Quellcode/API-Routen gezielt lesen | GitHub Repository | Web, HA, ARR |
|
||||
| Entitäten, Zustände, Historie, Automationen und Dashboards | Home Assistant | Web, Unraid |
|
||||
| Serien, Filme, fehlende Episoden und Indexer-Releases | Sonarr und Radarr | Web |
|
||||
| Persönliche Musikbibliothek, Titel, Alben, Künstler und Playlists | Navidrome | Web, ARR |
|
||||
| Lesende NAS-, Docker-, Array-, Netzwerk- und Logdiagnose | MUA · Unraid-Diagnose (read-only) | MUA-Verwaltung |
|
||||
| Athena-KI-Plattform entwickeln, testen, deployen, Modelle/Git/Recovery pflegen | Athena Operator | Platform Context für reine Architekturauskunft |
|
||||
| Ausdrücklich benötigte MUA-Verwaltungsaktion | MUA | Unraid-Diagnose nicht parallel |
|
||||
|
||||
Ein leeres Ergebnis ist kein Grund, dieselbe Frage über mehrere unpassende
|
||||
Werkzeuge oder leicht veränderte Suchbegriffe erneut auszuführen. Das Modell
|
||||
soll die Grenze transparent nennen und gezielt nachfragen, wenn eine Freigabe
|
||||
oder ein anderes Werkzeug benötigt wird.
|
||||
|
||||
## Sicherheitsmodell
|
||||
|
||||
- Kein MCP-Port wird auf der physischen Universitätsadresse veröffentlicht.
|
||||
Über Athenas WireGuard-Adresse sind die Fach-MCPs direkt auf den in
|
||||
`docs/VPN_SERVICE_PORTS.md` dokumentierten Ports erreichbar.
|
||||
- Nur Clients im privaten Docker-Netz `mike-ai-tools` erreichen die Endpunkte.
|
||||
- Secrets bleiben in Dateien unter `/etc/mike-ai` und werden read-only
|
||||
eingehängt. Sie gehören weder in Git noch in OpenWebUI-Tooldefinitionen.
|
||||
- Jeder Container ist read-only, verliert Linux-Capabilities und hat
|
||||
`no-new-privileges`.
|
||||
- Der SSH-basierte Unraid-Container ist nicht Teil des Standardstarts.
|
||||
- Das allgemeine Terminal ist Bestandteil des Athena Operators auf Port 8202;
|
||||
ein zweiter Shell-MCP ist nicht erforderlich.
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
sudo platform/mcp/install-tools.sh
|
||||
```
|
||||
|
||||
Der Grundstart enthält Plattformwissen, den Athena Operator und das allgemeine
|
||||
TinySearch-Webwerkzeug. Bereits konfigurierte Fachbereiche werden explizit
|
||||
ergänzt:
|
||||
|
||||
Das Skript erkennt vorhandene Secret-Dateien und aktiviert dadurch automatisch
|
||||
`homeassistant`, `arr`, `navidrome` und `github`. Ohne Fach-Secrets bleiben die
|
||||
secretfreien Grunddienste aktiv.
|
||||
|
||||
Für den derzeit migrierten Container kann der Name `Open-WebUI` lauten. Der
|
||||
Netzwerkbefehl ist idempotent zu behandeln.
|
||||
|
||||
Die lokale Installation benötigt die vorhandenen Secret-Dateien:
|
||||
|
||||
```text
|
||||
/etc/mike-ai/homeassistant-admin-mcp.env
|
||||
/etc/mike-ai/arr-mcp.env
|
||||
/etc/mike-ai/navidrome-mcp.env
|
||||
/etc/mike-ai/github-mcp.env
|
||||
/etc/mike-ai/mua-mcp.env
|
||||
```
|
||||
|
||||
`mua-mcp.env` enthält ausschließlich MUA-Endpunkt und Bearer-Token. Der
|
||||
Installer legt daraus die vollständige MUA-Verbindung und eine strikt auf
|
||||
Lesewerkzeuge begrenzte automatische Ansicht an. Die Datei ist root-only
|
||||
(Modus `0600`) und wird nur verschlüsselt im Recovery-Bundle gesichert.
|
||||
|
||||
Die erweiterte Unraid-Diagnose benötigt zusätzlich die Konfigurationsdatei,
|
||||
den eingeschränkten Schlüssel und die bekannte Hostsignatur. Sie wird nur mit
|
||||
`--profile extended` gestartet.
|
||||
|
||||
TinySearch speichert sein lokales Embedding-Modell in einem Docker-Volume.
|
||||
Nach einer Erstinstallation wird das Modell einmalig im Container mit
|
||||
`tinysearch setup` geladen. Das Volume bleibt bei Containerupdates erhalten.
|
||||
|
||||
## Navidrome
|
||||
|
||||
Der Navidrome-MCP basiert auf `Blakeem/Navidrome-MCP` 2.2.0; das amd64-Image
|
||||
ist per OCI-Digest festgeschrieben. Ein kleiner Build-Patch ergänzt bei zwei
|
||||
Internetradio-URL-Schemas das von llama.cpp verlangte abschließende `$`;
|
||||
Verhalten und API-Aufrufe bleiben unverändert. Der Container veröffentlicht keinen
|
||||
Host-Port, besitzt keinen Dateizugriff auf die Musikbibliothek und enthält
|
||||
bewusst kein `mpv`. Er kann daher nicht auf Athena selbst Musik wiedergeben.
|
||||
|
||||
Navidrome sollte einen eigenen normalen Benutzer `mcp` erhalten. Dessen
|
||||
Zugangsdaten liegen ausschließlich in der root-only Datei
|
||||
`/etc/mike-ai/navidrome-mcp.env`; die Vorlage steht unter
|
||||
`config/navidrome-mcp.env.example`. Anschließend genügt:
|
||||
|
||||
```bash
|
||||
sudo install -m 0600 config/navidrome-mcp.env.example /etc/mike-ai/navidrome-mcp.env
|
||||
sudoedit /etc/mike-ai/navidrome-mcp.env
|
||||
sudo platform/mcp/install-tools.sh
|
||||
sudo platform/openwebui/install-filters.sh
|
||||
```
|
||||
|
||||
Der Upstream-Server stellt ohne Playback noch immer über 40 Werkzeuge bereit.
|
||||
Darum wird Navidrome **nicht** als Standardwerkzeug an jedes Modellprofil
|
||||
gehängt. Es wird in OpenWebUI nur für konkrete Musikaufgaben ausgewählt und
|
||||
danach wieder ausgeschaltet. Schreibende Funktionen wie Playlist-Änderungen,
|
||||
Favoriten und Bewertungen wirken unmittelbar im Konto des MCP-Benutzers.
|
||||
|
||||
Optional aktiviert `LASTFM_API_KEY` in derselben Secret-Datei sieben öffentliche
|
||||
Empfehlungswerkzeuge für ähnliche Künstler/Titel, Trends und ergänzende
|
||||
Metadaten. Das Last.fm Shared Secret ist dafür nicht erforderlich und wird
|
||||
nicht gespeichert. Die Integration greift damit weder auf das persönliche
|
||||
Last.fm-Profil noch auf dessen Hörverlauf zu.
|
||||
|
||||
## GitHub
|
||||
|
||||
Der GitHub-Container verwendet unverändert den offiziellen
|
||||
`github/github-mcp-server` 1.10.1. Da dessen lokaler Container stdio spricht,
|
||||
wandelt `mcp-proxy` 0.12.0 ausschließlich den Transport in Streamable HTTP für
|
||||
Open WebUI und weitere interne Clients um. Basisimage und GitHub-Image sind per
|
||||
OCI-Digest festgeschrieben; die Brücke implementiert keine GitHub-Operationen.
|
||||
|
||||
`mcp-proxy` läuft bewusst **stateless**. Die zuerst getestete Supergateway-
|
||||
Brücke war mit Open WebUIs Python-MCP-Client nicht zuverlässig kompatibel: Im
|
||||
stateful Betrieb konnten Sitzungen ablaufen; im stateless Betrieb beantwortete
|
||||
sie die reguläre `notifications/initialized`-Nachricht mit HTTP 400. Beides
|
||||
führte trotz gesundem GitHub-Server und gültigem Token zu
|
||||
`Failed to connect to MCP server 'github-local'`. Der jetzt verwendete Proxy
|
||||
ist derselbe Transport, der sich bereits beim Athena Platform Context MCP
|
||||
bewährt hat.
|
||||
|
||||
Die Brücke wird mit `--pass-environment` gestartet. Ohne diese ausdrückliche
|
||||
Option sieht zwar der Proxy-Prozess den per Docker-Envfile injizierten PAT, der
|
||||
von ihm gestartete GitHub-stdio-Unterprozess jedoch nicht; der offizielle
|
||||
Server fällt dann irreführend auf die interaktive GitHub-Geräteanmeldung
|
||||
zurück. Der Token bleibt dabei eine Umgebungsvariable und erscheint weder in
|
||||
Kommandozeile noch Image, Log oder Open-WebUI-Konfiguration.
|
||||
|
||||
Dem Modell werden ausschließlich `search_repositories`, `get_file_contents`
|
||||
und `search_code` angeboten. Rekursive Komplettbäume wurden entfernt, nachdem
|
||||
ein einzelner Aufruf mehr als 100.000 Zeichen erzeugte und die Antwort
|
||||
verdrängte. Der offizielle Server wird
|
||||
zusätzlich explizit mit `--read-only` gestartet; die Umgebungsvariablen im
|
||||
Compose-Stack bleiben als zweite, deklarative Sicherung erhalten. Damit sind
|
||||
Schreiboperationen auch serverseitig ausgeschlossen. Der Container
|
||||
veröffentlicht keinen Host-Port und speichert den Token nicht in Open WebUI.
|
||||
|
||||
Einrichtung:
|
||||
|
||||
```bash
|
||||
sudo install -m 0600 config/github-mcp.env.example /etc/mike-ai/github-mcp.env
|
||||
sudoedit /etc/mike-ai/github-mcp.env
|
||||
sudo platform/mcp/install-tools.sh
|
||||
sudo platform/openwebui/install-filters.sh
|
||||
```
|
||||
|
||||
Der Token muss eigens für Athena erzeugt werden und ausschließlich lesenden
|
||||
Zugriff auf die tatsächlich benötigten Repositories erhalten. Die drei
|
||||
begrenzten Werkzeuge werden bei vorhandener Secret-Datei an die fünf
|
||||
MikeAI-Profile geheftet. Dadurch kann das Modell Repositoryfragen selbständig
|
||||
prüfen, ohne den großen GitHub-Standardwerkzeugkatalog in den Kontext zu laden.
|
||||
|
||||
## Client-Auswahl
|
||||
|
||||
Große Fachwerkzeuge werden nicht pauschal an jedes Modell gehängt. Nur die
|
||||
native OpenWebUI-Websuche und die drei GitHub-Lesewerkzeuge sind allgemein verfügbar.
|
||||
Für Home-Assistant-Fragen wird HA ausgewählt, für Medien ARR und für die NAS
|
||||
Unraid. Weitere Werkzeuge werden nur aktiviert, wenn die Aufgabe tatsächlich
|
||||
mehrere Bereiche verbindet.
|
||||
|
||||
Schreibende Aktionen bleiben hinter der jeweiligen serverseitigen Policy und
|
||||
einem Vorschau-/Bestätigungsablauf. Ein Client-Schalter allein darf niemals
|
||||
eine read-only Policy aufheben.
|
||||
|
||||
## Home Assistant: abgesicherter YAML-Zugang
|
||||
|
||||
Die Referenzinstallation überlagert im nativen `czechbol/hass-mcp` das Werkzeug
|
||||
`ha_yaml_config` mit
|
||||
`patches/hass_mcp/yaml_config.py`. Es erlaubt strukturierte Zugriffe nur auf
|
||||
`automations.yaml`, `scripts.yaml` und `scenes.yaml`. Für auskommentierte Blöcke
|
||||
stehen begrenztes `read_source` und `find_source` zur Verfügung;
|
||||
`configuration.yaml` darf dabei ebenfalls gelesen werden. Beliebige Pfade und
|
||||
`secrets.yaml` sind konstruktiv ausgeschlossen. Verdächtige Inline-Zugangsdaten
|
||||
und selbst Namen von `!secret`-Referenzen werden in Rohtextantworten maskiert.
|
||||
|
||||
Jede Änderung arbeitet zweistufig: Vorschau mit einmaligem Ticket, anschließend
|
||||
derselbe unveränderte Aufruf mit `confirm=true` nach ausdrücklicher Zustimmung.
|
||||
Vor dem atomaren Schreiben wird eine lokale Sicherung erzeugt. Danach läuft die
|
||||
vollständige Home-Assistant-Konfigurationsprüfung; bei Fehlern oder gescheitertem
|
||||
Reload wird automatisch zurückgerollt. `configuration.yaml` wird nicht live neu
|
||||
geladen und meldet deshalb nach einer erfolgreichen Änderung
|
||||
`restart_required=true`.
|
||||
|
||||
Installation auf dem Host, der das Home-Assistant-Konfigurationsverzeichnis
|
||||
besitzt:
|
||||
|
||||
```bash
|
||||
sudo platform/mcp/install-hass-mcp-yaml-guard.sh \
|
||||
/mnt/user/appdata/HomeAssistant/config
|
||||
```
|
||||
|
||||
Danach Home Assistant kontrolliert neu starten und den Werkzeugkatalog prüfen.
|
||||
Der Installer aktiviert **nicht** pauschal Schreibrechte: In Home Assistant unter
|
||||
**Einstellungen → Geräte & Dienste → Native MCP for Home Assistant → Konfigurieren**
|
||||
muss `Allow write tools` bewusst eingeschaltet werden. Das gibt auch anderen
|
||||
nicht-destruktiven Schreibwerkzeugen dieser Integration Zugriff und sollte daher
|
||||
nur zusammen mit sichtbarer Tool-Freigabe im Client aktiviert werden.
|
||||
|
||||
## Sonarr: sichere Episodensuche
|
||||
|
||||
Der lokale Sonarr-Patch stellt bewusst keine freie Sonarr-Command-API bereit.
|
||||
Der erlaubte Schreibablauf ist eng auf fehlende Episoden begrenzt:
|
||||
|
||||
1. `preview_episode_search` bekommt Serien-ID, Staffel und die exakten
|
||||
Episodennummern. Es liest Sonarr-Metadaten, entfernt bereits vorhandene
|
||||
Episoden und erzeugt eine konkrete Vorschau samt kurzlebigem Ticket.
|
||||
2. Der Client zeigt diese Vorschau unverändert an. Ohne ausdrückliche
|
||||
Benutzerfreigabe endet der Ablauf hier.
|
||||
3. `start_episode_search` akzeptiert nur denselben Umfang, `confirm=true` und
|
||||
das passende Ticket. Erst dann startet Sonarr eine `EpisodeSearch` über die
|
||||
dort konfigurierten Indexer.
|
||||
|
||||
`EpisodeSearch` ist keine bloße Ergebnisvorschau: Sonarr kann dabei sofort das
|
||||
beste akzeptierte Release pro Episode an den Download-Client übergeben. Das
|
||||
gilt auch für explizit ausgewählte, momentan nicht überwachte Episoden;
|
||||
Monitoring steuert vor allem die spätere automatische/RSS-Verarbeitung.
|
||||
|
||||
Der Ablauf ändert weder Serien- noch Staffel-Monitoring und erlaubt weder
|
||||
beliebige Commands noch direkte URL-Downloads. Tickets gelten zehn Minuten,
|
||||
sind einmalig und an genau die angezeigte Auswahl gebunden.
|
||||
|
||||
### Sonarr: ein konkretes Release oder Staffelpaket laden
|
||||
|
||||
Eine automatische Episodensuche ist **kein Ersatz** für die Auswahl eines
|
||||
bestimmten Releases. Wenn ein Benutzer etwa ausdrücklich ein FuN-Staffelpaket
|
||||
verlangt, gilt stattdessen dieser Ablauf:
|
||||
|
||||
1. `search_releases` sucht ausschließlich über Sonarrs konfigurierte Indexer.
|
||||
Für Gruppen- oder Staffelpaketfragen werden `release_group` und
|
||||
`season_pack_only=true` direkt gesetzt; vorhandene Bibliotheksdateien sind
|
||||
kein Beleg dafür, was aktuell auf den Indexern verfügbar ist.
|
||||
2. `preview_release_grab` bekommt Serien-ID, Staffel und die **exakte GUID** des
|
||||
ausgewählten Suchergebnisses. Sonarr wird erneut abgefragt; Titel, Größe,
|
||||
Indexer, Ablehnungsgründe und vorhandene Episodendateien werden angezeigt.
|
||||
3. Erst nach ausdrücklicher Freigabe darf `grab_release` mit demselben Umfang,
|
||||
`confirm=true` und dem kurzlebigen Ticket aufgerufen werden. Es übergibt
|
||||
exakt dieses Release an Sonarrs konfigurierten Download-Client.
|
||||
|
||||
Hat Sonarr das Release abgelehnt oder `downloadAllowed=false` gemeldet, muss
|
||||
bereits die Vorschau nach gesonderter Zustimmung `force=true` enthalten. Das
|
||||
Ticket ist auch daran gebunden. Der MCP löscht keine vorhandenen Dateien und
|
||||
verspricht keine Überschreibung: Ob eine vorhandene Episode nach dem Download
|
||||
ersetzt wird, entscheiden Sonarrs Qualitätsprofil-, Upgrade- und Importregeln.
|
||||
|
||||
## Deemix MCP
|
||||
|
||||
`mcp-deemix` steuert ausschließlich die bereits vorhandene Deemix-Instanz auf
|
||||
Unraid unter `192.168.1.2:6595`. Es installiert kein zweites Deemix. Die
|
||||
root-only Datei `/etc/mike-ai/deemix-mcp.env` aktiviert das Compose-Profil.
|
||||
Hermes erreicht den Dienst intern als `http://mcp-deemix:8000/mcp`, OpenWebUI
|
||||
als `http://mike-ai-mcp-deemix:8000/mcp`. Im Single-User-Modus übernimmt der
|
||||
MCP die in Deemix gespeicherte Anmeldung nur kurzzeitig im Arbeitsspeicher;
|
||||
Secrets werden nicht in Tool-Ausgaben zurückgegeben. Nach Änderungen immer
|
||||
Handshake, `deemix_status`, eine begrenzte Suche und eine unveränderte Queue
|
||||
prüfen. Reinstall: Env-Beispiel nach `/etc/mike-ai/deemix-mcp.env` kopieren,
|
||||
Modus `0600` setzen und `platform/mcp/install-tools.sh` ausführen.
|
||||
@@ -10,7 +10,7 @@ import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
VERSION = "3.0.0"
|
||||
VERSION = "3.1.0"
|
||||
SOCKET_PATH = os.environ.get("ATHENA_OPERATOR_SOCKET", "/operator/operator.sock")
|
||||
|
||||
if hasattr(sys.stdin, "reconfigure"):
|
||||
@@ -22,11 +22,11 @@ if hasattr(sys.stdout, "reconfigure"):
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "athena_operator_inspect",
|
||||
"description": "START HERE once for Athena work. Inspect the requested live area without changing it.",
|
||||
"description": "START HERE with subject=guide. Returns ATHENA.md or one compact live area without changing it.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subject": {"type": "string", "enum": ["overview", "git_status", "containers", "models", "jobs"], "default": "overview"},
|
||||
"subject": {"type": "string", "enum": ["guide", "overview", "git_status", "containers", "models", "jobs"], "default": "guide"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
@@ -82,7 +82,7 @@ TOOLS = [
|
||||
"description": (
|
||||
"Apply one durable change that the user has requested. Operations: patch_update, "
|
||||
"file_update, mcp_release, run_checks, compose_deploy, container_action, "
|
||||
"openwebui_sync, git_publish, model_download, benchmark, recovery. Use a small "
|
||||
"openwebui_sync, git_publish, model_download, benchmark, backup. Use a small "
|
||||
"patch for edits and file_update only for a new or intentionally replaced file. "
|
||||
"mcp_release can perform the complete MCP build/test/deploy/publish workflow."
|
||||
),
|
||||
@@ -91,7 +91,7 @@ TOOLS = [
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["patch_update", "file_update", "mcp_release", "run_checks", "compose_deploy", "container_action", "openwebui_sync", "git_publish", "model_download", "benchmark", "recovery"],
|
||||
"enum": ["patch_update", "file_update", "mcp_release", "run_checks", "compose_deploy", "container_action", "openwebui_sync", "git_publish", "model_download", "benchmark", "backup"],
|
||||
},
|
||||
"payload": {"type": "object"},
|
||||
},
|
||||
|
||||
+10
-46
@@ -1,5 +1,3 @@
|
||||
name: mike-ai-tools
|
||||
|
||||
x-tool-common: &tool-common
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
@@ -28,7 +26,7 @@ services:
|
||||
# needs both the private tool network and the explicitly separated egress
|
||||
# network; keeping it on `tools` only makes search discovery work while
|
||||
# every page fetch fails.
|
||||
networks: [tools, egress]
|
||||
networks: [tools, tools-egress]
|
||||
dns: ["${AI_DNS:-1.1.1.1}"]
|
||||
environment:
|
||||
TINYSEARCH_MCP_URL: http://tinysearch:8000/mcp
|
||||
@@ -52,7 +50,7 @@ services:
|
||||
dns: ["${AI_DNS:-1.1.1.1}"]
|
||||
volumes:
|
||||
- ${SEARXNG_SETTINGS_FILE:-../web-search/searxng-settings.example.yml}:/etc/searxng/settings.yml:ro
|
||||
networks: [tools, egress]
|
||||
networks: [tools, tools-egress]
|
||||
|
||||
tinysearch:
|
||||
<<: *tool-common
|
||||
@@ -80,7 +78,7 @@ services:
|
||||
SEARXNG_URL: http://searxng:8080/search
|
||||
depends_on: [searxng]
|
||||
cap_add: [SETUID, SETGID, CHOWN]
|
||||
networks: [tools, egress]
|
||||
networks: [tools, tools-egress]
|
||||
# The image's built-in `tinysearch doctor` also requires a writable
|
||||
# configuration directory, although normal server operation does not.
|
||||
# Check the service socket instead so read-only hardening remains intact.
|
||||
@@ -108,7 +106,7 @@ services:
|
||||
volumes:
|
||||
- ${HA_ENV_FILE:-/etc/mike-ai/homeassistant-admin-mcp.env}:/run/secrets/homeassistant.env:ro
|
||||
cap_add: [CHOWN, SETUID, SETGID]
|
||||
networks: [tools, egress]
|
||||
networks: [tools, tools-egress]
|
||||
|
||||
mcp-arr:
|
||||
<<: *tool-common
|
||||
@@ -127,7 +125,7 @@ services:
|
||||
# Upstream's generic "Execute any Radarr API action" text gives small
|
||||
# models no routing boundary. This overlay changes guidance only.
|
||||
- ${ARR_RADARR_PATCH:-./patches/mcp_radarr.py}:/usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_radarr.py:ro
|
||||
networks: [tools, egress]
|
||||
networks: [tools, tools-egress]
|
||||
|
||||
mcp-navidrome:
|
||||
<<: *tool-common
|
||||
@@ -153,7 +151,7 @@ services:
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=64m
|
||||
- /config:rw,noexec,nosuid,nodev,size=4m,mode=0700
|
||||
networks: [tools, egress]
|
||||
networks: [tools, tools-egress]
|
||||
|
||||
mcp-deemix:
|
||||
<<: *tool-common
|
||||
@@ -167,30 +165,7 @@ services:
|
||||
- ${DEEMIX_MCP_ENV_FILE:-/etc/mike-ai/deemix-mcp.env}
|
||||
environment:
|
||||
PORT: "8000"
|
||||
networks: [tools, egress]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
mcp-platform-context:
|
||||
<<: *tool-common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.platform-context
|
||||
image: mike-ai/mcp-platform-context:2.0.0
|
||||
container_name: mike-ai-mcp-platform-context
|
||||
environment:
|
||||
ATHENA_REPO_ROOT: /knowledge/repo
|
||||
ATHENA_RUNTIME_FILE: /runtime/runtime.json
|
||||
volumes:
|
||||
- ${PLATFORM_STACK_DIR:-/opt/mike-ai/stack}:/knowledge/repo:ro
|
||||
- ${PLATFORM_CONTEXT_RUNTIME_DIR:-/var/lib/mike-ai-platform-context}:/runtime:ro
|
||||
# Documentation is strictly read-only. Runtime inspection and all changes
|
||||
# belong to the Athena Operator instead of a second maintenance workflow.
|
||||
networks: [tools]
|
||||
networks: [tools, tools-egress]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
|
||||
interval: 30s
|
||||
@@ -203,7 +178,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.athena-operator
|
||||
image: mike-ai/mcp-athena-operator:3.0.0
|
||||
image: mike-ai/mcp-athena-operator:3.1.0
|
||||
container_name: mike-ai-mcp-athena-operator
|
||||
environment:
|
||||
ATHENA_OPERATOR_SOCKET: /operator/operator.sock
|
||||
@@ -235,7 +210,7 @@ services:
|
||||
# for broader toolsets. The token itself must also remain read-only.
|
||||
GITHUB_TOOLS: search_repositories,get_file_contents,search_code
|
||||
GITHUB_READ_ONLY: "1"
|
||||
networks: [tools, egress]
|
||||
networks: [tools, tools-egress]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
|
||||
interval: 30s
|
||||
@@ -259,18 +234,7 @@ services:
|
||||
- ${UNRAID_SSH_KEY:-/etc/mike-ai/keys/unraid_root}:/etc/mike-ai/keys/unraid_root:ro
|
||||
- ${UNRAID_KNOWN_HOSTS:-/etc/mike-ai/ssh/known_hosts_unraid_ai}:/etc/mike-ai/ssh/known_hosts_unraid_ai:ro
|
||||
- unraid-audit:/var/log/mike-ai
|
||||
networks: [tools, egress]
|
||||
|
||||
networks:
|
||||
tools:
|
||||
name: mike-ai-tools
|
||||
internal: true
|
||||
ipam:
|
||||
config: [{subnet: 172.30.40.0/24}]
|
||||
egress:
|
||||
name: mike-ai-tools-egress
|
||||
ipam:
|
||||
config: [{subnet: 172.30.50.0/24}]
|
||||
networks: [tools, tools-egress]
|
||||
|
||||
volumes:
|
||||
tinysearch-models:
|
||||
|
||||
@@ -4,12 +4,13 @@ set -Eeuo pipefail
|
||||
[[ $EUID -eq 0 ]] || { echo "Bitte als root ausführen." >&2; exit 1; }
|
||||
|
||||
MCP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
STACK_DIR="$(cd "$MCP_DIR/../.." && pwd)"
|
||||
STACK_ENV=${STACK_ENV:-/etc/mike-ai/stack.env}
|
||||
COMPOSE=(docker compose)
|
||||
if [[ -s $STACK_ENV ]]; then
|
||||
COMPOSE+=(--env-file "$STACK_ENV")
|
||||
fi
|
||||
COMPOSE+=(-f "$MCP_DIR/compose.yaml")
|
||||
COMPOSE+=(-f "$STACK_DIR/compose.yaml")
|
||||
export SEARXNG_SETTINGS_FILE="${SEARXNG_SETTINGS_FILE:-$MCP_DIR/../web-search/searxng-settings.yml}"
|
||||
|
||||
[[ -s $SEARXNG_SETTINGS_FILE ]] || {
|
||||
@@ -17,48 +18,44 @@ export SEARXNG_SETTINGS_FILE="${SEARXNG_SETTINGS_FILE:-$MCP_DIR/../web-search/se
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The read-only platform context MCP never receives the Docker socket. A
|
||||
# root-owned timer writes a bounded metadata snapshot instead.
|
||||
install -d -m 0755 /usr/local/libexec /var/lib/mike-ai-platform-context
|
||||
install -m 0755 "$MCP_DIR/platform-context-snapshot.py" \
|
||||
/usr/local/libexec/mike-ai-platform-context-snapshot
|
||||
install -m 0644 "$MCP_DIR/../systemd/mike-ai-platform-context-snapshot.service" \
|
||||
/etc/systemd/system/mike-ai-platform-context-snapshot.service
|
||||
install -m 0644 "$MCP_DIR/../systemd/mike-ai-platform-context-snapshot.timer" \
|
||||
/etc/systemd/system/mike-ai-platform-context-snapshot.timer
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now mike-ai-platform-context-snapshot.timer
|
||||
systemctl start mike-ai-platform-context-snapshot.service
|
||||
docker network inspect mike-ai-tools >/dev/null 2>&1 || \
|
||||
docker network create --internal --subnet 172.30.40.0/24 mike-ai-tools >/dev/null
|
||||
docker network inspect mike-ai-tools-egress >/dev/null 2>&1 || \
|
||||
docker network create --subnet 172.30.50.0/24 mike-ai-tools-egress >/dev/null
|
||||
|
||||
# One user-facing Athena Operator MCP controls the local AI platform through a
|
||||
# root-side executor. The facade exposes six bounded tools and no Docker socket
|
||||
# or host paths to its unprivileged container.
|
||||
# One administrative MCP exposes ATHENA.md plus the bounded host operator.
|
||||
"$MCP_DIR/../operator/install-operator.sh"
|
||||
|
||||
profiles=()
|
||||
services=(searxng tinysearch mcp-athena-operator)
|
||||
if [[ -s /etc/mike-ai/homeassistant-admin-mcp.env ]]; then
|
||||
profiles+=(--profile homeassistant)
|
||||
services+=(mcp-homeassistant)
|
||||
else
|
||||
echo "Home Assistant bleibt aus: Secret-Datei fehlt."
|
||||
fi
|
||||
if [[ -s /etc/mike-ai/arr-mcp.env ]]; then
|
||||
profiles+=(--profile arr)
|
||||
services+=(mcp-arr)
|
||||
else
|
||||
echo "ARR bleibt aus: Secret-Datei fehlt."
|
||||
fi
|
||||
if [[ -s /etc/mike-ai/navidrome-mcp.env ]]; then
|
||||
profiles+=(--profile navidrome)
|
||||
services+=(mcp-navidrome)
|
||||
else
|
||||
echo "Navidrome bleibt aus: Secret-Datei fehlt."
|
||||
fi
|
||||
if [[ -s /etc/mike-ai/deemix-mcp.env ]]; then
|
||||
profiles+=(--profile deemix)
|
||||
services+=(mcp-deemix)
|
||||
else
|
||||
echo "Deemix MCP bleibt aus: Konfigurationsdatei fehlt."
|
||||
fi
|
||||
if [[ -s /etc/mike-ai/github-mcp.env ]] && \
|
||||
grep -Eq '^GITHUB_PERSONAL_ACCESS_TOKEN=.+$' /etc/mike-ai/github-mcp.env; then
|
||||
profiles+=(--profile github)
|
||||
services+=(mcp-github)
|
||||
else
|
||||
echo "GitHub bleibt aus: dedizierter Read-only-Token fehlt."
|
||||
fi
|
||||
@@ -81,7 +78,7 @@ if ! docker run --rm --entrypoint test \
|
||||
-c 'from tinysearch.services.onnx_bundle_service import ensure_onnx_bundle_sync; ensure_onnx_bundle_sync("fast")'
|
||||
fi
|
||||
|
||||
"${COMPOSE[@]}" "${profiles[@]}" up -d --build
|
||||
"${COMPOSE[@]}" "${profiles[@]}" up -d --build "${services[@]}"
|
||||
|
||||
for webui in mike-ai-open-webui Open-WebUI; do
|
||||
if docker container inspect "$webui" >/dev/null 2>&1; then
|
||||
|
||||
@@ -1,38 +1,150 @@
|
||||
"""Radarr action-routed MCP tool with model-oriented routing guidance."""
|
||||
"""Small, model-oriented Radarr tools built on the upstream API client."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from agent_utilities.mcp_utilities import dispatch, run_blocking
|
||||
from agent_utilities.mcp_utilities import dispatch, public_actions, run_blocking
|
||||
from fastmcp import FastMCP
|
||||
from pydantic import Field
|
||||
|
||||
from arr_mcp.auth import get_radarr_client
|
||||
|
||||
|
||||
BLOCKED_ACTIONS = {
|
||||
"request", "get_", "get_api", "get_content_path", "get_path",
|
||||
"get_login", "get_logout", "post_login", "post_system_restart",
|
||||
"post_system_shutdown",
|
||||
}
|
||||
|
||||
|
||||
def _safe_actions(client: Any) -> list[str]:
|
||||
"""Hide transport, authentication and service-power implementation methods."""
|
||||
return [name for name in public_actions(client) if name not in BLOCKED_ACTIONS]
|
||||
|
||||
|
||||
def _codec_aliases(value: str) -> set[str]:
|
||||
aliases = {
|
||||
"h264": {"h264", "x264", "avc"},
|
||||
"x264": {"h264", "x264", "avc"},
|
||||
"avc": {"h264", "x264", "avc"},
|
||||
"h265": {"h265", "x265", "hevc"},
|
||||
"x265": {"h265", "x265", "hevc"},
|
||||
"hevc": {"h265", "x265", "hevc"},
|
||||
}
|
||||
requested: set[str] = set()
|
||||
for item in value.split(","):
|
||||
key = item.strip().casefold()
|
||||
if key:
|
||||
requested.update(aliases.get(key, {key}))
|
||||
return requested
|
||||
|
||||
|
||||
def _compact_inventory(
|
||||
movies: list[dict[str, Any]], *, codecs: str = "", query: str = "",
|
||||
offset: int = 0, limit: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
wanted = _codec_aliases(codecs)
|
||||
needle = query.strip().casefold()
|
||||
rows: list[dict[str, Any]] = []
|
||||
for movie in movies:
|
||||
movie_file = movie.get("movieFile") or {}
|
||||
if not movie.get("hasFile") or not movie_file:
|
||||
continue
|
||||
media = movie_file.get("mediaInfo") or {}
|
||||
codec = str(media.get("videoCodec") or "unknown")
|
||||
if wanted and codec.casefold() not in wanted:
|
||||
continue
|
||||
title = str(movie.get("title") or "")
|
||||
if needle and needle not in title.casefold():
|
||||
continue
|
||||
quality = movie_file.get("quality") or {}
|
||||
quality_name = (quality.get("quality") or {}).get("name") if isinstance(quality, dict) else None
|
||||
size = int(movie_file.get("size") or 0)
|
||||
rows.append({
|
||||
"radarrId": movie.get("id"),
|
||||
"title": title,
|
||||
"year": movie.get("year"),
|
||||
"movieFileId": movie_file.get("id"),
|
||||
"relativePath": movie_file.get("relativePath"),
|
||||
"sizeBytes": size,
|
||||
"sizeGiB": round(size / 1073741824, 2),
|
||||
"quality": quality_name,
|
||||
"resolution": media.get("resolution"),
|
||||
"videoCodec": codec,
|
||||
"videoBitDepth": media.get("videoBitDepth"),
|
||||
"audioCodec": media.get("audioCodec"),
|
||||
"audioLanguages": media.get("audioLanguages"),
|
||||
"subtitles": media.get("subtitles"),
|
||||
})
|
||||
rows.sort(key=lambda row: (str(row["title"]).casefold(), row.get("year") or 0))
|
||||
total = len(rows)
|
||||
start = max(0, int(offset))
|
||||
count = min(500, max(1, int(limit)))
|
||||
selected = rows[start:start + count]
|
||||
return {
|
||||
"totalMatched": total,
|
||||
"offset": start,
|
||||
"returned": len(selected),
|
||||
"hasMore": start + len(selected) < total,
|
||||
"movies": selected,
|
||||
}
|
||||
|
||||
|
||||
def register_radarr_tools(mcp: FastMCP) -> None:
|
||||
@mcp.tool(tags={"radarr"})
|
||||
async def radarr_movie_codec_inventory(
|
||||
video_codecs: str = Field(
|
||||
default="",
|
||||
description="Optional comma-separated filter, e.g. h264, x264, h265, x265 or hevc.",
|
||||
),
|
||||
query: str = Field(default="", description="Optional case-insensitive title fragment."),
|
||||
offset: int = Field(default=0, ge=0),
|
||||
limit: int = Field(default=200, ge=1, le=500),
|
||||
) -> Any:
|
||||
"""Compact authoritative Radarr movie-file inventory. Use for codec, resolution, language and size questions instead of get_movie, raw API requests or filesystem scans. Results are valid bounded JSON without alternate titles, images, overviews or ratings."""
|
||||
client = get_radarr_client()
|
||||
response = await run_blocking(client.get_movie)
|
||||
movies = response.get("result", response) if isinstance(response, dict) else response
|
||||
if not isinstance(movies, list):
|
||||
raise RuntimeError("Radarr get_movie returned an unexpected response")
|
||||
return _compact_inventory(
|
||||
movies, codecs=video_codecs, query=query, offset=offset, limit=limit,
|
||||
)
|
||||
|
||||
@mcp.tool(tags={"radarr"})
|
||||
async def radarr_action(
|
||||
action: str = Field(
|
||||
description=(
|
||||
"Choose one Radarr operation. Common read choices include get_movie for the "
|
||||
"movie library and get_system_status/get_health for Radarr diagnostics. Use "
|
||||
"list_actions only when an unusual Radarr operation is genuinely required. "
|
||||
"Never guess a modifying action and never change Radarr without explicit user approval."
|
||||
"A named Radarr API operation. Prefer radarr_movie_codec_inventory for "
|
||||
"library/file/codec questions. Use list_actions once for unusual operations. "
|
||||
"Raw request, authentication and Radarr restart/shutdown methods are unavailable."
|
||||
)
|
||||
),
|
||||
params_json: str = Field(
|
||||
default="{}",
|
||||
description=(
|
||||
"JSON object encoded as a string containing only parameters required by the "
|
||||
"selected Radarr action. Use \"{}\" for actions without parameters; never "
|
||||
"invent movie IDs, paths, profile IDs or monitoring settings."
|
||||
),
|
||||
description="JSON object string with only the selected action's required parameters.",
|
||||
),
|
||||
) -> Any:
|
||||
"""USE ONLY for movies managed by Radarr: inspect the movie library, wanted/queue/history state, releases, profiles, or Radarr health. DO NOT use for TV episodes (use Sonarr), public-web research, media playback, filesystem copying, or direct downloads. Prefer read actions; any mutation requires explicit user approval."""
|
||||
"""Other Radarr operations for movies, queue, history, releases, profiles and health. TV episodes belong to Sonarr. Mutations require an explicit user request."""
|
||||
client = get_radarr_client()
|
||||
kwargs = {k: v for k, v in json.loads(params_json).items() if v is not None}
|
||||
actions = _safe_actions(client)
|
||||
if action in {"list_actions", "actions", "help", "capabilities"}:
|
||||
return {"service": "arr-radarr", "actions": actions}
|
||||
if action not in actions:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "unknown or unavailable Radarr action",
|
||||
"action": action,
|
||||
"retry": False,
|
||||
"hint": "Use list_actions once or radarr_movie_codec_inventory for file/codec questions.",
|
||||
}
|
||||
try:
|
||||
parsed = json.loads(params_json)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"params_json is not valid JSON: {exc.msg}") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("params_json must encode a JSON object")
|
||||
kwargs = {key: value for key, value in parsed.items() if value is not None}
|
||||
return await run_blocking(
|
||||
dispatch, client, action, kwargs, service="arr-radarr"
|
||||
)
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a bounded, payload-free Athena runtime snapshot for the context MCP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
OUTPUT = pathlib.Path("/var/lib/mike-ai-platform-context/runtime.json")
|
||||
STACK = pathlib.Path("/opt/mike-ai/stack")
|
||||
|
||||
|
||||
def command(*args: str) -> str:
|
||||
try:
|
||||
return subprocess.run(args, check=True, text=True, capture_output=True, timeout=15).stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
|
||||
|
||||
def docs_hash() -> str | None:
|
||||
digest = hashlib.sha256()
|
||||
files = sorted((STACK / "docs").glob("*.md"))
|
||||
if not files:
|
||||
return None
|
||||
for path in files:
|
||||
digest.update(path.name.encode())
|
||||
digest.update(b"\0")
|
||||
digest.update(path.read_bytes())
|
||||
digest.update(b"\0")
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def containers() -> list[dict[str, str]]:
|
||||
raw = command("docker", "ps", "--filter", "name=mike-ai-", "--format", "{{.Names}}|{{.Image}}|{{.Status}}")
|
||||
result = []
|
||||
for line in raw.splitlines():
|
||||
fields = line.split("|", 2)
|
||||
if len(fields) == 3:
|
||||
result.append({"name": fields[0], "image": fields[1], "status": fields[2]})
|
||||
return sorted(result, key=lambda item: item["name"])
|
||||
|
||||
|
||||
def gpus() -> list[dict[str, object]]:
|
||||
raw = command("nvidia-smi", "--query-gpu=uuid,name,memory.total,memory.used,driver_version", "--format=csv,noheader,nounits")
|
||||
result = []
|
||||
for line in raw.splitlines():
|
||||
fields = [field.strip() for field in line.split(",")]
|
||||
if len(fields) == 5:
|
||||
result.append({"uuid": fields[0], "name": fields[1], "memory_total_mib": int(fields[2]), "memory_used_mib": int(fields[3]), "driver": fields[4]})
|
||||
return result
|
||||
|
||||
|
||||
def filesystems() -> list[dict[str, object]]:
|
||||
raw = command("df", "-B1", "--output=target,fstype,size,used,avail,pcent", "/", "/data")
|
||||
result = []
|
||||
for line in raw.splitlines()[1:]:
|
||||
fields = line.split()
|
||||
if len(fields) == 6:
|
||||
result.append({"mount": fields[0], "fstype": fields[1], "size_bytes": int(fields[2]), "used_bytes": int(fields[3]), "available_bytes": int(fields[4]), "used_percent": fields[5]})
|
||||
return result
|
||||
|
||||
|
||||
def recovery_status() -> dict[str, object]:
|
||||
link = pathlib.Path("/data/mike-ai-recovery-kit")
|
||||
if not link.exists():
|
||||
return {"present": False}
|
||||
target = link.resolve()
|
||||
checksums = target / "SHA256SUMS"
|
||||
return {"present": True, "target": str(target), "modified_unix": int(target.stat().st_mtime), "checksums_present": checksums.is_file()}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
generated = int(time.time())
|
||||
active = [item["name"].removeprefix("mike-ai-llama-") for item in containers() if item["name"].startswith("mike-ai-llama-")]
|
||||
git_commit = command("git", "-C", str(STACK), "rev-parse", "HEAD")
|
||||
commit_file = STACK / ".mike-ai-source-commit"
|
||||
data = {
|
||||
"generated_unix": generated,
|
||||
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(generated)),
|
||||
"hostname": command("hostname"),
|
||||
"os_release": command("sh", "-c", ". /etc/os-release && printf '%s %s' \"$ID\" \"$VERSION_ID\""),
|
||||
"kernel": command("uname", "-r"),
|
||||
"uptime_seconds": float(pathlib.Path("/proc/uptime").read_text().split()[0]),
|
||||
"memory": {"summary": command("free", "-b", "--si").splitlines()[1] if command("free", "-b", "--si") else ""},
|
||||
"filesystems": filesystems(),
|
||||
"gpus": gpus(),
|
||||
"containers": containers(),
|
||||
"active_inference_profiles": active,
|
||||
"source_commit": git_commit or (commit_file.read_text().strip() if commit_file.is_file() else None),
|
||||
"documentation_tree_sha256": docs_hash(),
|
||||
"recovery_kit": recovery_status(),
|
||||
"privacy_scope": "No logs, prompts, chats, container environment values, file contents outside versioned docs, or secrets are collected.",
|
||||
}
|
||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".runtime.", dir=OUTPUT.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
os.chmod(temporary, 0o644)
|
||||
os.replace(temporary, OUTPUT)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,265 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small read-only Athena knowledge MCP.
|
||||
|
||||
The normal entry point is ATHENA.md. Large historical documentation remains
|
||||
available through bounded search/read tools but is never loaded automatically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
VERSION = "2.0.0"
|
||||
REPO_ROOT = Path(os.environ.get("ATHENA_REPO_ROOT", "/knowledge/repo")).resolve()
|
||||
RUNTIME_FILE = Path(os.environ.get("ATHENA_RUNTIME_FILE", "/runtime/runtime.json"))
|
||||
MAX_OVERVIEW_CHARS = 14_000
|
||||
MAX_READ_LINES = 160
|
||||
MAX_SEARCH_RESULTS = 8
|
||||
ALLOWED_SUFFIXES = {".md", ".json", ".yaml", ".yml", ".txt"}
|
||||
BLOCKED_PARTS = {".git", "secrets", "private", "credentials"}
|
||||
|
||||
if hasattr(sys.stdin, "reconfigure"):
|
||||
sys.stdin.reconfigure(encoding="utf-8", errors="replace")
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "athena_get_overview",
|
||||
"description": (
|
||||
"START HERE for Athena architecture or administration. Returns the compact, "
|
||||
"authoritative ATHENA.md. Do not read additional platform documents unless a "
|
||||
"specific unresolved question remains."
|
||||
),
|
||||
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"name": "athena_get_current_state",
|
||||
"description": "Return the compact generated runtime snapshot: active profile, containers, GPUs, source commit and recovery status.",
|
||||
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"name": "athena_get_external_services",
|
||||
"description": "List known services outside Athena so an existing Unraid or home-network backend is reused instead of duplicated.",
|
||||
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"name": "athena_search_reference",
|
||||
"description": (
|
||||
"Search ATHENA.md and documentation for one concrete term. Returns at most eight "
|
||||
"short excerpts. Use only when ATHENA.md did not answer the question."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string", "minLength": 2, "maxLength": 120}},
|
||||
"required": ["query"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "athena_read_reference",
|
||||
"description": (
|
||||
"Read a bounded line range from one known documentation file. Missing paths are "
|
||||
"reported as a normal not-found result and must not be retried by guessing."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "pattern": "^[A-Za-z0-9_.+/-]{1,200}$"},
|
||||
"start_line": {"type": "integer", "minimum": 1, "maximum": 1000000, "default": 1},
|
||||
"line_count": {"type": "integer", "minimum": 1, "maximum": MAX_READ_LINES, "default": 80},
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def result_error(message: str, **details: Any) -> dict[str, Any]:
|
||||
return {"ok": False, "error": message, "retry": False, **details}
|
||||
|
||||
|
||||
def safe_path(relative: str) -> Path | None:
|
||||
if not relative or relative.startswith("/"):
|
||||
return None
|
||||
candidate = Path(relative)
|
||||
if ".." in candidate.parts or any(part.lower() in BLOCKED_PARTS for part in candidate.parts):
|
||||
return None
|
||||
target = (REPO_ROOT / candidate).resolve(strict=False)
|
||||
try:
|
||||
target.relative_to(REPO_ROOT)
|
||||
except ValueError:
|
||||
return None
|
||||
if candidate.name != "ATHENA.md" and (not candidate.parts or candidate.parts[0] != "docs"):
|
||||
return None
|
||||
if target.suffix.lower() not in ALLOWED_SUFFIXES:
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
def read_text(path: Path, limit: int | None = None) -> str:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
return text if limit is None else text[:limit]
|
||||
|
||||
|
||||
def overview() -> dict[str, Any]:
|
||||
path = REPO_ROOT / "ATHENA.md"
|
||||
try:
|
||||
content = read_text(path, MAX_OVERVIEW_CHARS)
|
||||
except (OSError, PermissionError) as exc:
|
||||
return result_error("ATHENA.md is unavailable", path="ATHENA.md", detail=str(exc))
|
||||
return {"ok": True, "source": "ATHENA.md", "content": content, "truncated": path.stat().st_size > len(content.encode())}
|
||||
|
||||
|
||||
def current_state() -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(RUNTIME_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
return result_error("runtime snapshot is unavailable", detail=str(exc))
|
||||
containers = value.get("containers") or []
|
||||
return {
|
||||
"ok": True,
|
||||
"generated_at": value.get("generated_at"),
|
||||
"hostname": value.get("hostname"),
|
||||
"active_inference_profiles": value.get("active_inference_profiles") or [],
|
||||
"source_commit": value.get("source_commit"),
|
||||
"gpus": value.get("gpus") or [],
|
||||
"containers": containers,
|
||||
"container_count": len(containers),
|
||||
"recovery_kit": value.get("recovery_kit") or {"present": False},
|
||||
}
|
||||
|
||||
|
||||
def external_services() -> dict[str, Any]:
|
||||
path = REPO_ROOT / "config/service-catalog.json"
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
return result_error("service catalog is unavailable", detail=str(exc))
|
||||
services = []
|
||||
for item in value.get("services", []):
|
||||
services.append({key: item.get(key) for key in ("id", "name", "host", "address", "port", "protocol", "purpose") if item.get(key) is not None})
|
||||
return {"ok": True, "services": services, "count": len(services)}
|
||||
|
||||
|
||||
def reference_files() -> list[Path]:
|
||||
files = [REPO_ROOT / "ATHENA.md"]
|
||||
docs = REPO_ROOT / "docs"
|
||||
try:
|
||||
files.extend(sorted(path for path in docs.glob("*.md") if path.is_file()))
|
||||
except OSError:
|
||||
pass
|
||||
return files
|
||||
|
||||
|
||||
def search_reference(arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
query = str(arguments.get("query", "")).strip()
|
||||
if len(query) < 2:
|
||||
return result_error("query must contain at least two characters")
|
||||
pattern = re.compile(re.escape(query), re.IGNORECASE)
|
||||
matches: list[dict[str, Any]] = []
|
||||
for path in reference_files():
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
continue
|
||||
for number, line in enumerate(lines, 1):
|
||||
if pattern.search(line):
|
||||
matches.append({
|
||||
"path": str(path.relative_to(REPO_ROOT)),
|
||||
"line": number,
|
||||
"excerpt": line.strip()[:280],
|
||||
})
|
||||
if len(matches) >= MAX_SEARCH_RESULTS:
|
||||
return {"ok": True, "query": query, "matches": matches, "truncated": True}
|
||||
return {"ok": True, "query": query, "matches": matches, "truncated": False}
|
||||
|
||||
|
||||
def read_reference(arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
relative = str(arguments.get("path", ""))
|
||||
path = safe_path(relative)
|
||||
if path is None:
|
||||
return result_error("path is not an allowed documentation path", path=relative)
|
||||
if not path.is_file():
|
||||
return result_error("documentation file not found", path=relative)
|
||||
start = max(1, int(arguments.get("start_line", 1)))
|
||||
count = min(MAX_READ_LINES, max(1, int(arguments.get("line_count", 80))))
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError as exc:
|
||||
return result_error("documentation file is unreadable", path=relative, detail=str(exc))
|
||||
selected = lines[start - 1:start - 1 + count]
|
||||
return {
|
||||
"ok": True,
|
||||
"path": relative,
|
||||
"start_line": start,
|
||||
"end_line": start + len(selected) - 1 if selected else start - 1,
|
||||
"total_lines": len(lines),
|
||||
"content": "\n".join(selected),
|
||||
"truncated": start - 1 + len(selected) < len(lines),
|
||||
}
|
||||
|
||||
|
||||
def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
if name == "athena_get_overview":
|
||||
return overview()
|
||||
if name == "athena_get_current_state":
|
||||
return current_state()
|
||||
if name == "athena_get_external_services":
|
||||
return external_services()
|
||||
if name == "athena_search_reference":
|
||||
return search_reference(arguments)
|
||||
if name == "athena_read_reference":
|
||||
return read_reference(arguments)
|
||||
return result_error("unknown tool", tool=name)
|
||||
|
||||
|
||||
def emit(request_id: Any, result: Any = None, error: dict[str, Any] | None = None) -> None:
|
||||
message = {"jsonrpc": "2.0", "id": request_id}
|
||||
message["error" if error else "result"] = error or result
|
||||
sys.stdout.write(json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def handle(message: dict[str, Any]) -> None:
|
||||
method, request_id = message.get("method"), message.get("id")
|
||||
if method == "initialize":
|
||||
emit(request_id, {
|
||||
"protocolVersion": message.get("params", {}).get("protocolVersion", "2024-11-05"),
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
"serverInfo": {"name": "mike-ai-platform-context", "version": VERSION},
|
||||
})
|
||||
elif method == "tools/list":
|
||||
emit(request_id, {"tools": TOOLS})
|
||||
elif method == "tools/call":
|
||||
params = message.get("params") or {}
|
||||
value = call_tool(str(params.get("name", "")), params.get("arguments") or {})
|
||||
emit(request_id, {
|
||||
"content": [{"type": "text", "text": json.dumps(value, ensure_ascii=False, separators=(",", ":"))}],
|
||||
"structuredContent": value,
|
||||
"isError": False,
|
||||
})
|
||||
elif request_id is not None:
|
||||
emit(request_id, error={"code": -32601, "message": "method not found"})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
if line.strip():
|
||||
handle(json.loads(line))
|
||||
except Exception as exc:
|
||||
sys.stderr.write(f"MCP input error: {exc}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Hermes and OpenWebUI MCP registrations from one JSON registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
|
||||
BEGIN = "# BEGIN MANAGED MCP SERVERS"
|
||||
END = "# END MANAGED MCP SERVERS"
|
||||
|
||||
|
||||
def env_file(path: str) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
source = pathlib.Path(path)
|
||||
if not source.is_file():
|
||||
return values
|
||||
for raw in source.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||
return values
|
||||
|
||||
|
||||
def enabled(item: dict) -> bool:
|
||||
required = item.get("required_file")
|
||||
if required and not pathlib.Path(required).is_file():
|
||||
return False
|
||||
source = item.get("env_file")
|
||||
if source:
|
||||
values = env_file(source)
|
||||
return bool(values.get(item.get("url_env", ""))) and bool(values.get(item.get("key_env", "")))
|
||||
return True
|
||||
|
||||
|
||||
def resolved(item: dict) -> tuple[str, str]:
|
||||
if item.get("env_file"):
|
||||
values = env_file(item["env_file"])
|
||||
return values[item["url_env"]], values[item["key_env"]]
|
||||
return item["url"], ""
|
||||
|
||||
|
||||
def active(registry: pathlib.Path, client: str) -> list[dict]:
|
||||
document = json.loads(registry.read_text(encoding="utf-8"))
|
||||
if document.get("version") != 1 or not isinstance(document.get("servers"), list):
|
||||
raise SystemExit("Unsupported MCP registry schema")
|
||||
return [item for item in document["servers"] if client in item.get("clients", []) and enabled(item)]
|
||||
|
||||
|
||||
def yaml_quote(value: str) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def hermes_block(items: list[dict]) -> str:
|
||||
lines = [BEGIN, "mcp_servers:"]
|
||||
for item in items:
|
||||
url, key = resolved(item)
|
||||
lines.extend([
|
||||
f" {item.get('hermes_id', item['id'])}:",
|
||||
f" url: {yaml_quote(url)}",
|
||||
])
|
||||
if key:
|
||||
lines.extend([" headers:", f" Authorization: {yaml_quote('Bearer ' + key)}"])
|
||||
lines.extend([
|
||||
f" timeout: {int(item.get('timeout', 300))}",
|
||||
" connect_timeout: 30",
|
||||
" supports_parallel_tool_calls: false",
|
||||
])
|
||||
lines.append(END)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def update_hermes(path: pathlib.Path, block: str) -> None:
|
||||
if not path.is_file():
|
||||
return
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if BEGIN in text and END in text:
|
||||
prefix, rest = text.split(BEGIN, 1)
|
||||
_, suffix = rest.split(END, 1)
|
||||
text = prefix.rstrip() + "\n\n" + block + suffix.lstrip("\n")
|
||||
else:
|
||||
marker = "\nmcp_servers:"
|
||||
if marker in text:
|
||||
text = text.split(marker, 1)[0].rstrip() + "\n\n" + block
|
||||
else:
|
||||
text = text.rstrip() + "\n\n" + block
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def openwebui_connection(item: dict) -> dict:
|
||||
url, key = resolved(item)
|
||||
config = {"enable": True, "access_grants": []}
|
||||
if item.get("functions"):
|
||||
config["function_name_filter_list"] = item["functions"]
|
||||
return {
|
||||
"url": url, "path": "", "type": "mcp",
|
||||
"auth_type": item.get("auth_type", "none"), "headers": None,
|
||||
"key": key, "config": config,
|
||||
"info": {"id": item["id"], "name": item["name"], "description": item["description"]},
|
||||
}
|
||||
|
||||
|
||||
def update_openwebui(db: pathlib.Path, items: list[dict]) -> None:
|
||||
con = sqlite3.connect(db)
|
||||
now = int(time.time())
|
||||
row = con.execute("select value from config where key=?", ("tool_server.connections",)).fetchone()
|
||||
old = json.loads(row[0]) if row else []
|
||||
if not isinstance(old, list):
|
||||
raise SystemExit("Unexpected OpenWebUI tool_server.connections format")
|
||||
managed_ids = {
|
||||
"athena-platform", "athena-operator-local", "web-general-local", "github-local",
|
||||
"homeassistant-local", "arr-local", "navidrome-local", "deemix-local", "mua",
|
||||
"mua-readonly-local", "athena-terminal-local", "unraid-readonly-local", "web-local",
|
||||
}
|
||||
keep = [entry for entry in old if str((entry.get("info") or {}).get("id", "")) not in managed_ids]
|
||||
keep.extend(openwebui_connection(item) for item in items)
|
||||
with con:
|
||||
con.execute(
|
||||
"""insert into config (key,value,updated_at) values (?,?,?)
|
||||
on conflict(key) do update set value=excluded.value,updated_at=excluded.updated_at""",
|
||||
("tool_server.connections", json.dumps(keep, ensure_ascii=False), now),
|
||||
)
|
||||
con.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--registry", type=pathlib.Path, required=True)
|
||||
parser.add_argument("--hermes", type=pathlib.Path, action="append", default=[])
|
||||
parser.add_argument("--openwebui-db", type=pathlib.Path)
|
||||
args = parser.parse_args()
|
||||
if args.hermes:
|
||||
block = hermes_block(active(args.registry, "hermes"))
|
||||
for path in args.hermes:
|
||||
update_hermes(path, block)
|
||||
if args.openwebui_db:
|
||||
update_openwebui(args.openwebui_db, active(args.registry, "openwebui"))
|
||||
print("MCP_CLIENT_SYNC_OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user