Consolidate portable MCPs in Unraid MCPHub

This commit is contained in:
Mikei386
2026-08-25 23:21:29 +02:00
parent d38af69380
commit cca1fb217e
15 changed files with 589 additions and 39 deletions
+22 -14
View File
@@ -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
View File
@@ -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"
}
]
}
+4
View File
@@ -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
+3 -2
View File
@@ -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.&#13;
<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.&#13;
&#13;
Weboberfläche: http://[IP]:[PORT:3000]/&#13;
Benutzer beim ersten Start: admin&#13;
@@ -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
View File
@@ -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.
+5 -1
View File
@@ -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"))
+6 -2
View File
@@ -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"], ""
+42
View File
@@ -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"]
+50
View File
@@ -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.
+21
View File
@@ -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 "$@"
+157
View File
@@ -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()
+92
View File
@@ -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": []
}
+47
View File
@@ -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()
+53
View File
@@ -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()
+49
View File
@@ -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()