publish MCPs through a filtered Hermes group
This commit is contained in:
@@ -27,7 +27,7 @@ Hermes Agent und MCPHub laufen auf Unraid und werden dort mit Appdata gesichert.
|
|||||||
sind der produktive Zustand. Oberfläche und offizielle API ändern genau
|
sind der produktive Zustand. Oberfläche und offizielle API ändern genau
|
||||||
diese Datei; Container-Updates überschreiben sie nicht.
|
diese Datei; Container-Updates überschreiben sie nicht.
|
||||||
`config/mcp-registry.json` ist nur der Neuinstallations-Seed.
|
`config/mcp-registry.json` ist nur der Neuinstallations-Seed.
|
||||||
- Hermes verbindet sich einmal mit MCPHubs gemeinsamem `/mcp`-Endpunkt. Neue
|
- Hermes verbindet sich einmal mit MCPHubs gefiltertem `/mcp/hermes`-Endpunkt. Neue
|
||||||
aktivierte Server erscheinen dadurch nach **MCP neu laden**, ohne dass pro
|
aktivierte Server erscheinen dadurch nach **MCP neu laden**, ohne dass pro
|
||||||
MCP eine weitere Hermes-Konfiguration geschrieben werden muss.
|
MCP eine weitere Hermes-Konfiguration geschrieben werden muss.
|
||||||
- Modelle und Athena-Backups liegen auf `/data`. Hermes liegt vollständig unter
|
- Modelle und Athena-Backups liegen auf `/data`. Hermes liegt vollständig unter
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"hermes_id": "mcphub",
|
"hermes_id": "mcphub",
|
||||||
"name": "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.",
|
"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",
|
"url": "http://192.168.1.2:8787/mcp/hermes",
|
||||||
"clients": ["hermes"],
|
"clients": ["hermes"],
|
||||||
"env_file": "/etc/mike-ai/mcphub-client.env",
|
"env_file": "/etc/mike-ai/mcphub-client.env",
|
||||||
"key_env": "MCPHUB_BEARER_TOKEN",
|
"key_env": "MCPHUB_BEARER_TOKEN",
|
||||||
@@ -26,7 +26,8 @@
|
|||||||
"args": ["/opt/casaderoll/mcps/mcphub_admin_mcp.py"],
|
"args": ["/opt/casaderoll/mcps/mcphub_admin_mcp.py"],
|
||||||
"env": {
|
"env": {
|
||||||
"MCPHUB_API_URL": "http://127.0.0.1:3000/api",
|
"MCPHUB_API_URL": "http://127.0.0.1:3000/api",
|
||||||
"MCPHUB_API_TOKEN_FILE": "/app/data/client-token"
|
"MCPHUB_API_TOKEN_FILE": "/app/data/client-token",
|
||||||
|
"MCPHUB_CLIENT_GROUP": "hermes"
|
||||||
},
|
},
|
||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.2</Repository>
|
<Repository>casaderoll/mcphub:1.2.3</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/>
|
||||||
|
|||||||
@@ -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.2 \
|
casaderoll/mcphub:1.2.3 \
|
||||||
/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"
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ deaktiviert. Fehlt die dedizierte Secret-Datei oder ein Pflichtfeld, verweigert
|
|||||||
er die Aktivierung technisch. Ein Neustart des MCPHub-Containers ist nicht
|
er die Aktivierung technisch. Ein Neustart des MCPHub-Containers ist nicht
|
||||||
erforderlich.
|
erforderlich.
|
||||||
|
|
||||||
Hermes verbindet sich nur mit dem gemeinsamen Endpunkt `/mcp`. 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
|
||||||
Hermes-Konfiguration zu erzeugen. Der interne Server `mcphub-admin` stellt die
|
Hermes-Konfiguration zu erzeugen. Der interne Server `mcphub-admin` stellt die
|
||||||
üblichen Installationswege für HTTP-, npm- und Python-MCPs als Werkzeuge bereit.
|
üblichen Installationswege für HTTP-, npm- und Python-MCPs als Werkzeuge bereit.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import os
|
|||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ from mcp.server.fastmcp import FastMCP
|
|||||||
|
|
||||||
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()
|
||||||
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._+~-]+)?$")
|
||||||
@@ -135,9 +137,35 @@ def _install(name: str, config: dict[str, Any]) -> str:
|
|||||||
if any(isinstance(row, dict) and row.get("name") == name for row in rows):
|
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")
|
raise HubError(f"Server already exists: {name}; inspect or update it instead")
|
||||||
result = _request("POST", "/servers", {"name": name, "config": config})
|
result = _request("POST", "/servers", {"name": name, "config": config})
|
||||||
|
group_result = _add_to_group(name)
|
||||||
verified = _request("GET", f"/servers/{name}")
|
verified = _request("GET", f"/servers/{name}")
|
||||||
data = verified.get("data", verified) if isinstance(verified, dict) else verified
|
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)
|
return json.dumps({
|
||||||
|
"installed": True,
|
||||||
|
"published_to": CLIENT_GROUP,
|
||||||
|
"server": _redact(data),
|
||||||
|
"api_result": _redact(result),
|
||||||
|
"group_result": _redact(group_result),
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_to_group(name: str, tools: list[str] | None = None) -> Any:
|
||||||
|
if not CLIENT_GROUP:
|
||||||
|
return {"skipped": "no-client-group"}
|
||||||
|
group = urllib.parse.quote(CLIENT_GROUP, safe="")
|
||||||
|
configs = _request("GET", f"/groups/{group}/server-configs")
|
||||||
|
rows = configs.get("data", []) if isinstance(configs, dict) else []
|
||||||
|
present = any(isinstance(row, dict) and row.get("name") == name for row in rows)
|
||||||
|
result: Any = {"already_present": True}
|
||||||
|
if not present:
|
||||||
|
result = _request("POST", f"/groups/{group}/servers", {"serverName": name})
|
||||||
|
if tools is not None:
|
||||||
|
result = _request(
|
||||||
|
"PUT",
|
||||||
|
f"/groups/{group}/server-configs/{urllib.parse.quote(name, safe='')}/tools",
|
||||||
|
{"tools": tools},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -197,6 +225,24 @@ def mcphub_admin_reload(name: str) -> str:
|
|||||||
return json.dumps(_redact(result), ensure_ascii=False)
|
return json.dumps(_redact(result), ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def mcphub_admin_publish_to_hermes(name: str, tools: list[str] | None = None) -> str:
|
||||||
|
"""Publish one installed server to the central Hermes group. Optionally expose only the named tools; omit tools to expose all."""
|
||||||
|
name = _name(name)
|
||||||
|
selected = None if tools is None else _args(tools)
|
||||||
|
result = _add_to_group(name, selected)
|
||||||
|
return json.dumps({"published": name, "group": CLIENT_GROUP, "result": _redact(result)}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def mcphub_admin_unpublish_from_hermes(name: str) -> str:
|
||||||
|
"""Remove one server from the central Hermes group without uninstalling or disabling the server itself."""
|
||||||
|
name = _name(name)
|
||||||
|
group = urllib.parse.quote(CLIENT_GROUP, safe="")
|
||||||
|
result = _request("DELETE", f"/groups/{group}/servers/{urllib.parse.quote(name, safe='')}")
|
||||||
|
return json.dumps({"unpublished": name, "group": CLIENT_GROUP, "result": _redact(result)}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def mcphub_admin_remove(name: str, confirmation: str) -> str:
|
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>."""
|
"""Permanently remove a server only after the user explicitly requested it. confirmation must exactly equal REMOVE:<name>."""
|
||||||
|
|||||||
Reference in New Issue
Block a user