Move Deemix MCP to standalone repository

This commit is contained in:
Mikei386
2026-08-28 22:42:55 +02:00
parent 79be9e7f40
commit c721db47d0
16 changed files with 13 additions and 1067 deletions
+6 -1
View File
@@ -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://<Unraid-IP>:9119`
Hermes-API: `http://<Unraid-IP>: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`:
-13
View File
@@ -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
-19
View File
@@ -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",
+1 -1
View File
@@ -11,7 +11,7 @@
<Support>https://github.com/samanhappy/mcphub/issues</Support>
<Project>https://github.com/samanhappy/mcphub</Project>
<ReadMe>https://github.com/samanhappy/mcphub#readme</ReadMe>
<Overview>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.&#13;
<Overview>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.&#13;
&#13;
Weboberfläche: http://[IP]:[PORT:3000]/&#13;
Benutzer beim ersten Start: admin&#13;
-469
View File
@@ -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())
-1
View File
@@ -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 |
-13
View File
@@ -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"]
-20
View File
@@ -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:
-511
View File
@@ -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://<unraid>: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"))
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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]
-1
View File
@@ -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
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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"}
-12
View File
@@ -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",
+1 -1
View File
@@ -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
)