diff --git a/README.md b/README.md index 9fdb781..7f4c0b3 100644 --- a/README.md +++ b/README.md @@ -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 Keenable-Provider für Suche und Seitenabruf; der frühere Athena-Webadapter wird nicht mehr gestartet. -- Die produktive Liste der MCPHub-Server liegt in Unraid-Appdata unter - `MCPHub/config/mcp-registry.json`; `config/mcp-registry.json` ist der - Neuinstallations-Seed. `platform/mcp/sync-clients.py` erzeugt Hermes daraus. +- MCPHubs eigene persistente Einstellungen unter `MCPHub/mcp_settings.json` + sind der produktive Zustand. Oberfläche und offizielle API ändern genau + 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 `/mnt/nvme-storage/appdata/Hermes-Agent`; MCPHub-Zustand, Client-Schlüssel und MCP-Zugänge liegen unter `/mnt/nvme-storage/appdata/MCPHub`. diff --git a/config/mcp-registry.json b/config/mcp-registry.json index e864ab9..419cb7a 100644 --- a/config/mcp-registry.json +++ b/config/mcp-registry.json @@ -1,13 +1,43 @@ { "version": 1, "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", "hermes_id": "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.", "url": "http://192.168.1.2:8787/mcp/athena-operator", - "clients": ["hermes", "openwebui"], + "clients": ["openwebui"], "env_file": "/etc/mike-ai/mcphub-client.env", "key_env": "MCPHUB_BEARER_TOKEN", "auth_type": "bearer", @@ -25,7 +55,7 @@ "name": "GitHub (offiziell, read-only)", "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", - "clients": ["hermes", "openwebui"], + "clients": ["openwebui"], "env_file": "/etc/mike-ai/mcphub-client.env", "key_env": "MCPHUB_BEARER_TOKEN", "auth_type": "bearer", @@ -44,7 +74,7 @@ "name": "Home Assistant", "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", - "clients": ["hermes", "openwebui"], + "clients": ["openwebui"], "env_file": "/etc/mike-ai/mcphub-client.env", "key_env": "MCPHUB_BEARER_TOKEN", "auth_type": "bearer", @@ -64,7 +94,7 @@ "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://192.168.1.2:8787/mcp/arr", - "clients": ["hermes", "openwebui"], + "clients": ["openwebui"], "env_file": "/etc/mike-ai/mcphub-client.env", "key_env": "MCPHUB_BEARER_TOKEN", "auth_type": "bearer", @@ -82,7 +112,7 @@ "name": "Navidrome", "description": "Persönliche Musikbibliothek: Titel, Alben, Künstler, Playlists, Favoriten und Hörverlauf.", "url": "http://192.168.1.2:8787/mcp/navidrome", - "clients": ["hermes", "openwebui"], + "clients": ["openwebui"], "env_file": "/etc/mike-ai/mcphub-client.env", "key_env": "MCPHUB_BEARER_TOKEN", "auth_type": "bearer", @@ -101,7 +131,7 @@ "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://192.168.1.2:8787/mcp/deemix", - "clients": ["hermes", "openwebui"], + "clients": ["openwebui"], "env_file": "/etc/mike-ai/mcphub-client.env", "key_env": "MCPHUB_BEARER_TOKEN", "auth_type": "bearer", @@ -123,7 +153,7 @@ "key_env": "MCPHUB_BEARER_TOKEN", "env_file": "/etc/mike-ai/mcphub-client.env", "auth_type": "bearer", - "clients": ["hermes", "openwebui"], + "clients": ["openwebui"], "timeout": 900, "hub": { "type": "streamable-http", @@ -143,7 +173,7 @@ "key_env": "MCPHUB_BEARER_TOKEN", "env_file": "/etc/mike-ai/mcphub-client.env", "auth_type": "bearer", - "clients": ["hermes", "openwebui"], + "clients": ["openwebui"], "timeout": 300, "tool_include": [ "fritzbox-list_services", diff --git a/config/unraid-templates/my-MCPHub.xml b/config/unraid-templates/my-MCPHub.xml index 3978b57..bc753bd 100644 --- a/config/unraid-templates/my-MCPHub.xml +++ b/config/unraid-templates/my-MCPHub.xml @@ -1,7 +1,7 @@ MCPHub - casaderoll/mcphub:1.2.1 + casaderoll/mcphub:1.2.2 https://hub.docker.com/r/samanhappy/mcphub bridge diff --git a/dev/test_mcphub_deploy_extension.py b/dev/test_mcphub_deploy_extension.py index 8595e09..4bb718e 100644 --- a/dev/test_mcphub_deploy_extension.py +++ b/dev/test_mcphub_deploy_extension.py @@ -60,6 +60,7 @@ class DeployExtensionTest(unittest.TestCase): values = { "appdata": self.appdata, "registry": self.registry, "secrets": self.secrets, "manifest": self.manifest, "id": "example", + "skip_api": True, } values.update(extra) return argparse.Namespace(**values) diff --git a/platform/hermes/install-profiles.sh b/platform/hermes/install-profiles.sh index 88c6b5e..c8df4ae 100755 --- a/platform/hermes/install-profiles.sh +++ b/platform/hermes/install-profiles.sh @@ -95,7 +95,7 @@ docker run --rm --entrypoint python \ -v "$HERMES_DATA_DIR:/hermes:rw" \ -v "$mcphub_registry:/run/input/mcp-registry.json:ro" \ "${token_mount[@]}" \ - casaderoll/mcphub:1.2.1 \ + casaderoll/mcphub:1.2.2 \ /stack/platform/mcp/sync-clients.py "${sync_args[@]}" "$STACK_DIR/platform/hermes/install-skills.sh" diff --git a/platform/hermes/skills/mcphub-deployer/SKILL.md b/platform/hermes/skills/mcphub-deployer/SKILL.md index e367383..121f1e0 100644 --- a/platform/hermes/skills/mcphub-deployer/SKILL.md +++ b/platform/hermes/skills/mcphub-deployer/SKILL.md @@ -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 stages disabled. It technically blocks activation when the dedicated secret or a required key is missing. -7. Recreate only `MCPHub` once so it reconciles the external registry. Do not - restart Hermes, Athena, Router, Qwen, WireGuard, Unraid, or other services. -8. If credentials are ready, activate with the helper, recreate only MCPHub, - then verify: health; all old routes; new handshake; `list_tools` schemas; - one bounded read-only call; no test writes or residue. Otherwise stop while - disabled. -9. Run the existing client-sync script only after successful activation. Do - not edit client YAML by hand. New routes are not advertised automatically. +7. The helper registers the disabled server through MCPHub's official API. Do + not recreate MCPHub and do not restart Hermes, Athena, Router, Qwen, + WireGuard, Unraid, or other services. +8. If credentials are ready, activate with the helper, then verify: health; + all old routes; new handshake; `list_tools` schemas; one bounded read-only + call; no test writes or residue. Otherwise stop while disabled. +9. Ask Hermes to reload MCP connections. Hermes uses MCPHub's aggregate `/mcp` + endpoint, so no per-server client config or YAML edit is needed. 10. Report version, state, tool count, secret path (never values), tests, client sync, durable source status, and rollback. diff --git a/platform/mcphub/Dockerfile b/platform/mcphub/Dockerfile index 726d8de..e8e642e 100644 --- a/platform/mcphub/Dockerfile +++ b/platform/mcphub/Dockerfile @@ -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 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_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 diff --git a/platform/mcphub/README.md b/platform/mcphub/README.md index d9af85e..749369c 100644 --- a/platform/mcphub/README.md +++ b/platform/mcphub/README.md @@ -22,8 +22,8 @@ sie sich einen Docker-Container und ein Appdata-Backup teilen. `/mnt/nvme-storage/appdata/MCPHub` on Unraid contains: -- `mcp_settings.json` (users, server registrations and tool toggles) -- `config/mcp-registry.json` (produktive deklarative Serverliste) +- `mcp_settings.json` (produktive Serverliste, Benutzer und Tool-Schalter) +- `config/mcp-registry.json` (Seed und Extension-Metadaten für Recovery) - `extensions//` (geprüfte portable MCP-Laufzeiten) - `work//` (Manifest, Build- und Resume-Zwischenstand) - `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 öffentliche Internet weitergeleitet werden. -`configure-settings.py` erhält bestehende MCPHub-Benutzer und rendert die -externe Produktionsliste. Das Image liefert nur den Seed für einen leeren -Neuaufbau. `verify-hub.py` führt +`configure-settings.py` rendert die Registry ausschließlich bei einer frischen +Wiederherstellung. Danach sind MCPHubs eigene Oberfläche und offizielle API die +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 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//manifest.json ``` -Der Helfer prüft Checksummen, kopiert atomar nach `extensions/` und setzt -den Server immer zuerst auf deaktiviert. Fehlt die dedizierte Secret-Datei oder -ein Pflichtfeld, verweigert er die Aktivierung technisch und veröffentlicht -den Server an keinen Client. Erst nach Handshake und begrenzter read-only Probe -wird aktiviert und die Client-Konfiguration aus derselben Registry erzeugt. +Der Helfer prüft Checksummen, kopiert atomar nach `extensions/`, registriert +den Server über MCPHubs offizielle API und setzt ihn immer zuerst auf +deaktiviert. Fehlt die dedizierte Secret-Datei oder ein Pflichtfeld, verweigert +er die Aktivierung technisch. Ein Neustart des MCPHub-Containers ist nicht +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 diff --git a/platform/mcphub/casaderoll-entrypoint.sh b/platform/mcphub/casaderoll-entrypoint.sh index 731a130..03c95af 100644 --- a/platform/mcphub/casaderoll-entrypoint.sh +++ b/platform/mcphub/casaderoll-entrypoint.sh @@ -30,8 +30,15 @@ if [ ! -s "$registry_file" ]; then cp /opt/casaderoll/config/mcp-registry.json "$registry_file" chmod 0600 "$registry_file" fi -python3 /opt/casaderoll/configure-settings.py \ - "$settings_file" /run/secrets/mcphub \ - --registry "$registry_file" + +# 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 \ + "$settings_file" /run/secrets/mcphub \ + --registry "$registry_file" +fi exec "$@" diff --git a/platform/mcphub/deploy-extension.py b/platform/mcphub/deploy-extension.py index cdb5d71..382fb2e 100644 --- a/platform/mcphub/deploy-extension.py +++ b/platform/mcphub/deploy-extension.py @@ -16,6 +16,9 @@ import pathlib import re import shutil import tempfile +import urllib.error +import urllib.parse +import urllib.request 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) +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: server_id = str(server.get("id") or "") 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"].append(server) atomic_json(args.registry, document) + sync_runtime(args, server, False, ready) print(json.dumps({ "status": "staged", "id": server_id, "enabled": False, "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) if enabled and not ready: raise SystemExit(f"Activation refused: {reason}") + sync_runtime(args, server, enabled, ready) server["hub"]["enabled"] = enabled desired = list((server.get("deployment") or {}).get("desired_clients", [])) 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("--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("--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) stage_cmd = sub.add_parser("stage") stage_cmd.add_argument("--manifest", type=pathlib.Path, required=True) diff --git a/platform/mcphub/mcphub_admin_mcp.py b/platform/mcphub/mcphub_admin_mcp.py new file mode 100644 index 0000000..bab5858 --- /dev/null +++ b/platform/mcphub/mcphub_admin_mcp.py @@ -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) + 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")