Add approval-bound Sonarr episode search
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user