Add approval-bound Sonarr episode search

This commit is contained in:
Mikei386 committed 2026-08-21 10:57:33 +02:00
1 parent f0d552ef58
commit 4089bb46e6
3 files changed
+165 -13

No files matched your search

+1 -1
View File
@@ -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-read` | Entities, Bereiche, Historie, Diagnose | nur lesen |
| `home-assistant-mcp-write` | kontrollierte HA-Änderungen | Preview/Approval | | `home-assistant-mcp-write` | kontrollierte HA-Änderungen | Preview/Approval |
| `arr-mcp-read` | Sonarr/Radarr-Status und Releasesuche | nur lesen | | `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-read` | System-, Container- und begrenzte Logdiagnose | nur lesen |
| `unraid-mcp-admin` | eng definierte Verwaltungsaktionen | bewusst aktivieren | | `unraid-mcp-admin` | eng definierte Verwaltungsaktionen | bewusst aktivieren |
| `sandbox-mcp` | temporäre Code- und Dateiarbeit | isolierter Arbeitsraum | | `sandbox-mcp` | temporäre Code- und Dateiarbeit | isolierter Arbeitsraum |
+18
View File
@@ -72,3 +72,21 @@ mehrere Bereiche verbindet.
Schreibende Aktionen bleiben hinter der jeweiligen serverseitigen Policy und Schreibende Aktionen bleiben hinter der jeweiligen serverseitigen Policy und
einem Vorschau-/Bestätigungsablauf. Ein Client-Schalter allein darf niemals einem Vorschau-/Bestätigungsablauf. Ein Client-Schalter allein darf niemals
eine read-only Policy aufheben. 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.
+146 -12
View File
@@ -6,6 +6,8 @@ CONCEPT:ECO-4.82 — gitlab-style organized per-service tool surface.
import os import os
import json import json
import re import re
import secrets
import time
from typing import Any from typing import Any
from agent_utilities.mcp_utilities import dispatch, run_blocking 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({ WRITE_ACTIONS = frozenset({"start_episode_search"})
"post_command",
"post_release",
"put_episode_id",
"put_episode_monitor",
"put_series_id",
"put_series",
"put_wanted",
"post_wanted",
})
MAX_COLLECTION_ITEMS = 50 MAX_COLLECTION_ITEMS = 50
APPROVAL_TTL_SECONDS = 600
_APPROVALS: dict[str, tuple[float, str]] = {}
def _plain(value: Any) -> Any: 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: def register_sonarr_tools(mcp: FastMCP) -> None:
@mcp.tool(tags={"sonarr"}) @mcp.tool(tags={"sonarr"})
async def sonarr_action( async def sonarr_action(
action: str = Field( 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( params_json: str = Field(
default="{}", default="{}",
@@ -323,6 +453,10 @@ def register_sonarr_tools(mcp: FastMCP) -> None:
return await _season_summary(client, kwargs) return await _season_summary(client, kwargs)
if action == "search_releases": if action == "search_releases":
return await _search_releases(client, kwargs) 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( result = await run_blocking(
dispatch, client, action, kwargs, service="arr-sonarr" dispatch, client, action, kwargs, service="arr-sonarr"
) )