Harden ARR MCP tool surface and installer
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
return {
|
if needle:
|
||||||
"ok": False,
|
releases = [
|
||||||
"error": "unknown or unavailable Radarr action",
|
item for item in releases
|
||||||
"action": action,
|
if needle in (str(item.get("releaseGroup", "")) + " " + str(item.get("title", ""))).casefold()
|
||||||
"retry": False,
|
]
|
||||||
"hint": "Use list_actions once or radarr_movie_codec_inventory for file/codec questions.",
|
compact = [_compact_release(item) for item in releases[:limit]]
|
||||||
}
|
return {
|
||||||
try:
|
"movie_id": movie_id,
|
||||||
parsed = json.loads(params_json)
|
"release_group_filter": release_group or None,
|
||||||
except json.JSONDecodeError as exc:
|
"total": len(releases),
|
||||||
raise ValueError(f"params_json is not valid JSON: {exc.msg}") from exc
|
"returned": len(compact),
|
||||||
if not isinstance(parsed, dict):
|
"truncated": len(releases) > limit,
|
||||||
raise ValueError("params_json must encode a JSON object")
|
"results": compact,
|
||||||
kwargs = {key: value for key, value in parsed.items() if value is not None}
|
"download_started": False,
|
||||||
return await run_blocking(
|
}
|
||||||
dispatch, client, action, kwargs, service="arr-radarr"
|
|
||||||
)
|
|
||||||
|
|||||||
+138
-132
@@ -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)
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" \
|
||||||
|
|||||||
Reference in New Issue
Block a user