simplify MCPHub extensions and restore Hermes web tools
This commit is contained in:
@@ -20,10 +20,12 @@ Hermes Agent und MCPHub laufen auf Unraid und werden dort mit Appdata gesichert.
|
|||||||
- Home Assistant, ARR, Unraid, Navidrome, Deemix und GitHub laufen gemeinsam im
|
- Home Assistant, ARR, Unraid, Navidrome, Deemix und GitHub laufen gemeinsam im
|
||||||
MCPHub-Container auf Unraid, bleiben aber als getrennte MCP-Server unter
|
MCPHub-Container auf Unraid, bleiben aber als getrennte MCP-Server unter
|
||||||
`/mcp/NAME` sichtbar, abschaltbar und unabhängig für Clients freigebbar.
|
`/mcp/NAME` sichtbar, abschaltbar und unabhängig für Clients freigebbar.
|
||||||
- Hermes verwendet für allgemeine Recherche seine integrierten Webwerkzeuge;
|
- Hermes verwendet für allgemeine Recherche den eingebauten schlüssellosen
|
||||||
der frühere Athena-Webadapter wird nicht mehr gestartet.
|
Keenable-Provider für Suche und Seitenabruf; der frühere Athena-Webadapter
|
||||||
- `config/mcp-registry.json` ist die einzige Liste der MCPHub-Server und ihrer
|
wird nicht mehr gestartet.
|
||||||
Client-Registrierungen. `platform/mcp/sync-clients.py` erzeugt Hermes daraus.
|
- 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.
|
||||||
- Modelle und Athena-Backups liegen auf `/data`. Hermes liegt vollständig unter
|
- Modelle und Athena-Backups liegen auf `/data`. Hermes liegt vollständig unter
|
||||||
`/mnt/nvme-storage/appdata/Hermes-Agent`; MCPHub-Zustand, Client-Schlüssel und
|
`/mnt/nvme-storage/appdata/Hermes-Agent`; MCPHub-Zustand, Client-Schlüssel und
|
||||||
MCP-Zugänge liegen unter `/mnt/nvme-storage/appdata/MCPHub`.
|
MCP-Zugänge liegen unter `/mnt/nvme-storage/appdata/MCPHub`.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"id": "himalaya",
|
||||||
|
"hermes_id": "himalaya",
|
||||||
|
"name": "Himalaya Mail",
|
||||||
|
"description": "Apple-unabhängiger Mailzugriff über die Himalaya CLI. Lesen, suchen und Anhänge verwalten; schreibende Mailaktionen nur auf ausdrücklichen Auftrag.",
|
||||||
|
"url": "http://192.168.1.2:8787/mcp/himalaya",
|
||||||
|
"clients": ["hermes"],
|
||||||
|
"timeout": 300,
|
||||||
|
"deployment": {
|
||||||
|
"version": "himalaya-mcp 2.1.2 / himalaya-cli 2.1.0",
|
||||||
|
"source": "https://github.com/Data-Wise/himalaya-mcp",
|
||||||
|
"required_env": ["HIMALAYA_CONFIG"],
|
||||||
|
"required_files": ["himalaya-config.toml"]
|
||||||
|
},
|
||||||
|
"hub": {
|
||||||
|
"type": "stdio",
|
||||||
|
"secret_file": "himalaya.env",
|
||||||
|
"command": "/usr/local/bin/run-with-env",
|
||||||
|
"args": [
|
||||||
|
"/run/secrets/mcphub/himalaya.env",
|
||||||
|
"--",
|
||||||
|
"node",
|
||||||
|
"/app/data/extensions/himalaya/index.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"HIMALAYA_BINARY": "/app/data/extensions/himalaya/himalaya",
|
||||||
|
"MCP_TRANSPORT": "stdio"
|
||||||
|
},
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"source": "/app/data/work/himalaya/himalaya",
|
||||||
|
"path": "himalaya",
|
||||||
|
"sha256": "7bc31ca0ea596218d97f1b2637e14c6653b1ebf9741711ac0f8a675384d67472",
|
||||||
|
"mode": "0755"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/app/data/work/himalaya/index.js",
|
||||||
|
"path": "index.js",
|
||||||
|
"sha256": "c8a94a46b33e3e683d38bdfbd5d84f4441e4668780facb5f3e90fc22e68ab683",
|
||||||
|
"mode": "0644"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.1.1</Repository>
|
<Repository>casaderoll/mcphub:1.2.1</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/>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<Support>https://github.com/samanhappy/mcphub/issues</Support>
|
<Support>https://github.com/samanhappy/mcphub/issues</Support>
|
||||||
<Project>https://github.com/samanhappy/mcphub</Project>
|
<Project>https://github.com/samanhappy/mcphub</Project>
|
||||||
<ReadMe>https://github.com/samanhappy/mcphub#readme</ReadMe>
|
<ReadMe>https://github.com/samanhappy/mcphub#readme</ReadMe>
|
||||||
<Overview>Zentrale MCP-Verwaltung mit Weboberfläche. Das lokale CasaDeRoll-Image basiert reproduzierbar auf MCPHub 1.0.32 und enthält die versionierten ARR-, Deemix-, Navidrome-, GitHub- und FritzBox-Laufzeiten. Home Assistant, MUA und der Athena Operator werden als vorhandene HTTP-MCPs eingebunden. Einzelne Server bleiben unter /mcp/NAME getrennt sichtbar und schaltbar. Hermes nutzt für allgemeine Webrecherche seine eingebauten Werkzeuge.
|
<Overview>Zentrale MCP-Verwaltung mit Weboberfläche. Das lokale CasaDeRoll-Image basiert reproduzierbar auf MCPHub 1.0.32 und enthält die versionierten ARR-, Deemix-, Navidrome-, GitHub- und FritzBox-Laufzeiten. Zusätzliche portable MCPs liegen updatefest im gemounteten Appdata-Verzeichnis und benötigen keinen Image-Neubau. Home Assistant, MUA und der Athena Operator werden als vorhandene HTTP-MCPs eingebunden. Einzelne Server bleiben unter /mcp/NAME getrennt sichtbar und schaltbar. Hermes nutzt für allgemeine Webrecherche seine eingebauten Werkzeuge.
|
||||||
|
|
||||||
Weboberfläche: http://[IP]:[PORT:3000]/
|
Weboberfläche: http://[IP]:[PORT:3000]/
|
||||||
Benutzer beim ersten Start: admin
|
Benutzer beim ersten Start: admin
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE = pathlib.Path(__file__).parents[1] / "platform/mcphub/deploy-extension.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("deploy_extension", SOURCE)
|
||||||
|
assert SPEC and SPEC.loader
|
||||||
|
deploy = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(deploy)
|
||||||
|
|
||||||
|
|
||||||
|
class DeployExtensionTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = pathlib.Path(self.temp.name)
|
||||||
|
self.appdata = self.root / "appdata"
|
||||||
|
self.work = self.appdata / "work/example"
|
||||||
|
self.secrets = self.root / "secrets"
|
||||||
|
self.registry = self.appdata / "config/mcp-registry.json"
|
||||||
|
self.work.mkdir(parents=True)
|
||||||
|
self.secrets.mkdir()
|
||||||
|
self.registry.parent.mkdir(parents=True)
|
||||||
|
self.registry.write_text('{"version":1,"servers":[]}\n')
|
||||||
|
artifact = self.work / "index.js"
|
||||||
|
artifact.write_text("console.log('ok')\n")
|
||||||
|
digest = hashlib.sha256(artifact.read_bytes()).hexdigest()
|
||||||
|
self.manifest = self.work / "manifest.json"
|
||||||
|
self.manifest.write_text(json.dumps({
|
||||||
|
"server": {
|
||||||
|
"id": "example", "hermes_id": "example", "name": "Example",
|
||||||
|
"description": "Example MCP", "url": "http://host/mcp/example",
|
||||||
|
"clients": ["hermes"],
|
||||||
|
"deployment": {
|
||||||
|
"required_env": ["EXAMPLE_TOKEN"],
|
||||||
|
"required_files": ["example-config.toml"],
|
||||||
|
},
|
||||||
|
"hub": {
|
||||||
|
"type": "stdio", "secret_file": "example.env",
|
||||||
|
"command": "node", "args": ["/app/data/extensions/example/index.js"],
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"artifacts": [{
|
||||||
|
"source": str(artifact), "path": "index.js",
|
||||||
|
"sha256": digest, "mode": "0644",
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def args(self, **extra: object) -> argparse.Namespace:
|
||||||
|
values = {
|
||||||
|
"appdata": self.appdata, "registry": self.registry,
|
||||||
|
"secrets": self.secrets, "manifest": self.manifest, "id": "example",
|
||||||
|
}
|
||||||
|
values.update(extra)
|
||||||
|
return argparse.Namespace(**values)
|
||||||
|
|
||||||
|
def registered(self) -> dict:
|
||||||
|
return json.loads(self.registry.read_text())["servers"][0]
|
||||||
|
|
||||||
|
def test_missing_secret_forces_disabled_and_unpublished(self) -> None:
|
||||||
|
deploy.stage(self.args())
|
||||||
|
server = self.registered()
|
||||||
|
self.assertFalse(server["hub"]["enabled"])
|
||||||
|
self.assertEqual(server["clients"], [])
|
||||||
|
self.assertTrue((self.appdata / "extensions/example/index.js").is_file())
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
deploy.set_enabled(self.args(), True)
|
||||||
|
|
||||||
|
def test_complete_secret_allows_activation(self) -> None:
|
||||||
|
(self.secrets / "example.env").write_text("EXAMPLE_TOKEN=value\n")
|
||||||
|
(self.secrets / "example-config.toml").write_text("account = 'example'\n")
|
||||||
|
deploy.stage(self.args())
|
||||||
|
deploy.set_enabled(self.args(), True)
|
||||||
|
server = self.registered()
|
||||||
|
self.assertTrue(server["hub"]["enabled"])
|
||||||
|
self.assertEqual(server["clients"], ["hermes"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+2
-1
@@ -19,7 +19,8 @@ und überleben den Austausch der Debian-Systemplatte. Docker-Images werden aus
|
|||||||
dem Compose-Stack reproduziert und gehören nicht ins Backup.
|
dem Compose-Stack reproduziert und gehören nicht ins Backup.
|
||||||
|
|
||||||
Die portablen Fach-MCPs und Hermes gehören nicht mehr zum Athena-Systembackup.
|
Die portablen Fach-MCPs und Hermes gehören nicht mehr zum Athena-Systembackup.
|
||||||
MCPHub liegt unter `/mnt/nvme-storage/appdata/MCPHub`, Hermes vollständig unter
|
MCPHub liegt einschließlich externer Registry und portabler Erweiterungen unter
|
||||||
|
`/mnt/nvme-storage/appdata/MCPHub`, Hermes vollständig unter
|
||||||
`/mnt/nvme-storage/appdata/Hermes-Agent`. Beide Verzeichnisse werden vom
|
`/mnt/nvme-storage/appdata/Hermes-Agent`. Beide Verzeichnisse werden vom
|
||||||
bestehenden Unraid-Appdata-Backup gesichert. Für ein vollständiges
|
bestehenden Unraid-Appdata-Backup gesichert. Für ein vollständiges
|
||||||
Desaster-Recovery müssen daher sowohl Athenas `/data` als auch dieses
|
Desaster-Recovery müssen daher sowohl Athenas `/data` als auch dieses
|
||||||
|
|||||||
@@ -36,8 +36,11 @@ terminal:
|
|||||||
lifetime_seconds: 1800
|
lifetime_seconds: 1800
|
||||||
|
|
||||||
web:
|
web:
|
||||||
search_backend: "searxng"
|
# Keenable is bundled with Hermes and provides both search and extraction
|
||||||
extract_backend: "native"
|
# through its keyless tier. This remains available after Hermes moved to
|
||||||
|
# Unraid, where the former Athena-local SearXNG no longer exists.
|
||||||
|
search_backend: "keenable"
|
||||||
|
extract_backend: "keenable"
|
||||||
extract_char_limit: 15000
|
extract_char_limit: 15000
|
||||||
keyless_fallback: true
|
keyless_fallback: true
|
||||||
keyless_rescue: true
|
keyless_rescue: true
|
||||||
@@ -161,7 +164,7 @@ skills:
|
|||||||
creation_nudge_interval: 20
|
creation_nudge_interval: 20
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
enabled: ["web-searxng"]
|
enabled: ["browser-browser-use", "web-keenable"]
|
||||||
|
|
||||||
timeouts:
|
timeouts:
|
||||||
tools:
|
tools:
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ ROUTER_API_KEY=$router_key
|
|||||||
VOICE_TOOLS_OPENAI_KEY=$router_key
|
VOICE_TOOLS_OPENAI_KEY=$router_key
|
||||||
MUA_MCP_URL=$mua_url
|
MUA_MCP_URL=$mua_url
|
||||||
MUA_MCP_BEARER_TOKEN=$mua_token
|
MUA_MCP_BEARER_TOKEN=$mua_token
|
||||||
SEARXNG_URL=http://searxng:8080
|
|
||||||
API_SERVER_ENABLED=true
|
API_SERVER_ENABLED=true
|
||||||
API_SERVER_HOST=0.0.0.0
|
API_SERVER_HOST=0.0.0.0
|
||||||
API_SERVER_PORT=8642
|
API_SERVER_PORT=8642
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ create_profile() {
|
|||||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set agent.reasoning_effort low
|
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set agent.reasoning_effort low
|
||||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set display.show_reasoning false
|
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set display.show_reasoning false
|
||||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set auxiliary.title_generation.enabled false
|
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set auxiliary.title_generation.enabled false
|
||||||
|
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set web.search_backend keenable
|
||||||
|
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set web.extract_backend keenable
|
||||||
|
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set web.keyless_fallback true
|
||||||
|
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set web.keyless_rescue true
|
||||||
|
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set plugins.enabled \
|
||||||
|
'["browser-browser-use","web-keenable"]'
|
||||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config unset compression.threshold_tokens || true
|
docker exec "$HERMES_CONTAINER" hermes -p "$name" config unset compression.threshold_tokens || true
|
||||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.threshold 0.95
|
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.threshold 0.95
|
||||||
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.target_ratio 0.15
|
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.target_ratio 0.15
|
||||||
@@ -72,7 +78,9 @@ done < <(jq -r --argjson max "$(jq '.max_output_tokens' "$PROFILE_MATRIX")" \
|
|||||||
# The Unraid host deliberately has no system Python. Run the small declarative
|
# The Unraid host deliberately has no system Python. Run the small declarative
|
||||||
# client renderer in the already version-pinned MCPHub image instead of adding
|
# client renderer in the already version-pinned MCPHub image instead of adding
|
||||||
# host dependencies.
|
# host dependencies.
|
||||||
sync_args=(--registry /stack/config/mcp-registry.json)
|
mcphub_registry=${MCPHUB_REGISTRY:-/mnt/nvme-storage/appdata/MCPHub/config/mcp-registry.json}
|
||||||
|
[[ -s $mcphub_registry ]] || die "MCPHub-Registry fehlt: $mcphub_registry"
|
||||||
|
sync_args=(--registry /run/input/mcp-registry.json)
|
||||||
mcphub_token=${MCPHUB_TOKEN_FILE:-/mnt/nvme-storage/appdata/MCPHub/client-token}
|
mcphub_token=${MCPHUB_TOKEN_FILE:-/mnt/nvme-storage/appdata/MCPHub/client-token}
|
||||||
token_mount=()
|
token_mount=()
|
||||||
if [[ -s $mcphub_token ]]; then
|
if [[ -s $mcphub_token ]]; then
|
||||||
@@ -85,8 +93,9 @@ done < <(find "$HERMES_DATA_DIR" -name config.yaml -type f -print)
|
|||||||
docker run --rm --entrypoint python \
|
docker run --rm --entrypoint python \
|
||||||
-v "$STACK_DIR:/stack:ro" \
|
-v "$STACK_DIR:/stack:ro" \
|
||||||
-v "$HERMES_DATA_DIR:/hermes:rw" \
|
-v "$HERMES_DATA_DIR:/hermes:rw" \
|
||||||
|
-v "$mcphub_registry:/run/input/mcp-registry.json:ro" \
|
||||||
"${token_mount[@]}" \
|
"${token_mount[@]}" \
|
||||||
casaderoll/mcphub:1.1.0 \
|
casaderoll/mcphub:1.2.1 \
|
||||||
/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"
|
||||||
|
|||||||
@@ -1,229 +1,107 @@
|
|||||||
---
|
---
|
||||||
name: mcphub-deployer
|
name: mcphub-deployer
|
||||||
description: Install, update, disable, test, publish, or remove portable MCP servers in CasaDeRoll MCPHub on Unraid. Use for MCP catalog, GitHub, npm, PyPI, Go-binary, existing HTTP-MCP, client-registration, MCPHub repair, or moving an MCP out of Athena or Hermes.
|
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.
|
||||||
---
|
---
|
||||||
|
|
||||||
# MCPHub Deployer
|
# MCPHub Deployer
|
||||||
|
|
||||||
Install portable MCPs in the existing `MCPHub` container on Unraid. Never
|
Install portable MCPs in the existing `MCPHub`; never create a separate
|
||||||
create one Docker container per portable MCP and never install one inside
|
container and never install them inside Hermes.
|
||||||
Hermes. Hermes, OpenWebUI, Pi, and other agents are clients of MCPHub.
|
|
||||||
|
|
||||||
## Fixed production map
|
## Fixed map
|
||||||
|
|
||||||
Use these paths directly. Do not search the filesystem for alternatives.
|
- 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>`
|
||||||
|
|
||||||
- Host: Unraid `192.168.1.2`
|
Do not search for other checkouts, registries, templates, or secret stores.
|
||||||
- Container: `MCPHub`
|
Normal extensions do not modify Dockerfile, image tag, Unraid template, MCPHub
|
||||||
- UI/base URL: `http://192.168.1.2:8787`
|
source, or Hermes config manually.
|
||||||
- Operational source/build tree: `/mnt/nvme-storage/appdata/MCPHub/build/repo`
|
|
||||||
- Dockerfile: `/mnt/nvme-storage/appdata/MCPHub/build/repo/platform/mcphub/Dockerfile`
|
|
||||||
- Single server and client registry: `/mnt/nvme-storage/appdata/MCPHub/build/repo/config/mcp-registry.json`
|
|
||||||
- Registry renderer: `/mnt/nvme-storage/appdata/MCPHub/build/repo/platform/mcphub/configure-settings.py` (normally unchanged)
|
|
||||||
- Versioned Unraid-template source: `/mnt/nvme-storage/appdata/MCPHub/build/repo/config/unraid-templates/my-MCPHub.xml`
|
|
||||||
- Live DockerMan template: `/boot/config/plugins/dockerMan/templates-user/my-MCPHub.xml`
|
|
||||||
- Persistent state: `/mnt/nvme-storage/appdata/MCPHub`
|
|
||||||
- Secrets: `/mnt/nvme-storage/appdata/MCPHub/secrets/<server>.env`, mode `0600`
|
|
||||||
- Client bearer token: `/mnt/nvme-storage/appdata/MCPHub/client-token`
|
|
||||||
- Individual route: `http://192.168.1.2:8787/mcp/<server>`
|
|
||||||
|
|
||||||
The versioned template and the live DockerMan template are different files.
|
## Hard credential boundary
|
||||||
Keep their image tag and required mounts aligned. If the versioned template is
|
|
||||||
missing, copy the live template to that exact versioned path once; do not search
|
|
||||||
for another template and do not infer a replacement from unrelated containers.
|
|
||||||
|
|
||||||
The operational build tree is persistent and covered by the normal Unraid
|
Credentials are user input. Check only whether the dedicated secret file and
|
||||||
Appdata backup. The checkout below is legacy and MUST NOT be used or inspected
|
required keys exist; never print values. Never inspect other containers,
|
||||||
for MCPHub work:
|
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.
|
||||||
|
|
||||||
`/mnt/nvme-storage/Eigene Dateien/Michael/Entwicklung/AI-Profile-Router`
|
## One-pass workflow
|
||||||
|
|
||||||
The normal Git repository remains the durable documentation/history. Publish
|
1. Read this skill once. Inspect only MCPHub state, the fixed registry, the
|
||||||
the same focused files there when an authorized Git write path is available.
|
dedicated secret-file presence, and the target upstream release/source.
|
||||||
Lack of Git access is a warning to report, not permission to search for other
|
2. Classify once: existing HTTP MCP, packaged stdio MCP, released binary,
|
||||||
checkouts and not a reason to abandon an otherwise requested local install.
|
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`:
|
||||||
|
|
||||||
## Mandatory fast path
|
```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 a normal installation, perform these phases once and in order. Do not
|
Use paths as seen inside MCPHub (`/app/data/...`) in the manifest.
|
||||||
restart discovery after a phase has completed.
|
6. Stage with exactly:
|
||||||
|
|
||||||
### 1. Preflight — at most six checks
|
```sh
|
||||||
|
docker exec MCPHub python3 /opt/casaderoll/deploy-extension.py stage \
|
||||||
|
--manifest /app/data/work/<id>/manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
Check only:
|
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.
|
||||||
|
10. Report version, state, tool count, secret path (never values), tests,
|
||||||
|
client sync, durable source status, and rollback.
|
||||||
|
|
||||||
1. `MCPHub` container state, image tag, mounts, and network.
|
## Limits
|
||||||
2. The fixed Dockerfile, registry, renderer, and both template paths above.
|
|
||||||
3. Existing MCPHub server names to avoid duplication.
|
|
||||||
4. Target service reachability or the upstream release.
|
|
||||||
5. Required secret-file presence; never print its values.
|
|
||||||
6. Current Git availability, if any.
|
|
||||||
|
|
||||||
Never run `find` to rediscover a listed path. Never read unrelated Compose
|
- At most six preflight reads and two attempts per hypothesis.
|
||||||
stacks, repositories, documentation trees, container logs, container
|
- At most one corrected build.
|
||||||
environments, application configs, home directories, or secret stores.
|
- 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.
|
||||||
|
|
||||||
### Credential boundary — mandatory stop
|
## Resume
|
||||||
|
|
||||||
Credentials and account configuration are user-supplied inputs, not discovery
|
Before compression write `<work>/checkpoint.json` containing only phase,
|
||||||
targets. This rule overrides the desire to complete a deployment or live test.
|
version, completed checks, pending action, modified paths, and rollback. After
|
||||||
|
compression reload this skill and that checkpoint before continuing. Delete
|
||||||
- Never inspect another container, service, environment, mount, config file,
|
the checkpoint only after success.
|
||||||
mailbox, shell history, password manager, or secret directory to obtain or
|
|
||||||
infer credentials.
|
|
||||||
- Never reuse credentials found in an existing mail server or another app
|
|
||||||
unless the user explicitly names that exact source and authorizes reuse.
|
|
||||||
- Check only whether the dedicated fixed secret file for this server exists;
|
|
||||||
do not read its values during preflight.
|
|
||||||
- If the MCP needs credentials or account settings and the dedicated file is
|
|
||||||
absent or incomplete, finish all credential-independent build work, install
|
|
||||||
the server disabled, and stop before live authentication. Ask the user for
|
|
||||||
the missing fields and state the exact secret-file path.
|
|
||||||
- Do not substitute inspection of an existing service for that question.
|
|
||||||
- A missing credential may reduce verification to build plus MCP handshake; it
|
|
||||||
is never permission to investigate the user's infrastructure.
|
|
||||||
|
|
||||||
### 2. Classify once
|
|
||||||
|
|
||||||
Choose exactly one integration:
|
|
||||||
|
|
||||||
- Existing HTTP MCP: declare its URL and authentication; do not copy it.
|
|
||||||
- Packaged stdio MCP: pin and install the exact npm/Python package in MCPHub.
|
|
||||||
- Released binary: pin version and checksum; download and verify it during the
|
|
||||||
Docker image build. Do not commit a downloaded binary.
|
|
||||||
- Custom MCP: keep source in the build tree and copy it into the image.
|
|
||||||
- Host-bound MCP: leave it on its required host and proxy its authenticated
|
|
||||||
HTTP endpoint through MCPHub.
|
|
||||||
|
|
||||||
Do not reconsider this classification unless a real build or handshake result
|
|
||||||
contradicts it.
|
|
||||||
|
|
||||||
### 3. Interpret the user's intent
|
|
||||||
|
|
||||||
- “Prüfe/plane/zeige den Ablauf”: inspect and return a short plan; change
|
|
||||||
nothing.
|
|
||||||
- “Installiere/baue ein/los/Abfahrt”: continue through deployment and tests.
|
|
||||||
- “Zunächst deaktiviert”: install the runtime and declaration with
|
|
||||||
`enabled: false`; do not add it to client registries yet.
|
|
||||||
- “Nur lesen”: disable or omit mutating tools before client publication.
|
|
||||||
|
|
||||||
Ask only for information that cannot be derived safely: credentials, a
|
|
||||||
material license decision, or an ambiguous destructive permission.
|
|
||||||
|
|
||||||
For mail-related MCPs, repository inspection may determine which field names
|
|
||||||
are required, but the IMAP/SMTP host, user, password/token, sender identity, and
|
|
||||||
TLS choices must come from the user or the dedicated secret file. The presence
|
|
||||||
of a Docker mail server does not answer those questions.
|
|
||||||
|
|
||||||
### 4. Implement the smallest change
|
|
||||||
|
|
||||||
Modify only the necessary fixed production files. Rules:
|
|
||||||
|
|
||||||
- Pin image, package, release, and checksum versions.
|
|
||||||
- Put credentials only in the matching secret file with mode `0600`.
|
|
||||||
- Never print, log, commit, summarize, or return a secret.
|
|
||||||
- Use `/usr/local/bin/run-with-env` for secret-backed stdio servers.
|
|
||||||
- Declare servers only in `config/mcp-registry.json`; do not hard-code a server
|
|
||||||
in `configure-settings.py` and do not edit `mcp_settings.json` manually.
|
|
||||||
- Preserve existing users, bearer keys, prompts, resources, enabled states,
|
|
||||||
and per-tool toggles.
|
|
||||||
- Build a new image tag. Never overwrite the tag currently running.
|
|
||||||
- Update both the versioned template source and the live DockerMan template to
|
|
||||||
the exact new tag. Do not rebuild `template.xml` inside an upstream image.
|
|
||||||
|
|
||||||
For servers exposing many tools, install disabled first. After a successful
|
|
||||||
local test, enable only the required tool groups in MCPHub. Do not publish an
|
|
||||||
unfiltered large server to clients.
|
|
||||||
|
|
||||||
### 5. Deploy without collateral changes
|
|
||||||
|
|
||||||
Build from `/mnt/nvme-storage/appdata/MCPHub/build/repo`, then recreate only
|
|
||||||
`MCPHub` through Unraid DockerMan so it stays a managed Unraid container.
|
|
||||||
Preserve all Appdata and mounts.
|
|
||||||
|
|
||||||
Never restart Athena, Router, Qwen, Hermes, OpenWebUI, WireGuard, Unraid, or
|
|
||||||
unrelated containers for an MCPHub deployment.
|
|
||||||
|
|
||||||
Do not use unrestricted host shell access during repository analysis,
|
|
||||||
classification, or credential preflight. Use it only after the exact file
|
|
||||||
changes, new image tag, verification steps, and rollback tag are known. Its
|
|
||||||
scope is then limited to those declared paths and the `MCPHub` container.
|
|
||||||
|
|
||||||
### 6. Prove the result
|
|
||||||
|
|
||||||
Verify, in this order:
|
|
||||||
|
|
||||||
1. New container uses the intended image and remains healthy.
|
|
||||||
2. Existing MCP routes still handshake.
|
|
||||||
3. New server starts when enabled.
|
|
||||||
4. MCP handshake and `list_tools` succeed with valid schemas.
|
|
||||||
5. One bounded read-only function returns plausible live data.
|
|
||||||
6. No test download, queue item, write, or second backend remains.
|
|
||||||
7. If installed disabled, return it to disabled after the temporary test.
|
|
||||||
|
|
||||||
If credentials are unavailable, steps 3 and 5 may be recorded as blocked.
|
|
||||||
Never weaken the credential boundary merely to make the live probe pass.
|
|
||||||
|
|
||||||
A running container alone is not success. Never claim install, test,
|
|
||||||
registration, Git push, or backup without observing its result.
|
|
||||||
|
|
||||||
### 7. Publish to clients only after filtering
|
|
||||||
|
|
||||||
Add `http://192.168.1.2:8787/mcp/<server>` to
|
|
||||||
`config/mcp-registry.json` only after the server and selected tools have passed
|
|
||||||
verification. Generate intended Hermes/OpenWebUI registrations from that one
|
|
||||||
registry. Reload only the affected client gateway if required.
|
|
||||||
|
|
||||||
New MCPHub servers are not automatically advertised by the model router.
|
|
||||||
|
|
||||||
### 8. Finish compactly
|
|
||||||
|
|
||||||
Report exactly:
|
|
||||||
|
|
||||||
- installed version and image tag;
|
|
||||||
- enabled/disabled state and exposed tool count;
|
|
||||||
- secret-file path without values;
|
|
||||||
- handshake and read-only probe result;
|
|
||||||
- client registrations changed or intentionally omitted;
|
|
||||||
- Git status/push result;
|
|
||||||
- rollback image tag.
|
|
||||||
|
|
||||||
Do not narrate repeated planning or internal reconsideration.
|
|
||||||
|
|
||||||
## Context-compaction checkpoint
|
|
||||||
|
|
||||||
Before a long build or whenever context use approaches compression, write a
|
|
||||||
small checkpoint to:
|
|
||||||
|
|
||||||
`/mnt/nvme-storage/appdata/MCPHub/work/<server>.json`
|
|
||||||
|
|
||||||
Store only: requested outcome, integration type, completed phases, modified
|
|
||||||
paths, old/new image tags, pending action, verification results, and rollback.
|
|
||||||
Never store credentials. After compression, reread this SKILL.md directly from
|
|
||||||
|
|
||||||
`/opt/data/skills/platform/mcphub-deployer/SKILL.md`
|
|
||||||
|
|
||||||
inside the Hermes container plus that checkpoint, then continue at the pending
|
|
||||||
phase. Do not rely on a deduplicated/pruned earlier skill result and never
|
|
||||||
rediscover completed phases.
|
|
||||||
|
|
||||||
Delete the checkpoint after a successful final report. Keep it on failure so a
|
|
||||||
new session can resume safely.
|
|
||||||
|
|
||||||
## Hard limits and stop rules
|
|
||||||
|
|
||||||
- Maximum two attempts for the same command, endpoint, or hypothesis.
|
|
||||||
- Maximum one corrected build after the first failed build.
|
|
||||||
- Never repeat “I now understand the mechanism” and continue researching.
|
|
||||||
- If upstream command, transport, license, or credentials remain unknown after
|
|
||||||
two focused checks, stop and state that exact blocker.
|
|
||||||
- If deployment fails, keep the previous container/image running and report the
|
|
||||||
failing phase. Do not improvise a second container or unversioned binary.
|
|
||||||
- Ask before destructive queue actions, downloads, service mutations, or
|
|
||||||
credential rotation that the user did not explicitly authorize.
|
|
||||||
|
|
||||||
## Rollback
|
|
||||||
|
|
||||||
Restore the previous image tag and declarative server entry, recreate only
|
|
||||||
`MCPHub`, and repeat existing-route handshakes plus one read-only probe. Never
|
|
||||||
delete Appdata or shared secret files during rollback.
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ COPY platform/mcp/patches/mcp_sonarr.py /usr/local/lib/python3.13/site-packages/
|
|||||||
COPY platform/mcp/patches/mcp_radarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_radarr.py
|
COPY platform/mcp/patches/mcp_radarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_radarr.py
|
||||||
COPY config/mcp-registry.json /opt/casaderoll/config/mcp-registry.json
|
COPY config/mcp-registry.json /opt/casaderoll/config/mcp-registry.json
|
||||||
COPY platform/mcphub/configure-settings.py /opt/casaderoll/configure-settings.py
|
COPY platform/mcphub/configure-settings.py /opt/casaderoll/configure-settings.py
|
||||||
|
COPY platform/mcphub/deploy-extension.py /opt/casaderoll/deploy-extension.py
|
||||||
COPY platform/mcphub/run-with-env.py /usr/local/bin/run-with-env
|
COPY platform/mcphub/run-with-env.py /usr/local/bin/run-with-env
|
||||||
COPY platform/mcphub/casaderoll-entrypoint.sh /usr/local/bin/casaderoll-mcphub-entrypoint
|
COPY platform/mcphub/casaderoll-entrypoint.sh /usr/local/bin/casaderoll-mcphub-entrypoint
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ sie sich einen Docker-Container und ein Appdata-Backup teilen.
|
|||||||
- ARR, Deemix, Navidrome und GitHub laufen als lokale stdio-Unterprozesse.
|
- ARR, Deemix, Navidrome und GitHub laufen als lokale stdio-Unterprozesse.
|
||||||
- Home Assistant und MUA/Unraid sind vorhandene HTTP-MCP-Endpunkte und werden
|
- Home Assistant und MUA/Unraid sind vorhandene HTTP-MCP-Endpunkte und werden
|
||||||
vom Hub direkt weitergereicht.
|
vom Hub direkt weitergereicht.
|
||||||
- Allgemeine Webrecherche bleibt ein eingebautes Hermes-Werkzeug. Der alte
|
- Allgemeine Webrecherche bleibt ein eingebautes Hermes-Werkzeug. Hermes nutzt
|
||||||
|
den schlüssellosen Keenable-Provider für Suche und Seitenabruf. Der alte
|
||||||
Athena-Webadapter sowie SearXNG/TinySearch gehören nicht zum MCPHub-Image.
|
Athena-Webadapter sowie SearXNG/TinySearch gehören nicht zum MCPHub-Image.
|
||||||
- Athenas administrativer Operator ist hostgebunden und bleibt auf Athena.
|
- Athenas administrativer Operator ist hostgebunden und bleibt auf Athena.
|
||||||
MCPHub reicht den vorhandenen, nur über WireGuard erreichbaren HTTP-Endpunkt
|
MCPHub reicht den vorhandenen, nur über WireGuard erreichbaren HTTP-Endpunkt
|
||||||
@@ -22,6 +23,9 @@ sie sich einen Docker-Container und ein Appdata-Backup teilen.
|
|||||||
`/mnt/nvme-storage/appdata/MCPHub` on Unraid contains:
|
`/mnt/nvme-storage/appdata/MCPHub` on Unraid contains:
|
||||||
|
|
||||||
- `mcp_settings.json` (users, server registrations and tool toggles)
|
- `mcp_settings.json` (users, server registrations and tool toggles)
|
||||||
|
- `config/mcp-registry.json` (produktive deklarative Serverliste)
|
||||||
|
- `extensions/<id>/` (geprüfte portable MCP-Laufzeiten)
|
||||||
|
- `work/<id>/` (Manifest, Build- und Resume-Zwischenstand)
|
||||||
- `jwt-secret` (stable login sessions)
|
- `jwt-secret` (stable login sessions)
|
||||||
- `secrets/*.env` (local credentials, mode `0600`)
|
- `secrets/*.env` (local credentials, mode `0600`)
|
||||||
|
|
||||||
@@ -52,11 +56,29 @@ 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
|
pro Client nötig, ohne die MCP-Routen anonym zu öffnen. Port 8787 darf nicht ins
|
||||||
öffentliche Internet weitergeleitet werden.
|
öffentliche Internet weitergeleitet werden.
|
||||||
|
|
||||||
`configure-settings.py` erhält bestehende MCPHub-Benutzer und ersetzt
|
`configure-settings.py` erhält bestehende MCPHub-Benutzer und rendert die
|
||||||
Demo-Server durch die deklarative Produktionsliste. `verify-hub.py` führt
|
externe Produktionsliste. Das Image liefert nur den Seed für einen leeren
|
||||||
|
Neuaufbau. `verify-hub.py` führt
|
||||||
Handshakes und Tool-Listen ohne Schreibzugriff aus. `probe-hub.py` führt genau
|
Handshakes und Tool-Listen ohne Schreibzugriff aus. `probe-hub.py` führt genau
|
||||||
eine ausdrücklich benannte, begrenzte Funktionsprobe aus.
|
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:
|
||||||
|
|
||||||
|
```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>` 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.
|
||||||
|
|
||||||
## Migrationsregel
|
## Migrationsregel
|
||||||
|
|
||||||
Jeweils nur einen Server verschieben, seinen Handshake und einen begrenzten
|
Jeweils nur einen Server verschieben, seinen Handshake und einen begrenzten
|
||||||
|
|||||||
@@ -23,8 +23,15 @@ export JWT_SECRET
|
|||||||
# This makes image upgrades reproducible instead of relying on manual edits in
|
# This makes image upgrades reproducible instead of relying on manual edits in
|
||||||
# MCPHub's database/UI.
|
# MCPHub's database/UI.
|
||||||
settings_file="$state_dir/mcp_settings.json"
|
settings_file="$state_dir/mcp_settings.json"
|
||||||
|
registry_dir="$state_dir/config"
|
||||||
|
registry_file="$registry_dir/mcp-registry.json"
|
||||||
|
mkdir -p "$registry_dir" "$state_dir/extensions" "$state_dir/work"
|
||||||
|
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 \
|
python3 /opt/casaderoll/configure-settings.py \
|
||||||
"$settings_file" /run/secrets/mcphub \
|
"$settings_file" /run/secrets/mcphub \
|
||||||
--registry /opt/casaderoll/config/mcp-registry.json
|
--registry "$registry_file"
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ def registry_servers(registry: pathlib.Path, secrets_dir: pathlib.Path,
|
|||||||
if isinstance(rendered, dict) and isinstance(rendered.get("url"), str):
|
if isinstance(rendered, dict) and isinstance(rendered.get("url"), str):
|
||||||
rendered["url"] = re.sub(r"(?<!:)//+", "/", rendered["url"])
|
rendered["url"] = re.sub(r"(?<!:)//+", "/", rendered["url"])
|
||||||
previous = existing.get(server_id)
|
previous = existing.get(server_id)
|
||||||
if isinstance(previous, dict) and "enabled" in previous:
|
deployment = item.get("deployment") or {}
|
||||||
|
if (not deployment.get("managed_enabled")
|
||||||
|
and isinstance(previous, dict) and "enabled" in previous):
|
||||||
rendered["enabled"] = bool(previous["enabled"])
|
rendered["enabled"] = bool(previous["enabled"])
|
||||||
result[server_id] = rendered
|
result[server_id] = rendered
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Stage portable MCP runtimes in persistent MCPHub Appdata safely.
|
||||||
|
|
||||||
|
The helper deliberately separates staging from activation. Missing credentials
|
||||||
|
can never result in a published server, even if an agent submits a manifest
|
||||||
|
that requests activation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: pathlib.Path) -> dict:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise SystemExit(f"Expected JSON object: {path}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_json(path: pathlib.Path, value: dict) -> 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_keys(path: pathlib.Path) -> set[str]:
|
||||||
|
keys: set[str] = set()
|
||||||
|
if not path.is_file():
|
||||||
|
return keys
|
||||||
|
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("\"'"):
|
||||||
|
keys.add(key.removeprefix("export ").strip())
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def credential_state(server: dict, secrets_dir: pathlib.Path) -> tuple[bool, str]:
|
||||||
|
deployment = server.get("deployment") or {}
|
||||||
|
required = [str(item) for item in deployment.get("required_env", [])]
|
||||||
|
required_files = [str(item) for item in deployment.get("required_files", [])]
|
||||||
|
secret_name = str((server.get("hub") or {}).get("secret_file") or "")
|
||||||
|
if not required and not secret_name:
|
||||||
|
return True, "not-required"
|
||||||
|
if not secret_name:
|
||||||
|
return False, "secret-file-not-declared"
|
||||||
|
source = secrets_dir / secret_name
|
||||||
|
present = env_keys(source)
|
||||||
|
missing = [key for key in required if key not in present]
|
||||||
|
if not source.is_file():
|
||||||
|
return False, f"missing:{source}"
|
||||||
|
if missing:
|
||||||
|
return False, "missing-keys:" + ",".join(missing)
|
||||||
|
missing_files = [name for name in required_files if not (secrets_dir / name).is_file()]
|
||||||
|
if missing_files:
|
||||||
|
return False, "missing-files:" + ",".join(missing_files)
|
||||||
|
return True, "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def registry(path: pathlib.Path) -> dict:
|
||||||
|
document = load_json(path)
|
||||||
|
if document.get("version") != 1 or not isinstance(document.get("servers"), list):
|
||||||
|
raise SystemExit("Unsupported MCP registry schema")
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
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 validate_server(server: dict) -> str:
|
||||||
|
server_id = str(server.get("id") or "")
|
||||||
|
if not ID_RE.fullmatch(server_id):
|
||||||
|
raise SystemExit("Invalid server id")
|
||||||
|
for key in ("name", "description", "url", "hub"):
|
||||||
|
if not server.get(key):
|
||||||
|
raise SystemExit(f"Server field is required: {key}")
|
||||||
|
if not isinstance(server["hub"], dict) or not server["hub"].get("type"):
|
||||||
|
raise SystemExit("hub.type is required")
|
||||||
|
return server_id
|
||||||
|
|
||||||
|
|
||||||
|
def stage(args: argparse.Namespace) -> None:
|
||||||
|
manifest = load_json(args.manifest)
|
||||||
|
server = manifest.get("server")
|
||||||
|
if not isinstance(server, dict):
|
||||||
|
raise SystemExit("manifest.server must be an object")
|
||||||
|
server = json.loads(json.dumps(server))
|
||||||
|
server_id = validate_server(server)
|
||||||
|
extension_dir = args.appdata / "extensions" / server_id
|
||||||
|
work_root = (args.appdata / "work").resolve()
|
||||||
|
extension_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = pathlib.Path(tempfile.mkdtemp(prefix=f".{server_id}.", dir=extension_dir.parent))
|
||||||
|
try:
|
||||||
|
for artifact in manifest.get("artifacts", []):
|
||||||
|
source = pathlib.Path(str(artifact["source"])).resolve()
|
||||||
|
if work_root not in source.parents:
|
||||||
|
raise SystemExit(f"Artifact must be under {work_root}")
|
||||||
|
relative = pathlib.PurePosixPath(str(artifact["path"]))
|
||||||
|
if relative.is_absolute() or ".." in relative.parts:
|
||||||
|
raise SystemExit("Invalid artifact destination")
|
||||||
|
expected = str(artifact["sha256"]).lower()
|
||||||
|
actual = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||||
|
if actual != expected:
|
||||||
|
raise SystemExit(f"Checksum mismatch for {relative}")
|
||||||
|
target = temporary / relative
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(source, target)
|
||||||
|
target.chmod(int(str(artifact.get("mode", "0644")), 8))
|
||||||
|
backup = extension_dir.with_name(extension_dir.name + ".previous")
|
||||||
|
if backup.exists():
|
||||||
|
shutil.rmtree(backup)
|
||||||
|
if extension_dir.exists():
|
||||||
|
extension_dir.rename(backup)
|
||||||
|
temporary.rename(extension_dir)
|
||||||
|
except BaseException:
|
||||||
|
shutil.rmtree(temporary, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
desired = list(server.get("clients", []))
|
||||||
|
deployment = server.setdefault("deployment", {})
|
||||||
|
deployment["desired_clients"] = desired
|
||||||
|
deployment["managed_enabled"] = True
|
||||||
|
ready, reason = credential_state(server, args.secrets)
|
||||||
|
server["hub"]["enabled"] = False
|
||||||
|
server["clients"] = []
|
||||||
|
document = registry(args.registry)
|
||||||
|
document["servers"] = [item for item in document["servers"] if item.get("id") != server_id]
|
||||||
|
document["servers"].append(server)
|
||||||
|
atomic_json(args.registry, document)
|
||||||
|
print(json.dumps({
|
||||||
|
"status": "staged", "id": server_id, "enabled": False,
|
||||||
|
"credentials_ready": ready, "credential_state": reason,
|
||||||
|
"extension": str(extension_dir),
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
def set_enabled(args: argparse.Namespace, enabled: bool) -> None:
|
||||||
|
document = registry(args.registry)
|
||||||
|
server = find_server(document, args.id)
|
||||||
|
if server is None:
|
||||||
|
raise SystemExit(f"Unknown server: {args.id}")
|
||||||
|
ready, reason = credential_state(server, args.secrets)
|
||||||
|
if enabled and not ready:
|
||||||
|
raise SystemExit(f"Activation refused: {reason}")
|
||||||
|
server["hub"]["enabled"] = enabled
|
||||||
|
desired = list((server.get("deployment") or {}).get("desired_clients", []))
|
||||||
|
server["clients"] = desired if enabled else []
|
||||||
|
atomic_json(args.registry, document)
|
||||||
|
print(json.dumps({"status": "enabled" if enabled else "disabled", "id": args.id}))
|
||||||
|
|
||||||
|
|
||||||
|
def status(args: argparse.Namespace) -> None:
|
||||||
|
document = registry(args.registry)
|
||||||
|
server = find_server(document, args.id)
|
||||||
|
if server is None:
|
||||||
|
print(json.dumps({"id": args.id, "registered": False}))
|
||||||
|
return
|
||||||
|
ready, reason = credential_state(server, args.secrets)
|
||||||
|
print(json.dumps({
|
||||||
|
"id": args.id,
|
||||||
|
"registered": True,
|
||||||
|
"enabled": bool((server.get("hub") or {}).get("enabled")),
|
||||||
|
"clients": server.get("clients", []),
|
||||||
|
"credentials_ready": ready,
|
||||||
|
"credential_state": reason,
|
||||||
|
"extension_exists": (args.appdata / "extensions" / args.id).is_dir(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
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"))
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
stage_cmd = sub.add_parser("stage")
|
||||||
|
stage_cmd.add_argument("--manifest", type=pathlib.Path, required=True)
|
||||||
|
for name in ("status", "activate", "disable"):
|
||||||
|
command = sub.add_parser(name)
|
||||||
|
command.add_argument("id")
|
||||||
|
args = parser.parse_args()
|
||||||
|
args.appdata.mkdir(parents=True, exist_ok=True)
|
||||||
|
if args.command == "stage":
|
||||||
|
stage(args)
|
||||||
|
elif args.command == "status":
|
||||||
|
status(args)
|
||||||
|
elif args.command == "activate":
|
||||||
|
set_enabled(args, True)
|
||||||
|
else:
|
||||||
|
set_enabled(args, False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user