"""Sonarr condensed action-routed MCP tool. CONCEPT:ECO-4.82 — gitlab-style organized per-service tool surface. """ import os import json import re import secrets import time from typing import Any from agent_utilities.mcp_utilities import dispatch, run_blocking from fastmcp import FastMCP from pydantic import Field from arr_mcp.auth import get_sonarr_client READ_ONLY_ACTIONS = frozenset( { "get_system_status", "get_health", "get_diskspace", "get_ping", "get_series", "get_series_id", "get_series_lookup", "lookup_series", "get_episode", "get_episode_id", "get_episodefile", "get_episodefile_id", "get_calendar", "get_calendar_id", "get_history", "get_history_series", "get_history_since", "get_queue", "get_queue_details", "get_queue_status", "get_wanted_missing", "get_wanted_missing_id", "get_wanted_cutoff", "get_wanted_cutoff_id", "get_qualityprofile", "get_qualityprofile_id", "get_languageprofile", "get_languageprofile_id", "get_tag", "get_tag_id", "get_tag_detail", "get_tag_detail_id", "get_command", "get_command_id", "get_release", } ) PSEUDO_ACTIONS = frozenset( { "find_series", "get_season_summary", "search_releases", "preview_episode_search", "preview_release_grab", } ) WRITE_ACTIONS = frozenset({"start_episode_search", "grab_release"}) MAX_COLLECTION_ITEMS = 50 APPROVAL_TTL_SECONDS = 600 _APPROVALS: dict[str, tuple[float, str]] = {} 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() if isinstance(value, list): return [_plain(item) for item in value] if isinstance(value, dict): return {str(key): _plain(item) for key, item in value.items()} return value def _unwrap(value: Any) -> Any: value = _plain(value) if isinstance(value, dict) and set(value) == {"result"}: return value["result"] return value def _pick(item: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]: return {field: item[field] for field in fields if item.get(field) is not None} def _compact_series(item: dict[str, Any], include_seasons: bool = False) -> dict[str, Any]: result = _pick( item, ("id", "title", "sortTitle", "year", "status", "monitored", "path", "tvdbId"), ) statistics = item.get("statistics") or {} if isinstance(statistics, dict): result["statistics"] = _pick( statistics, ("seasonCount", "episodeFileCount", "episodeCount", "totalEpisodeCount", "sizeOnDisk", "percentOfEpisodes"), ) if include_seasons: result["seasons"] = [ { **_pick(season, ("seasonNumber", "monitored")), "statistics": _pick( season.get("statistics") or {}, ("episodeFileCount", "episodeCount", "totalEpisodeCount", "sizeOnDisk", "percentOfEpisodes"), ), } for season in item.get("seasons", []) if isinstance(season, dict) ] return result def _compact_episode(item: dict[str, Any]) -> dict[str, Any]: return _pick( item, ("id", "seriesId", "seasonNumber", "episodeNumber", "title", "airDate", "airDateUtc", "monitored", "hasFile", "episodeFileId"), ) def _compact_file(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 = _pick( item, ("id", "seriesId", "seasonNumber", "relativePath", "path", "size", "dateAdded", "releaseGroup"), ) if quality_name: result["quality"] = quality_name return result 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 = _pick( item, ( "guid", "title", "indexer", "indexerId", "size", "age", "ageHours", "seeders", "leechers", "protocol", "downloadAllowed", "releaseWeight", "releaseGroup", "seasonNumber", "fullSeason", ), ) if quality_name: result["quality"] = quality_name rejections = item.get("rejections") if isinstance(rejections, list) and rejections: result["rejections"] = [str(reason)[:180] for reason in rejections[:5]] return result def _bounded(items: list[Any], compact) -> dict[str, Any]: total = len(items) return { "total": total, "returned": min(total, MAX_COLLECTION_ITEMS), "truncated": total > MAX_COLLECTION_ITEMS, "items": [compact(item) for item in items[:MAX_COLLECTION_ITEMS] if isinstance(item, dict)], "next_step": ( "Use find_series or narrower Sonarr parameters; do not repeat the same broad request." if total > MAX_COLLECTION_ITEMS else None ), } def _compact_result(action: str, value: Any) -> Any: value = _unwrap(value) if isinstance(value, list): if action in {"get_series", "get_series_lookup", "lookup_series"}: return _bounded(value, _compact_series) if action in {"get_episode", "get_calendar", "get_wanted_missing", "get_wanted_cutoff"}: return _bounded(value, _compact_episode) if action == "get_episodefile": return _bounded(value, _compact_file) if action == "get_release": return _bounded(value, _compact_release) return _bounded(value, lambda item: item) if isinstance(value, dict) and action in {"get_series_id"}: return _compact_series(value, include_seasons=True) if isinstance(value, dict) and action in {"get_episode_id"}: return _compact_episode(value) if isinstance(value, dict) and action in {"get_episodefile_id"}: return _compact_file(value) return value async def _find_series(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: query = str(kwargs.get("query", "")).strip() if len(query) < 2: raise ValueError("find_series requires params_json with a query of at least 2 characters") limit = max(1, min(int(kwargs.get("limit", 8)), 15)) raw = _unwrap(await run_blocking(dispatch, client, "get_series", {}, service="arr-sonarr")) words = [word for word in re.findall(r"[a-z0-9]+", query.casefold()) if len(word) > 1] matches = [] for item in raw if isinstance(raw, list) else []: haystack = " ".join( str(item.get(field, "")) for field in ("title", "sortTitle", "originalTitle", "alternateTitles") ).casefold() if all(word in haystack for word in words): matches.append(_compact_series(item, include_seasons=False)) return { "query": query, "matches": matches[:limit], "match_count": len(matches), "truncated": len(matches) > limit, "task_complete": True, "instruction": "Use the returned series id for details. Do not call get_series for discovery.", } async def _season_summary(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: series_id = int(kwargs["series_id"]) season_number = int(kwargs["season_number"]) series = _unwrap( await run_blocking(dispatch, client, "get_series_id", {"id": series_id}, service="arr-sonarr") ) episodes = _unwrap( await run_blocking( dispatch, client, "get_episode", {"seriesId": series_id, "seasonNumber": season_number}, service="arr-sonarr", ) ) files = _unwrap( await run_blocking( dispatch, client, "get_episodefile", {"seriesId": series_id}, service="arr-sonarr", ) ) selected_episodes = [ _compact_episode(item) for item in episodes if isinstance(item, dict) and item.get("seasonNumber") == season_number ] if isinstance(episodes, list) else [] selected_files = [ _compact_file(item) for item in files if isinstance(item, dict) and item.get("seasonNumber") == season_number ] if isinstance(files, list) else [] groups = sorted({str(item.get("releaseGroup")) for item in selected_files if item.get("releaseGroup")}) return { "series": _compact_series(series) if isinstance(series, dict) else {"id": series_id}, "season_number": season_number, "episode_count": len(selected_episodes), "file_count": len(selected_files), "release_groups": groups, "episodes": selected_episodes[:30], "files": selected_files[:30], "task_complete": True, "instruction": "This is the complete compact season answer. Do not repeat broad series or episode queries.", } async def _search_releases(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: series_id = kwargs.get("series_id") episode_id = kwargs.get("episode_id") season_number = kwargs.get("season_number") release_group = str(kwargs.get("release_group", "")).strip() season_pack_only = kwargs.get("season_pack_only") is True if series_id is None and episode_id is None: raise ValueError("search_releases requires series_id or episode_id") query: dict[str, Any] = {} if series_id is not None: query["seriesId"] = int(series_id) if episode_id is not None: query["episodeId"] = int(episode_id) if season_number is not None: query["seasonNumber"] = int(season_number) raw = await run_blocking(dispatch, client, "get_release", query, service="arr-sonarr") raw = _unwrap(raw) if release_group and isinstance(raw, list): needle = release_group.casefold() raw = [ item for item in raw if isinstance(item, dict) and needle in ( str(item.get("releaseGroup", "")) + " " + str(item.get("title", "")) ).casefold() ] if season_pack_only: if season_number is None: raise ValueError("season_pack_only=true requires season_number") season_token = rf"(?:^|[. _-])S0*{int(season_number)}(?:[. _-]|$)" episode_token = rf"S0*{int(season_number)}E\d+" raw = [ item for item in raw if isinstance(item, dict) and ( item.get("fullSeason") is True or ( re.search(season_token, str(item.get("title", "")), re.IGNORECASE) and not re.search(episode_token, str(item.get("title", "")), re.IGNORECASE) ) ) ] if isinstance(raw, list) else raw compact = _compact_result("get_release", raw) return { "task_complete": True, "search_scope": { "series_id": series_id, "episode_id": episode_id, "season_number": season_number, "release_group_filter": release_group or None, "season_pack_only": season_pack_only, }, "monitoring_changed": False, "download_started": False, "results": compact, "instruction": ( "These are Sonarr indexer results. Do not use web search to replace them. " "If the user requests one specific release, release group, or complete season pack, " "NEVER substitute an automatic episode search: preview that exact result with " "preview_release_grab, then wait for explicit approval before grab_release." ), } def _episode_numbers(value: Any) -> list[int]: if value is None: return [] if not isinstance(value, list) or len(value) > 100: raise ValueError("episode_numbers must be a JSON list with at most 100 entries") numbers = sorted({int(item) for item in value}) if any(item < 0 or item > 9999 for item in numbers): raise ValueError("episode_numbers contains an invalid episode number") return numbers async def _resolve_episode_search(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: series_id = int(kwargs["series_id"]) season_number = int(kwargs["season_number"]) requested_numbers = _episode_numbers(kwargs.get("episode_numbers")) if series_id < 1 or season_number < 0: raise ValueError("series_id and season_number must be non-negative identifiers") series = _unwrap( await run_blocking(dispatch, client, "get_series_id", {"id": series_id}, service="arr-sonarr") ) episodes = _unwrap( await run_blocking( dispatch, client, "get_episode", {"seriesId": series_id, "seasonNumber": season_number}, service="arr-sonarr", ) ) candidates = [ item for item in episodes if isinstance(item, dict) and int(item.get("seasonNumber", -1)) == season_number and (not requested_numbers or int(item.get("episodeNumber", -1)) in requested_numbers) ] if isinstance(episodes, list) else [] if requested_numbers: found_numbers = {int(item.get("episodeNumber", -1)) for item in candidates} missing_metadata = sorted(set(requested_numbers) - found_numbers) if missing_metadata: raise ValueError(f"Sonarr has no episode metadata for episode numbers: {missing_metadata}") missing = [item for item in candidates if not bool(item.get("hasFile"))] if not candidates: raise ValueError("No Sonarr episodes match the requested scope") if len(missing) > 100: raise ValueError("Refusing to search more than 100 missing episodes at once") compact = [_compact_episode(item) for item in missing] scope = { "series_id": series_id, "series_title": str(series.get("title", "")) if isinstance(series, dict) else "", "season_number": season_number, "requested_episode_numbers": requested_numbers, "missing_episode_ids": [int(item["id"]) for item in missing], "missing_episode_numbers": [int(item["episodeNumber"]) for item in missing], } fingerprint = json.dumps(scope, ensure_ascii=False, sort_keys=True, separators=(",", ":")) return { "scope": scope, "fingerprint": fingerprint, "selected_episode_count": len(candidates), "already_present_count": len(candidates) - len(missing), "unmonitored_missing_count": sum(not bool(item.get("monitored")) for item in missing), "missing_episodes": compact, } async def _preview_episode_search(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: resolved = await _resolve_episode_search(client, kwargs) ticket = secrets.token_urlsafe(24) now = time.monotonic() for old_ticket, (expires, _) in list(_APPROVALS.items()): if expires <= now: _APPROVALS.pop(old_ticket, None) _APPROVALS[ticket] = (now + APPROVAL_TTL_SECONDS, resolved["fingerprint"]) return { "action": "preview-only", **{key: value for key, value in resolved.items() if key != "fingerprint"}, "monitoring_changed": False, "download_started": False, "approval_ticket": ticket, "approval_expires_in_seconds": APPROVAL_TTL_SECONDS, "next_step": ( "Review series, season and episode list. Only after explicit approval call " "start_episode_search with exactly the same scope, confirm=true and this ticket." ), } async def _start_episode_search(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: if kwargs.get("confirm") is not True: raise PermissionError("confirm=true is required after reviewing preview_episode_search") ticket = str(kwargs.get("approval_ticket", "")) if not ticket: raise PermissionError("approval_ticket is required") resolved = await _resolve_episode_search(client, kwargs) approval = _APPROVALS.pop(ticket, None) if approval is None or approval[0] <= time.monotonic(): raise PermissionError("Approval ticket is missing, expired or already used") if not secrets.compare_digest(approval[1], resolved["fingerprint"]): raise PermissionError("Approval ticket does not match this exact episode search") episode_ids = resolved["scope"]["missing_episode_ids"] if not episode_ids: return { "ok": True, "command_started": False, "reason": "All selected episodes already have files", "scope": resolved["scope"], } command = _unwrap( await run_blocking( dispatch, client, "post_command", {"name": "EpisodeSearch", "episodeIds": episode_ids}, service="arr-sonarr", ) ) return { "ok": True, "command_started": True, "sonarr_command": _pick(command, ("id", "name", "status", "queued", "startedOn")) if isinstance(command, dict) else command, "scope": resolved["scope"], "monitoring_changed": False, "download_may_start_immediately": True, "instruction": ( "Sonarr is now searching its configured indexers and may immediately grab/download " "the best acceptable release for every approved episode. EpisodeSearch is NOT a " "read-only manual-search preview and is NOT limited by the monitored flag. Check the " "queue before claiming that nothing was downloaded." ), } def _release_query(kwargs: dict[str, Any]) -> dict[str, Any]: series_id = int(kwargs["series_id"]) if series_id < 1: raise ValueError("series_id must be a positive Sonarr identifier") query: dict[str, Any] = {"seriesId": series_id} if kwargs.get("season_number") is not None: season_number = int(kwargs["season_number"]) if season_number < 0: raise ValueError("season_number must be non-negative") query["seasonNumber"] = season_number if kwargs.get("episode_id") is not None: episode_id = int(kwargs["episode_id"]) if episode_id < 1: raise ValueError("episode_id must be a positive Sonarr identifier") query["episodeId"] = episode_id return query async def _resolve_release_grab(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: guid = str(kwargs.get("guid", "")).strip() if not guid: raise ValueError( "guid is required; copy it from the exact search_releases result the user selected" ) query = _release_query(kwargs) raw = _unwrap( await run_blocking(dispatch, client, "get_release", query, service="arr-sonarr") ) matches = [ item for item in raw if isinstance(item, dict) and str(item.get("guid", "")) == guid ] if isinstance(raw, list) else [] if len(matches) != 1: raise ValueError( "The exact release GUID is no longer present in Sonarr's current indexer results; " "run search_releases again and do not guess or substitute another release" ) release = matches[0] compact = _compact_release(release) rejections = compact.get("rejections") or [] blocked_without_force = release.get("downloadAllowed") is False or bool(rejections) force = kwargs.get("force") is True existing_file_count = None season_number = query.get("seasonNumber") if season_number is not None: episodes = _unwrap( await run_blocking( dispatch, client, "get_episode", {"seriesId": query["seriesId"], "seasonNumber": season_number}, service="arr-sonarr", ) ) if isinstance(episodes, list): existing_file_count = sum( bool(item.get("hasFile")) for item in episodes if isinstance(item, dict) ) stable_release = _pick( release, ( "guid", "title", "indexer", "indexerId", "size", "protocol", "downloadAllowed", "releaseGroup", "seasonNumber", "fullSeason", ), ) raw_rejections = release.get("rejections") stable_release["rejections"] = ( [str(reason) for reason in raw_rejections] if isinstance(raw_rejections, list) else [] ) scope = { "series_id": query["seriesId"], "season_number": query.get("seasonNumber"), "episode_id": query.get("episodeId"), "guid": guid, "force": force, } fingerprint = json.dumps( {"scope": scope, "release": stable_release}, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ) return { "scope": scope, "fingerprint": fingerprint, "release": compact, "raw_release": release, "existing_episode_files_in_season": existing_file_count, "blocked_without_force": blocked_without_force, } async def _preview_release_grab(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: resolved = await _resolve_release_grab(client, kwargs) ticket = secrets.token_urlsafe(24) now = time.monotonic() for old_ticket, (expires, _) in list(_APPROVALS.items()): if expires <= now: _APPROVALS.pop(old_ticket, None) force_required = resolved["blocked_without_force"] and not resolved["scope"]["force"] if not force_required: _APPROVALS[ticket] = (now + APPROVAL_TTL_SECONDS, resolved["fingerprint"]) return { "action": "preview-only", "scope": resolved["scope"], "release": resolved["release"], "existing_episode_files_in_season": resolved["existing_episode_files_in_season"], "monitoring_changed": False, "download_started": False, "existing_files_deleted": False, "replacement_guaranteed": False, "warning": ( "Grabbing a season pack does not itself delete or guarantee replacement of existing " "episode files. Sonarr applies its import, quality-profile and upgrade rules after download." ), "force_required": force_required, "approval_ticket": None if force_required else ticket, "approval_expires_in_seconds": None if force_required else APPROVAL_TTL_SECONDS, "next_step": ( "This result has Sonarr rejections or downloadAllowed=false. Explain the rejections and " "only after the user explicitly accepts them call preview_release_grab again with force=true." if force_required else "Show the exact title, size, indexer, rejections and overwrite warning. Only after explicit " "approval call grab_release with exactly the same scope, confirm=true and this ticket." ), } async def _grab_release(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]: if kwargs.get("confirm") is not True: raise PermissionError("confirm=true is required after reviewing preview_release_grab") ticket = str(kwargs.get("approval_ticket", "")) if not ticket: raise PermissionError("approval_ticket is required") resolved = await _resolve_release_grab(client, kwargs) if resolved["blocked_without_force"] and not resolved["scope"]["force"]: raise PermissionError( "This release has Sonarr rejections or downloadAllowed=false; an explicitly approved " "force=true preview is required" ) approval = _APPROVALS.pop(ticket, None) if approval is None or approval[0] <= time.monotonic(): raise PermissionError("Approval ticket is missing, expired or already used") if not secrets.compare_digest(approval[1], resolved["fingerprint"]): raise PermissionError("Approval ticket does not match this exact release grab") result = _unwrap( await run_blocking( dispatch, client, "post_release", resolved["raw_release"], service="arr-sonarr", ) ) return { "ok": True, "download_started": True, "selected_release": resolved["release"], "sonarr_result": _compact_release(result) if isinstance(result, dict) else result, "monitoring_changed": False, "existing_files_deleted": False, "replacement_guaranteed": False, "instruction": ( "The exact approved release was sent to Sonarr's configured download client. " "Do not claim that an existing episode was overwritten; verify queue/import history later." ), } def register_sonarr_tools(mcp: FastMCP) -> None: @mcp.tool(tags={"sonarr"}) async def sonarr_action( action: str = Field( description="Choose one Sonarr operation. Use find_series only to resolve a TV-series name. For questions whether a release/group/season pack is AVAILABLE, immediately use search_releases with release_group and season_pack_only as requested; do not infer availability from existing library files or call get_season_summary first. If the user requests a SPECIFIC release, group, or complete season pack, NEVER use automatic episode search: use preview_release_grab with its exact GUID, wait for approval, then grab_release. preview_episode_search/start_episode_search automatically chooses releases and may immediately download them; use it only when the user accepts any suitable release." ), params_json: str = Field( default="{}", description="JSON object encoded as a string. Common forms: find_series {\"query\":\"Title\"}; get_season_summary {\"series_id\":123,\"season_number\":2}; search_releases {\"series_id\":123,\"season_number\":2,\"release_group\":\"FuN\",\"season_pack_only\":true}; preview_release_grab {\"series_id\":123,\"season_number\":2,\"guid\":\"exact-guid-from-search\"}; preview_episode_search {\"series_id\":123,\"season_number\":2,\"episode_numbers\":[2,3]}. For grab_release or start_episode_search reuse the exact preview scope and add confirm:true plus approval_ticket.", ), ) -> Any: """USE ONLY for TV-series tasks managed by Sonarr: identify a series, inspect missing episodes, search configured indexers, grab one explicitly selected and approved release, or start an approved automatic missing-episode search. A specific season pack/release must use preview_release_grab then grab_release; automatic episode search is not equivalent. DO NOT use for movies (use Radarr), public-web research, media playback, filesystem copying, or direct URL downloads. Read-only by default; monitoring is never changed.""" if action in {"list_actions", "help", "actions"}: return { "service": "sonarr", "access_mode": "write" if os.environ.get("ARR_MCP_WRITE", "").strip().lower() in ("1", "true", "yes", "on") else "read-only", "actions": sorted(READ_ONLY_ACTIONS), "write_actions": sorted(WRITE_ACTIONS) if os.environ.get("ARR_MCP_WRITE", "").strip().lower() in ("1", "true", "yes", "on") else [], "preferred_compact_actions": sorted(PSEUDO_ACTIONS), } allow_write = os.environ.get("ARR_MCP_WRITE", "").strip().lower() in ( "1", "true", "yes", "on" ) if action in READ_ONLY_ACTIONS | PSEUDO_ACTIONS: pass elif allow_write and action in WRITE_ACTIONS: pass else: if allow_write: raise PermissionError( f"Sonarr MCP write mode is enabled, but action '{action}' " "is not in the allowed write set. Allowed: " f"{sorted(WRITE_ACTIONS)}" ) raise PermissionError( f"Sonarr action '{action}' is blocked by the server-side " "read-only policy. Set ARR_MCP_WRITE=1 to enable write mode." ) client = get_sonarr_client() kwargs = {k: v for k, v in json.loads(params_json).items() if v is not None} if action == "find_series": return await _find_series(client, kwargs) if action == "get_season_summary": return await _season_summary(client, kwargs) if action == "search_releases": return await _search_releases(client, kwargs) if action == "preview_episode_search": return await _preview_episode_search(client, kwargs) if action == "preview_release_grab": return await _preview_release_grab(client, kwargs) if action == "start_episode_search": return await _start_episode_search(client, kwargs) if action == "grab_release": return await _grab_release(client, kwargs) result = await run_blocking( dispatch, client, action, kwargs, service="arr-sonarr" ) return _compact_result(action, result)