add autonomous GitHub MCP installer
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic GitHub MCP installer used by the MCPHub admin server.
|
||||
|
||||
The installer deliberately does not invoke a shell. It accepts one GitHub
|
||||
repository, resolves the requested ref to an immutable commit, builds either a
|
||||
Python or Node runtime in versioned MCPHub appdata and returns a stdio server
|
||||
configuration. Publishing and MCPHub API updates remain the responsibility of
|
||||
the small admin MCP wrapper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/+~-]{0,199}$")
|
||||
ENV_RE = re.compile(r"^[A-Z_][A-Z0-9_]{0,127}$")
|
||||
EXECUTABLE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$")
|
||||
MAX_ARGS = 20
|
||||
MAX_REQUIRED_ENV = 40
|
||||
COMMAND_TIMEOUT = 900
|
||||
|
||||
|
||||
class GitInstallError(RuntimeError):
|
||||
"""A bounded, user-facing installation error."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitInstallSpec:
|
||||
name: str
|
||||
repository: str
|
||||
ref: str
|
||||
runtime: str
|
||||
entrypoint: str
|
||||
subdirectory: str = "."
|
||||
arguments: tuple[str, ...] = ()
|
||||
required_env: tuple[str, ...] = ()
|
||||
run_build: bool = True
|
||||
|
||||
|
||||
def _bounded(text: object, limit: int = 600) -> str:
|
||||
value = str(text).replace("\x00", "").strip()
|
||||
return value if len(value) <= limit else value[:limit] + "..."
|
||||
|
||||
|
||||
def _run(argv: list[str], cwd: pathlib.Path | None = None,
|
||||
timeout: int = COMMAND_TIMEOUT) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise GitInstallError(f"Command failed to start or timed out: {_bounded(exc)}") from exc
|
||||
if result.returncode != 0:
|
||||
command = pathlib.Path(argv[0]).name
|
||||
raise GitInstallError(f"{command} exited with {result.returncode}: {_bounded(result.stdout)}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def validate_repository(value: str) -> str:
|
||||
value = value.strip().removesuffix("/")
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme != "https" or parsed.hostname not in {"github.com", "www.github.com"}:
|
||||
raise GitInstallError("Repository must be an HTTPS GitHub URL")
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise GitInstallError("Repository URL must not contain credentials, query parameters or fragments")
|
||||
parts = [item for item in parsed.path.removesuffix(".git").split("/") if item]
|
||||
if len(parts) != 2 or any(not re.fullmatch(r"[A-Za-z0-9._-]+", item) for item in parts):
|
||||
raise GitInstallError("Repository must have the form https://github.com/OWNER/REPOSITORY")
|
||||
return f"https://github.com/{parts[0]}/{parts[1]}.git"
|
||||
|
||||
|
||||
def validate_relative_path(value: str) -> str:
|
||||
value = value.strip() or "."
|
||||
path = pathlib.PurePosixPath(value)
|
||||
if path.is_absolute() or ".." in path.parts:
|
||||
raise GitInstallError("Subdirectory and entrypoint paths must stay inside the repository")
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def normalize_spec(spec: GitInstallSpec) -> GitInstallSpec:
|
||||
name = spec.name.strip().lower()
|
||||
if not NAME_RE.fullmatch(name):
|
||||
raise GitInstallError("Name must be lowercase letters, numbers or hyphens")
|
||||
repository = validate_repository(spec.repository)
|
||||
ref = spec.ref.strip()
|
||||
if not REF_RE.fullmatch(ref) or ref.startswith("-"):
|
||||
raise GitInstallError("Invalid Git ref")
|
||||
runtime = spec.runtime.strip().lower()
|
||||
if runtime not in {"python", "node"}:
|
||||
raise GitInstallError("Runtime must be python or node")
|
||||
subdirectory = validate_relative_path(spec.subdirectory)
|
||||
entrypoint = spec.entrypoint.strip()
|
||||
if runtime == "python":
|
||||
if not EXECUTABLE_RE.fullmatch(entrypoint):
|
||||
raise GitInstallError("Python entrypoint must be the installed console-script name")
|
||||
else:
|
||||
if entrypoint.startswith("bin:"):
|
||||
if not EXECUTABLE_RE.fullmatch(entrypoint[4:]):
|
||||
raise GitInstallError("Invalid Node package bin name")
|
||||
else:
|
||||
entrypoint = validate_relative_path(entrypoint)
|
||||
arguments = tuple(str(item) for item in spec.arguments)
|
||||
if len(arguments) > MAX_ARGS or any(len(item) > 300 or "\x00" in item for item in arguments):
|
||||
raise GitInstallError(f"At most {MAX_ARGS} bounded arguments are allowed")
|
||||
required_env = tuple(dict.fromkeys(str(item).strip() for item in spec.required_env))
|
||||
if len(required_env) > MAX_REQUIRED_ENV or any(not ENV_RE.fullmatch(item) for item in required_env):
|
||||
raise GitInstallError("Required environment names must use uppercase ENV_STYLE names")
|
||||
return GitInstallSpec(
|
||||
name=name,
|
||||
repository=repository,
|
||||
ref=ref,
|
||||
runtime=runtime,
|
||||
entrypoint=entrypoint,
|
||||
subdirectory=subdirectory,
|
||||
arguments=arguments,
|
||||
required_env=required_env,
|
||||
run_build=bool(spec.run_build),
|
||||
)
|
||||
|
||||
|
||||
def _atomic_json(path: pathlib.Path, value: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(value, handle, indent=2, ensure_ascii=False)
|
||||
handle.write("\n")
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def env_key_state(path: pathlib.Path, required: tuple[str, ...]) -> tuple[bool, list[str]]:
|
||||
if not required:
|
||||
return True, []
|
||||
present: set[str] = set()
|
||||
if path.is_file():
|
||||
for raw in path.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)
|
||||
if value.strip().strip("\"'"):
|
||||
present.add(key.removeprefix("export ").strip())
|
||||
missing = [item for item in required if item not in present]
|
||||
return not missing, missing
|
||||
|
||||
|
||||
def _clone(spec: GitInstallSpec, destination: pathlib.Path) -> str:
|
||||
if destination.exists():
|
||||
shutil.rmtree(destination)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
_run(["git", "-c", "core.hooksPath=/dev/null", "init", str(destination)])
|
||||
_run(["git", "-C", str(destination), "remote", "add", "origin", spec.repository])
|
||||
_run([
|
||||
"git", "-c", "core.hooksPath=/dev/null", "-C", str(destination),
|
||||
"fetch", "--depth", "1", "origin", spec.ref,
|
||||
])
|
||||
_run(["git", "-c", "core.hooksPath=/dev/null", "-C", str(destination), "checkout", "--detach", "FETCH_HEAD"])
|
||||
commit = _run(["git", "-C", str(destination), "rev-parse", "HEAD"]).strip()
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", commit):
|
||||
raise GitInstallError("Git returned an invalid commit id")
|
||||
return commit
|
||||
|
||||
|
||||
def _source_root(checkout: pathlib.Path, subdirectory: str) -> pathlib.Path:
|
||||
root = (checkout / subdirectory).resolve()
|
||||
if checkout.resolve() != root and checkout.resolve() not in root.parents:
|
||||
raise GitInstallError("Subdirectory escaped the checkout")
|
||||
if not root.is_dir():
|
||||
raise GitInstallError(f"Repository subdirectory does not exist: {subdirectory}")
|
||||
return root
|
||||
|
||||
|
||||
def _python_release(source: pathlib.Path, release: pathlib.Path, entrypoint: str) -> list[str]:
|
||||
if not ((source / "pyproject.toml").is_file() or (source / "setup.py").is_file()):
|
||||
raise GitInstallError("Python project has neither pyproject.toml nor setup.py")
|
||||
release.mkdir(parents=True, exist_ok=False)
|
||||
venv = release / ".venv"
|
||||
try:
|
||||
_run(["uv", "venv", "--python", "python3", str(venv)])
|
||||
_run(["uv", "pip", "install", "--python", str(venv / "bin/python"), str(source)])
|
||||
executable = venv / "bin" / entrypoint
|
||||
if not executable.is_file():
|
||||
raise GitInstallError(f"Installed Python console script does not exist: {entrypoint}")
|
||||
executable.chmod(executable.stat().st_mode | 0o100)
|
||||
return [str(executable)]
|
||||
except BaseException:
|
||||
shutil.rmtree(release, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def _node_release(source: pathlib.Path, release: pathlib.Path, entrypoint: str,
|
||||
run_build: bool) -> list[str]:
|
||||
package_file = source / "package.json"
|
||||
if not package_file.is_file():
|
||||
raise GitInstallError("Node project has no package.json")
|
||||
try:
|
||||
package = json.loads(package_file.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise GitInstallError("Node package.json is invalid") from exc
|
||||
app = release / "app"
|
||||
release.mkdir(parents=True, exist_ok=False)
|
||||
try:
|
||||
shutil.copytree(source, app, dirs_exist_ok=True, ignore=shutil.ignore_patterns(".git", "node_modules"))
|
||||
install = ["npm", "ci"] if (app / "package-lock.json").is_file() else ["npm", "install"]
|
||||
_run(install, cwd=app)
|
||||
scripts = package.get("scripts") if isinstance(package.get("scripts"), dict) else {}
|
||||
if run_build and "build" in scripts:
|
||||
_run(["npm", "run", "build"], cwd=app)
|
||||
if entrypoint.startswith("bin:"):
|
||||
bin_value = package.get("bin")
|
||||
bin_name = entrypoint[4:]
|
||||
if isinstance(bin_value, str):
|
||||
bin_path = bin_value
|
||||
elif isinstance(bin_value, dict) and isinstance(bin_value.get(bin_name), str):
|
||||
bin_path = bin_value[bin_name]
|
||||
else:
|
||||
raise GitInstallError(f"Node package does not declare bin: {bin_name}")
|
||||
executable = (app / bin_path).resolve()
|
||||
if app.resolve() not in executable.parents or not executable.is_file():
|
||||
raise GitInstallError(f"Built Node package bin does not exist: {bin_name}")
|
||||
return ["node", str(executable)]
|
||||
executable = (app / entrypoint).resolve()
|
||||
if app.resolve() not in executable.parents or not executable.is_file():
|
||||
raise GitInstallError(f"Node entrypoint does not exist: {entrypoint}")
|
||||
return ["node", str(executable)]
|
||||
except BaseException:
|
||||
shutil.rmtree(release, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def prepare_release(spec: GitInstallSpec, appdata: pathlib.Path,
|
||||
secrets: pathlib.Path) -> dict[str, Any]:
|
||||
spec = normalize_spec(spec)
|
||||
work = appdata / "work" / spec.name
|
||||
checkout = work / "source"
|
||||
commit = _clone(spec, checkout)
|
||||
release_id = commit[:12]
|
||||
extension = appdata / "extensions" / spec.name
|
||||
release = extension / "releases" / release_id
|
||||
source = _source_root(checkout, spec.subdirectory)
|
||||
if release.exists():
|
||||
state_file = extension / "install-state.json"
|
||||
if not state_file.is_file():
|
||||
raise GitInstallError("Existing release has no installation state; refusing to guess")
|
||||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
release_info = (state.get("releases") or {}).get(release_id)
|
||||
expected = {
|
||||
"commit": commit,
|
||||
"repository": spec.repository,
|
||||
"runtime": spec.runtime,
|
||||
"entrypoint": spec.entrypoint,
|
||||
"subdirectory": spec.subdirectory,
|
||||
"arguments": list(spec.arguments),
|
||||
"required_env": list(spec.required_env),
|
||||
"run_build": spec.run_build,
|
||||
}
|
||||
if not isinstance(release_info, dict) or any(
|
||||
release_info.get(key) != value for key, value in expected.items()
|
||||
):
|
||||
raise GitInstallError("Existing release metadata does not match the resolved commit")
|
||||
command = list(release_info["command"])
|
||||
elif spec.runtime == "python":
|
||||
command = _python_release(source, release, spec.entrypoint)
|
||||
command.extend(spec.arguments)
|
||||
else:
|
||||
command = _node_release(source, release, spec.entrypoint, spec.run_build)
|
||||
command.extend(spec.arguments)
|
||||
secret_file = secrets / f"{spec.name}.env"
|
||||
credentials_ready, missing = env_key_state(secret_file, spec.required_env)
|
||||
if spec.required_env:
|
||||
hub_command = "/usr/local/bin/run-with-env"
|
||||
hub_args = [f"/run/secrets/mcphub/{spec.name}.env", "--", *command]
|
||||
else:
|
||||
hub_command, *hub_args = command
|
||||
config = {
|
||||
"type": "stdio",
|
||||
"command": hub_command,
|
||||
"args": hub_args,
|
||||
"enabled": False,
|
||||
"owner": "admin",
|
||||
}
|
||||
state_file = extension / "install-state.json"
|
||||
previous_state: dict[str, Any] = {}
|
||||
if state_file.is_file():
|
||||
try:
|
||||
previous_state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise GitInstallError("Existing installation state is invalid") from exc
|
||||
old_current = previous_state.get("current_release")
|
||||
releases = previous_state.get("releases") if isinstance(previous_state.get("releases"), dict) else {}
|
||||
releases[release_id] = {
|
||||
"commit": commit,
|
||||
"repository": spec.repository,
|
||||
"ref": spec.ref,
|
||||
"runtime": spec.runtime,
|
||||
"entrypoint": spec.entrypoint,
|
||||
"subdirectory": spec.subdirectory,
|
||||
"arguments": list(spec.arguments),
|
||||
"required_env": list(spec.required_env),
|
||||
"run_build": spec.run_build,
|
||||
"command": command,
|
||||
"config": config,
|
||||
}
|
||||
state = {
|
||||
"version": 1,
|
||||
"current_release": release_id,
|
||||
"previous_release": old_current if old_current and old_current != release_id else previous_state.get("previous_release"),
|
||||
"releases": releases,
|
||||
}
|
||||
_atomic_json(state_file, state)
|
||||
return {
|
||||
"name": spec.name,
|
||||
"repository": spec.repository,
|
||||
"requested_ref": spec.ref,
|
||||
"commit": commit,
|
||||
"release": release_id,
|
||||
"previous_release": state.get("previous_release"),
|
||||
"runtime": spec.runtime,
|
||||
"config": config,
|
||||
"required_env": list(spec.required_env),
|
||||
"secret_file": f"{spec.name}.env" if spec.required_env else None,
|
||||
"credentials_ready": credentials_ready,
|
||||
"missing_env": missing,
|
||||
}
|
||||
|
||||
|
||||
def rollback_release(name: str, appdata: pathlib.Path) -> dict[str, Any]:
|
||||
name = name.strip().lower()
|
||||
if not NAME_RE.fullmatch(name):
|
||||
raise GitInstallError("Invalid server name")
|
||||
state_file = appdata / "extensions" / name / "install-state.json"
|
||||
if not state_file.is_file():
|
||||
raise GitInstallError(f"No managed Git installation exists: {name}")
|
||||
try:
|
||||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise GitInstallError("Installation state is invalid") from exc
|
||||
current = state.get("current_release")
|
||||
previous = state.get("previous_release")
|
||||
releases = state.get("releases") if isinstance(state.get("releases"), dict) else {}
|
||||
release = releases.get(previous)
|
||||
if not previous or not isinstance(release, dict):
|
||||
raise GitInstallError("No previous release is available")
|
||||
state["current_release"] = previous
|
||||
state["previous_release"] = current
|
||||
_atomic_json(state_file, state)
|
||||
return {
|
||||
"name": name,
|
||||
"release": previous,
|
||||
"previous_release": current,
|
||||
"commit": release.get("commit"),
|
||||
"config": release.get("config"),
|
||||
}
|
||||
|
||||
|
||||
def registry_entry(result: dict[str, Any], description: str,
|
||||
clients: list[str] | None = None) -> dict[str, Any]:
|
||||
name = str(result["name"])
|
||||
config = json.loads(json.dumps(result["config"]))
|
||||
return {
|
||||
"id": f"{name}-local",
|
||||
"hermes_id": name,
|
||||
"name": name,
|
||||
"description": _bounded(description, 300),
|
||||
"url": f"http://192.168.1.2:8787/mcp/{name}",
|
||||
"clients": list(clients or []),
|
||||
"deployment": {
|
||||
"kind": "git",
|
||||
"repository": result["repository"],
|
||||
"ref": result["requested_ref"],
|
||||
"commit": result["commit"],
|
||||
"release": result["release"],
|
||||
"required_env": result["required_env"],
|
||||
"secret_file": result["secret_file"],
|
||||
},
|
||||
"hub": config,
|
||||
}
|
||||
|
||||
|
||||
def update_registry(path: pathlib.Path, entry: dict[str, Any]) -> None:
|
||||
if path.is_file():
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
document = {"version": 1, "servers": []}
|
||||
if document.get("version") != 1 or not isinstance(document.get("servers"), list):
|
||||
raise GitInstallError("Unsupported MCP registry schema")
|
||||
server_id = entry["id"]
|
||||
hermes_id = entry["hermes_id"]
|
||||
document["servers"] = [
|
||||
item for item in document["servers"]
|
||||
if not isinstance(item, dict)
|
||||
or (item.get("id") != server_id and item.get("hermes_id") != hermes_id)
|
||||
]
|
||||
document["servers"].append(entry)
|
||||
_atomic_json(path, document)
|
||||
|
||||
|
||||
def state_summary(name: str, appdata: pathlib.Path, secrets: pathlib.Path) -> dict[str, Any]:
|
||||
name = name.strip().lower()
|
||||
if not NAME_RE.fullmatch(name):
|
||||
raise GitInstallError("Invalid server name")
|
||||
state_file = appdata / "extensions" / name / "install-state.json"
|
||||
if not state_file.is_file():
|
||||
return {"name": name, "managed": False}
|
||||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
current = state.get("current_release")
|
||||
release = (state.get("releases") or {}).get(current, {})
|
||||
required = tuple(release.get("required_env") or ())
|
||||
ready, missing = env_key_state(secrets / f"{name}.env", required)
|
||||
return {
|
||||
"name": name,
|
||||
"managed": True,
|
||||
"current_release": current,
|
||||
"previous_release": state.get("previous_release"),
|
||||
"commit": release.get("commit"),
|
||||
"repository": release.get("repository"),
|
||||
"runtime": release.get("runtime"),
|
||||
"credentials_ready": ready,
|
||||
"missing_env": missing,
|
||||
}
|
||||
|
||||
|
||||
def current_release(name: str, appdata: pathlib.Path,
|
||||
secrets: pathlib.Path) -> dict[str, Any]:
|
||||
"""Return the active managed release, its disabled config and credential state."""
|
||||
name = name.strip().lower()
|
||||
if not NAME_RE.fullmatch(name):
|
||||
raise GitInstallError("Invalid server name")
|
||||
state_file = appdata / "extensions" / name / "install-state.json"
|
||||
if not state_file.is_file():
|
||||
raise GitInstallError(f"No managed Git installation exists: {name}")
|
||||
try:
|
||||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise GitInstallError("Installation state is invalid") from exc
|
||||
release_id = state.get("current_release")
|
||||
release = (state.get("releases") or {}).get(release_id)
|
||||
if not isinstance(release, dict) or not isinstance(release.get("config"), dict):
|
||||
raise GitInstallError("Current managed release metadata is incomplete")
|
||||
required = tuple(release.get("required_env") or ())
|
||||
ready, missing = env_key_state(secrets / f"{name}.env", required)
|
||||
config = json.loads(json.dumps(release["config"]))
|
||||
config["enabled"] = False
|
||||
return {
|
||||
"name": name,
|
||||
"repository": release.get("repository"),
|
||||
"requested_ref": release.get("ref"),
|
||||
"commit": release.get("commit"),
|
||||
"release": release_id,
|
||||
"runtime": release.get("runtime"),
|
||||
"config": config,
|
||||
"required_env": list(required),
|
||||
"secret_file": f"{name}.env" if required else None,
|
||||
"credentials_ready": ready,
|
||||
"missing_env": missing,
|
||||
}
|
||||
Reference in New Issue
Block a user