add autonomous GitHub MCP installer
This commit is contained in:
1 parent
9ae7d22618
commit
588f24894d
8 files changed
+837
-113
No files matched your search
@@ -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/>
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user