481 lines
19 KiB
Python
481 lines
19 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,
|
|
}
|