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
+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()