let Hermes manage MCPHub through its API
This commit is contained in:
1 parent
bc902261b9
commit
2595bab17b
11 files changed
+377
-34
No files matched your search
@@ -16,6 +16,9 @@ import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
@@ -89,6 +92,81 @@ def find_server(document: dict, server_id: str) -> dict | None:
|
||||
return next((item for item in document["servers"] if item.get("id") == server_id), None)
|
||||
|
||||
|
||||
def expand_runtime(value: object, values: dict[str, str]) -> object:
|
||||
if isinstance(value, str):
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
key = match.group(1)
|
||||
resolved = values.get(key, "").strip()
|
||||
if not resolved:
|
||||
raise SystemExit(f"Missing runtime value: {key}")
|
||||
return resolved
|
||||
return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", replace, value)
|
||||
if isinstance(value, list):
|
||||
return [expand_runtime(item, values) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: expand_runtime(item, values) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def api_request(args: argparse.Namespace, method: str, path: str,
|
||||
body: dict | None = None, allow_not_found: bool = False) -> dict | None:
|
||||
if getattr(args, "skip_api", False):
|
||||
return None
|
||||
token_file = getattr(args, "token_file", None) or args.appdata / "client-token"
|
||||
token = pathlib.Path(token_file).read_text(encoding="utf-8").strip()
|
||||
if not token:
|
||||
raise SystemExit("MCPHub API token is empty")
|
||||
payload = None if body is None else json.dumps(body).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
str(getattr(args, "api_url", "http://127.0.0.1:3000/api")).rstrip("/") + path,
|
||||
data=payload,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
raw = response.read(1_000_000)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if allow_not_found and exc.code == 404:
|
||||
return None
|
||||
detail = exc.read(500).decode("utf-8", errors="replace").replace("\n", " ")
|
||||
raise SystemExit(f"MCPHub API HTTP {exc.code}: {detail[:300]}") from exc
|
||||
except OSError as exc:
|
||||
raise SystemExit(f"MCPHub API unavailable: {str(exc)[:300]}") from exc
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def sync_runtime(args: argparse.Namespace, server: dict, enabled: bool,
|
||||
credentials_ready: bool) -> None:
|
||||
if getattr(args, "skip_api", False):
|
||||
return
|
||||
name = str(server.get("hermes_id") or server["id"])
|
||||
config = json.loads(json.dumps(server["hub"]))
|
||||
secret_name = str(config.pop("secret_file", ""))
|
||||
# env_keys intentionally returns names only. Re-read values only for the
|
||||
# runtime rendering path, never print or return them.
|
||||
if credentials_ready and secret_name:
|
||||
values: dict[str, str] = {}
|
||||
for raw in (args.secrets / secret_name).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)
|
||||
values[key.removeprefix("export ").strip()] = value.strip().strip("\"'")
|
||||
config = expand_runtime(json.loads(json.dumps(server["hub"])), values)
|
||||
config.pop("secret_file", None)
|
||||
config["enabled"] = bool(enabled)
|
||||
current = api_request(args, "GET", f"/servers/{urllib.parse.quote(name, safe='')}", allow_not_found=True)
|
||||
if current is None:
|
||||
api_request(args, "POST", "/servers", {"name": name, "config": config})
|
||||
else:
|
||||
api_request(args, "PUT", f"/servers/{urllib.parse.quote(name, safe='')}", {"config": config})
|
||||
|
||||
|
||||
def validate_server(server: dict) -> str:
|
||||
server_id = str(server.get("id") or "")
|
||||
if not ID_RE.fullmatch(server_id):
|
||||
@@ -149,6 +227,7 @@ def stage(args: argparse.Namespace) -> None:
|
||||
document["servers"] = [item for item in document["servers"] if item.get("id") != server_id]
|
||||
document["servers"].append(server)
|
||||
atomic_json(args.registry, document)
|
||||
sync_runtime(args, server, False, ready)
|
||||
print(json.dumps({
|
||||
"status": "staged", "id": server_id, "enabled": False,
|
||||
"credentials_ready": ready, "credential_state": reason,
|
||||
@@ -164,6 +243,7 @@ def set_enabled(args: argparse.Namespace, enabled: bool) -> None:
|
||||
ready, reason = credential_state(server, args.secrets)
|
||||
if enabled and not ready:
|
||||
raise SystemExit(f"Activation refused: {reason}")
|
||||
sync_runtime(args, server, enabled, ready)
|
||||
server["hub"]["enabled"] = enabled
|
||||
desired = list((server.get("deployment") or {}).get("desired_clients", []))
|
||||
server["clients"] = desired if enabled else []
|
||||
@@ -194,6 +274,9 @@ def main() -> None:
|
||||
parser.add_argument("--appdata", type=pathlib.Path, default=pathlib.Path("/app/data"))
|
||||
parser.add_argument("--registry", type=pathlib.Path, default=pathlib.Path("/app/data/config/mcp-registry.json"))
|
||||
parser.add_argument("--secrets", type=pathlib.Path, default=pathlib.Path("/run/secrets/mcphub"))
|
||||
parser.add_argument("--api-url", default="http://127.0.0.1:3000/api")
|
||||
parser.add_argument("--token-file", type=pathlib.Path, default=pathlib.Path("/app/data/client-token"))
|
||||
parser.add_argument("--skip-api", action="store_true", help=argparse.SUPPRESS)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
stage_cmd = sub.add_parser("stage")
|
||||
stage_cmd.add_argument("--manifest", type=pathlib.Path, required=True)
|
||||
|
||||
Reference in new issue
Block a user