diff --git a/config/unraid-templates/my-ARR-MCP.xml b/config/unraid-templates/my-ARR-MCP.xml index 8f57bd1..23b9aa3 100644 --- a/config/unraid-templates/my-ARR-MCP.xml +++ b/config/unraid-templates/my-ARR-MCP.xml @@ -1,7 +1,7 @@ ARR-MCP - mike-ai/arr-mcp:1.0.0 + mike-ai/arr-mcp:1.1.0 bridge sh false @@ -18,6 +18,6 @@ - Das lokale Image mike-ai/arr-mcp:1.0.0 muss vorher mit services/arr-mcp/install-on-unraid.sh gebaut worden sein. + Das lokale Image mike-ai/arr-mcp:1.1.0 muss vorher mit services/arr-mcp/install-on-unraid.sh gebaut worden sein. 8207 diff --git a/dev/test_radarr_patch.py b/dev/test_radarr_patch.py index d1a0c21..7de0ef8 100644 --- a/dev/test_radarr_patch.py +++ b/dev/test_radarr_patch.py @@ -14,12 +14,6 @@ SOURCE = Path(__file__).parents[1] / "platform/mcp/patches/mcp_radarr.py" def load_module(): - utilities = types.ModuleType("agent_utilities.mcp_utilities") - utilities.dispatch = lambda *args, **kwargs: None - utilities.public_actions = lambda client: client.actions - utilities.run_blocking = lambda *args, **kwargs: None - sys.modules["agent_utilities"] = types.ModuleType("agent_utilities") - sys.modules["agent_utilities.mcp_utilities"] = utilities fastmcp = types.ModuleType("fastmcp") fastmcp.FastMCP = object sys.modules["fastmcp"] = fastmcp @@ -66,9 +60,24 @@ class RadarrPatchTests(unittest.TestCase): self.assertEqual(result["returned"], 2) self.assertTrue(result["hasMore"]) - def test_internal_transport_and_power_actions_are_hidden(self): - client = types.SimpleNamespace(actions=["get_movie", "request", "post_system_shutdown", "get_queue"]) - self.assertEqual(self.module._safe_actions(client), ["get_movie", "get_queue"]) + def test_surface_has_only_explicit_read_tools(self): + class FakeMcp: + def __init__(self): + self.names = [] + + def tool(self, **_kwargs): + def decorate(function): + self.names.append(function.__name__) + return function + return decorate + + mcp = FakeMcp() + self.module.register_radarr_tools(mcp) + self.assertEqual( + mcp.names, + ["radarr_find_movie", "radarr_movie_codec_inventory", "radarr_search_releases"], + ) + self.assertNotIn("radarr_action", mcp.names) if __name__ == "__main__": diff --git a/dev/test_sonarr_release_grab.py b/dev/test_sonarr_release_grab.py index 448d23c..cc82eb6 100755 --- a/dev/test_sonarr_release_grab.py +++ b/dev/test_sonarr_release_grab.py @@ -8,6 +8,7 @@ import sys import types import unittest from pathlib import Path +from unittest.mock import patch SOURCE = Path(__file__).parents[1] / "platform/mcp/patches/mcp_sonarr.py" @@ -145,6 +146,50 @@ class ReleaseGrabTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(result["results"]["total"], 1) self.assertEqual(result["results"]["items"][0]["guid"], "exact-guid") + def test_read_only_surface_is_explicit_and_has_no_generic_action(self) -> None: + class FakeMcp: + def __init__(self): + self.names = [] + + def tool(self, **_kwargs): + def decorate(function): + self.names.append(function.__name__) + return function + return decorate + + with patch.dict("os.environ", {"ARR_MCP_WRITE": "0"}): + mcp = FakeMcp() + self.module.register_sonarr_tools(mcp) + self.assertEqual( + mcp.names, + [ + "sonarr_find_series", + "sonarr_get_season_summary", + "sonarr_search_releases", + "sonarr_system_status", + ], + ) + self.assertNotIn("sonarr_action", mcp.names) + + def test_write_tools_are_registered_only_when_enabled(self) -> None: + class FakeMcp: + def __init__(self): + self.names = [] + + def tool(self, **_kwargs): + def decorate(function): + self.names.append(function.__name__) + return function + return decorate + + with patch.dict("os.environ", {"ARR_MCP_WRITE": "1"}): + mcp = FakeMcp() + self.module.register_sonarr_tools(mcp) + self.assertIn("sonarr_preview_release_grab", mcp.names) + self.assertIn("sonarr_grab_release", mcp.names) + self.assertIn("sonarr_preview_episode_search", mcp.names) + self.assertIn("sonarr_start_episode_search", mcp.names) + if __name__ == "__main__": unittest.main() diff --git a/platform/mcp/patches/mcp_radarr.py b/platform/mcp/patches/mcp_radarr.py index 94608a7..6daf104 100644 --- a/platform/mcp/patches/mcp_radarr.py +++ b/platform/mcp/patches/mcp_radarr.py @@ -1,25 +1,55 @@ -"""Small, model-oriented Radarr tools built on the upstream API client.""" +"""Small, explicit, read-only Radarr tools built on the upstream API client.""" -import json +import asyncio from typing import Any -from agent_utilities.mcp_utilities import dispatch, public_actions, run_blocking from fastmcp import FastMCP from pydantic import Field from arr_mcp.auth import get_radarr_client -BLOCKED_ACTIONS = { - "request", "get_", "get_api", "get_content_path", "get_path", - "get_login", "get_logout", "post_login", "post_system_restart", - "post_system_shutdown", -} +async def _call(client: Any, action: str, kwargs: dict[str, Any] | None = None) -> Any: + """Call one known upstream API method without exposing dynamic dispatch.""" + return await asyncio.to_thread(getattr(client, action), **(kwargs or {})) -def _safe_actions(client: Any) -> list[str]: - """Hide transport, authentication and service-power implementation methods.""" - return [name for name in public_actions(client) if name not in BLOCKED_ACTIONS] +def _plain(value: Any) -> Any: + if hasattr(value, "model_dump") and callable(value.model_dump): + return value.model_dump() + if hasattr(value, "dict") and callable(value.dict): + return value.dict() + return value + + +def _movies(value: Any) -> list[dict[str, Any]]: + value = _plain(value) + if isinstance(value, dict) and "result" in value: + value = value["result"] + return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else [] + + +def _compact_movie(movie: dict[str, Any]) -> dict[str, Any]: + return { + key: movie[key] + for key in ("id", "title", "originalTitle", "year", "status", "monitored", "hasFile", "path", "tmdbId") + if movie.get(key) is not None + } + + +def _compact_release(item: dict[str, Any]) -> dict[str, Any]: + quality = item.get("quality") or {} + quality_name = (quality.get("quality") or {}).get("name") if isinstance(quality, dict) else None + result = { + key: item[key] + for key in ("guid", "title", "indexer", "indexerId", "size", "age", "seeders", "leechers", "protocol", "downloadAllowed", "releaseGroup") + if item.get(key) is not None + } + if quality_name: + result["quality"] = quality_name + if isinstance(item.get("rejections"), list) and item["rejections"]: + result["rejections"] = [str(reason)[:180] for reason in item["rejections"][:5]] + return result def _codec_aliases(value: str) -> set[str]: @@ -91,6 +121,23 @@ def _compact_inventory( def register_radarr_tools(mcp: FastMCP) -> None: + @mcp.tool(tags={"radarr"}) + async def radarr_find_movie( + query: str = Field(description="Movie title or title fragment."), + limit: int = Field(default=10, ge=1, le=25), + ) -> Any: + """Find a movie already managed by Radarr. READ ONLY. Never starts a search or download.""" + needle = query.strip().casefold() + if len(needle) < 2: + raise ValueError("query must contain at least two characters") + movies = _movies(await _call(get_radarr_client(), "get_movie")) + matches = [ + _compact_movie(movie) + for movie in movies + if needle in " ".join(str(movie.get(key, "")) for key in ("title", "originalTitle", "sortTitle")).casefold() + ] + return {"query": query, "match_count": len(matches), "matches": matches[:limit], "truncated": len(matches) > limit} + @mcp.tool(tags={"radarr"}) async def radarr_movie_codec_inventory( video_codecs: str = Field( @@ -103,48 +150,38 @@ def register_radarr_tools(mcp: FastMCP) -> None: ) -> Any: """Compact authoritative Radarr movie-file inventory. Use for codec, resolution, language and size questions instead of get_movie, raw API requests or filesystem scans. Results are valid bounded JSON without alternate titles, images, overviews or ratings.""" client = get_radarr_client() - response = await run_blocking(client.get_movie) - movies = response.get("result", response) if isinstance(response, dict) else response - if not isinstance(movies, list): + response = _plain(await _call(client, "get_movie")) + movies = _movies(response) + if not movies and response not in ([], {"result": []}): raise RuntimeError("Radarr get_movie returned an unexpected response") return _compact_inventory( movies, codecs=video_codecs, query=query, offset=offset, limit=limit, ) @mcp.tool(tags={"radarr"}) - async def radarr_action( - action: str = Field( - description=( - "A named Radarr API operation. Prefer radarr_movie_codec_inventory for " - "library/file/codec questions. Use list_actions once for unusual operations. " - "Raw request, authentication and Radarr restart/shutdown methods are unavailable." - ) - ), - params_json: str = Field( - default="{}", - description="JSON object string with only the selected action's required parameters.", - ), + async def radarr_search_releases( + movie_id: int = Field(ge=1, description="Exact Radarr movie id returned by radarr_find_movie."), + release_group: str = Field(default="", description="Optional release-group filter."), + limit: int = Field(default=50, ge=1, le=100), ) -> Any: - """Other Radarr operations for movies, queue, history, releases, profiles and health. TV episodes belong to Sonarr. Mutations require an explicit user request.""" - client = get_radarr_client() - actions = _safe_actions(client) - if action in {"list_actions", "actions", "help", "capabilities"}: - return {"service": "arr-radarr", "actions": actions} - if action not in actions: - return { - "ok": False, - "error": "unknown or unavailable Radarr action", - "action": action, - "retry": False, - "hint": "Use list_actions once or radarr_movie_codec_inventory for file/codec questions.", - } - try: - parsed = json.loads(params_json) - except json.JSONDecodeError as exc: - raise ValueError(f"params_json is not valid JSON: {exc.msg}") from exc - if not isinstance(parsed, dict): - raise ValueError("params_json must encode a JSON object") - kwargs = {key: value for key, value in parsed.items() if value is not None} - return await run_blocking( - dispatch, client, action, kwargs, service="arr-radarr" - ) + """Search Radarr's configured indexers for one movie. READ ONLY: never grabs or downloads a release.""" + raw = _plain(await _call(get_radarr_client(), "get_release", {"movieId": movie_id})) + if isinstance(raw, dict) and "result" in raw: + raw = raw["result"] + releases = [item for item in raw if isinstance(item, dict)] if isinstance(raw, list) else [] + needle = release_group.strip().casefold() + if needle: + releases = [ + item for item in releases + if needle in (str(item.get("releaseGroup", "")) + " " + str(item.get("title", ""))).casefold() + ] + compact = [_compact_release(item) for item in releases[:limit]] + return { + "movie_id": movie_id, + "release_group_filter": release_group or None, + "total": len(releases), + "returned": len(compact), + "truncated": len(releases) > limit, + "results": compact, + "download_started": False, + } diff --git a/platform/mcp/patches/mcp_sonarr.py b/platform/mcp/patches/mcp_sonarr.py index 433dada..786e81e 100644 --- a/platform/mcp/patches/mcp_sonarr.py +++ b/platform/mcp/patches/mcp_sonarr.py @@ -3,6 +3,7 @@ CONCEPT:ECO-4.82 — gitlab-style organized per-service tool surface. """ +import asyncio import os import json import re @@ -10,44 +11,23 @@ import secrets import time from typing import Any -from agent_utilities.mcp_utilities import dispatch, run_blocking from fastmcp import FastMCP from pydantic import Field from arr_mcp.auth import get_sonarr_client -READ_ONLY_ACTIONS = frozenset( - { - "get_system_status", "get_health", "get_diskspace", "get_ping", - "get_series", "get_series_id", "get_series_lookup", "lookup_series", - "get_episode", "get_episode_id", "get_episodefile", "get_episodefile_id", - "get_calendar", "get_calendar_id", "get_history", "get_history_series", - "get_history_since", "get_queue", "get_queue_details", "get_queue_status", - "get_wanted_missing", "get_wanted_missing_id", "get_wanted_cutoff", - "get_wanted_cutoff_id", "get_qualityprofile", "get_qualityprofile_id", - "get_languageprofile", "get_languageprofile_id", "get_tag", "get_tag_id", - "get_tag_detail", "get_tag_detail_id", "get_command", "get_command_id", - "get_release", - } -) - -PSEUDO_ACTIONS = frozenset( - { - "find_series", - "get_season_summary", - "search_releases", - "preview_episode_search", - "preview_release_grab", - } -) - -WRITE_ACTIONS = frozenset({"start_episode_search", "grab_release"}) MAX_COLLECTION_ITEMS = 50 APPROVAL_TTL_SECONDS = 600 _APPROVALS: dict[str, tuple[float, str]] = {} +async def _call(client: Any, action: str, kwargs: dict[str, Any] | None = None) -> Any: + """Call one known upstream API method without exposing dynamic dispatch.""" + method = getattr(client, action) + return await asyncio.to_thread(method, **(kwargs or {})) + + def _plain(value: Any) -> Any: if hasattr(value, "model_dump") and callable(value.model_dump): return value.model_dump() @@ -173,9 +153,9 @@ def _compact_result(action: str, value: Any) -> Any: async def _find_series(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: query = str(kwargs.get("query", "")).strip() if len(query) < 2: - raise ValueError("find_series requires params_json with a query of at least 2 characters") + raise ValueError("query must contain at least two characters") limit = max(1, min(int(kwargs.get("limit", 8)), 15)) - raw = _unwrap(await run_blocking(dispatch, client, "get_series", {}, service="arr-sonarr")) + raw = _unwrap(await _call(client, "get_series")) words = [word for word in re.findall(r"[a-z0-9]+", query.casefold()) if len(word) > 1] matches = [] for item in raw if isinstance(raw, list) else []: @@ -197,26 +177,12 @@ async def _find_series(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: async def _season_summary(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: series_id = int(kwargs["series_id"]) season_number = int(kwargs["season_number"]) - series = _unwrap( - await run_blocking(dispatch, client, "get_series_id", {"id": series_id}, service="arr-sonarr") - ) + series = _unwrap(await _call(client, "get_series_id", {"id": series_id})) episodes = _unwrap( - await run_blocking( - dispatch, - client, - "get_episode", - {"seriesId": series_id, "seasonNumber": season_number}, - service="arr-sonarr", - ) + await _call(client, "get_episode", {"seriesId": series_id, "seasonNumber": season_number}) ) files = _unwrap( - await run_blocking( - dispatch, - client, - "get_episodefile", - {"seriesId": series_id}, - service="arr-sonarr", - ) + await _call(client, "get_episodefile", {"seriesId": series_id}) ) selected_episodes = [ _compact_episode(item) for item in episodes @@ -255,7 +221,7 @@ async def _search_releases(client: Any, kwargs: dict[str, Any]) -> dict[str, Any query["episodeId"] = int(episode_id) if season_number is not None: query["seasonNumber"] = int(season_number) - raw = await run_blocking(dispatch, client, "get_release", query, service="arr-sonarr") + raw = await _call(client, "get_release", query) raw = _unwrap(raw) if release_group and isinstance(raw, list): needle = release_group.casefold() @@ -322,17 +288,9 @@ async def _resolve_episode_search(client: Any, kwargs: dict[str, Any]) -> dict[s if series_id < 1 or season_number < 0: raise ValueError("series_id and season_number must be non-negative identifiers") - series = _unwrap( - await run_blocking(dispatch, client, "get_series_id", {"id": series_id}, service="arr-sonarr") - ) + series = _unwrap(await _call(client, "get_series_id", {"id": series_id})) episodes = _unwrap( - await run_blocking( - dispatch, - client, - "get_episode", - {"seriesId": series_id, "seasonNumber": season_number}, - service="arr-sonarr", - ) + await _call(client, "get_episode", {"seriesId": series_id, "seasonNumber": season_number}) ) candidates = [ item for item in episodes @@ -414,13 +372,7 @@ async def _start_episode_search(client: Any, kwargs: dict[str, Any]) -> dict[str "scope": resolved["scope"], } command = _unwrap( - await run_blocking( - dispatch, - client, - "post_command", - {"name": "EpisodeSearch", "episodeIds": episode_ids}, - service="arr-sonarr", - ) + await _call(client, "post_command", {"data": {"name": "EpisodeSearch", "episodeIds": episode_ids}}) ) return { "ok": True, @@ -464,7 +416,7 @@ async def _resolve_release_grab(client: Any, kwargs: dict[str, Any]) -> dict[str ) query = _release_query(kwargs) raw = _unwrap( - await run_blocking(dispatch, client, "get_release", query, service="arr-sonarr") + await _call(client, "get_release", query) ) matches = [ item for item in raw if isinstance(item, dict) and str(item.get("guid", "")) == guid @@ -484,13 +436,7 @@ async def _resolve_release_grab(client: Any, kwargs: dict[str, Any]) -> dict[str season_number = query.get("seasonNumber") if season_number is not None: episodes = _unwrap( - await run_blocking( - dispatch, - client, - "get_episode", - {"seriesId": query["seriesId"], "seasonNumber": season_number}, - service="arr-sonarr", - ) + await _call(client, "get_episode", {"seriesId": query["seriesId"], "seasonNumber": season_number}) ) if isinstance(episodes, list): existing_file_count = sum( @@ -587,13 +533,7 @@ async def _grab_release(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: if not secrets.compare_digest(approval[1], resolved["fingerprint"]): raise PermissionError("Approval ticket does not match this exact release grab") result = _unwrap( - await run_blocking( - dispatch, - client, - "post_release", - resolved["raw_release"], - service="arr-sonarr", - ) + await _call(client, "post_release", {"data": resolved["raw_release"]}) ) return { "ok": True, @@ -612,59 +552,125 @@ async def _grab_release(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: def register_sonarr_tools(mcp: FastMCP) -> None: @mcp.tool(tags={"sonarr"}) - async def sonarr_action( - action: str = Field( - description="Choose one Sonarr operation. Use find_series only to resolve a TV-series name. For questions whether a release/group/season pack is AVAILABLE, immediately use search_releases with release_group and season_pack_only as requested; do not infer availability from existing library files or call get_season_summary first. If the user requests a SPECIFIC release, group, or complete season pack, NEVER use automatic episode search: use preview_release_grab with its exact GUID, wait for approval, then grab_release. preview_episode_search/start_episode_search automatically chooses releases and may immediately download them; use it only when the user accepts any suitable release." - ), - params_json: str = Field( - default="{}", - description="JSON object encoded as a string. Common forms: find_series {\"query\":\"Title\"}; get_season_summary {\"series_id\":123,\"season_number\":2}; search_releases {\"series_id\":123,\"season_number\":2,\"release_group\":\"FuN\",\"season_pack_only\":true}; preview_release_grab {\"series_id\":123,\"season_number\":2,\"guid\":\"exact-guid-from-search\"}; preview_episode_search {\"series_id\":123,\"season_number\":2,\"episode_numbers\":[2,3]}. For grab_release or start_episode_search reuse the exact preview scope and add confirm:true plus approval_ticket.", - ), + async def sonarr_find_series( + query: str = Field(description="Series title or title fragment, for example Mord ist ihr Hobby."), + limit: int = Field(default=8, ge=1, le=15), ) -> Any: - """USE ONLY for TV-series tasks managed by Sonarr: identify a series, inspect missing episodes, search configured indexers, grab one explicitly selected and approved release, or start an approved automatic missing-episode search. A specific season pack/release must use preview_release_grab then grab_release; automatic episode search is not equivalent. DO NOT use for movies (use Radarr), public-web research, media playback, filesystem copying, or direct URL downloads. Read-only by default; monitoring is never changed.""" - if action in {"list_actions", "help", "actions"}: - return { - "service": "sonarr", - "access_mode": "write" if os.environ.get("ARR_MCP_WRITE", "").strip().lower() in ("1", "true", "yes", "on") else "read-only", - "actions": sorted(READ_ONLY_ACTIONS), - "write_actions": sorted(WRITE_ACTIONS) if os.environ.get("ARR_MCP_WRITE", "").strip().lower() in ("1", "true", "yes", "on") else [], - "preferred_compact_actions": sorted(PSEUDO_ACTIONS), - } - allow_write = os.environ.get("ARR_MCP_WRITE", "").strip().lower() in ( - "1", "true", "yes", "on" - ) - if action in READ_ONLY_ACTIONS | PSEUDO_ACTIONS: - pass - elif allow_write and action in WRITE_ACTIONS: - pass - else: - if allow_write: - raise PermissionError( - f"Sonarr MCP write mode is enabled, but action '{action}' " - "is not in the allowed write set. Allowed: " - f"{sorted(WRITE_ACTIONS)}" - ) - raise PermissionError( - f"Sonarr action '{action}' is blocked by the server-side " - "read-only policy. Set ARR_MCP_WRITE=1 to enable write mode." - ) + """Find a Sonarr series by name. READ ONLY. Never changes monitoring and never starts a search or download.""" client = get_sonarr_client() - kwargs = {k: v for k, v in json.loads(params_json).items() if v is not None} - if action == "find_series": - return await _find_series(client, kwargs) - if action == "get_season_summary": - return await _season_summary(client, kwargs) - if action == "search_releases": - return await _search_releases(client, kwargs) - if action == "preview_episode_search": - return await _preview_episode_search(client, kwargs) - if action == "preview_release_grab": - return await _preview_release_grab(client, kwargs) - if action == "start_episode_search": - return await _start_episode_search(client, kwargs) - if action == "grab_release": - return await _grab_release(client, kwargs) - result = await run_blocking( - dispatch, client, action, kwargs, service="arr-sonarr" + return await _find_series(client, {"query": query, "limit": limit}) + + @mcp.tool(tags={"sonarr"}) + async def sonarr_get_season_summary( + series_id: int = Field(description="Exact Sonarr series id returned by sonarr_find_series."), + season_number: int = Field(ge=0, description="Season number."), + ) -> Any: + """Return episodes and existing files for one Sonarr season. READ ONLY. File names do not prove audio language.""" + return await _season_summary( + get_sonarr_client(), + {"series_id": series_id, "season_number": season_number}, + ) + + @mcp.tool(tags={"sonarr"}) + async def sonarr_search_releases( + series_id: int = Field(description="Exact Sonarr series id."), + season_number: int | None = Field(default=None, ge=0), + episode_id: int | None = Field(default=None, ge=1), + release_group: str = Field(default="", description="Optional release-group filter, for example FuN."), + season_pack_only: bool = Field(default=False, description="Only complete season packs. Requires season_number."), + ) -> Any: + """Search Sonarr's configured indexers and return compact matching releases. READ ONLY: does not alter monitoring, start automatic search, grab, or download anything.""" + return await _search_releases( + get_sonarr_client(), + { + "series_id": series_id, + "season_number": season_number, + "episode_id": episode_id, + "release_group": release_group, + "season_pack_only": season_pack_only, + }, + ) + + @mcp.tool(tags={"sonarr"}) + async def sonarr_system_status() -> Any: + """Return compact Sonarr version and runtime status. READ ONLY.""" + return _compact_result("get_system_status", await _call(get_sonarr_client(), "get_system_status")) + + if os.environ.get("ARR_MCP_WRITE", "").strip().lower() not in ("1", "true", "yes", "on"): + return + + @mcp.tool(tags={"sonarr", "write"}) + async def sonarr_preview_release_grab( + series_id: int = Field(description="Exact Sonarr series id."), + guid: str = Field(description="Exact GUID returned by sonarr_search_releases."), + season_number: int | None = Field(default=None, ge=0), + episode_id: int | None = Field(default=None, ge=1), + force: bool = Field(default=False), + ) -> Any: + """Preview one exact release grab and issue a short-lived approval ticket. Does not download anything.""" + return await _preview_release_grab( + get_sonarr_client(), + { + "series_id": series_id, + "guid": guid, + "season_number": season_number, + "episode_id": episode_id, + "force": force, + }, + ) + + @mcp.tool(tags={"sonarr", "write"}) + async def sonarr_grab_release( + series_id: int = Field(description="Same series id used for the preview."), + guid: str = Field(description="Same exact release GUID used for the preview."), + approval_ticket: str = Field(description="Ticket returned by sonarr_preview_release_grab."), + confirm: bool = Field(description="Must be true after explicit user approval."), + season_number: int | None = Field(default=None, ge=0), + episode_id: int | None = Field(default=None, ge=1), + force: bool = Field(default=False), + ) -> Any: + """Grab exactly one previously previewed release. WRITE: can immediately start a download.""" + return await _grab_release( + get_sonarr_client(), + { + "series_id": series_id, + "guid": guid, + "approval_ticket": approval_ticket, + "confirm": confirm, + "season_number": season_number, + "episode_id": episode_id, + "force": force, + }, + ) + + @mcp.tool(tags={"sonarr", "write"}) + async def sonarr_preview_episode_search( + series_id: int = Field(description="Exact Sonarr series id."), + season_number: int = Field(ge=0), + episode_numbers: list[int] | None = Field(default=None, description="Optional episode numbers; omit for all missing episodes in the season."), + ) -> Any: + """Preview an automatic Sonarr episode search. Does not change monitoring or download anything.""" + return await _preview_episode_search( + get_sonarr_client(), + {"series_id": series_id, "season_number": season_number, "episode_numbers": episode_numbers}, + ) + + @mcp.tool(tags={"sonarr", "write"}) + async def sonarr_start_episode_search( + series_id: int = Field(description="Same series id used for the preview."), + season_number: int = Field(ge=0), + approval_ticket: str = Field(description="Ticket returned by sonarr_preview_episode_search."), + confirm: bool = Field(description="Must be true after explicit user approval."), + episode_numbers: list[int] | None = Field(default=None), + ) -> Any: + """Start a previously previewed automatic episode search. WRITE: may immediately download releases.""" + return await _start_episode_search( + get_sonarr_client(), + { + "series_id": series_id, + "season_number": season_number, + "episode_numbers": episode_numbers, + "approval_ticket": approval_ticket, + "confirm": confirm, + }, ) - return _compact_result(action, result) diff --git a/services/arr-mcp/Dockerfile b/services/arr-mcp/Dockerfile index 6a0e747..ea0ba76 100644 --- a/services/arr-mcp/Dockerfile +++ b/services/arr-mcp/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.13-slim AS builder +FROM python:3.14-slim@sha256:cea0e6040540fb2b965b6e7fb5ffa00871e632eef63719f0ea54bca189ce14a6 AS builder ARG ARR_MCP_VERSION=1.0.1 @@ -14,7 +14,7 @@ RUN site_packages="$(/opt/arr-mcp/venv/bin/python -c 'import site; print(site.ge && install -m 0644 /tmp/mcp_radarr.py "$site_packages/arr_mcp/mcp/mcp_radarr.py" \ && /opt/arr-mcp/venv/bin/python -m compileall -q "$site_packages/arr_mcp/mcp" -FROM python:3.13-slim +FROM python:3.14-slim@sha256:cea0e6040540fb2b965b6e7fb5ffa00871e632eef63719f0ea54bca189ce14a6 RUN groupadd --system --gid 10001 arrmcp \ && useradd --system --uid 10001 --gid 10001 --no-create-home arrmcp @@ -23,6 +23,9 @@ COPY --from=builder /opt/arr-mcp /opt/arr-mcp COPY services/arr-mcp/run.sh /usr/local/bin/run-arr-mcp ENV PATH="/opt/arr-mcp/venv/bin:${PATH}" \ + HOME=/tmp \ + XDG_CONFIG_HOME=/tmp/.config \ + XDG_CACHE_HOME=/tmp/.cache \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 @@ -32,6 +35,6 @@ USER 10001:10001 EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD python -c "import socket; s=socket.create_connection(('127.0.0.1',8000),3); s.close()" + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)" ENTRYPOINT ["/usr/local/bin/run-arr-mcp"] diff --git a/services/arr-mcp/README.md b/services/arr-mcp/README.md index 6d9227a..173abf5 100644 --- a/services/arr-mcp/README.md +++ b/services/arr-mcp/README.md @@ -1,91 +1,102 @@ -# ARR-MCP auf Unraid +# Mikes ARR-MCP -Dieser Ordner baut **einen eigenständigen Container nur für Sonarr und Radarr**. -Hermes, Pi Coding und andere MCP-Clients erreichen ihn anschließend über -`http://:8207/mcp`. +Ein kleiner, eigenständiger Docker-Container für **Sonarr und Radarr**. Das +Dockerfile basiert bereits auf Debian Slim; ein leerer Debian-Container muss +nicht vorher angelegt oder nachträglich verändert werden. -## Warum dieser Fork bleibt +MCP-Endpunkt nach der Standardinstallation: -Geprüft am 27. August 2026: +```text +http://:8207/mcp +``` -- [`Knuckles-Team/arr-mcp`](https://github.com/Knuckles-Team/arr-mcp) 2.1.0 - ist der moderne Upstream unseres Pakets, stellt - in der kompakten Oberfläche aber weiterhin generische `*_action`-Werkzeuge - mit frei wählbaren API-Methoden bereit. Das führte bei lokalen Modellen zu - falschen Aktionen, unnötigen Schemaabfragen und großen Antworten. -- Andere öffentliche ARR-MCPs bieten teilweise mehr Dienste oder eine eigene - Weboberfläche, ersetzen aber nicht unser kompaktes Radarr-Codec-Inventar und - den an eine Vorschau gebundenen Sonarr-Freigabeablauf. +## Warum nicht einfach der unveränderte Knuckles-MCP? -Wir bleiben deshalb vorläufig bei `arr-mcp` 1.0.1 plus zwei kleinen, -versionierten Patches. Ein späterer Wechsel ist sinnvoll, sobald ein Upstream -diese Eigenschaften ohne lokale Anpassungen anbietet. +Stand 28. August 2026 wurde `Knuckles-Team/arr-mcp` 2.1.0 geprüft. Übernommen +werden dessen Python-3.14-Slim-Basis, `/health`-Check, Prozess-Init und begrenzte +Docker-Logs. Nicht übernommen wird die generische Oberfläche mit +`sonarr_action(action, params_json)` und `radarr_action(action, params_json)`. +Ein kleines Modell muss dabei Methodennamen und Parameter erraten und kann +versehentlich eine Schreibaktion wählen. Das Paket 2.1.0 selbst wird noch nicht +als Basis verwendet: Seine veröffentlichte PyPI-Version verlangt derzeit ein +nicht öffentlich verfügbares `agent-utilities >=2.0.0`. Die reproduzierbar +installierbare Basis bleibt deshalb vorläufig `arr-mcp[mcp]==1.0.1`. -## Besondere Werkzeuge +Dieser Fork bietet stattdessen eindeutige Werkzeuge mit festen Parametern: -- `radarr_movie_codec_inventory`: kompakte, paginierte Liste mit Codec, - Auflösung, Sprachen und Dateigröße; keine riesigen Radarr-Rohantworten. -- `sonarr_action`: begrenzte, kompakte Sonarr-Aktionen wie `find_series`, - `get_season_summary` und `search_releases`. -- Sonarr-Schreibaktionen sind standardmäßig abgeschaltet. Im Schreibmodus - benötigen `start_episode_search` und `grab_release` zuerst eine passende - Vorschau, danach eine ausdrückliche Freigabe und ein kurzlebiges Ticket. +```text +sonarr_find_series +sonarr_get_season_summary +sonarr_search_releases +sonarr_system_status +radarr_find_movie +radarr_movie_codec_inventory +radarr_search_releases +``` -## Empfohlene Installation auf Unraid +Alle sieben sind rein lesend. Es gibt keinen Raw-Request und kein generisches +Action-Werkzeug. `sonarr_search_releases` ändert insbesondere kein Monitoring +und startet weder automatische Suche noch Download. -Im vollständigen Checkout des Repositories als root: +Nur mit `ARR_MCP_WRITE=1` erscheinen zusätzlich: + +```text +sonarr_preview_release_grab +sonarr_grab_release +sonarr_preview_episode_search +sonarr_start_episode_search +``` + +Die beiden ausführenden Werkzeuge verlangen eine vorherige Vorschau, eine +ausdrückliche Bestätigung und ein kurzlebiges Ticket. Radarr-Schreibwerkzeuge +sind absichtlich nicht enthalten. + +## Installation auf Unraid + +Im vollständigen Checkout als root genau einen Befehl ausführen: ```bash ./services/arr-mcp/install-on-unraid.sh ``` -Beim ersten Aufruf entsteht: +Beim ersten Lauf wird nur diese Datei angelegt: ```text /mnt/nvme-storage/appdata/ARR-MCP/arr-mcp.env ``` -Dort Sonarr- und Radarr-Schlüssel eintragen. Danach denselben Installationsbefehl -erneut ausführen. Das Skript: - -1. baut das lokale Image `mike-ai/arr-mcp:1.0.0` aus `python:3.13-slim`, -2. startet ausschließlich den Container `ARR-MCP` im normalen Bridge-Netz, -3. wartet auf den Healthcheck und -4. installiert das DockerMan-Template für spätere Bedienung in der Unraid-GUI. - -Es liest oder druckt keine Schlüssel. Eine vorhandene Konfiguration wird nie -überschrieben. +Dort `SONARR_TOKEN` und `RADARR_TOKEN` eintragen und denselben Befehl erneut +starten. Das Skript baut `mike-ai/arr-mcp:1.1.0`, startet ausschließlich +`ARR-MCP` und installiert die vorhandene Unraid-DockerMan-Vorlage. Eine +vorhandene Env-Datei wird weder angezeigt noch überschrieben. ### Kurzer Auftrag für Hermes/Qwen ```text -Klone auf Unraid das Repository -ssh://git@192.168.1.2:33/michael/AI-Profile-Router.git, lies vollständig -services/arr-mcp/README.md und installiere ausschließlich nach dem dort -dokumentierten Weg. Verwende services/arr-mcp/install-on-unraid.sh. Zeige keine -API-Schlüssel an und überschreibe keine vorhandene arr-mcp.env. Stoppe, falls -Werte fehlen, und sage mir nur, welche Variablennamen ich eintragen muss. -Entferne den bisherigen ARR-Endpunkt erst nach einem erfolgreichen Healthcheck -und MCP-Handshake des neuen Containers. +Installiere Mikes ARR-MCP aus +ssh://git@192.168.1.2:33/michael/AI-Profile-Router.git auf Unraid. Lies dazu +services/arr-mcp/README.md und führe ausschließlich +services/arr-mcp/install-on-unraid.sh aus. Erfinde keinen eigenen Container und +keine andere ARR-Konfiguration. Falls SONARR_TOKEN oder RADARR_TOKEN fehlen, +stoppe und nenne mir nur die fehlenden Variablennamen; zeige keine vorhandenen +Werte an. Melde am Ende nur Containerstatus, Healthcheck, MCP-URL und die +angebotenen Werkzeugnamen. ``` -## Installation in einem vorhandenen Debian-Slim-Container +## Vorhandener nackter Debian-Slim-Container -Nur verwenden, wenn bereits bewusst ein nackter Debian-Slim-Container mit dem -kompletten Git-Checkout läuft: +Nur als Alternative, wenn bewusst bereits ein persistenter Debian-Slim- +Container mit dem kompletten Repository läuft: ```bash ./services/arr-mcp/install-in-debian-slim.sh ``` -Danach startet `/usr/local/bin/run-arr-mcp` den Server. Dieser Weg funktioniert, -ist aber weniger reproduzierbar als der Dockerfile-Build: Eine Neuerstellung des -nackten Containers entfernt die Installation. Für den Produktivbetrieb deshalb -den ersten Weg verwenden. +Danach startet `/usr/local/bin/run-arr-mcp` den Server. Dieser Weg ist weniger +reproduzierbar; bei Neuerstellung des Containers geht die nachträgliche +Installation verloren. Der Dockerfile-Build oben ist deshalb empfohlen. -## Hermes oder anderer MCP-Client - -Direkte Registrierung: +## MCP-Client eintragen ```yaml mcp_servers: @@ -94,62 +105,22 @@ mcp_servers: timeout: 600 ``` -Nach dem Eintragen die MCP-Liste des Clients neu laden beziehungsweise einen -neuen Chat öffnen. Sonarr und Radarr erscheinen als Werkzeuge desselben -Fach-MCPs; sie sind nicht Bestandteil des Hermes-Containers. +Danach die Werkzeugliste neu laden oder einen neuen Chat öffnen. -## Schreiben aktivieren +## Test, Update und Entfernung -In `arr-mcp.env`: - -```text -ARR_MCP_WRITE=1 -``` - -Danach nur diesen Container neu starten. Selbst dann erlaubt der Sonarr-Patch -nur die dokumentierten, ticketgebundenen Aktionen. Radarr besitzt derzeit noch -keinen gleichwertigen Freigabeablauf; Änderungen dort nur auf einen eindeutigen -Benutzerauftrag ausführen. - -## Update und Test - -Nach einem Git-Update denselben Befehl erneut ausführen: +Offline-Tests: ```bash -./services/arr-mcp/install-on-unraid.sh +python3 -m unittest dev/test_sonarr_release_grab.py dev/test_radarr_patch.py ``` -Status und Logs: +Update nach `git pull`: denselben Installationsbefehl erneut ausführen. -```bash -docker inspect --format '{{.State.Health.Status}}' ARR-MCP -docker logs --tail 100 ARR-MCP -``` - -Die Offline-Tests des Forks: - -```bash -python3 dev/test_radarr_patch.py -python3 -m unittest dev/test_sonarr_release_grab.py -``` - -## Deinstallation +Entfernung: ```bash ./services/arr-mcp/install-on-unraid.sh uninstall ``` -Der Container verschwindet, während die Appdata-Konfiguration als Rückfall -erhalten bleibt. Erst wenn sie wirklich nicht mehr benötigt wird, kann -`/mnt/nvme-storage/appdata/ARR-MCP` separat gelöscht werden. - -## Regeln für Hermes/Qwen - -1. Diese README vollständig lesen. -2. Keine eigene ARR-Implementierung und keinen zweiten Sonarr-/Radarr-Dienst - erstellen. -3. Ausschließlich `install-on-unraid.sh` verwenden; keine Befehle improvisieren. -4. Vorhandene `arr-mcp.env` weder anzeigen noch überschreiben. -5. Nach Installation Healthcheck und MCP-Handshake prüfen. -6. Den alten MCPHub-/Athena-Endpunkt erst entfernen, wenn der neue Endpunkt - nachweislich funktioniert. +Die Appdata-Konfiguration bleibt dabei als Rückfall erhalten. diff --git a/services/arr-mcp/compose.yaml b/services/arr-mcp/compose.yaml index b8fc558..50b3d24 100644 --- a/services/arr-mcp/compose.yaml +++ b/services/arr-mcp/compose.yaml @@ -5,8 +5,9 @@ services: dockerfile: services/arr-mcp/Dockerfile args: ARR_MCP_VERSION: "1.0.1" - image: mike-ai/arr-mcp:1.0.0 + image: mike-ai/arr-mcp:1.1.0 container_name: ARR-MCP + init: true restart: unless-stopped network_mode: bridge env_file: @@ -21,8 +22,15 @@ services: security_opt: - no-new-privileges:true pids_limit: 256 + mem_limit: 1g + cpus: 1.0 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" healthcheck: - test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),3); s.close()"] + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)"] interval: 30s timeout: 5s start_period: 20s diff --git a/services/arr-mcp/install-on-unraid.sh b/services/arr-mcp/install-on-unraid.sh index 245c885..f8d4e1b 100755 --- a/services/arr-mcp/install-on-unraid.sh +++ b/services/arr-mcp/install-on-unraid.sh @@ -60,7 +60,7 @@ done docker build \ --build-arg ARR_MCP_VERSION=1.0.1 \ -f "$SCRIPT_DIR/Dockerfile" \ - -t mike-ai/arr-mcp:1.0.0 \ + -t mike-ai/arr-mcp:1.1.0 \ "$REPO_ROOT" ARR_MCP_ENV_FILE="$ENV_FILE" ARR_MCP_PUBLIC_PORT="$PUBLIC_PORT" \