diff --git a/README.md b/README.md index 297c4de..9fdb781 100644 --- a/README.md +++ b/README.md @@ -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 MCPHub-Container auf Unraid, bleiben aber als getrennte MCP-Server unter `/mcp/NAME` sichtbar, abschaltbar und unabhängig für Clients freigebbar. -- Hermes verwendet für allgemeine Recherche seine integrierten Webwerkzeuge; - der frühere Athena-Webadapter wird nicht mehr gestartet. -- `config/mcp-registry.json` ist die einzige Liste der MCPHub-Server und ihrer - Client-Registrierungen. `platform/mcp/sync-clients.py` erzeugt Hermes daraus. +- Hermes verwendet für allgemeine Recherche den eingebauten schlüssellosen + Keenable-Provider für Suche und Seitenabruf; der frühere Athena-Webadapter + wird nicht mehr gestartet. +- Die produktive Liste der MCPHub-Server liegt in Unraid-Appdata unter + `MCPHub/config/mcp-registry.json`; `config/mcp-registry.json` ist der + Neuinstallations-Seed. `platform/mcp/sync-clients.py` erzeugt Hermes daraus. - Modelle und Athena-Backups liegen auf `/data`. Hermes liegt vollständig unter `/mnt/nvme-storage/appdata/Hermes-Agent`; MCPHub-Zustand, Client-Schlüssel und MCP-Zugänge liegen unter `/mnt/nvme-storage/appdata/MCPHub`. diff --git a/config/mcp-extensions/himalaya.json b/config/mcp-extensions/himalaya.json new file mode 100644 index 0000000..e0ffb24 --- /dev/null +++ b/config/mcp-extensions/himalaya.json @@ -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" + } + ] +} diff --git a/config/unraid-templates/my-MCPHub.xml b/config/unraid-templates/my-MCPHub.xml index e5032ec..3978b57 100644 --- a/config/unraid-templates/my-MCPHub.xml +++ b/config/unraid-templates/my-MCPHub.xml @@ -1,7 +1,7 @@ MCPHub - casaderoll/mcphub:1.1.1 + casaderoll/mcphub:1.2.1 https://hub.docker.com/r/samanhappy/mcphub bridge @@ -11,7 +11,7 @@ https://github.com/samanhappy/mcphub/issues https://github.com/samanhappy/mcphub https://github.com/samanhappy/mcphub#readme - 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. + 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]/ Benutzer beim ersten Start: admin diff --git a/dev/test_mcphub_deploy_extension.py b/dev/test_mcphub_deploy_extension.py new file mode 100644 index 0000000..8595e09 --- /dev/null +++ b/dev/test_mcphub_deploy_extension.py @@ -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() diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md index f210d2c..45c09fb 100644 --- a/docs/RECOVERY.md +++ b/docs/RECOVERY.md @@ -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. 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 bestehenden Unraid-Appdata-Backup gesichert. Für ein vollständiges Desaster-Recovery müssen daher sowohl Athenas `/data` als auch dieses diff --git a/platform/hermes/config.yaml b/platform/hermes/config.yaml index e225975..7793a7b 100644 --- a/platform/hermes/config.yaml +++ b/platform/hermes/config.yaml @@ -36,8 +36,11 @@ terminal: lifetime_seconds: 1800 web: - search_backend: "searxng" - extract_backend: "native" + # Keenable is bundled with Hermes and provides both search and extraction + # 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 keyless_fallback: true keyless_rescue: true @@ -161,7 +164,7 @@ skills: creation_nudge_interval: 20 plugins: - enabled: ["web-searxng"] + enabled: ["browser-browser-use", "web-keenable"] timeouts: tools: diff --git a/platform/hermes/install-hermes.sh b/platform/hermes/install-hermes.sh index c8d7ccf..adb223e 100755 --- a/platform/hermes/install-hermes.sh +++ b/platform/hermes/install-hermes.sh @@ -42,7 +42,6 @@ ROUTER_API_KEY=$router_key VOICE_TOOLS_OPENAI_KEY=$router_key MUA_MCP_URL=$mua_url MUA_MCP_BEARER_TOKEN=$mua_token -SEARXNG_URL=http://searxng:8080 API_SERVER_ENABLED=true API_SERVER_HOST=0.0.0.0 API_SERVER_PORT=8642 diff --git a/platform/hermes/install-profiles.sh b/platform/hermes/install-profiles.sh index d24199e..88c6b5e 100755 --- a/platform/hermes/install-profiles.sh +++ b/platform/hermes/install-profiles.sh @@ -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 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 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 set compression.threshold 0.95 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 # client renderer in the already version-pinned MCPHub image instead of adding # 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} token_mount=() 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 \ -v "$STACK_DIR:/stack:ro" \ -v "$HERMES_DATA_DIR:/hermes:rw" \ + -v "$mcphub_registry:/run/input/mcp-registry.json:ro" \ "${token_mount[@]}" \ - casaderoll/mcphub:1.1.0 \ + casaderoll/mcphub:1.2.1 \ /stack/platform/mcp/sync-clients.py "${sync_args[@]}" "$STACK_DIR/platform/hermes/install-skills.sh" diff --git a/platform/hermes/skills/mcphub-deployer/SKILL.md b/platform/hermes/skills/mcphub-deployer/SKILL.md index 13c0ba7..e367383 100644 --- a/platform/hermes/skills/mcphub-deployer/SKILL.md +++ b/platform/hermes/skills/mcphub-deployer/SKILL.md @@ -1,229 +1,107 @@ --- 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 -Install portable MCPs in the existing `MCPHub` container on Unraid. Never -create one Docker container per portable MCP and never install one inside -Hermes. Hermes, OpenWebUI, Pi, and other agents are clients of MCPHub. +Install portable MCPs in the existing `MCPHub`; never create a separate +container and never install them inside Hermes. -## 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/` +- Extensions: `/mnt/nvme-storage/appdata/MCPHub/extensions/` +- Registry: `/mnt/nvme-storage/appdata/MCPHub/config/mcp-registry.json` +- Secret: `/mnt/nvme-storage/appdata/MCPHub/secrets/.env` (0600) +- Helper in container: `/opt/casaderoll/deploy-extension.py` +- Route: `http://192.168.1.2:8787/mcp/` -- Host: Unraid `192.168.1.2` -- Container: `MCPHub` -- UI/base URL: `http://192.168.1.2:8787` -- 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/.env`, mode `0600` -- Client bearer token: `/mnt/nvme-storage/appdata/MCPHub/client-token` -- Individual route: `http://192.168.1.2:8787/mcp/` +Do not search for other checkouts, registries, templates, or secret stores. +Normal extensions do not modify Dockerfile, image tag, Unraid template, MCPHub +source, or Hermes config manually. -The versioned template and the live DockerMan template are different files. -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. +## Hard credential boundary -The operational build tree is persistent and covered by the normal Unraid -Appdata backup. The checkout below is legacy and MUST NOT be used or inspected -for MCPHub work: +Credentials are user input. Check only whether the dedicated secret file and +required keys exist; never print values. Never inspect other containers, +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 -the same focused files there when an authorized Git write path is available. -Lack of Git access is a warning to report, not permission to search for other -checkouts and not a reason to abandon an otherwise requested local install. +1. Read this skill once. Inspect only MCPHub state, the fixed registry, the + dedicated secret-file presence, and the target upstream release/source. +2. Classify once: existing HTTP MCP, packaged stdio MCP, released binary, + 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 `/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 -restart discovery after a phase has completed. +Use paths as seen inside MCPHub (`/app/data/...`) in the manifest. +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//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. -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. +## Limits -Never run `find` to rediscover a listed path. Never read unrelated Compose -stacks, repositories, documentation trees, container logs, container -environments, application configs, home directories, or secret stores. +- At most six preflight reads and two attempts per hypothesis. +- At most one corrected build. +- 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 -targets. This rule overrides the desire to complete a deployment or live test. - -- Never inspect another container, service, environment, mount, config file, - 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/` 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/.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. +Before compression write `/checkpoint.json` containing only phase, +version, completed checks, pending action, modified paths, and rollback. After +compression reload this skill and that checkpoint before continuing. Delete +the checkpoint only after success. diff --git a/platform/mcphub/Dockerfile b/platform/mcphub/Dockerfile index 97da1ef..726d8de 100644 --- a/platform/mcphub/Dockerfile +++ b/platform/mcphub/Dockerfile @@ -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 config/mcp-registry.json /opt/casaderoll/config/mcp-registry.json 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/casaderoll-entrypoint.sh /usr/local/bin/casaderoll-mcphub-entrypoint diff --git a/platform/mcphub/README.md b/platform/mcphub/README.md index 9a5d2d3..d9af85e 100644 --- a/platform/mcphub/README.md +++ b/platform/mcphub/README.md @@ -9,7 +9,8 @@ sie sich einen Docker-Container und ein Appdata-Backup teilen. - ARR, Deemix, Navidrome und GitHub laufen als lokale stdio-Unterprozesse. - Home Assistant und MUA/Unraid sind vorhandene HTTP-MCP-Endpunkte und werden 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. - Athenas administrativer Operator ist hostgebunden und bleibt auf Athena. 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: - `mcp_settings.json` (users, server registrations and tool toggles) +- `config/mcp-registry.json` (produktive deklarative Serverliste) +- `extensions//` (geprüfte portable MCP-Laufzeiten) +- `work//` (Manifest, Build- und Resume-Zwischenstand) - `jwt-secret` (stable login sessions) - `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 öffentliche Internet weitergeleitet werden. -`configure-settings.py` erhält bestehende MCPHub-Benutzer und ersetzt -Demo-Server durch die deklarative Produktionsliste. `verify-hub.py` führt +`configure-settings.py` erhält bestehende MCPHub-Benutzer und rendert die +externe Produktionsliste. Das Image liefert nur den Seed für einen leeren +Neuaufbau. `verify-hub.py` führt Handshakes und Tool-Listen ohne Schreibzugriff aus. `probe-hub.py` führt genau 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/` 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//manifest.json +``` + +Der Helfer prüft Checksummen, kopiert atomar nach `extensions/` 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 Jeweils nur einen Server verschieben, seinen Handshake und einen begrenzten diff --git a/platform/mcphub/casaderoll-entrypoint.sh b/platform/mcphub/casaderoll-entrypoint.sh index 69fca90..731a130 100644 --- a/platform/mcphub/casaderoll-entrypoint.sh +++ b/platform/mcphub/casaderoll-entrypoint.sh @@ -23,8 +23,15 @@ export JWT_SECRET # This makes image upgrades reproducible instead of relying on manual edits in # MCPHub's database/UI. 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 \ "$settings_file" /run/secrets/mcphub \ - --registry /opt/casaderoll/config/mcp-registry.json + --registry "$registry_file" exec "$@" diff --git a/platform/mcphub/configure-settings.py b/platform/mcphub/configure-settings.py index 0839563..08b902a 100644 --- a/platform/mcphub/configure-settings.py +++ b/platform/mcphub/configure-settings.py @@ -74,7 +74,9 @@ def registry_servers(registry: pathlib.Path, secrets_dir: pathlib.Path, if isinstance(rendered, dict) and isinstance(rendered.get("url"), str): rendered["url"] = re.sub(r"(? 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()