Harden ARR MCP tool surface and installer
This commit is contained in:
@@ -1,25 +1,55 @@
|
||||
"""Small, model-oriented Radarr tools built on the upstream API client."""
|
||||
"""Small, explicit, read-only Radarr tools built on the upstream API client."""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from agent_utilities.mcp_utilities import dispatch, public_actions, run_blocking
|
||||
from fastmcp import FastMCP
|
||||
from pydantic import Field
|
||||
|
||||
from arr_mcp.auth import get_radarr_client
|
||||
|
||||
|
||||
BLOCKED_ACTIONS = {
|
||||
"request", "get_", "get_api", "get_content_path", "get_path",
|
||||
"get_login", "get_logout", "post_login", "post_system_restart",
|
||||
"post_system_shutdown",
|
||||
}
|
||||
async def _call(client: Any, action: str, kwargs: dict[str, Any] | None = None) -> Any:
|
||||
"""Call one known upstream API method without exposing dynamic dispatch."""
|
||||
return await asyncio.to_thread(getattr(client, action), **(kwargs or {}))
|
||||
|
||||
|
||||
def _safe_actions(client: Any) -> list[str]:
|
||||
"""Hide transport, authentication and service-power implementation methods."""
|
||||
return [name for name in public_actions(client) if name not in BLOCKED_ACTIONS]
|
||||
def _plain(value: Any) -> Any:
|
||||
if hasattr(value, "model_dump") and callable(value.model_dump):
|
||||
return value.model_dump()
|
||||
if hasattr(value, "dict") and callable(value.dict):
|
||||
return value.dict()
|
||||
return value
|
||||
|
||||
|
||||
def _movies(value: Any) -> list[dict[str, Any]]:
|
||||
value = _plain(value)
|
||||
if isinstance(value, dict) and "result" in value:
|
||||
value = value["result"]
|
||||
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _compact_movie(movie: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: movie[key]
|
||||
for key in ("id", "title", "originalTitle", "year", "status", "monitored", "hasFile", "path", "tmdbId")
|
||||
if movie.get(key) is not None
|
||||
}
|
||||
|
||||
|
||||
def _compact_release(item: dict[str, Any]) -> dict[str, Any]:
|
||||
quality = item.get("quality") or {}
|
||||
quality_name = (quality.get("quality") or {}).get("name") if isinstance(quality, dict) else None
|
||||
result = {
|
||||
key: item[key]
|
||||
for key in ("guid", "title", "indexer", "indexerId", "size", "age", "seeders", "leechers", "protocol", "downloadAllowed", "releaseGroup")
|
||||
if item.get(key) is not None
|
||||
}
|
||||
if quality_name:
|
||||
result["quality"] = quality_name
|
||||
if isinstance(item.get("rejections"), list) and item["rejections"]:
|
||||
result["rejections"] = [str(reason)[:180] for reason in item["rejections"][:5]]
|
||||
return result
|
||||
|
||||
|
||||
def _codec_aliases(value: str) -> set[str]:
|
||||
@@ -91,6 +121,23 @@ def _compact_inventory(
|
||||
|
||||
|
||||
def register_radarr_tools(mcp: FastMCP) -> None:
|
||||
@mcp.tool(tags={"radarr"})
|
||||
async def radarr_find_movie(
|
||||
query: str = Field(description="Movie title or title fragment."),
|
||||
limit: int = Field(default=10, ge=1, le=25),
|
||||
) -> Any:
|
||||
"""Find a movie already managed by Radarr. READ ONLY. Never starts a search or download."""
|
||||
needle = query.strip().casefold()
|
||||
if len(needle) < 2:
|
||||
raise ValueError("query must contain at least two characters")
|
||||
movies = _movies(await _call(get_radarr_client(), "get_movie"))
|
||||
matches = [
|
||||
_compact_movie(movie)
|
||||
for movie in movies
|
||||
if needle in " ".join(str(movie.get(key, "")) for key in ("title", "originalTitle", "sortTitle")).casefold()
|
||||
]
|
||||
return {"query": query, "match_count": len(matches), "matches": matches[:limit], "truncated": len(matches) > limit}
|
||||
|
||||
@mcp.tool(tags={"radarr"})
|
||||
async def radarr_movie_codec_inventory(
|
||||
video_codecs: str = Field(
|
||||
@@ -103,48 +150,38 @@ def register_radarr_tools(mcp: FastMCP) -> None:
|
||||
) -> Any:
|
||||
"""Compact authoritative Radarr movie-file inventory. Use for codec, resolution, language and size questions instead of get_movie, raw API requests or filesystem scans. Results are valid bounded JSON without alternate titles, images, overviews or ratings."""
|
||||
client = get_radarr_client()
|
||||
response = await run_blocking(client.get_movie)
|
||||
movies = response.get("result", response) if isinstance(response, dict) else response
|
||||
if not isinstance(movies, list):
|
||||
response = _plain(await _call(client, "get_movie"))
|
||||
movies = _movies(response)
|
||||
if not movies and response not in ([], {"result": []}):
|
||||
raise RuntimeError("Radarr get_movie returned an unexpected response")
|
||||
return _compact_inventory(
|
||||
movies, codecs=video_codecs, query=query, offset=offset, limit=limit,
|
||||
)
|
||||
|
||||
@mcp.tool(tags={"radarr"})
|
||||
async def radarr_action(
|
||||
action: str = Field(
|
||||
description=(
|
||||
"A named Radarr API operation. Prefer radarr_movie_codec_inventory for "
|
||||
"library/file/codec questions. Use list_actions once for unusual operations. "
|
||||
"Raw request, authentication and Radarr restart/shutdown methods are unavailable."
|
||||
)
|
||||
),
|
||||
params_json: str = Field(
|
||||
default="{}",
|
||||
description="JSON object string with only the selected action's required parameters.",
|
||||
),
|
||||
async def radarr_search_releases(
|
||||
movie_id: int = Field(ge=1, description="Exact Radarr movie id returned by radarr_find_movie."),
|
||||
release_group: str = Field(default="", description="Optional release-group filter."),
|
||||
limit: int = Field(default=50, ge=1, le=100),
|
||||
) -> Any:
|
||||
"""Other Radarr operations for movies, queue, history, releases, profiles and health. TV episodes belong to Sonarr. Mutations require an explicit user request."""
|
||||
client = get_radarr_client()
|
||||
actions = _safe_actions(client)
|
||||
if action in {"list_actions", "actions", "help", "capabilities"}:
|
||||
return {"service": "arr-radarr", "actions": actions}
|
||||
if action not in actions:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "unknown or unavailable Radarr action",
|
||||
"action": action,
|
||||
"retry": False,
|
||||
"hint": "Use list_actions once or radarr_movie_codec_inventory for file/codec questions.",
|
||||
}
|
||||
try:
|
||||
parsed = json.loads(params_json)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"params_json is not valid JSON: {exc.msg}") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("params_json must encode a JSON object")
|
||||
kwargs = {key: value for key, value in parsed.items() if value is not None}
|
||||
return await run_blocking(
|
||||
dispatch, client, action, kwargs, service="arr-radarr"
|
||||
)
|
||||
"""Search Radarr's configured indexers for one movie. READ ONLY: never grabs or downloads a release."""
|
||||
raw = _plain(await _call(get_radarr_client(), "get_release", {"movieId": movie_id}))
|
||||
if isinstance(raw, dict) and "result" in raw:
|
||||
raw = raw["result"]
|
||||
releases = [item for item in raw if isinstance(item, dict)] if isinstance(raw, list) else []
|
||||
needle = release_group.strip().casefold()
|
||||
if needle:
|
||||
releases = [
|
||||
item for item in releases
|
||||
if needle in (str(item.get("releaseGroup", "")) + " " + str(item.get("title", ""))).casefold()
|
||||
]
|
||||
compact = [_compact_release(item) for item in releases[:limit]]
|
||||
return {
|
||||
"movie_id": movie_id,
|
||||
"release_group_filter": release_group or None,
|
||||
"total": len(releases),
|
||||
"returned": len(compact),
|
||||
"truncated": len(releases) > limit,
|
||||
"results": compact,
|
||||
"download_started": False,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user