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] 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 )