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

+1
View File
@@ -32,6 +32,7 @@ COPY --from=navidrome /app /opt/casaderoll/navidrome
COPY platform/mcp/deemix_mcp.py /opt/casaderoll/mcps/deemix_mcp.py
COPY platform/mcphub/mcphub_admin_mcp.py /opt/casaderoll/mcps/mcphub_admin_mcp.py
COPY platform/mcphub/mcphub_git_installer.py /opt/casaderoll/mcps/mcphub_git_installer.py
COPY platform/mcp/patches/mcp_sonarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_sonarr.py
COPY platform/mcp/patches/mcp_radarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_radarr.py
COPY config/mcp-registry.json /opt/casaderoll/config/mcp-registry.json
+13 -13
View File
@@ -65,20 +65,20 @@ eine ausdrücklich benannte, begrenzte Funktionsprobe aus.
## Portable MCPs installieren
Neue portable MCPs erfordern keinen Image-Neubau und keine Änderung am
Unraid-Template. Laufzeitdateien werden zunächst unter `work/<id>` gebaut und
per Manifest mit dem geprüften Helfer übernommen:
Hermes installiert portable MCPs ausschließlich mit den Werkzeugen des
`mcphub-admin`-Servers. HTTP-Endpunkte sowie veröffentlichte npm-/Python-Pakete
haben je ein direktes Installationswerkzeug. Für GitHub-Repositories übernimmt
`mcphub_admin_install_git` den kompletten Ablauf: klonen, einen festen Commit
auflösen, Python oder Node bauen, versioniert unter
`extensions/<id>/releases/` speichern und deaktiviert registrieren.
```bash
docker exec MCPHub python3 /opt/casaderoll/deploy-extension.py stage \
--manifest /app/data/work/<id>/manifest.json
```
Der Helfer prüft Checksummen, kopiert atomar nach `extensions/<id>`, registriert
den Server über MCPHubs offizielle API und setzt ihn immer zuerst auf
deaktiviert. Fehlt die dedizierte Secret-Datei oder ein Pflichtfeld, verweigert
er die Aktivierung technisch. Ein Neustart des MCPHub-Containers ist nicht
erforderlich.
Danach prüft `mcphub_admin_git_status` ausschließlich, ob die namentlich
angegebenen Umgebungsvariablen vorhanden sind. Erst
`mcphub_admin_activate_git` aktiviert, veröffentlicht, lädt neu und liefert die
echte Tool-Liste. Updates benutzen denselben Installationsaufruf; die vorige
Version bleibt für `mcphub_admin_rollback_git` erhalten. Dafür sind weder
Terminal noch SSH, Docker-Befehle, ein Image-Neubau oder Änderungen am
Unraid-Template nötig.
Hermes verbindet sich nur mit der verwalteten Gruppe `/mcp/hermes`. Dadurch werden
neu aktivierte Server nach **MCP neu laden** sichtbar, ohne pro MCP eine weitere
+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."""
+480
View File
@@ -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,
}