simplify Athena runtime and centralize MCP management
This commit is contained in:
@@ -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" "$@"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
Executable
+126
@@ -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()
|
||||
Reference in New Issue
Block a user