Radarr and Sonarr MCP Server
by BerryKuipers
- radarr_sonarr_mcp
#!/usr/bin/env python
"""Main MCP server implementation for Radarr/Sonarr."""
import os
import json
import sys
import logging
from typing import Optional
import argparse
from fastmcp import FastMCP
import requests
# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Configuration handling
# -----------------------------------------------------------------------------
def load_config():
"""Load configuration from environment variables or config file."""
if os.environ.get('RADARR_API_KEY') or os.environ.get('SONARR_API_KEY'):
logger.info("Loading configuration from environment variables...")
nas_ip = os.environ.get('NAS_IP', '10.0.0.23')
return {
"nasConfig": {
"ip": nas_ip,
"port": os.environ.get('RADARR_PORT', '7878')
},
"radarrConfig": {
"apiKey": os.environ.get('RADARR_API_KEY', ''),
"basePath": os.environ.get('RADARR_BASE_PATH', '/api/v3'),
"port": os.environ.get('RADARR_PORT', '7878')
},
"sonarrConfig": {
"apiKey": os.environ.get('SONARR_API_KEY', ''),
"basePath": os.environ.get('SONARR_BASE_PATH', '/api/v3'),
"port": os.environ.get('SONARR_PORT', '8989')
},
# Optionally, include Jellyfin and Plex configuration if set in env
"jellyfinConfig": {
"baseUrl": os.environ.get('JELLYFIN_BASE_URL', ''), # e.g., "http://10.0.0.23:5055"
"apiKey": os.environ.get('JELLYFIN_API_KEY', ''),
"userId": os.environ.get('JELLYFIN_USER_ID', '')
},
"plexConfig": {
"baseUrl": os.environ.get('PLEX_BASE_URL', ''), # e.g., "http://10.0.0.23:32400"
"token": os.environ.get('PLEX_TOKEN', '')
},
"server": {
"port": int(os.environ.get('MCP_SERVER_PORT', '3000'))
}
}
else:
config_path = 'config.json'
try:
with open(config_path, 'r') as f:
return json.load(f)
except Exception as e:
logger.error(f"Error loading config: {e}")
logger.info("Using default configuration")
return {
"nasConfig": {"ip": "10.0.0.23", "port": "7878"},
"radarrConfig": {"apiKey": "", "basePath": "/api/v3", "port": "7878"},
"sonarrConfig": {"apiKey": "", "basePath": "/api/v3", "port": "8989"},
"server": {"port": 3000}
}
# -----------------------------------------------------------------------------
# API Service functions
# -----------------------------------------------------------------------------
def get_radarr_url(config):
nas_ip = config["nasConfig"]["ip"]
port = config["radarrConfig"]["port"]
base_path = config["radarrConfig"]["basePath"]
return f"http://{nas_ip}:{port}{base_path}"
def get_sonarr_url(config):
nas_ip = config["nasConfig"]["ip"]
port = config["sonarrConfig"]["port"]
base_path = config["sonarrConfig"]["basePath"]
return f"http://{nas_ip}:{port}{base_path}"
def make_radarr_request(config, endpoint, params=None):
api_key = config["radarrConfig"]["apiKey"]
base_url = get_radarr_url(config)
url = f"{base_url}/{endpoint}"
if params is None:
params = {}
params['apikey'] = api_key
try:
response = requests.get(url, params=params, timeout=30)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Error making request to {url}: {e}")
return []
def make_sonarr_request(config, endpoint, params=None):
api_key = config["sonarrConfig"]["apiKey"]
base_url = get_sonarr_url(config)
url = f"{base_url}/{endpoint}"
if params is None:
params = {}
params['apikey'] = api_key
try:
response = requests.get(url, params=params, timeout=30)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Error making request to {url}: {e}")
return []
def get_all_series(config):
from radarr_sonarr_mcp.services.sonarr_service import SonarrService
service = SonarrService(config["sonarrConfig"])
return service.get_all_series()
# -----------------------------------------------------------------------------
# Helper function to check watched status from multiple sources
# -----------------------------------------------------------------------------
def is_watched_series(title: str, fallback: bool, config: dict, sonarr_service) -> bool:
"""
Check if a series is watched using available media services.
Returns True if any service reports the series as watched.
"""
statuses = []
if config.get("jellyfinConfig", {}).get("baseUrl"):
from radarr_sonarr_mcp.services.jellyfin_service import JellyfinService
jellyfin = JellyfinService(config["jellyfinConfig"])
try:
statuses.append(jellyfin.is_series_watched(title))
except Exception as e:
logger.error(f"Jellyfin check failed for {title}: {e}")
if config.get("plexConfig", {}).get("baseUrl"):
from radarr_sonarr_mcp.services.plex_service import PlexService
plex = PlexService(config["plexConfig"])
try:
statuses.append(plex.is_series_watched(title))
except Exception as e:
logger.error(f"Plex check failed for {title}: {e}")
if statuses:
return any(statuses)
# Fallback to Sonarr's own logic if no external services are configured.
return sonarr_service.is_series_watched(title)
def is_watched_movie(title: str, config: dict) -> bool:
"""
Check if a movie is watched using available media services.
Returns True if any service reports the movie as watched.
"""
statuses = []
if config.get("jellyfinConfig", {}).get("baseUrl"):
from radarr_sonarr_mcp.services.jellyfin_service import JellyfinService
jellyfin = JellyfinService(config["jellyfinConfig"])
try:
# For movies, you could implement a similar method in JellyfinService.
statuses.append(jellyfin.is_movie_watched(title))
except Exception as e:
logger.error(f"Jellyfin movie check failed for {title}: {e}")
if config.get("plexConfig", {}).get("baseUrl"):
from radarr_sonarr_mcp.services.plex_service import PlexService
plex = PlexService(config["plexConfig"])
try:
statuses.append(plex.is_movie_watched(title))
except Exception as e:
logger.error(f"Plex movie check failed for {title}: {e}")
# If no external services configured, default to unwatched.
return any(statuses)
# -----------------------------------------------------------------------------
# MCP Server implementation
# -----------------------------------------------------------------------------
from radarr_sonarr_mcp.services.sonarr_service import SonarrService
class RadarrSonarrMCP:
"""MCP Server for Radarr and Sonarr."""
def __init__(self):
self.config = load_config()
self.server = FastMCP(
name="radarr-sonarr-mcp-server",
description="MCP Server for Radarr and Sonarr media management"
)
self.sonarr_service = SonarrService(self.config["sonarrConfig"])
self._register_tools()
self._register_resources()
# Optionally, register prompts.
def _register_tools(self):
@self.server.tool()
def get_available_series(year: Optional[int] = None,
downloaded: Optional[bool] = None,
watched: Optional[bool] = None,
actors: Optional[str] = None) -> dict:
"""
Get a list of available TV series with optional filters.
Watched status is determined using Plex and/or Jellyfin; if either reports watched, the series is considered watched.
"""
all_series = get_all_series(self.config) # List of Series objects
filtered_series = all_series
if year is not None:
filtered_series = [s for s in filtered_series if s.year == year]
if downloaded is not None:
filtered_series = [
s for s in filtered_series
if (s.statistics and s.statistics.episode_file_count > 0) == downloaded
]
if watched is not None:
if watched:
filtered_series = [
s for s in filtered_series
if is_watched_series(s.title, False, self.config, self.sonarr_service)
]
else:
filtered_series = [
s for s in filtered_series
if not is_watched_series(s.title, False, self.config, self.sonarr_service)
]
if actors:
filtered_series = [
s for s in filtered_series
if s.data.get("credits") and any(
actors.lower() in cast.get("name", "").lower()
for cast in s.data.get("credits", {}).get("cast", [])
)
]
return {
"count": len(filtered_series),
"series": [
{
"id": s.id,
"title": s.title,
"year": s.year,
"overview": s.overview,
"status": s.status,
"network": s.network,
"genres": s.genres,
"watched": is_watched_series(s.title, False, self.config, self.sonarr_service)
}
for s in filtered_series
]
}
@self.server.tool()
def lookup_series(term: str) -> dict:
service = SonarrService(self.config["sonarrConfig"])
results = service.lookup_series(term)
return {
"count": len(results),
"series": [
{
"id": s.id,
"title": s.title,
"year": s.year,
"overview": s.overview
}
for s in results
]
}
# Similarly, for movies you can define a tool:
@self.server.tool()
def get_available_movies(year: Optional[int] = None,
downloaded: Optional[bool] = None,
watched: Optional[bool] = None,
actors: Optional[str] = None) -> dict:
"""
Get a list of all available movies with optional filters.
Watched status is determined using Plex and/or Jellyfin.
"""
# For movies, assume you have a function get_all_movies similar to get_all_series.
from radarr_sonarr_mcp.services.radarr_service import RadarrService
# You would need to instantiate a RadarrService and fetch movies.
radarr_service = RadarrService(self.config["radarrConfig"])
all_movies = radarr_service.get_all_movies() # Assuming this returns a list of dicts
filtered_movies = all_movies
if year is not None:
filtered_movies = [m for m in filtered_movies if m.get("year") == year]
if downloaded is not None:
filtered_movies = [m for m in filtered_movies if m.get("hasFile") == downloaded]
if watched is not None:
if watched:
filtered_movies = [
m for m in filtered_movies
if is_watched_movie(m.get("title", ""), self.config)
]
else:
filtered_movies = [
m for m in filtered_movies
if not is_watched_movie(m.get("title", ""), self.config)
]
if actors:
filtered_movies = [
m for m in filtered_movies
if m.get("credits") and any(
actors.lower() in cast.get("name", "").lower()
for cast in m.get("credits", {}).get("cast", [])
)
]
return {
"count": len(filtered_movies),
"movies": [
{
"id": m.get("id"),
"title": m.get("title"),
"year": m.get("year"),
"overview": m.get("overview"),
"hasFile": m.get("hasFile"),
"status": m.get("status"),
"genres": m.get("genres", []),
"watched": is_watched_movie(m.get("title", ""), self.config)
}
for m in filtered_movies
]
}
def _register_resources(self):
@self.server.resource("http://example.com/series", description="TV series collection from Sonarr")
def series() -> dict:
series_list = get_all_series(self.config)
return {
"count": len(series_list),
"series": [
{
"id": s.id,
"title": s.title,
"year": s.year
}
for s in series_list
]
}
@self.server.resource("http://example.com/movies", description="Movie collection from Radarr")
def movies() -> dict:
from radarr_sonarr_mcp.services.radarr_service import RadarrService
radarr_service = RadarrService(self.config["radarrConfig"])
movies_list = radarr_service.get_all_movies() # Assuming list of dicts
return {
"count": len(movies_list),
"movies": [
{
"id": m.get("id"),
"title": m.get("title"),
"year": m.get("year")
}
for m in movies_list
]
}
def run(self):
port = self.config["server"]["port"]
logger.info(f"Starting Radarr-Sonarr MCP Server on port {port}")
logger.info(f"Connect Claude Desktop to: http://localhost:{port}")
self.server.run()
if __name__ == "__main__":
server = RadarrSonarrMCP()
server.run()