Consolidate portable MCPs in Unraid MCPHub
This commit is contained in:
@@ -1,24 +1,29 @@
|
||||
# Athena AI
|
||||
|
||||
Ein reproduzierbarer Docker-Stack für Athenas lokale KI. Ein Compose-Projekt
|
||||
enthält Router, llama.cpp-Profile, OpenWebUI, Hermes, Sprache, Bildgenerierung,
|
||||
fachliche MCP-Container und das regelmäßige Datenbackup.
|
||||
Ein reproduzierbarer Docker-Stack für Athenas lokale KI. Athena stellt Router,
|
||||
llama.cpp-Profile, OpenWebUI, Hermes, Sprache und Bildgenerierung bereit. Die
|
||||
portablen Werkzeuge laufen zentral im MCPHub auf Unraid und werden dort zusammen
|
||||
mit dem übrigen Appdata gesichert.
|
||||
|
||||
## Aufbau
|
||||
|
||||
- `compose.yaml` ist der einzige Einstieg; `platform/mcp/compose.yaml` wird mit
|
||||
Docker Composes standardisiertem `include` in dasselbe Projekt geladen.
|
||||
- `compose.yaml` ist der einzige Einstieg für die KI-Dienste auf Athena.
|
||||
- Genau ein llama.cpp-Profil ist aktiv. Der Router schaltet zwischen Fast,
|
||||
Medium, Large, Ultra und Uncensored.
|
||||
- Der **Athena Operator** ist der einzige administrative MCP. Er liefert mit
|
||||
`athena_operator_inspect(subject=guide)` auch diese Plattformanleitung aus
|
||||
`ATHENA.md`.
|
||||
- Home Assistant, ARR, Unraid, Navidrome, Deemix, GitHub und Web bleiben als
|
||||
getrennte Fach-MCPs isolierbar und unabhängig aktualisierbar.
|
||||
- Der **Athena Operator** bleibt während der Migration der einzige
|
||||
hostgebundene administrative MCP. Er wird anschließend durch einen
|
||||
SSH-basierten Operator im MCPHub ersetzt.
|
||||
- Home Assistant, ARR, Unraid, Navidrome, Deemix und GitHub laufen gemeinsam im
|
||||
MCPHub-Container auf Unraid, bleiben aber als getrennte MCP-Server unter
|
||||
`/mcp/NAME` sichtbar, abschaltbar und unabhängig für Clients freigebbar.
|
||||
- SearXNG/TinySearch dürfen als eigene Such-Backends laufen. Der eigentliche
|
||||
Web-MCP-Adapter zieht ebenfalls in den MCPHub, sobald der Backend-Pfad aus dem
|
||||
Unraid-Netz verfügbar ist.
|
||||
- `config/mcp-registry.json` ist die einzige Liste der MCPs für Hermes und
|
||||
OpenWebUI. `platform/mcp/sync-clients.py` erzeugt beide Registrierungen.
|
||||
- Modelle, Hermes-Daten und Backups liegen auf `/data`; Secrets ausschließlich
|
||||
unter `/etc/mike-ai`.
|
||||
- Modelle, Hermes-Daten und Athena-Backups liegen auf `/data`. MCPHub-Zustand,
|
||||
Client-Schlüssel und MCP-Zugänge liegen im Unraid-Appdata-Verzeichnis
|
||||
`/mnt/nvme-storage/appdata/MCPHub`.
|
||||
- KI-Oberflächen und APIs sind nur über WireGuard erreichbar.
|
||||
|
||||
## Installation – ein Befehl
|
||||
@@ -38,8 +43,8 @@ Modelle, baut den Stack und startet die benötigten Profile und MCPs.
|
||||
# Gesamten Stack anzeigen
|
||||
docker compose --env-file /etc/mike-ai/stack.env ps
|
||||
|
||||
# Eine gezielte Änderung ausrollen
|
||||
docker compose --env-file /etc/mike-ai/stack.env up -d --build mcp-arr
|
||||
# Eine gezielte Athena-Komponente ausrollen
|
||||
docker compose --env-file /etc/mike-ai/stack.env up -d --build router
|
||||
|
||||
# Sofortiges Datenbackup zusätzlich zum Fünf-Stunden-Zeitplan
|
||||
docker exec mike-ai-backup backup
|
||||
@@ -68,4 +73,7 @@ Details, Prüfschritte und der exakte Sicherungsumfang stehen in
|
||||
- [`docs/STANDARD_PROFILE_MATRIX.md`](docs/STANDARD_PROFILE_MATRIX.md) – Profile und Messwerte
|
||||
- [`docs/RECOVERY.md`](docs/RECOVERY.md) – Backup und Neuaufbau
|
||||
|
||||
Die MCPHub-Installation, Endpunkte und der schrittweise Rückbau der alten
|
||||
Athena-MCPs stehen in [`platform/mcphub/README.md`](platform/mcphub/README.md).
|
||||
|
||||
Git enthält keine Secrets, Chatdaten oder Modellgewichte.
|
||||
|
||||
+28
-18
@@ -24,20 +24,24 @@
|
||||
"hermes_id": "github",
|
||||
"name": "GitHub (offiziell, read-only)",
|
||||
"description": "Repository-Suche, echte Datei-Inhalte und gezielte Code-Suche. Keine rekursiven Komplettbäume oder Schreibzugriffe.",
|
||||
"url": "http://mcp-github:8000/mcp",
|
||||
"url": "http://192.168.1.2:8787/mcp/github",
|
||||
"clients": ["hermes", "openwebui"],
|
||||
"required_file": "/etc/mike-ai/github-mcp.env",
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"auth_type": "bearer",
|
||||
"timeout": 300,
|
||||
"functions": "search_repositories,get_file_contents,search_code"
|
||||
"functions": "github-search_repositories,github-get_file_contents,github-search_code"
|
||||
},
|
||||
{
|
||||
"id": "homeassistant-local",
|
||||
"hermes_id": "homeassistant-admin",
|
||||
"name": "Home Assistant",
|
||||
"description": "Entitäten, Zustände, Historie, Automationen, Dashboards, Diagnose und freigegebene YAML-Dateien. Änderungen nur auf ausdrücklichen Auftrag.",
|
||||
"url": "http://mcp-homeassistant:8000/mcp",
|
||||
"url": "http://192.168.1.2:8787/mcp/homeassistant",
|
||||
"clients": ["hermes", "openwebui"],
|
||||
"required_file": "/etc/mike-ai/homeassistant-admin-mcp.env",
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"auth_type": "bearer",
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
@@ -45,9 +49,11 @@
|
||||
"hermes_id": "arr",
|
||||
"name": "Sonarr und Radarr",
|
||||
"description": "Serien, Filme, Queue, Indexer-Suche und kompakte Medieninventare. Für Codec-Fragen radarr_movie_codec_inventory verwenden; keine rohen API-Requests oder Dateisystem-Scans.",
|
||||
"url": "http://mcp-arr:8000/mcp",
|
||||
"url": "http://192.168.1.2:8787/mcp/arr",
|
||||
"clients": ["hermes", "openwebui"],
|
||||
"required_file": "/etc/mike-ai/arr-mcp.env",
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"auth_type": "bearer",
|
||||
"timeout": 600
|
||||
},
|
||||
{
|
||||
@@ -55,9 +61,11 @@
|
||||
"hermes_id": "navidrome",
|
||||
"name": "Navidrome",
|
||||
"description": "Persönliche Musikbibliothek: Titel, Alben, Künstler, Playlists, Favoriten und Hörverlauf.",
|
||||
"url": "http://mike-ai-mcp-navidrome:3000/mcp",
|
||||
"url": "http://192.168.1.2:8787/mcp/navidrome",
|
||||
"clients": ["hermes", "openwebui"],
|
||||
"required_file": "/etc/mike-ai/navidrome-mcp.env",
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"auth_type": "bearer",
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
@@ -65,9 +73,11 @@
|
||||
"hermes_id": "deemix",
|
||||
"name": "Deemix",
|
||||
"description": "Nutzt ausschließlich die bestehende Deemix-Instanz auf Unraid. Status und Suche sind read-only; Queue-Aktionen nur auf ausdrücklichen Auftrag.",
|
||||
"url": "http://mcp-deemix:8000/mcp",
|
||||
"url": "http://192.168.1.2:8787/mcp/deemix",
|
||||
"clients": ["hermes", "openwebui"],
|
||||
"required_file": "/etc/mike-ai/deemix-mcp.env",
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"auth_type": "bearer",
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
@@ -75,9 +85,9 @@
|
||||
"hermes_id": "unraid",
|
||||
"name": "MUA (Unraid-Verwaltung)",
|
||||
"description": "Unraid-Verwaltung über das vorhandene MUA-Plugin. Zustand zuerst lesen, engste Änderung ausführen, danach verifizieren.",
|
||||
"url_env": "MUA_MCP_URL",
|
||||
"key_env": "MUA_MCP_BEARER_TOKEN",
|
||||
"env_file": "/etc/mike-ai/mua-mcp.env",
|
||||
"url": "http://192.168.1.2:8787/mcp/unraid",
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"auth_type": "bearer",
|
||||
"clients": ["hermes", "openwebui"],
|
||||
"timeout": 900
|
||||
@@ -86,13 +96,13 @@
|
||||
"id": "mua-readonly-local",
|
||||
"name": "MUA (Unraid read-only)",
|
||||
"description": "Automatisch nutzbare Unraid-Diagnose für Container, Logs, System, Storage, Shares und Medieninventare. Keine Änderungen oder freie Shell.",
|
||||
"url_env": "MUA_MCP_URL",
|
||||
"key_env": "MUA_MCP_BEARER_TOKEN",
|
||||
"env_file": "/etc/mike-ai/mua-mcp.env",
|
||||
"url": "http://192.168.1.2:8787/mcp/unraid",
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"auth_type": "bearer",
|
||||
"clients": ["openwebui"],
|
||||
"timeout": 900,
|
||||
"functions": "unraid_docker_list,unraid_docker_inspect,unraid_docker_logs,unraid_docker_analyze_logs,unraid_docker_processes,unraid_docker_stats,unraid_docker_info,unraid_docker_update_status,unraid_ca_search,unraid_network_inventory,unraid_network_list,unraid_network_inspect,unraid_network_host_state,unraid_network_audit_tcp,unraid_network_lan_probe,unraid_system_health,unraid_storage_status,unraid_disk_health,unraid_notifications_list,unraid_shares_list,unraid_share_inspect,unraid_files_inventory,unraid_system_connection_test,unraid_system_shell_readonly"
|
||||
"functions": "unraid-unraid_docker_list,unraid-unraid_docker_inspect,unraid-unraid_docker_logs,unraid-unraid_docker_analyze_logs,unraid-unraid_docker_processes,unraid-unraid_docker_stats,unraid-unraid_docker_info,unraid-unraid_docker_update_status,unraid-unraid_ca_search,unraid-unraid_network_inventory,unraid-unraid_network_list,unraid-unraid_network_inspect,unraid-unraid_network_host_state,unraid-unraid_network_audit_tcp,unraid-unraid_network_lan_probe,unraid-unraid_system_health,unraid-unraid_storage_status,unraid-unraid_disk_health,unraid-unraid_notifications_list,unraid-unraid_shares_list,unraid-unraid_share_inspect,unraid-unraid_files_inventory,unraid-unraid_system_connection_test,unraid-unraid_system_shell_readonly"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Shared bearer key generated by platform/mcphub/configure-settings.py.
|
||||
# The live value is stored in MCPHub appdata/client-token and copied only to
|
||||
# /etc/mike-ai/mcphub-client.env on clients.
|
||||
MCPHUB_BEARER_TOKEN=REPLACE_WITH_LOCAL_MCPHUB_CLIENT_TOKEN
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>MCPHub</Name>
|
||||
<Repository>samanhappy/mcphub:1.0.32</Repository>
|
||||
<Repository>casaderoll/mcphub:1.0.0</Repository>
|
||||
<Registry>https://hub.docker.com/r/samanhappy/mcphub</Registry>
|
||||
<Network>bridge</Network>
|
||||
<MyIP/>
|
||||
@@ -11,7 +11,7 @@
|
||||
<Support>https://github.com/samanhappy/mcphub/issues</Support>
|
||||
<Project>https://github.com/samanhappy/mcphub</Project>
|
||||
<ReadMe>https://github.com/samanhappy/mcphub#readme</ReadMe>
|
||||
<Overview>Zentrale MCP-Verwaltung mit Weboberfläche. MCPHub startet und überwacht stdio-, SSE- und Streamable-HTTP-MCPs, stellt einzelne Server sowie Gruppen über gemeinsame HTTP-Endpunkte bereit und erlaubt das Aktivieren oder Deaktivieren einzelner Werkzeuge. Das normale Image enthält Node.js, Python, uv/uvx, npx und Git.
|
||||
<Overview>Zentrale MCP-Verwaltung mit Weboberfläche. Das lokale CasaDeRoll-Image basiert reproduzierbar auf MCPHub 1.0.32 und enthält die versionierten ARR-, Deemix-, Navidrome-, GitHub- und Web-MCP-Laufzeiten. Home Assistant und MUA werden als vorhandene HTTP-MCPs eingebunden. Einzelne Server bleiben unter /mcp/NAME getrennt sichtbar und schaltbar.
|
||||
|
||||
Weboberfläche: http://[IP]:[PORT:3000]/
|
||||
Benutzer beim ersten Start: admin
|
||||
@@ -29,6 +29,7 @@ Wenn kein Admin-Passwort eingetragen wird, erzeugt MCPHub eines und schreibt es
|
||||
<Requires/>
|
||||
<Config Name="WebUI und MCP Port" Target="3000" Default="8787" Mode="tcp" Description="Weboberfläche und MCP-Endpunkte. Beispiel: http://UNRAID-IP:8787/mcp" Type="Port" Display="always" Required="true" Mask="false">8787</Config>
|
||||
<Config Name="Persistente Daten" Target="/app/data" Default="/mnt/nvme-storage/appdata/MCPHub" Mode="rw" Description="Benutzer, Einstellungen, Serverdefinitionen, Schlüssel und Laufzeitstatus. Dieses Verzeichnis wird vom Unraid-Appdata-Backup erfasst." Type="Path" Display="always" Required="true" Mask="false">/mnt/nvme-storage/appdata/MCPHub</Config>
|
||||
<Config Name="MCP-Konfigurationen" Target="/run/secrets/mcphub" Default="/mnt/nvme-storage/appdata/MCPHub/secrets" Mode="ro" Description="Lokale API-Zugänge der verwalteten MCP-Unterprozesse. Das Verzeichnis ist nur im Container lesbar und wird vom Appdata-Backup erfasst." Type="Path" Display="always" Required="true" Mask="false">/mnt/nvme-storage/appdata/MCPHub/secrets</Config>
|
||||
<Config Name="Admin-Passwort" Target="ADMIN_PASSWORD" Default="" Mode="" Description="Optional vor dem ersten Start setzen. Leer lassen erzeugt ein Zufallspasswort, das im Containerprotokoll angezeigt wird." Type="Variable" Display="always" Required="false" Mask="true"></Config>
|
||||
<Config Name="Node-Umgebung" Target="NODE_ENV" Default="production" Mode="" Description="Produktionsmodus für MCPHub." Type="Variable" Display="advanced" Required="true" Mask="false">production</Config>
|
||||
<Config Name="Anfrage-Timeout" Target="REQUEST_TIMEOUT" Default="120000" Mode="" Description="Zeitlimit für MCP-Aufrufe in Millisekunden." Type="Variable" Display="advanced" Required="true" Mask="false">120000</Config>
|
||||
|
||||
+10
-2
@@ -21,6 +21,13 @@ Nicht kopiert werden `/data/models`, `/data/hermes` und
|
||||
überleben den Austausch der Debian-Systemplatte. Docker-Images werden aus dem
|
||||
Compose-Stack reproduziert und gehören nicht ins Backup.
|
||||
|
||||
Die portablen Fach-MCPs gehören nicht mehr zum Athena-Systembackup. MCPHub,
|
||||
seine Serverliste, Client-Schlüssel und die lokalen MCP-Zugänge liegen unter
|
||||
`/mnt/nvme-storage/appdata/MCPHub` auf Unraid und werden vom bestehenden
|
||||
Unraid-Appdata-Backup gesichert. Für ein vollständiges Desaster-Recovery müssen
|
||||
daher sowohl Athenas `/data` als auch dieses Unraid-Appdata-Backup verfügbar
|
||||
sein.
|
||||
|
||||
## Manuelles Backup
|
||||
|
||||
```bash
|
||||
@@ -57,5 +64,6 @@ test -s /data/docker-backups/athena-latest.tar.gz
|
||||
```
|
||||
|
||||
Danach einen OpenWebUI-Login, einen Router-Request und je einen read-only
|
||||
MCP-Aufruf testen. Alte Recovery-Koffer sind für Neuinstallationen nicht mehr
|
||||
erforderlich; Git plus dieses Datenbackup bilden die Wiederherstellung.
|
||||
MCP-Aufruf über `http://UNRAID-IP:8787/mcp/NAME` testen. Alte Recovery-Koffer
|
||||
sind für Neuinstallationen nicht mehr erforderlich; Git, Athenas Datenbackup
|
||||
und das Unraid-Appdata-Backup bilden die Wiederherstellung.
|
||||
|
||||
@@ -504,4 +504,8 @@ def deemix_cancel_all() -> str:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="streamable-http")
|
||||
# MCPHub manages local servers as stdio subprocesses. The standalone
|
||||
# Athena container can keep using Streamable HTTP by setting
|
||||
# MCP_TRANSPORT=streamable-http, so the same source works in both places
|
||||
# during the migration.
|
||||
mcp.run(transport=os.environ.get("MCP_TRANSPORT", "stdio"))
|
||||
|
||||
@@ -36,14 +36,18 @@ def enabled(item: dict) -> bool:
|
||||
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", "")))
|
||||
url_ready = bool(item.get("url")) or bool(values.get(item.get("url_env", "")))
|
||||
key_ready = not item.get("key_env") or bool(values.get(item["key_env"]))
|
||||
return url_ready and key_ready
|
||||
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"]]
|
||||
url = item.get("url") or values[item["url_env"]]
|
||||
key = values.get(item.get("key_env", ""), "")
|
||||
return url, key
|
||||
return item["url"], ""
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
FROM ghcr.io/github/github-mcp-server@sha256:1817b57d43916532dc002bdc5f344d639bd9fb54a9148d42168458f7c3280567 AS github
|
||||
|
||||
FROM ghcr.io/blakeem/navidrome-mcp:2.2.0@sha256:047f911a5a8f7cc8f185bb4d6e7ca6c435542edefff4694a00c2f718ab0ee7f5 AS navidrome
|
||||
|
||||
FROM samanhappy/mcphub:1.0.32
|
||||
|
||||
ARG MCP_VERSION=1.29.0
|
||||
ARG ARR_MCP_VERSION=1.0.1
|
||||
ARG YT_DLP_VERSION=2026.7.4
|
||||
|
||||
USER root
|
||||
|
||||
# One image, multiple isolated MCP subprocesses. MCPHub already contains
|
||||
# Python, Node.js, uv/uvx and build tools; only the pinned runtime packages
|
||||
# used by our local servers are added here.
|
||||
RUN python3 -m pip install --no-cache-dir \
|
||||
"mcp==${MCP_VERSION}" \
|
||||
"arr-mcp[mcp]==${ARR_MCP_VERSION}" \
|
||||
"yt-dlp==${YT_DLP_VERSION}"
|
||||
|
||||
COPY --from=github /server/github-mcp-server /usr/local/bin/github-mcp-server
|
||||
COPY --from=navidrome /app /opt/casaderoll/navidrome
|
||||
|
||||
COPY platform/mcp/deemix_mcp.py /opt/casaderoll/mcps/deemix_mcp.py
|
||||
COPY platform/web-search/web_search_mcp.py /opt/casaderoll/mcps/web_search_mcp.py
|
||||
COPY platform/mcp/patches/mcp_sonarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_sonarr.py
|
||||
COPY platform/mcp/patches/mcp_radarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_radarr.py
|
||||
COPY platform/mcphub/run-with-env.py /usr/local/bin/run-with-env
|
||||
COPY platform/mcphub/casaderoll-entrypoint.sh /usr/local/bin/casaderoll-mcphub-entrypoint
|
||||
|
||||
# Keep the llama.cpp-compatible schema correction from the former dedicated
|
||||
# Navidrome image. Abort the image build if upstream changes unexpectedly.
|
||||
RUN node -e 'const fs=require("node:fs"); const p="/opt/casaderoll/navidrome/dist/tools/handlers/radio-handlers.js"; let s=fs.readFileSync(p,"utf8"); const a="pattern: '\''^https?://.+'\''"; const b="pattern: '\''^https?://.+$'\''"; const n=s.split(a).length-1; if(n!==2) throw new Error(`expected 2 schema patterns, found ${n}`); fs.writeFileSync(p,s.split(a).join(b));' \
|
||||
&& chmod 0755 /usr/local/bin/run-with-env /usr/local/bin/casaderoll-mcphub-entrypoint /usr/local/bin/github-mcp-server \
|
||||
&& mkdir -p /app/data /run/secrets/mcphub
|
||||
|
||||
ENV MCPHUB_SETTING_PATH=/app/data/ \
|
||||
REQUEST_TIMEOUT=120000 \
|
||||
NODE_ENV=production
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/casaderoll-mcphub-entrypoint"]
|
||||
CMD ["/usr/local/bin/entrypoint.sh", "pnpm", "start"]
|
||||
@@ -0,0 +1,50 @@
|
||||
# CasaDeRoll MCPHub
|
||||
|
||||
MCPHub ist die zentrale Laufzeit und Verwaltungsoberfläche für portable
|
||||
MCP-Server. Die einzelnen Server bleiben unter `/mcp/{server}` sichtbar, obwohl
|
||||
sie sich einen Docker-Container und ein Appdata-Backup teilen.
|
||||
|
||||
## Was hier hinein gehört
|
||||
|
||||
- ARR, Deemix, Navidrome und GitHub laufen als lokale stdio-Unterprozesse.
|
||||
- Home Assistant und MUA/Unraid sind vorhandene HTTP-MCP-Endpunkte und werden
|
||||
vom Hub direkt weitergereicht.
|
||||
- Der Web-Adapter kann hier laufen; SearXNG/TinySearch dürfen getrennte
|
||||
Backend-Dienste bleiben.
|
||||
- Athenas administrativer Operator ist hostgebunden. Vor dem Entfernen seines
|
||||
alten Athena-Containers wird er durch einen SSH-basierten Operator im Hub
|
||||
ersetzt.
|
||||
|
||||
## Dauerhafte Daten
|
||||
|
||||
`/mnt/nvme-storage/appdata/MCPHub` on Unraid contains:
|
||||
|
||||
- `mcp_settings.json` (users, server registrations and tool toggles)
|
||||
- `jwt-secret` (stable login sessions)
|
||||
- `secrets/*.env` (local credentials, mode `0600`)
|
||||
|
||||
Das Verzeichnis wird vom normalen Unraid-Appdata-Backup erfasst. Das Image
|
||||
enthält nur versionierten Code und keine Zugangsdaten.
|
||||
|
||||
Das Dashboard bleibt passwortgeschützt. MCP-Clients teilen sich einen
|
||||
generierten Bearer-Schlüssel in `client-token`. Dadurch ist kein OAuth-Ablauf
|
||||
pro Client nötig, ohne die MCP-Routen anonym zu öffnen. Port 8787 darf nicht ins
|
||||
öffentliche Internet weitergeleitet werden.
|
||||
|
||||
`configure-settings.py` erhält bestehende MCPHub-Benutzer und ersetzt
|
||||
Demo-Server durch die deklarative Produktionsliste. `verify-hub.py` führt
|
||||
Handshakes und Tool-Listen ohne Schreibzugriff aus. `probe-hub.py` führt genau
|
||||
eine ausdrücklich benannte, begrenzte Funktionsprobe aus.
|
||||
|
||||
## Migrationsregel
|
||||
|
||||
Jeweils nur einen Server verschieben, seinen Handshake und einen begrenzten
|
||||
read-only-Aufruf prüfen und erst danach Clients auf
|
||||
`http://UNRAID-IP:8787/mcp/{server}` umstellen. Der alte Athena-Container wird
|
||||
erst gestoppt, wenn Hermes und OpenWebUI nachweislich über MCPHub funktionieren.
|
||||
|
||||
Aktueller Stand: ARR, Deemix, Navidrome, GitHub, Home Assistant und MUA/Unraid
|
||||
sind auf MCPHub produktiv und wurden über Hermes sowie OpenWebUI geprüft. Die
|
||||
alten Athena-Container bleiben vorläufig als ausgeschaltetes beziehungsweise
|
||||
abschaltbares Rückfallnetz bestehen. Web-Adapter und Athena Operator sind die
|
||||
letzten beiden Migrationspunkte.
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
data_dir=${MCPHUB_SETTING_PATH:-/app/data/}
|
||||
case "$data_dir" in
|
||||
*/) state_dir=${data_dir%/} ;;
|
||||
*) state_dir=$(dirname "$data_dir") ;;
|
||||
esac
|
||||
mkdir -p "$state_dir"
|
||||
|
||||
# A stable signing key prevents every container recreation from invalidating
|
||||
# all logged-in browser sessions. It lives only in persistent appdata.
|
||||
jwt_file="$state_dir/jwt-secret"
|
||||
if [ ! -s "$jwt_file" ]; then
|
||||
umask 077
|
||||
python3 -c 'import secrets; print(secrets.token_urlsafe(64))' > "$jwt_file"
|
||||
fi
|
||||
JWT_SECRET=$(cat "$jwt_file")
|
||||
export JWT_SECRET
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replace demo MCPHub entries with CasaDeRoll's declarative server set.
|
||||
|
||||
The existing users, bearer keys, prompts and resources are preserved. Secret
|
||||
values are read locally and written only to the runtime settings file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import secrets
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
|
||||
def env_file(path: pathlib.Path) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return values
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key, value = key.strip(), value.strip()
|
||||
if key.startswith("export "):
|
||||
key = key[7:].strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||
value = value[1:-1]
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def required(values: dict[str, str], key: str, source: pathlib.Path) -> str:
|
||||
value = values.get(key, "").strip()
|
||||
if not value:
|
||||
raise SystemExit(f"{key} is missing in {source}")
|
||||
return value
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("settings", type=pathlib.Path)
|
||||
parser.add_argument("secrets", type=pathlib.Path)
|
||||
parser.add_argument("--web-backend", default="", help="TinySearch MCP URL; empty keeps web disabled")
|
||||
parser.add_argument("--searxng", default="", help="SearXNG base URL")
|
||||
args = parser.parse_args()
|
||||
|
||||
settings = json.loads(args.settings.read_text(encoding="utf-8")) if args.settings.exists() else {}
|
||||
ha_path = args.secrets / "homeassistant.env"
|
||||
mua_path = args.secrets / "mua.env"
|
||||
ha = env_file(ha_path)
|
||||
mua = env_file(mua_path)
|
||||
|
||||
servers: dict[str, object] = {
|
||||
"arr": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": ["/run/secrets/mcphub/arr.env", "--", "arr-mcp", "--transport", "stdio", "--auth-type", "none"],
|
||||
"enabled": True,
|
||||
},
|
||||
"deemix": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": ["/run/secrets/mcphub/deemix.env", "--", "python3", "/opt/casaderoll/mcps/deemix_mcp.py"],
|
||||
"env": {"MCP_TRANSPORT": "stdio"},
|
||||
"enabled": True,
|
||||
},
|
||||
"navidrome": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": ["/run/secrets/mcphub/navidrome.env", "--", "node", "/opt/casaderoll/navidrome/dist/index.js"],
|
||||
"env": {"MCP_TRANSPORT": "stdio", "MCP_HTTP_EXPOSE": "false", "WEBUI_ENABLED": "false"},
|
||||
"enabled": True,
|
||||
},
|
||||
"github": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": ["/run/secrets/mcphub/github.env", "--", "/usr/local/bin/github-mcp-server", "stdio", "--read-only", "--tools", "search_repositories,get_file_contents,search_code"],
|
||||
"enabled": True,
|
||||
},
|
||||
"homeassistant": {
|
||||
"type": "streamable-http",
|
||||
"url": required(ha, "HASS_URL", ha_path).rstrip("/") + "/api/hass_mcp",
|
||||
"headers": {"Authorization": "Bearer " + required(ha, "HASS_TOKEN", ha_path)},
|
||||
"owner": "admin",
|
||||
"enabled": True,
|
||||
},
|
||||
"unraid": {
|
||||
"type": "streamable-http",
|
||||
"url": required(mua, "MUA_MCP_URL", mua_path),
|
||||
"headers": {"Authorization": "Bearer " + required(mua, "MUA_MCP_BEARER_TOKEN", mua_path)},
|
||||
"owner": "admin",
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
if args.web_backend:
|
||||
servers["web"] = {
|
||||
"type": "stdio",
|
||||
"command": "python3",
|
||||
"args": ["/opt/casaderoll/mcps/web_search_mcp.py"],
|
||||
"env": {
|
||||
"TINYSEARCH_MCP_URL": args.web_backend,
|
||||
"SEARXNG_URL": args.searxng,
|
||||
},
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
settings["mcpServers"] = servers
|
||||
settings.setdefault("users", [])
|
||||
token_path = args.settings.parent / "client-token"
|
||||
keys = settings.setdefault("bearerKeys", [])
|
||||
client_key = next((item for item in keys if item.get("name") == "casaderoll-clients"), None)
|
||||
if client_key is None:
|
||||
token = secrets.token_urlsafe(48)
|
||||
client_key = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": "casaderoll-clients",
|
||||
"token": token,
|
||||
"enabled": True,
|
||||
"kind": "system",
|
||||
"accessType": "all",
|
||||
"allowedGroups": [],
|
||||
"allowedServers": [],
|
||||
}
|
||||
keys.append(client_key)
|
||||
else:
|
||||
token = str(client_key["token"])
|
||||
client_key["enabled"] = True
|
||||
client_key["accessType"] = "all"
|
||||
token_path.write_text(token + "\n", encoding="utf-8")
|
||||
os.chmod(token_path, 0o600)
|
||||
settings.setdefault("prompts", [])
|
||||
settings.setdefault("resources", [])
|
||||
system = settings.setdefault("systemConfig", {})
|
||||
system.setdefault("routing", {})["skipAuth"] = False
|
||||
|
||||
args.settings.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".mcp-settings-", dir=args.settings.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(settings, handle, indent=2, ensure_ascii=False)
|
||||
handle.write("\n")
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, args.settings)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
print("MCPHUB_SETTINGS_CONFIGURED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"arr": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": [
|
||||
"/run/secrets/mcphub/arr.env",
|
||||
"--",
|
||||
"arr-mcp",
|
||||
"--transport",
|
||||
"stdio",
|
||||
"--auth-type",
|
||||
"none"
|
||||
],
|
||||
"enabled": true
|
||||
},
|
||||
"deemix": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": [
|
||||
"/run/secrets/mcphub/deemix.env",
|
||||
"--",
|
||||
"python3",
|
||||
"/opt/casaderoll/mcps/deemix_mcp.py"
|
||||
],
|
||||
"env": {"MCP_TRANSPORT": "stdio"},
|
||||
"enabled": true
|
||||
},
|
||||
"navidrome": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": [
|
||||
"/run/secrets/mcphub/navidrome.env",
|
||||
"--",
|
||||
"node",
|
||||
"/opt/casaderoll/navidrome/dist/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MCP_TRANSPORT": "stdio",
|
||||
"MCP_HTTP_EXPOSE": "false",
|
||||
"WEBUI_ENABLED": "false"
|
||||
},
|
||||
"enabled": true
|
||||
},
|
||||
"github": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/run-with-env",
|
||||
"args": [
|
||||
"/run/secrets/mcphub/github.env",
|
||||
"--",
|
||||
"/usr/local/bin/github-mcp-server",
|
||||
"stdio",
|
||||
"--read-only",
|
||||
"--tools",
|
||||
"search_repositories,get_file_contents,search_code"
|
||||
],
|
||||
"enabled": true
|
||||
},
|
||||
"homeassistant": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://ha.example.invalid/api/hass_mcp",
|
||||
"headers": {"Authorization": "Bearer REPLACE_FROM_LOCAL_SECRET"},
|
||||
"owner": "admin",
|
||||
"enabled": true
|
||||
},
|
||||
"unraid": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://UNRAID-IP:3002/mcp",
|
||||
"headers": {"Authorization": "Bearer REPLACE_FROM_LOCAL_SECRET"},
|
||||
"owner": "admin",
|
||||
"enabled": true
|
||||
},
|
||||
"web": {
|
||||
"type": "stdio",
|
||||
"command": "python3",
|
||||
"args": ["/opt/casaderoll/mcps/web_search_mcp.py"],
|
||||
"env": {
|
||||
"TINYSEARCH_MCP_URL": "http://ATHENA-VPN-IP:TINYSEARCH-PORT/mcp",
|
||||
"SEARXNG_URL": "http://ATHENA-VPN-IP:SEARXNG-PORT"
|
||||
},
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"users": [],
|
||||
"systemConfig": {
|
||||
"oauthServer": {"enabled": false},
|
||||
"routing": {"skipAuth": false}
|
||||
},
|
||||
"bearerKeys": [],
|
||||
"prompts": [],
|
||||
"resources": []
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Invoke one explicitly chosen MCPHub tool and print a bounded result."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import httpx
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
|
||||
async def probe(url: str, token: str, tool: str, arguments: dict[str, object]) -> None:
|
||||
async with httpx.AsyncClient(headers={"Authorization": f"Bearer {token}"}) as client:
|
||||
async with streamable_http_client(url, http_client=client) as (reader, writer, _):
|
||||
async with ClientSession(reader, writer) as session:
|
||||
await session.initialize()
|
||||
result = await session.call_tool(tool, arguments)
|
||||
text = "\n".join(getattr(item, "text", "") for item in result.content)
|
||||
print(text[:2000])
|
||||
if result.isError:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("url")
|
||||
parser.add_argument("tool")
|
||||
parser.add_argument("arguments_b64", help="Base64-encoded JSON object")
|
||||
parser.add_argument("--token-file", required=True, type=pathlib.Path)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(
|
||||
probe(
|
||||
args.url,
|
||||
args.token_file.read_text(encoding="utf-8").strip(),
|
||||
args.tool,
|
||||
json.loads(base64.b64decode(args.arguments_b64).decode("utf-8")),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run a command with one or more Docker-style env files.
|
||||
|
||||
Values are parsed literally rather than sourced by a shell. Tokens containing
|
||||
`$`, `#`, spaces or shell metacharacters therefore remain unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
|
||||
def load_env(path: pathlib.Path) -> None:
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"secret environment file is missing: {path}")
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("export "):
|
||||
line = line[7:].lstrip()
|
||||
if "=" not in line:
|
||||
raise SystemExit(f"invalid environment line in {path}: {raw!r}")
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
if not key or not key.replace("_", "A").isalnum() or key[0].isdigit():
|
||||
raise SystemExit(f"invalid environment variable in {path}: {key!r}")
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||
value = value[1:-1]
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 3:
|
||||
raise SystemExit("usage: run-with-env ENV_FILE [ENV_FILE ...] -- COMMAND [ARG ...]")
|
||||
try:
|
||||
separator = sys.argv.index("--")
|
||||
except ValueError as exc:
|
||||
raise SystemExit("missing -- before command") from exc
|
||||
env_files = [pathlib.Path(item) for item in sys.argv[1:separator]]
|
||||
command = sys.argv[separator + 1 :]
|
||||
if not env_files or not command:
|
||||
raise SystemExit("at least one env file and a command are required")
|
||||
for env_file in env_files:
|
||||
load_env(env_file)
|
||||
os.execvp(command[0], command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify MCPHub server endpoints without invoking write operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import httpx
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
|
||||
async def verify(base_url: str, servers: list[str], token: str) -> None:
|
||||
results: dict[str, object] = {}
|
||||
for server in servers:
|
||||
url = f"{base_url.rstrip('/')}/mcp/{server}"
|
||||
try:
|
||||
async with httpx.AsyncClient(headers={"Authorization": f"Bearer {token}"}) as client:
|
||||
transport = streamable_http_client(url, http_client=client)
|
||||
async with transport as (reader, writer, _):
|
||||
async with ClientSession(reader, writer) as session:
|
||||
await session.initialize()
|
||||
tools = await session.list_tools()
|
||||
results[server] = {
|
||||
"ok": True,
|
||||
"tool_count": len(tools.tools),
|
||||
"tools": [tool.name for tool in tools.tools],
|
||||
}
|
||||
except Exception as exc:
|
||||
results[server] = {"ok": False, "error": str(exc)[:300]}
|
||||
print(json.dumps(results, ensure_ascii=False, indent=2))
|
||||
if not all(item.get("ok") for item in results.values()):
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("base_url")
|
||||
parser.add_argument("--token-file", required=True, type=pathlib.Path)
|
||||
parser.add_argument("servers", nargs="+")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(verify(args.base_url, args.servers, args.token_file.read_text(encoding="utf-8").strip()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user