add autonomous GitHub MCP installer
This commit is contained in:
1 parent
9ae7d22618
commit
588f24894d
8 files changed
+837
-113
No files matched your search
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user