Files
AI-Profile-Router/platform/mcphub/mcphub_git_installer.py
T

481 lines
20 KiB
Python

#!/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,
}