Harden ARR MCP tool surface and installer

This commit is contained in:
Mikei386
2026-08-28 13:05:21 +02:00
parent 0008dd3b3e
commit da6e762d72
9 changed files with 380 additions and 301 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<Container version="2"> <Container version="2">
<Name>ARR-MCP</Name> <Name>ARR-MCP</Name>
<Repository>mike-ai/arr-mcp:1.0.0</Repository> <Repository>mike-ai/arr-mcp:1.1.0</Repository>
<Network>bridge</Network> <Network>bridge</Network>
<Shell>sh</Shell> <Shell>sh</Shell>
<Privileged>false</Privileged> <Privileged>false</Privileged>
@@ -18,6 +18,6 @@
<DateInstalled/> <DateInstalled/>
<DonateText/> <DonateText/>
<DonateLink/> <DonateLink/>
<Requires>Das lokale Image mike-ai/arr-mcp:1.0.0 muss vorher mit services/arr-mcp/install-on-unraid.sh gebaut worden sein.</Requires> <Requires>Das lokale Image mike-ai/arr-mcp:1.1.0 muss vorher mit services/arr-mcp/install-on-unraid.sh gebaut worden sein.</Requires>
<Config Name="MCP Port" Target="8000" Default="8207" Mode="tcp" Description="Streamable-HTTP-Endpunkt; MCP-URL ist http://UNRAID-IP:8207/mcp." Type="Port" Display="always" Required="true" Mask="false">8207</Config> <Config Name="MCP Port" Target="8000" Default="8207" Mode="tcp" Description="Streamable-HTTP-Endpunkt; MCP-URL ist http://UNRAID-IP:8207/mcp." Type="Port" Display="always" Required="true" Mask="false">8207</Config>
</Container> </Container>
+18 -9
View File
@@ -14,12 +14,6 @@ SOURCE = Path(__file__).parents[1] / "platform/mcp/patches/mcp_radarr.py"
def load_module(): 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 = types.ModuleType("fastmcp")
fastmcp.FastMCP = object fastmcp.FastMCP = object
sys.modules["fastmcp"] = fastmcp sys.modules["fastmcp"] = fastmcp
@@ -66,9 +60,24 @@ class RadarrPatchTests(unittest.TestCase):
self.assertEqual(result["returned"], 2) self.assertEqual(result["returned"], 2)
self.assertTrue(result["hasMore"]) self.assertTrue(result["hasMore"])
def test_internal_transport_and_power_actions_are_hidden(self): def test_surface_has_only_explicit_read_tools(self):
client = types.SimpleNamespace(actions=["get_movie", "request", "post_system_shutdown", "get_queue"]) class FakeMcp:
self.assertEqual(self.module._safe_actions(client), ["get_movie", "get_queue"]) 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__": if __name__ == "__main__":
+45
View File
@@ -8,6 +8,7 @@ import sys
import types import types
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
SOURCE = Path(__file__).parents[1] / "platform/mcp/patches/mcp_sonarr.py" 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"]["total"], 1)
self.assertEqual(result["results"]["items"][0]["guid"], "exact-guid") 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+84 -47
View File
@@ -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 typing import Any
from agent_utilities.mcp_utilities import dispatch, public_actions, run_blocking
from fastmcp import FastMCP from fastmcp import FastMCP
from pydantic import Field from pydantic import Field
from arr_mcp.auth import get_radarr_client from arr_mcp.auth import get_radarr_client
BLOCKED_ACTIONS = { async def _call(client: Any, action: str, kwargs: dict[str, Any] | None = None) -> Any:
"request", "get_", "get_api", "get_content_path", "get_path", """Call one known upstream API method without exposing dynamic dispatch."""
"get_login", "get_logout", "post_login", "post_system_restart", return await asyncio.to_thread(getattr(client, action), **(kwargs or {}))
"post_system_shutdown",
}
def _safe_actions(client: Any) -> list[str]: def _plain(value: Any) -> Any:
"""Hide transport, authentication and service-power implementation methods.""" if hasattr(value, "model_dump") and callable(value.model_dump):
return [name for name in public_actions(client) if name not in BLOCKED_ACTIONS] 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]: def _codec_aliases(value: str) -> set[str]:
@@ -91,6 +121,23 @@ def _compact_inventory(
def register_radarr_tools(mcp: FastMCP) -> None: 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"}) @mcp.tool(tags={"radarr"})
async def radarr_movie_codec_inventory( async def radarr_movie_codec_inventory(
video_codecs: str = Field( video_codecs: str = Field(
@@ -103,48 +150,38 @@ def register_radarr_tools(mcp: FastMCP) -> None:
) -> Any: ) -> 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.""" """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() client = get_radarr_client()
response = await run_blocking(client.get_movie) response = _plain(await _call(client, "get_movie"))
movies = response.get("result", response) if isinstance(response, dict) else response movies = _movies(response)
if not isinstance(movies, list): if not movies and response not in ([], {"result": []}):
raise RuntimeError("Radarr get_movie returned an unexpected response") raise RuntimeError("Radarr get_movie returned an unexpected response")
return _compact_inventory( return _compact_inventory(
movies, codecs=video_codecs, query=query, offset=offset, limit=limit, movies, codecs=video_codecs, query=query, offset=offset, limit=limit,
) )
@mcp.tool(tags={"radarr"}) @mcp.tool(tags={"radarr"})
async def radarr_action( async def radarr_search_releases(
action: str = Field( movie_id: int = Field(ge=1, description="Exact Radarr movie id returned by radarr_find_movie."),
description=( release_group: str = Field(default="", description="Optional release-group filter."),
"A named Radarr API operation. Prefer radarr_movie_codec_inventory for " limit: int = Field(default=50, ge=1, le=100),
"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.",
),
) -> Any: ) -> Any:
"""Other Radarr operations for movies, queue, history, releases, profiles and health. TV episodes belong to Sonarr. Mutations require an explicit user request.""" """Search Radarr's configured indexers for one movie. READ ONLY: never grabs or downloads a release."""
client = get_radarr_client() raw = _plain(await _call(get_radarr_client(), "get_release", {"movieId": movie_id}))
actions = _safe_actions(client) if isinstance(raw, dict) and "result" in raw:
if action in {"list_actions", "actions", "help", "capabilities"}: raw = raw["result"]
return {"service": "arr-radarr", "actions": actions} releases = [item for item in raw if isinstance(item, dict)] if isinstance(raw, list) else []
if action not in actions: 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 { return {
"ok": False, "movie_id": movie_id,
"error": "unknown or unavailable Radarr action", "release_group_filter": release_group or None,
"action": action, "total": len(releases),
"retry": False, "returned": len(compact),
"hint": "Use list_actions once or radarr_movie_codec_inventory for file/codec questions.", "truncated": len(releases) > limit,
"results": compact,
"download_started": False,
} }
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"
)
+138 -132
View File
@@ -3,6 +3,7 @@
CONCEPT:ECO-4.82 — gitlab-style organized per-service tool surface. CONCEPT:ECO-4.82 — gitlab-style organized per-service tool surface.
""" """
import asyncio
import os import os
import json import json
import re import re
@@ -10,44 +11,23 @@ import secrets
import time import time
from typing import Any from typing import Any
from agent_utilities.mcp_utilities import dispatch, run_blocking
from fastmcp import FastMCP from fastmcp import FastMCP
from pydantic import Field from pydantic import Field
from arr_mcp.auth import get_sonarr_client 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 MAX_COLLECTION_ITEMS = 50
APPROVAL_TTL_SECONDS = 600 APPROVAL_TTL_SECONDS = 600
_APPROVALS: dict[str, tuple[float, str]] = {} _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: def _plain(value: Any) -> Any:
if hasattr(value, "model_dump") and callable(value.model_dump): if hasattr(value, "model_dump") and callable(value.model_dump):
return 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]: async def _find_series(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
query = str(kwargs.get("query", "")).strip() query = str(kwargs.get("query", "")).strip()
if len(query) < 2: 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)) 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] words = [word for word in re.findall(r"[a-z0-9]+", query.casefold()) if len(word) > 1]
matches = [] matches = []
for item in raw if isinstance(raw, list) else []: 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]: async def _season_summary(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
series_id = int(kwargs["series_id"]) series_id = int(kwargs["series_id"])
season_number = int(kwargs["season_number"]) season_number = int(kwargs["season_number"])
series = _unwrap( series = _unwrap(await _call(client, "get_series_id", {"id": series_id}))
await run_blocking(dispatch, client, "get_series_id", {"id": series_id}, service="arr-sonarr")
)
episodes = _unwrap( episodes = _unwrap(
await run_blocking( await _call(client, "get_episode", {"seriesId": series_id, "seasonNumber": season_number})
dispatch,
client,
"get_episode",
{"seriesId": series_id, "seasonNumber": season_number},
service="arr-sonarr",
)
) )
files = _unwrap( files = _unwrap(
await run_blocking( await _call(client, "get_episodefile", {"seriesId": series_id})
dispatch,
client,
"get_episodefile",
{"seriesId": series_id},
service="arr-sonarr",
)
) )
selected_episodes = [ selected_episodes = [
_compact_episode(item) for item in 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) query["episodeId"] = int(episode_id)
if season_number is not None: if season_number is not None:
query["seasonNumber"] = int(season_number) 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) raw = _unwrap(raw)
if release_group and isinstance(raw, list): if release_group and isinstance(raw, list):
needle = release_group.casefold() 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: if series_id < 1 or season_number < 0:
raise ValueError("series_id and season_number must be non-negative identifiers") raise ValueError("series_id and season_number must be non-negative identifiers")
series = _unwrap( series = _unwrap(await _call(client, "get_series_id", {"id": series_id}))
await run_blocking(dispatch, client, "get_series_id", {"id": series_id}, service="arr-sonarr")
)
episodes = _unwrap( episodes = _unwrap(
await run_blocking( await _call(client, "get_episode", {"seriesId": series_id, "seasonNumber": season_number})
dispatch,
client,
"get_episode",
{"seriesId": series_id, "seasonNumber": season_number},
service="arr-sonarr",
)
) )
candidates = [ candidates = [
item for item in episodes 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"], "scope": resolved["scope"],
} }
command = _unwrap( command = _unwrap(
await run_blocking( await _call(client, "post_command", {"data": {"name": "EpisodeSearch", "episodeIds": episode_ids}})
dispatch,
client,
"post_command",
{"name": "EpisodeSearch", "episodeIds": episode_ids},
service="arr-sonarr",
)
) )
return { return {
"ok": True, "ok": True,
@@ -464,7 +416,7 @@ async def _resolve_release_grab(client: Any, kwargs: dict[str, Any]) -> dict[str
) )
query = _release_query(kwargs) query = _release_query(kwargs)
raw = _unwrap( raw = _unwrap(
await run_blocking(dispatch, client, "get_release", query, service="arr-sonarr") await _call(client, "get_release", query)
) )
matches = [ matches = [
item for item in raw if isinstance(item, dict) and str(item.get("guid", "")) == guid 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") season_number = query.get("seasonNumber")
if season_number is not None: if season_number is not None:
episodes = _unwrap( episodes = _unwrap(
await run_blocking( await _call(client, "get_episode", {"seriesId": query["seriesId"], "seasonNumber": season_number})
dispatch,
client,
"get_episode",
{"seriesId": query["seriesId"], "seasonNumber": season_number},
service="arr-sonarr",
)
) )
if isinstance(episodes, list): if isinstance(episodes, list):
existing_file_count = sum( 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"]): if not secrets.compare_digest(approval[1], resolved["fingerprint"]):
raise PermissionError("Approval ticket does not match this exact release grab") raise PermissionError("Approval ticket does not match this exact release grab")
result = _unwrap( result = _unwrap(
await run_blocking( await _call(client, "post_release", {"data": resolved["raw_release"]})
dispatch,
client,
"post_release",
resolved["raw_release"],
service="arr-sonarr",
)
) )
return { return {
"ok": True, "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: def register_sonarr_tools(mcp: FastMCP) -> None:
@mcp.tool(tags={"sonarr"}) @mcp.tool(tags={"sonarr"})
async def sonarr_action( async def sonarr_find_series(
action: str = Field( query: str = Field(description="Series title or title fragment, for example Mord ist ihr Hobby."),
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." limit: int = Field(default=8, ge=1, le=15),
),
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.",
),
) -> Any: ) -> 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.""" """Find a Sonarr series by name. READ ONLY. Never changes monitoring and never starts a search or download."""
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."
)
client = get_sonarr_client() client = get_sonarr_client()
kwargs = {k: v for k, v in json.loads(params_json).items() if v is not None} return await _find_series(client, {"query": query, "limit": limit})
if action == "find_series":
return await _find_series(client, kwargs) @mcp.tool(tags={"sonarr"})
if action == "get_season_summary": async def sonarr_get_season_summary(
return await _season_summary(client, kwargs) series_id: int = Field(description="Exact Sonarr series id returned by sonarr_find_series."),
if action == "search_releases": season_number: int = Field(ge=0, description="Season number."),
return await _search_releases(client, kwargs) ) -> Any:
if action == "preview_episode_search": """Return episodes and existing files for one Sonarr season. READ ONLY. File names do not prove audio language."""
return await _preview_episode_search(client, kwargs) return await _season_summary(
if action == "preview_release_grab": get_sonarr_client(),
return await _preview_release_grab(client, kwargs) {"series_id": series_id, "season_number": season_number},
if action == "start_episode_search": )
return await _start_episode_search(client, kwargs)
if action == "grab_release": @mcp.tool(tags={"sonarr"})
return await _grab_release(client, kwargs) async def sonarr_search_releases(
result = await run_blocking( series_id: int = Field(description="Exact Sonarr series id."),
dispatch, client, action, kwargs, service="arr-sonarr" 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)
+6 -3
View File
@@ -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 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" \ && 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" && /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 \ RUN groupadd --system --gid 10001 arrmcp \
&& useradd --system --uid 10001 --gid 10001 --no-create-home 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 COPY services/arr-mcp/run.sh /usr/local/bin/run-arr-mcp
ENV PATH="/opt/arr-mcp/venv/bin:${PATH}" \ ENV PATH="/opt/arr-mcp/venv/bin:${PATH}" \
HOME=/tmp \
XDG_CONFIG_HOME=/tmp/.config \
XDG_CACHE_HOME=/tmp/.cache \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
@@ -32,6 +35,6 @@ USER 10001:10001
EXPOSE 8000 EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ 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"] ENTRYPOINT ["/usr/local/bin/run-arr-mcp"]
+74 -103
View File
@@ -1,91 +1,102 @@
# ARR-MCP auf Unraid # Mikes ARR-MCP
Dieser Ordner baut **einen eigenständigen Container nur für Sonarr und Radarr**. Ein kleiner, eigenständiger Docker-Container für **Sonarr und Radarr**. Das
Hermes, Pi Coding und andere MCP-Clients erreichen ihn anschließend über Dockerfile basiert bereits auf Debian Slim; ein leerer Debian-Container muss
`http://<UNRAID-IP>:8207/mcp`. 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://<UNRAID-IP>:8207/mcp
```
- [`Knuckles-Team/arr-mcp`](https://github.com/Knuckles-Team/arr-mcp) 2.1.0 ## Warum nicht einfach der unveränderte Knuckles-MCP?
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.
Wir bleiben deshalb vorläufig bei `arr-mcp` 1.0.1 plus zwei kleinen, Stand 28. August 2026 wurde `Knuckles-Team/arr-mcp` 2.1.0 geprüft. Übernommen
versionierten Patches. Ein späterer Wechsel ist sinnvoll, sobald ein Upstream werden dessen Python-3.14-Slim-Basis, `/health`-Check, Prozess-Init und begrenzte
diese Eigenschaften ohne lokale Anpassungen anbietet. 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, ```text
Auflösung, Sprachen und Dateigröße; keine riesigen Radarr-Rohantworten. sonarr_find_series
- `sonarr_action`: begrenzte, kompakte Sonarr-Aktionen wie `find_series`, sonarr_get_season_summary
`get_season_summary` und `search_releases`. sonarr_search_releases
- Sonarr-Schreibaktionen sind standardmäßig abgeschaltet. Im Schreibmodus sonarr_system_status
benötigen `start_episode_search` und `grab_release` zuerst eine passende radarr_find_movie
Vorschau, danach eine ausdrückliche Freigabe und ein kurzlebiges Ticket. 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 ```bash
./services/arr-mcp/install-on-unraid.sh ./services/arr-mcp/install-on-unraid.sh
``` ```
Beim ersten Aufruf entsteht: Beim ersten Lauf wird nur diese Datei angelegt:
```text ```text
/mnt/nvme-storage/appdata/ARR-MCP/arr-mcp.env /mnt/nvme-storage/appdata/ARR-MCP/arr-mcp.env
``` ```
Dort Sonarr- und Radarr-Schlüssel eintragen. Danach denselben Installationsbefehl Dort `SONARR_TOKEN` und `RADARR_TOKEN` eintragen und denselben Befehl erneut
erneut ausführen. Das Skript: starten. Das Skript baut `mike-ai/arr-mcp:1.1.0`, startet ausschließlich
`ARR-MCP` und installiert die vorhandene Unraid-DockerMan-Vorlage. Eine
1. baut das lokale Image `mike-ai/arr-mcp:1.0.0` aus `python:3.13-slim`, vorhandene Env-Datei wird weder angezeigt noch überschrieben.
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.
### Kurzer Auftrag für Hermes/Qwen ### Kurzer Auftrag für Hermes/Qwen
```text ```text
Klone auf Unraid das Repository Installiere Mikes ARR-MCP aus
ssh://git@192.168.1.2:33/michael/AI-Profile-Router.git, lies vollständig ssh://git@192.168.1.2:33/michael/AI-Profile-Router.git auf Unraid. Lies dazu
services/arr-mcp/README.md und installiere ausschließlich nach dem dort services/arr-mcp/README.md und führe ausschließlich
dokumentierten Weg. Verwende services/arr-mcp/install-on-unraid.sh. Zeige keine services/arr-mcp/install-on-unraid.sh aus. Erfinde keinen eigenen Container und
API-Schlüssel an und überschreibe keine vorhandene arr-mcp.env. Stoppe, falls keine andere ARR-Konfiguration. Falls SONARR_TOKEN oder RADARR_TOKEN fehlen,
Werte fehlen, und sage mir nur, welche Variablennamen ich eintragen muss. stoppe und nenne mir nur die fehlenden Variablennamen; zeige keine vorhandenen
Entferne den bisherigen ARR-Endpunkt erst nach einem erfolgreichen Healthcheck Werte an. Melde am Ende nur Containerstatus, Healthcheck, MCP-URL und die
und MCP-Handshake des neuen Containers. 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 Nur als Alternative, wenn bewusst bereits ein persistenter Debian-Slim-
kompletten Git-Checkout läuft: Container mit dem kompletten Repository läuft:
```bash ```bash
./services/arr-mcp/install-in-debian-slim.sh ./services/arr-mcp/install-in-debian-slim.sh
``` ```
Danach startet `/usr/local/bin/run-arr-mcp` den Server. Dieser Weg funktioniert, Danach startet `/usr/local/bin/run-arr-mcp` den Server. Dieser Weg ist weniger
ist aber weniger reproduzierbar als der Dockerfile-Build: Eine Neuerstellung des reproduzierbar; bei Neuerstellung des Containers geht die nachträgliche
nackten Containers entfernt die Installation. Für den Produktivbetrieb deshalb Installation verloren. Der Dockerfile-Build oben ist deshalb empfohlen.
den ersten Weg verwenden.
## Hermes oder anderer MCP-Client ## MCP-Client eintragen
Direkte Registrierung:
```yaml ```yaml
mcp_servers: mcp_servers:
@@ -94,62 +105,22 @@ mcp_servers:
timeout: 600 timeout: 600
``` ```
Nach dem Eintragen die MCP-Liste des Clients neu laden beziehungsweise einen Danach die Werkzeugliste neu laden oder einen neuen Chat öffnen.
neuen Chat öffnen. Sonarr und Radarr erscheinen als Werkzeuge desselben
Fach-MCPs; sie sind nicht Bestandteil des Hermes-Containers.
## Schreiben aktivieren ## Test, Update und Entfernung
In `arr-mcp.env`: Offline-Tests:
```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:
```bash ```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 Entfernung:
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
```bash ```bash
./services/arr-mcp/install-on-unraid.sh uninstall ./services/arr-mcp/install-on-unraid.sh uninstall
``` ```
Der Container verschwindet, während die Appdata-Konfiguration als Rückfall Die Appdata-Konfiguration bleibt dabei als Rückfall erhalten.
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.
+10 -2
View File
@@ -5,8 +5,9 @@ services:
dockerfile: services/arr-mcp/Dockerfile dockerfile: services/arr-mcp/Dockerfile
args: args:
ARR_MCP_VERSION: "1.0.1" 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 container_name: ARR-MCP
init: true
restart: unless-stopped restart: unless-stopped
network_mode: bridge network_mode: bridge
env_file: env_file:
@@ -21,8 +22,15 @@ services:
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
pids_limit: 256 pids_limit: 256
mem_limit: 1g
cpus: 1.0
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
healthcheck: 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 interval: 30s
timeout: 5s timeout: 5s
start_period: 20s start_period: 20s
+1 -1
View File
@@ -60,7 +60,7 @@ done
docker build \ docker build \
--build-arg ARR_MCP_VERSION=1.0.1 \ --build-arg ARR_MCP_VERSION=1.0.1 \
-f "$SCRIPT_DIR/Dockerfile" \ -f "$SCRIPT_DIR/Dockerfile" \
-t mike-ai/arr-mcp:1.0.0 \ -t mike-ai/arr-mcp:1.1.0 \
"$REPO_ROOT" "$REPO_ROOT"
ARR_MCP_ENV_FILE="$ENV_FILE" ARR_MCP_PUBLIC_PORT="$PUBLIC_PORT" \ ARR_MCP_ENV_FILE="$ENV_FILE" ARR_MCP_PUBLIC_PORT="$PUBLIC_PORT" \