441 lines
18 KiB
Python
441 lines
18 KiB
Python
#!/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:<name>. 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(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")
|