simplify MCPHub extensions and restore Hermes web tools

This commit is contained in:
Mikei386
2026-08-26 11:08:47 +02:00
parent 9455a79dc2
commit bc902261b9
14 changed files with 503 additions and 226 deletions
+6 -4
View File
@@ -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`.
+47
View File
@@ -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"
}
]
}
+2 -2
View File
@@ -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.&#13; <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.&#13;
&#13; &#13;
Weboberfläche: http://[IP]:[PORT:3000]/&#13; Weboberfläche: http://[IP]:[PORT:3000]/&#13;
Benutzer beim ersten Start: admin&#13; Benutzer beim ersten Start: admin&#13;
+90
View File
@@ -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
View File
@@ -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
+6 -3
View File
@@ -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:
-1
View File
@@ -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
+11 -2
View File
@@ -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"
+86 -208
View File
@@ -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.
+1
View File
@@ -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
+25 -3
View File
@@ -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
+8 -1
View File
@@ -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 "$@"
+3 -1
View File
@@ -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
+216
View File
@@ -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()