diff --git a/README.md b/README.md index a2c3e80..2112d8f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config/mcp-registry.json b/config/mcp-registry.json index ebdd6ba..286dcd3 100644 --- a/config/mcp-registry.json +++ b/config/mcp-registry.json @@ -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" } ] } diff --git a/config/mcphub-client.env.example b/config/mcphub-client.env.example new file mode 100644 index 0000000..85fa705 --- /dev/null +++ b/config/mcphub-client.env.example @@ -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 diff --git a/config/unraid-templates/my-MCPHub.xml b/config/unraid-templates/my-MCPHub.xml index 6720fb5..802427c 100644 --- a/config/unraid-templates/my-MCPHub.xml +++ b/config/unraid-templates/my-MCPHub.xml @@ -1,7 +1,7 @@ MCPHub - samanhappy/mcphub:1.0.32 + casaderoll/mcphub:1.0.0 https://hub.docker.com/r/samanhappy/mcphub bridge @@ -11,7 +11,7 @@ https://github.com/samanhappy/mcphub/issues https://github.com/samanhappy/mcphub https://github.com/samanhappy/mcphub#readme - 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. + 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 8787 /mnt/nvme-storage/appdata/MCPHub + /mnt/nvme-storage/appdata/MCPHub/secrets production 120000 diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md index 3ce447b..bd79e31 100644 --- a/docs/RECOVERY.md +++ b/docs/RECOVERY.md @@ -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. diff --git a/platform/mcp/deemix_mcp.py b/platform/mcp/deemix_mcp.py index efa8c31..040f856 100644 --- a/platform/mcp/deemix_mcp.py +++ b/platform/mcp/deemix_mcp.py @@ -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")) diff --git a/platform/mcp/sync-clients.py b/platform/mcp/sync-clients.py index 2095733..90e36dc 100755 --- a/platform/mcp/sync-clients.py +++ b/platform/mcp/sync-clients.py @@ -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"], "" diff --git a/platform/mcphub/Dockerfile b/platform/mcphub/Dockerfile new file mode 100644 index 0000000..5f36d25 --- /dev/null +++ b/platform/mcphub/Dockerfile @@ -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"] diff --git a/platform/mcphub/README.md b/platform/mcphub/README.md new file mode 100644 index 0000000..e609b61 --- /dev/null +++ b/platform/mcphub/README.md @@ -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. diff --git a/platform/mcphub/casaderoll-entrypoint.sh b/platform/mcphub/casaderoll-entrypoint.sh new file mode 100644 index 0000000..faa8090 --- /dev/null +++ b/platform/mcphub/casaderoll-entrypoint.sh @@ -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 "$@" diff --git a/platform/mcphub/configure-settings.py b/platform/mcphub/configure-settings.py new file mode 100644 index 0000000..58aa876 --- /dev/null +++ b/platform/mcphub/configure-settings.py @@ -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() diff --git a/platform/mcphub/mcp-settings.example.json b/platform/mcphub/mcp-settings.example.json new file mode 100644 index 0000000..dde65cd --- /dev/null +++ b/platform/mcphub/mcp-settings.example.json @@ -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": [] +} diff --git a/platform/mcphub/probe-hub.py b/platform/mcphub/probe-hub.py new file mode 100644 index 0000000..27ba83b --- /dev/null +++ b/platform/mcphub/probe-hub.py @@ -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() diff --git a/platform/mcphub/run-with-env.py b/platform/mcphub/run-with-env.py new file mode 100644 index 0000000..b447e4a --- /dev/null +++ b/platform/mcphub/run-with-env.py @@ -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() diff --git a/platform/mcphub/verify-hub.py b/platform/mcphub/verify-hub.py new file mode 100644 index 0000000..8f8cabe --- /dev/null +++ b/platform/mcphub/verify-hub.py @@ -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()