let Hermes manage MCPHub through its API

This commit is contained in:
Mikei386
2026-08-26 11:28:51 +02:00
parent bc902261b9
commit 2595bab17b
11 changed files with 377 additions and 34 deletions
+7 -3
View File
@@ -23,9 +23,13 @@ Hermes Agent und MCPHub laufen auf Unraid und werden dort mit Appdata gesichert.
- Hermes verwendet für allgemeine Recherche den eingebauten schlüssellosen - Hermes verwendet für allgemeine Recherche den eingebauten schlüssellosen
Keenable-Provider für Suche und Seitenabruf; der frühere Athena-Webadapter Keenable-Provider für Suche und Seitenabruf; der frühere Athena-Webadapter
wird nicht mehr gestartet. wird nicht mehr gestartet.
- Die produktive Liste der MCPHub-Server liegt in Unraid-Appdata unter - MCPHubs eigene persistente Einstellungen unter `MCPHub/mcp_settings.json`
`MCPHub/config/mcp-registry.json`; `config/mcp-registry.json` ist der sind der produktive Zustand. Oberfläche und offizielle API ändern genau
Neuinstallations-Seed. `platform/mcp/sync-clients.py` erzeugt Hermes daraus. diese Datei; Container-Updates überschreiben sie nicht.
`config/mcp-registry.json` ist nur der Neuinstallations-Seed.
- Hermes verbindet sich einmal mit MCPHubs gemeinsamem `/mcp`-Endpunkt. Neue
aktivierte Server erscheinen dadurch nach **MCP neu laden**, ohne dass pro
MCP eine weitere Hermes-Konfiguration geschrieben werden muss.
- Modelle und Athena-Backups liegen auf `/data`. Hermes liegt vollständig unter - Modelle und Athena-Backups liegen auf `/data`. Hermes liegt vollständig unter
`/mnt/nvme-storage/appdata/Hermes-Agent`; MCPHub-Zustand, Client-Schlüssel und `/mnt/nvme-storage/appdata/Hermes-Agent`; MCPHub-Zustand, Client-Schlüssel und
MCP-Zugänge liegen unter `/mnt/nvme-storage/appdata/MCPHub`. MCP-Zugänge liegen unter `/mnt/nvme-storage/appdata/MCPHub`.
+38 -8
View File
@@ -1,13 +1,43 @@
{ {
"version": 1, "version": 1,
"servers": [ "servers": [
{
"id": "mcphub-all",
"hermes_id": "mcphub",
"name": "MCPHub",
"description": "Zentraler Zugang zu allen auf Unraid aktivierten MCP-Servern. Neue Server erscheinen nach einem MCP-Reload ohne Änderung der Hermes-Konfiguration.",
"url": "http://192.168.1.2:8787/mcp",
"clients": ["hermes"],
"env_file": "/etc/mike-ai/mcphub-client.env",
"key_env": "MCPHUB_BEARER_TOKEN",
"auth_type": "bearer",
"timeout": 900
},
{
"id": "mcphub-admin-local",
"name": "MCPHub Administration",
"description": "Installiert und verwaltet HTTP-, npm- und Python-MCPs direkt über die offizielle MCPHub-API. Kein Unraid-Terminal erforderlich.",
"url": "http://192.168.1.2:8787/mcp/mcphub-admin",
"clients": [],
"timeout": 300,
"hub": {
"type": "stdio",
"command": "python3",
"args": ["/opt/casaderoll/mcps/mcphub_admin_mcp.py"],
"env": {
"MCPHUB_API_URL": "http://127.0.0.1:3000/api",
"MCPHUB_API_TOKEN_FILE": "/app/data/client-token"
},
"enabled": true
}
},
{ {
"id": "athena-operator-local", "id": "athena-operator-local",
"hermes_id": "athena-operator", "hermes_id": "athena-operator",
"name": "Athena Operator", "name": "Athena Operator",
"description": "Zentrale administrative Schnittstelle für Athena. Beginne mit athena_operator_inspect(subject=guide). Verwaltet Docker, Modelle, MCPs, Git und Backups; Stromversorgung und Remote-Erreichbarkeit bleiben blockiert.", "description": "Zentrale administrative Schnittstelle für Athena. Beginne mit athena_operator_inspect(subject=guide). Verwaltet Docker, Modelle, MCPs, Git und Backups; Stromversorgung und Remote-Erreichbarkeit bleiben blockiert.",
"url": "http://192.168.1.2:8787/mcp/athena-operator", "url": "http://192.168.1.2:8787/mcp/athena-operator",
"clients": ["hermes", "openwebui"], "clients": ["openwebui"],
"env_file": "/etc/mike-ai/mcphub-client.env", "env_file": "/etc/mike-ai/mcphub-client.env",
"key_env": "MCPHUB_BEARER_TOKEN", "key_env": "MCPHUB_BEARER_TOKEN",
"auth_type": "bearer", "auth_type": "bearer",
@@ -25,7 +55,7 @@
"name": "GitHub (offiziell, read-only)", "name": "GitHub (offiziell, read-only)",
"description": "Repository-Suche, echte Datei-Inhalte und gezielte Code-Suche. Keine rekursiven Komplettbäume oder Schreibzugriffe.", "description": "Repository-Suche, echte Datei-Inhalte und gezielte Code-Suche. Keine rekursiven Komplettbäume oder Schreibzugriffe.",
"url": "http://192.168.1.2:8787/mcp/github", "url": "http://192.168.1.2:8787/mcp/github",
"clients": ["hermes", "openwebui"], "clients": ["openwebui"],
"env_file": "/etc/mike-ai/mcphub-client.env", "env_file": "/etc/mike-ai/mcphub-client.env",
"key_env": "MCPHUB_BEARER_TOKEN", "key_env": "MCPHUB_BEARER_TOKEN",
"auth_type": "bearer", "auth_type": "bearer",
@@ -44,7 +74,7 @@
"name": "Home Assistant", "name": "Home Assistant",
"description": "Entitäten, Zustände, Historie, Automationen, Dashboards, Diagnose und freigegebene YAML-Dateien. Änderungen nur auf ausdrücklichen Auftrag.", "description": "Entitäten, Zustände, Historie, Automationen, Dashboards, Diagnose und freigegebene YAML-Dateien. Änderungen nur auf ausdrücklichen Auftrag.",
"url": "http://192.168.1.2:8787/mcp/homeassistant", "url": "http://192.168.1.2:8787/mcp/homeassistant",
"clients": ["hermes", "openwebui"], "clients": ["openwebui"],
"env_file": "/etc/mike-ai/mcphub-client.env", "env_file": "/etc/mike-ai/mcphub-client.env",
"key_env": "MCPHUB_BEARER_TOKEN", "key_env": "MCPHUB_BEARER_TOKEN",
"auth_type": "bearer", "auth_type": "bearer",
@@ -64,7 +94,7 @@
"name": "Sonarr und Radarr", "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.", "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://192.168.1.2:8787/mcp/arr", "url": "http://192.168.1.2:8787/mcp/arr",
"clients": ["hermes", "openwebui"], "clients": ["openwebui"],
"env_file": "/etc/mike-ai/mcphub-client.env", "env_file": "/etc/mike-ai/mcphub-client.env",
"key_env": "MCPHUB_BEARER_TOKEN", "key_env": "MCPHUB_BEARER_TOKEN",
"auth_type": "bearer", "auth_type": "bearer",
@@ -82,7 +112,7 @@
"name": "Navidrome", "name": "Navidrome",
"description": "Persönliche Musikbibliothek: Titel, Alben, Künstler, Playlists, Favoriten und Hörverlauf.", "description": "Persönliche Musikbibliothek: Titel, Alben, Künstler, Playlists, Favoriten und Hörverlauf.",
"url": "http://192.168.1.2:8787/mcp/navidrome", "url": "http://192.168.1.2:8787/mcp/navidrome",
"clients": ["hermes", "openwebui"], "clients": ["openwebui"],
"env_file": "/etc/mike-ai/mcphub-client.env", "env_file": "/etc/mike-ai/mcphub-client.env",
"key_env": "MCPHUB_BEARER_TOKEN", "key_env": "MCPHUB_BEARER_TOKEN",
"auth_type": "bearer", "auth_type": "bearer",
@@ -101,7 +131,7 @@
"name": "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.", "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://192.168.1.2:8787/mcp/deemix", "url": "http://192.168.1.2:8787/mcp/deemix",
"clients": ["hermes", "openwebui"], "clients": ["openwebui"],
"env_file": "/etc/mike-ai/mcphub-client.env", "env_file": "/etc/mike-ai/mcphub-client.env",
"key_env": "MCPHUB_BEARER_TOKEN", "key_env": "MCPHUB_BEARER_TOKEN",
"auth_type": "bearer", "auth_type": "bearer",
@@ -123,7 +153,7 @@
"key_env": "MCPHUB_BEARER_TOKEN", "key_env": "MCPHUB_BEARER_TOKEN",
"env_file": "/etc/mike-ai/mcphub-client.env", "env_file": "/etc/mike-ai/mcphub-client.env",
"auth_type": "bearer", "auth_type": "bearer",
"clients": ["hermes", "openwebui"], "clients": ["openwebui"],
"timeout": 900, "timeout": 900,
"hub": { "hub": {
"type": "streamable-http", "type": "streamable-http",
@@ -143,7 +173,7 @@
"key_env": "MCPHUB_BEARER_TOKEN", "key_env": "MCPHUB_BEARER_TOKEN",
"env_file": "/etc/mike-ai/mcphub-client.env", "env_file": "/etc/mike-ai/mcphub-client.env",
"auth_type": "bearer", "auth_type": "bearer",
"clients": ["hermes", "openwebui"], "clients": ["openwebui"],
"timeout": 300, "timeout": 300,
"tool_include": [ "tool_include": [
"fritzbox-list_services", "fritzbox-list_services",
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<Container version="2"> <Container version="2">
<Name>MCPHub</Name> <Name>MCPHub</Name>
<Repository>casaderoll/mcphub:1.2.1</Repository> <Repository>casaderoll/mcphub:1.2.2</Repository>
<Registry>https://hub.docker.com/r/samanhappy/mcphub</Registry> <Registry>https://hub.docker.com/r/samanhappy/mcphub</Registry>
<Network>bridge</Network> <Network>bridge</Network>
<MyIP/> <MyIP/>
+1
View File
@@ -60,6 +60,7 @@ class DeployExtensionTest(unittest.TestCase):
values = { values = {
"appdata": self.appdata, "registry": self.registry, "appdata": self.appdata, "registry": self.registry,
"secrets": self.secrets, "manifest": self.manifest, "id": "example", "secrets": self.secrets, "manifest": self.manifest, "id": "example",
"skip_api": True,
} }
values.update(extra) values.update(extra)
return argparse.Namespace(**values) return argparse.Namespace(**values)
+1 -1
View File
@@ -95,7 +95,7 @@ docker run --rm --entrypoint python \
-v "$HERMES_DATA_DIR:/hermes:rw" \ -v "$HERMES_DATA_DIR:/hermes:rw" \
-v "$mcphub_registry:/run/input/mcp-registry.json:ro" \ -v "$mcphub_registry:/run/input/mcp-registry.json:ro" \
"${token_mount[@]}" \ "${token_mount[@]}" \
casaderoll/mcphub:1.2.1 \ casaderoll/mcphub:1.2.2 \
/stack/platform/mcp/sync-clients.py "${sync_args[@]}" /stack/platform/mcp/sync-clients.py "${sync_args[@]}"
"$STACK_DIR/platform/hermes/install-skills.sh" "$STACK_DIR/platform/hermes/install-skills.sh"
@@ -77,14 +77,14 @@ docker exec MCPHub python3 /opt/casaderoll/deploy-extension.py stage \
The helper copies verified files, updates the registry atomically, and always The helper copies verified files, updates the registry atomically, and always
stages disabled. It technically blocks activation when the dedicated secret stages disabled. It technically blocks activation when the dedicated secret
or a required key is missing. or a required key is missing.
7. Recreate only `MCPHub` once so it reconciles the external registry. Do not 7. The helper registers the disabled server through MCPHub's official API. Do
restart Hermes, Athena, Router, Qwen, WireGuard, Unraid, or other services. not recreate MCPHub and do not restart Hermes, Athena, Router, Qwen,
8. If credentials are ready, activate with the helper, recreate only MCPHub, WireGuard, Unraid, or other services.
then verify: health; all old routes; new handshake; `list_tools` schemas; 8. If credentials are ready, activate with the helper, then verify: health;
one bounded read-only call; no test writes or residue. Otherwise stop while all old routes; new handshake; `list_tools` schemas; one bounded read-only
disabled. call; no test writes or residue. Otherwise stop while disabled.
9. Run the existing client-sync script only after successful activation. Do 9. Ask Hermes to reload MCP connections. Hermes uses MCPHub's aggregate `/mcp`
not edit client YAML by hand. New routes are not advertised automatically. endpoint, so no per-server client config or YAML edit is needed.
10. Report version, state, tool count, secret path (never values), tests, 10. Report version, state, tool count, secret path (never values), tests,
client sync, durable source status, and rollback. client sync, durable source status, and rollback.
+1
View File
@@ -31,6 +31,7 @@ COPY --from=github /server/github-mcp-server /usr/local/bin/github-mcp-server
COPY --from=navidrome /app /opt/casaderoll/navidrome COPY --from=navidrome /app /opt/casaderoll/navidrome
COPY platform/mcp/deemix_mcp.py /opt/casaderoll/mcps/deemix_mcp.py COPY platform/mcp/deemix_mcp.py /opt/casaderoll/mcps/deemix_mcp.py
COPY platform/mcphub/mcphub_admin_mcp.py /opt/casaderoll/mcps/mcphub_admin_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_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/mcp/patches/mcp_radarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_radarr.py
COPY config/mcp-registry.json /opt/casaderoll/config/mcp-registry.json COPY config/mcp-registry.json /opt/casaderoll/config/mcp-registry.json
+16 -10
View File
@@ -22,8 +22,8 @@ sie sich einen Docker-Container und ein Appdata-Backup teilen.
`/mnt/nvme-storage/appdata/MCPHub` on Unraid contains: `/mnt/nvme-storage/appdata/MCPHub` on Unraid contains:
- `mcp_settings.json` (users, server registrations and tool toggles) - `mcp_settings.json` (produktive Serverliste, Benutzer und Tool-Schalter)
- `config/mcp-registry.json` (produktive deklarative Serverliste) - `config/mcp-registry.json` (Seed und Extension-Metadaten für Recovery)
- `extensions/<id>/` (geprüfte portable MCP-Laufzeiten) - `extensions/<id>/` (geprüfte portable MCP-Laufzeiten)
- `work/<id>/` (Manifest, Build- und Resume-Zwischenstand) - `work/<id>/` (Manifest, Build- und Resume-Zwischenstand)
- `jwt-secret` (stable login sessions) - `jwt-secret` (stable login sessions)
@@ -56,9 +56,10 @@ 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 pro Client nötig, ohne die MCP-Routen anonym zu öffnen. Port 8787 darf nicht ins
öffentliche Internet weitergeleitet werden. öffentliche Internet weitergeleitet werden.
`configure-settings.py` erhält bestehende MCPHub-Benutzer und rendert die `configure-settings.py` rendert die Registry ausschließlich bei einer frischen
externe Produktionsliste. Das Image liefert nur den Seed für einen leeren Wiederherstellung. Danach sind MCPHubs eigene Oberfläche und offizielle API die
Neuaufbau. `verify-hub.py` führt Quelle der Wahrheit; ein Image-Update überschreibt neue MCPs nicht.
`verify-hub.py` führt
Handshakes und Tool-Listen ohne Schreibzugriff aus. `probe-hub.py` führt genau Handshakes und Tool-Listen ohne Schreibzugriff aus. `probe-hub.py` führt genau
eine ausdrücklich benannte, begrenzte Funktionsprobe aus. eine ausdrücklich benannte, begrenzte Funktionsprobe aus.
@@ -73,11 +74,16 @@ docker exec MCPHub python3 /opt/casaderoll/deploy-extension.py stage \
--manifest /app/data/work/<id>/manifest.json --manifest /app/data/work/<id>/manifest.json
``` ```
Der Helfer prüft Checksummen, kopiert atomar nach `extensions/<id>` und setzt Der Helfer prüft Checksummen, kopiert atomar nach `extensions/<id>`, registriert
den Server immer zuerst auf deaktiviert. Fehlt die dedizierte Secret-Datei oder den Server über MCPHubs offizielle API und setzt ihn immer zuerst auf
ein Pflichtfeld, verweigert er die Aktivierung technisch und veröffentlicht deaktiviert. Fehlt die dedizierte Secret-Datei oder ein Pflichtfeld, verweigert
den Server an keinen Client. Erst nach Handshake und begrenzter read-only Probe er die Aktivierung technisch. Ein Neustart des MCPHub-Containers ist nicht
wird aktiviert und die Client-Konfiguration aus derselben Registry erzeugt. erforderlich.
Hermes verbindet sich nur mit dem gemeinsamen Endpunkt `/mcp`. Dadurch werden
neu aktivierte Server nach **MCP neu laden** sichtbar, ohne pro MCP eine weitere
Hermes-Konfiguration zu erzeugen. Der interne Server `mcphub-admin` stellt die
üblichen Installationswege für HTTP-, npm- und Python-MCPs als Werkzeuge bereit.
## Migrationsregel ## Migrationsregel
+7
View File
@@ -30,8 +30,15 @@ if [ ! -s "$registry_file" ]; then
cp /opt/casaderoll/config/mcp-registry.json "$registry_file" cp /opt/casaderoll/config/mcp-registry.json "$registry_file"
chmod 0600 "$registry_file" chmod 0600 "$registry_file"
fi fi
# MCPHub's own persistent settings and official API are the runtime source of
# truth. Render the declarative registry only for a fresh recovery (or an
# explicitly requested migration), never on every image update: otherwise an
# MCP installed through the dashboard/API would disappear on restart.
if [ ! -s "$settings_file" ] || [ "${MCPHUB_RECONCILE:-0}" = "1" ]; then
python3 /opt/casaderoll/configure-settings.py \ python3 /opt/casaderoll/configure-settings.py \
"$settings_file" /run/secrets/mcphub \ "$settings_file" /run/secrets/mcphub \
--registry "$registry_file" --registry "$registry_file"
fi
exec "$@" exec "$@"
+83
View File
@@ -16,6 +16,9 @@ import pathlib
import re import re
import shutil import shutil
import tempfile import tempfile
import urllib.error
import urllib.parse
import urllib.request
ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$") ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
@@ -89,6 +92,81 @@ def find_server(document: dict, server_id: str) -> dict | None:
return next((item for item in document["servers"] if item.get("id") == server_id), None) return next((item for item in document["servers"] if item.get("id") == server_id), None)
def expand_runtime(value: object, values: dict[str, str]) -> object:
if isinstance(value, str):
def replace(match: re.Match[str]) -> str:
key = match.group(1)
resolved = values.get(key, "").strip()
if not resolved:
raise SystemExit(f"Missing runtime value: {key}")
return resolved
return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", replace, value)
if isinstance(value, list):
return [expand_runtime(item, values) for item in value]
if isinstance(value, dict):
return {key: expand_runtime(item, values) for key, item in value.items()}
return value
def api_request(args: argparse.Namespace, method: str, path: str,
body: dict | None = None, allow_not_found: bool = False) -> dict | None:
if getattr(args, "skip_api", False):
return None
token_file = getattr(args, "token_file", None) or args.appdata / "client-token"
token = pathlib.Path(token_file).read_text(encoding="utf-8").strip()
if not token:
raise SystemExit("MCPHub API token is empty")
payload = None if body is None else json.dumps(body).encode("utf-8")
request = urllib.request.Request(
str(getattr(args, "api_url", "http://127.0.0.1:3000/api")).rstrip("/") + path,
data=payload,
method=method,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read(1_000_000)
except urllib.error.HTTPError as exc:
if allow_not_found and exc.code == 404:
return None
detail = exc.read(500).decode("utf-8", errors="replace").replace("\n", " ")
raise SystemExit(f"MCPHub API HTTP {exc.code}: {detail[:300]}") from exc
except OSError as exc:
raise SystemExit(f"MCPHub API unavailable: {str(exc)[:300]}") from exc
return json.loads(raw) if raw else {}
def sync_runtime(args: argparse.Namespace, server: dict, enabled: bool,
credentials_ready: bool) -> None:
if getattr(args, "skip_api", False):
return
name = str(server.get("hermes_id") or server["id"])
config = json.loads(json.dumps(server["hub"]))
secret_name = str(config.pop("secret_file", ""))
# env_keys intentionally returns names only. Re-read values only for the
# runtime rendering path, never print or return them.
if credentials_ready and secret_name:
values: dict[str, str] = {}
for raw in (args.secrets / secret_name).read_text(encoding="utf-8", errors="replace").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.removeprefix("export ").strip()] = value.strip().strip("\"'")
config = expand_runtime(json.loads(json.dumps(server["hub"])), values)
config.pop("secret_file", None)
config["enabled"] = bool(enabled)
current = api_request(args, "GET", f"/servers/{urllib.parse.quote(name, safe='')}", allow_not_found=True)
if current is None:
api_request(args, "POST", "/servers", {"name": name, "config": config})
else:
api_request(args, "PUT", f"/servers/{urllib.parse.quote(name, safe='')}", {"config": config})
def validate_server(server: dict) -> str: def validate_server(server: dict) -> str:
server_id = str(server.get("id") or "") server_id = str(server.get("id") or "")
if not ID_RE.fullmatch(server_id): if not ID_RE.fullmatch(server_id):
@@ -149,6 +227,7 @@ def stage(args: argparse.Namespace) -> None:
document["servers"] = [item for item in document["servers"] if item.get("id") != server_id] document["servers"] = [item for item in document["servers"] if item.get("id") != server_id]
document["servers"].append(server) document["servers"].append(server)
atomic_json(args.registry, document) atomic_json(args.registry, document)
sync_runtime(args, server, False, ready)
print(json.dumps({ print(json.dumps({
"status": "staged", "id": server_id, "enabled": False, "status": "staged", "id": server_id, "enabled": False,
"credentials_ready": ready, "credential_state": reason, "credentials_ready": ready, "credential_state": reason,
@@ -164,6 +243,7 @@ def set_enabled(args: argparse.Namespace, enabled: bool) -> None:
ready, reason = credential_state(server, args.secrets) ready, reason = credential_state(server, args.secrets)
if enabled and not ready: if enabled and not ready:
raise SystemExit(f"Activation refused: {reason}") raise SystemExit(f"Activation refused: {reason}")
sync_runtime(args, server, enabled, ready)
server["hub"]["enabled"] = enabled server["hub"]["enabled"] = enabled
desired = list((server.get("deployment") or {}).get("desired_clients", [])) desired = list((server.get("deployment") or {}).get("desired_clients", []))
server["clients"] = desired if enabled else [] server["clients"] = desired if enabled else []
@@ -194,6 +274,9 @@ def main() -> None:
parser.add_argument("--appdata", type=pathlib.Path, default=pathlib.Path("/app/data")) parser.add_argument("--appdata", type=pathlib.Path, default=pathlib.Path("/app/data"))
parser.add_argument("--registry", type=pathlib.Path, default=pathlib.Path("/app/data/config/mcp-registry.json")) parser.add_argument("--registry", type=pathlib.Path, default=pathlib.Path("/app/data/config/mcp-registry.json"))
parser.add_argument("--secrets", type=pathlib.Path, default=pathlib.Path("/run/secrets/mcphub")) parser.add_argument("--secrets", type=pathlib.Path, default=pathlib.Path("/run/secrets/mcphub"))
parser.add_argument("--api-url", default="http://127.0.0.1:3000/api")
parser.add_argument("--token-file", type=pathlib.Path, default=pathlib.Path("/app/data/client-token"))
parser.add_argument("--skip-api", action="store_true", help=argparse.SUPPRESS)
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
stage_cmd = sub.add_parser("stage") stage_cmd = sub.add_parser("stage")
stage_cmd.add_argument("--manifest", type=pathlib.Path, required=True) stage_cmd.add_argument("--manifest", type=pathlib.Path, required=True)
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""Small MCP client for MCPHub's official management API.
This server deliberately exposes the common installation path (remote HTTP,
npx, uvx) without giving an agent a shell on Unraid. MCPHub remains the
single source of truth and performs process supervision itself.
"""
from __future__ import annotations
import json
import os
import pathlib
import re
import urllib.error
import urllib.request
from typing import Any
from mcp.server.fastmcp import FastMCP
API_BASE = os.environ.get("MCPHUB_API_URL", "http://127.0.0.1:3000/api").rstrip("/")
TOKEN_FILE = pathlib.Path(os.environ.get("MCPHUB_API_TOKEN_FILE", "/app/data/client-token"))
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$")
NPM_PACKAGE_RE = re.compile(r"^(?:@[A-Za-z0-9._-]+/)?[A-Za-z0-9._-]+(?:@[A-Za-z0-9._+~-]+)?$")
PYTHON_PACKAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*(?:==[A-Za-z0-9._+~-]+)?$")
MAX_SERVERS = 80
MAX_TOOLS = 80
mcp = FastMCP(
"mcphub-admin",
instructions=(
"Manage MCP servers through MCPHub's official API. Prefer HTTP MCPs or "
"pinned npx/uvx packages. Inspect first, install once, then verify the "
"connection and tools. Removal requires the user's explicit request."
),
)
class HubError(RuntimeError):
pass
def _token() -> str:
try:
value = TOKEN_FILE.read_text(encoding="utf-8").strip()
except OSError as exc:
raise HubError("MCPHub API token is unavailable") from exc
if not value:
raise HubError("MCPHub API token is empty")
return value
def _request(method: str, path: str, body: dict[str, Any] | None = None) -> Any:
payload = None if body is None else json.dumps(body).encode("utf-8")
request = urllib.request.Request(
API_BASE + path,
data=payload,
method=method,
headers={
"Authorization": f"Bearer {_token()}",
"Accept": "application/json",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read(2_000_000)
except urllib.error.HTTPError as exc:
detail = exc.read(800).decode("utf-8", errors="replace").replace("\n", " ")
raise HubError(f"MCPHub API returned HTTP {exc.code}: {detail[:500]}") from exc
except OSError as exc:
raise HubError(f"MCPHub API is unreachable: {str(exc)[:300]}") from exc
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise HubError("MCPHub API returned invalid JSON") from exc
def _name(value: str) -> str:
value = value.strip()
if not NAME_RE.fullmatch(value):
raise HubError("Name must contain only letters, numbers, dot, underscore or hyphen")
return value
def _package(value: str, pattern: re.Pattern[str]) -> str:
value = value.strip()
if not pattern.fullmatch(value):
raise HubError("Invalid package name or version")
return value
def _args(value: list[str] | None) -> list[str]:
result = [str(item) for item in (value or [])]
if len(result) > 20 or any(len(item) > 300 for item in result):
raise HubError("At most 20 bounded arguments are allowed")
return result
SECRET_KEYS = re.compile(r"(?i)(authorization|password|passwd|secret|token|api.?key|cookie)")
def _redact(value: Any) -> Any:
if isinstance(value, dict):
return {
str(key): ("[configured]" if SECRET_KEYS.search(str(key)) else _redact(item))
for key, item in value.items()
}
if isinstance(value, list):
return [_redact(item) for item in value[:MAX_TOOLS]]
if isinstance(value, str) and len(value) > 500:
return value[:500] + "..."
return value
def _compact_server(item: dict[str, Any]) -> dict[str, Any]:
tools = item.get("tools") if isinstance(item.get("tools"), list) else []
config = item.get("config") if isinstance(item.get("config"), dict) else {}
return {
"name": item.get("name"),
"status": item.get("status"),
"enabled": config.get("enabled", True),
"type": config.get("type"),
"tool_count": len(tools),
"tools": [tool.get("name") for tool in tools[:MAX_TOOLS] if isinstance(tool, dict)],
}
def _install(name: str, config: dict[str, Any]) -> str:
name = _name(name)
existing = _request("GET", "/servers")
rows = existing.get("data", []) if isinstance(existing, dict) else []
if any(isinstance(row, dict) and row.get("name") == name for row in rows):
raise HubError(f"Server already exists: {name}; inspect or update it instead")
result = _request("POST", "/servers", {"name": name, "config": config})
verified = _request("GET", f"/servers/{name}")
data = verified.get("data", verified) if isinstance(verified, dict) else verified
return json.dumps({"installed": True, "server": _redact(data), "api_result": _redact(result)}, ensure_ascii=False)
@mcp.tool()
def mcphub_admin_list_servers() -> str:
"""List configured MCPHub servers with connection state and compact tool names."""
result = _request("GET", "/servers")
rows = result.get("data", []) if isinstance(result, dict) else []
compact = [_compact_server(row) for row in rows[:MAX_SERVERS] if isinstance(row, dict)]
return json.dumps({"count": len(rows), "servers": compact, "truncated": len(rows) > MAX_SERVERS}, ensure_ascii=False)
@mcp.tool()
def mcphub_admin_get_server(name: str) -> str:
"""Inspect one MCPHub server, its status, transport and exposed tools. Secrets are redacted."""
name = _name(name)
result = _request("GET", f"/servers/{name}")
return json.dumps(_redact(result), ensure_ascii=False)
@mcp.tool()
def mcphub_admin_install_http(name: str, url: str, enabled: bool = True) -> str:
"""Install a remote Streamable-HTTP MCP by URL and verify registration. Use OAuth-capable entries through the UI when interactive login is required."""
url = url.strip()
if not re.match(r"^https?://", url):
raise HubError("HTTP MCP URL must start with http:// or https://")
return _install(name, {"type": "streamable-http", "url": url, "enabled": bool(enabled), "owner": "admin"})
@mcp.tool()
def mcphub_admin_install_npx(name: str, package: str, arguments: list[str] | None = None, enabled: bool = True) -> str:
"""Install an npm-distributed stdio MCP with npx. Pin package@version whenever possible; arguments are passed without a shell."""
package = _package(package, NPM_PACKAGE_RE)
args = ["-y", package, *_args(arguments)]
return _install(name, {"type": "stdio", "command": "npx", "args": args, "enabled": bool(enabled), "owner": "admin"})
@mcp.tool()
def mcphub_admin_install_uvx(name: str, package: str, arguments: list[str] | None = None, enabled: bool = True) -> str:
"""Install a Python-distributed stdio MCP with uvx. Pin package==version whenever possible; arguments are passed without a shell."""
package = _package(package, PYTHON_PACKAGE_RE)
return _install(name, {"type": "stdio", "command": "uvx", "args": [package, *_args(arguments)], "enabled": bool(enabled), "owner": "admin"})
@mcp.tool()
def mcphub_admin_set_enabled(name: str, enabled: bool) -> str:
"""Enable or disable one explicitly named MCPHub server."""
name = _name(name)
result = _request("POST", f"/servers/{name}/toggle", {"enabled": bool(enabled)})
return json.dumps(_redact(result), ensure_ascii=False)
@mcp.tool()
def mcphub_admin_reload(name: str) -> str:
"""Reconnect or respawn one explicitly named MCPHub server after a change."""
name = _name(name)
result = _request("POST", f"/servers/{name}/reload")
return json.dumps(_redact(result), ensure_ascii=False)
@mcp.tool()
def mcphub_admin_remove(name: str, confirmation: str) -> str:
"""Permanently remove a server only after the user explicitly requested it. confirmation must exactly equal REMOVE:<name>."""
name = _name(name)
if confirmation != f"REMOVE:{name}":
raise HubError(f"Confirmation must exactly equal REMOVE:{name}")
result = _request("DELETE", f"/servers/{name}")
return json.dumps(_redact(result), ensure_ascii=False)
if __name__ == "__main__":
mcp.run(transport="stdio")