#!/usr/bin/env python3 """Small MCP client for MCPHub's official management API. This server deliberately exposes the common installation paths (remote HTTP, npx, uvx and GitHub source builds) without giving an agent a shell on Unraid. MCPHub remains the single source of truth and performs process supervision. """ from __future__ import annotations import json import os import pathlib import re import urllib.error import urllib.parse import urllib.request from typing import Any from mcp.server.fastmcp import FastMCP from mcphub_git_installer import ( GitInstallError, GitInstallSpec, current_release, prepare_release, registry_entry, rollback_release, state_summary, update_registry, ) 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")) CLIENT_GROUP = os.environ.get("MCPHUB_CLIENT_GROUP", "hermes").strip() APPDATA = pathlib.Path(os.environ.get("MCPHUB_APPDATA_DIR", "/app/data")) SECRETS = pathlib.Path(os.environ.get("MCPHUB_SECRETS_DIR", "/run/secrets/mcphub")) REGISTRY = pathlib.Path(os.environ.get("MCPHUB_REGISTRY_FILE", "/app/data/config/mcp-registry.json")) 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 without a terminal. For a GitHub " "repository use mcphub_admin_install_git; never use execute_code, SSH, " "Unraid shell or Docker commands. Git installs are staged disabled, " "then activated with mcphub_admin_activate_git after credentials exist. " "Removal and rollback require explicit confirmation." ), ) 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}) group_result = _add_to_group(name) verified = _request("GET", f"/servers/{name}") data = verified.get("data", verified) if isinstance(verified, dict) else verified return json.dumps({ "installed": True, "published_to": CLIENT_GROUP, "server": _redact(data), "api_result": _redact(result), "group_result": _redact(group_result), }, ensure_ascii=False) def _group_id(name: str) -> str: result = _request("GET", "/groups") rows = result.get("data", []) if isinstance(result, dict) else [] match = next( (row for row in rows if isinstance(row, dict) and (row.get("name") == name or row.get("id") == name)), None, ) if not isinstance(match, dict) or not match.get("id"): raise HubError(f"MCPHub group does not exist: {name}") return str(match["id"]) def _add_to_group(name: str, tools: list[str] | None = None) -> Any: if not CLIENT_GROUP: return {"skipped": "no-client-group"} group = urllib.parse.quote(_group_id(CLIENT_GROUP), safe="") configs = _request("GET", f"/groups/{group}/server-configs") rows = configs.get("data", []) if isinstance(configs, dict) else [] present = any(isinstance(row, dict) and row.get("name") == name for row in rows) result: Any = {"already_present": True} if not present: result = _request("POST", f"/groups/{group}/servers", {"serverName": name}) if tools is not None: result = _request( "PUT", f"/groups/{group}/server-configs/{urllib.parse.quote(name, safe='')}/tools", {"tools": tools}, ) return result def _remove_from_group(name: str) -> Any: if not CLIENT_GROUP: return {"skipped": "no-client-group"} group = urllib.parse.quote(_group_id(CLIENT_GROUP), safe="") configs = _request("GET", f"/groups/{group}/server-configs") rows = configs.get("data", []) if isinstance(configs, dict) else [] if not any(isinstance(row, dict) and row.get("name") == name for row in rows): return {"already_absent": True} return _request("DELETE", f"/groups/{group}/servers/{urllib.parse.quote(name, safe='')}") def _upsert_config(name: str, config: dict[str, Any]) -> Any: result = _request("GET", "/servers") rows = result.get("data", []) if isinstance(result, dict) else [] exists = any(isinstance(row, dict) and row.get("name") == name for row in rows) if exists: return _request("PUT", f"/servers/{urllib.parse.quote(name, safe='')}", {"config": config}) return _request("POST", "/servers", {"name": name, "config": config}) def _registry_description(name: str) -> str: if not REGISTRY.is_file(): return f"MCP server {name}, installed from a GitHub repository." try: document = json.loads(REGISTRY.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return f"MCP server {name}, installed from a GitHub repository." for item in document.get("servers", []): if isinstance(item, dict) and item.get("hermes_id") == name: return str(item.get("description") or f"MCP server {name}.")[:300] return f"MCP server {name}, installed from a GitHub repository." def _sync_git_registry(result: dict[str, Any], description: str, active: bool) -> None: entry = registry_entry(result, description, ["hermes"] if active else []) entry["deployment"]["desired_clients"] = ["hermes"] entry["hub"]["enabled"] = bool(active) update_registry(REGISTRY, entry) @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_install_git( name: str, repository: str, runtime: str, entrypoint: str, description: str, ref: str = "main", subdirectory: str = ".", arguments: list[str] | None = None, required_env: list[str] | None = None, run_build: bool = True, ) -> str: """Clone and build one MCP from an HTTPS GitHub repository inside persistent MCPHub appdata. Use runtime='python' with an installed console-script entrypoint, or runtime='node' with a relative built JS file (or bin:NAME). This is the complete Git install path: do not use a terminal, execute_code, SSH, Docker or Unraid tools. It stages the server disabled and never prints secret values. Call mcphub_admin_activate_git only after this succeeds and required env keys are ready.""" try: result = prepare_release( GitInstallSpec( name=name, repository=repository, ref=ref, runtime=runtime, entrypoint=entrypoint, subdirectory=subdirectory, arguments=tuple(_args(arguments)), required_env=tuple(required_env or ()), run_build=run_build, ), APPDATA, SECRETS, ) config = json.loads(json.dumps(result["config"])) config["enabled"] = False _upsert_config(str(result["name"]), config) _remove_from_group(str(result["name"])) _sync_git_registry(result, description, False) except GitInstallError as exc: raise HubError(str(exc)) from exc return json.dumps({ "installed": True, "staged": True, "enabled": False, "name": result["name"], "commit": result["commit"], "release": result["release"], "previous_release": result["previous_release"], "credentials_ready": result["credentials_ready"], "missing_env": result["missing_env"], "next": "Call mcphub_admin_activate_git after credentials are ready.", }, ensure_ascii=False) @mcp.tool() def mcphub_admin_git_status(name: str) -> str: """Show the compact state of one MCP installed by mcphub_admin_install_git, including release and missing environment key names but never values.""" try: result = state_summary(name, APPDATA, SECRETS) except GitInstallError as exc: raise HubError(str(exc)) from exc return json.dumps(result, ensure_ascii=False) @mcp.tool() def mcphub_admin_activate_git(name: str) -> str: """Activate and publish one successfully staged Git MCP. Refuses activation while required environment keys are missing; verifies the live server and returns its compact tool list.""" try: result = current_release(name, APPDATA, SECRETS) except GitInstallError as exc: raise HubError(str(exc)) from exc if not result["credentials_ready"]: raise HubError("Activation refused; missing environment keys: " + ",".join(result["missing_env"])) config = json.loads(json.dumps(result["config"])) config["enabled"] = True try: _upsert_config(str(result["name"]), config) _add_to_group(str(result["name"])) _request("POST", f"/servers/{urllib.parse.quote(str(result['name']), safe='')}/reload") verified = _request("GET", f"/servers/{urllib.parse.quote(str(result['name']), safe='')}") except BaseException: config["enabled"] = False try: _upsert_config(str(result["name"]), config) _remove_from_group(str(result["name"])) except BaseException: pass raise _sync_git_registry(result, _registry_description(str(result["name"])), True) data = verified.get("data", verified) if isinstance(verified, dict) else verified compact = _compact_server(data) if isinstance(data, dict) else data return json.dumps({"activated": True, "server": compact}, ensure_ascii=False) @mcp.tool() def mcphub_admin_rollback_git(name: str, confirmation: str) -> str: """Switch a managed Git MCP back to its previous built release and leave it disabled. Use only on explicit request; confirmation must exactly equal ROLLBACK:. Activate separately after inspection.""" safe_name = _name(name).lower() if confirmation != f"ROLLBACK:{safe_name}": raise HubError(f"Confirmation must exactly equal ROLLBACK:{safe_name}") try: rolled = rollback_release(safe_name, APPDATA) result = current_release(safe_name, APPDATA, SECRETS) except GitInstallError as exc: raise HubError(str(exc)) from exc _upsert_config(safe_name, result["config"]) _remove_from_group(safe_name) _sync_git_registry(result, _registry_description(safe_name), False) return json.dumps({ "rolled_back": True, "name": safe_name, "release": rolled["release"], "commit": rolled["commit"], "enabled": False, }, ensure_ascii=False) @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_publish_to_hermes(name: str, tools: list[str] | None = None) -> str: """Publish one installed server to the central Hermes group. Optionally expose only the named tools; omit tools to expose all.""" name = _name(name) selected = None if tools is None else _args(tools) result = _add_to_group(name, selected) return json.dumps({"published": name, "group": CLIENT_GROUP, "result": _redact(result)}, ensure_ascii=False) @mcp.tool() def mcphub_admin_unpublish_from_hermes(name: str) -> str: """Remove one server from the central Hermes group without uninstalling or disabling the server itself.""" name = _name(name) group = urllib.parse.quote(_group_id(CLIENT_GROUP), safe="") result = _request("DELETE", f"/groups/{group}/servers/{urllib.parse.quote(name, safe='')}") return json.dumps({"unpublished": name, "group": CLIENT_GROUP, "result": _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")