add autonomous GitHub MCP installer

This commit is contained in:
Mikei386 committed 2026-08-26 12:46:48 +02:00
1 parent 9ae7d22618
commit 588f24894d
8 files changed
+837 -113

No files matched your search

+176 -6
View File
@@ -1,9 +1,9 @@
#!/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.
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
@@ -19,10 +19,24 @@ 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._+~-]+)?$")
@@ -33,9 +47,11 @@ 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."
"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."
),
)
@@ -181,6 +197,46 @@ def _add_to_group(name: str, tools: list[str] | None = None) -> Any:
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."""
@@ -222,6 +278,120 @@ def mcphub_admin_install_uvx(name: str, package: str, arguments: list[str] | Non
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."""