simplify Athena runtime and centralize MCP management

This commit is contained in:
Mikei386
2026-08-26 09:57:38 +02:00
parent 104c9904a5
commit ab671a6396
31 changed files with 918 additions and 419 deletions
+4 -100
View File
@@ -1,102 +1,6 @@
#!/usr/bin/env bash
set -uo pipefail
# Compatibility entrypoint: there is only one authoritative release check.
set -Eeuo pipefail
PASS=0
WARN=0
FAIL=0
pass() { printf 'PASS %s\n' "$*"; PASS=$((PASS + 1)); }
warn() { printf 'WARN %s\n' "$*"; WARN=$((WARN + 1)); }
fail() { printf 'FAIL %s\n' "$*"; FAIL=$((FAIL + 1)); }
echo "== Local AI platform verification =="
ROOT_USE="$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')"
if [[ -n "$ROOT_USE" && "$ROOT_USE" -lt 85 ]]; then
pass "Systempartition bei ${ROOT_USE}%"
else
fail "Systempartition bei ${ROOT_USE:-unbekannt}% (Ziel: unter 85%)"
fi
if command -v nvidia-smi >/dev/null 2>&1; then
GPU_LIST="$(nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null)"
GPU_COUNT="$(printf '%s\n' "$GPU_LIST" | sed '/^[[:space:]]*$/d' | wc -l)"
if [[ "$GPU_COUNT" -ge 2 ]]; then
pass "beide GPUs erkannt: $(printf '%s' "$GPU_LIST" | paste -sd ' | ' -)"
elif [[ "$GPU_COUNT" -eq 1 ]]; then
fail "nur eine von zwei erwarteten GPUs erkannt: $GPU_LIST"
else
fail "nvidia-smi liefert keine GPU"
fi
else
fail "nvidia-smi fehlt"
fi
if [[ -e /sys/class/net/lan0 ]] && \
[[ "$(< /sys/class/net/lan0/address)" == "58:11:22:bb:ad:0c" ]] && \
[[ "$(< /sys/class/net/lan0/operstate)" == "up" ]]; then
pass "stabiles LAN-Interface lan0 aktiv (58:11:22:bb:ad:0c)"
else
fail "stabiles LAN-Interface lan0 fehlt, ist down oder hat die falsche MAC"
fi
container_healthy() {
local name=$1 state health
state="$(docker inspect --format '{{.State.Status}}' "$name" 2>/dev/null || true)"
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$name" 2>/dev/null || true)"
[[ $state == running && ( -z $health || $health == healthy ) ]]
}
for container in mike-ai-profile-controller mike-ai-router mike-ai-xtts \
mike-ai-piper mike-ai-tts-gateway mike-ai-open-webui; do
if container_healthy "$container"; then
pass "$container gesund"
else
fail "$container fehlt oder ist nicht gesund"
fi
done
ACTIVE_LLAMA="$(docker ps --format '{{.Names}}' | grep -Ec '^mike-ai-llama-(fast|medium|large|ultra|uncensored|experimental)$' || true)"
if [[ $ACTIVE_LLAMA -eq 1 ]]; then
pass "exakt ein llama.cpp-Profil aktiv"
else
fail "$ACTIVE_LLAMA llama.cpp-Profile aktiv (erwartet: 1)"
fi
for optional in mike-ai-mcp-web mike-ai-mcp-homeassistant mike-ai-mcp-arr \
mike-ai-mcp-github mike-ai-mcp-athena-operator mike-ai-backup; do
if container_healthy "$optional"; then
pass "$optional aktiv"
else
warn "$optional nicht aktiv oder nicht installiert"
fi
done
if docker ps -a --format '{{.Names}}' | grep -qx 'mike-ai-mcp-unraid-official'; then
fail "veralteter GraphQL-basierter Unraid-MCP ist noch vorhanden"
else
pass "kein GraphQL-basierter Unraid-MCP vorhanden"
fi
if docker exec mike-ai-router python -c \
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8081/health', timeout=3)" \
>/dev/null 2>&1; then
pass "Router-Liveness intern erreichbar"
else
fail "Router-Liveness intern nicht erreichbar"
fi
if systemctl list-unit-files --no-legend 2>/dev/null | grep -Eq '(vision-rx|whisper-rx|granite-rx).*enabled'; then
warn "Aktivierte RX-Altlast gefunden"
else
pass "Keine aktivierte RX-Altlast"
fi
if systemctl list-unit-files --no-legend 2>/dev/null | grep -E '(benchmark|race).*enabled' >/dev/null; then
warn "Automatisch aktivierter Benchmark-/Race-Dienst gefunden"
else
pass "Keine automatisch aktivierten Benchmarks"
fi
printf '\nErgebnis: %d PASS, %d WARN, %d FAIL\n' "$PASS" "$WARN" "$FAIL"
[[ "$FAIL" -eq 0 ]]
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
exec "$ROOT_DIR/smoke-test.sh" "$@"
+15 -6
View File
@@ -59,7 +59,7 @@ agent:
# Keep enough deliberation for tool choice while avoiding the provider's
# unbounded `auto` reasoning mode on ordinary turns. Users can still raise it
# per session with /reasoning.
reasoning_effort: "minimal"
reasoning_effort: "low"
gateway_timeout: 3600
session_stall_timeout: 600
tool_loop_guardrails:
@@ -77,7 +77,9 @@ agent:
max_web_searches: 8
max_subagents: 8
# Keep ample room for long agent work. Compression starts at 82% of whichever
# Keep ample room for long agent work. Compression starts late; deterministic
# tool-result pruning and rolling micro-compaction keep it from getting there
# during normal jobs.
# router profile is selected. Keep the result compact enough that a local
# model does not spend many minutes generating the handoff.
compression:
@@ -89,19 +91,26 @@ compression:
micro_compact_every_n_turns: 5
micro_compact_defrag_threshold_tokens: 2000
progress_notices: true
threshold: 0.82
threshold: 0.95
target_ratio: 0.15
max_attempts: 1
tail_mode: "lean"
protect_last_n: 20
protect_first_n: 0
proactive_prune_tokens: 50000
proactive_prune_min_result_chars: 4000
proactive_prune_min_reclaim_tokens: 4096
proactive_prune_tokens: 32000
proactive_prune_min_result_chars: 2000
proactive_prune_min_reclaim_tokens: 2048
context_timeout_seconds: 45
# A failed local summarizer must not block an interactive client for ten
# minutes. Continue without dropping messages after two minutes.
context_total_ceiling_seconds: 120
auxiliary:
compression:
provider: "main"
reasoning_effort: "none"
timeout: 120
max_concurrency: 1
# Session names are cosmetic and used to create a second concurrent LLM
# request after every first reply. On a single inference slot this blocks the
# actual chat, so keep the original timestamp/session id instead.
+42 -49
View File
@@ -1,23 +1,26 @@
#!/usr/bin/env bash
set -Eeuo pipefail
HERMES_CONTAINER=${HERMES_CONTAINER:-mike-ai-hermes}
HERMES_CONTAINER=${HERMES_CONTAINER:-Hermes-Agent}
SECRETS_DIR=${SECRETS_DIR:-/etc/mike-ai}
HERMES_DATA_DIR=${HERMES_DATA_DIR:-/data/hermes}
HERMES_DATA_DIR=${HERMES_DATA_DIR:-/mnt/nvme-storage/appdata/Hermes-Agent}
STACK_DIR=${STACK_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}
PROFILE_MATRIX=${PROFILE_MATRIX:-$STACK_DIR/config/profile-matrix.json}
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
docker inspect "$HERMES_CONTAINER" >/dev/null 2>&1 || \
die "Hermes-Container fehlt: $HERMES_CONTAINER"
[[ -s $PROFILE_MATRIX ]] || die "Profilmatrix fehlt: $PROFILE_MATRIX"
deadline=$((SECONDS + 180))
until [[ $(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \
"$HERMES_CONTAINER" 2>/dev/null || true) == healthy ]]; do
"$HERMES_CONTAINER" 2>/dev/null || true) =~ ^(healthy|running)$ ]]; do
(( SECONDS < deadline )) || die "Hermes wurde nicht rechtzeitig gesund."
sleep 2
done
create_profile() {
local name=$1 model=$2 context=$3 description=$4
local name=$1 model=$2 context=$3 description=$4 max_tokens=$5
if ! docker exec "$HERMES_CONTAINER" hermes profile show "$name" >/dev/null 2>&1; then
docker exec "$HERMES_CONTAINER" hermes profile create "$name" \
--clone-from default --description "$description"
@@ -26,23 +29,32 @@ create_profile() {
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set model.context_length "$context"
# These are platform-wide latency and loop safeguards, not model-specific
# tuning. Enforce them on old profiles as well as newly cloned profiles.
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set model.max_tokens 8192
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set model.max_tokens "$max_tokens"
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set agent.max_turns 64
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set agent.reasoning_effort minimal
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 unset compression.threshold_tokens || true
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.threshold 0.82
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.max_attempts 1
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.context_timeout_seconds 45
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.context_total_ceiling_seconds 120
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.protect_last_n 20
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.protect_first_n 0
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.micro_compact true
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.micro_compact_every_n_turns 5
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.micro_compact_defrag_threshold_tokens 2000
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.proactive_prune_tokens 32000
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.proactive_prune_min_result_chars 2000
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.proactive_prune_min_reclaim_tokens 2048
# The selected 27B profile performs production compaction. A separate small
# compressor was removed after it lost exact technical state in benchmarks.
docker exec "$HERMES_CONTAINER" hermes -p "$name" config unset auxiliary.compression || true
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set auxiliary.compression.provider main
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set auxiliary.compression.reasoning_effort none
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set auxiliary.compression.timeout 120
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set auxiliary.compression.max_concurrency 1
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set platform_toolsets.cli \
'["web","terminal","file","skills","todo","memory","vision","tts"]'
[[ $(docker exec "$HERMES_CONTAINER" hermes -p "$name" config get model.default) == "$model" ]] || \
@@ -51,51 +63,32 @@ create_profile() {
die "Kontext von Profil $name konnte nicht verifiziert werden."
}
create_profile fast qwen-fast 76800 \
"Schnelles Qwen3.8-27B-Profil mit 76,8K Kontext fuer kurze Chats und schnelle Aufgaben."
create_profile medium qwen-medium 160000 \
"Ausgewogenes Qwen3.8-27B-Standardprofil mit 160K Kontext fuer Alltag und agentische Aufgaben."
create_profile large qwen-large 192000 \
"Grosses Qwen3.8-27B-Profil mit 192K Kontext fuer umfangreiche Dokumente und lange Aufgaben."
create_profile ultra qwen-ultra 262144 \
"Maximales Qwen3.8-27B-Profil mit 262K Kontext fuer sehr grosse Kontexte; langsamer als die Standardprofile."
create_profile uncensored qwen-uncensored 80000 \
"Unzensiertes Qwen3.8-27B-Profil mit 80K Kontext fuer spezielle Anfragen."
while IFS=$'\t' read -r name model context description max_tokens; do
create_profile "$name" "$model" "$context" "$description" "$max_tokens"
done < <(jq -r --argjson max "$(jq '.max_output_tokens' "$PROFILE_MATRIX")" \
'.profiles[] | [.id,.alias,(.context|tostring),(.description|gsub("[\\t\\n]";" ")),($max|tostring)] | @tsv' \
"$PROFILE_MATRIX")
# Existing profiles may predate managed secret rendering and therefore contain
# the literal ${ROUTER_API_KEY}. Repair only that exact placeholder; never log
# or commit the secret itself.
[[ -s $SECRETS_DIR/router-api-key ]] || die "Router-API-Key fehlt."
router_key=$(<"$SECRETS_DIR/router-api-key")
ROUTER_API_KEY="$router_key" HERMES_DATA_DIR="$HERMES_DATA_DIR" python3 <<'PY'
import os
import pathlib
root = pathlib.Path(os.environ["HERMES_DATA_DIR"]) / "profiles"
placeholder = "${ROUTER_API_KEY}"
paths = [pathlib.Path(os.environ["HERMES_DATA_DIR"]) / "config.yaml"]
paths.extend(sorted(root.glob("*/config.yaml")))
for path in paths:
if not path.exists():
continue
text = path.read_text()
if placeholder in text:
text = text.replace(placeholder, os.environ["ROUTER_API_KEY"], 1)
# Avoid collision with Hermes' disabled built-in `homeassistant` toolset.
# The collision filters the healthy external MCP out of agent snapshots.
text = text.replace(
"\n homeassistant:\n url: http://mcp-homeassistant:8000/mcp\n",
"\n homeassistant-admin:\n url: http://mcp-homeassistant:8000/mcp\n",
)
path.write_text(text)
PY
sync_args=(--registry "${STACK_DIR:-/opt/mike-ai/stack}/config/mcp-registry.json")
# 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_token=${MCPHUB_TOKEN_FILE:-/mnt/nvme-storage/appdata/MCPHub/client-token}
token_mount=()
if [[ -s $mcphub_token ]]; then
token_mount=(-v "$mcphub_token:/run/input/mcphub-token:ro")
sync_args+=(--mcphub-token-file /run/input/mcphub-token)
fi
while IFS= read -r config; do
sync_args+=(--hermes "$config")
sync_args+=(--hermes "/hermes/${config#"$HERMES_DATA_DIR"/}")
done < <(find "$HERMES_DATA_DIR" -name config.yaml -type f -print)
python3 "${STACK_DIR:-/opt/mike-ai/stack}/platform/mcp/sync-clients.py" "${sync_args[@]}"
docker run --rm --entrypoint python \
-v "$STACK_DIR:/stack:ro" \
-v "$HERMES_DATA_DIR:/hermes:rw" \
"${token_mount[@]}" \
casaderoll/mcphub:1.1.0 \
/stack/platform/mcp/sync-clients.py "${sync_args[@]}"
"${STACK_DIR:-/opt/mike-ai/stack}/platform/hermes/install-skills.sh"
"$STACK_DIR/platform/hermes/install-skills.sh"
docker exec "$HERMES_CONTAINER" hermes profile list
printf 'HERMES_PROFILES_OK\n'
+24 -16
View File
@@ -1,31 +1,39 @@
#!/usr/bin/env bash
set -Eeuo pipefail
STACK_DIR=${STACK_DIR:-/opt/mike-ai/stack}
HERMES_DATA_DIR=${HERMES_DATA_DIR:-/data/hermes}
SKILL_SOURCE=$STACK_DIR/platform/hermes/skills/athena-operator/SKILL.md
PROFILES=(fast medium large ultra uncensored)
STACK_DIR=${STACK_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}
HERMES_DATA_DIR=${HERMES_DATA_DIR:-/mnt/nvme-storage/appdata/Hermes-Agent}
PROFILE_MATRIX=${PROFILE_MATRIX:-$STACK_DIR/config/profile-matrix.json}
SKILL_ROOT=$STACK_DIR/platform/hermes/skills
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
[[ -s $SKILL_SOURCE ]] || die "Skill-Quelle fehlt: $SKILL_SOURCE"
grep -Fxq -- 'name: athena-operator' "$SKILL_SOURCE" || \
die "Skill-Quelle hat kein gültiges Athena-Operator-Frontmatter."
[[ -s $PROFILE_MATRIX ]] || die "Profilmatrix fehlt: $PROFILE_MATRIX"
install_skill() {
local root=$1 target=$1/platform/athena-operator/SKILL.md
local source=$1 root=$2 name target
name=${source%/SKILL.md}
name=${name##*/}
target=$root/platform/$name/SKILL.md
grep -Fxq -- "name: $name" "$source" || \
die "Skill-Quelle hat kein gültiges Frontmatter: $source"
install -d -o 10000 -g 10000 -m 0750 "${target%/*}"
if [[ -s $target ]] && ! cmp -s "$SKILL_SOURCE" "$target"; then
if [[ -s $target ]] && ! cmp -s "$source" "$target"; then
cp -a "$target" "$target.before-managed-update-$(date +%Y%m%d-%H%M%S)"
fi
install -o 10000 -g 10000 -m 0640 "$SKILL_SOURCE" "$target"
cmp -s "$SKILL_SOURCE" "$target" || die "Skill-Synchronisierung fehlgeschlagen: $target"
install -o 10000 -g 10000 -m 0640 "$source" "$target"
cmp -s "$source" "$target" || die "Skill-Synchronisierung fehlgeschlagen: $target"
}
install_skill "$HERMES_DATA_DIR/skills"
for profile in "${PROFILES[@]}"; do
[[ -d $HERMES_DATA_DIR/profiles/$profile ]] || continue
install_skill "$HERMES_DATA_DIR/profiles/$profile/skills"
mapfile -t profiles < <(jq -r '.profiles[].id' "$PROFILE_MATRIX")
for source in "$SKILL_ROOT"/*/SKILL.md; do
[[ -s $source ]] || continue
install_skill "$source" "$HERMES_DATA_DIR/skills"
for profile in "${profiles[@]}"; do
[[ -d $HERMES_DATA_DIR/profiles/$profile ]] || continue
install_skill "$source" "$HERMES_DATA_DIR/profiles/$profile/skills"
done
done
printf 'HERMES_ATHENA_OPERATOR_SKILL_OK\n'
printf 'HERMES_SKILLS_OK\n'
@@ -16,10 +16,10 @@ Use these paths directly. Do not search the filesystem for alternatives.
- 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`
- Operational source/build tree: `/mnt/nvme-storage/appdata/MCPHub/build/repo`
- Dockerfile: `platform/mcphub/Dockerfile` below that tree
- Server declaration: `platform/mcphub/configure-settings.py`
- Client registry: `config/mcp-registry.json`
- Single server and client registry: `config/mcp-registry.json`
- Registry renderer: `platform/mcphub/configure-settings.py` (normally unchanged)
- Unraid template: `config/unraid-templates/my-MCPHub.xml`
- Persistent state: `/mnt/nvme-storage/appdata/MCPHub`
- Secrets: `/mnt/nvme-storage/appdata/MCPHub/secrets/<server>.env`, mode `0600`
@@ -91,8 +91,8 @@ Modify only the necessary fixed production files. Rules:
- 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 in `configure-settings.py`; do not manually treat
`mcp_settings.json` as the source of truth.
- 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.
@@ -104,7 +104,7 @@ unfiltered large server to clients.
### 5. Deploy without collateral changes
Build from `/mnt/nvme-storage/appdata/MCPHub/build`, then recreate only
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.
+1 -33
View File
@@ -11,13 +11,6 @@ if [[ -s $STACK_ENV ]]; then
COMPOSE+=(--env-file "$STACK_ENV")
fi
COMPOSE+=(-f "$STACK_DIR/compose.yaml")
export SEARXNG_SETTINGS_FILE="${SEARXNG_SETTINGS_FILE:-$MCP_DIR/../web-search/searxng-settings.yml}"
[[ -s $SEARXNG_SETTINGS_FILE ]] || {
echo "SearXNG-Konfiguration fehlt: $SEARXNG_SETTINGS_FILE" >&2
exit 1
}
docker network inspect mike-ai-tools >/dev/null 2>&1 || \
docker network create --internal --subnet 172.30.40.0/24 mike-ai-tools >/dev/null
docker network inspect mike-ai-tools-egress >/dev/null 2>&1 || \
@@ -26,36 +19,11 @@ docker network inspect mike-ai-tools-egress >/dev/null 2>&1 || \
# One administrative MCP exposes ATHENA.md plus the bounded host operator.
"$MCP_DIR/../operator/install-operator.sh"
services=(searxng tinysearch mcp-athena-operator)
services=(mcp-athena-operator)
# Portable Fach-MCPs laufen zentral im MCPHub auf Unraid. Ihre Compose-Blöcke
# bleiben vorläufig als explizite Rollback-Profile erhalten, werden bei einer
# normalen Athena-Installation aber weder gebaut noch gestartet.
echo "ARR, Deemix, GitHub, Home Assistant und Navidrome werden über MCPHub auf Unraid bereitgestellt."
# TinySearch keeps the embedding bundle outside the container. Download it
# once on a fresh host; subsequent rebuilds reuse the named volume.
#
# Do not call `tinysearch setup` here. The pinned container image already
# contains a complete Playwright/Chromium installation, while that command
# unconditionally tries to install Chromium again. On IPv4-only hosts this
# redundant download can hang indefinitely. Prepare only the persistent ONNX
# bundle that is actually absent on a fresh installation.
docker volume create mike-ai-tools_tinysearch-models >/dev/null
tiny_image="marcellm01/tinysearch@sha256:7a7d0585f5000f462e699e42b97409715826a9e2edcd09a166afa93a4b7cba31"
if ! docker run --rm --entrypoint test \
-v mike-ai-tools_tinysearch-models:/data/models "$tiny_image" \
-f /data/models/all-minilm-l6-v2-onnx/model.onnx; then
echo "TinySearch-Modell wird einmalig geladen."
docker run --rm --entrypoint python \
-v mike-ai-tools_tinysearch-models:/data/models "$tiny_image" \
-c 'from tinysearch.services.onnx_bundle_service import ensure_onnx_bundle_sync; ensure_onnx_bundle_sync("fast")'
fi
"${COMPOSE[@]}" up -d --build "${services[@]}"
for webui in mike-ai-open-webui Open-WebUI; do
if docker container inspect "$webui" >/dev/null 2>&1; then
docker network connect mike-ai-tools "$webui" 2>/dev/null || true
fi
done
"${COMPOSE[@]}" ps
+25 -1
View File
@@ -13,6 +13,7 @@ import time
BEGIN = "# BEGIN MANAGED MCP SERVERS"
END = "# END MANAGED MCP SERVERS"
CLIENT_TOKEN = ""
def env_file(path: str) -> dict[str, str]:
@@ -37,7 +38,10 @@ def enabled(item: dict) -> bool:
if source:
values = env_file(source)
url_ready = bool(item.get("url")) or bool(values.get(item.get("url_env", "")))
key_ready = not item.get("key_env") or bool(values.get(item["key_env"]))
key_ready = (not item.get("key_env")
or bool(values.get(item["key_env"]))
or (item.get("key_env") == "MCPHUB_BEARER_TOKEN"
and bool(CLIENT_TOKEN)))
return url_ready and key_ready
return True
@@ -47,6 +51,8 @@ def resolved(item: dict) -> tuple[str, str]:
values = env_file(item["env_file"])
url = item.get("url") or values[item["url_env"]]
key = values.get(item.get("key_env", ""), "")
if not key and item.get("key_env") == "MCPHUB_BEARER_TOKEN":
key = CLIENT_TOKEN
return url, key
return item["url"], ""
@@ -72,6 +78,16 @@ def hermes_block(items: list[dict]) -> str:
])
if key:
lines.extend([" headers:", f" Authorization: {yaml_quote('Bearer ' + key)}"])
if "tool_include" in item:
lines.append(" tools:")
lines.append(" include:")
for tool in item["tool_include"]:
lines.append(f" - {yaml_quote(str(tool))}")
elif "tool_exclude" in item:
lines.append(" tools:")
lines.append(" exclude:")
for tool in item["tool_exclude"]:
lines.append(f" - {yaml_quote(str(tool))}")
lines.extend([
f" timeout: {int(item.get('timeout', 300))}",
" connect_timeout: 30",
@@ -103,6 +119,8 @@ def openwebui_connection(item: dict) -> dict:
config = {"enable": True, "access_grants": []}
if item.get("functions"):
config["function_name_filter_list"] = item["functions"]
elif item.get("tool_include"):
config["function_name_filter_list"] = ",".join(item["tool_include"])
return {
"url": url, "path": "", "type": "mcp",
"auth_type": item.get("auth_type", "none"), "headers": None,
@@ -135,11 +153,17 @@ def update_openwebui(db: pathlib.Path, items: list[dict]) -> None:
def main() -> None:
global CLIENT_TOKEN
parser = argparse.ArgumentParser()
parser.add_argument("--registry", type=pathlib.Path, required=True)
parser.add_argument("--hermes", type=pathlib.Path, action="append", default=[])
parser.add_argument("--openwebui-db", type=pathlib.Path)
parser.add_argument("--mcphub-token-file", type=pathlib.Path)
args = parser.parse_args()
if args.mcphub_token_file:
CLIENT_TOKEN = args.mcphub_token_file.read_text(encoding="utf-8").strip()
if not CLIENT_TOKEN:
raise SystemExit("MCPHub token file is empty")
if args.hermes:
block = hermes_block(active(args.registry, "hermes"))
for path in args.hermes:
+14 -3
View File
@@ -2,11 +2,13 @@ FROM ghcr.io/github/github-mcp-server@sha256:1817b57d43916532dc002bdc5f344d639bd
FROM ghcr.io/blakeem/navidrome-mcp:2.2.0@sha256:047f911a5a8f7cc8f185bb4d6e7ca6c435542edefff4694a00c2f718ab0ee7f5 AS navidrome
FROM samanhappy/mcphub:1.0.32
FROM samanhappy/mcphub:1.0.32@sha256:df34df85e639743d0bf4b64182fc2f47586bf544beb08c85a5e5d693891daad3
ARG MCP_VERSION=1.29.0
ARG ARR_MCP_VERSION=1.0.1
ARG YT_DLP_VERSION=2026.7.4
ARG FRITZ_MCP_VERSION=0.8.0
ARG FRITZ_MCP_SHA256=47f4e2b5595a522aeda4aed9f5c65a57e500c3589c9f4262b8fbf72bd8c72bd4
USER root
@@ -18,13 +20,21 @@ RUN python3 -m pip install --no-cache-dir \
"arr-mcp[mcp]==${ARR_MCP_VERSION}" \
"yt-dlp==${YT_DLP_VERSION}"
# Released, checksum-pinned Go binary. Keeping the download in the reproducible
# image build prevents MCPHub upgrades from silently dropping the Fritz tools.
RUN mkdir -p /opt/casaderoll \
&& python3 -c 'import sys,urllib.request; v=sys.argv[1]; urllib.request.urlretrieve(f"https://github.com/kambriso/fritzbox-mcp-server/releases/download/v{v}/fritz-mcp-linux-amd64", "/opt/casaderoll/fritz-mcp")' "${FRITZ_MCP_VERSION}" \
&& echo "${FRITZ_MCP_SHA256} /opt/casaderoll/fritz-mcp" | sha256sum -c - \
&& chmod 0755 /opt/casaderoll/fritz-mcp
COPY --from=github /server/github-mcp-server /usr/local/bin/github-mcp-server
COPY --from=navidrome /app /opt/casaderoll/navidrome
COPY platform/mcp/deemix_mcp.py /opt/casaderoll/mcps/deemix_mcp.py
COPY platform/web-search/web_search_mcp.py /opt/casaderoll/mcps/web_search_mcp.py
COPY platform/mcp/patches/mcp_sonarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_sonarr.py
COPY platform/mcp/patches/mcp_radarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_radarr.py
COPY config/mcp-registry.json /opt/casaderoll/config/mcp-registry.json
COPY platform/mcphub/configure-settings.py /opt/casaderoll/configure-settings.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
@@ -36,7 +46,8 @@ RUN node -e 'const fs=require("node:fs"); const p="/opt/casaderoll/navidrome/dis
ENV MCPHUB_SETTING_PATH=/app/data/ \
REQUEST_TIMEOUT=120000 \
NODE_ENV=production
NODE_ENV=production \
FRITZ_MCP_VERSION=${FRITZ_MCP_VERSION}
ENTRYPOINT ["/usr/local/bin/casaderoll-mcphub-entrypoint"]
CMD ["/usr/local/bin/entrypoint.sh", "pnpm", "start"]
+6 -6
View File
@@ -9,8 +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.
- Der Web-Adapter kann hier laufen; SearXNG/TinySearch dürfen getrennte
Backend-Dienste bleiben.
- Allgemeine Webrecherche bleibt ein eingebautes Hermes-Werkzeug. 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
`http://192.168.1.212:8202/mcp` als `/mcp/athena-operator` weiter. Dadurch
@@ -64,7 +64,7 @@ read-only-Aufruf prüfen und erst danach Clients auf
`http://UNRAID-IP:8787/mcp/{server}` umstellen. Der alte Athena-Container wird
erst gestoppt, wenn Hermes und OpenWebUI nachweislich über MCPHub funktionieren.
Aktueller Stand: Athena Operator, ARR, Deemix, Navidrome, GitHub, Home Assistant
und MUA/Unraid sind auf MCPHub registriert. Alte portable Athena-MCP-Container
bleiben vorläufig als ausgeschaltetes Rückfallnetz bestehen. Der Web-Adapter
ist der letzte noch offene Migrationspunkt.
Aktueller Stand: Athena Operator, ARR, Deemix, Navidrome, GitHub, Home Assistant,
MUA/Unraid und FRITZ!Box sind auf MCPHub registriert. Alte portable
Athena-MCP-Container bleiben ausgeschaltet als kurzfristiges Rückfallnetz
bestehen. Der frühere Webadapter ist nicht mehr Bestandteil des Images.
+9
View File
@@ -18,4 +18,13 @@ fi
JWT_SECRET=$(cat "$jwt_file")
export JWT_SECRET
# Reconcile the persistent MCPHub state with the versioned registry on every
# start. Existing users, bearer tokens and per-server enabled flags survive.
# This makes image upgrades reproducible instead of relying on manual edits in
# MCPHub's database/UI.
settings_file="$state_dir/mcp_settings.json"
python3 /opt/casaderoll/configure-settings.py \
"$settings_file" /run/secrets/mcphub \
--registry /opt/casaderoll/config/mcp-registry.json
exec "$@"
+53 -58
View File
@@ -11,6 +11,7 @@ import argparse
import json
import os
import pathlib
import re
import secrets
import tempfile
import uuid
@@ -34,75 +35,70 @@ def env_file(path: pathlib.Path) -> dict[str, str]:
return values
def required(values: dict[str, str], key: str, source: pathlib.Path) -> str:
value = values.get(key, "").strip()
if not value:
raise SystemExit(f"{key} is missing in {source}")
PLACEHOLDER = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
def expand(value: object, values: dict[str, str], source: pathlib.Path) -> object:
"""Resolve secret placeholders without ever logging their values."""
if isinstance(value, str):
def replace(match: re.Match[str]) -> str:
key = match.group(1)
resolved = values.get(key, "").strip()
if not resolved:
raise SystemExit(f"{key} is missing in {source}")
return resolved
return PLACEHOLDER.sub(replace, value)
if isinstance(value, list):
return [expand(item, values, source) for item in value]
if isinstance(value, dict):
return {key: expand(item, values, source) for key, item in value.items()}
return value
def registry_servers(registry: pathlib.Path, secrets_dir: pathlib.Path,
existing: dict[str, object]) -> dict[str, object]:
document = json.loads(registry.read_text(encoding="utf-8"))
if document.get("version") != 1:
raise SystemExit("Unsupported MCP registry schema")
result: dict[str, object] = {}
for item in document.get("servers", []):
spec = item.get("hub")
if not isinstance(spec, dict):
continue
server_id = str(item.get("hermes_id") or item["id"])
spec = dict(spec)
secret_name = str(spec.pop("secret_file", ""))
secret_path = secrets_dir / secret_name if secret_name else secrets_dir
values = env_file(secret_path) if secret_name else {}
rendered = expand(spec, values, secret_path)
if isinstance(rendered, dict) and isinstance(rendered.get("url"), str):
rendered["url"] = re.sub(r"(?<!:)//+", "/", rendered["url"])
previous = existing.get(server_id)
if isinstance(previous, dict) and "enabled" in previous:
rendered["enabled"] = bool(previous["enabled"])
result[server_id] = rendered
return result
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("settings", type=pathlib.Path)
parser.add_argument("secrets", type=pathlib.Path)
parser.add_argument(
"--registry", type=pathlib.Path,
default=pathlib.Path("/opt/casaderoll/config/mcp-registry.json"),
)
parser.add_argument("--web-backend", default="", help="TinySearch MCP URL; empty keeps web disabled")
parser.add_argument("--searxng", default="", help="SearXNG base URL")
args = parser.parse_args()
args.settings.parent.mkdir(parents=True, exist_ok=True)
settings = json.loads(args.settings.read_text(encoding="utf-8")) if args.settings.exists() else {}
ha_path = args.secrets / "homeassistant.env"
mua_path = args.secrets / "mua.env"
ha = env_file(ha_path)
mua = env_file(mua_path)
servers: dict[str, object] = {
"athena-operator": {
"type": "streamable-http",
"url": "http://192.168.1.212:8202/mcp",
"owner": "admin",
"enabled": True,
},
"arr": {
"type": "stdio",
"command": "/usr/local/bin/run-with-env",
"args": ["/run/secrets/mcphub/arr.env", "--", "arr-mcp", "--transport", "stdio", "--auth-type", "none"],
"enabled": True,
},
"deemix": {
"type": "stdio",
"command": "/usr/local/bin/run-with-env",
"args": ["/run/secrets/mcphub/deemix.env", "--", "python3", "/opt/casaderoll/mcps/deemix_mcp.py"],
"env": {"MCP_TRANSPORT": "stdio"},
"enabled": True,
},
"navidrome": {
"type": "stdio",
"command": "/usr/local/bin/run-with-env",
"args": ["/run/secrets/mcphub/navidrome.env", "--", "node", "/opt/casaderoll/navidrome/dist/index.js"],
"env": {"MCP_TRANSPORT": "stdio", "MCP_HTTP_EXPOSE": "false", "WEBUI_ENABLED": "false"},
"enabled": True,
},
"github": {
"type": "stdio",
"command": "/usr/local/bin/run-with-env",
"args": ["/run/secrets/mcphub/github.env", "--", "/usr/local/bin/github-mcp-server", "stdio", "--read-only", "--tools", "search_repositories,get_file_contents,search_code"],
"enabled": True,
},
"homeassistant": {
"type": "streamable-http",
"url": required(ha, "HASS_URL", ha_path).rstrip("/") + "/api/hass_mcp",
"headers": {"Authorization": "Bearer " + required(ha, "HASS_TOKEN", ha_path)},
"owner": "admin",
"enabled": True,
},
"unraid": {
"type": "streamable-http",
"url": required(mua, "MUA_MCP_URL", mua_path),
"headers": {"Authorization": "Bearer " + required(mua, "MUA_MCP_BEARER_TOKEN", mua_path)},
"owner": "admin",
"enabled": True,
},
}
servers = registry_servers(
args.registry,
args.secrets,
settings.get("mcpServers", {}) if isinstance(settings.get("mcpServers"), dict) else {},
)
if args.web_backend:
servers["web"] = {
"type": "stdio",
@@ -144,7 +140,6 @@ def main() -> None:
system = settings.setdefault("systemConfig", {})
system.setdefault("routing", {})["skipAuth"] = False
args.settings.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".mcp-settings-", dir=args.settings.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Render the human-readable MCP list from the single JSON registry."""
from __future__ import annotations
import argparse
import json
import pathlib
ROOT = pathlib.Path(__file__).resolve().parents[2]
REGISTRY = ROOT / "config" / "mcp-registry.json"
OUTPUT = ROOT / "docs" / "MCP_SERVERS.md"
def render() -> str:
document = json.loads(REGISTRY.read_text(encoding="utf-8"))
rows = []
for item in document["servers"]:
if not item.get("hub"):
continue
clients = ", ".join(item.get("clients", [])) or "–"
selection = str(len(item["tool_include"])) if item.get("tool_include") else "alle"
rows.append(
f"| {item['name']} | `{item['hermes_id']}` | `{item['url']}` | "
f"{clients} | {selection} |"
)
return "\n".join([
"# MCP-Server",
"",
"Diese Datei wird aus `config/mcp-registry.json` erzeugt. Änderungen gehören nur in die JSON-Registry.",
"",
"| Server | Hermes-ID | Endpunkt | Clients | Werkzeuge |",
"|---|---|---|---|---:|",
*rows,
"",
"Allgemeine Webrecherche ist ein eingebautes Hermes-Werkzeug und kein MCPHub-Server.",
"",
])
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
expected = render()
if args.check:
if not OUTPUT.is_file() or OUTPUT.read_text(encoding="utf-8") != expected:
raise SystemExit("MCP registry documentation is out of date")
print("MCP_REGISTRY_OK")
return
OUTPUT.write_text(expected, encoding="utf-8")
print("MCP_REGISTRY_RENDERED")
if __name__ == "__main__":
main()
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Render and verify all client-facing profile data from one matrix."""
from __future__ import annotations
import argparse
import json
import pathlib
import re
def load(path: pathlib.Path) -> dict:
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("version") != 1 or not isinstance(data.get("profiles"), list):
raise SystemExit("Unsupported profile matrix schema")
ids: set[str] = set()
aliases: set[str] = set()
for item in data["profiles"]:
required = {"id", "alias", "context", "model_env", "description"}
missing = required - item.keys()
if missing:
raise SystemExit(f"Profile entry missing: {sorted(missing)}")
if item["id"] in ids or item["alias"] in aliases:
raise SystemExit(f"Duplicate profile or alias: {item['id']}")
if not isinstance(item["context"], int) or item["context"] < 8192:
raise SystemExit(f"Invalid context for {item['id']}")
ids.add(item["id"])
aliases.add(item["alias"])
if data.get("default_profile") not in ids:
raise SystemExit("default_profile is not defined")
return data
def write_if_changed(path: pathlib.Path, content: str) -> None:
if path.exists() and path.read_text(encoding="utf-8") == content:
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def render_router(data: dict) -> str:
payload = {
"profiles": {
item["id"]: {
"context": item["context"],
"model_alias": item["alias"],
}
for item in data["profiles"]
}
}
return json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
def render_docs(data: dict) -> str:
lines = [
"# Profilmatrix",
"",
"Diese Datei wird aus `config/profile-matrix.json` erzeugt. Änderungen gehören nur in die JSON-Matrix.",
"",
f"Standardprofil: **{data['default_profile']}** · globales Ausgabelimit: **{data['max_output_tokens']} Token**",
"",
"| Profil | API-Alias | Kontext | Modell | GPU-Verteilung | Vision | MTP |",
"|---|---|---:|---|---|---|---:|",
]
for item in data["profiles"]:
lines.append(
f"| {item['id']} | `{item['alias']}` | {item['context']:,} | "
f"{item.get('model_family', '')} | {item.get('gpu_split', '')} | "
f"{'ja' if item.get('vision') else 'nein'} | {item.get('mtp', '')} |"
)
lines.extend(["", "## Zweck", ""])
for item in data["profiles"]:
lines.append(f"- **{item['id']}**: {item['description']}")
return "\n".join(lines) + "\n"
def verify_compose(data: dict, compose: pathlib.Path) -> None:
text = compose.read_text(encoding="utf-8")
expected_cap = f'MAX_GENERATION_TOKENS: "{int(data["max_output_tokens"])}"'
if expected_cap not in text:
raise SystemExit("Router output-token cap drift")
for item in data["profiles"]:
block_match = re.search(
rf"(?ms)^ llama-{re.escape(item['id'])}:\n(?P<body>.*?)(?=^ [a-zA-Z0-9_-]+:|\Z)",
text,
)
if not block_match:
raise SystemExit(f"Compose service llama-{item['id']} is missing")
block = block_match.group("body")
if f"- {item['alias']}" not in block:
raise SystemExit(f"Compose alias drift for {item['id']}")
env_prefix = item["id"].upper()
if f"${{{env_prefix}_CONTEXT:-{item['context']}}}" not in block:
raise SystemExit(f"Compose context drift for {item['id']}")
def main() -> None:
root = pathlib.Path(__file__).resolve().parents[2]
parser = argparse.ArgumentParser()
parser.add_argument("--matrix", type=pathlib.Path,
default=root / "config/profile-matrix.json")
parser.add_argument("--router", type=pathlib.Path,
default=root / "router/router_profiles.json")
parser.add_argument("--docs", type=pathlib.Path,
default=root / "docs/STANDARD_PROFILE_MATRIX.md")
parser.add_argument("--compose", type=pathlib.Path,
default=root / "compose.yaml")
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
data = load(args.matrix)
router = render_router(data)
docs = render_docs(data)
verify_compose(data, args.compose)
if args.check:
if args.router.read_text(encoding="utf-8") != router:
raise SystemExit("router_profiles.json is not generated from the matrix")
if args.docs.read_text(encoding="utf-8") != docs:
raise SystemExit("profile documentation is not generated from the matrix")
else:
write_if_changed(args.router, router)
write_if_changed(args.docs, docs)
print("PROFILE_MATRIX_OK")
if __name__ == "__main__":
main()