Architecture¶
Deployment model¶
arr-mcp runs as a container alongside the media stack. It communicates with the container runtime via a bind-mounted Unix socket and exposes an MCP endpoint and a read-only dashboard over HTTP.
Stack and systemd management require arr-helper, a small host-side process that runs as the service account and communicates with arr-mcp via a bind-mounted Unix socket.
Claude (MCP client) Browser
│ HTTP + Bearer auth │ HTTP + ?key=
▼ ▼
┌─────────────────────────────────────────┐
│ arr-mcp container │
│ /mcp – MCP endpoint │
│ / – read-only dashboard (Jinja2) │
│ /api/status – JSON status │
└───────────────┬─────────────────────────┘
│ Unix socket (podman.sock / docker.sock)
▼
Container runtime
│
▼
Media stack containers (plex, sonarr, radarr, ...)
arr-helper (host process, service account)
│ Unix socket (arr-helper.sock, bind-mounted into arr-mcp)
├── podman-compose up/down/pull/restart
├── systemctl --user start/stop/restart/status/daemon-reload
└── read/write ~/.config/containers/systemd/*.container
Target environment¶
- OS: Debian/Ubuntu
- Runtime: Rootless Podman under a dedicated service account (e.g.
media) - Socket:
/run/user/<UID>/podman/podman.sock— where<UID>is the service account UID (id media) - Stacks:
/opt/stacks/<stack-name>/compose.yaml - Media:
/media-server/
Core components¶
arr-mcp (container)¶
| File | Responsibility |
|---|---|
src/arr_mcp/server.py |
Starlette ASGI app, API key auth middleware, route assembly, entry point |
src/arr_mcp/config.py |
Pydantic settings loaded from environment / .env |
src/arr_mcp/runtime/detector.py |
Auto-detects Podman or Docker socket at startup |
src/arr_mcp/runtime/client.py |
Async HTTP client over the container runtime socket |
src/arr_mcp/tools/containers.py |
Container lifecycle tools |
src/arr_mcp/tools/stacks.py |
Stack management tools (delegates to arr-helper) |
src/arr_mcp/tools/filesystem.py |
Filesystem tools scoped to allowed paths |
src/arr_mcp/tools/logs.py |
Log reading and searching tools |
src/arr_mcp/tools/conversion.py |
Compose ↔ Quadlet conversion tools |
src/arr_mcp/tools/diagnostics.py |
Service health diagnostics (filesystem + API reachability) |
src/arr_mcp/tools/services.py |
Static service registry and pure diagnostic logic |
src/arr_mcp/tools/utils.py |
Shared utilities (ownership checks, etc.) |
src/arr_mcp/helper/client.py |
HTTP/JSON client for the arr-helper Unix socket |
src/arr_mcp/dashboard/routes.py |
Dashboard route handlers |
src/arr_mcp/dashboard/data.py |
Status data assembly from runtime client |
src/arr_mcp/dashboard/templates/ |
Jinja2 HTML templates |
src/arr_mcp/dashboard/static/ |
CSS stylesheet (no external CDN) |
arr-helper (host process)¶
| File | Responsibility |
|---|---|
src/arr_helper/server.py |
Unix socket HTTP server (hand-rolled, no framework) |
src/arr_helper/handlers.py |
Operation dispatch table (14 operations) |
src/arr_helper/validation.py |
Input validators — regex-gated, no path traversal possible |
src/arr_helper/subprocess.py |
Safe subprocess runner (create_subprocess_exec, never shell=True) |
Security boundaries¶
| Boundary | Mechanism |
|---|---|
| MCP endpoint auth | Authorization: Bearer <key> header required on /mcp |
| Dashboard auth | ?key=<key> query param, or DASHBOARD_PUBLIC=true for LAN |
| Filesystem scope | _check_path() restricts to stacks_dir, media_dir, /var/log |
| Ownership check | is_owned_by_current_user() blocks operations on root-owned files |
| Helper input | Regex validation on all args; create_subprocess_exec prevents injection |
| Helper socket | Mode 0600, owned by service account — no other process can connect |
See Security and ADR-0001 for full details.
Key architectural decisions¶
| Decision | ADR |
|---|---|
| Filesystem ownership scoping | ADR-0001 |
| Host-side helper agent | ADR-0002 |
| Read-only dashboard (Option C) | ADR-0003 |
| Supported runtime configurations | ADR-0004 |
Phase 2 Service Client Layer¶
Phase 2 introduces direct HTTP communication with running media services (Sonarr, Radarr, Plex, SABnzbd). This section describes the design that all Phase 2 work must follow.
Design principles¶
- One client class per service. No one-off
httpxclients in tool code. - Credentials never in compose files.
CredentialStoreis the only source of truth. - Registry-driven.
KNOWN_SERVICESalready defines ports and health paths; the client layer builds on that. - Testable without a running service. All client classes accept an injectable
httpx.AsyncClientfor unit testing.
Layered class hierarchy¶
BaseServiceClient — async HTTP, timeout, error normalisation
└── ArrClient — shared /api/v3 schema (Sonarr, Radarr, Lidarr)
├── SonarrClient
└── RadarrClient
└── PlexClient
└── SABnzbdClient
└── QBittorrentClient
Component responsibilities¶
CredentialStore (src/arr_mcp/services/credentials.py)
Secure per-service credential storage. Each entry holds the API key (or token) for one service. Env-var overrides (SONARR_API_KEY, PLEX_TOKEN, etc.) take precedence over stored values — this enables CI/testing without touching the store.
Storage format is deliberately simple: an encrypted JSON file at a fixed path inside the container's data volume. The encryption key comes from ARR_MCP_SECRET in the environment.
BaseServiceClient (src/arr_mcp/services/base.py)
Thin async wrapper around httpx.AsyncClient. Responsibilities:
- Accept base_url and api_key at construction (resolved by ServiceRegistry)
- Expose get(), post(), delete() with consistent error normalisation
- Implement health() using the service's api_health_path from KNOWN_SERVICES
- Never raise — return structured error objects
ArrClient (src/arr_mcp/services/arr.py)
Extends BaseServiceClient with the API schema shared by all *arr applications:
- /api/v3/system/status
- /api/v3/queue
- /api/v3/wanted/missing
- /api/v3/health
Sonarr and Radarr extend this with their own resources (series/episodes vs. movies).
ServiceRegistry (src/arr_mcp/services/registry.py)
Combines KNOWN_SERVICES (static metadata) with CredentialStore (runtime credentials) to produce ready-to-use client instances. The key method:
This reads the port from the service's config file (via the existing extract_service_port helper), fetches the API key from CredentialStore, and returns a configured client. Tool code never assembles URLs or reads credentials directly.
Background tasks¶
Phase 2 introduces two long-running background tasks attached to the server lifespan:
AlertWatcher
Runs on a configurable interval. Evaluates a set of alert rules (stuck downloads, error-rate thresholds, disk usage). When a rule fires it emits a structured notification. Phase 2 delivers: logged alerts + MCP tool to query recent alerts. Phase 3+ can add webhooks / push.
VersionChecker
Polls GitHub Releases API and Docker Hub tags on a daily schedule. For each monitored service it compares the running image tag against the latest available release, parses the changelog, and surfaces a structured upgrade recommendation. The recommendation includes: current version, latest version, changelog summary, and a risk assessment (major/minor/patch).
Cross-service intelligence pattern¶
The signature Phase 2 use case — watched content cleanup — demonstrates the pattern for all cross-service features:
Tool receives natural language intent (via Claude)
→ Query PlexClient for watch history
→ Query SonarrClient for library state
→ Join on series title (case-insensitive)
→ Apply business rules (quorum, season exclusions, user protection)
→ Present candidates to user for confirmation
→ Call SonarrClient.delete_episode_file() per confirmed item
Every destructive operation requires explicit confirmation. The join and business-rule logic lives in the tool module, not in the client classes — clients are pure data access.
File layout (Phase 2 additions)¶
src/arr_mcp/
services/
__init__.py
credentials.py # CredentialStore
base.py # BaseServiceClient
arr.py # ArrClient
sonarr.py # SonarrClient
radarr.py # RadarrClient
plex.py # PlexClient
sabnzbd.py # SABnzbdClient
registry.py # ServiceRegistry
tasks/
__init__.py
alerts.py # AlertWatcher
versions.py # VersionChecker
tools/
media.py # watched_cleanup_preview, watched_cleanup_delete
alerts.py # list_alerts, configure_alert_rule
upgrades.py # list_available_upgrades