diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 47744b8..e148dcd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -116,7 +116,7 @@ weitere MCP-Clients ──────┴── mcp-gateway (später) ── das | `home-assistant-mcp-read` | Entities, Bereiche, Historie, Diagnose | nur lesen | | `home-assistant-mcp-write` | kontrollierte HA-Änderungen | Preview/Approval | | `arr-mcp-read` | Sonarr/Radarr-Status und Releasesuche | nur lesen | -| `arr-mcp-write` | Suche, Monitoring und Downloadtrigger | Preview/Approval | +| `arr-mcp-write` | Suche fehlender Episoden über Sonarr-Indexer | Preview/Approval; Monitoring unverändert | | `unraid-mcp-read` | System-, Container- und begrenzte Logdiagnose | nur lesen | | `unraid-mcp-admin` | eng definierte Verwaltungsaktionen | bewusst aktivieren | | `sandbox-mcp` | temporäre Code- und Dateiarbeit | isolierter Arbeitsraum | diff --git a/platform/mcp/README.md b/platform/mcp/README.md index 7a9aeb2..bd4c5d9 100644 --- a/platform/mcp/README.md +++ b/platform/mcp/README.md @@ -72,3 +72,21 @@ mehrere Bereiche verbindet. Schreibende Aktionen bleiben hinter der jeweiligen serverseitigen Policy und einem Vorschau-/Bestätigungsablauf. Ein Client-Schalter allein darf niemals eine read-only Policy aufheben. + +## Sonarr: sichere Episodensuche + +Der lokale Sonarr-Patch stellt bewusst keine freie Sonarr-Command-API bereit. +Der erlaubte Schreibablauf ist eng auf fehlende Episoden begrenzt: + +1. `preview_episode_search` bekommt Serien-ID, Staffel und die exakten + Episodennummern. Es liest Sonarr-Metadaten, entfernt bereits vorhandene + Episoden und erzeugt eine konkrete Vorschau samt kurzlebigem Ticket. +2. Der Client zeigt diese Vorschau unverändert an. Ohne ausdrückliche + Benutzerfreigabe endet der Ablauf hier. +3. `start_episode_search` akzeptiert nur denselben Umfang, `confirm=true` und + das passende Ticket. Erst dann startet Sonarr eine `EpisodeSearch` über die + dort konfigurierten Indexer. + +Der Ablauf ändert weder Serien- noch Staffel-Monitoring und erlaubt weder +beliebige Commands noch direkte URL-/Release-Downloads. Tickets gelten zehn +Minuten, sind einmalig und an genau die angezeigte Auswahl gebunden. diff --git a/platform/mcp/patches/mcp_sonarr.py b/platform/mcp/patches/mcp_sonarr.py index 3ce7967..50367d6 100644 --- a/platform/mcp/patches/mcp_sonarr.py +++ b/platform/mcp/patches/mcp_sonarr.py @@ -6,6 +6,8 @@ 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 @@ -30,19 +32,19 @@ READ_ONLY_ACTIONS = frozenset( } ) -PSEUDO_ACTIONS = frozenset({"find_series", "get_season_summary", "search_releases"}) +PSEUDO_ACTIONS = frozenset( + { + "find_series", + "get_season_summary", + "search_releases", + "preview_episode_search", + } +) -WRITE_ACTIONS = frozenset({ - "post_command", - "post_release", - "put_episode_id", - "put_episode_monitor", - "put_series_id", - "put_series", - "put_wanted", - "post_wanted", -}) +WRITE_ACTIONS = frozenset({"start_episode_search"}) MAX_COLLECTION_ITEMS = 50 +APPROVAL_TTL_SECONDS = 600 +_APPROVALS: dict[str, tuple[float, str]] = {} def _plain(value: Any) -> Any: @@ -277,11 +279,139 @@ async def _search_releases(client: Any, kwargs: dict[str, Any]) -> dict[str, Any } +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, + "instruction": "Sonarr is now searching its configured indexers for the approved missing episodes.", + } + + def register_sonarr_tools(mcp: FastMCP) -> None: @mcp.tool(tags={"sonarr"}) async def sonarr_action( action: str = Field( - description="Read-only Sonarr action. Use find_series {query} for titles, get_season_summary {series_id, season_number} for holdings, and search_releases {series_id, season_number, optional release_group} to query configured Sonarr indexers without downloading or changing monitoring. Avoid broad get_series/get_episode calls." + description="Sonarr action. Read with find_series {query}, get_season_summary {series_id, season_number}, or search_releases {series_id, season_number}. To download missing episodes, first call preview_episode_search {series_id, season_number, episode_numbers:[2,3,...]}, show its exact preview to the user, then only after explicit approval call start_episode_search with the same scope plus confirm:true and approval_ticket. Monitoring is never changed." ), params_json: str = Field( default="{}", @@ -323,6 +453,10 @@ def register_sonarr_tools(mcp: FastMCP) -> None: 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 == "start_episode_search": + return await _start_episode_search(client, kwargs) result = await run_blocking( dispatch, client, action, kwargs, service="arr-sonarr" )