let Hermes manage MCPHub through its API
This commit is contained in:
@@ -23,9 +23,13 @@ Hermes Agent und MCPHub laufen auf Unraid und werden dort mit Appdata gesichert.
|
||||
- Hermes verwendet für allgemeine Recherche den eingebauten schlüssellosen
|
||||
Keenable-Provider für Suche und Seitenabruf; der frühere Athena-Webadapter
|
||||
wird nicht mehr gestartet.
|
||||
- Die produktive Liste der MCPHub-Server liegt in Unraid-Appdata unter
|
||||
`MCPHub/config/mcp-registry.json`; `config/mcp-registry.json` ist der
|
||||
Neuinstallations-Seed. `platform/mcp/sync-clients.py` erzeugt Hermes daraus.
|
||||
- MCPHubs eigene persistente Einstellungen unter `MCPHub/mcp_settings.json`
|
||||
sind der produktive Zustand. Oberfläche und offizielle API ändern genau
|
||||
diese Datei; Container-Updates überschreiben sie nicht.
|
||||
`config/mcp-registry.json` ist nur der Neuinstallations-Seed.
|
||||
- Hermes verbindet sich einmal mit MCPHubs gemeinsamem `/mcp`-Endpunkt. Neue
|
||||
aktivierte Server erscheinen dadurch nach **MCP neu laden**, ohne dass pro
|
||||
MCP eine weitere Hermes-Konfiguration geschrieben werden muss.
|
||||
- Modelle und Athena-Backups liegen auf `/data`. Hermes liegt vollständig unter
|
||||
`/mnt/nvme-storage/appdata/Hermes-Agent`; MCPHub-Zustand, Client-Schlüssel und
|
||||
MCP-Zugänge liegen unter `/mnt/nvme-storage/appdata/MCPHub`.
|
||||
|
||||
@@ -1,13 +1,43 @@
|
||||
{
|
||||
"version": 1,
|
||||
"servers": [
|
||||
{
|
||||
"id": "mcphub-all",
|
||||
"hermes_id": "mcphub",
|
||||
"name": "MCPHub",
|
||||
"description": "Zentraler Zugang zu allen auf Unraid aktivierten MCP-Servern. Neue Server erscheinen nach einem MCP-Reload ohne Änderung der Hermes-Konfiguration.",
|
||||
"url": "http://192.168.1.2:8787/mcp",
|
||||
"clients": ["hermes"],
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"auth_type": "bearer",
|
||||
"timeout": 900
|
||||
},
|
||||
{
|
||||
"id": "mcphub-admin-local",
|
||||
"name": "MCPHub Administration",
|
||||
"description": "Installiert und verwaltet HTTP-, npm- und Python-MCPs direkt über die offizielle MCPHub-API. Kein Unraid-Terminal erforderlich.",
|
||||
"url": "http://192.168.1.2:8787/mcp/mcphub-admin",
|
||||
"clients": [],
|
||||
"timeout": 300,
|
||||
"hub": {
|
||||
"type": "stdio",
|
||||
"command": "python3",
|
||||
"args": ["/opt/casaderoll/mcps/mcphub_admin_mcp.py"],
|
||||
"env": {
|
||||
"MCPHUB_API_URL": "http://127.0.0.1:3000/api",
|
||||
"MCPHUB_API_TOKEN_FILE": "/app/data/client-token"
|
||||
},
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "athena-operator-local",
|
||||
"hermes_id": "athena-operator",
|
||||
"name": "Athena Operator",
|
||||
"description": "Zentrale administrative Schnittstelle für Athena. Beginne mit athena_operator_inspect(subject=guide). Verwaltet Docker, Modelle, MCPs, Git und Backups; Stromversorgung und Remote-Erreichbarkeit bleiben blockiert.",
|
||||
"url": "http://192.168.1.2:8787/mcp/athena-operator",
|
||||
"clients": ["hermes", "openwebui"],
|
||||
"clients": ["openwebui"],
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"auth_type": "bearer",
|
||||
@@ -25,7 +55,7 @@
|
||||
"name": "GitHub (offiziell, read-only)",
|
||||
"description": "Repository-Suche, echte Datei-Inhalte und gezielte Code-Suche. Keine rekursiven Komplettbäume oder Schreibzugriffe.",
|
||||
"url": "http://192.168.1.2:8787/mcp/github",
|
||||
"clients": ["hermes", "openwebui"],
|
||||
"clients": ["openwebui"],
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"auth_type": "bearer",
|
||||
@@ -44,7 +74,7 @@
|
||||
"name": "Home Assistant",
|
||||
"description": "Entitäten, Zustände, Historie, Automationen, Dashboards, Diagnose und freigegebene YAML-Dateien. Änderungen nur auf ausdrücklichen Auftrag.",
|
||||
"url": "http://192.168.1.2:8787/mcp/homeassistant",
|
||||
"clients": ["hermes", "openwebui"],
|
||||
"clients": ["openwebui"],
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"auth_type": "bearer",
|
||||
@@ -64,7 +94,7 @@
|
||||
"name": "Sonarr und Radarr",
|
||||
"description": "Serien, Filme, Queue, Indexer-Suche und kompakte Medieninventare. Für Codec-Fragen radarr_movie_codec_inventory verwenden; keine rohen API-Requests oder Dateisystem-Scans.",
|
||||
"url": "http://192.168.1.2:8787/mcp/arr",
|
||||
"clients": ["hermes", "openwebui"],
|
||||
"clients": ["openwebui"],
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"auth_type": "bearer",
|
||||
@@ -82,7 +112,7 @@
|
||||
"name": "Navidrome",
|
||||
"description": "Persönliche Musikbibliothek: Titel, Alben, Künstler, Playlists, Favoriten und Hörverlauf.",
|
||||
"url": "http://192.168.1.2:8787/mcp/navidrome",
|
||||
"clients": ["hermes", "openwebui"],
|
||||
"clients": ["openwebui"],
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"auth_type": "bearer",
|
||||
@@ -101,7 +131,7 @@
|
||||
"name": "Deemix",
|
||||
"description": "Nutzt ausschließlich die bestehende Deemix-Instanz auf Unraid. Status und Suche sind read-only; Queue-Aktionen nur auf ausdrücklichen Auftrag.",
|
||||
"url": "http://192.168.1.2:8787/mcp/deemix",
|
||||
"clients": ["hermes", "openwebui"],
|
||||
"clients": ["openwebui"],
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"auth_type": "bearer",
|
||||
@@ -123,7 +153,7 @@
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"auth_type": "bearer",
|
||||
"clients": ["hermes", "openwebui"],
|
||||
"clients": ["openwebui"],
|
||||
"timeout": 900,
|
||||
"hub": {
|
||||
"type": "streamable-http",
|
||||
@@ -143,7 +173,7 @@
|
||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||
"auth_type": "bearer",
|
||||
"clients": ["hermes", "openwebui"],
|
||||
"clients": ["openwebui"],
|
||||
"timeout": 300,
|
||||
"tool_include": [
|
||||
"fritzbox-list_services",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>MCPHub</Name>
|
||||
<Repository>casaderoll/mcphub:1.2.1</Repository>
|
||||
<Repository>casaderoll/mcphub:1.2.2</Repository>
|
||||
<Registry>https://hub.docker.com/r/samanhappy/mcphub</Registry>
|
||||
<Network>bridge</Network>
|
||||
<MyIP/>
|
||||
|
||||
@@ -60,6 +60,7 @@ class DeployExtensionTest(unittest.TestCase):
|
||||
values = {
|
||||
"appdata": self.appdata, "registry": self.registry,
|
||||
"secrets": self.secrets, "manifest": self.manifest, "id": "example",
|
||||
"skip_api": True,
|
||||
}
|
||||
values.update(extra)
|
||||
return argparse.Namespace(**values)
|
||||
|
||||
@@ -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.1 \
|
||||
casaderoll/mcphub:1.2.2 \
|
||||
/stack/platform/mcp/sync-clients.py "${sync_args[@]}"
|
||||
|
||||
"$STACK_DIR/platform/hermes/install-skills.sh"
|
||||
|
||||
@@ -77,14 +77,14 @@ docker exec MCPHub python3 /opt/casaderoll/deploy-extension.py stage \
|
||||
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. Recreate only `MCPHub` once so it reconciles the external registry. Do not
|
||||
restart Hermes, Athena, Router, Qwen, WireGuard, Unraid, or other services.
|
||||
8. If credentials are ready, activate with the helper, recreate only MCPHub,
|
||||
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. Run the existing client-sync script only after successful activation. Do
|
||||
not edit client YAML by hand. New routes are not advertised automatically.
|
||||
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.
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ COPY --from=github /server/github-mcp-server /usr/local/bin/github-mcp-server
|
||||
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/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
|
||||
|
||||
+16
-10
@@ -22,8 +22,8 @@ sie sich einen Docker-Container und ein Appdata-Backup teilen.
|
||||
|
||||
`/mnt/nvme-storage/appdata/MCPHub` on Unraid contains:
|
||||
|
||||
- `mcp_settings.json` (users, server registrations and tool toggles)
|
||||
- `config/mcp-registry.json` (produktive deklarative Serverliste)
|
||||
- `mcp_settings.json` (produktive Serverliste, Benutzer und Tool-Schalter)
|
||||
- `config/mcp-registry.json` (Seed und Extension-Metadaten für Recovery)
|
||||
- `extensions/<id>/` (geprüfte portable MCP-Laufzeiten)
|
||||
- `work/<id>/` (Manifest, Build- und Resume-Zwischenstand)
|
||||
- `jwt-secret` (stable login sessions)
|
||||
@@ -56,9 +56,10 @@ generierten Bearer-Schlüssel in `client-token`. Dadurch ist kein OAuth-Ablauf
|
||||
pro Client nötig, ohne die MCP-Routen anonym zu öffnen. Port 8787 darf nicht ins
|
||||
öffentliche Internet weitergeleitet werden.
|
||||
|
||||
`configure-settings.py` erhält bestehende MCPHub-Benutzer und rendert die
|
||||
externe Produktionsliste. Das Image liefert nur den Seed für einen leeren
|
||||
Neuaufbau. `verify-hub.py` führt
|
||||
`configure-settings.py` rendert die Registry ausschließlich bei einer frischen
|
||||
Wiederherstellung. Danach sind MCPHubs eigene Oberfläche und offizielle API die
|
||||
Quelle der Wahrheit; ein Image-Update überschreibt neue MCPs nicht.
|
||||
`verify-hub.py` führt
|
||||
Handshakes und Tool-Listen ohne Schreibzugriff aus. `probe-hub.py` führt genau
|
||||
eine ausdrücklich benannte, begrenzte Funktionsprobe aus.
|
||||
|
||||
@@ -73,11 +74,16 @@ 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>` und setzt
|
||||
den Server immer zuerst auf deaktiviert. Fehlt die dedizierte Secret-Datei oder
|
||||
ein Pflichtfeld, verweigert er die Aktivierung technisch und veröffentlicht
|
||||
den Server an keinen Client. Erst nach Handshake und begrenzter read-only Probe
|
||||
wird aktiviert und die Client-Konfiguration aus derselben Registry erzeugt.
|
||||
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.
|
||||
|
||||
Hermes verbindet sich nur mit dem gemeinsamen Endpunkt `/mcp`. Dadurch werden
|
||||
neu aktivierte Server nach **MCP neu laden** sichtbar, ohne pro MCP eine weitere
|
||||
Hermes-Konfiguration zu erzeugen. Der interne Server `mcphub-admin` stellt die
|
||||
üblichen Installationswege für HTTP-, npm- und Python-MCPs als Werkzeuge bereit.
|
||||
|
||||
## Migrationsregel
|
||||
|
||||
|
||||
@@ -30,8 +30,15 @@ if [ ! -s "$registry_file" ]; then
|
||||
cp /opt/casaderoll/config/mcp-registry.json "$registry_file"
|
||||
chmod 0600 "$registry_file"
|
||||
fi
|
||||
python3 /opt/casaderoll/configure-settings.py \
|
||||
"$settings_file" /run/secrets/mcphub \
|
||||
--registry "$registry_file"
|
||||
|
||||
# MCPHub's own persistent settings and official API are the runtime source of
|
||||
# truth. Render the declarative registry only for a fresh recovery (or an
|
||||
# explicitly requested migration), never on every image update: otherwise an
|
||||
# MCP installed through the dashboard/API would disappear on restart.
|
||||
if [ ! -s "$settings_file" ] || [ "${MCPHUB_RECONCILE:-0}" = "1" ]; then
|
||||
python3 /opt/casaderoll/configure-settings.py \
|
||||
"$settings_file" /run/secrets/mcphub \
|
||||
--registry "$registry_file"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
|
||||
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"))
|
||||
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._+~-]+)?$")
|
||||
MAX_SERVERS = 80
|
||||
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."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class HubError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _token() -> str:
|
||||
try:
|
||||
value = TOKEN_FILE.read_text(encoding="utf-8").strip()
|
||||
except OSError as exc:
|
||||
raise HubError("MCPHub API token is unavailable") from exc
|
||||
if not value:
|
||||
raise HubError("MCPHub API token is empty")
|
||||
return value
|
||||
|
||||
|
||||
def _request(method: str, path: str, body: dict[str, Any] | None = None) -> Any:
|
||||
payload = None if body is None else json.dumps(body).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
API_BASE + 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(2_000_000)
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read(800).decode("utf-8", errors="replace").replace("\n", " ")
|
||||
raise HubError(f"MCPHub API returned HTTP {exc.code}: {detail[:500]}") from exc
|
||||
except OSError as exc:
|
||||
raise HubError(f"MCPHub API is unreachable: {str(exc)[:300]}") from exc
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HubError("MCPHub API returned invalid JSON") from exc
|
||||
|
||||
|
||||
def _name(value: str) -> str:
|
||||
value = value.strip()
|
||||
if not NAME_RE.fullmatch(value):
|
||||
raise HubError("Name must contain only letters, numbers, dot, underscore or hyphen")
|
||||
return value
|
||||
|
||||
|
||||
def _package(value: str, pattern: re.Pattern[str]) -> str:
|
||||
value = value.strip()
|
||||
if not pattern.fullmatch(value):
|
||||
raise HubError("Invalid package name or version")
|
||||
return value
|
||||
|
||||
|
||||
def _args(value: list[str] | None) -> list[str]:
|
||||
result = [str(item) for item in (value or [])]
|
||||
if len(result) > 20 or any(len(item) > 300 for item in result):
|
||||
raise HubError("At most 20 bounded arguments are allowed")
|
||||
return result
|
||||
|
||||
|
||||
SECRET_KEYS = re.compile(r"(?i)(authorization|password|passwd|secret|token|api.?key|cookie)")
|
||||
|
||||
|
||||
def _redact(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): ("[configured]" if SECRET_KEYS.search(str(key)) else _redact(item))
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_redact(item) for item in value[:MAX_TOOLS]]
|
||||
if isinstance(value, str) and len(value) > 500:
|
||||
return value[:500] + "..."
|
||||
return value
|
||||
|
||||
|
||||
def _compact_server(item: dict[str, Any]) -> dict[str, Any]:
|
||||
tools = item.get("tools") if isinstance(item.get("tools"), list) else []
|
||||
config = item.get("config") if isinstance(item.get("config"), dict) else {}
|
||||
return {
|
||||
"name": item.get("name"),
|
||||
"status": item.get("status"),
|
||||
"enabled": config.get("enabled", True),
|
||||
"type": config.get("type"),
|
||||
"tool_count": len(tools),
|
||||
"tools": [tool.get("name") for tool in tools[:MAX_TOOLS] if isinstance(tool, dict)],
|
||||
}
|
||||
|
||||
|
||||
def _install(name: str, config: dict[str, Any]) -> str:
|
||||
name = _name(name)
|
||||
existing = _request("GET", "/servers")
|
||||
rows = existing.get("data", []) if isinstance(existing, dict) else []
|
||||
if any(isinstance(row, dict) and row.get("name") == name for row in rows):
|
||||
raise HubError(f"Server already exists: {name}; inspect or update it instead")
|
||||
result = _request("POST", "/servers", {"name": name, "config": config})
|
||||
verified = _request("GET", f"/servers/{name}")
|
||||
data = verified.get("data", verified) if isinstance(verified, dict) else verified
|
||||
return json.dumps({"installed": True, "server": _redact(data), "api_result": _redact(result)}, ensure_ascii=False)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def mcphub_admin_list_servers() -> str:
|
||||
"""List configured MCPHub servers with connection state and compact tool names."""
|
||||
result = _request("GET", "/servers")
|
||||
rows = result.get("data", []) if isinstance(result, dict) else []
|
||||
compact = [_compact_server(row) for row in rows[:MAX_SERVERS] if isinstance(row, dict)]
|
||||
return json.dumps({"count": len(rows), "servers": compact, "truncated": len(rows) > MAX_SERVERS}, ensure_ascii=False)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def mcphub_admin_get_server(name: str) -> str:
|
||||
"""Inspect one MCPHub server, its status, transport and exposed tools. Secrets are redacted."""
|
||||
name = _name(name)
|
||||
result = _request("GET", f"/servers/{name}")
|
||||
return json.dumps(_redact(result), ensure_ascii=False)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def mcphub_admin_install_http(name: str, url: str, enabled: bool = True) -> str:
|
||||
"""Install a remote Streamable-HTTP MCP by URL and verify registration. Use OAuth-capable entries through the UI when interactive login is required."""
|
||||
url = url.strip()
|
||||
if not re.match(r"^https?://", url):
|
||||
raise HubError("HTTP MCP URL must start with http:// or https://")
|
||||
return _install(name, {"type": "streamable-http", "url": url, "enabled": bool(enabled), "owner": "admin"})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def mcphub_admin_install_npx(name: str, package: str, arguments: list[str] | None = None, enabled: bool = True) -> str:
|
||||
"""Install an npm-distributed stdio MCP with npx. Pin package@version whenever possible; arguments are passed without a shell."""
|
||||
package = _package(package, NPM_PACKAGE_RE)
|
||||
args = ["-y", package, *_args(arguments)]
|
||||
return _install(name, {"type": "stdio", "command": "npx", "args": args, "enabled": bool(enabled), "owner": "admin"})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def mcphub_admin_install_uvx(name: str, package: str, arguments: list[str] | None = None, enabled: bool = True) -> str:
|
||||
"""Install a Python-distributed stdio MCP with uvx. Pin package==version whenever possible; arguments are passed without a shell."""
|
||||
package = _package(package, PYTHON_PACKAGE_RE)
|
||||
return _install(name, {"type": "stdio", "command": "uvx", "args": [package, *_args(arguments)], "enabled": bool(enabled), "owner": "admin"})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def mcphub_admin_set_enabled(name: str, enabled: bool) -> str:
|
||||
"""Enable or disable one explicitly named MCPHub server."""
|
||||
name = _name(name)
|
||||
result = _request("POST", f"/servers/{name}/toggle", {"enabled": bool(enabled)})
|
||||
return json.dumps(_redact(result), ensure_ascii=False)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def mcphub_admin_reload(name: str) -> str:
|
||||
"""Reconnect or respawn one explicitly named MCPHub server after a change."""
|
||||
name = _name(name)
|
||||
result = _request("POST", f"/servers/{name}/reload")
|
||||
return json.dumps(_redact(result), ensure_ascii=False)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def mcphub_admin_remove(name: str, confirmation: str) -> str:
|
||||
"""Permanently remove a server only after the user explicitly requested it. confirmation must exactly equal REMOVE:<name>."""
|
||||
name = _name(name)
|
||||
if confirmation != f"REMOVE:{name}":
|
||||
raise HubError(f"Confirmation must exactly equal REMOVE:{name}")
|
||||
result = _request("DELETE", f"/servers/{name}")
|
||||
return json.dumps(_redact(result), ensure_ascii=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
Reference in New Issue
Block a user