From da3571ef9f4d41abcaf841ee81d302ba2b14b865 Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Fri, 28 Aug 2026 19:56:04 +0200
Subject: [PATCH 01/13] Move STRATO MCP to standalone repository
---
dev/test_strato_readonly.py | 116 --------
services/strato-dns-mcp/Dockerfile | 22 --
services/strato-dns-mcp/README.md | 45 ---
services/strato-dns-mcp/strato.env.example | 13 -
services/strato-dns-mcp/strato_client.py | 319 ---------------------
services/strato-dns-mcp/strato_mcp.py | 65 -----
6 files changed, 580 deletions(-)
delete mode 100644 dev/test_strato_readonly.py
delete mode 100644 services/strato-dns-mcp/Dockerfile
delete mode 100644 services/strato-dns-mcp/README.md
delete mode 100644 services/strato-dns-mcp/strato.env.example
delete mode 100644 services/strato-dns-mcp/strato_client.py
delete mode 100644 services/strato-dns-mcp/strato_mcp.py
diff --git a/dev/test_strato_readonly.py b/dev/test_strato_readonly.py
deleted file mode 100644
index bc36071..0000000
--- a/dev/test_strato_readonly.py
+++ /dev/null
@@ -1,116 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import importlib.util
-import os
-import sys
-import unittest
-from pathlib import Path
-
-
-SOURCE = Path(__file__).parents[1] / "services/strato-dns-mcp/strato_client.py"
-spec = importlib.util.spec_from_file_location("strato_client", SOURCE)
-module = importlib.util.module_from_spec(spec)
-assert spec.loader
-sys.modules[spec.name] = module
-spec.loader.exec_module(module)
-
-
-class FakeResponse:
- def __init__(self, url: str, body: str) -> None:
- self.url = url
- self.body = body.encode()
-
- def read(self, _limit: int) -> bytes:
- return self.body
-
- def geturl(self) -> str:
- return self.url
-
-
-class FakeOpener:
- def __init__(self, responses: list[FakeResponse]) -> None:
- self.responses = responses
- self.requests: list[tuple[object, float]] = []
-
- def open(self, request: object, timeout: float) -> FakeResponse:
- self.requests.append((request, timeout))
- return self.responses.pop(0)
-
-
-class StratoParserTests(unittest.TestCase):
- def test_dns_form_is_parsed_without_script_or_markup(self) -> None:
- html = """
-
-
-
-
-
-
- """
- records = module.parse_records(html)
- self.assertEqual(records[0].as_dict(), {
- "type": "CNAME", "prefix": "media", "value": "proxy.example.net."
- })
- self.assertEqual(records[1].type, "TXT")
-
- def test_changed_form_fails_closed(self) -> None:
- with self.assertRaisesRegex(module.StratoParseError, "field counts"):
- module.parse_records('')
-
- def test_package_is_selected_by_domain_not_fallback(self) -> None:
- html = """
-
| other.example | open |
- | example.de Hosting | open |
- """
- self.assertEqual(module.parse_package_id(html, "example.de"), "42")
- with self.assertRaises(module.StratoParseError):
- module.parse_package_id(html, "missing.de")
-
- def test_totp_matches_rfc_vector_truncated_to_six_digits(self) -> None:
- # RFC 6238 secret, SHA-1, at t=59 gives 94287082 (therefore 287082 for 6 digits).
- secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
- self.assertEqual(module._totp(secret, at_time=59), "287082")
-
- def test_environment_errors_do_not_echo_values(self) -> None:
- old = dict(os.environ)
- try:
- for key in ("STRATO_USERNAME", "STRATO_PASSWORD", "STRATO_DOMAIN"):
- os.environ.pop(key, None)
- with self.assertRaises(module.StratoError) as caught:
- module.StratoConfig.from_env()
- message = str(caught.exception)
- self.assertIn("STRATO_PASSWORD", message)
- self.assertNotIn("secret-value", message)
- finally:
- os.environ.clear()
- os.environ.update(old)
-
- def test_complete_read_path_has_no_dns_write_request(self) -> None:
- package_html = """
- | example.de Hosting |
- open |
- """
- records_html = """
-
-
-
- """
- opener = FakeOpener([
- FakeResponse(module.STRATO_URL, "login"),
- FakeResponse(module.STRATO_URL + "?sessionID=test-session", "welcome"),
- FakeResponse(module.STRATO_URL, package_html),
- FakeResponse(module.STRATO_URL, records_html),
- ])
- config = module.StratoConfig("customer", "secret-value", "example.de")
- records = module.StratoClient(
- config, opener=opener, sleep=lambda _seconds: None
- ).list_cnames()
- self.assertEqual([record.prefix for record in records], ["media"])
- methods = [request.method for request, _timeout in opener.requests]
- self.assertEqual(methods, ["GET", "POST", "GET", "GET"])
- self.assertNotIn("secret-value", opener.requests[1][0].full_url)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/services/strato-dns-mcp/Dockerfile b/services/strato-dns-mcp/Dockerfile
deleted file mode 100644
index 68dd355..0000000
--- a/services/strato-dns-mcp/Dockerfile
+++ /dev/null
@@ -1,22 +0,0 @@
-FROM python:3.13-slim@sha256:ffb752e139c0a19692a43af8d8523b274222dd68eebad5d583b45c2201c6e30a
-
-ARG MCP_VERSION=1.29.0
-RUN pip install --no-cache-dir "mcp==${MCP_VERSION}" \
- && groupadd --system --gid 10009 stratomcp \
- && useradd --system --uid 10009 --gid 10009 --no-create-home stratomcp
-
-COPY services/strato-dns-mcp/strato_client.py /app/strato_client.py
-COPY services/strato-dns-mcp/strato_mcp.py /app/strato_mcp.py
-
-USER 10009:10009
-WORKDIR /app
-ENV PYTHONDONTWRITEBYTECODE=1 \
- PYTHONUNBUFFERED=1 \
- MCP_TRANSPORT=streamable-http \
- PORT=8000
-EXPOSE 8000
-
-HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
- CMD python -c "import socket; s=socket.create_connection(('127.0.0.1',8000),3); s.close()"
-
-ENTRYPOINT ["python", "/app/strato_mcp.py"]
diff --git a/services/strato-dns-mcp/README.md b/services/strato-dns-mcp/README.md
deleted file mode 100644
index 47ed1f0..0000000
--- a/services/strato-dns-mcp/README.md
+++ /dev/null
@@ -1,45 +0,0 @@
-# STRATO-DNS-MCP – Read-only-Prototyp
-
-Dieser Prototyp prüft den undokumentierten HTTP-Leseweg des STRATO-
-Kundenbereichs. Er kann sich anmelden und die CNAME-Einträge genau einer
-konfigurierten DNS-Zone auflisten.
-
-**Diese Version kann keine DNS-Einträge erstellen, ändern oder löschen.** Im
-Quellcode existiert absichtlich keine Speichermethode.
-
-## Technische Grundlage
-
-STRATO dokumentiert für normale Hosting-Domains nur die Verwaltung im
-Kunden-Login. Der öffentliche Certbot-Plugin
-[`FlixMa/certbot-dns-strato`](https://github.com/FlixMa/certbot-dns-strato)
-zeigt jedoch einen funktionsfähigen HTTP-Ablauf über
-`https://www.strato.de/apps/CustomerService`. Der hier verwendete Leseweg wurde
-ohne Certbot-Abhängigkeit neu und deutlich defensiver implementiert.
-
-Referenzstand der Untersuchung:
-`FlixMa/certbot-dns-strato@67df6dcfc3ef0035ec5aa5daf7c5b0bd8310d7fb`.
-
-## Lokaler Test – noch nicht produktiv installieren
-
-1. `strato.env.example` außerhalb von Git nach `strato.env` kopieren.
-2. Dort Benutzername, Passwort und DNS-Zone eintragen.
-3. Falls STRATO TOTP verlangt, zusätzlich TOTP-Secret und den bei STRATO
- angezeigten Gerätenamen eintragen.
-4. Image bauen und zunächst ausschließlich `strato_connection_status` sowie
- `strato_list_cnames` testen.
-
-Zugangsdaten, TOTP-Werte, Cookies und STRATO-Session-ID werden weder geloggt
-noch als Toolausgabe zurückgegeben. Fehlermeldungen enthalten nur eine kurze
-Fehlerklasse.
-
-## Noch bewusst nicht enthalten
-
-- kein Compose-Deployment
-- keine Unraid-XML
-- keine Hermes-Registrierung
-- keine schreibenden Werkzeuge
-- keine produktive Installation
-
-Diese Teile kommen erst, wenn der Read-only-Test gegen das aktuelle STRATO-
-Konto funktioniert. Falls sich der Login oder das HTML geändert hat, wird nur
-der Parser angepasst; es findet kein Schreibversuch statt.
diff --git a/services/strato-dns-mcp/strato.env.example b/services/strato-dns-mcp/strato.env.example
deleted file mode 100644
index 9e3ef99..0000000
--- a/services/strato-dns-mcp/strato.env.example
+++ /dev/null
@@ -1,13 +0,0 @@
-# Nur als lokale Vorlage. Niemals echte Werte in Git committen.
-STRATO_USERNAME=CHANGE_ME
-STRATO_PASSWORD=CHANGE_ME
-STRATO_DOMAIN=example.de
-
-# Optional: spart die Paket-Erkennung, falls die cID bekannt ist.
-STRATO_PACKAGE_ID=
-
-# Nur bei aktiviertem STRATO-TOTP notwendig. Diese Werte bleiben lokal.
-STRATO_TOTP_SECRET=
-STRATO_TOTP_DEVICE=
-
-STRATO_TIMEOUT_SECONDS=20
diff --git a/services/strato-dns-mcp/strato_client.py b/services/strato-dns-mcp/strato_client.py
deleted file mode 100644
index 87ee00f..0000000
--- a/services/strato-dns-mcp/strato_client.py
+++ /dev/null
@@ -1,319 +0,0 @@
-#!/usr/bin/env python3
-"""Small read-only HTTP client for STRATO's customer portal.
-
-STRATO does not publish a DNS-zone API for ordinary hosted domains. This
-client deliberately implements only the minimum read path proven by the
-public certbot-dns-strato project: authenticate, resolve the package that owns
-one configured DNS zone, and read its combined TXT/CNAME form.
-
-There is intentionally no method that submits DNS changes.
-"""
-
-from __future__ import annotations
-
-import base64
-import hashlib
-import hmac
-import os
-import re
-import struct
-import time
-import urllib.error
-import urllib.parse
-import urllib.request
-from dataclasses import dataclass
-from html.parser import HTMLParser
-from http.cookiejar import CookieJar
-from typing import Callable, Iterable
-
-
-STRATO_URL = "https://www.strato.de/apps/CustomerService"
-MAX_RESPONSE_BYTES = 5 * 1024 * 1024
-USER_AGENT = "Mozilla/5.0 (compatible; mike-ai-strato-dns-readonly/0.1)"
-
-
-class StratoError(RuntimeError):
- """Short credential-free error suitable for an MCP response."""
-
-
-class StratoAuthenticationError(StratoError):
- pass
-
-
-class StratoParseError(StratoError):
- pass
-
-
-@dataclass(frozen=True)
-class StratoConfig:
- username: str
- password: str
- domain: str
- package_id: str | None = None
- totp_secret: str | None = None
- totp_device: str | None = None
- timeout_seconds: float = 20.0
-
- @classmethod
- def from_env(cls) -> "StratoConfig":
- values = {
- "username": os.environ.get("STRATO_USERNAME", "").strip(),
- "password": os.environ.get("STRATO_PASSWORD", ""),
- "domain": os.environ.get("STRATO_DOMAIN", "").strip().rstrip("."),
- }
- missing = [name.upper() for name, value in values.items() if not value]
- if missing:
- raise StratoError(
- "Missing configuration: " + ", ".join(f"STRATO_{name}" for name in missing)
- )
- domain = values["domain"].encode("idna").decode("ascii").lower()
- if not re.fullmatch(r"(?=.{1,253}$)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?", domain):
- raise StratoError("STRATO_DOMAIN is not a valid DNS zone name")
- return cls(
- username=values["username"],
- password=values["password"],
- domain=domain,
- package_id=os.environ.get("STRATO_PACKAGE_ID", "").strip() or None,
- totp_secret=os.environ.get("STRATO_TOTP_SECRET", "").strip() or None,
- totp_device=os.environ.get("STRATO_TOTP_DEVICE", "").strip() or None,
- timeout_seconds=float(os.environ.get("STRATO_TIMEOUT_SECONDS", "20")),
- )
-
-
-@dataclass(frozen=True)
-class DnsRecord:
- type: str
- prefix: str
- value: str
-
- def as_dict(self) -> dict[str, str]:
- return {"type": self.type, "prefix": self.prefix, "value": self.value}
-
-
-class _FormParser(HTMLParser):
- """Extract parallel type/prefix/value fields from STRATO's DNS form."""
-
- def __init__(self) -> None:
- super().__init__(convert_charrefs=True)
- self.prefixes: list[str] = []
- self.types: list[str] = []
- self.values: list[str] = []
- self._in_type_select = False
- self._selected_option = False
- self._option_value = ""
- self._in_value_textarea = False
- self._textarea_parts: list[str] = []
-
- def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
- data = dict(attrs)
- if tag == "input" and data.get("name") == "prefix":
- self.prefixes.append(data.get("value") or "")
- elif tag == "select" and data.get("name") == "type":
- self._in_type_select = True
- elif tag == "option" and self._in_type_select:
- self._selected_option = "selected" in data
- self._option_value = data.get("value") or ""
- if self._selected_option:
- self.types.append(self._option_value)
- elif tag == "textarea" and data.get("name") == "value":
- self._in_value_textarea = True
- self._textarea_parts = []
-
- def handle_endtag(self, tag: str) -> None:
- if tag == "select":
- self._in_type_select = False
- elif tag == "option":
- self._selected_option = False
- elif tag == "textarea" and self._in_value_textarea:
- self.values.append("".join(self._textarea_parts))
- self._in_value_textarea = False
-
- def handle_data(self, data: str) -> None:
- if self._in_value_textarea:
- self._textarea_parts.append(data)
-
-
-class _PackageParser(HTMLParser):
- def __init__(self) -> None:
- super().__init__(convert_charrefs=True)
- self.rows: list[tuple[str, list[str]]] = []
- self._depth = 0
- self._text: list[str] = []
- self._links: list[str] = []
-
- def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
- if tag == "tr":
- if self._depth == 0:
- self._text, self._links = [], []
- self._depth += 1
- if self._depth and tag == "a":
- href = dict(attrs).get("href")
- if href:
- self._links.append(href)
-
- def handle_endtag(self, tag: str) -> None:
- if tag == "tr" and self._depth:
- self._depth -= 1
- if self._depth == 0:
- self.rows.append((" ".join(self._text), list(self._links)))
-
- def handle_data(self, data: str) -> None:
- if self._depth and data.strip():
- self._text.append(data.strip())
-
-
-def _totp(secret: str, at_time: int | None = None) -> str:
- """Generate a standard six-digit SHA-1 TOTP without third-party modules."""
- normalized = re.sub(r"\s+", "", secret).upper()
- try:
- key = base64.b32decode(normalized + "=" * ((8 - len(normalized) % 8) % 8))
- except Exception as exc:
- raise StratoAuthenticationError("STRATO_TOTP_SECRET is not valid base32") from exc
- counter = int((at_time if at_time is not None else time.time()) // 30)
- digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
- offset = digest[-1] & 0x0F
- number = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF
- return f"{number % 1_000_000:06d}"
-
-
-def parse_records(html: str) -> list[DnsRecord]:
- parser = _FormParser()
- parser.feed(html)
- counts = (len(parser.types), len(parser.prefixes), len(parser.values))
- if len(set(counts)) != 1:
- raise StratoParseError(
- "STRATO DNS form changed: type/prefix/value field counts do not match"
- )
- return [DnsRecord(t.upper(), p.strip(), v.strip()) for t, p, v in zip(
- parser.types, parser.prefixes, parser.values, strict=True
- )]
-
-
-def parse_package_id(html: str, domain: str) -> str:
- parser = _PackageParser()
- parser.feed(html)
- for text, links in parser.rows:
- if domain.lower() not in text.lower():
- continue
- for link in links:
- package = urllib.parse.parse_qs(urllib.parse.urlparse(link).query).get("cID")
- if package and package[0].isdigit():
- return package[0]
- raise StratoParseError(f"Configured domain {domain} was not found in STRATO packages")
-
-
-class StratoClient:
- def __init__(
- self,
- config: StratoConfig,
- *,
- opener: object | None = None,
- sleep: Callable[[float], None] = time.sleep,
- ) -> None:
- self.config = config
- self.opener = opener or urllib.request.build_opener(
- urllib.request.HTTPCookieProcessor(CookieJar())
- )
- self.sleep = sleep
- self.session_id: str | None = None
- self.package_id: str | None = config.package_id
-
- def _request(
- self,
- method: str,
- *,
- params: dict[str, object] | None = None,
- form: dict[str, object] | None = None,
- ) -> tuple[str, str]:
- url = STRATO_URL
- if params:
- url += "?" + urllib.parse.urlencode(params, doseq=True)
- body = urllib.parse.urlencode(form, doseq=True).encode() if form is not None else None
- request = urllib.request.Request(
- url,
- data=body,
- method=method,
- headers={"User-Agent": USER_AGENT, "Accept": "text/html,application/xhtml+xml"},
- )
- try:
- response = self.opener.open(request, timeout=self.config.timeout_seconds)
- raw = response.read(MAX_RESPONSE_BYTES + 1)
- except urllib.error.HTTPError as exc:
- raise StratoError(f"STRATO returned HTTP {exc.code}") from None
- except (urllib.error.URLError, TimeoutError, OSError):
- raise StratoError("STRATO could not be reached") from None
- if len(raw) > MAX_RESPONSE_BYTES:
- raise StratoError("STRATO response exceeded the size limit")
- return response.geturl(), raw.decode("utf-8", errors="replace")
-
- def login(self) -> None:
- self._request("GET")
- self.sleep(1.0)
- url, html = self._request(
- "POST",
- form={
- "identifier": self.config.username,
- "passwd": self.config.password,
- "action_customer_login.x": "Login",
- },
- )
- if re.search(r"Zwei.Faktor.Authentifizierung", html, flags=re.IGNORECASE):
- if not self.config.totp_secret or not self.config.totp_device:
- raise StratoAuthenticationError(
- "STRATO requested 2FA; configure STRATO_TOTP_SECRET and STRATO_TOTP_DEVICE"
- )
- token = re.search(r'name=["\']totp_token["\'][^>]*value=["\']([^"\']+)', html)
- device = re.search(
- rf'',
- html,
- flags=re.IGNORECASE,
- )
- if not token or not device:
- raise StratoParseError("STRATO 2FA form could not be understood")
- self.sleep(1.0)
- url, html = self._request(
- "POST",
- form={
- "identifier": self.config.username,
- "totp_token": token.group(1),
- "pw_id": device.group(1),
- "totp": _totp(self.config.totp_secret),
- "action_customer_login.x": 1,
- },
- )
- session = urllib.parse.parse_qs(urllib.parse.urlparse(url).query).get("sessionID")
- if not session:
- raise StratoAuthenticationError("STRATO login was not accepted")
- self.session_id = session[0]
-
- def resolve_package(self) -> str:
- if self.package_id:
- return self.package_id
- if not self.session_id:
- raise StratoAuthenticationError("STRATO session is not initialized")
- _, html = self._request(
- "GET",
- params={"sessionID": self.session_id, "cID": 0, "node": "kds_CustomerEntryPage"},
- )
- self.package_id = parse_package_id(html, self.config.domain)
- return self.package_id
-
- def list_txt_and_cname_records(self) -> list[DnsRecord]:
- if not self.session_id:
- self.login()
- package_id = self.resolve_package()
- _, html = self._request(
- "GET",
- params={
- "sessionID": self.session_id or "",
- "cID": package_id,
- "node": "ManageDomains",
- "action_show_txt_records": "",
- "vhost": self.config.domain,
- },
- )
- return parse_records(html)
-
- def list_cnames(self) -> list[DnsRecord]:
- return [record for record in self.list_txt_and_cname_records() if record.type == "CNAME"]
diff --git a/services/strato-dns-mcp/strato_mcp.py b/services/strato-dns-mcp/strato_mcp.py
deleted file mode 100644
index 23bd6ba..0000000
--- a/services/strato-dns-mcp/strato_mcp.py
+++ /dev/null
@@ -1,65 +0,0 @@
-#!/usr/bin/env python3
-"""Read-only STRATO DNS MCP proof of concept."""
-
-from __future__ import annotations
-
-import json
-import os
-
-from mcp.server.fastmcp import FastMCP
-
-from strato_client import StratoClient, StratoConfig, StratoError
-
-
-mcp = FastMCP(
- "strato-dns-readonly",
- instructions=(
- "Read the configured STRATO DNS zone. This experimental server is "
- "strictly read-only and cannot create, change, or delete DNS records."
- ),
- host="0.0.0.0",
- port=int(os.environ.get("PORT", "8000")),
- stateless_http=True,
-)
-
-
-def _json(value: object) -> str:
- return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
-
-
-@mcp.tool()
-def strato_connection_status() -> str:
- """Log in and verify that the configured DNS zone can be read. Makes no change."""
- try:
- config = StratoConfig.from_env()
- records = StratoClient(config).list_txt_and_cname_records()
- return _json({
- "connected": True,
- "domain": config.domain,
- "record_count": len(records),
- "cname_count": sum(record.type == "CNAME" for record in records),
- "read_only": True,
- })
- except StratoError as exc:
- return _json({"connected": False, "error": str(exc), "read_only": True})
-
-
-@mcp.tool()
-def strato_list_cnames() -> str:
- """List CNAME records for the one configured STRATO zone. Makes no change."""
- try:
- config = StratoConfig.from_env()
- records = StratoClient(config).list_cnames()
- return _json({
- "domain": config.domain,
- "count": len(records),
- "records": [record.as_dict() for record in records[:200]],
- "truncated": len(records) > 200,
- "read_only": True,
- })
- except StratoError as exc:
- return _json({"error": str(exc), "read_only": True})
-
-
-if __name__ == "__main__":
- mcp.run(transport=os.environ.get("MCP_TRANSPORT", "streamable-http"))
From aab9579b0633c706ba962816b95f68955ebbbb15 Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Fri, 28 Aug 2026 22:42:55 +0200
Subject: [PATCH 02/13] Move Deemix MCP to standalone repository
---
README.md | 7 +-
config/deemix-mcp.env.example | 13 -
config/mcp-registry.json | 19 -
config/unraid-templates/my-MCPHub.xml | 2 +-
dev/test_deemix_mcp.py | 469 --------------------
docs/MCP_SERVERS.md | 1 -
platform/mcp/Dockerfile.deemix | 13 -
platform/mcp/compose.yaml | 20 -
platform/mcp/deemix_mcp.py | 511 ----------------------
platform/mcp/install-tools.sh | 2 +-
platform/mcp/sync-clients.py | 2 +-
platform/mcphub/Dockerfile | 1 -
platform/mcphub/README.md | 4 +-
platform/mcphub/configure-settings.py | 2 +-
platform/mcphub/mcp-settings.example.json | 12 -
platform/operator/athena_operatord.py | 2 +-
16 files changed, 13 insertions(+), 1067 deletions(-)
delete mode 100644 config/deemix-mcp.env.example
delete mode 100644 dev/test_deemix_mcp.py
delete mode 100644 platform/mcp/Dockerfile.deemix
delete mode 100644 platform/mcp/deemix_mcp.py
diff --git a/README.md b/README.md
index eee33ed..5ec9454 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ Hermes Agent und MCPHub laufen auf Unraid und werden dort mit Appdata gesichert.
MCP direkt auf Athena. MCPHub veröffentlicht seinen vorhandenen
WireGuard-HTTP-Endpunkt zentral unter `/mcp/athena-operator`; es gibt keinen
zweiten Operator und keine administrative SSH-Implementierung im Hub.
-- Home Assistant, ARR, Unraid, Navidrome, Deemix und GitHub laufen gemeinsam im
+- Home Assistant, ARR, Unraid, Navidrome 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 den eingebauten schlüssellosen
@@ -72,6 +72,11 @@ Hermes-Dashboard: `http://:9119`
Hermes-API: `http://:8642`
+## Ausgegliederte Fach-MCPs
+
+- [Deemix-MCP](https://git.casaderoll.de/michael/Deemix-MCP)
+- [Strato-MCP](https://git.casaderoll.de/michael/Strato-MCP)
+
## Wiederherstellung – ein Befehl
Nach einer frischen Installation und eingehängtem `/data`:
diff --git a/config/deemix-mcp.env.example b/config/deemix-mcp.env.example
deleted file mode 100644
index f800a3b..0000000
--- a/config/deemix-mcp.env.example
+++ /dev/null
@@ -1,13 +0,0 @@
-# Root-only auf Athena unter /etc/mike-ai/deemix-mcp.env ablegen (Modus 0600).
-# Der Wert gehoert niemals ins Repository.
-
-# Deemix-Backend auf dem Unraid-HomeServer (ueber WireGuard/Heimnetz).
-DEEMIX_URL=http://192.168.1.2:6595
-
-# Optionaler Fallback fuer Multi-User-Setups: Deezer-ARL (192-hex-Zeichen).
-# Im Single-User-Modus liefert Deemix den gespeicherten ARL selbst ueber
-# GET /connect (singleUser.arl) und dieser Wert bleibt leer.
-DEEMIX_ARL=
-
-# Optional: HTTP-Timeout in Sekunden (Standard 30).
-#DEEMIX_TIMEOUT=30
diff --git a/config/mcp-registry.json b/config/mcp-registry.json
index 2075538..4fb7b0f 100644
--- a/config/mcp-registry.json
+++ b/config/mcp-registry.json
@@ -126,25 +126,6 @@
"enabled": true
}
},
- {
- "id": "deemix-local",
- "hermes_id": "deemix",
- "name": "Deemix",
- "description": "Nutzt ausschließlich die bestehende Deemix-Instanz auf Unraid. Status und Suche sind read-only; Queue-Aktionen nur auf ausdrücklichen Auftrag.",
- "url": "http://192.168.1.2:8787/mcp/deemix",
- "clients": ["openwebui"],
- "env_file": "/etc/mike-ai/mcphub-client.env",
- "key_env": "MCPHUB_BEARER_TOKEN",
- "auth_type": "bearer",
- "timeout": 300,
- "hub": {
- "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
- }
- },
{
"id": "mua",
"hermes_id": "unraid",
diff --git a/config/unraid-templates/my-MCPHub.xml b/config/unraid-templates/my-MCPHub.xml
index 44ed729..58336e9 100644
--- a/config/unraid-templates/my-MCPHub.xml
+++ b/config/unraid-templates/my-MCPHub.xml
@@ -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. 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.
+ Zentrale MCP-Verwaltung mit Weboberfläche. Das lokale CasaDeRoll-Image basiert reproduzierbar auf MCPHub 1.0.32 und enthält die versionierten ARR-, 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_deemix_mcp.py b/dev/test_deemix_mcp.py
deleted file mode 100644
index 1b163b5..0000000
--- a/dev/test_deemix_mcp.py
+++ /dev/null
@@ -1,469 +0,0 @@
-#!/usr/bin/env python3
-"""Reproducible test suite for the Deemix MCP server.
-
-Run: python test_deemix_mcp.py (from the package directory)
-
-The suite spins up a local HTTP server that emulates the Deemix webui API
-(connect / search / getQueue / addToQueue / loginArl / removeFromQueue /
-retryDownload / removeFinishedDownloads / cancelAllDownloads) and points the
-MCP at it. No real Deezer access, no real downloads, no queue residue:
-
- * read + auth flow (fresh first call, one-shot re-login on NotLoggedIn,
- 401, 403, re-login failure abort)
- * bitrate validation (valid TrackFormats ids, invalid rejected, not kbit/s)
- * url/term/result limits
- * invalid url/id handling
- * compact queue output
- * secret redaction (ARL, cookie headers, long hex)
-
-Write-path tools that would enqueue real downloads are verified here ONLY
-against the emulated backend; against production Deemix they are considered
-verified against the source code, not practically executed.
-"""
-
-from __future__ import annotations
-
-import json
-import os
-import sys
-import threading
-import urllib.error
-import urllib.request
-from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
-from typing import Optional
-
-HERE = os.path.dirname(os.path.abspath(__file__))
-sys.path.insert(0, HERE)
-
-# --------------------------------------------------------------------------
-# Emulated Deemix backend
-# --------------------------------------------------------------------------
-FAKE_ARL = "a1" * 96 # 192 hex chars, like a real Deezer ARL
-
-
-class DeemixState:
- def __init__(self) -> None:
- self.logged_in = False
- self.connect_calls = 0
- self.login_calls = 0
- self.requests: list[str] = []
- self.fail_next_relogin = False
- self.force_401_once = False
- self.force_403_once = False
- self.queue = {
- "album_1_1": {
- "uuid": "album_1_1", "type": "album", "id": "1",
- "title": "Folge 240", "artist": "Die drei ???",
- "progress": 37, "downloaded": 52428800, "size": 80530636,
- "failed": False, "errors": [],
- },
- "album_2_3": {
- "uuid": "album_2_3", "type": "album", "id": "2",
- "title": "Folge 239", "artist": "Die drei ???",
- "progress": 100, "downloaded": 76000000, "size": 76000000,
- "failed": True,
- "errors": ["Tagging failed: unknown picture MIME",
- "second error", "third error", "fourth error"],
- },
- }
- self.added: list[dict] = []
- self.cancelled_all = False
-
-
-STATE = DeemixState()
-
-
-class EmulatedDeemix(BaseHTTPRequestHandler):
- def log_message(self, *a): # quiet
- pass
-
- def _send(self, obj: dict, status: int = 200) -> None:
- body = json.dumps(obj).encode()
- self.send_response(status)
- self.send_header("Content-Type", "application/json")
- self.send_header("Content-Length", str(len(body)))
- self.end_headers()
- self.wfile.write(body)
-
- def _auth_required(self) -> bool:
- """True when the session cookie is missing (not logged in)."""
- cookie = self.headers.get("Cookie", "")
- return "deemix_session" not in cookie
-
- def do_GET(self):
- STATE.requests.append("GET " + self.path)
- if self.path.startswith("/deemix/api/connect"):
- STATE.connect_calls += 1
- # In real Deemix the session IS the cookie: without it the
- # server reports singleUser + the ARL again, so a client can
- # re-login after any session loss.
- authed = not self._auth_required()
- obj: dict = {
- "update": {"deemixVersion": "3.13.7", "webuiVersion": "4.6.0"},
- "autologin": not authed,
- "currentUser": None,
- "deezerAvailable": "yes",
- "spotifyEnabled": True,
- "settingsData": {"settings": {
- "downloadLocation": "/downloads", "maxBitrate": 9,
- }},
- }
- if not authed:
- obj["singleUser"] = {"arl": FAKE_ARL}
- else:
- obj["currentUser"] = {"id": 12345, "name": "Test",
- "lastName": "User", "country": "DE",
- "subscription": "premium"}
- self._send(obj)
- return
- if self._force_auth_error():
- return
- if self._auth_required():
- self._send({"result": False, "errid": "NotLoggedIn"})
- return
- if self.path.startswith("/deemix/api/search"):
- q = urllib.parse.parse_qs(self.path.split("?", 1)[1]) if "?" in self.path else {}
- nb = int(q.get("nb", ["10"])[0])
- total = 31
- data = [{"id": 985501051, "title": "Die drei ??? Folge 240",
- "release_date": "2026-08-18", "nb_tracks": 48,
- "related": {"artists": [{"name": "Die drei ???"},
- {"name": "Mystery-Club"}]}}]
- data += [{"id": i, "title": f"Treffer {i}",
- "release_date": "2020-01-01", "nb_tracks": 10,
- "related": {"artists": [{"name": "K"}]}} for i in range(1, total)]
- self._send({"data": data[:nb], "total": total, "type": "album",
- "error": ""})
- return
- if self.path.startswith("/deemix/api/getQueue"):
- self._send({"queue": dict(STATE.queue),
- "queueOrder": list(STATE.queue.keys())})
- return
- self._send({"result": False, "errid": "NotFound"}, 404)
-
- def _force_auth_error(self) -> bool:
- """Emulate a session that the server rejects with HTTP 401/403."""
- if STATE.force_401_once:
- STATE.force_401_once = False
- self._send({"result": False, "errid": "http401"}, 401)
- return True
- if STATE.force_403_once:
- STATE.force_403_once = False
- self._send({"result": False, "errid": "http403"}, 403)
- return True
- return False
-
- def do_POST(self):
- length = int(self.headers.get("Content-Length", 0))
- raw = self.rfile.read(length).decode() if length else ""
- try:
- body = json.loads(raw) if raw else {}
- except json.JSONDecodeError:
- body = {}
- STATE.requests.append("POST " + self.path)
- if self.path.startswith("/deemix/api/loginArl"):
- STATE.login_calls += 1
- if STATE.fail_next_relogin:
- STATE.fail_next_relogin = False
- self._send({"status": 0, "error": "invalidArl"})
- return
- if body.get("arl") != FAKE_ARL:
- self._send({"status": 0, "error": "invalidArl"})
- return
- STATE.logged_in = True
- # Emulate setting the session cookie (real Deemix sets a cookie).
- self.send_response(200)
- self.send_header("Set-Cookie", "deemix_session=abc123; Path=/")
- self.send_header("Content-Type", "application/json")
- payload = json.dumps({"status": 1, "user": {"id": 12345,
- "name": "Test",
- "lastName": "User",
- "country": "DE"}})
- self.send_header("Content-Length", str(len(payload)))
- self.end_headers()
- self.wfile.write(payload.encode())
- return
- if self._auth_required():
- self._send({"result": False, "errid": "NotLoggedIn"})
- return
- if self._force_auth_error():
- return
- if self.path.startswith("/deemix/api/addToQueue"):
- url = body.get("url", "")
- bitrate = body.get("bitrate")
- # Mirror the real route: empty/"null" falls back to settings.
- if bitrate == "null" or not bitrate:
- bitrate = 9
- bitrate = int(bitrate)
- if "badid" in url or "9999999999" in url:
- # Real Deemix behaviour: an unknown ID is accepted (result
- # true) but resolves to zero items, so nothing is queued.
- self._send({"result": True, "data": {
- "url": [u for u in url.split()], "bitrate": bitrate,
- "obj": []}})
- return
- STATE.added.append({"url": url, "bitrate": bitrate})
- ids = [u.rsplit("/", 1)[-1] for u in url.split()]
- self._send({"result": True, "data": {
- "url": ids, "bitrate": bitrate,
- "obj": [{"id": i, "type": "album"} for i in ids]}})
- return
- if self.path.startswith("/deemix/api/removeFromQueue"):
- uuid = urllib.parse.parse_qs(self.path.split("?", 1)[1]).get("uuid", [""])[0]
- if uuid not in STATE.queue:
- self._send({"result": False, "errid": "UnknownUuid"})
- return
- del STATE.queue[uuid]
- self._send({"result": True})
- return
- if self.path.startswith("/deemix/api/retryDownload"):
- uuid = body.get("uuid", "")
- if uuid not in STATE.queue:
- self._send({"result": False, "errid": "UnknownUuid"})
- return
- STATE.queue[uuid]["failed"] = False
- self._send({"result": True})
- return
- if self.path.startswith("/deemix/api/removeFinishedDownloads"):
- for k in list(STATE.queue):
- if not STATE.queue[k]["failed"]:
- del STATE.queue[k]
- self._send({"result": True})
- return
- if self.path.startswith("/deemix/api/cancelAllDownloads"):
- STATE.cancelled_all = True
- self._send({"result": True})
- return
- self._send({"result": False, "errid": "NotFound"}, 404)
-
-
-# --------------------------------------------------------------------------
-# Tests
-# --------------------------------------------------------------------------
-import urllib.parse # noqa: E402 (used by the emulated handler above)
-
-PASS: list[str] = []
-FAIL: list[str] = []
-
-
-def check(name: str, cond: bool, detail: str = "") -> None:
- (PASS if cond else FAIL).append(name)
- print(("PASS " if cond else "FAIL ") + name + (f" [{detail}]" if detail and not cond else ""))
-
-
-def main() -> int:
- server = ThreadingHTTPServer(("127.0.0.1", 0), EmulatedDeemix)
- port = server.server_address[1]
- threading.Thread(target=server.serve_forever, daemon=True).start()
-
- os.environ["DEEMIX_URL"] = f"http://127.0.0.1:{port}"
- os.environ["DEEMIX_ARL"] = "" # force single-user path
- # fresh import so module-level env reads pick up the emulated URL
- if "deemix_mcp" in sys.modules:
- del sys.modules["deemix_mcp"]
- import importlib
- import deemix_mcp
- importlib.reload(deemix_mcp)
- from deemix_mcp import (Session, DeemixError, redact, _clean_error,
- deemix_status, deemix_search, deemix_add_to_queue,
- deemix_queue, deemix_retry_download,
- deemix_remove_from_queue, TRACKFORMATS,
- MAX_URLS_PER_CALL, MAX_SEARCH_RESULTS,
- MAX_SEARCH_TERM)
-
- S = deemix_mcp.SESSION
- S._logged_in = False
- S.jar.clear()
-
- # 1) fresh first call right after "container start" --------------------
- out = json.loads(deemix_status())
- check("erstaufruf: status nach frischem Start",
- out["logged_in_user"].get("name") == "Test", str(out.get("logged_in_user")))
- check("erstaufruf: ARL nicht in Status-Output",
- FAKE_ARL not in json.dumps(out))
- check("erstaufruf: genau ein Login-Versuch",
- STATE.login_calls == 1, f"login_calls={STATE.login_calls}")
-
- # 2) direct first call of search (session already warm) -----------------
- out = json.loads(deemix_search("Die drei ??? Folge 240"))
- check("erstaufruf: search funktioniert",
- out["total"] == 31 and len(out["results"]) == 10)
- check("search: Deezer-Link praeSENT",
- out["results"][0]["link"] == "https://www.deezer.com/album/985501051")
-
- out = json.loads(deemix_queue())
- check("erstaufruf: queue funktioniert", out["total"] == 2)
- check("queue: kompakt (keine groben Rohfelder)",
- all("downloaded_mb" in r for r in out["rows"]) and
- all("size" not in r for r in out["rows"]))
- check("queue: errors auf 3 begrenzt",
- [r for r in out["rows"] if r["uuid"] == "album_2_3"][0]["errors"] and
- len([r for r in out["rows"] if r["uuid"] == "album_2_3"][0]["errors"]) == 3)
-
- # 3) automatic ONE re-login on NotExpired session ------------------------
- S._logged_in = True
- S.jar.clear() # server now sees no session cookie -> NotLoggedIn
- out = json.loads(deemix_queue())
- check("re-login: NotLoggedIn wird automatisch einxmal behoben",
- out["total"] == 2)
- check("re-login: genau EIN zusaeztlicher Login",
- STATE.login_calls == 2, f"login_calls={STATE.login_calls}")
-
- # 4) HTTP 401 triggers the same bounded re-login -------------------------
- STATE.force_401_once = True
- S._logged_in = True
- out = json.loads(deemix_queue())
- check("re-login: HTTP 401 wird automatisch einxmal behoben",
- out["total"] == 2 and STATE.login_calls == 3)
-
- # 5) HTTP 403 likewise ----------------------------------------------------
- STATE.force_403_once = True
- out = json.loads(deemix_queue())
- check("re-login: HTTP 403 wird automatisch einxmal behoben",
- out["total"] == 2 and STATE.login_calls == 4)
-
- # 6) failed re-login aborts (no loop) ------------------------------------
- S._logged_in = False
- S.jar.clear()
- STATE.fail_next_relogin = True
- try:
- deemix_queue()
- check("re-login: fehlgeschlagener Re-Login bricht ab", False, "kein Fehler geworfen")
- except DeemixError as e:
- ok = "re-login" in str(e).lower() or "session" in str(e).lower()
- check("re-login: fehlgeschlagener Re-Login bricht ab", ok, str(e))
- check("re-login: kein Endlosschleifen-Verhalten (max. 1 Versuch)",
- STATE.login_calls == 5, f"login_calls={STATE.login_calls}")
-
- # restore a working session for the remaining tests
- S._logged_in = False
- S.jar.clear()
- deemix_status()
-
- # 7) bitrate validation ----------------------------------------------------
- out = json.loads(deemix_add_to_queue(
- "https://www.deezer.com/album/985501051", bitrate=3))
- check("bitrate: 3 (MP3 320) akzeptiert und uebergeben",
- out["queued"] and out["bitrate_used"] == 3)
- out = json.loads(deemix_add_to_queue(
- "https://www.deezer.com/album/985501051", bitrate=9))
- check("bitrate: 9 (FLAC) akzeptiert", out["queued"] and out["bitrate_used"] == 9)
- out = json.loads(deemix_add_to_queue(
- "https://www.deezer.com/album/985501051", bitrate=1))
- check("bitrate: 1 (MP3 128) akzeptiert", out["queued"] and out["bitrate_used"] == 1)
- out = json.loads(deemix_add_to_queue("https://www.deezer.com/album/985501051"))
- check("bitrate: weggelassen -> Deemix-Setting (9) greift",
- out["bitrate_requested"] is None and out["bitrate_used"] == 9)
- try:
- deemix_add_to_queue("https://www.deezer.com/album/985501051", bitrate=320)
- check("bitrate: 320 (kbit/s-Irrtum) wird abgelehnt", False, "kein Fehler")
- except DeemixError as e:
- check("bitrate: 320 (kbit/s-Irrtum) wird abgelehnt",
- "320" in str(e) and "not a valid" in str(e), str(e))
- try:
- deemix_add_to_queue("https://www.deezer.com/album/985501051", bitrate=0)
- check("bitrate: 0 (LOCAL) wird abgelehnt", False, "kein Fehler")
- except DeemixError:
- check("bitrate: 0 (LOCAL) wird abgelehnt", True)
- check("bitrate: nur die WebUI-Werte 1/3/9 sind gueltig",
- set(TRACKFORMATS) == {1, 3, 9}, str(TRACKFORMATS))
-
- # 8) url limits -----------------------------------------------------------
- many = " ".join(f"https://www.deezer.com/album/{i}" for i in range(MAX_URLS_PER_CALL + 1))
- try:
- deemix_add_to_queue(many)
- check("limit: >10 URLs abgelehnt", False, "kein Fehler")
- except DeemixError as e:
- check("limit: >10 URLs abgelehnt", "11" in str(e), str(e))
- ok10 = " ".join(f"https://www.deezer.com/album/{i}" for i in range(10))
- out = json.loads(deemix_add_to_queue(ok10))
- check("limit: genau 10 URLs akzeptiert", out["queued"])
-
- # 9) search limits ----------------------------------------------------------
- out = json.loads(deemix_search("x" * (MAX_SEARCH_TERM + 50), nb=999))
- check("limit: Suchtext wird auf 100 Zeichen gekuerzt",
- out["total"] == 31) # emulated server answers regardless; no crash
- out = json.loads(deemix_search("test", nb=999))
- check("limit: nb wird auf 20 begrenzt",
- len(out["results"]) <= MAX_SEARCH_RESULTS)
-
- # 10) invalid url / id --------------------------------------------------------
- # Real Deemix behaviour: unknown IDs are accepted but resolve to zero
- # items -> the tool reports queued=false with a warning and nothing is
- # queued (verified against production Deemix 3.13.7 / webui 4.6.0).
- out = json.loads(deemix_add_to_queue("https://www.deeeeezer.com/badid/9999999999"))
- check("ungueltige URL/ID: wird ehrlich gemeldet (queued=false + Warnung)",
- out["queued"] is False and "NO items" in (out.get("warning") or ""),
- str(out))
- check("ungueltige URL/ID: nichts in der Queue (kein Download)",
- STATE.added == [] or all("9999999999" not in a["url"] for a in STATE.added),
- str(STATE.added))
- try:
- deemix_add_to_queue("")
- check("leere urls abgelehnt", False, "kein Fehler")
- except DeemixError:
- check("leere urls abgelehnt", True)
- try:
- deemix_retry_download("album_1_1")
- check("retry: vorhandene UUID ok", True)
- except DeemixError as e:
- check("retry: vorhandene UUID ok", False, str(e))
- try:
- deemix_retry_download("album_kein_kein")
- check("retry: unbekannte UUID sauberer Fehler", False, "kein Fehler")
- except DeemixError as e:
- check("retry: unbekannte UUID sauberer Fehler", "UnknownUuid" in str(e), str(e))
-
- # 11) remove / finished (against emulated backend only) ----------------------
- out = json.loads(deemix_remove_from_queue("album_2_3"))
- check("remove_from_queue: UUID entfernt", out["result"] and
- "album_2_3" not in STATE.queue)
- out = json.loads(deemix_queue())
- check("queue: nach Entfernen nur 1 Eintrag", out["total"] == 1)
- try:
- deemix_remove_from_queue("album_unbekannt")
- check("remove_from_queue: unbekannte UUID sauberer Fehler", False, "kein Fehler")
- except DeemixError:
- check("remove_from_queue: unbekannte UUID sauberer Fehler", True)
-
- # 12) secret redaction ---------------------------------------------------------
- blob = {
- "arl": FAKE_ARL,
- "cookie": "deemix_session=abc123",
- "note": f"token {FAKE_ARL[:100]} embedded",
- "nested": {"set-cookie": "a=1", "ok": "bleibt"},
- "list": [FAKE_ARL],
- }
- red = redact(blob)
- s = json.dumps(red)
- check("redaction: ARL verschwaengt", FAKE_ARL not in s)
- check("redaction: Cookie-Feld verschwaengt",
- red["cookie"] == "[REDACTED]" and red["nested"]["set-cookie"] == "[REDACTED]")
- check("redaction: unfaellige Felder bleiben",
- red["nested"]["ok"] == "bleibt")
- check("redaction: lange Hexfolgen werden maskiert",
- "[REDACTED]" in s)
- msg = _clean_error(f"boom arl={FAKE_ARL}")
- check("redaction: Fehlermeldungen werden bereinigt",
- FAKE_ARL not in msg and "[REDACTED]" in msg)
-
- # 13) compact queue output ------------------------------------------------------
- big = [{"uuid": f"u{i}", "type": "album", "id": i, "title": "t", "artist": "a",
- "progress": i, "downloaded": 1048576 * i, "size": 2097152 * i,
- "failed": i % 7 == 0} for i in range(60)]
- q = json.loads(deemix_queue())
- check("queue: Ausgabe kompakt und begrenzt",
- q["total"] <= 50 or q.get("truncated"))
-
- server.shutdown()
-
- print(f"\n{len(PASS)} bestanden, {len(FAIL)} fehlgeschlagen")
- if FAIL:
- print("Fehlschlaege:", *FAIL, sep="\n - ")
- return 1
- print("ALLE TESTS BESTANDEN (emuliertes Deemix-Backend, keine echten Downloads)")
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/docs/MCP_SERVERS.md b/docs/MCP_SERVERS.md
index 07fb947..09b7da1 100644
--- a/docs/MCP_SERVERS.md
+++ b/docs/MCP_SERVERS.md
@@ -9,7 +9,6 @@ Diese Datei wird aus `config/mcp-registry.json` erzeugt. Änderungen gehören nu
| Home Assistant | `homeassistant-admin` | `http://192.168.1.2:8787/mcp/homeassistant` | hermes, openwebui | alle |
| Sonarr und Radarr | `arr` | `http://192.168.1.2:8787/mcp/arr` | hermes, openwebui | alle |
| Navidrome | `navidrome` | `http://192.168.1.2:8787/mcp/navidrome` | hermes, openwebui | alle |
-| Deemix | `deemix` | `http://192.168.1.2:8787/mcp/deemix` | hermes, openwebui | alle |
| MUA (Unraid-Verwaltung) | `unraid` | `http://192.168.1.2:8787/mcp/unraid` | hermes, openwebui | alle |
| FRITZ!Box | `fritzbox` | `http://192.168.1.2:8787/mcp/fritzbox` | hermes, openwebui | 4 |
diff --git a/platform/mcp/Dockerfile.deemix b/platform/mcp/Dockerfile.deemix
deleted file mode 100644
index a8145fb..0000000
--- a/platform/mcp/Dockerfile.deemix
+++ /dev/null
@@ -1,13 +0,0 @@
-FROM python:3.13-slim@sha256:ffb752e139c0a19692a43af8d8523b274222dd68eebad5d583b45c2201c6e30a
-
-ARG MCP_VERSION=1.29.0
-RUN pip install --no-cache-dir "mcp==${MCP_VERSION}" \
- && groupadd --system --gid 10005 deemixmcp \
- && useradd --system --uid 10005 --gid 10005 --no-create-home deemixmcp
-COPY deemix_mcp.py /app/deemix_mcp.py
-RUN chown -R 10005:10005 /app
-USER 10005:10005
-WORKDIR /app
-ENV PYTHONUNBUFFERED=1
-EXPOSE 8000
-ENTRYPOINT ["python", "/app/deemix_mcp.py"]
diff --git a/platform/mcp/compose.yaml b/platform/mcp/compose.yaml
index 81082ca..caf158a 100644
--- a/platform/mcp/compose.yaml
+++ b/platform/mcp/compose.yaml
@@ -153,26 +153,6 @@ services:
- /config:rw,noexec,nosuid,nodev,size=4m,mode=0700
networks: [tools, tools-egress]
- mcp-deemix:
- <<: *tool-common
- build:
- context: .
- dockerfile: Dockerfile.deemix
- image: mike-ai/mcp-deemix:1.1.0
- container_name: mike-ai-mcp-deemix
- profiles: [deemix]
- env_file:
- - ${DEEMIX_MCP_ENV_FILE:-/etc/mike-ai/deemix-mcp.env}
- environment:
- PORT: "8000"
- networks: [tools, tools-egress]
- healthcheck:
- test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
- interval: 30s
- timeout: 5s
- retries: 5
- start_period: 10s
-
mcp-athena-operator:
<<: *tool-common
build:
diff --git a/platform/mcp/deemix_mcp.py b/platform/mcp/deemix_mcp.py
deleted file mode 100644
index 040f856..0000000
--- a/platform/mcp/deemix_mcp.py
+++ /dev/null
@@ -1,511 +0,0 @@
-#!/usr/bin/env python3
-"""mike-ai MCP: Deemix.
-
-Drives the existing Deemix instance on the Unraid home server through its
-Web-UI HTTP API (http://:6595/deemix/api/*). Deemix ships no CLI and
-no documented public REST API; the endpoints used here were verified against
-the route files of webui 4.6.0 / deemix 3.13.7 (dist/routes/api/{get,post}).
-
-Credential handling (honest documentation):
- * No credential is stored on Athena or in this container on disk.
- * In single-user mode the Deemix app itself serves the saved Deezer ARL from
- GET /connect (singleUser.arl). The MCP reads it into process memory ONLY
- for the duration of a (re-)login and never writes it to disk, logs, tool
- output or error messages. DEEMIX_ARL is only a fallback for multi-user
- setups and is read from the environment.
- * The ARL is therefore transiently present in the MCP's working memory
- between a /connect read and the corresponding /loginArl call, and in the
- cookie jar afterwards. It is redacted from anything that leaves the
- process.
-
-Session handling:
- * Every tool funnels through Session.call(), which guarantees a login before
- the first request (so a direct first call right after a fresh container
- start works) and, if Deemix reports an expired/absent session
- (errid NotLoggedIn) or answers HTTP 401/403, performs AT MOST ONE
- automatic re-login and retries the request once. There is no retry loop.
-"""
-
-from __future__ import annotations
-
-import http.cookiejar
-import json
-import os
-import re
-import urllib.error
-import urllib.parse
-import urllib.request
-from typing import Any, Optional
-
-from mcp.server.fastmcp import FastMCP
-
-DEEMIX_URL = os.environ.get("DEEMIX_URL", "http://192.168.1.2:6595").rstrip("/")
-API = DEEMIX_URL + "/deemix/api"
-FALLBACK_ARL = os.environ.get("DEEMIX_ARL", "").strip()
-TIMEOUT = float(os.environ.get("DEEMIX_TIMEOUT", "30"))
-
-# --- Limits (kept small and bounded so tool output stays compact) ---------
-MAX_QUEUE_ROWS = 50 # max queue entries returned per call
-MAX_SEARCH_RESULTS = 20 # hard cap on search result rows
-MAX_SEARCH_TERM = 100 # max characters in a search term
-MAX_URLS_PER_CALL = 10 # max Deezer URLs accepted by deemix_add_to_queue
-MAX_ERRORS_PER_ENTRY = 3
-
-# --- Bitrate: verified against the deployed webui/deezer-sdk code ---------
-# `addToQueue.js` does `bitrate = Number(bitrate)` and falls back to the
-# Deemix `maxBitrate` setting; it is NOT a kbit/s figure. The values are
-# deezer-sdk `TrackFormats` enum ids. The Deemix settings UI offers exactly:
-TRACKFORMATS: dict[int, str] = {
- 9: "FLAC (lossless, ~1411 kbps)",
- 3: "MP3 320 kbps",
- 1: "MP3 128 kbps",
-}
-VALID_BITRATES = tuple(sorted(TRACKFORMATS)) # (1, 3, 9)
-
-# --- Secret redaction ------------------------------------------------------
-# Long hex runs (Deezer ARL is a long lowercase-hex cookie) are masked, as is
-# any value that matches a secret we actually hold, plus cookie headers.
-_HEX_RUN = re.compile(r"[0-9a-fA-F]{32,}")
-_COOKIE_LINE = re.compile(r"(?im)^\s*(set-?cookie|cookie)\s*:\s*.*$")
-_KNOWN_SECRETS: list[str] = [s for s in {FALLBACK_ARL, os.environ.get("DEEMIX_ARL", "").strip()} if s]
-
-
-def redact(value: Any) -> Any:
- """Recursively strip anything that could carry a credential."""
- if isinstance(value, dict):
- out: dict = {}
- for k, v in value.items():
- if isinstance(k, str) and k.lower() in ("arl", "cookie", "cookies", "set-cookie", "token", "authorization"):
- out[k] = "[REDACTED]"
- else:
- out[k] = redact(v)
- return out
- if isinstance(value, (list, tuple)):
- return [redact(v) for v in value]
- if isinstance(value, str):
- s = value
- for sec in _KNOWN_SECRETS:
- s = s.replace(sec, "[REDACTED]")
- s = _HEX_RUN.sub("[REDACTED]", s)
- s = _COOKIE_LINE.sub("cookie: [REDACTED]", s)
- return s
- return value
-
-
-def _clean_error(message: str) -> str:
- """Trim + redact an error so no credential/cookie/raw body escapes."""
- s = str(message).replace("\n", " ")
- for sec in _KNOWN_SECRETS:
- s = s.replace(sec, "[REDACTED]")
- s = _HEX_RUN.sub("[REDACTED]", s)
- return s[:300]
-
-
-mcp = FastMCP(
- "deemix",
- instructions=(
- "Control the Deemix download server on the Unraid home server: "
- "search Deezer, queue albums/tracks, monitor download progress, "
- "retry failures. Files land on Unraid at the configured download "
- "location (see deemix_status). No credentials are stored here."
- ),
- host="0.0.0.0",
- port=int(os.environ.get("PORT", "8000")),
- stateless_http=True,
-)
-
-
-class DeemixError(RuntimeError):
- """User-facing error with a short, actionable, credential-free message."""
-
-
-class AuthError(DeemixError):
- """The Deemix session is missing or expired; a re-login may help."""
-
-
-class Session:
- """One HTTP session against Deemix with bounded automatic (re-)login."""
-
- def __init__(self) -> None:
- self.jar = http.cookiejar.CookieJar()
- self.opener = urllib.request.build_opener(
- urllib.request.HTTPCookieProcessor(self.jar)
- )
- self.user: dict = {}
- self._logged_in = False
-
- # -- low level ---------------------------------------------------------
- def _headers(self) -> dict:
- return {
- "Accept": "application/json",
- "User-Agent": "mike-ai-mcp-deemix/1.1",
- }
-
- def _raw(self, method: str, path: str, body: Optional[dict] = None):
- """Return (http_status, parsed). Network failures raise DeemixError.
-
- HTTP 401/403 are returned (not raised) so the caller can decide on a
- re-login. Any non-JSON body is turned into a clean, redacted error.
- """
- url = API + path
- data = json.dumps(body).encode() if body is not None else None
- headers = self._headers()
- if data is not None:
- headers["Content-Type"] = "application/json"
- req = urllib.request.Request(url, data=data, headers=headers, method=method)
- try:
- resp = self.opener.open(req, timeout=TIMEOUT)
- status = resp.getcode()
- raw = resp.read().decode("utf-8", "replace")
- except urllib.error.HTTPError as e:
- if e.code in (401, 403):
- return e.code, None
- raise DeemixError(
- _clean_error(f"Deemix {path} -> HTTP {e.code}")
- ) from None
- except (urllib.error.URLError, TimeoutError, OSError) as e:
- raise DeemixError(
- _clean_error(f"Deemix unreachable at {API}: {getattr(e, 'reason', e)}")
- ) from None
- if not raw.strip():
- return status, {}
- try:
- return status, json.loads(raw)
- except json.JSONDecodeError:
- raise DeemixError(
- _clean_error(f"Deemix {path} returned non-JSON ({raw[:80]!r})")
- ) from None
-
- # -- login -------------------------------------------------------------
- def _jar_has_session(self) -> bool:
- return any(c.name == "deemix_session" for c in self.jar)
-
- def login(self) -> None:
- """Establish a session. Raises DeemixError on failure (no retry)."""
- status, info = self._raw("GET", "/connect")
- if status in (401, 403):
- # Not authenticated yet; /connect should still answer, but if it
- # refuses we treat it as "needs login" and fall through.
- info = info or {}
- if not isinstance(info, dict):
- info = {}
- if str(info.get("deezerAvailable")) != "yes":
- raise DeemixError(
- "Deemix reports Deezer unavailable "
- f"(deezerAvailable={info.get('deezerAvailable')!r}) - "
- "check the Unraid VPN tunnel"
- )
- if not info.get("autologin", True) and self._jar_has_session():
- # autologin=false is only trusted while a live session cookie is
- # actually held; after a cookie loss a re-login is required even
- # though the server still reports autologin=false.
- self._logged_in = True
- self.user = info.get("currentUser") or {}
- return
- arl = ((info.get("singleUser") or {}).get("arl") or "") or FALLBACK_ARL
- if not arl:
- raise DeemixError(
- "No ARL available: Deemix is not in single-user mode and "
- "DEEMIX_ARL is not set in the MCP environment"
- )
- status, res = self._raw("POST", "/loginArl", {"arl": arl})
- if status in (401, 403) or not isinstance(res, dict) or res.get("status") not in (1, 2, 3):
- raise DeemixError(
- f"Deezer ARL login failed (status="
- f"{(res or {}).get('status') if isinstance(res, dict) else status}, "
- f"error={(res or {}).get('error') if isinstance(res, dict) else 'http'})"
- )
- self._logged_in = True
- self.user = res.get("user") or {}
-
- # -- central call with at most ONE re-login ----------------------------
- def call(self, method: str, path: str, body: Optional[dict] = None) -> Any:
- for _attempt in (1, 2):
- if not self._logged_in:
- try:
- self.login()
- except DeemixError as e:
- # The login (or the automatic re-login) failed. Abort
- # cleanly and boundedly: we never loop, we raise after
- # this single failed attempt.
- raise DeemixError(
- "Deemix session could not be established: "
- f"login/re-login attempt failed - {_clean_error(e)}"
- ) from None
- status, res = self._raw(method, path, body)
- if status in (401, 403):
- # Expiring session: reset and allow exactly one re-login.
- self._logged_in = False
- self.jar.clear()
- continue
- if (
- isinstance(res, dict)
- and res.get("result") is False
- and res.get("errid") == "NotLoggedIn"
- ):
- self._logged_in = False
- self.jar.clear()
- continue
- return res
- raise DeemixError(
- "Deemix session could not be established: re-login was attempted "
- "once and the request still reports an expired/absent session."
- )
-
- def request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
- """Back-compat alias used by tools that only need a read."""
- return self.call(method, path, body)
-
-
-SESSION = Session()
-
-
-def call(method: str, path: str, body: Optional[dict] = None) -> Any:
- return SESSION.call(method, path, body)
-
-
-def _out(payload: Any) -> str:
- return json.dumps(redact(payload), ensure_ascii=False, indent=2)
-
-
-def _compact_entry(e: dict) -> dict:
- size = e.get("size") or 0
- row = {
- "uuid": e.get("uuid"),
- "type": e.get("type"),
- "id": e.get("id"),
- "title": e.get("title"),
- "artist": e.get("artist"),
- "progress": e.get("progress"),
- "downloaded_mb": round((e.get("downloaded") or 0) / 1048576, 1),
- "total_mb": round(size / 1048576, 1) if size else None,
- "failed": bool(e.get("failed")),
- }
- errs = e.get("errors")
- if errs:
- row["errors"] = redact(errs)[:MAX_ERRORS_PER_ENTRY]
- return row
-
-
-def _compact_search_row(item: dict, kind: str) -> dict:
- if kind == "album":
- artists = ", ".join(
- a.get("name", "") for a in (item.get("related") or {}).get("artists", [])[:2]
- )
- return {
- "id": item.get("id"),
- "title": item.get("title"),
- "artist": artists,
- "year": ((item.get("release_date") or "")[:4] or None),
- "tracks": item.get("nb_tracks"),
- "link": f"https://www.deezer.com/album/{item.get('id')}",
- }
- if kind == "track":
- return {
- "id": item.get("id"),
- "title": item.get("title"),
- "artist": (item.get("artist") or {}).get("name"),
- "album": (item.get("album") or {}).get("title"),
- "duration": item.get("duration"),
- "link": f"https://www.deezer.com/track/{item.get('id')}",
- }
- if kind == "artist":
- return {
- "id": item.get("id"),
- "name": item.get("name"),
- "link": f"https://www.deezer.com/artist/{item.get('id')}",
- }
- if kind == "playlist":
- return {
- "id": item.get("id"),
- "title": item.get("name") or item.get("title"),
- "nb_tracks": item.get("nb_tracks"),
- "link": f"https://www.deezer.com/playlist/{item.get('id')}",
- }
- return {
- k: item.get(k)
- for k in ("id", "name", "title", "link", "url")
- if item.get(k) is not None
- }
-
-
-def _valid_bitrate(bitrate: Optional[int]) -> Optional[int]:
- if bitrate is None:
- return None
- if bitrate not in TRACKFORMATS:
- raise DeemixError(
- f"bitrate {bitrate} is not a valid TrackFormats value. "
- f"Use one of: {', '.join(f'{b}={TRACKFORMATS[b]}' for b in VALID_BITRATES)}. "
- "These are deezer-sdk TrackFormats enum ids, not kbit/s; "
- "omit bitrate to use Deemix's own maxBitrate setting."
- )
- return bitrate
-
-
-@mcp.tool()
-def deemix_status() -> str:
- """Status of the Deemix server: versions, Deezer login, download location and a compact queue summary."""
- info = call("GET", "/connect") # guarantees a session (also first call after fresh start)
- queue = call("GET", "/getQueue")
- entries = list((queue.get("queue") or {}).values())
- order = queue.get("queueOrder") or []
- by_id = {e.get("uuid"): e for e in entries}
- ordered = [by_id[u] for u in order if u in by_id]
- for e in entries:
- if e.get("uuid") not in order:
- ordered.append(e)
- rows = [_compact_entry(e) for e in ordered[:MAX_QUEUE_ROWS]]
- u = SESSION.user or {}
- settings = (info.get("settingsData") or {}).get("settings") or {}
- user_view = {
- k: u.get(k)
- for k in ("id", "name", "lastName", "country", "subscription")
- if u.get(k) not in (None, "")
- }
- return _out(
- {
- "deemix_version": (info.get("update") or {}).get("deemixVersion"),
- "webui_version": (info.get("update") or {}).get("webuiVersion"),
- "deezer_available": info.get("deezerAvailable"),
- "logged_in_user": user_view or "not logged in",
- "spotify_enabled": info.get("spotifyEnabled"),
- "download_location": settings.get("downloadLocation"),
- "max_bitrate_setting": settings.get("maxBitrate"),
- "queue": {
- "total": len(entries),
- "failed": sum(1 for e in entries if e.get("failed")),
- "rows": rows,
- "truncated": len(entries) > MAX_QUEUE_ROWS,
- },
- }
- )
-
-
-@mcp.tool()
-def deemix_search(term: str, type: str = "album", start: int = 0, nb: int = 10) -> str:
- """Search Deezer via Deemix. type: album, track, artist, playlist or radio.
- Returns compact rows with Deezer links usable for deemix_add_to_queue.
- term is limited to 100 chars and nb is capped at 20 results."""
- term = (term or "").strip()
- if not term:
- raise DeemixError("term is empty")
- if len(term) > MAX_SEARCH_TERM:
- term = term[:MAX_SEARCH_TERM]
- nb = max(1, min(int(nb), MAX_SEARCH_RESULTS))
- start = max(0, int(start))
- q = urllib.parse.urlencode({"term": term, "type": type, "start": start, "nb": nb})
- data = SESSION.call("GET", "/search?" + q)
- if not isinstance(data, dict):
- data = {}
- if data.get("error"):
- raise DeemixError(_clean_error(f"Deezer search failed: {data.get('error')}"))
- rows = [_compact_search_row(i, type) for i in (data.get("data") or [])[:nb]]
- return _out({"type": type, "total": data.get("total"), "results": rows})
-
-
-@mcp.tool()
-def deemix_add_to_queue(urls: str, bitrate: Optional[int] = None) -> str:
- """Queue Deezer URLs for download. urls: one or several Deezer links,
- space separated (album/track/artist/playlist), max 10 per call.
- bitrate (optional) is a deezer-sdk TrackFormats enum id, NOT kbit/s:
- 1=MP3 128kbps, 3=MP3 320kbps, 9=FLAC lossless. Omit to use Deemix's
- own maxBitrate setting."""
- url_list = [u.strip() for u in (urls or "").split() if u.strip()]
- if not url_list:
- raise DeemixError("urls is empty")
- if len(url_list) > MAX_URLS_PER_CALL:
- raise DeemixError(
- f"too many urls ({len(url_list)}); max {MAX_URLS_PER_CALL} per call"
- )
- body: dict = {"url": " ".join(url_list)}
- br = _valid_bitrate(bitrate)
- if br is not None:
- body["bitrate"] = br
- res = SESSION.call("POST", "/addToQueue", body)
- if not isinstance(res, dict) or res.get("result") is False:
- errid = (res or {}).get("errid") if isinstance(res, dict) else "http"
- raise DeemixError(_clean_error(f"addToQueue rejected (errid={errid!r})"))
- data = res.get("data") or {}
- obj = data.get("obj")
- # Real Deemix behaviour: an unknown/invalid ID is accepted but resolves
- # to zero items (obj == []) - nothing is queued. Report that honestly.
- resolved = obj not in ([], None, "")
- payload = {
- "queued": resolved,
- "bitrate_requested": br,
- "bitrate_used": data.get("bitrate"),
- "items": obj if isinstance(obj, (list, dict)) else str(obj),
- }
- if not resolved:
- payload["warning"] = (
- "Deemix accepted the URL(s) but resolved NO items - the ID(s) "
- "are probably invalid. Nothing was queued."
- )
- return _out(payload)
-
-
-@mcp.tool()
-def deemix_queue() -> str:
- """Current download queue with per-entry progress, size and errors (compact)."""
- queue = SESSION.call("GET", "/getQueue")
- entries = list((queue.get("queue") or {}).values())
- order = queue.get("queueOrder") or []
- by_id = {e.get("uuid"): e for e in entries}
- ordered = [by_id[u] for u in order if u in by_id]
- for e in entries:
- if e.get("uuid") not in order:
- ordered.append(e)
- rows = [_compact_entry(e) for e in ordered[:MAX_QUEUE_ROWS]]
- return _out(
- {
- "total": len(entries),
- "failed": sum(1 for e in entries if e.get("failed")),
- "rows": rows,
- "truncated": len(entries) > MAX_QUEUE_ROWS,
- }
- )
-
-
-@mcp.tool()
-def deemix_retry_download(uuid: str) -> str:
- """Retry a failed queue entry (uuid from deemix_queue). Re-queues the whole album/track; existing files are not overwritten."""
- res = SESSION.call("POST", "/retryDownload", {"uuid": uuid})
- if not isinstance(res, dict) or res.get("result") is False:
- raise DeemixError(_clean_error(f"retryDownload failed (errid={(res or {}).get('errid')!r})"))
- return _out({"retried": uuid, "result": True})
-
-
-@mcp.tool()
-def deemix_remove_from_queue(uuid: str) -> str:
- """Cancel one queue entry by uuid (from deemix_queue)."""
- q = urllib.parse.urlencode({"uuid": uuid})
- res = SESSION.call("POST", "/removeFromQueue?" + q)
- if not isinstance(res, dict) or res.get("result") is False:
- raise DeemixError(_clean_error("removeFromQueue failed - uuid unknown?"))
- return _out({"removed": uuid, "result": True})
-
-
-@mcp.tool()
-def deemix_remove_finished() -> str:
- """Remove all finished (non-failed) entries from the queue. Does not touch files on disk."""
- res = SESSION.call("POST", "/removeFinishedDownloads")
- if not isinstance(res, dict) or res.get("result") is False:
- raise DeemixError(_clean_error("removeFinishedDownloads failed"))
- return _out({"result": True})
-
-
-@mcp.tool()
-def deemix_cancel_all() -> str:
- """Cancel ALL active downloads in Deemix. Use only when the user explicitly asked to stop downloading."""
- res = SESSION.call("POST", "/cancelAllDownloads")
- if not isinstance(res, dict) or res.get("result") is False:
- raise DeemixError(_clean_error("cancelAllDownloads failed"))
- return _out({"result": True})
-
-
-if __name__ == "__main__":
- # MCPHub manages local servers as stdio subprocesses. The standalone
- # Athena container can keep using Streamable HTTP by setting
- # MCP_TRANSPORT=streamable-http, so the same source works in both places
- # during the migration.
- mcp.run(transport=os.environ.get("MCP_TRANSPORT", "stdio"))
diff --git a/platform/mcp/install-tools.sh b/platform/mcp/install-tools.sh
index 5bff534..95021dd 100755
--- a/platform/mcp/install-tools.sh
+++ b/platform/mcp/install-tools.sh
@@ -23,7 +23,7 @@ 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."
+echo "ARR, GitHub, Home Assistant und Navidrome werden über MCPHub auf Unraid bereitgestellt."
"${COMPOSE[@]}" up -d --build "${services[@]}"
"${COMPOSE[@]}" ps
diff --git a/platform/mcp/sync-clients.py b/platform/mcp/sync-clients.py
index a583901..c94db1f 100755
--- a/platform/mcp/sync-clients.py
+++ b/platform/mcp/sync-clients.py
@@ -138,7 +138,7 @@ def update_openwebui(db: pathlib.Path, items: list[dict]) -> None:
raise SystemExit("Unexpected OpenWebUI tool_server.connections format")
managed_ids = {
"athena-platform", "athena-operator-local", "web-general-local", "github-local",
- "homeassistant-local", "arr-local", "navidrome-local", "deemix-local", "mua",
+ "homeassistant-local", "arr-local", "navidrome-local", "mua",
"mua-readonly-local", "athena-terminal-local", "unraid-readonly-local", "web-local",
}
keep = [entry for entry in old if str((entry.get("info") or {}).get("id", "")) not in managed_ids]
diff --git a/platform/mcphub/Dockerfile b/platform/mcphub/Dockerfile
index 25b04eb..1e9600c 100644
--- a/platform/mcphub/Dockerfile
+++ b/platform/mcphub/Dockerfile
@@ -30,7 +30,6 @@ RUN mkdir -p /opt/casaderoll \
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/mcphub/mcphub_admin_mcp.py /opt/casaderoll/mcps/mcphub_admin_mcp.py
COPY platform/mcphub/mcphub_git_installer.py /opt/casaderoll/mcps/mcphub_git_installer.py
COPY platform/mcp/patches/mcp_sonarr.py /usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_sonarr.py
diff --git a/platform/mcphub/README.md b/platform/mcphub/README.md
index 5ee59e6..14a9bb6 100644
--- a/platform/mcphub/README.md
+++ b/platform/mcphub/README.md
@@ -6,7 +6,7 @@ sie sich einen Docker-Container und ein Appdata-Backup teilen.
## Was hier hinein gehört
-- ARR, Deemix, Navidrome und GitHub laufen als lokale stdio-Unterprozesse.
+- ARR, 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. Hermes nutzt
@@ -92,7 +92,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,
+Aktueller Stand: Athena Operator, ARR, 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.
diff --git a/platform/mcphub/configure-settings.py b/platform/mcphub/configure-settings.py
index ef39c2a..6b39ebd 100644
--- a/platform/mcphub/configure-settings.py
+++ b/platform/mcphub/configure-settings.py
@@ -86,7 +86,7 @@ def ensure_hermes_group(settings: dict[str, object]) -> None:
"""Seed the bounded Hermes aggregate route on fresh installs/recovery."""
server_names = [
"mcphub-admin", "athena-operator", "github", "arr", "navidrome",
- "deemix", "unraid",
+ "unraid",
]
members: list[dict[str, object]] = [
{"name": name, "tools": "all", "prompts": "all", "resources": "all"}
diff --git a/platform/mcphub/mcp-settings.example.json b/platform/mcphub/mcp-settings.example.json
index 2bbe02b..93ab6ac 100644
--- a/platform/mcphub/mcp-settings.example.json
+++ b/platform/mcphub/mcp-settings.example.json
@@ -20,18 +20,6 @@
],
"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",
diff --git a/platform/operator/athena_operatord.py b/platform/operator/athena_operatord.py
index 5b7a0fe..8b579dd 100755
--- a/platform/operator/athena_operatord.py
+++ b/platform/operator/athena_operatord.py
@@ -44,7 +44,7 @@ STAGING_ROOTS = tuple(
Path(value).resolve()
for value in os.environ.get(
"ATHENA_OPERATOR_STAGING_ROOTS",
- "/data/mike-ai-operator/staging:/data/hermes/deemix-mcp-build",
+ "/data/mike-ai-operator/staging",
).split(":")
if value
)
From 9bc7d9803a3a4ab268426c2afe6d19dffaaea294 Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Sun, 30 Aug 2026 21:59:44 +0200
Subject: [PATCH 03/13] Document Athena slot toggle
---
README.md | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/README.md b/README.md
index 5ec9454..0542536 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,31 @@ docker exec mike-ai-backup backup
sudo ./smoke-test.sh
```
+### Ein oder zwei Modell-Slots
+
+Produktiv laufen alle Profile mit einem Slot. Damit erhält ein einzelner Chat
+den vollständigen Profilkontext und die bewährte Ausgabegeschwindigkeit. Die
+Einstellung liegt auf Athena in `/etc/mike-ai/stack.env`:
+
+```bash
+MEDIUM_PARALLEL_SLOTS=1
+```
+
+Für einen späteren erneuten Paralleltest wird der Wert auf `2` gesetzt und nur
+das betroffene Profil neu erstellt:
+
+```bash
+sed -i 's/^MEDIUM_PARALLEL_SLOTS=.*/MEDIUM_PARALLEL_SLOTS=2/' /etc/mike-ai/stack.env
+cd /opt/mike-ai/stack
+docker compose --env-file /etc/mike-ai/stack.env up -d --no-deps --force-recreate llama-medium
+```
+
+Zurück zum stabilen Ein-Slot-Betrieb geht es mit denselben Befehlen und
+`MEDIUM_PARALLEL_SLOTS=1`. `--kv-unified` ist bereits im Compose-Stack gesetzt.
+Zwei Slots funktionieren direkt am Router; Hermes verwaltete zwei gleichzeitig
+aktive Chats jedoch nicht zuverlässig. Deshalb bleibt ein Slot der Standard,
+bis Hermes' Sitzungsfehler behoben ist.
+
Router-API: `http://:8081/v1`
Hermes-Dashboard: `http://:9119`
From 0b927d47b9b05dd38d39a65a2730ec23249237bb Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Sun, 30 Aug 2026 08:45:52 +0200
Subject: [PATCH 04/13] Simplify Athena runtime and document current
architecture
---
.env.example | 6 +-
ATHENA.md | 143 ++--
README.md | 133 ++-
compose.yaml | 208 +----
config/github-mcp.env.example | 9 -
config/install.env.example | 13 +-
config/mcp-extensions/himalaya.json | 47 --
config/mcp-registry.json | 186 -----
config/mcphub-client.env.example | 4 -
config/mua-mcp.env.example | 4 -
config/navidrome-mcp.env.example | 8 -
config/unraid-templates/my-ARR-MCP.xml | 23 -
.../my-Hermes-Agent-Official.xml | 50 --
config/unraid-templates/my-MCPHub.xml | 38 -
dev/test_hass_mcp_yaml_guard.py | 91 --
dev/test_mcp_registry.py | 106 ---
dev/test_mcphub_deploy_extension.py | 91 --
dev/test_mcphub_git_installer.py | 131 ---
dev/test_mcphub_settings.py | 94 ---
dev/test_openwebui_filters.py | 616 --------------
dev/test_radarr_patch.py | 84 --
dev/test_sonarr_release_grab.py | 195 -----
dev/test_web_search_mcp.py | 148 ----
docs/ARCHITECTURE.md | 35 +
docs/MCP_SERVERS.md | 39 +-
docs/RECOVERY.md | 99 +--
docs/athena-architecture-map.png | Bin 0 -> 1577839 bytes
install.sh | 40 +-
manage.sh | 18 +-
.../{flux-worker => image-worker}/Dockerfile | 10 +-
.../image_worker.py} | 33 +-
.../profile-controller/profile_controller.py | 4 +-
.../docker/wireguard-gateway/entrypoint.sh | 21 +-
platform/llama-dashboard/Dockerfile | 14 +
platform/llama-dashboard/app.py | 775 ++++++++++++++++++
platform/mcp/Dockerfile.arr | 12 -
platform/mcp/Dockerfile.github | 25 -
platform/mcp/Dockerfile.homeassistant-relay | 8 -
platform/mcp/Dockerfile.navidrome | 8 -
platform/mcp/Dockerfile.unraid-ssh | 15 -
platform/mcp/Dockerfile.web | 18 -
platform/mcp/compose.github-maintenance.yaml | 12 -
platform/mcp/compose.yaml | 196 +----
platform/mcp/github-mcp-mode.sh | 49 --
platform/mcp/ha-relay-entrypoint.sh | 23 -
platform/mcp/homeassistant.conf.template | 30 -
platform/mcp/install-hass-mcp-yaml-guard.sh | 27 -
platform/mcp/install-tools.sh | 10 +-
platform/mcp/patches/hass_mcp/yaml_config.py | 707 ----------------
platform/mcp/patches/mcp_radarr.py | 187 -----
platform/mcp/patches/mcp_sonarr.py | 676 ---------------
platform/mcp/sync-clients.py | 177 ----
platform/mcp/verify-navidrome.sh | 96 ---
platform/models/manifest.example.yaml | 8 +-
router/ai_profile_router.py | 172 +++-
smoke-test.sh | 16 +-
56 files changed, 1276 insertions(+), 4712 deletions(-)
delete mode 100644 config/github-mcp.env.example
delete mode 100644 config/mcp-extensions/himalaya.json
delete mode 100644 config/mcp-registry.json
delete mode 100644 config/mcphub-client.env.example
delete mode 100644 config/mua-mcp.env.example
delete mode 100644 config/navidrome-mcp.env.example
delete mode 100644 config/unraid-templates/my-ARR-MCP.xml
delete mode 100644 config/unraid-templates/my-Hermes-Agent-Official.xml
delete mode 100644 config/unraid-templates/my-MCPHub.xml
delete mode 100644 dev/test_hass_mcp_yaml_guard.py
delete mode 100644 dev/test_mcp_registry.py
delete mode 100644 dev/test_mcphub_deploy_extension.py
delete mode 100644 dev/test_mcphub_git_installer.py
delete mode 100644 dev/test_mcphub_settings.py
delete mode 100644 dev/test_openwebui_filters.py
delete mode 100644 dev/test_radarr_patch.py
delete mode 100755 dev/test_sonarr_release_grab.py
delete mode 100644 dev/test_web_search_mcp.py
create mode 100644 docs/ARCHITECTURE.md
create mode 100644 docs/athena-architecture-map.png
rename platform/docker/{flux-worker => image-worker}/Dockerfile (70%)
rename platform/docker/{flux-worker/flux_worker.py => image-worker/image_worker.py} (79%)
create mode 100644 platform/llama-dashboard/Dockerfile
create mode 100644 platform/llama-dashboard/app.py
delete mode 100644 platform/mcp/Dockerfile.arr
delete mode 100644 platform/mcp/Dockerfile.github
delete mode 100644 platform/mcp/Dockerfile.homeassistant-relay
delete mode 100644 platform/mcp/Dockerfile.navidrome
delete mode 100644 platform/mcp/Dockerfile.unraid-ssh
delete mode 100644 platform/mcp/Dockerfile.web
delete mode 100644 platform/mcp/compose.github-maintenance.yaml
delete mode 100755 platform/mcp/github-mcp-mode.sh
delete mode 100644 platform/mcp/ha-relay-entrypoint.sh
delete mode 100644 platform/mcp/homeassistant.conf.template
delete mode 100755 platform/mcp/install-hass-mcp-yaml-guard.sh
delete mode 100644 platform/mcp/patches/hass_mcp/yaml_config.py
delete mode 100644 platform/mcp/patches/mcp_radarr.py
delete mode 100644 platform/mcp/patches/mcp_sonarr.py
delete mode 100755 platform/mcp/sync-clients.py
delete mode 100755 platform/mcp/verify-navidrome.sh
diff --git a/.env.example b/.env.example
index d9ffd75..12993a9 100644
--- a/.env.example
+++ b/.env.example
@@ -1,10 +1,10 @@
# Generated as /etc/mike-ai/stack.env by install.sh. Never commit real values.
AI_BIND_ADDRESS=10.77.0.2
-MODEL_DIR=/srv/mike-ai/models
+MODEL_DIR=/data/models
ROUTER_API_KEY=GENERATED_BY_INSTALLER
CONTROLLER_TOKEN=GENERATED_BY_INSTALLER
-WEBUI_SECRET_KEY=GENERATED_BY_INSTALLER
-OPENWEBUI_IMAGE=mike-ai/openwebui:main-01f4282-tool-final-v3
+Z_IMAGE_MODEL_DIR=/data/models/Z-Image-Turbo
+IMAGE_GPU_DEVICES=1
PIPER_TTS_VERSION=1.6.0
PIPER_VOICE=de_DE-thorsten-high
XTTS_IMAGE=ghcr.io/coqui-ai/xtts-streaming-server:latest-cuda121@sha256:f7fb3b1f9d4bc88af94da1b5959d8002f1e0b003c97557164034eb8a29f01b90
diff --git a/ATHENA.md b/ATHENA.md
index 3d980be..9a17f6e 100644
--- a/ATHENA.md
+++ b/ATHENA.md
@@ -1,110 +1,79 @@
# Athena – Betriebsanleitung
Diese Datei ist der kurze, verbindliche Einstieg für Menschen und Agenten.
-Für normale Arbeiten reicht sie aus. Detaildokumente unter `docs/` werden nur
-gelesen, wenn diese Datei ausdrücklich darauf verweist oder eine konkrete
-Fehlersuche sie benötigt.
-## Aufbau
+## Rolle
-- Host: Debian, ohne lokalen Notfallzugriff oder KVM.
-- Arbeitsbaum und laufender Stack: `/opt/mike-ai/stack`.
-- Persistente Daten, Modelle und Backups: `/data`.
-- Lokale Konfiguration und Secrets: `/etc/mike-ai` (niemals in Git).
-- Benutzerzugriff auf KI-Dienste: über WireGuard, nicht über das Uni-LAN.
-- OpenAI-kompatible Modell-API: Profile Router auf Port 8081.
-- Oberfläche und Agent: Hermes auf Unraid unter
- `/mnt/nvme-storage/appdata/Hermes-Agent`. Athena stellt dafür nur die
- OpenAI-kompatible Router-API bereit. OpenWebUI ist abgeschaltetes Rückfallnetz.
-- Inferenz: genau ein aktives llama.cpp-Textprofil; der Router wechselt bei
- Bedarf zwischen Fast, Medium, Large, Ultra und Uncensored.
+Athena ist eine Inferenzmaschine, kein allgemeiner Anwendungsserver.
-## Verzeichnisse
+Sie betreibt:
+
+- llama.cpp mit genau einem aktiven Qwen-Profil,
+- den OpenAI-kompatiblen Profile Router,
+- Z-Image-Turbo für Bilder,
+- XTTS und Piper für Sprache,
+- das Athena-Dashboard,
+- WireGuard-Gateway und Datenbackup,
+- den hostgebundenen Athena-Operator.
+
+Hermes, Benutzeroberfläche und portable Fach-MCPs laufen auf Unraid. Auf Athena
+werden keine zweiten Instanzen dieser Dienste angelegt.
+
+## Pfade
| Pfad | Zweck |
|---|---|
-| `/opt/mike-ai/stack` | Einziger Git-Checkout und einzige Quelle für Deployments |
-| `/data/models` | GGUF-Modelle, Projektoren und weitere große Modelldateien |
-| `/data` | Persistente Anwendungsdaten und Docker-Backups |
-| `/etc/mike-ai` | Lokale Env-Dateien, API-Schlüssel und SSH-Schlüssel |
-| `/tmp` | Einmalige Hilfsprogramme und temporäre Arbeitsdateien |
+| `/opt/mike-ai/stack` | kanonischer Checkout und Compose-Stack |
+| `/data/models` | Modellgewichte |
+| `/data/llama-dashboard` | historische Dashboard-Messwerte |
+| `/data/docker-backups` | automatische Athena-Backups |
+| `/etc/mike-ai` | lokale Konfiguration und Secrets, niemals Git |
-Die früheren Checkouts `/data/mike-ai-operator/repository` und
-`/root/AI-Profile-Router` sind keine Arbeitsquellen. Sie dürfen nach der
-Migration höchstens als gekennzeichnetes Archiv existieren.
+## Standardbefehle
-## Container-Prinzip
+```bash
+cd /opt/mike-ai/stack
+./manage.sh validate
+./manage.sh deploy SERVICE
+./manage.sh deploy core
+sudo ./smoke-test.sh
+```
-Athena betreibt nur Inferenz, Router, Sprache/Bild, Backup und den
-hostgebundenen Athena-Operator. Portable Fach-MCPs laufen als getrennte
-Unterprozesse im **einen MCPHub-Container auf Unraid**. Sie bleiben unter
-`/mcp/NAME` einzeln sichtbar und abschaltbar, brauchen aber nicht je einen
-Docker-Container. `config/mcp-registry.json` ist die einzige Serverliste.
+`deploy SERVICE` verwendet `--no-deps` und fasst keine anderen Container an.
+`deploy core` aktualisiert den vollständigen Athena-Kern. Das aktuell aktive
+Qwen-Profil wird vom Profile Controller verwaltet.
-## Standardablauf für Änderungen
+## Modelle
-1. `athena_operator_inspect` einmal für den betroffenen Bereich aufrufen.
-2. Mit `athena_operator_search_source` die konkrete Datei finden.
-3. Mit `athena_operator_read_source` nur den benötigten Ausschnitt lesen.
-4. Änderung über `athena_operator_change` ausführen.
-5. Syntax, Compose, Dienstzustand und eine kleine Funktionsprobe prüfen.
-6. Geänderte Dateien committen und pushen.
-7. Ein manuelles Datenbackup nur nach speicherrelevanten Änderungen auslösen.
+- Fast: kurze, interaktive Aufgaben
+- Medium/Large/Ultra: steigende Kontextgrößen desselben lokalen Qwen-Modells
+- Uncensored: separates lokales Profil
+- Z-Image-Turbo: Bildgenerierung; Qwen wird dafür kurz entladen und danach
+ automatisch wiederhergestellt
+- XTTS: RTX 3060; Piper bleibt CPU-Fallback
-Nicht bei jedem Zwischenschritt die gesamte Plattform neu untersuchen. Keine
-vollständigen Compose-, Installations- oder Dokumentationsdateien in den Chat
-laden, wenn ein kleiner Ausschnitt genügt. Derselbe fehlgeschlagene Pfad oder
-Werkzeugaufruf wird höchstens einmal wiederholt.
+Die verbindlichen Werte stehen in `config/profile-matrix.json` und
+`docs/STANDARD_PROFILE_MATRIX.md`.
-## Neuer MCP
+## Werkzeuge
-Portable MCPs werden nach dem Skill `mcphub-deployer` in das reproduzierbare
-MCPHub-Image eingebaut. Dazu gehören gepinnte Quelle, genau ein Registry-Eintrag,
-Secret-Datei nur im Appdata, Build, Handshake und eine read-only-Probe. Danach
-wird ausschließlich MCPHub über Unraid DockerMan neu erstellt. Router, Qwen,
-WireGuard und andere Container werden nicht neu gestartet.
-
-Nur ein Werkzeug, das Athenas Host selbst verwalten muss, gehört in den
-Athena-Operator. Es wird kein zweiter allgemeiner Terminal- oder Doku-MCP gebaut.
-
-## Temporär oder dauerhaft
-
-- „Nutze Programm X“: wenn es fehlt, nur temporär unter `/tmp` oder in einem
- kurzlebigen Container verwenden und anschließend entfernen.
-- „Installiere Programm X dauerhaft“: versioniert in den Stack aufnehmen.
-- Bestehende Dienste auf Unraid oder im Heimnetz werden weiterverwendet; auf
- Athena wird nicht ohne Grund eine zweite Instanz aufgebaut.
+Portable Werkzeuge gehören auf Unraid in eigene, per DockerMan verwaltete
+Container. Der einzige MCP auf Athena ist der Athena-Operator, weil nur er den
+Athena-Host verwalten muss. Neue Fach-MCPs werden nicht in diesen Stack
+eingebaut.
## Sicherheitsgrenze
-Athena darf ohne ausdrücklichen, aktuellen Auftrag niemals heruntergefahren
-oder neu gestartet werden. Ebenfalls tabu sind Änderungen an SSH, LAN,
-WireGuard, Firewall, Bootloader, Kernel, Partitionen und Mounts. Diese Grenze
-schützt die Erreichbarkeit des entfernten Hosts.
-
-Innerhalb des vertrauenswürdigen WireGuard-Netzes dürfen die vorgesehenen
-Container normal miteinander, mit dem Heimnetz und mit dem Internet
-kommunizieren. Keine zusätzlichen Netzwerkbarrieren ohne konkreten Bedarf.
-
-Secrets dürfen lokal von Athena und dem lokalen Modell verwendet werden. Sie
-werden aber weder in Git noch in normalen Werkzeugausgaben oder Chatantworten
-veröffentlicht.
+Ohne ausdrücklichen aktuellen Auftrag niemals Shutdown, Reboot, Kernel,
+Bootloader, Partitionen, Mounts, SSH, LAN, WireGuard oder Firewall ändern.
+Secrets dürfen lokal verwendet, aber nie in Git, Logs oder Chatantworten
+veröffentlicht werden.
## Fertig bedeutet
-Eine Änderung ist erst fertig, wenn:
-
-- der versionierte Arbeitsbaum die Änderung enthält,
-- der betroffene Dienst den neuen Stand verwendet,
-- ein fokussierter Test erfolgreich war,
-- Git-Status und Commit bekannt sind,
-- das automatische Backup läuft und bei Datenänderungen ein Archiv geprüft wurde.
-
-Bei Unsicherheit wird der konkrete offene Punkt genannt. Es werden keine
-Ergebnisse, Werkzeugaufrufe oder erfolgreichen Deployments erfunden.
-
-## Referenzen
-
-- Installation und Überblick: `README.md`
-- Wiederherstellung: `docs/RECOVERY.md`
-- Modellprofile: `docs/STANDARD_PROFILE_MATRIX.md`
+- Änderung ist im kanonischen Git-Checkout,
+- Compose und Syntax sind gültig,
+- betroffener Dienst ist gesund,
+- eine kleine Funktionsprobe war erfolgreich,
+- Commit und Push sind erfolgt,
+- das automatische Backup bleibt gesund.
diff --git a/README.md b/README.md
index 0542536..ede56f1 100644
--- a/README.md
+++ b/README.md
@@ -1,68 +1,60 @@
# Athena AI
-Ein reproduzierbarer Docker-Stack für Athenas lokale Inferenz. Athena stellt
-Router, llama.cpp-Profile, Sprache und Bildgenerierung bereit. Der offizielle
-Hermes Agent und MCPHub laufen auf Unraid und werden dort mit Appdata gesichert.
+Athena ist die lokale Inferenzmaschine. Der reproduzierbare Docker-Stack stellt
+Qwen über eine kleine OpenAI-kompatible Router-API bereit und übernimmt lokale
+Bild- und Sprachausgabe. **Hermes und die Fach-MCPs laufen auf Unraid.**
-## Aufbau
+## Aktueller Aufbau
-- `compose.yaml` ist der einzige Einstieg für die KI-Dienste auf Athena.
-- Genau ein llama.cpp-Profil ist aktiv. Der Router schaltet zwischen Fast,
- Medium, Large, Ultra und Uncensored.
-- Hermes verdichtet ältere Assistenten- und Werkzeug-Turns fortlaufend per
- Micro-Compaction (alle fünf abgeschlossenen Turns). Das jeweils gewählte
- 27B-Hauptprofil erstellt die Zusammenfassung; ein separates, weniger
- zuverlässiges Kompressionsmodell wird nicht betrieben.
-- Der **Athena Operator** bleibt als einziger hostgebundener administrativer
- MCP direkt auf Athena. MCPHub veröffentlicht seinen vorhandenen
- WireGuard-HTTP-Endpunkt zentral unter `/mcp/athena-operator`; es gibt keinen
- zweiten Operator und keine administrative SSH-Implementierung im Hub.
-- Home Assistant, ARR, Unraid, Navidrome 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 den eingebauten schlüssellosen
- Keenable-Provider für Suche und Seitenabruf; der frühere Athena-Webadapter
- wird nicht mehr gestartet.
-- MCPHubs eigene persistente Einstellungen unter `MCPHub/mcp_settings.json`
- sind der produktive Zustand. Oberfläche und offizielle API ändern genau
- diese Datei; Container-Updates überschreiben sie nicht.
- `config/mcp-registry.json` ist nur der Neuinstallations-Seed.
-- Hermes verbindet sich einmal mit MCPHubs gefiltertem `/mcp/hermes`-Endpunkt. Neue
- aktivierte Server erscheinen dadurch nach **MCP neu laden**, ohne dass pro
- MCP eine weitere Hermes-Konfiguration geschrieben werden muss.
-- 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`.
-- Hermes verwendet unverändert `nousresearch/hermes-agent:latest`. Seine
- Profile erreichen Athenas Router über `http://192.168.1.212:8081/v1`.
- Die frühere Athena-Instanz bleibt vorerst gestoppt als Rückfall erhalten.
-- KI-Oberflächen und APIs sind nur über WireGuard erreichbar.
+### Athena
+
+- genau ein aktives llama.cpp-Profil: Fast, Medium, Large, Ultra oder Uncensored
+- Profile Router auf Port 8081
+- Z-Image-Turbo als exklusiver Bild-Worker auf der RTX 5080
+- XTTS auf der RTX 3060 mit Piper als CPU-Fallback
+- Live-Dashboard mit 21 Tagen Detailhistorie auf Port 8099
+- WireGuard-Gateway, Datenbackup und Athena-Operator
+- keine produktive Hermes-, OpenWebUI- oder portable Fach-MCP-Instanz
+
+### Unraid
+
+- offizieller Hermes-Agent mit persistentem Appdata
+- je ein eigener Container für ARR, Deemix, Navidrome, STRATO und
+ Nginx Proxy Manager
+- MUA/Unraid-MCP als Unraid-Plugin
+- Media-Tools als nachrüstbare Werkzeugkiste
+- Sicherung durch das vorhandene Unraid-Appdata-Backup
+
+Hermes nutzt Athenas Router unter `http://192.168.1.212:8081/v1`. Ein MCPHub
+ist nicht mehr Bestandteil der produktiven Architektur.
## Installation – ein Befehl
-Nach dem Ausfüllen von `config/install.env`:
-
```bash
-sudo ./install.sh --config config/install.env
+cp config/install.env.example /root/mike-ai-install.env
+# Werte in /root/mike-ai-install.env eintragen und chmod 600 setzen
+sudo ./install.sh --config /root/mike-ai-install.env
```
-Das Skript installiert Docker und NVIDIA-Unterstützung, lädt die konfigurierten
-Modelle und startet ausschließlich Athenas Inferenz-Kern plus Operator.
+Das Installationsskript baut llama.cpp und die lokalen Images, lädt die
+versionierten Modellartefakte und startet ausschließlich den Athena-Kern.
-## Bedienung
+## Betrieb
```bash
-# Gesamten Stack anzeigen
-docker compose --env-file /etc/mike-ai/stack.env ps
+# Konfiguration prüfen
+./manage.sh validate
-# Erst anzeigen, dann eine gezielte Komponente ohne Nebenwirkungen ausrollen
-./manage.sh --dry-run deploy router
+# Gesamten Athena-Kern gezielt aktualisieren
+./manage.sh deploy core
+
+# Nur einen Dienst ausrollen
./manage.sh deploy router
-# Sofortiges Datenbackup zusätzlich zum Fünf-Stunden-Zeitplan
-docker exec mike-ai-backup backup
+# Eindeutige Altcontainer entfernen
+./manage.sh purge-legacy
-# Kurzer read-only Ende-zu-Ende-Test nach jedem Release
+# Read-only Ende-zu-Ende-Test
sudo ./smoke-test.sh
```
@@ -93,42 +85,41 @@ bis Hermes' Sitzungsfehler behoben ist.
Router-API: `http://:8081/v1`
-Hermes-Dashboard: `http://:9119`
+## Endpunkte
-Hermes-API: `http://:8642`
+- Router: `http://192.168.1.212:8081/v1`
+- Athena-Dashboard: `http://192.168.1.212:8099`
+- Hermes-Dashboard auf Unraid: `http://192.168.1.2:9119`
-## Ausgegliederte Fach-MCPs
+Die Adressen sind nur über die vorgesehenen privaten Netze erreichbar.
+## Ausgegliederte MCPs
+
+- [ARR-MCP](https://git.casaderoll.de/michael/arr-mcp)
- [Deemix-MCP](https://git.casaderoll.de/michael/Deemix-MCP)
- [Strato-MCP](https://git.casaderoll.de/michael/Strato-MCP)
-## Wiederherstellung – ein Befehl
+Weitere produktive Container verwenden ihre jeweiligen Upstream-Images und
+Unraid-DockerMan-Templates. Details stehen in
+[docs/MCP_SERVERS.md](docs/MCP_SERVERS.md).
-Nach einer frischen Installation und eingehängtem `/data`:
+## Wiederherstellung
+
+Nach einer frischen Debian-Installation und erneut eingehängtem `/data`:
```bash
+sudo ./install.sh --config /root/mike-ai-install.env
sudo ./restore.sh /data/docker-backups/athena-latest.tar.gz
+sudo ./smoke-test.sh
```
-Details, Prüfschritte und der exakte Sicherungsumfang stehen in
-[`docs/RECOVERY.md`](docs/RECOVERY.md).
+Der genaue Sicherungsumfang steht in [docs/RECOVERY.md](docs/RECOVERY.md).
-## Dokumentation
+## Verbindliche Dokumentation
-- [`ATHENA.md`](ATHENA.md) – kurze Maschinen- und Operatoranleitung
-- [`docs/STANDARD_PROFILE_MATRIX.md`](docs/STANDARD_PROFILE_MATRIX.md) – Profile und Messwerte
-- [`docs/MCP_SERVERS.md`](docs/MCP_SERVERS.md) – automatisch erzeugte MCP-Liste
-- [`docs/RECOVERY.md`](docs/RECOVERY.md) – Backup und Neuaufbau
-
-Die MCPHub-Installation, Endpunkte und der schrittweise Rückbau der alten
-Athena-MCPs stehen in [`platform/mcphub/README.md`](platform/mcphub/README.md).
-Der eigenständig betreibbare Sonarr-/Radarr-Container einschließlich
-Debian-Slim-Installer und Unraid-Template liegt unter
-[`services/arr-mcp/`](services/arr-mcp/README.md). Er ist für die schrittweise
-Ablösung des bisherigen MCPHub-Prozesses vorbereitet, wird durch Athenas
-Standardinstallation aber nicht automatisch gestartet.
-Für Installation oder Wiederherstellung des Hermes-Gateways auf Unraid liegt unter
-[`config/unraid-templates/my-Hermes-Agent-Official.xml`](config/unraid-templates/my-Hermes-Agent-Official.xml)
-ein DockerMan-Template, das unverändert das offizielle Nous-Image verwendet.
+- [ATHENA.md](ATHENA.md) – kurze Betriebsanleitung
+- [docs/STANDARD_PROFILE_MATRIX.md](docs/STANDARD_PROFILE_MATRIX.md) – Profile
+- [docs/MCP_SERVERS.md](docs/MCP_SERVERS.md) – produktive Werkzeuge
+- [docs/RECOVERY.md](docs/RECOVERY.md) – Backup und Neuaufbau
Git enthält keine Secrets, Chatdaten oder Modellgewichte.
diff --git a/compose.yaml b/compose.yaml
index e49d1ea..ab49ff6 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -477,7 +477,6 @@ services:
labels:
com.mike-ai.llama-profile: experimental
environment:
- SEARXNG_URL: http://searxng:8080
NVIDIA_VISIBLE_DEVICES: ${EXPERIMENTAL_GPU_DEVICES:-0}
NVIDIA_DRIVER_CAPABILITIES: compute,utility
command:
@@ -532,7 +531,7 @@ services:
environment:
CONTROLLER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
ALLOWED_PROFILES: fast,medium,large,ultra,uncensored,experimental
- IMAGE_WORKER: flux
+ IMAGE_WORKER: image
networks: [control]
security_opt: ["no-new-privileges:true"]
healthcheck:
@@ -572,8 +571,9 @@ services:
# consume the complete context before yielding visible output.
MAX_GENERATION_TOKENS: "8192"
IMAGE_DIR: /data/images
- IMAGE_WORKER_URL: http://flux-worker:8086
+ IMAGE_WORKER_URL: http://image-worker:8086
IMAGE_WORKER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
+ IMAGE_MODEL_NAME: Z-Image-Turbo
CHAT_IMAGE_ALLOW_REMOTE_URLS: "false"
ENABLE_IMAGE_GENERATION: "true"
ENABLE_TTS: "true"
@@ -610,31 +610,31 @@ services:
tts-gateway:
condition: service_healthy
- flux-worker:
+ image-worker:
build:
- context: platform/docker/flux-worker
+ context: platform/docker/image-worker
args:
DIFFUSERS_VERSION: ${DIFFUSERS_VERSION:-0.40.0}
TRANSFORMERS_VERSION: ${TRANSFORMERS_VERSION:-5.15.1}
ACCELERATE_VERSION: ${ACCELERATE_VERSION:-1.14.0}
HF_HUB_VERSION: ${HF_HUB_VERSION:-1.28.0}
- image: mike-ai/flux-worker:local
- container_name: mike-ai-flux-worker
+ image: mike-ai/image-worker:local
+ container_name: mike-ai-image-worker
restart: "no"
profiles: [image]
labels:
- com.mike-ai.image-worker: flux
+ com.mike-ai.image-worker: image
gpus: all
read_only: true
tmpfs: ["/tmp:size=1g,mode=1777"]
volumes:
- - "${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}:/models/FLUX.2-klein-4B:ro"
+ - "${Z_IMAGE_MODEL_DIR:-/data/models/Z-Image-Turbo}:/models/Z-Image-Turbo:ro"
- router-images:/data/images
environment:
NVIDIA_VISIBLE_DEVICES: ${IMAGE_GPU_DEVICES:-1}
NVIDIA_DRIVER_CAPABILITIES: compute,utility
WORKER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
- FLUX_MODEL_DIR: /models/FLUX.2-klein-4B
+ Z_IMAGE_MODEL_DIR: /models/Z-Image-Turbo
IMAGE_DIR: /data/images
networks: [inference]
security_opt: ["no-new-privileges:true"]
@@ -750,168 +750,47 @@ services:
retries: 12
start_period: 10s
- open-webui:
- build:
- context: .
- dockerfile: platform/openwebui/Dockerfile
- image: ${OPENWEBUI_IMAGE:-mike-ai/openwebui:main-01f4282-agent-loop-v9}
- container_name: mike-ai-open-webui
+ llama-dashboard:
+ build: ./platform/llama-dashboard
+ image: mike-ai/llama-dashboard:local
+ container_name: mike-ai-llama-dashboard
restart: unless-stopped
- labels:
- # SQLite is quiesced briefly while the scheduled data backup is created.
- docker-volume-backup.stop-during-backup: "true"
- volumes:
- - open-webui-data:/app/backend/data
- # Upstream-supported static customization hooks. Keeping these files in
- # the repository makes the global dark theme reproducible and update-safe.
- - ./platform/openwebui/theme/custom.css:/app/build/static/custom.css:ro
- - ./platform/openwebui/theme/loader.js:/app/build/static/loader.js:ro
- - ./platform/openwebui/theme/midnight-aurora.svg:/app/build/static/midnight-aurora.svg:ro
- - ./platform/openwebui/theme/tool-status:/app/build/static/tool-status:ro
- environment:
- WEBUI_SECRET_KEY: "${WEBUI_SECRET_KEY:?WEBUI_SECRET_KEY is required}"
- DEFAULT_MODELS: mikeai-medium
- OLLAMA_BASE_URL: ""
- OPENAI_API_BASE_URLS: http://router:8081/v1
- OPENAI_API_KEYS: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}"
- # Open WebUI uses the router's OpenAI-compatible image endpoint. The
- # router performs the exclusive RTX-5080 hot swap and restores the
- # previously active Qwen profile after every image.
- ENABLE_IMAGE_GENERATION: "true"
- IMAGE_GENERATION_ENGINE: openai
- # OpenWebUI v0.9.x exposes a fixed OpenAI image-model dropdown. The
- # router accepts this compatibility alias and still executes local
- # FLUX.2 Klein; no request is sent to OpenAI.
- IMAGE_GENERATION_MODEL: gpt-image-1
- # The local router can return embedded image data. Force that mode so
- # OpenWebUI does not reject the router's private Docker/LAN URL through
- # its correct SSRF protection.
- IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN: "^$"
- IMAGES_OPENAI_API_BASE_URL: http://router:8081/v1
- IMAGES_OPENAI_API_KEY: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}"
- IMAGE_SIZE: 1024x1024
- IMAGE_STEPS: "4"
- AUDIO_TTS_ENGINE: openai
- AUDIO_TTS_OPENAI_API_BASE_URL: http://router:8081/v1
- AUDIO_TTS_OPENAI_API_KEY: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}"
- AUDIO_TTS_MODEL: piper
- AUDIO_TTS_VOICE: alloy
- ENABLE_SIGNUP: ${OPENWEBUI_ENABLE_SIGNUP:-false}
- ENABLE_FOLLOW_UP_GENERATION: ${OPENWEBUI_ENABLE_FOLLOW_UP_GENERATION:-false}
- # The derived image reserves the last round for a tool-free synthesis.
- # Forty executions permit real multi-domain agent work. Exact-repeat,
- # per-tool and total-execution limits in the derived image stop loops.
- # Leave continuation headroom after the execution middleware budget:
- # one additional model turn is required to synthesize the visible answer.
- CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS: "48"
- USER_AGENT: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
- DO_NOT_TRACK: "true"
- SCARF_NO_ANALYTICS: "true"
- dns: ["${AI_DNS:-1.1.1.1}"]
- networks: [frontend, tools]
- depends_on:
- wireguard-gateway:
- condition: service_healthy
- router:
- condition: service_healthy
- security_opt: ["no-new-privileges:true"]
-
- hermes:
- build:
- context: .
- dockerfile: platform/hermes/Dockerfile
- image: ${HERMES_IMAGE:-mike-ai/hermes-agent:0.20.5-mcpfix1}
- container_name: mike-ai-hermes
- restart: unless-stopped
- command: [/usr/local/bin/start-hermes-managed]
- env_file:
- - /data/hermes/.env
- volumes:
- - /data/hermes:/opt/data
- - /data/hermes/workspace:/workspace
- - ./platform/hermes/start-hermes-managed.sh:/usr/local/bin/start-hermes-managed:ro
- - ./platform/hermes/patch-api-mcp-refresh.py:/usr/local/lib/mike-ai/patch-api-mcp-refresh.py:ro
- environment:
- HERMES_HOME: /opt/data
- dns: ["${AI_DNS:-1.1.1.1}"]
- networks: [frontend, tools, tools-egress]
- depends_on:
- wireguard-gateway:
- condition: service_healthy
- router:
- condition: service_healthy
- security_opt: ["no-new-privileges:true"]
- healthcheck:
- test: [CMD, curl, -fsS, "http://127.0.0.1:8642/health"]
- interval: 15s
- timeout: 5s
- retries: 20
- start_period: 45s
-
- # Optional, fully removable community chat surface. Chat execution goes
- # through the existing Hermes gateway. Upstream's container entrypoint
- # requires a writable Hermes home for its ownership/init checks; UI-only
- # state still remains on a separate bind mount for easy removal.
- hermes-webui:
- image: ${HERMES_WEBUI_IMAGE:-mike-ai/hermes-webui:0.52.113-hermes-source-v1}
- container_name: mike-ai-hermes-webui
- restart: unless-stopped
- profiles: [hermes-webui]
- env_file:
- - /data/hermes-webui/.env
- volumes:
- - /data/hermes:/home/hermeswebui/.hermes
- - /data/hermes-webui/state:/state
- - /data/hermes-webui/hermes-agent:/home/hermeswebui/.hermes/hermes-agent:ro
- - /data/hermes/workspace:/workspace
- environment:
- HERMES_HOME: /home/hermeswebui/.hermes
- HERMES_WEBUI_STATE_DIR: /state
- HERMES_WEBUI_HOST: 0.0.0.0
- HERMES_WEBUI_PORT: "8787"
- HERMES_WEBUI_CHAT_BACKEND: gateway
- HERMES_WEBUI_GATEWAY_BASE_URL: http://hermes:8642
- HERMES_API_URL: http://hermes:8642
- HERMES_WEBUI_AGENT_DIR: /home/hermeswebui/.hermes/hermes-agent
- HERMES_WEBUI_GATEWAY_USE_RUNS_API: "true"
- HERMES_SKIP_CHMOD: "1"
- WANTED_UID: "10000"
- WANTED_GID: "10000"
- networks: [frontend]
- depends_on:
- hermes:
- condition: service_healthy
- security_opt: ["no-new-privileges:true"]
- healthcheck:
- test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/health', timeout=3)"]
- interval: 15s
- timeout: 5s
- retries: 20
- start_period: 45s
-
- # Persistent VPN listener for the optional WebUI. Sharing the existing
- # WireGuard network namespace avoids recreating the remote-access gateway
- # merely to add one listener.
- hermes-webui-vpn-proxy:
- image: mike-ai/wireguard-gateway:local
- container_name: mike-ai-hermes-webui-vpn-proxy
- restart: unless-stopped
- profiles: [hermes-webui]
network_mode: "service:wireguard-gateway"
- entrypoint: [socat]
- command:
- - TCP-LISTEN:8787,bind=192.168.1.212,reuseaddr,fork
- - TCP:hermes-webui:8787
+ gpus: all
read_only: true
tmpfs:
- - /tmp:size=4m,mode=1777
- cap_drop: [ALL]
- security_opt: ["no-new-privileges:true"]
+ - /tmp:size=16m,mode=1777
+ volumes:
+ - /proc:/host/proc:ro
+ - /data:/host/data:ro
+ - /data/models:/host/models:ro
+ - /data/llama-dashboard:/var/lib/llama-dashboard
+ environment:
+ DASHBOARD_HOST: 0.0.0.0
+ DASHBOARD_PORT: "8099"
+ ROUTER_URL: http://router:8081
+ ROUTER_API_KEY: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}"
+ HOST_PROC: /host/proc
+ HOST_DATA: /host/data
+ HOST_MODELS: /host/models
+ DASHBOARD_HISTORY_DB: /var/lib/llama-dashboard/history.sqlite3
+ DASHBOARD_HISTORY_INTERVAL: "15"
+ DASHBOARD_DETAIL_RETENTION_DAYS: "21"
+ NVIDIA_VISIBLE_DEVICES: all
+ NVIDIA_DRIVER_CAPABILITIES: compute,utility
depends_on:
wireguard-gateway:
condition: service_healthy
- hermes-webui:
+ router:
condition: service_healthy
+ security_opt: ["no-new-privileges:true"]
+ cap_drop: [ALL]
+ healthcheck:
+ test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8099/health', timeout=2)"]
+ interval: 10s
+ timeout: 3s
+ retries: 12
+ start_period: 10s
backup:
image: ${BACKUP_IMAGE:-offen/docker-volume-backup@sha256:19102d8e59eb1d598cf8c647c2b21100abaadc5a1c808ac643fa612e323c3013}
@@ -954,7 +833,6 @@ networks:
name: mike-ai-tools-egress
volumes:
- open-webui-data:
piper-data:
router-state:
router-images:
diff --git a/config/github-mcp.env.example b/config/github-mcp.env.example
deleted file mode 100644
index fb1fe5b..0000000
--- a/config/github-mcp.env.example
+++ /dev/null
@@ -1,9 +0,0 @@
-# Create a dedicated fine-grained GitHub token. Grant repository contents and
-# metadata read-only; do not grant write permissions. Keep the real file only
-# at /etc/mike-ai/github-mcp.env with mode 0600.
-GITHUB_PERSONAL_ACCESS_TOKEN=
-
-# Four deliberately bounded repository-reading tools. Do not replace this
-# with the broad default toolsets unless the resulting schemas were reviewed.
-GITHUB_TOOLS=search_repositories,get_repository_tree,get_file_contents,search_code
-GITHUB_READ_ONLY=1
diff --git a/config/install.env.example b/config/install.env.example
index 71ae05b..6ad7d4d 100644
--- a/config/install.env.example
+++ b/config/install.env.example
@@ -3,7 +3,7 @@
AI_HOSTNAME=ki-host
ADMIN_USER=mike
-MODEL_DIR=/srv/mike-ai/models
+MODEL_DIR=/data/models
# Installing a new NVIDIA driver can require one reboot. In that case this
# installer exits with code 20 (NVIDIA) or 21 (stable NIC rename); rerun the
@@ -15,8 +15,8 @@ NVIDIA_DRIVER_BRANCH=
NVIDIA_MIN_DRIVER_MAJOR=570
TEXT_GPU_DEVICES=0
SECONDARY_GPU_DEVICES=1
-IMAGE_GPU_DEVICES=0
-FLUX_MODEL_DIR=/data/models/FLUX.2-klein-4B
+IMAGE_GPU_DEVICES=1
+Z_IMAGE_MODEL_DIR=/data/models/Z-Image-Turbo
# Headless remote reachability. Firmware power-loss recovery is configured
# separately once at the physical machine.
@@ -88,13 +88,6 @@ UNCENSORED_MTP_MAX=2
EXPERIMENTAL_CONTEXT=76800
LLAMA_THREADS=6
LLAMA_THREADS_BATCH=6
-OPENWEBUI_IMAGE=mike-ai/openwebui:main-01f4282-tool-final-v3
-OPENWEBUI_ENABLE_SIGNUP=false
-OPENWEBUI_ENABLE_FOLLOW_UP_GENERATION=false
-# Removable community Hermes chat surface. Set false to keep only the official
-# Hermes Dashboard/API and native clients.
-INSTALL_HERMES_WEBUI=true
-HERMES_WEBUI_IMAGE=mike-ai/hermes-webui:0.52.113-hermes-source-v1
PIPER_TTS_VERSION=1.6.0
PIPER_VOICE=de_DE-thorsten-high
XTTS_IMAGE=ghcr.io/coqui-ai/xtts-streaming-server:latest-cuda121@sha256:f7fb3b1f9d4bc88af94da1b5959d8002f1e0b003c97557164034eb8a29f01b90
diff --git a/config/mcp-extensions/himalaya.json b/config/mcp-extensions/himalaya.json
deleted file mode 100644
index e0ffb24..0000000
--- a/config/mcp-extensions/himalaya.json
+++ /dev/null
@@ -1,47 +0,0 @@
-{
- "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/mcp-registry.json b/config/mcp-registry.json
deleted file mode 100644
index 4fb7b0f..0000000
--- a/config/mcp-registry.json
+++ /dev/null
@@ -1,186 +0,0 @@
-{
- "version": 1,
- "servers": [
- {
- "id": "mcphub-all",
- "hermes_id": "mcphub",
- "name": "MCPHub",
- "description": "Zentraler Zugang zu allen auf Unraid aktivierten MCP-Servern. Neue Server erscheinen nach einem MCP-Reload ohne Änderung der Hermes-Konfiguration.",
- "url": "http://192.168.1.2:8787/mcp/hermes",
- "clients": ["hermes"],
- "env_file": "/etc/mike-ai/mcphub-client.env",
- "key_env": "MCPHUB_BEARER_TOKEN",
- "auth_type": "bearer",
- "timeout": 900
- },
- {
- "id": "mcphub-admin-local",
- "name": "MCPHub Administration",
- "description": "Installiert und verwaltet HTTP-, npm- und Python-MCPs direkt über die offizielle MCPHub-API. Kein Unraid-Terminal erforderlich.",
- "url": "http://192.168.1.2:8787/mcp/mcphub-admin",
- "clients": [],
- "timeout": 300,
- "hub": {
- "type": "stdio",
- "command": "python3",
- "args": ["/opt/casaderoll/mcps/mcphub_admin_mcp.py"],
- "env": {
- "MCPHUB_API_URL": "http://127.0.0.1:3000/api",
- "MCPHUB_API_TOKEN_FILE": "/app/data/client-token",
- "MCPHUB_CLIENT_GROUP": "hermes"
- },
- "enabled": true
- }
- },
- {
- "id": "athena-operator-local",
- "hermes_id": "athena-operator",
- "name": "Athena Operator",
- "description": "Zentrale administrative Schnittstelle für Athena. Beginne mit athena_operator_inspect(subject=guide). Verwaltet Docker, Modelle, MCPs, Git und Backups; Stromversorgung und Remote-Erreichbarkeit bleiben blockiert.",
- "url": "http://192.168.1.2:8787/mcp/athena-operator",
- "clients": ["openwebui"],
- "env_file": "/etc/mike-ai/mcphub-client.env",
- "key_env": "MCPHUB_BEARER_TOKEN",
- "auth_type": "bearer",
- "timeout": 900,
- "hub": {
- "type": "streamable-http",
- "url": "http://192.168.1.212:8202/mcp",
- "owner": "admin",
- "enabled": true
- }
- },
- {
- "id": "github-local",
- "hermes_id": "github",
- "name": "GitHub (offiziell, read-only)",
- "description": "Repository-Suche, echte Datei-Inhalte und gezielte Code-Suche. Keine rekursiven Komplettbäume oder Schreibzugriffe.",
- "url": "http://192.168.1.2:8787/mcp/github",
- "clients": ["openwebui"],
- "env_file": "/etc/mike-ai/mcphub-client.env",
- "key_env": "MCPHUB_BEARER_TOKEN",
- "auth_type": "bearer",
- "timeout": 300,
- "functions": "github-search_repositories,github-get_file_contents,github-search_code",
- "hub": {
- "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
- }
- },
- {
- "id": "homeassistant-local",
- "hermes_id": "homeassistant-admin",
- "name": "Home Assistant",
- "description": "Entitäten, Zustände, Historie, Automationen, Dashboards, Diagnose und freigegebene YAML-Dateien. Änderungen nur auf ausdrücklichen Auftrag.",
- "url": "http://192.168.1.2:8787/mcp/homeassistant",
- "clients": ["openwebui"],
- "env_file": "/etc/mike-ai/mcphub-client.env",
- "key_env": "MCPHUB_BEARER_TOKEN",
- "auth_type": "bearer",
- "timeout": 300,
- "hub": {
- "type": "streamable-http",
- "secret_file": "homeassistant.env",
- "url": "${HASS_URL}/api/hass_mcp",
- "headers": {"Authorization": "Bearer ${HASS_TOKEN}"},
- "owner": "admin",
- "enabled": true
- }
- },
- {
- "id": "arr-local",
- "hermes_id": "arr",
- "name": "Sonarr und Radarr",
- "description": "Serien, Filme, Queue, Indexer-Suche und kompakte Medieninventare. Für Codec-Fragen radarr_movie_codec_inventory verwenden; keine rohen API-Requests oder Dateisystem-Scans.",
- "url": "http://192.168.1.2:8787/mcp/arr",
- "clients": ["openwebui"],
- "env_file": "/etc/mike-ai/mcphub-client.env",
- "key_env": "MCPHUB_BEARER_TOKEN",
- "auth_type": "bearer",
- "timeout": 600,
- "hub": {
- "type": "stdio",
- "command": "/usr/local/bin/run-with-env",
- "args": ["/run/secrets/mcphub/arr.env", "--", "arr-mcp", "--transport", "stdio", "--auth-type", "none"],
- "enabled": true
- }
- },
- {
- "id": "navidrome-local",
- "hermes_id": "navidrome",
- "name": "Navidrome",
- "description": "Persönliche Musikbibliothek: Titel, Alben, Künstler, Playlists, Favoriten und Hörverlauf.",
- "url": "http://192.168.1.2:8787/mcp/navidrome",
- "clients": ["openwebui"],
- "env_file": "/etc/mike-ai/mcphub-client.env",
- "key_env": "MCPHUB_BEARER_TOKEN",
- "auth_type": "bearer",
- "timeout": 300,
- "hub": {
- "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
- }
- },
- {
- "id": "mua",
- "hermes_id": "unraid",
- "name": "MUA (Unraid-Verwaltung)",
- "description": "Unraid-Verwaltung über das vorhandene MUA-Plugin. Zustand zuerst lesen, engste Änderung ausführen, danach verifizieren.",
- "url": "http://192.168.1.2:8787/mcp/unraid",
- "key_env": "MCPHUB_BEARER_TOKEN",
- "env_file": "/etc/mike-ai/mcphub-client.env",
- "auth_type": "bearer",
- "clients": ["openwebui"],
- "timeout": 900,
- "hub": {
- "type": "streamable-http",
- "secret_file": "mua.env",
- "url": "${MUA_MCP_URL}",
- "headers": {"Authorization": "Bearer ${MUA_MCP_BEARER_TOKEN}"},
- "owner": "admin",
- "enabled": true
- }
- },
- {
- "id": "fritzbox-local",
- "hermes_id": "fritzbox",
- "name": "FRITZ!Box",
- "description": "FRITZ!Box-Status, WAN/Glasfaser, Netzwerkgeräte, WLAN und Telefonie. Zuerst die aktive WAN-Verbindung ermitteln; Änderungen nur auf ausdrücklichen Auftrag.",
- "url": "http://192.168.1.2:8787/mcp/fritzbox",
- "key_env": "MCPHUB_BEARER_TOKEN",
- "env_file": "/etc/mike-ai/mcphub-client.env",
- "auth_type": "bearer",
- "clients": ["openwebui"],
- "timeout": 300,
- "tool_include": [
- "fritzbox-list_services",
- "fritzbox-list_actions",
- "fritzbox-describe_action",
- "fritzbox-call_action"
- ],
- "hub": {
- "type": "stdio",
- "command": "/usr/local/bin/run-with-env",
- "args": ["/run/secrets/mcphub/fritzbox.env", "--", "/opt/casaderoll/fritz-mcp"],
- "enabled": true
- }
- },
- {
- "id": "mua-readonly-local",
- "name": "MUA (Unraid read-only)",
- "description": "Automatisch nutzbare Unraid-Diagnose für Container, Logs, System, Storage, Shares und Medieninventare. Keine Änderungen oder freie Shell.",
- "url": "http://192.168.1.2:8787/mcp/unraid",
- "key_env": "MCPHUB_BEARER_TOKEN",
- "env_file": "/etc/mike-ai/mcphub-client.env",
- "auth_type": "bearer",
- "clients": ["openwebui"],
- "timeout": 900,
- "functions": "unraid-unraid_docker_list,unraid-unraid_docker_inspect,unraid-unraid_docker_logs,unraid-unraid_docker_analyze_logs,unraid-unraid_docker_processes,unraid-unraid_docker_stats,unraid-unraid_docker_info,unraid-unraid_docker_update_status,unraid-unraid_ca_search,unraid-unraid_network_inventory,unraid-unraid_network_list,unraid-unraid_network_inspect,unraid-unraid_network_host_state,unraid-unraid_network_audit_tcp,unraid-unraid_network_lan_probe,unraid-unraid_system_health,unraid-unraid_storage_status,unraid-unraid_disk_health,unraid-unraid_notifications_list,unraid-unraid_shares_list,unraid-unraid_share_inspect,unraid-unraid_files_inventory,unraid-unraid_system_connection_test,unraid-unraid_system_shell_readonly"
- }
- ]
-}
diff --git a/config/mcphub-client.env.example b/config/mcphub-client.env.example
deleted file mode 100644
index 85fa705..0000000
--- a/config/mcphub-client.env.example
+++ /dev/null
@@ -1,4 +0,0 @@
-# Shared bearer key generated by platform/mcphub/configure-settings.py.
-# The live value is stored in MCPHub appdata/client-token and copied only to
-# /etc/mike-ai/mcphub-client.env on clients.
-MCPHUB_BEARER_TOKEN=REPLACE_WITH_LOCAL_MCPHUB_CLIENT_TOKEN
diff --git a/config/mua-mcp.env.example b/config/mua-mcp.env.example
deleted file mode 100644
index 5a6853e..0000000
--- a/config/mua-mcp.env.example
+++ /dev/null
@@ -1,4 +0,0 @@
-# Root-only auf Athena unter /etc/mike-ai/mua-mcp.env ablegen (Modus 0600).
-# Der echte Bearer-Token gehört niemals ins Git-Repository.
-MUA_MCP_URL=http://192.168.1.2:3002/mcp
-MUA_MCP_BEARER_TOKEN=REPLACE_WITH_MUA_BEARER_TOKEN
diff --git a/config/navidrome-mcp.env.example b/config/navidrome-mcp.env.example
deleted file mode 100644
index 169168d..0000000
--- a/config/navidrome-mcp.env.example
+++ /dev/null
@@ -1,8 +0,0 @@
-# Root-only deployment secret. Copy to /etc/mike-ai/navidrome-mcp.env,
-# replace the two placeholders and chmod 600. Never commit the real file.
-NAVIDROME_URL=http://192.168.1.2:4533
-NAVIDROME_USERNAME=REPLACE_WITH_DEDICATED_USER
-NAVIDROME_PASSWORD=REPLACE_WITH_DEDICATED_PASSWORD
-# Optional: enables seven public Last.fm discovery/recommendation tools.
-# The Last.fm shared secret is not required and must not be stored here.
-LASTFM_API_KEY=
diff --git a/config/unraid-templates/my-ARR-MCP.xml b/config/unraid-templates/my-ARR-MCP.xml
deleted file mode 100644
index 23b9aa3..0000000
--- a/config/unraid-templates/my-ARR-MCP.xml
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
- ARR-MCP
- mike-ai/arr-mcp:1.1.0
- bridge
- sh
- false
- https://git.casaderoll.de/michael/AI-Profile-Router
- https://git.casaderoll.de/michael/AI-Profile-Router/src/branch/main/services/arr-mcp/README.md
- Eigenständiger Sonarr- und Radarr-MCP für Hermes und andere MCP-Clients. Das lokale Image wird reproduzierbar aus dem privaten Git gebaut. Der Container nutzt kompakte, modellfreundliche Werkzeuge, ein begrenztes Codec-Inventar und ticketgebundene Sonarr-Schreibaktionen. Konfiguration und Schlüssel liegen ausschließlich in Unraid-Appdata.
- AI:
-
- false
- https://raw.githubusercontent.com/Sonarr/Sonarr/develop/Logo/256.png
- --read-only --cap-drop=ALL --security-opt=no-new-privileges --pids-limit=256 --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m --env-file=/mnt/nvme-storage/appdata/ARR-MCP/arr-mcp.env
-
-
-
-
-
- Das lokale Image mike-ai/arr-mcp:1.1.0 muss vorher mit services/arr-mcp/install-on-unraid.sh gebaut worden sein.
- 8207
-
diff --git a/config/unraid-templates/my-Hermes-Agent-Official.xml b/config/unraid-templates/my-Hermes-Agent-Official.xml
deleted file mode 100644
index f96e309..0000000
--- a/config/unraid-templates/my-Hermes-Agent-Official.xml
+++ /dev/null
@@ -1,50 +0,0 @@
-
-
- Hermes-Agent
- nousresearch/hermes-agent:latest
- https://hub.docker.com/r/nousresearch/hermes-agent
- bridge
-
- bash
- false
- https://github.com/NousResearch/hermes-agent/issues
- https://hermes-agent.nousresearch.com/
- https://github.com/NousResearch/hermes-agent/blob/main/website/docs/user-guide/docker.md
- Offizieller Hermes Agent von Nous Research als persistenter Gateway- und Dashboard-Dienst. Dieses Unraid-Template verwendet unverändert das offizielle Image nousresearch/hermes-agent:latest; es enthält keinen Fork und keine eigene Build-Schicht. Sämtliche Konfigurationen, Sitzungen, Skills, Erinnerungen und Zugangsdaten liegen dauerhaft unter /opt/data im Unraid-Appdata.
-
-Vor dem ersten Start müssen sichere Werte für API-Key, Dashboard-Passwort und Dashboard-Secret eingetragen werden. Der Container startet den offiziellen Befehl gateway run.
-
-Dokumentation: https://hermes-agent.nousresearch.com/docs/user-guide/docker
- AI:
- http://[IP]:[PORT:9119]/
- false
- https://raw.githubusercontent.com/NousResearch/hermes-agent/main/web/public/favicon.ico
- --shm-size=1g
- gateway run
-
-
-
-
-
-
- /mnt/nvme-storage/appdata/Hermes-Agent
-
- 8642
- 9119
-
- 10000
- 10000
-
- true
- 0.0.0.0
- 8642
-
- *
-
- 1
- 0.0.0.0
- 9119
- admin
-
-
-
diff --git a/config/unraid-templates/my-MCPHub.xml b/config/unraid-templates/my-MCPHub.xml
deleted file mode 100644
index 58336e9..0000000
--- a/config/unraid-templates/my-MCPHub.xml
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
- MCPHub
- casaderoll/mcphub:1.2.5
- https://hub.docker.com/r/samanhappy/mcphub
- bridge
-
-
- sh
- false
- 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-, 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
-Wenn kein Admin-Passwort eingetragen wird, erzeugt MCPHub eines und schreibt es ins Containerprotokoll.
- Tools:Utilities AI:
- http://[IP]:[PORT:3000]/
-
- https://github.com/samanhappy.png
- --restart=unless-stopped
-
-
- 0
-
-
-
- 8787
- /mnt/nvme-storage/appdata/MCPHub
- /mnt/nvme-storage/appdata/MCPHub/secrets
-
- production
- 120000
- Europe/Berlin
-
-
diff --git a/dev/test_hass_mcp_yaml_guard.py b/dev/test_hass_mcp_yaml_guard.py
deleted file mode 100644
index 48ff617..0000000
--- a/dev/test_hass_mcp_yaml_guard.py
+++ /dev/null
@@ -1,91 +0,0 @@
-#!/usr/bin/env python3
-"""Dependency-free safety checks for the hass_mcp YAML overlay."""
-
-from __future__ import annotations
-
-import importlib.util
-import pathlib
-import sys
-import types
-
-
-def module(name: str, **attributes):
- value = types.ModuleType(name)
- for key, item in attributes.items():
- setattr(value, key, item)
- sys.modules[name] = value
- return value
-
-
-class ToolError(Exception):
- pass
-
-
-def decorator(**_kwargs):
- return lambda function: function
-
-
-module("homeassistant")
-module("homeassistant.core", HomeAssistant=object)
-module("homeassistant.util", slugify=lambda value: str(value).lower().replace(" ", "_"))
-module("guarded")
-module("guarded.tools")
-module("guarded.identity", user_context=lambda: None)
-module("guarded.protocol", ToolError=ToolError, internal_error=lambda message, error: RuntimeError(f"{message}: {error}"))
-module(
- "guarded.registry",
- LIMIT_FIELD={"type": "integer"},
- OFFSET_FIELD={"type": "integer"},
- paginate=lambda items, limit, offset: {"items": items[offset : offset + limit]},
- schema=lambda **kwargs: kwargs,
- tool=decorator,
-)
-
-source = pathlib.Path(__file__).parents[1] / "platform/mcp/patches/hass_mcp/yaml_config.py"
-spec = importlib.util.spec_from_file_location("guarded.tools.yaml_config", source)
-assert spec and spec.loader
-guard = importlib.util.module_from_spec(spec)
-sys.modules[spec.name] = guard
-spec.loader.exec_module(guard)
-
-assert set(guard._KINDS) == {"automation", "script", "scene", "configuration"}
-assert all("secret" not in filename for filename, _, _ in guard._KINDS.values())
-assert guard._redact_line("api_key: abc") == "api_key: "
-assert guard._redact_line("token: abc") == "token: "
-assert guard._redact_line("value: !secret private_name") == "value: !secret "
-assert guard._redact_line("alias: Safe automation") == "alias: Safe automation"
-
-for sensitive in ("token: abc", "password: abc", "value: !secret private_name"):
- try:
- guard._reject_sensitive_replacement(sensitive)
- except ToolError:
- pass
- else:
- raise AssertionError(f"sensitive replacement was accepted: {sensitive}")
-
-change = guard._change_record("automation", "update", "before", {"id": "demo"})
-preview = guard._preview(change)
-assert preview["changed"] is False
-assert preview["confirmation_required"] is True
-guard._consume_ticket(change, preview["approval_ticket"])
-try:
- guard._consume_ticket(change, preview["approval_ticket"])
-except ToolError:
- pass
-else:
- raise AssertionError("one-time approval ticket was reusable")
-
-other = guard._change_record("automation", "update", "different", {"id": "demo"})
-ticket = guard._preview(change)["approval_ticket"]
-try:
- guard._consume_ticket(other, ticket)
-except ToolError:
- pass
-else:
- raise AssertionError("ticket accepted a different current file fingerprint")
-
-diff = guard._source_diff("automations.yaml", "a\nb\n", "a\nc\n")
-assert any("-b" in line for line in diff)
-assert any("+c" in line for line in diff)
-
-print("hass_mcp_yaml_guard_tests=ok")
diff --git a/dev/test_mcp_registry.py b/dev/test_mcp_registry.py
deleted file mode 100644
index 2158753..0000000
--- a/dev/test_mcp_registry.py
+++ /dev/null
@@ -1,106 +0,0 @@
-#!/usr/bin/env python3
-"""Tests for the single declarative MCP client registry."""
-
-from __future__ import annotations
-
-import importlib.util
-import json
-import sqlite3
-import tempfile
-import unittest
-from pathlib import Path
-
-
-ROOT = Path(__file__).parents[1]
-SOURCE = ROOT / "platform/mcp/sync-clients.py"
-
-
-def load_module():
- spec = importlib.util.spec_from_file_location("sync_clients", SOURCE)
- module = importlib.util.module_from_spec(spec)
- assert spec.loader
- spec.loader.exec_module(module)
- return module
-
-
-class RegistryTests(unittest.TestCase):
- def setUp(self):
- self.module = load_module()
- self.temp = tempfile.TemporaryDirectory()
- self.root = Path(self.temp.name)
- self.registry = self.root / "registry.json"
- self.registry.write_text(json.dumps({"version": 1, "servers": [{
- "id": "one", "name": "One", "description": "Test", "url": "http://one/mcp",
- "clients": ["hermes", "openwebui"], "timeout": 123,
- }]}))
-
- def tearDown(self):
- self.temp.cleanup()
-
- def test_same_registry_generates_both_clients(self):
- items = self.module.active(self.registry, "hermes")
- block = self.module.hermes_block(items)
- self.assertIn("one:", block)
- db = self.root / "webui.db"
- con = sqlite3.connect(db)
- con.execute("create table config (key text primary key, value text, updated_at integer)")
- con.commit(); con.close()
- self.module.update_openwebui(db, self.module.active(self.registry, "openwebui"))
- con = sqlite3.connect(db)
- value = json.loads(con.execute("select value from config where key='tool_server.connections'").fetchone()[0])
- con.close()
- self.assertEqual(value[0]["info"]["id"], "one")
-
- def test_old_platform_context_registration_is_removed(self):
- db = self.root / "webui.db"
- con = sqlite3.connect(db)
- con.execute("create table config (key text primary key, value text, updated_at integer)")
- con.execute("insert into config values (?,?,?)", ("tool_server.connections", json.dumps([
- {"info": {"id": "athena-platform"}, "url": "http://old/mcp"},
- {"info": {"id": "unmanaged"}, "url": "http://keep/mcp"},
- ]), 0))
- con.commit(); con.close()
- self.module.update_openwebui(db, self.module.active(self.registry, "openwebui"))
- con = sqlite3.connect(db)
- ids = [item["info"]["id"] for item in json.loads(con.execute("select value from config where key='tool_server.connections'").fetchone()[0])]
- con.close()
- self.assertEqual(ids, ["unmanaged", "one"])
-
- def test_production_registry_has_unique_ids_and_fritzbox(self):
- document = json.loads((ROOT / "config/mcp-registry.json").read_text())
- ids = [item["id"] for item in document["servers"]]
- hermes_ids = [
- item.get("hermes_id", item["id"])
- for item in document["servers"] if "hermes" in item.get("clients", [])
- ]
- self.assertEqual(len(ids), len(set(ids)))
- self.assertEqual(len(hermes_ids), len(set(hermes_ids)))
- fritz = next(item for item in document["servers"] if item["id"] == "fritzbox-local")
- self.assertEqual(fritz["hub"]["type"], "stdio")
- self.assertIn("fritz-mcp", fritz["hub"]["args"][-1])
- self.assertEqual(len(fritz["tool_include"]), 4)
-
- def test_hermes_tool_filter_is_generated(self):
- item = {
- "id": "wide", "name": "Wide", "description": "Test",
- "url": "http://wide/mcp", "clients": ["hermes"],
- "tool_include": ["list", "describe", "call"],
- }
- block = self.module.hermes_block([item])
- self.assertIn(" tools:\n include:", block)
- self.assertIn(' - "describe"', block)
-
- def test_raw_mcphub_token_can_replace_host_specific_env_file(self):
- item = {
- "id": "hub", "name": "Hub", "description": "Test",
- "url": "http://hub/mcp/test", "clients": ["hermes"],
- "env_file": "/missing/client.env",
- "key_env": "MCPHUB_BEARER_TOKEN",
- }
- self.module.CLIENT_TOKEN = "local-token"
- self.assertTrue(self.module.enabled(item))
- self.assertEqual(self.module.resolved(item), ("http://hub/mcp/test", "local-token"))
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/dev/test_mcphub_deploy_extension.py b/dev/test_mcphub_deploy_extension.py
deleted file mode 100644
index 4bb718e..0000000
--- a/dev/test_mcphub_deploy_extension.py
+++ /dev/null
@@ -1,91 +0,0 @@
-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",
- "skip_api": True,
- }
- 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/dev/test_mcphub_git_installer.py b/dev/test_mcphub_git_installer.py
deleted file mode 100644
index a3896cb..0000000
--- a/dev/test_mcphub_git_installer.py
+++ /dev/null
@@ -1,131 +0,0 @@
-from __future__ import annotations
-
-import importlib.util
-import json
-import pathlib
-import sys
-import tempfile
-import unittest
-from unittest import mock
-
-
-SOURCE = pathlib.Path(__file__).parents[1] / "platform/mcphub/mcphub_git_installer.py"
-SPEC = importlib.util.spec_from_file_location("mcphub_git_installer", SOURCE)
-assert SPEC and SPEC.loader
-installer = importlib.util.module_from_spec(SPEC)
-sys.modules[SPEC.name] = installer
-SPEC.loader.exec_module(installer)
-
-
-class GitInstallerTest(unittest.TestCase):
- def setUp(self) -> None:
- self.temp = tempfile.TemporaryDirectory()
- self.root = pathlib.Path(self.temp.name)
- self.appdata = self.root / "appdata"
- self.secrets = self.root / "secrets"
- self.secrets.mkdir()
- self.commits = iter(["a" * 40, "b" * 40, "c" * 40])
-
- def tearDown(self) -> None:
- self.temp.cleanup()
-
- def spec(self, **values: object):
- data = {
- "name": "example",
- "repository": "https://github.com/example/mcp",
- "ref": "main",
- "runtime": "python",
- "entrypoint": "example-mcp",
- "arguments": ("--stdio",),
- "required_env": ("EXAMPLE_TOKEN",),
- }
- data.update(values)
- return installer.GitInstallSpec(**data)
-
- def clone(self, _spec, destination: pathlib.Path) -> str:
- destination.mkdir(parents=True, exist_ok=True)
- (destination / "pyproject.toml").write_text("[project]\nname='example'\n")
- return next(self.commits)
-
- @staticmethod
- def clone_same(_spec, destination: pathlib.Path) -> str:
- destination.mkdir(parents=True, exist_ok=True)
- (destination / "pyproject.toml").write_text("[project]\nname='example'\n")
- return "a" * 40
-
- @staticmethod
- def build(_source: pathlib.Path, release: pathlib.Path, _entrypoint: str) -> list[str]:
- release.mkdir(parents=True)
- executable = release / ".venv/bin/example-mcp"
- executable.parent.mkdir(parents=True)
- executable.write_text("ok")
- return [str(executable)]
-
- def test_install_is_disabled_and_reports_only_missing_key_names(self) -> None:
- with mock.patch.object(installer, "_clone", self.clone), mock.patch.object(
- installer, "_python_release", self.build
- ):
- result = installer.prepare_release(self.spec(), self.appdata, self.secrets)
- self.assertFalse(result["config"]["enabled"])
- self.assertFalse(result["credentials_ready"])
- self.assertEqual(result["missing_env"], ["EXAMPLE_TOKEN"])
- self.assertEqual(result["config"]["command"], "/usr/local/bin/run-with-env")
- self.assertEqual(result["config"]["args"][-1], "--stdio")
-
- def test_same_release_reuses_build_without_duplicating_arguments(self) -> None:
- with mock.patch.object(installer, "_clone", self.clone_same), mock.patch.object(
- installer, "_python_release", side_effect=self.build
- ) as build:
- first = installer.prepare_release(self.spec(), self.appdata, self.secrets)
- second = installer.prepare_release(self.spec(), self.appdata, self.secrets)
- self.assertEqual(build.call_count, 1)
- self.assertEqual(first["config"]["args"], second["config"]["args"])
- self.assertEqual(second["config"]["args"].count("--stdio"), 1)
-
- def test_update_and_rollback_preserve_both_releases(self) -> None:
- with mock.patch.object(installer, "_clone", self.clone), mock.patch.object(
- installer, "_python_release", self.build
- ):
- first = installer.prepare_release(self.spec(), self.appdata, self.secrets)
- second = installer.prepare_release(self.spec(ref="v2"), self.appdata, self.secrets)
- self.assertEqual(second["previous_release"], first["release"])
- rolled = installer.rollback_release("example", self.appdata)
- self.assertEqual(rolled["release"], first["release"])
- self.assertEqual(installer.current_release("example", self.appdata, self.secrets)["release"], first["release"])
-
- def test_failed_update_leaves_previous_state_current(self) -> None:
- with mock.patch.object(installer, "_clone", self.clone), mock.patch.object(
- installer, "_python_release", self.build
- ):
- first = installer.prepare_release(self.spec(), self.appdata, self.secrets)
- with mock.patch.object(installer, "_clone", self.clone), mock.patch.object(
- installer, "_python_release", side_effect=installer.GitInstallError("build failed")
- ):
- with self.assertRaises(installer.GitInstallError):
- installer.prepare_release(self.spec(ref="broken"), self.appdata, self.secrets)
- current = installer.current_release("example", self.appdata, self.secrets)
- self.assertEqual(current["release"], first["release"])
-
- def test_registry_updates_only_matching_server(self) -> None:
- registry = self.appdata / "config/mcp-registry.json"
- registry.parent.mkdir(parents=True)
- registry.write_text(json.dumps({"version": 1, "servers": [{"id": "keep", "hermes_id": "keep"}]}))
- result = {
- "name": "example", "repository": "https://github.com/example/mcp.git",
- "requested_ref": "main", "commit": "a" * 40, "release": "a" * 12,
- "required_env": [], "secret_file": None,
- "config": {"type": "stdio", "command": "example", "args": [], "enabled": False},
- }
- installer.update_registry(registry, installer.registry_entry(result, "Example"))
- servers = json.loads(registry.read_text())["servers"]
- self.assertEqual({item["id"] for item in servers}, {"keep", "example-local"})
-
- def test_rejects_non_github_and_escaping_subdirectory(self) -> None:
- with self.assertRaises(installer.GitInstallError):
- installer.normalize_spec(self.spec(repository="https://evil.example/repo"))
- with self.assertRaises(installer.GitInstallError):
- installer.normalize_spec(self.spec(subdirectory="../escape"))
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/dev/test_mcphub_settings.py b/dev/test_mcphub_settings.py
deleted file mode 100644
index c4629e1..0000000
--- a/dev/test_mcphub_settings.py
+++ /dev/null
@@ -1,94 +0,0 @@
-#!/usr/bin/env python3
-"""Regression tests for declarative, update-safe MCPHub settings."""
-
-from __future__ import annotations
-
-import importlib.util
-import json
-import os
-import tempfile
-import unittest
-from pathlib import Path
-
-
-ROOT = Path(__file__).parents[1]
-SOURCE = ROOT / "platform/mcphub/configure-settings.py"
-
-
-def load_module():
- spec = importlib.util.spec_from_file_location("configure_settings", SOURCE)
- module = importlib.util.module_from_spec(spec)
- assert spec.loader
- spec.loader.exec_module(module)
- return module
-
-
-class MCPHubSettingsTests(unittest.TestCase):
- def setUp(self):
- self.module = load_module()
- self.temp = tempfile.TemporaryDirectory()
- self.root = Path(self.temp.name)
- self.secrets = self.root / "secrets"
- self.secrets.mkdir()
- (self.secrets / "remote.env").write_text(
- "REMOTE_URL=http://example.test/mcp\nTOKEN=secret-value\n",
- encoding="utf-8",
- )
- self.registry = self.root / "registry.json"
- self.registry.write_text(json.dumps({
- "version": 1,
- "servers": [
- {
- "id": "remote-local",
- "hermes_id": "remote",
- "hub": {
- "type": "streamable-http",
- "secret_file": "remote.env",
- "url": "${REMOTE_URL}",
- "headers": {"Authorization": "Bearer ${TOKEN}"},
- "enabled": True,
- },
- },
- {"id": "client-only", "url": "http://unused/mcp"},
- ],
- }), encoding="utf-8")
-
- def tearDown(self):
- self.temp.cleanup()
-
- def test_registry_renders_only_hub_servers_and_expands_secrets(self):
- servers = self.module.registry_servers(self.registry, self.secrets, {})
- self.assertEqual(list(servers), ["remote"])
- self.assertEqual(servers["remote"]["url"], "http://example.test/mcp")
- self.assertEqual(
- servers["remote"]["headers"]["Authorization"],
- "Bearer secret-value",
- )
-
- def test_existing_enabled_toggle_survives_reconciliation(self):
- servers = self.module.registry_servers(
- self.registry, self.secrets, {"remote": {"enabled": False}}
- )
- self.assertFalse(servers["remote"]["enabled"])
-
- def test_missing_secret_fails_closed(self):
- os.unlink(self.secrets / "remote.env")
- with self.assertRaises(SystemExit):
- self.module.registry_servers(self.registry, self.secrets, {})
-
- def test_hermes_group_is_recoverable_and_bounded(self):
- settings = {"groups": [{"id": "keep", "name": "other", "servers": []}]}
- self.module.ensure_hermes_group(settings)
- self.module.ensure_hermes_group(settings)
- groups = settings["groups"]
- self.assertEqual(len([group for group in groups if group["name"] == "hermes"]), 1)
- hermes = next(group for group in groups if group["name"] == "hermes")
- fritzbox = next(item for item in hermes["servers"] if item["name"] == "fritzbox")
- self.assertEqual(
- fritzbox["tools"],
- ["list_services", "list_actions", "describe_action", "call_action"],
- )
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/dev/test_openwebui_filters.py b/dev/test_openwebui_filters.py
deleted file mode 100644
index 9f5710f..0000000
--- a/dev/test_openwebui_filters.py
+++ /dev/null
@@ -1,616 +0,0 @@
-#!/usr/bin/env python3
-"""Offline tests for the versioned OpenWebUI filters.
-
-The production container provides pydantic. A tiny local stand-in keeps these
-logic tests dependency-free and prevents test setup from reaching the network.
-"""
-
-from __future__ import annotations
-
-import importlib.util
-import json
-import sys
-import tempfile
-import types
-import unittest
-from pathlib import Path
-
-
-class _BaseModel:
- def __init__(self, **values):
- annotations = {}
- for base in reversed(type(self).__mro__):
- annotations.update(getattr(base, "__annotations__", {}))
- for name in annotations:
- setattr(self, name, values.get(name, getattr(type(self), name, None)))
-
-
-fake_pydantic = types.ModuleType("pydantic")
-fake_pydantic.BaseModel = _BaseModel
-fake_pydantic.Field = lambda default=None, **kwargs: default
-sys.modules.setdefault("pydantic", fake_pydantic)
-
-FILTER_DIR = Path(__file__).parents[1] / "platform" / "openwebui" / "filters"
-ACTION_DIR = Path(__file__).parents[1] / "platform" / "openwebui" / "actions"
-
-
-def _load(name: str):
- spec = importlib.util.spec_from_file_location(name, FILTER_DIR / f"{name}.py")
- module = importlib.util.module_from_spec(spec)
- assert spec.loader is not None
- spec.loader.exec_module(module)
- return module
-
-
-def _load_action(name: str):
- spec = importlib.util.spec_from_file_location(name, ACTION_DIR / f"{name}.py")
- module = importlib.util.module_from_spec(spec)
- assert spec.loader is not None
- spec.loader.exec_module(module)
- return module
-
-
-class StabilityGuardTests(unittest.IsolatedAsyncioTestCase):
- async def asyncSetUp(self):
- self.module = _load("stability_guard")
- self.guard = self.module.Filter()
-
- async def test_large_tool_output_is_bounded(self):
- body = {
- "model": "qwen-fast",
- "messages": [
- {"role": "user", "content": "Prüfe das Log."},
- {"role": "tool", "tool_call_id": "x", "content": "A" * 50000},
- ],
- }
- result = await self.guard.inlet(body)
- content = result["messages"][1]["content"]
- self.assertLessEqual(len(content), self.guard.valves.max_single_tool_chars)
- self.assertIn("Werkzeugausgabe gekürzt", content)
-
- async def test_uncensored_uses_its_80k_context_limit(self):
- self.assertEqual(
- self.guard._context_limit("mikeai-uncensored"),
- self.guard.valves.uncensored_context_tokens,
- )
-
- async def test_duplicate_calls_disable_tools(self):
- call = {
- "id": "call",
- "type": "function",
- "function": {"name": "search", "arguments": '{"q":"same"}'},
- }
- body = {
- "model": "qwen-fast",
- "tools": [{"type": "function", "function": {"name": "search"}}],
- "tool_ids": ["server:mcp:web"],
- "messages": [
- {"role": "user", "content": "Suche genau einmal."},
- {"role": "assistant", "tool_calls": [call]},
- {"role": "tool", "tool_call_id": "1", "content": "nichts"},
- {"role": "assistant", "tool_calls": [call]},
- {"role": "tool", "tool_call_id": "2", "content": "nichts"},
- {"role": "assistant", "tool_calls": [call]},
- ],
- }
- result = await self.guard.inlet(body)
- self.assertEqual(result["tools"], [])
- self.assertEqual(result["tool_ids"], [])
- self.assertIn("Weitere Werkzeugaufrufe", result["messages"][0]["content"])
-
- async def test_old_context_is_compacted_before_current_turn(self):
- self.guard.valves.default_context_tokens = 10000
- self.guard.valves.hard_context_ratio = 0.8
- self.guard.valves.reserved_output_tokens = 1000
- body = {
- "model": "unknown",
- "messages": [
- {"role": "system", "content": "Sicher arbeiten."},
- {"role": "user", "content": "alt " * 15000},
- {"role": "assistant", "content": "altantwort " * 8000},
- {"role": "tool", "tool_call_id": "old", "content": "log " * 20000},
- {"role": "user", "content": "Aktuelle wichtige Frage"},
- ],
- }
- result = await self.guard.inlet(body)
- self.assertEqual(result["messages"][-1]["content"], "Aktuelle wichtige Frage")
- self.assertLess(len(result["messages"][1]["content"]), 3000)
-
- async def test_private_csv_disables_web_and_requires_local_table_analysis(self):
- body = {
- "model": "qwen-fast",
- "features": {"web_search": True, "code_interpreter": True},
- "tool_ids": ["server:mcp:web-local", "server:mcp:arr-local"],
- "tools": [
- {"type": "function", "function": {"name": "search_web"}},
- {"type": "function", "function": {"name": "execute_code"}},
- ],
- "metadata": {"files": [{"name": "private-bank.csv"}]},
- "messages": [
- {"role": "user", "content": "Sortiere Ein- und Ausgänge."},
- ],
- }
- result = await self.guard.inlet(body)
- self.assertEqual(result["tool_ids"], [])
- self.assertFalse(result["features"]["web_search"])
- self.assertTrue(result["features"]["code_interpreter"])
- self.assertEqual(
- [tool["function"]["name"] for tool in result["tools"]],
- ["execute_code"],
- )
- self.assertIn("private table rule", result["messages"][0]["content"])
- self.assertIn("sep=None", result["messages"][0]["content"])
- self.assertIn("delimiter", result["messages"][0]["content"])
-
- async def test_private_csv_is_detected_from_user_text_without_metadata(self):
- body = {
- "model": "qwen-fast",
- "tools": [
- {"type": "function", "function": {"name": "search_web"}},
- {"type": "function", "function": {"name": "execute_code"}},
- ],
- "messages": [
- {"role": "user", "content": "Werte bitte diese CSV meines Bankkontos aus."},
- ],
- }
- result = await self.guard.inlet(body)
- self.assertEqual(
- [tool["function"]["name"] for tool in result["tools"]],
- ["execute_code"],
- )
-
- async def test_private_csv_stops_before_openwebui_hard_tool_limit(self):
- calls = []
- for index in range(8):
- calls.extend(
- [
- {
- "role": "assistant",
- "tool_calls": [
- {
- "function": {
- "name": "execute_code",
- "arguments": '{"code":"step %d"}' % index,
- }
- }
- ],
- },
- {"role": "tool", "content": "ok", "tool_call_id": str(index)},
- ]
- )
- body = {
- "model": "qwen-fast",
- "tools": [
- {"type": "function", "function": {"name": "execute_code"}},
- ],
- "messages": [
- {"role": "user", "content": "Werte diese CSV aus."},
- *calls,
- ],
- }
- result = await self.guard.inlet(body)
- self.assertEqual(result["tools"], [])
- self.assertIn("vorhandenen Ergebnisse", result["messages"][0]["content"])
-
-
-class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase):
- async def asyncSetUp(self):
- self.module = _load("auto_tool_selector")
- self.selector = self.module.Filter()
-
- async def _select(self, prompt: str, existing=None):
- body = {
- "model": "mikeai-medium",
- "messages": [{"role": "user", "content": prompt}],
- }
- if existing is not None:
- body["tool_ids"] = existing
- return await self.selector.inlet(body)
-
- async def test_homeassistant_is_selected_for_room_temperature(self):
- result = await self._select("Wie warm ist es gerade in der Küche?")
- self.assertEqual(
- result["tool_ids"], ["server:mcp:homeassistant-local"]
- )
- self.assertIn("Never call ha_list_states merely", result["messages"][0]["content"])
- self.assertIn("Never infer an automation entity_id", result["messages"][0]["content"])
-
- async def test_hyphenated_homeassistant_and_tool_name_are_selected(self):
- result = await self._select(
- "Führe einen Home-Assistant-Test aus und nutze ha_list_states genau einmal."
- )
- self.assertEqual(result["tool_ids"], ["server:mcp:homeassistant-local"])
-
- async def test_general_web_is_available_without_site_specific_rules(self):
- result = await self._select("Erkläre mir kurz, wie ein Fahrrad funktioniert.")
- self.assertTrue(result["features"]["web_search"])
- self.assertTrue(result["metadata"]["features"]["web_search"])
- self.assertNotIn("tool_ids", result)
-
- async def test_weather_gets_native_web_and_compact_fallback(self):
- result = await self._select("Soll es heute in Rastatt regnen?")
- self.assertEqual(result["tool_ids"], ["server:mcp:web-general-local"])
-
- async def test_youtube_channel_question_gets_general_web_fallback(self):
- result = await self._select(
- "Welches Video steht aktuell oben auf dem YouTube-Kanal The Proper People?"
- )
- self.assertEqual(result["tool_ids"], ["server:mcp:web-general-local"])
-
- async def test_unraid_uses_readonly_not_mua(self):
- result = await self._select(
- "Welche Docker-Container laufen aktuell auf Unraid?"
- )
- self.assertEqual(
- result["tool_ids"], ["server:mcp:mua-readonly-local"]
- )
- self.assertNotIn("server:mcp:mua", result["tool_ids"])
- self.assertIn("bounded evidence ladder", result["messages"][0]["content"])
- self.assertIn("Do not dump complete configuration files", result["messages"][0]["content"])
-
- async def test_explicit_unraid_update_gets_read_and_management_tools(self):
- result = await self._select(
- "Prüfe auf Unraid alle Docker-Updates, führe die Updates durch und kontrolliere danach den Zustand."
- )
- self.assertEqual(
- result["tool_ids"],
- ["server:mcp:mua-readonly-local", "server:mcp:mua"],
- )
- self.assertIn("single batched update workflow", result["messages"][0]["content"])
-
- async def test_unraid_update_question_stays_readonly(self):
- result = await self._select(
- "Gibt es auf Unraid Updates für Docker-Container? Bitte nur prüfen."
- )
- self.assertEqual(
- result["tool_ids"], ["server:mcp:mua-readonly-local"]
- )
-
- async def test_readonly_unraid_media_audit_does_not_attach_admin_or_arr(self):
- result = await self._select(
- "Prüfe auf Unraid, welche Folgen meiner Hörspielserie Die drei Fragezeichen fehlen. "
- "Nur lesen, ohne Downloads, Umbenennungen oder sonstige Änderungen."
- )
- self.assertEqual(
- result["tool_ids"], ["server:mcp:mua-readonly-local"]
- )
- self.assertIn("unraid_files_inventory", result["messages"][0]["content"])
-
- async def test_unraid_media_audit_with_deezer_enables_native_web(self):
- result = await self._select(
- "Prüfe auf Unraid, welche Folgen meiner Hörspielserie fehlen, ermittle die "
- "aktuelle offizielle Liste online und prüfe jede fehlende Folge bei Deezer. "
- "Nur lesen, ohne Downloads oder Änderungen."
- )
- self.assertEqual(
- result["tool_ids"],
- ["server:mcp:mua-readonly-local", "server:mcp:web-general-local"],
- )
- self.assertTrue(result["features"]["web_search"])
- self.assertTrue(result["metadata"]["features"]["web_search"])
- self.assertIn("search_web/fetch_url", result["messages"][0]["content"])
- self.assertEqual(result["reasoning_effort"], "medium")
-
- async def test_unraid_media_write_gets_portable_operator_and_web(self):
- result = await self._select(
- "Ermittle online das neueste Video, lade es mit yt-dlp auf dem Unraid-Host "
- "in einen temporären Ordner, konvertiere es mit ffmpeg und lege die fertige "
- "Datei im Share Transfer ab."
- )
- self.assertEqual(
- result["tool_ids"],
- [
- "server:mcp:athena-operator-local",
- "server:mcp:mua-readonly-local",
- "server:mcp:mua",
- "server:mcp:web-general-local",
- ],
- )
- self.assertIn("direct MUA management or shell tool", result["messages"][0]["content"])
- self.assertIn("Do not ask the user to enable another tool", result["messages"][0]["content"])
- self.assertIn("start-status-result pattern", result["messages"][0]["content"])
- self.assertIn("transient-by-default dependency handling", result["messages"][0]["content"])
- self.assertIn("task-local copy under /tmp", result["messages"][0]["content"])
- self.assertTrue(result["metadata"]["mikeai_long_operator_task"])
-
- async def test_generic_remote_host_file_operation_gets_operator(self):
- result = await self._select(
- "Führe auf dem Server ein vorhandenes Skript aus und speichere die neue Datei unter /data/export."
- )
- self.assertEqual(result["tool_ids"], ["server:mcp:athena-operator-local"])
- self.assertTrue(result["metadata"]["mikeai_long_operator_task"])
-
- async def test_readonly_diagnostics_do_not_get_long_operator_budget(self):
- result = await self._select("Prüfe nur lesend den Zustand von Unraid.")
- self.assertNotIn("mikeai_long_operator_task", result["metadata"])
-
- async def test_narrow_safety_clause_does_not_cancel_authorized_unraid_write(self):
- result = await self._select(
- "Lade das Video auf dem Unraid-Host herunter, konvertiere es und lege es "
- "im Transfer-Share ab. Installiere dabei kein Paket dauerhaft und ändere "
- "keine Unraid-Systemkonfiguration."
- )
- self.assertEqual(
- result["tool_ids"],
- [
- "server:mcp:athena-operator-local",
- "server:mcp:mua-readonly-local",
- "server:mcp:mua",
- ],
- )
-
- async def test_plain_download_advice_does_not_attach_operator(self):
- result = await self._select("Erkläre mir, wie ein Browser einen Download technisch durchführt.")
- self.assertNotIn("tool_ids", result)
-
- async def test_voice_transcription_variants_select_unraid(self):
- result = await self._select(
- "Welche Dacher Contäner laufen aktuell auf dem Anrate Server?"
- )
- self.assertEqual(
- result["tool_ids"], ["server:mcp:mua-readonly-local"]
- )
-
- async def test_navidrome_is_selected(self):
- result = await self._select(
- "Schau in Navidrome nach ähnlichen Titeln und meiner Playlist."
- )
- self.assertEqual(result["tool_ids"], ["server:mcp:navidrome-local"])
-
- async def test_platform_context_is_selected(self):
- result = await self._select(
- "Wie ist der KI-Host Athena aufgebaut und wo liegt der Recovery-Koffer?"
- )
- self.assertEqual(result["tool_ids"], ["server:mcp:athena-operator-local"])
-
- async def test_athena_operator_is_selected_for_platform_work(self):
- result = await self._select(
- "Baue und deploye auf Athena einen neuen MCP-Container."
- )
- self.assertEqual(
- result["tool_ids"], ["server:mcp:athena-operator-local"]
- )
-
- async def test_mcp_build_from_github_gets_source_and_operator(self):
- result = await self._select(
- "Ich möchte hierfür einen MCP bauen: https://github.com/foo/bar"
- )
- self.assertEqual(
- result["tool_ids"],
- ["server:mcp:github-local", "server:mcp:athena-operator-local"],
- )
-
- async def test_existing_unraid_backend_gets_operator_and_runtime_evidence(self):
- result = await self._select(
- "Baue aus https://github.com/foo/deemix einen MCP. Deemix läuft bereits als Container auf Unraid; prüfe ihn zuerst."
- )
- self.assertEqual(
- result["tool_ids"],
- [
- "server:mcp:github-local",
- "server:mcp:athena-operator-local",
- "server:mcp:mua-readonly-local",
- ],
- )
- self.assertIn("integrate or relay", result["messages"][0]["content"])
-
- async def test_readonly_integration_plan_does_not_attach_unraid_admin(self):
- result = await self._select(
- "Analysiere https://github.com/foo/deemix und prüfe den auf Unraid "
- "laufenden Container und entwirf einen Athena-MCP. Erstelle keinen zweiten Container, ändere nichts "
- "und lies keine Secrets."
- )
- self.assertEqual(
- result["tool_ids"],
- [
- "server:mcp:github-local",
- "server:mcp:athena-operator-local",
- "server:mcp:mua-readonly-local",
- ],
- )
-
- async def test_github_and_explicit_web_adds_compact_general_web(self):
- result = await self._select(
- "Prüfe dieses GitHub Repository und suche zusätzlich im Netz nach Nutzerstimmen."
- )
- self.assertEqual(
- result["tool_ids"],
- ["server:mcp:github-local", "server:mcp:web-general-local"],
- )
-
- async def test_plain_public_web_request_attaches_general_not_legacy_web(self):
- result = await self._select(
- "Suche im Netz auf MakerWorld einen Schlümpfe-Schlüsselanhänger."
- )
- self.assertNotIn("server:mcp:web-local", result.get("tool_ids", []))
- self.assertIn("server:mcp:web-general-local", result.get("tool_ids", []))
-
- async def test_ebay_research_gets_general_marketplace_protocol(self):
- result = await self._select(
- "Suche auf eBay nach einem vollständigen Highscreen 386 PC mit Preis und Versand."
- )
- self.assertEqual(result["tool_ids"], ["server:mcp:web-general-local"])
- self.assertIn("Marketplace research protocol", result["messages"][0]["content"])
- self.assertIn("roughly three searches and five page fetches", result["messages"][0]["content"])
- self.assertTrue(result["metadata"]["mikeai_marketplace_research"])
-
- async def test_generic_marketplace_research_does_not_need_site_rule(self):
- result = await self._select(
- "Finde auf einem Marktplatz aktuelle Angebote für einen gebrauchten Synthesizer."
- )
- self.assertEqual(result["tool_ids"], ["server:mcp:web-general-local"])
- self.assertIn("deduplicate by listing URL or item number", result["messages"][0]["content"])
- self.assertTrue(result["metadata"]["mikeai_marketplace_research"])
-
- async def test_manual_tool_is_preserved(self):
- result = await self._select(
- "Prüfe Sonarr.", ["server:mcp:manually-selected"]
- )
- self.assertEqual(
- result["tool_ids"],
- ["server:mcp:manually-selected", "server:mcp:arr-local"],
- )
-
- async def test_plain_chat_gets_no_tools(self):
- result = await self._select("Erkläre mir den Unterschied zwischen RAM und SSD.")
- self.assertNotIn("tool_ids", result)
-
- async def test_selection_adds_write_safety_rule(self):
- result = await self._select("Zeige mir die Home Assistant Automatisierungen.")
- self.assertEqual(result["messages"][0]["role"], "system")
- self.assertIn("Availability is not authorization", result["messages"][0]["content"])
-
-
-class MetricsTests(unittest.IsolatedAsyncioTestCase):
- async def test_metrics_file_contains_no_chat_content_or_ids(self):
- module = _load("local_performance_metrics")
- metrics = module.Filter()
- with tempfile.TemporaryDirectory() as directory:
- path = Path(directory) / "metrics.jsonl"
- metrics.valves.metrics_path = str(path)
- metadata = {"message_id": "secret-message-id", "chat_id": "secret-chat-id"}
- await metrics.inlet(
- {
- "model": "qwen-fast",
- "messages": [{"role": "user", "content": "private prompt"}],
- "tools": [{"name": "tool"}],
- },
- __metadata__=metadata,
- )
- await metrics.outlet(
- {
- "model": "qwen-fast",
- "messages": [
- {
- "role": "assistant",
- "content": "private answer",
- "usage": {
- "prompt_tokens": 10,
- "completion_tokens": 4,
- "total_tokens": 14,
- },
- }
- ],
- },
- __metadata__=metadata,
- )
- raw = path.read_text()
- record = json.loads(raw)
- self.assertEqual(record["prompt_tokens"], 10)
- self.assertNotIn("private", raw)
- self.assertNotIn("secret", raw)
-
-
-class SecretRedactionTests(unittest.IsolatedAsyncioTestCase):
- async def test_only_tool_and_assistant_content_is_redacted(self):
- module = _load("secret_redaction")
- guard = module.Filter()
- token = "eyJ" + "A" * 24 + "." + "B" * 24 + "." + "C" * 16
- body = {
- "messages": [
- {"role": "user", "content": f"Absichtlich lokal nutzen: {token}"},
- {"role": "tool", "content": f'{{"api_key":"1234567890abcdef"}} {token}'},
- ]
- }
- result = await guard.inlet(body)
- self.assertIn(token, result["messages"][0]["content"])
- self.assertNotIn(token, result["messages"][1]["content"])
- self.assertNotIn("1234567890abcdef", result["messages"][1]["content"])
-
- outlet = {
- "messages": [
- {"role": "assistant", "content": "Bearer " + "abcdefghijklmnopqrstuvwxyz"}
- ]
- }
- result = await guard.outlet(outlet)
- self.assertNotIn("abcdefghijklmnopqrstuvwxyz", result["messages"][0]["content"])
-
- async def test_documentation_placeholders_and_paths_are_not_redacted(self):
- module = _load("secret_redaction")
- guard = module.Filter()
- content = "\n".join(
- [
- 'ROUTER_API_KEY=${ROUTER_API_KEY:?required}',
- 'API_KEY=/etc/mike-ai/router-api-key',
- 'access_token=',
- 'password=[REDACTED]',
- 'API-Key: Inhalt von /etc/mike-ai/router-api-key',
- ]
- )
- redacted, counts = guard._redact(content)
- self.assertEqual(redacted, content)
- self.assertEqual(counts, {})
-
- async def test_notification_reports_category_and_origin_but_not_value(self):
- module = _load("secret_redaction")
- guard = module.Filter()
- events = []
-
- async def emit(event):
- events.append(event)
-
- secret = "realistic-secret-value-123456"
- body = {"messages": [{"role": "tool", "content": f"api_key={secret}"}]}
- result = await guard.inlet(body, __event_emitter__=emit)
- self.assertNotIn(secret, result["messages"][0]["content"])
- description = events[0]["data"]["description"]
- self.assertIn("API-Key: 1", description)
- self.assertIn("Werkzeugausgaben", description)
- self.assertIn("nicht protokolliert", description)
- self.assertNotIn(secret, description)
-
-
-class QuickActionTests(unittest.IsolatedAsyncioTestCase):
- async def asyncSetUp(self):
- self.module = _load_action("quick_actions")
- self.action = self.module.Action()
- self.body = {
- "id": "assistant-1",
- "model": "qwen-fast",
- "messages": [
- {"id": "user-1", "role": "user", "content": "Frage"},
- {"id": "assistant-1", "role": "assistant", "content": "Lange Antwort"},
- ],
- }
-
- async def test_summary_appends_to_selected_message(self):
- async def fake_completion(*args, **kwargs):
- return "- Kurze Antwort"
-
- self.action._completion = fake_completion
- result = await self.action.action(self.body, "summary")
- self.assertEqual(result["messages"][0]["id"], "assistant-1")
- self.assertIn("### Kurzfassung", result["messages"][0]["content"])
- self.assertIn("Kurze Antwort", result["messages"][0]["content"])
-
- async def test_source_check_uses_web_evidence(self):
- completions = iter(["Qwen Fakten", "Bestätigt: Aussage [https://example.invalid]"])
-
- async def fake_completion(*args, **kwargs):
- return next(completions)
-
- async def fake_web(query):
- self.assertEqual(query, "Qwen Fakten")
- return '{"sources":[{"url":"https://example.invalid"}]}'
-
- self.action._completion = fake_completion
- self.action._web_research = fake_web
- result = await self.action.action(self.body, "sources")
- self.assertIn("### Quellenprüfung", result["messages"][0]["content"])
- self.assertIn("example.invalid", result["messages"][0]["content"])
-
- async def test_markdown_copy_uses_browser_clipboard(self):
- calls = []
-
- async def event_call(event):
- calls.append(event)
- return True
-
- await self.action.action(self.body, "copy_markdown", __event_call__=event_call)
- self.assertEqual(calls[0]["type"], "execute")
- self.assertIn("navigator.clipboard.writeText", calls[0]["data"]["code"])
- self.assertIn("Lange Antwort", calls[0]["data"]["code"])
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/dev/test_radarr_patch.py b/dev/test_radarr_patch.py
deleted file mode 100644
index 7de0ef8..0000000
--- a/dev/test_radarr_patch.py
+++ /dev/null
@@ -1,84 +0,0 @@
-#!/usr/bin/env python3
-"""Focused offline tests for the model-oriented Radarr overlay."""
-
-from __future__ import annotations
-
-import importlib.util
-import sys
-import types
-import unittest
-from pathlib import Path
-
-
-SOURCE = Path(__file__).parents[1] / "platform/mcp/patches/mcp_radarr.py"
-
-
-def load_module():
- fastmcp = types.ModuleType("fastmcp")
- fastmcp.FastMCP = object
- sys.modules["fastmcp"] = fastmcp
- pydantic = types.ModuleType("pydantic")
- pydantic.Field = lambda *args, **kwargs: kwargs.get("default")
- sys.modules["pydantic"] = pydantic
- auth = types.ModuleType("arr_mcp.auth")
- auth.get_radarr_client = lambda: None
- sys.modules["arr_mcp"] = types.ModuleType("arr_mcp")
- sys.modules["arr_mcp.auth"] = auth
- spec = importlib.util.spec_from_file_location("radarr_patch", SOURCE)
- module = importlib.util.module_from_spec(spec)
- assert spec.loader
- spec.loader.exec_module(module)
- return module
-
-
-class RadarrPatchTests(unittest.TestCase):
- def setUp(self):
- self.module = load_module()
- self.movies = [{
- "id": 12, "title": "Example", "year": 2024, "hasFile": True,
- "alternateTitles": [{"title": "large unwanted block"}],
- "movieFile": {
- "id": 44, "relativePath": "Example.mkv", "size": 2147483648,
- "quality": {"quality": {"name": "Bluray-1080p"}},
- "mediaInfo": {
- "videoCodec": "x264", "resolution": "1920x1080",
- "videoBitDepth": 8, "audioCodec": "EAC3",
- "audioLanguages": "ger/eng", "subtitles": "ger",
- },
- },
- }]
-
- def test_inventory_is_compact_and_alias_aware(self):
- result = self.module._compact_inventory(self.movies, codecs="h264")
- self.assertEqual(result["totalMatched"], 1)
- self.assertEqual(result["movies"][0]["videoCodec"], "x264")
- self.assertEqual(result["movies"][0]["sizeGiB"], 2.0)
- self.assertNotIn("alternateTitles", result["movies"][0])
-
- def test_filter_and_pagination_return_valid_bounded_data(self):
- result = self.module._compact_inventory(self.movies * 5, query="example", offset=1, limit=2)
- self.assertEqual(result["returned"], 2)
- self.assertTrue(result["hasMore"])
-
- def test_surface_has_only_explicit_read_tools(self):
- class FakeMcp:
- def __init__(self):
- self.names = []
-
- def tool(self, **_kwargs):
- def decorate(function):
- self.names.append(function.__name__)
- return function
- return decorate
-
- mcp = FakeMcp()
- self.module.register_radarr_tools(mcp)
- self.assertEqual(
- mcp.names,
- ["radarr_find_movie", "radarr_movie_codec_inventory", "radarr_search_releases"],
- )
- self.assertNotIn("radarr_action", mcp.names)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/dev/test_sonarr_release_grab.py b/dev/test_sonarr_release_grab.py
deleted file mode 100755
index cc82eb6..0000000
--- a/dev/test_sonarr_release_grab.py
+++ /dev/null
@@ -1,195 +0,0 @@
-#!/usr/bin/env python3
-"""Focused offline tests for the model-oriented Sonarr overlay."""
-
-from __future__ import annotations
-
-import importlib.util
-import sys
-import types
-import unittest
-from pathlib import Path
-from unittest.mock import patch
-
-
-SOURCE = Path(__file__).parents[1] / "platform/mcp/patches/mcp_sonarr.py"
-
-
-def load_module():
- async def run_blocking(function, *args, **kwargs):
- kwargs.pop("service", None)
- return function(*args, **kwargs)
-
- def dispatch(client, action, kwargs, **_options):
- return getattr(client, action)(**kwargs)
-
- utilities = types.ModuleType("agent_utilities.mcp_utilities")
- utilities.dispatch = dispatch
- utilities.run_blocking = run_blocking
- sys.modules["agent_utilities"] = types.ModuleType("agent_utilities")
- sys.modules["agent_utilities.mcp_utilities"] = utilities
- fastmcp = types.ModuleType("fastmcp")
- fastmcp.FastMCP = object
- sys.modules["fastmcp"] = fastmcp
- pydantic = types.ModuleType("pydantic")
- pydantic.Field = lambda *args, **kwargs: kwargs.get("default")
- sys.modules["pydantic"] = pydantic
- auth = types.ModuleType("arr_mcp.auth")
- auth.get_sonarr_client = lambda: None
- sys.modules["arr_mcp"] = types.ModuleType("arr_mcp")
- sys.modules["arr_mcp.auth"] = auth
- spec = importlib.util.spec_from_file_location("sonarr_patch", SOURCE)
- module = importlib.util.module_from_spec(spec)
- assert spec.loader
- spec.loader.exec_module(module)
- return module
-
-
-class FakeSonarrClient:
- def __init__(self, *, rejected: bool = False) -> None:
- self.posted = []
- self.release = {
- "guid": "exact-guid",
- "title": "Murder.She.Wrote.S07.German.AC3D.DL.1080p.WebHD.x265-FuN",
- "indexer": "Test Indexer",
- "indexerId": 7,
- "size": 27_600_000_000,
- "protocol": "usenet",
- "downloadAllowed": not rejected,
- "releaseGroup": "FuN",
- "seasonNumber": 7,
- "fullSeason": True,
- "rejections": ["Existing file has equal or better quality"] if rejected else [],
- }
-
- def get_release(self, **_kwargs):
- return [dict(self.release)]
-
- def get_episode(self, **_kwargs):
- return [
- {"id": 1, "seasonNumber": 7, "episodeNumber": 1, "hasFile": True},
- {"id": 2, "seasonNumber": 7, "episodeNumber": 2, "hasFile": False},
- ]
-
- def post_release(self, data=None, **kwargs):
- payload = data if data is not None else kwargs
- self.posted.append(payload)
- return payload
-
-
-class ReleaseGrabTests(unittest.IsolatedAsyncioTestCase):
- def setUp(self) -> None:
- self.module = load_module()
- self.module._APPROVALS.clear()
-
- async def test_exact_release_requires_preview_and_ticket(self) -> None:
- client = FakeSonarrClient()
- scope = {"series_id": 42, "season_number": 7, "guid": "exact-guid"}
- preview = await self.module._preview_release_grab(client, scope)
- self.assertFalse(preview["download_started"])
- self.assertEqual(preview["existing_episode_files_in_season"], 1)
- self.assertTrue(preview["approval_ticket"])
-
- result = await self.module._grab_release(
- client,
- {**scope, "confirm": True, "approval_ticket": preview["approval_ticket"]},
- )
- self.assertTrue(result["download_started"])
- self.assertFalse(result["replacement_guaranteed"])
- self.assertEqual(len(client.posted), 1)
- self.assertEqual(client.posted[0]["guid"], "exact-guid")
-
- async def test_rejected_release_needs_force_in_preview(self) -> None:
- client = FakeSonarrClient(rejected=True)
- scope = {"series_id": 42, "season_number": 7, "guid": "exact-guid"}
- blocked = await self.module._preview_release_grab(client, scope)
- self.assertTrue(blocked["force_required"])
- self.assertIsNone(blocked["approval_ticket"])
-
- approved = await self.module._preview_release_grab(client, {**scope, "force": True})
- self.assertFalse(approved["force_required"])
- self.assertTrue(approved["approval_ticket"])
-
- async def test_guid_must_still_match_current_sonarr_results(self) -> None:
- client = FakeSonarrClient()
- with self.assertRaisesRegex(ValueError, "no longer present"):
- await self.module._preview_release_grab(
- client,
- {"series_id": 42, "season_number": 7, "guid": "different-guid"},
- )
-
- async def test_search_can_limit_results_to_group_and_season_pack(self) -> None:
- client = FakeSonarrClient()
- client.get_release = lambda **_kwargs: [
- dict(client.release),
- {
- **client.release,
- "guid": "episode-guid",
- "title": "Mord.ist.ihr.Hobby.S07E02.German.1080p-FuN",
- "fullSeason": False,
- },
- {
- **client.release,
- "guid": "other-group",
- "title": "Mord.ist.ihr.Hobby.S07.German.1080p-HQC",
- "releaseGroup": "HQC",
- },
- ]
- result = await self.module._search_releases(
- client,
- {
- "series_id": 42,
- "season_number": 7,
- "release_group": "FuN",
- "season_pack_only": True,
- },
- )
- self.assertEqual(result["results"]["total"], 1)
- self.assertEqual(result["results"]["items"][0]["guid"], "exact-guid")
-
- def test_read_only_surface_is_explicit_and_has_no_generic_action(self) -> None:
- class FakeMcp:
- def __init__(self):
- self.names = []
-
- def tool(self, **_kwargs):
- def decorate(function):
- self.names.append(function.__name__)
- return function
- return decorate
-
- with patch.dict("os.environ", {"ARR_MCP_WRITE": "0"}):
- mcp = FakeMcp()
- self.module.register_sonarr_tools(mcp)
- self.assertEqual(
- mcp.names,
- [
- "sonarr_find_series",
- "sonarr_get_season_summary",
- "sonarr_search_releases",
- "sonarr_system_status",
- ],
- )
- self.assertNotIn("sonarr_action", mcp.names)
-
- def test_write_tools_are_registered_only_when_enabled(self) -> None:
- class FakeMcp:
- def __init__(self):
- self.names = []
-
- def tool(self, **_kwargs):
- def decorate(function):
- self.names.append(function.__name__)
- return function
- return decorate
-
- with patch.dict("os.environ", {"ARR_MCP_WRITE": "1"}):
- mcp = FakeMcp()
- self.module.register_sonarr_tools(mcp)
- self.assertIn("sonarr_preview_release_grab", mcp.names)
- self.assertIn("sonarr_grab_release", mcp.names)
- self.assertIn("sonarr_preview_episode_search", mcp.names)
- self.assertIn("sonarr_start_episode_search", mcp.names)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/dev/test_web_search_mcp.py b/dev/test_web_search_mcp.py
deleted file mode 100644
index 37ef82a..0000000
--- a/dev/test_web_search_mcp.py
+++ /dev/null
@@ -1,148 +0,0 @@
-#!/usr/bin/env python3
-"""Regression tests for the compact web MCP facade."""
-
-from __future__ import annotations
-
-import importlib.util
-import json
-import pathlib
-import unittest
-from unittest import mock
-
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-SPEC = importlib.util.spec_from_file_location(
- "web_search_mcp", ROOT / "platform/web-search/web_search_mcp.py"
-)
-WEB = importlib.util.module_from_spec(SPEC)
-assert SPEC.loader
-SPEC.loader.exec_module(WEB)
-
-
-class WebSearchMcpTests(unittest.TestCase):
- def setUp(self) -> None:
- WEB._search_attempts.clear()
-
- def test_tool_surface_stays_small_and_explicit(self) -> None:
- self.assertEqual(
- [tool["name"] for tool in WEB.TOOLS],
- ["web_search", "web_read", "web_youtube", "web_compare", "web_shop", "web_research"],
- )
-
- def test_current_queries_do_not_get_wikipedia_noise(self) -> None:
- with (
- mock.patch.object(WEB, "SEARXNG_URL", "http://searxng:8080"),
- mock.patch.object(WEB, "searxng_json", return_value={"results": []}),
- mock.patch.object(WEB, "wikipedia_search") as wikipedia,
- ):
- results, _ = WEB.general_discovery("latest video The Proper People", 4)
- self.assertEqual(results, [])
- wikipedia.assert_not_called()
-
- def test_empty_fresh_search_relaxes_once_and_marks_result(self) -> None:
- hit = {"title": "Current page", "url": "https://example.com/current"}
- with mock.patch.object(
- WEB,
- "general_discovery",
- side_effect=[([], []), ([hit], [])],
- ) as discovery:
- result = WEB.web_search({
- "query": "current test release",
- "freshness": "week",
- "max_results": 3,
- })
- self.assertTrue(result["task_complete"])
- self.assertFalse(result["freshness_applied"])
- self.assertIn("unfiltered", result["backend_warning"])
- self.assertEqual(discovery.call_count, 2)
-
- def test_related_search_budget_is_enforced(self) -> None:
- with mock.patch.object(WEB, "SEARCH_BUDGET_MAX_RELATED_CALLS", 2):
- self.assertTrue(WEB.consume_search_budget("latest Proper People video")[0])
- self.assertTrue(WEB.consume_search_budget("Proper People newest video")[0])
- self.assertFalse(WEB.consume_search_budget("newest video by Proper People")[0])
-
- def test_youtube_feed_provides_order_and_dates(self) -> None:
- feed = b'''
-
- new123Newest
- 2026-08-23T12:00:00+00:00
- The Proper People
- old456Older
- 2026-08-10T12:00:00+00:00
- The Proper People
- '''
- with mock.patch.object(WEB, "fetch_public_bytes", return_value=feed):
- rows = WEB.youtube_feed_records(
- "https://www.youtube.com/channel/UCcem9I78ybZLHLRUlkUO3sw", 2
- )
- self.assertEqual([row["title"] for row in rows], ["Newest", "Older"])
- self.assertEqual(rows[0]["published_at"], "2026-08-23T12:00:00+00:00")
-
- def test_latest_youtube_is_one_bounded_specialist_operation(self) -> None:
- row = {
- "title": "Newest",
- "url": "https://www.youtube.com/watch?v=new123",
- "source_kind": "youtube_channel_feed",
- }
- with (
- mock.patch.object(WEB, "resolve_youtube_channel", return_value="https://www.youtube.com/channel/UCcem9I78ybZLHLRUlkUO3sw"),
- mock.patch.object(WEB, "youtube_feed_records", return_value=[row]),
- mock.patch.object(WEB, "run_ytdlp") as ytdlp,
- ):
- result = WEB.web_youtube({"query": "The Proper People", "mode": "latest"})
- self.assertTrue(result["task_complete"])
- self.assertEqual(result["results"][0]["title"], "Newest")
- ytdlp.assert_not_called()
-
- def test_latest_long_youtube_uses_verified_videos_tab(self) -> None:
- row = {
- "title": "Newest long video",
- "url": "https://www.youtube.com/watch?v=long123",
- "content_type": "long",
- "content_type_verified": True,
- "source_kind": "youtube_videos_tab",
- }
- with (
- mock.patch.object(
- WEB,
- "resolve_youtube_channel",
- return_value="https://www.youtube.com/channel/UCcem9I78ybZLHLRUlkUO3sw",
- ),
- mock.patch.object(WEB, "youtube_tab_records", return_value=[row]) as tab,
- mock.patch.object(WEB, "youtube_feed_records") as feed,
- ):
- result = WEB.web_youtube({
- "query": "The Proper People",
- "mode": "latest",
- "content_type": "long",
- })
- tab.assert_called_once_with(
- "https://www.youtube.com/channel/UCcem9I78ybZLHLRUlkUO3sw", "long", 5
- )
- feed.assert_not_called()
- self.assertEqual(result["content_type_filter"], "long")
- self.assertTrue(result["results"][0]["content_type_verified"])
-
- def test_youtube_rejects_content_filter_outside_latest_mode(self) -> None:
- with self.assertRaisesRegex(ValueError, "only supported with mode=latest"):
- WEB.web_youtube({
- "query": "The Proper People",
- "mode": "search",
- "content_type": "long",
- })
-
- def test_web_read_does_not_consume_search_loop_budget(self) -> None:
- page = {"url": "https://example.com/a", "page_evidence": ["Evidence"]}
- with mock.patch.object(WEB, "scrape", return_value=[page]):
- result = json.loads(WEB.call_tool("web_read", {
- "url": "https://example.com/a",
- "question": "What does this page say?",
- }))
- self.assertTrue(result["task_complete"])
- self.assertEqual(WEB._search_attempts, [])
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..00ca4bf
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,35 @@
+# Aktuelle Architektur
+
+```mermaid
+flowchart LR
+ C[Hermes Desktop / Web / Mobil] --> H[Hermes Agent
Unraid]
+ H -->|OpenAI API| R[Profile Router
Athena :8081]
+ R --> P[Profile Controller]
+ P --> Q[genau ein llama.cpp-Profil
Qwen Fast / Medium / Large / Ultra / Uncensored]
+ R --> I[Z-Image-Turbo
RTX 5080, bei Bedarf]
+ R --> T[XTTS RTX 3060
Piper CPU-Fallback]
+
+ H --> U[MUA / Unraid MCP]
+ H --> A[ARR-MCP]
+ H --> D[Deemix-MCP]
+ H --> N[Navidrome-MCP]
+ H --> S[STRATO-MCP]
+ H --> X[Nginx-Proxy-Manager-MCP]
+ U --> M[Media-Tools
ffmpeg / ffprobe / yt-dlp]
+
+ W[WireGuard-Gateway
Athena] --- R
+ W --- B[Athena Dashboard :8099]
+ W --- O[Athena Operator]
+ K[Backup alle 5 Stunden] --> DATA[/data und /etc/mike-ai]
+```
+
+## Verantwortung
+
+- **Unraid** hält Hermes, Chats, Skills, Fach-MCPs und deren Appdata.
+- **Athena** rechnet: Text, Bild und Sprache; Router und Dashboard koordinieren
+ und beobachten die Inferenz.
+- **MUA** verwaltet Unraid. **Athena Operator** bleibt auf den Athena-Host
+ begrenzt.
+- Der Router ist die einzige Modelladresse, die Hermes kennen muss.
+
+Die visuelle Fassung liegt als `athena-architecture-map.png` neben dieser Datei.
diff --git a/docs/MCP_SERVERS.md b/docs/MCP_SERVERS.md
index 09b7da1..e4b5cc4 100644
--- a/docs/MCP_SERVERS.md
+++ b/docs/MCP_SERVERS.md
@@ -1,15 +1,30 @@
-# MCP-Server
+# Produktive MCP- und Werkzeugdienste
-Diese Datei wird aus `config/mcp-registry.json` erzeugt. Änderungen gehören nur in die JSON-Registry.
+Die portablen Werkzeuge laufen als getrennte Container auf Unraid. Hermes bindet
+sie direkt ein; MCPHub ist nicht mehr im Datenpfad.
-| Server | Hermes-ID | Endpunkt | Clients | Werkzeuge |
-|---|---|---|---|---:|
-| Athena Operator | `athena-operator` | `http://192.168.1.2:8787/mcp/athena-operator` | hermes, openwebui | alle |
-| GitHub (offiziell, read-only) | `github` | `http://192.168.1.2:8787/mcp/github` | hermes, openwebui | alle |
-| Home Assistant | `homeassistant-admin` | `http://192.168.1.2:8787/mcp/homeassistant` | hermes, openwebui | alle |
-| Sonarr und Radarr | `arr` | `http://192.168.1.2:8787/mcp/arr` | hermes, openwebui | alle |
-| Navidrome | `navidrome` | `http://192.168.1.2:8787/mcp/navidrome` | hermes, openwebui | alle |
-| MUA (Unraid-Verwaltung) | `unraid` | `http://192.168.1.2:8787/mcp/unraid` | hermes, openwebui | alle |
-| FRITZ!Box | `fritzbox` | `http://192.168.1.2:8787/mcp/fritzbox` | hermes, openwebui | 4 |
+| Dienst | Endpunkt | Betrieb |
+|---|---|---|
+| MUA / Unraid | `http://192.168.1.2:3002/mcp` | Unraid-Plugin |
+| ARR | `http://192.168.1.2:8207/mcp` | `ARR-MCP` |
+| Deemix | `http://192.168.1.2:8209/mcp` | `Deemix-MCP` |
+| Navidrome | `http://192.168.1.2:8210/mcp` | `Navidrome-MCP` |
+| STRATO DNS | `http://192.168.1.2:2030/mcp` | `Strato-MCP` |
+| Nginx Proxy Manager | `http://192.168.1.2:8767/mcp` | `Nginx-Proxy-Manager-MCP` |
+| Home Assistant | Home-Assistant-MCP-Endpunkt | derzeit in Hermes deaktiviert |
+| Athena Operator | interner Athena-Dienst | nur für Athena-Administration |
-Allgemeine Webrecherche ist ein eingebautes Hermes-Werkzeug und kein MCPHub-Server.
+`Media-Tools` ist kein MCP. Der Container ist eine persistente Werkzeugkiste
+für ffmpeg, ffprobe, yt-dlp und ähnliche Hilfsprogramme und wird über das
+Unraid-Terminalwerkzeug angesprochen.
+
+Allgemeine Websuche und Terminal sind Hermes-eigene Werkzeuge. Sie benötigen
+keinen zusätzlichen Athena-Container.
+
+## Zuständigkeit
+
+- Athena-Host verändern: Athena Operator
+- Unraid und Container verwalten: MUA / Unraid
+- Mediendienste: jeweiliger Fach-MCP
+- fehlende CLI-Medienwerkzeuge: Media-Tools
+- allgemeine Recherche: Hermes-Webwerkzeug
diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md
index 45c09fb..6e2a760 100644
--- a/docs/RECOVERY.md
+++ b/docs/RECOVERY.md
@@ -1,87 +1,62 @@
# Backup und Wiederherstellung
-## Was automatisch gesichert wird
+## Athena
-Der Container `mike-ai-backup` erstellt alle fünf Stunden ein komprimiertes
-Archiv unter `/data/docker-backups` und behält 14 Tage. Netzwerk, WireGuard,
-Router und Qwen bleiben dabei erreichbar. Hermes läuft unabhängig auf Unraid.
+`mike-ai-backup` erzeugt alle fünf Stunden ein Archiv unter
+`/data/docker-backups` und behält 14 Tage. Gesichert werden:
-Enthalten sind:
+- `/etc/mike-ai` mit lokaler Konfiguration,
+- Router-Zustand und erzeugte Bilder,
+- Piper-Daten,
+- der kanonische Stack als zusätzlicher Snapshot.
-- `/etc/mike-ai` mit lokalen Konfigurationen und Secrets
-- Router-Zustand und Router-Bildablage
-- Piper-Daten
-- ein Quellbaum-Snapshot als zusätzliche Bequemlichkeit
+Nicht in das Archiv gehören die großen Modellgewichte unter `/data/models`.
+Sie bleiben auf der Daten-SSD oder werden anhand der gepinnten Angaben in
+`config/install.env.example` erneut geladen. Die Dashboard-Historie liegt
+dauerhaft unter `/data/llama-dashboard`.
-Nicht kopiert werden `/data/models`, die nur noch als Rückfall vorhandene Kopie
-`/data/hermes` und `/data/hermes-webui`: Sie liegen dauerhaft auf der Daten-SSD
-und überleben den Austausch der Debian-Systemplatte. Docker-Images werden aus
-dem Compose-Stack reproduziert und gehören nicht ins Backup.
+### Neuaufbau
-Die portablen Fach-MCPs und Hermes gehören nicht mehr zum Athena-Systembackup.
-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
-Unraid-Appdata-Backup verfügbar sein.
-
-## Hermes auf Unraid wiederherstellen
-
-1. `/mnt/nvme-storage/appdata/Hermes-Agent` aus dem Unraid-Appdata-Backup
- wiederherstellen.
-2. `config/unraid-templates/my-Hermes-Agent-Official.xml` nach
- `/boot/config/plugins/dockerMan/templates-user/` kopieren.
-3. In Unraid **Docker → Add Container → User Templates → Hermes-Agent** wählen,
- die maskierten Schlüssel aus der wiederhergestellten `.env` übernehmen und
- den Container starten.
-4. `http://127.0.0.1:8642/health` im Container beziehungsweise
- `http://:9119` im Browser prüfen.
-5. Falls sich Athenas VPN-Adresse geändert hat, in `config.yaml` und in allen
- `profiles/*/config.yaml` die Router-Basis-URL anpassen.
-
-## Manuelles Backup
-
-```bash
-docker exec mike-ai-backup backup
-```
-
-Die Datei `/data/docker-backups/athena-latest.tar.gz` zeigt danach auf das
-neueste erfolgreiche Archiv.
-
-## Neuaufbau
-
-1. Debian installieren und `/data` wieder unter demselben Pfad einhängen.
-2. Repository klonen.
-3. Installation einmal ausführen:
+1. Debian installieren und `/data` wieder am bisherigen Pfad einhängen.
+2. Dieses Repository klonen.
+3. Installationsdatei ausfüllen und Installation starten:
```bash
- sudo ./install.sh --config config/install.env
+ sudo ./install.sh --config /root/mike-ai-install.env
```
-4. Zustand mit einem Befehl wiederherstellen:
+4. Letztes Datenarchiv einspielen:
```bash
sudo ./restore.sh /data/docker-backups/athena-latest.tar.gz
+ sudo ./smoke-test.sh
```
-5. Falls `/etc/mike-ai/mcphub-client.env` nicht im Athena-Backup enthalten
- war, den Wert aus Unraids
- `/mnt/nvme-storage/appdata/MCPHub/client-token` einmalig als
- `MCPHUB_BEARER_TOKEN=...` eintragen und anschließend
- `platform/mcp/sync-clients.py` ausführen. Der Token gehört nicht ins Git.
+Das Restore verändert weder SSH noch LAN, WireGuard, Kernel, Partitionen oder
+Mounts.
-Das Restore stoppt ausschließlich Container, deren Volumes zurückgeschrieben
-werden. SSH, LAN und WireGuard werden nicht verändert.
+## Unraid
+
+Hermes und die Fach-MCPs sind kein Bestandteil des Athena-Backups. Sie werden
+durch das vorhandene Unraid-Appdata-Backup gesichert:
+
+- `/mnt/nvme-storage/appdata/Hermes-Agent`
+- die jeweiligen Appdata-Verzeichnisse der MCP-Container
+- DockerMan-Templates unter
+ `/boot/config/plugins/dockerMan/templates-user/`
+
+Container-Images stammen aus den dokumentierten Registries beziehungsweise den
+eigenen Gitea-Repositories. Damit besteht die Wiederherstellung aus
+Appdata-Restore plus Neuerstellung über die jeweilige Template-XML.
## Kontrolle
```bash
docker compose --env-file /etc/mike-ai/stack.env ps
test -s /data/docker-backups/athena-latest.tar.gz
+curl -fsS http://192.168.1.212:8099/health
+sudo ./smoke-test.sh
```
-Danach einen Router-Request, einen Hermes-Zweiturn-Chat und je einen read-only
-MCP-Aufruf über `http://UNRAID-IP:8787/mcp/NAME` testen. Alte Recovery-Koffer
-sind für Neuinstallationen nicht mehr erforderlich; Git, Athenas Datenbackup
-und das Unraid-Appdata-Backup bilden die Wiederherstellung.
+Anschließend einen Hermes-Chat, einen Router-Aufruf und je eine kleine
+read-only-Abfrage der benötigten MCPs testen.
diff --git a/docs/athena-architecture-map.png b/docs/athena-architecture-map.png
new file mode 100644
index 0000000000000000000000000000000000000000..78accf8c826076cf1920f8fbd5d7580448b5e413
GIT binary patch
literal 1577839
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