From 588f24894d9c7880981eb8828037504a992e1bdb Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:46:48 +0200 Subject: [PATCH] add autonomous GitHub MCP installer --- config/unraid-templates/my-MCPHub.xml | 2 +- dev/test_mcphub_git_installer.py | 131 +++++ platform/hermes/install-profiles.sh | 2 +- .../hermes/skills/mcphub-deployer/SKILL.md | 126 ++--- platform/mcphub/Dockerfile | 1 + platform/mcphub/README.md | 26 +- platform/mcphub/mcphub_admin_mcp.py | 182 ++++++- platform/mcphub/mcphub_git_installer.py | 480 ++++++++++++++++++ 8 files changed, 837 insertions(+), 113 deletions(-) create mode 100644 dev/test_mcphub_git_installer.py create mode 100644 platform/mcphub/mcphub_git_installer.py diff --git a/config/unraid-templates/my-MCPHub.xml b/config/unraid-templates/my-MCPHub.xml index 61614e9..44ed729 100644 --- a/config/unraid-templates/my-MCPHub.xml +++ b/config/unraid-templates/my-MCPHub.xml @@ -1,7 +1,7 @@ MCPHub - casaderoll/mcphub:1.2.4 + casaderoll/mcphub:1.2.5 https://hub.docker.com/r/samanhappy/mcphub bridge diff --git a/dev/test_mcphub_git_installer.py b/dev/test_mcphub_git_installer.py new file mode 100644 index 0000000..a3896cb --- /dev/null +++ b/dev/test_mcphub_git_installer.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import importlib.util +import json +import pathlib +import sys +import tempfile +import unittest +from unittest import mock + + +SOURCE = pathlib.Path(__file__).parents[1] / "platform/mcphub/mcphub_git_installer.py" +SPEC = importlib.util.spec_from_file_location("mcphub_git_installer", SOURCE) +assert SPEC and SPEC.loader +installer = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = installer +SPEC.loader.exec_module(installer) + + +class GitInstallerTest(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.temp.name) + self.appdata = self.root / "appdata" + self.secrets = self.root / "secrets" + self.secrets.mkdir() + self.commits = iter(["a" * 40, "b" * 40, "c" * 40]) + + def tearDown(self) -> None: + self.temp.cleanup() + + def spec(self, **values: object): + data = { + "name": "example", + "repository": "https://github.com/example/mcp", + "ref": "main", + "runtime": "python", + "entrypoint": "example-mcp", + "arguments": ("--stdio",), + "required_env": ("EXAMPLE_TOKEN",), + } + data.update(values) + return installer.GitInstallSpec(**data) + + def clone(self, _spec, destination: pathlib.Path) -> str: + destination.mkdir(parents=True, exist_ok=True) + (destination / "pyproject.toml").write_text("[project]\nname='example'\n") + return next(self.commits) + + @staticmethod + def clone_same(_spec, destination: pathlib.Path) -> str: + destination.mkdir(parents=True, exist_ok=True) + (destination / "pyproject.toml").write_text("[project]\nname='example'\n") + return "a" * 40 + + @staticmethod + def build(_source: pathlib.Path, release: pathlib.Path, _entrypoint: str) -> list[str]: + release.mkdir(parents=True) + executable = release / ".venv/bin/example-mcp" + executable.parent.mkdir(parents=True) + executable.write_text("ok") + return [str(executable)] + + def test_install_is_disabled_and_reports_only_missing_key_names(self) -> None: + with mock.patch.object(installer, "_clone", self.clone), mock.patch.object( + installer, "_python_release", self.build + ): + result = installer.prepare_release(self.spec(), self.appdata, self.secrets) + self.assertFalse(result["config"]["enabled"]) + self.assertFalse(result["credentials_ready"]) + self.assertEqual(result["missing_env"], ["EXAMPLE_TOKEN"]) + self.assertEqual(result["config"]["command"], "/usr/local/bin/run-with-env") + self.assertEqual(result["config"]["args"][-1], "--stdio") + + def test_same_release_reuses_build_without_duplicating_arguments(self) -> None: + with mock.patch.object(installer, "_clone", self.clone_same), mock.patch.object( + installer, "_python_release", side_effect=self.build + ) as build: + first = installer.prepare_release(self.spec(), self.appdata, self.secrets) + second = installer.prepare_release(self.spec(), self.appdata, self.secrets) + self.assertEqual(build.call_count, 1) + self.assertEqual(first["config"]["args"], second["config"]["args"]) + self.assertEqual(second["config"]["args"].count("--stdio"), 1) + + def test_update_and_rollback_preserve_both_releases(self) -> None: + with mock.patch.object(installer, "_clone", self.clone), mock.patch.object( + installer, "_python_release", self.build + ): + first = installer.prepare_release(self.spec(), self.appdata, self.secrets) + second = installer.prepare_release(self.spec(ref="v2"), self.appdata, self.secrets) + self.assertEqual(second["previous_release"], first["release"]) + rolled = installer.rollback_release("example", self.appdata) + self.assertEqual(rolled["release"], first["release"]) + self.assertEqual(installer.current_release("example", self.appdata, self.secrets)["release"], first["release"]) + + def test_failed_update_leaves_previous_state_current(self) -> None: + with mock.patch.object(installer, "_clone", self.clone), mock.patch.object( + installer, "_python_release", self.build + ): + first = installer.prepare_release(self.spec(), self.appdata, self.secrets) + with mock.patch.object(installer, "_clone", self.clone), mock.patch.object( + installer, "_python_release", side_effect=installer.GitInstallError("build failed") + ): + with self.assertRaises(installer.GitInstallError): + installer.prepare_release(self.spec(ref="broken"), self.appdata, self.secrets) + current = installer.current_release("example", self.appdata, self.secrets) + self.assertEqual(current["release"], first["release"]) + + def test_registry_updates_only_matching_server(self) -> None: + registry = self.appdata / "config/mcp-registry.json" + registry.parent.mkdir(parents=True) + registry.write_text(json.dumps({"version": 1, "servers": [{"id": "keep", "hermes_id": "keep"}]})) + result = { + "name": "example", "repository": "https://github.com/example/mcp.git", + "requested_ref": "main", "commit": "a" * 40, "release": "a" * 12, + "required_env": [], "secret_file": None, + "config": {"type": "stdio", "command": "example", "args": [], "enabled": False}, + } + installer.update_registry(registry, installer.registry_entry(result, "Example")) + servers = json.loads(registry.read_text())["servers"] + self.assertEqual({item["id"] for item in servers}, {"keep", "example-local"}) + + def test_rejects_non_github_and_escaping_subdirectory(self) -> None: + with self.assertRaises(installer.GitInstallError): + installer.normalize_spec(self.spec(repository="https://evil.example/repo")) + with self.assertRaises(installer.GitInstallError): + installer.normalize_spec(self.spec(subdirectory="../escape")) + + +if __name__ == "__main__": + unittest.main() diff --git a/platform/hermes/install-profiles.sh b/platform/hermes/install-profiles.sh index 414e77d..efc0bf6 100755 --- a/platform/hermes/install-profiles.sh +++ b/platform/hermes/install-profiles.sh @@ -95,7 +95,7 @@ docker run --rm --entrypoint python \ -v "$HERMES_DATA_DIR:/hermes:rw" \ -v "$mcphub_registry:/run/input/mcp-registry.json:ro" \ "${token_mount[@]}" \ - casaderoll/mcphub:1.2.4 \ + casaderoll/mcphub:1.2.5 \ /stack/platform/mcp/sync-clients.py "${sync_args[@]}" "$STACK_DIR/platform/hermes/install-skills.sh" diff --git a/platform/hermes/skills/mcphub-deployer/SKILL.md b/platform/hermes/skills/mcphub-deployer/SKILL.md index 121f1e0..f228a6b 100644 --- a/platform/hermes/skills/mcphub-deployer/SKILL.md +++ b/platform/hermes/skills/mcphub-deployer/SKILL.md @@ -1,107 +1,49 @@ --- name: mcphub-deployer -description: Install, update, disable, test, publish, or inspect portable MCP servers in CasaDeRoll MCPHub on Unraid. Use for MCP repositories, packages, binaries, HTTP MCPs, client registration, MCPHub repair, or moving an MCP out of Athena or Hermes. +description: Install, update, activate, disable, inspect, or roll back MCP servers in the central CasaDeRoll MCPHub. Use whenever the user provides an MCP URL, package, or GitHub repository. --- # MCPHub Deployer -Install portable MCPs in the existing `MCPHub`; never create a separate -container and never install them inside Hermes. +Use only the `mcphub_admin_*` tools. They are the installer. Never use +`execute_code`, a terminal, SSH, Docker, Unraid tools, or edit MCPHub files. +Never create another container. Do not inspect or infer credentials. -## Fixed map +## Choose exactly one install tool -- Unraid: `192.168.1.2`; container: `MCPHub`; base URL: `http://192.168.1.2:8787` -- Appdata: `/mnt/nvme-storage/appdata/MCPHub` -- Work: `/mnt/nvme-storage/appdata/MCPHub/work/` -- Extensions: `/mnt/nvme-storage/appdata/MCPHub/extensions/` -- Registry: `/mnt/nvme-storage/appdata/MCPHub/config/mcp-registry.json` -- Secret: `/mnt/nvme-storage/appdata/MCPHub/secrets/.env` (0600) -- Helper in container: `/opt/casaderoll/deploy-extension.py` -- Route: `http://192.168.1.2:8787/mcp/` +- Existing HTTP MCP endpoint: `mcphub_admin_install_http` +- Published npm package: `mcphub_admin_install_npx` +- Published Python package: `mcphub_admin_install_uvx` +- GitHub source repository: `mcphub_admin_install_git` -Do not search for other checkouts, registries, templates, or secret stores. -Normal extensions do not modify Dockerfile, image tag, Unraid template, MCPHub -source, or Hermes config manually. +For a GitHub repository, read only the upstream README and its package manifest +to determine these fields: -## Hard credential boundary +- `runtime`: `python` or `node` +- Python `entrypoint`: the installed console-script name from `pyproject.toml` +- Node `entrypoint`: the built JavaScript path, or `bin:NAME` +- `subdirectory`: only when the package is inside a monorepo +- `arguments`: only documented server arguments +- `required_env`: names of required variables, never their values -Credentials are user input. Check only whether the dedicated secret file and -required keys exist; never print values. Never inspect other containers, -environments, mail servers, configs, histories, or passwords to find or infer -credentials. If credentials are missing, complete credential-independent build -work, stage the MCP disabled, report the exact secret path and missing key -names, then stop. Never publish or authenticate it. +Then call `mcphub_admin_install_git` once. It clones, builds, stores, registers, +and versions the MCP itself. A successful install is intentionally disabled. +Do not reproduce those steps manually. -## One-pass workflow +## Activation and proof -1. Read this skill once. Inspect only MCPHub state, the fixed registry, the - dedicated secret-file presence, and the target upstream release/source. -2. Classify once: existing HTTP MCP, packaged stdio MCP, released binary, - custom MCP, or host-bound HTTP proxy. Do not reconsider without a concrete - failed build or handshake. -3. Interpret intent: “prüfen/planen” changes nothing; “installieren/einbauen” - continues; “deaktiviert” never activates; “read-only” omits mutating tools. -4. Build only in `/tmp` or the fixed work directory. Pin versions and verify - checksums. Put runtime files in the work directory, not in the repository. -5. Write one compact manifest at `/manifest.json`: +1. Report the exact missing environment key names, if any. The user fills + `/mnt/nvme-storage/appdata/MCPHub/secrets/.env`; never read its values. +2. Check `mcphub_admin_git_status`. +3. Call `mcphub_admin_activate_git`. It refuses missing credentials, publishes + the server to Hermes, reloads it, and returns the live tool list. +4. Confirm success only when the returned server is connected and has tools. + Run at most one harmless read-only tool call if the user requested a test. -```json -{ - "server": { - "id": "example", "hermes_id": "example", "name": "Example", - "description": "Short tool-selection description", - "url": "http://192.168.1.2:8787/mcp/example", - "clients": ["hermes"], - "deployment": {"required_env": ["EXAMPLE_TOKEN"]}, - "hub": { - "type": "stdio", "secret_file": "example.env", - "command": "/usr/local/bin/run-with-env", - "args": ["/run/secrets/mcphub/example.env", "--", "node", "/app/data/extensions/example/index.js"], - "enabled": false - } - }, - "artifacts": [ - {"source": "/app/data/work/example/index.js", "path": "index.js", "sha256": "HEX", "mode": "0644"} - ] -} -``` +For an update, call the same install tool with the new pinned `ref`, then +activate it. The old release remains available. On explicit rollback use +`mcphub_admin_rollback_git` with `ROLLBACK:`; it returns disabled, so +inspect and activate separately. Removal still requires `REMOVE:`. -Use paths as seen inside MCPHub (`/app/data/...`) in the manifest. -6. Stage with exactly: - -```sh -docker exec MCPHub python3 /opt/casaderoll/deploy-extension.py stage \ - --manifest /app/data/work//manifest.json -``` - -The helper copies verified files, updates the registry atomically, and always -stages disabled. It technically blocks activation when the dedicated secret -or a required key is missing. -7. The helper registers the disabled server through MCPHub's official API. Do - not recreate MCPHub and do not restart Hermes, Athena, Router, Qwen, - WireGuard, Unraid, or other services. -8. If credentials are ready, activate with the helper, then verify: health; - all old routes; new handshake; `list_tools` schemas; one bounded read-only - call; no test writes or residue. Otherwise stop while disabled. -9. Ask Hermes to reload MCP connections. Hermes uses MCPHub's aggregate `/mcp` - endpoint, so no per-server client config or YAML edit is needed. -10. Report version, state, tool count, secret path (never values), tests, - client sync, durable source status, and rollback. - -## Limits - -- At most six preflight reads and two attempts per hypothesis. -- At most one corrected build. -- Never dump full registries, repository trees, logs, or configs; use bounded - queries and compact JSON. -- Never use one giant shell call to write several files. -- Never claim success without observed handshake and probe results. -- On failure leave the previous MCPHub running and the new extension disabled. -- Ask before destructive actions or credential rotation not explicitly asked. - -## Resume - -Before compression write `/checkpoint.json` containing only phase, -version, completed checks, pending action, modified paths, and rollback. After -compression reload this skill and that checkpoint before continuing. Delete -the checkpoint only after success. +If a tool returns an error, report that exact error. Make at most one corrected +call for a concrete parameter mistake. Never invent a second deployment path. diff --git a/platform/mcphub/Dockerfile b/platform/mcphub/Dockerfile index e8e642e..25b04eb 100644 --- a/platform/mcphub/Dockerfile +++ b/platform/mcphub/Dockerfile @@ -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 diff --git a/platform/mcphub/README.md b/platform/mcphub/README.md index 852c55d..5ee59e6 100644 --- a/platform/mcphub/README.md +++ b/platform/mcphub/README.md @@ -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/` 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//releases/` speichern und deaktiviert registrieren. -```bash -docker exec MCPHub python3 /opt/casaderoll/deploy-extension.py stage \ - --manifest /app/data/work//manifest.json -``` - -Der Helfer prüft Checksummen, kopiert atomar nach `extensions/`, 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 diff --git a/platform/mcphub/mcphub_admin_mcp.py b/platform/mcphub/mcphub_admin_mcp.py index b6a4afb..5b9cb0b 100644 --- a/platform/mcphub/mcphub_admin_mcp.py +++ b/platform/mcphub/mcphub_admin_mcp.py @@ -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:. 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.""" diff --git a/platform/mcphub/mcphub_git_installer.py b/platform/mcphub/mcphub_git_installer.py new file mode 100644 index 0000000..e6769ae --- /dev/null +++ b/platform/mcphub/mcphub_git_installer.py @@ -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, + }