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 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<Container version="2"> <Container version="2">
<Name>MCPHub</Name> <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> <Registry>https://hub.docker.com/r/samanhappy/mcphub</Registry>
<Network>bridge</Network> <Network>bridge</Network>
<MyIP/> <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 "$HERMES_DATA_DIR:/hermes:rw" \
-v "$mcphub_registry:/run/input/mcp-registry.json:ro" \ -v "$mcphub_registry:/run/input/mcp-registry.json:ro" \
"${token_mount[@]}" \ "${token_mount[@]}" \
casaderoll/mcphub:1.2.4 \ casaderoll/mcphub:1.2.5 \
/stack/platform/mcp/sync-clients.py "${sync_args[@]}" /stack/platform/mcp/sync-clients.py "${sync_args[@]}"
"$STACK_DIR/platform/hermes/install-skills.sh" "$STACK_DIR/platform/hermes/install-skills.sh"
+34 -92
View File
@@ -1,107 +1,49 @@
--- ---
name: mcphub-deployer 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 # MCPHub Deployer
Install portable MCPs in the existing `MCPHub`; never create a separate Use only the `mcphub_admin_*` tools. They are the installer. Never use
container and never install them inside Hermes. `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` - Existing HTTP MCP endpoint: `mcphub_admin_install_http`
- Appdata: `/mnt/nvme-storage/appdata/MCPHub` - Published npm package: `mcphub_admin_install_npx`
- Work: `/mnt/nvme-storage/appdata/MCPHub/work/<id>` - Published Python package: `mcphub_admin_install_uvx`
- Extensions: `/mnt/nvme-storage/appdata/MCPHub/extensions/<id>` - GitHub source repository: `mcphub_admin_install_git`
- 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>`
Do not search for other checkouts, registries, templates, or secret stores. For a GitHub repository, read only the upstream README and its package manifest
Normal extensions do not modify Dockerfile, image tag, Unraid template, MCPHub to determine these fields:
source, or Hermes config manually.
## 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 Then call `mcphub_admin_install_git` once. It clones, builds, stores, registers,
required keys exist; never print values. Never inspect other containers, and versions the MCP itself. A successful install is intentionally disabled.
environments, mail servers, configs, histories, or passwords to find or infer Do not reproduce those steps manually.
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.
## One-pass workflow ## Activation and proof
1. Read this skill once. Inspect only MCPHub state, the fixed registry, the 1. Report the exact missing environment key names, if any. The user fills
dedicated secret-file presence, and the target upstream release/source. `/mnt/nvme-storage/appdata/MCPHub/secrets/<name>.env`; never read its values.
2. Classify once: existing HTTP MCP, packaged stdio MCP, released binary, 2. Check `mcphub_admin_git_status`.
custom MCP, or host-bound HTTP proxy. Do not reconsider without a concrete 3. Call `mcphub_admin_activate_git`. It refuses missing credentials, publishes
failed build or handshake. the server to Hermes, reloads it, and returns the live tool list.
3. Interpret intent: “prüfen/planen” changes nothing; “installieren/einbauen” 4. Confirm success only when the returned server is connected and has tools.
continues; “deaktiviert” never activates; “read-only” omits mutating tools. Run at most one harmless read-only tool call if the user requested a test.
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`:
```json 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
"server": { `mcphub_admin_rollback_git` with `ROLLBACK:<name>`; it returns disabled, so
"id": "example", "hermes_id": "example", "name": "Example", inspect and activate separately. Removal still requires `REMOVE:<name>`.
"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"}
]
}
```
Use paths as seen inside MCPHub (`/app/data/...`) in the manifest. If a tool returns an error, report that exact error. Make at most one corrected
6. Stage with exactly: call for a concrete parameter mistake. Never invent a second deployment path.
```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.
+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/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_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_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 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 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 ## Portable MCPs installieren
Neue portable MCPs erfordern keinen Image-Neubau und keine Änderung am Hermes installiert portable MCPs ausschließlich mit den Werkzeugen des
Unraid-Template. Laufzeitdateien werden zunächst unter `work/<id>` gebaut und `mcphub-admin`-Servers. HTTP-Endpunkte sowie veröffentlichte npm-/Python-Pakete
per Manifest mit dem geprüften Helfer übernommen: 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 Danach prüft `mcphub_admin_git_status` ausschließlich, ob die namentlich
docker exec MCPHub python3 /opt/casaderoll/deploy-extension.py stage \ angegebenen Umgebungsvariablen vorhanden sind. Erst
--manifest /app/data/work/<id>/manifest.json `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
Der Helfer prüft Checksummen, kopiert atomar nach `extensions/<id>`, registriert Terminal noch SSH, Docker-Befehle, ein Image-Neubau oder Änderungen am
den Server über MCPHubs offizielle API und setzt ihn immer zuerst auf Unraid-Template nötig.
deaktiviert. Fehlt die dedizierte Secret-Datei oder ein Pflichtfeld, verweigert
er die Aktivierung technisch. Ein Neustart des MCPHub-Containers ist nicht
erforderlich.
Hermes verbindet sich nur mit der verwalteten Gruppe `/mcp/hermes`. Dadurch werden 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 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 #!/usr/bin/env python3
"""Small MCP client for MCPHub's official management API. """Small MCP client for MCPHub's official management API.
This server deliberately exposes the common installation path (remote HTTP, This server deliberately exposes the common installation paths (remote HTTP,
npx, uvx) without giving an agent a shell on Unraid. MCPHub remains the npx, uvx and GitHub source builds) without giving an agent a shell on Unraid.
single source of truth and performs process supervision itself. MCPHub remains the single source of truth and performs process supervision.
""" """
from __future__ import annotations from __future__ import annotations
@@ -19,10 +19,24 @@ from typing import Any
from mcp.server.fastmcp import FastMCP 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("/") 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")) 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() 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}$") 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._+~-]+)?$") 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._+~-]+)?$") 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( mcp = FastMCP(
"mcphub-admin", "mcphub-admin",
instructions=( instructions=(
"Manage MCP servers through MCPHub's official API. Prefer HTTP MCPs or " "Manage MCP servers through MCPHub without a terminal. For a GitHub "
"pinned npx/uvx packages. Inspect first, install once, then verify the " "repository use mcphub_admin_install_git; never use execute_code, SSH, "
"connection and tools. Removal requires the user's explicit request." "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 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() @mcp.tool()
def mcphub_admin_list_servers() -> str: def mcphub_admin_list_servers() -> str:
"""List configured MCPHub servers with connection state and compact tool names.""" """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"}) 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() @mcp.tool()
def mcphub_admin_set_enabled(name: str, enabled: bool) -> str: def mcphub_admin_set_enabled(name: str, enabled: bool) -> str:
"""Enable or disable one explicitly named MCPHub server.""" """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,
}