add autonomous GitHub MCP installer

This commit is contained in:
Mikei386
2026-08-26 12:46:48 +02:00
parent 9ae7d22618
commit 588f24894d
8 changed files with 837 additions and 113 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0"?>
<Container version="2">
<Name>MCPHub</Name>
<Repository>casaderoll/mcphub:1.2.4</Repository>
<Repository>casaderoll/mcphub:1.2.5</Repository>
<Registry>https://hub.docker.com/r/samanhappy/mcphub</Registry>
<Network>bridge</Network>
<MyIP/>
+131
View File
@@ -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()
+1 -1
View File
@@ -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"
+34 -92
View File
@@ -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/<id>`
- Extensions: `/mnt/nvme-storage/appdata/MCPHub/extensions/<id>`
- Registry: `/mnt/nvme-storage/appdata/MCPHub/config/mcp-registry.json`
- Secret: `/mnt/nvme-storage/appdata/MCPHub/secrets/<id>.env` (0600)
- Helper in container: `/opt/casaderoll/deploy-extension.py`
- Route: `http://192.168.1.2:8787/mcp/<id>`
- 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 `<work>/manifest.json`:
1. Report the exact missing environment key names, if any. The user fills
`/mnt/nvme-storage/appdata/MCPHub/secrets/<name>.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:<name>`; it returns disabled, so
inspect and activate separately. Removal still requires `REMOVE:<name>`.
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/<id>/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 `<work>/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.
+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,
}