Harden ARR MCP tool surface and installer
This commit is contained in:
+138
-132
@@ -3,6 +3,7 @@
|
||||
CONCEPT:ECO-4.82 — gitlab-style organized per-service tool surface.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
@@ -10,44 +11,23 @@ import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from agent_utilities.mcp_utilities import dispatch, run_blocking
|
||||
from fastmcp import FastMCP
|
||||
from pydantic import Field
|
||||
|
||||
from arr_mcp.auth import get_sonarr_client
|
||||
|
||||
|
||||
READ_ONLY_ACTIONS = frozenset(
|
||||
{
|
||||
"get_system_status", "get_health", "get_diskspace", "get_ping",
|
||||
"get_series", "get_series_id", "get_series_lookup", "lookup_series",
|
||||
"get_episode", "get_episode_id", "get_episodefile", "get_episodefile_id",
|
||||
"get_calendar", "get_calendar_id", "get_history", "get_history_series",
|
||||
"get_history_since", "get_queue", "get_queue_details", "get_queue_status",
|
||||
"get_wanted_missing", "get_wanted_missing_id", "get_wanted_cutoff",
|
||||
"get_wanted_cutoff_id", "get_qualityprofile", "get_qualityprofile_id",
|
||||
"get_languageprofile", "get_languageprofile_id", "get_tag", "get_tag_id",
|
||||
"get_tag_detail", "get_tag_detail_id", "get_command", "get_command_id",
|
||||
"get_release",
|
||||
}
|
||||
)
|
||||
|
||||
PSEUDO_ACTIONS = frozenset(
|
||||
{
|
||||
"find_series",
|
||||
"get_season_summary",
|
||||
"search_releases",
|
||||
"preview_episode_search",
|
||||
"preview_release_grab",
|
||||
}
|
||||
)
|
||||
|
||||
WRITE_ACTIONS = frozenset({"start_episode_search", "grab_release"})
|
||||
MAX_COLLECTION_ITEMS = 50
|
||||
APPROVAL_TTL_SECONDS = 600
|
||||
_APPROVALS: dict[str, tuple[float, str]] = {}
|
||||
|
||||
|
||||
async def _call(client: Any, action: str, kwargs: dict[str, Any] | None = None) -> Any:
|
||||
"""Call one known upstream API method without exposing dynamic dispatch."""
|
||||
method = getattr(client, action)
|
||||
return await asyncio.to_thread(method, **(kwargs or {}))
|
||||
|
||||
|
||||
def _plain(value: Any) -> Any:
|
||||
if hasattr(value, "model_dump") and callable(value.model_dump):
|
||||
return value.model_dump()
|
||||
@@ -173,9 +153,9 @@ def _compact_result(action: str, value: Any) -> Any:
|
||||
async def _find_series(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
query = str(kwargs.get("query", "")).strip()
|
||||
if len(query) < 2:
|
||||
raise ValueError("find_series requires params_json with a query of at least 2 characters")
|
||||
raise ValueError("query must contain at least two characters")
|
||||
limit = max(1, min(int(kwargs.get("limit", 8)), 15))
|
||||
raw = _unwrap(await run_blocking(dispatch, client, "get_series", {}, service="arr-sonarr"))
|
||||
raw = _unwrap(await _call(client, "get_series"))
|
||||
words = [word for word in re.findall(r"[a-z0-9]+", query.casefold()) if len(word) > 1]
|
||||
matches = []
|
||||
for item in raw if isinstance(raw, list) else []:
|
||||
@@ -197,26 +177,12 @@ async def _find_series(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
async def _season_summary(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
series_id = int(kwargs["series_id"])
|
||||
season_number = int(kwargs["season_number"])
|
||||
series = _unwrap(
|
||||
await run_blocking(dispatch, client, "get_series_id", {"id": series_id}, service="arr-sonarr")
|
||||
)
|
||||
series = _unwrap(await _call(client, "get_series_id", {"id": series_id}))
|
||||
episodes = _unwrap(
|
||||
await run_blocking(
|
||||
dispatch,
|
||||
client,
|
||||
"get_episode",
|
||||
{"seriesId": series_id, "seasonNumber": season_number},
|
||||
service="arr-sonarr",
|
||||
)
|
||||
await _call(client, "get_episode", {"seriesId": series_id, "seasonNumber": season_number})
|
||||
)
|
||||
files = _unwrap(
|
||||
await run_blocking(
|
||||
dispatch,
|
||||
client,
|
||||
"get_episodefile",
|
||||
{"seriesId": series_id},
|
||||
service="arr-sonarr",
|
||||
)
|
||||
await _call(client, "get_episodefile", {"seriesId": series_id})
|
||||
)
|
||||
selected_episodes = [
|
||||
_compact_episode(item) for item in episodes
|
||||
@@ -255,7 +221,7 @@ async def _search_releases(client: Any, kwargs: dict[str, Any]) -> dict[str, Any
|
||||
query["episodeId"] = int(episode_id)
|
||||
if season_number is not None:
|
||||
query["seasonNumber"] = int(season_number)
|
||||
raw = await run_blocking(dispatch, client, "get_release", query, service="arr-sonarr")
|
||||
raw = await _call(client, "get_release", query)
|
||||
raw = _unwrap(raw)
|
||||
if release_group and isinstance(raw, list):
|
||||
needle = release_group.casefold()
|
||||
@@ -322,17 +288,9 @@ async def _resolve_episode_search(client: Any, kwargs: dict[str, Any]) -> dict[s
|
||||
if series_id < 1 or season_number < 0:
|
||||
raise ValueError("series_id and season_number must be non-negative identifiers")
|
||||
|
||||
series = _unwrap(
|
||||
await run_blocking(dispatch, client, "get_series_id", {"id": series_id}, service="arr-sonarr")
|
||||
)
|
||||
series = _unwrap(await _call(client, "get_series_id", {"id": series_id}))
|
||||
episodes = _unwrap(
|
||||
await run_blocking(
|
||||
dispatch,
|
||||
client,
|
||||
"get_episode",
|
||||
{"seriesId": series_id, "seasonNumber": season_number},
|
||||
service="arr-sonarr",
|
||||
)
|
||||
await _call(client, "get_episode", {"seriesId": series_id, "seasonNumber": season_number})
|
||||
)
|
||||
candidates = [
|
||||
item for item in episodes
|
||||
@@ -414,13 +372,7 @@ async def _start_episode_search(client: Any, kwargs: dict[str, Any]) -> dict[str
|
||||
"scope": resolved["scope"],
|
||||
}
|
||||
command = _unwrap(
|
||||
await run_blocking(
|
||||
dispatch,
|
||||
client,
|
||||
"post_command",
|
||||
{"name": "EpisodeSearch", "episodeIds": episode_ids},
|
||||
service="arr-sonarr",
|
||||
)
|
||||
await _call(client, "post_command", {"data": {"name": "EpisodeSearch", "episodeIds": episode_ids}})
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -464,7 +416,7 @@ async def _resolve_release_grab(client: Any, kwargs: dict[str, Any]) -> dict[str
|
||||
)
|
||||
query = _release_query(kwargs)
|
||||
raw = _unwrap(
|
||||
await run_blocking(dispatch, client, "get_release", query, service="arr-sonarr")
|
||||
await _call(client, "get_release", query)
|
||||
)
|
||||
matches = [
|
||||
item for item in raw if isinstance(item, dict) and str(item.get("guid", "")) == guid
|
||||
@@ -484,13 +436,7 @@ async def _resolve_release_grab(client: Any, kwargs: dict[str, Any]) -> dict[str
|
||||
season_number = query.get("seasonNumber")
|
||||
if season_number is not None:
|
||||
episodes = _unwrap(
|
||||
await run_blocking(
|
||||
dispatch,
|
||||
client,
|
||||
"get_episode",
|
||||
{"seriesId": query["seriesId"], "seasonNumber": season_number},
|
||||
service="arr-sonarr",
|
||||
)
|
||||
await _call(client, "get_episode", {"seriesId": query["seriesId"], "seasonNumber": season_number})
|
||||
)
|
||||
if isinstance(episodes, list):
|
||||
existing_file_count = sum(
|
||||
@@ -587,13 +533,7 @@ async def _grab_release(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
if not secrets.compare_digest(approval[1], resolved["fingerprint"]):
|
||||
raise PermissionError("Approval ticket does not match this exact release grab")
|
||||
result = _unwrap(
|
||||
await run_blocking(
|
||||
dispatch,
|
||||
client,
|
||||
"post_release",
|
||||
resolved["raw_release"],
|
||||
service="arr-sonarr",
|
||||
)
|
||||
await _call(client, "post_release", {"data": resolved["raw_release"]})
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -612,59 +552,125 @@ async def _grab_release(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
def register_sonarr_tools(mcp: FastMCP) -> None:
|
||||
@mcp.tool(tags={"sonarr"})
|
||||
async def sonarr_action(
|
||||
action: str = Field(
|
||||
description="Choose one Sonarr operation. Use find_series only to resolve a TV-series name. For questions whether a release/group/season pack is AVAILABLE, immediately use search_releases with release_group and season_pack_only as requested; do not infer availability from existing library files or call get_season_summary first. If the user requests a SPECIFIC release, group, or complete season pack, NEVER use automatic episode search: use preview_release_grab with its exact GUID, wait for approval, then grab_release. preview_episode_search/start_episode_search automatically chooses releases and may immediately download them; use it only when the user accepts any suitable release."
|
||||
),
|
||||
params_json: str = Field(
|
||||
default="{}",
|
||||
description="JSON object encoded as a string. Common forms: find_series {\"query\":\"Title\"}; get_season_summary {\"series_id\":123,\"season_number\":2}; search_releases {\"series_id\":123,\"season_number\":2,\"release_group\":\"FuN\",\"season_pack_only\":true}; preview_release_grab {\"series_id\":123,\"season_number\":2,\"guid\":\"exact-guid-from-search\"}; preview_episode_search {\"series_id\":123,\"season_number\":2,\"episode_numbers\":[2,3]}. For grab_release or start_episode_search reuse the exact preview scope and add confirm:true plus approval_ticket.",
|
||||
),
|
||||
async def sonarr_find_series(
|
||||
query: str = Field(description="Series title or title fragment, for example Mord ist ihr Hobby."),
|
||||
limit: int = Field(default=8, ge=1, le=15),
|
||||
) -> Any:
|
||||
"""USE ONLY for TV-series tasks managed by Sonarr: identify a series, inspect missing episodes, search configured indexers, grab one explicitly selected and approved release, or start an approved automatic missing-episode search. A specific season pack/release must use preview_release_grab then grab_release; automatic episode search is not equivalent. DO NOT use for movies (use Radarr), public-web research, media playback, filesystem copying, or direct URL downloads. Read-only by default; monitoring is never changed."""
|
||||
if action in {"list_actions", "help", "actions"}:
|
||||
return {
|
||||
"service": "sonarr",
|
||||
"access_mode": "write" if os.environ.get("ARR_MCP_WRITE", "").strip().lower() in ("1", "true", "yes", "on") else "read-only",
|
||||
"actions": sorted(READ_ONLY_ACTIONS),
|
||||
"write_actions": sorted(WRITE_ACTIONS) if os.environ.get("ARR_MCP_WRITE", "").strip().lower() in ("1", "true", "yes", "on") else [],
|
||||
"preferred_compact_actions": sorted(PSEUDO_ACTIONS),
|
||||
}
|
||||
allow_write = os.environ.get("ARR_MCP_WRITE", "").strip().lower() in (
|
||||
"1", "true", "yes", "on"
|
||||
)
|
||||
if action in READ_ONLY_ACTIONS | PSEUDO_ACTIONS:
|
||||
pass
|
||||
elif allow_write and action in WRITE_ACTIONS:
|
||||
pass
|
||||
else:
|
||||
if allow_write:
|
||||
raise PermissionError(
|
||||
f"Sonarr MCP write mode is enabled, but action '{action}' "
|
||||
"is not in the allowed write set. Allowed: "
|
||||
f"{sorted(WRITE_ACTIONS)}"
|
||||
)
|
||||
raise PermissionError(
|
||||
f"Sonarr action '{action}' is blocked by the server-side "
|
||||
"read-only policy. Set ARR_MCP_WRITE=1 to enable write mode."
|
||||
)
|
||||
"""Find a Sonarr series by name. READ ONLY. Never changes monitoring and never starts a search or download."""
|
||||
client = get_sonarr_client()
|
||||
kwargs = {k: v for k, v in json.loads(params_json).items() if v is not None}
|
||||
if action == "find_series":
|
||||
return await _find_series(client, kwargs)
|
||||
if action == "get_season_summary":
|
||||
return await _season_summary(client, kwargs)
|
||||
if action == "search_releases":
|
||||
return await _search_releases(client, kwargs)
|
||||
if action == "preview_episode_search":
|
||||
return await _preview_episode_search(client, kwargs)
|
||||
if action == "preview_release_grab":
|
||||
return await _preview_release_grab(client, kwargs)
|
||||
if action == "start_episode_search":
|
||||
return await _start_episode_search(client, kwargs)
|
||||
if action == "grab_release":
|
||||
return await _grab_release(client, kwargs)
|
||||
result = await run_blocking(
|
||||
dispatch, client, action, kwargs, service="arr-sonarr"
|
||||
return await _find_series(client, {"query": query, "limit": limit})
|
||||
|
||||
@mcp.tool(tags={"sonarr"})
|
||||
async def sonarr_get_season_summary(
|
||||
series_id: int = Field(description="Exact Sonarr series id returned by sonarr_find_series."),
|
||||
season_number: int = Field(ge=0, description="Season number."),
|
||||
) -> Any:
|
||||
"""Return episodes and existing files for one Sonarr season. READ ONLY. File names do not prove audio language."""
|
||||
return await _season_summary(
|
||||
get_sonarr_client(),
|
||||
{"series_id": series_id, "season_number": season_number},
|
||||
)
|
||||
|
||||
@mcp.tool(tags={"sonarr"})
|
||||
async def sonarr_search_releases(
|
||||
series_id: int = Field(description="Exact Sonarr series id."),
|
||||
season_number: int | None = Field(default=None, ge=0),
|
||||
episode_id: int | None = Field(default=None, ge=1),
|
||||
release_group: str = Field(default="", description="Optional release-group filter, for example FuN."),
|
||||
season_pack_only: bool = Field(default=False, description="Only complete season packs. Requires season_number."),
|
||||
) -> Any:
|
||||
"""Search Sonarr's configured indexers and return compact matching releases. READ ONLY: does not alter monitoring, start automatic search, grab, or download anything."""
|
||||
return await _search_releases(
|
||||
get_sonarr_client(),
|
||||
{
|
||||
"series_id": series_id,
|
||||
"season_number": season_number,
|
||||
"episode_id": episode_id,
|
||||
"release_group": release_group,
|
||||
"season_pack_only": season_pack_only,
|
||||
},
|
||||
)
|
||||
|
||||
@mcp.tool(tags={"sonarr"})
|
||||
async def sonarr_system_status() -> Any:
|
||||
"""Return compact Sonarr version and runtime status. READ ONLY."""
|
||||
return _compact_result("get_system_status", await _call(get_sonarr_client(), "get_system_status"))
|
||||
|
||||
if os.environ.get("ARR_MCP_WRITE", "").strip().lower() not in ("1", "true", "yes", "on"):
|
||||
return
|
||||
|
||||
@mcp.tool(tags={"sonarr", "write"})
|
||||
async def sonarr_preview_release_grab(
|
||||
series_id: int = Field(description="Exact Sonarr series id."),
|
||||
guid: str = Field(description="Exact GUID returned by sonarr_search_releases."),
|
||||
season_number: int | None = Field(default=None, ge=0),
|
||||
episode_id: int | None = Field(default=None, ge=1),
|
||||
force: bool = Field(default=False),
|
||||
) -> Any:
|
||||
"""Preview one exact release grab and issue a short-lived approval ticket. Does not download anything."""
|
||||
return await _preview_release_grab(
|
||||
get_sonarr_client(),
|
||||
{
|
||||
"series_id": series_id,
|
||||
"guid": guid,
|
||||
"season_number": season_number,
|
||||
"episode_id": episode_id,
|
||||
"force": force,
|
||||
},
|
||||
)
|
||||
|
||||
@mcp.tool(tags={"sonarr", "write"})
|
||||
async def sonarr_grab_release(
|
||||
series_id: int = Field(description="Same series id used for the preview."),
|
||||
guid: str = Field(description="Same exact release GUID used for the preview."),
|
||||
approval_ticket: str = Field(description="Ticket returned by sonarr_preview_release_grab."),
|
||||
confirm: bool = Field(description="Must be true after explicit user approval."),
|
||||
season_number: int | None = Field(default=None, ge=0),
|
||||
episode_id: int | None = Field(default=None, ge=1),
|
||||
force: bool = Field(default=False),
|
||||
) -> Any:
|
||||
"""Grab exactly one previously previewed release. WRITE: can immediately start a download."""
|
||||
return await _grab_release(
|
||||
get_sonarr_client(),
|
||||
{
|
||||
"series_id": series_id,
|
||||
"guid": guid,
|
||||
"approval_ticket": approval_ticket,
|
||||
"confirm": confirm,
|
||||
"season_number": season_number,
|
||||
"episode_id": episode_id,
|
||||
"force": force,
|
||||
},
|
||||
)
|
||||
|
||||
@mcp.tool(tags={"sonarr", "write"})
|
||||
async def sonarr_preview_episode_search(
|
||||
series_id: int = Field(description="Exact Sonarr series id."),
|
||||
season_number: int = Field(ge=0),
|
||||
episode_numbers: list[int] | None = Field(default=None, description="Optional episode numbers; omit for all missing episodes in the season."),
|
||||
) -> Any:
|
||||
"""Preview an automatic Sonarr episode search. Does not change monitoring or download anything."""
|
||||
return await _preview_episode_search(
|
||||
get_sonarr_client(),
|
||||
{"series_id": series_id, "season_number": season_number, "episode_numbers": episode_numbers},
|
||||
)
|
||||
|
||||
@mcp.tool(tags={"sonarr", "write"})
|
||||
async def sonarr_start_episode_search(
|
||||
series_id: int = Field(description="Same series id used for the preview."),
|
||||
season_number: int = Field(ge=0),
|
||||
approval_ticket: str = Field(description="Ticket returned by sonarr_preview_episode_search."),
|
||||
confirm: bool = Field(description="Must be true after explicit user approval."),
|
||||
episode_numbers: list[int] | None = Field(default=None),
|
||||
) -> Any:
|
||||
"""Start a previously previewed automatic episode search. WRITE: may immediately download releases."""
|
||||
return await _start_episode_search(
|
||||
get_sonarr_client(),
|
||||
{
|
||||
"series_id": series_id,
|
||||
"season_number": season_number,
|
||||
"episode_numbers": episode_numbers,
|
||||
"approval_ticket": approval_ticket,
|
||||
"confirm": confirm,
|
||||
},
|
||||
)
|
||||
return _compact_result(action, result)
|
||||
|
||||
Reference in New Issue
Block a user