Skip to main content
Glama

media-mcp

Serveur MCP pour piloter un stack média self-hosted : Sonarr + Radarr, qBittorrent via qui (autobrr), Prowlarr (indexeurs) et Jellyfin (collections curatives / BoxSets).

Deux transports : stdio (défaut, dev local / Claude Desktop) et HTTP (service Docker sur le homelab). Voir Déploiement.

Prérequis

  • Python 3.11+

  • uv installé

Related MCP server: Sonarr & Radarr MCP Server

Installation

# Cloner / se placer dans le répertoire du projet
cd media-mcp

# Installer les dépendances
uv sync

# Copier et remplir les variables d'environnement
cp .env.example .env
# Éditer .env avec vos URLs et clés API

Lancement en développement

uv run python -m media_mcp

Le serveur démarre en mode stdio (défaut) et attend des messages MCP sur stdin/stdout.

Pour le lancer en HTTP localement :

MCP_TRANSPORT=http PORT=8080 uv run python -m media_mcp
# endpoint MCP : http://127.0.0.1:8080/mcp

Configuration Claude Desktop

Ajouter dans ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) ou %APPDATA%\Claude\claude_desktop_config.json (Windows) :

{
  "mcpServers": {
    "media-mcp": {
      "command": "uv",
      "args": ["--directory", "/chemin/absolu/media-mcp", "run", "python", "-m", "media_mcp"],
      "env": {
        "SONARR_URL": "http://localhost:8989",
        "SONARR_API_KEY": "xxx",
        "RADARR_URL": "http://localhost:7878",
        "RADARR_API_KEY": "xxx",
        "QUI_URL": "https://qui.example.com",
        "QUI_API_KEY": "xxx",
        "QUI_INSTANCE": "",
        "PROWLARR_URL": "http://localhost:9696",
        "PROWLARR_API_KEY": "xxx"
      }
    }
  }
}

Remplacer /chemin/absolu/media-mcp par le chemin réel du projet.

Variables d'environnement

Variable

Description

Défaut

MCP_TRANSPORT

Transport : stdio, http (= streamable-http) ou sse

stdio

HOST

Interface d'écoute (transports HTTP uniquement)

0.0.0.0

PORT

Port d'écoute (transports HTTP uniquement)

8080

SONARR_URL

URL de base Sonarr

http://localhost:8989

SONARR_API_KEY

Clé API Sonarr

(requis)

RADARR_URL

URL de base Radarr

http://localhost:7878

RADARR_API_KEY

Clé API Radarr

(requis)

QUI_URL

URL de base de l'instance qui

(requis pour qBit)

QUI_API_KEY

Clé API qui (Settings > API Keys)

(requis pour qBit)

QUI_INSTANCE

Instance qBit ciblée (id ou nom) ; vide = auto si une seule

(optionnel)

PROWLARR_URL

URL de base Prowlarr

(requis pour Prowlarr)

PROWLARR_API_KEY

Clé API Prowlarr

(requis pour Prowlarr)

JELLYFIN_URL

URL de base Jellyfin (ex. http://192.168.1.20:8096)

(requis pour Jellyfin)

JELLYFIN_API_KEY

Clé API Jellyfin (Dashboard > API Keys)

(requis pour Jellyfin)

Tools disponibles

Sonarr

Tool

Type

Description

sonarr_system_status

read

Statut et version de Sonarr

sonarr_list_series

read

Liste des séries suivies

sonarr_lookup_series(term)

read

Recherche une série (pour ajout)

sonarr_quality_profiles

read

Profils de qualité disponibles

sonarr_root_folders

read

Dossiers racine configurés

sonarr_queue

read

File de téléchargement + diagnostic des items bloqués (voir ci-dessous)

sonarr_disk_space

read

Espace disque par volume, le plus plein en premier

sonarr_health

read

Avertissements de santé de l'instance

sonarr_history(limit=20, event_type=None)

read

Événements récents (grab/import/…) avec downloadId ; filtre event_type optionnel (voir ci-dessous)

sonarr_delete_queue_item(queue_id=None, download_id=None, remove_from_client=True, blocklist=False, confirm=False)

write

Retire un item (par queue_id) ou tous ceux d'un même download_id (season pack) — exactement un des deux

sonarr_upcoming(days=7)

read

Épisodes à venir via calendrier

sonarr_series_seasons(series_id)

read

Détail saison par saison d'une série

sonarr_season_episodes(series_id, season_number)

read

Liste les épisodes d'une saison (E-num, titre, hasFile ✓/✗, monitored ✓/✗, id, fileId)

sonarr_add_series(tvdb_id, quality_profile_id, root_folder_path, confirm=False)

write

Ajoute une série

sonarr_set_season_monitoring(series_id, season_number, monitored)

write

(Dé)monitore une saison précise

sonarr_search_season(series_id, season_number, confirm=False)

write

Lance la recherche d'une saison

sonarr_delete_season(series_id, season_number, confirm=False)

destructive

Supprime tous les fichiers d'une saison

sonarr_delete_episode_file(episode_file_id, confirm=False)

destructive

Supprime un fichier d'épisode

sonarr_delete_series(series_id, delete_files=False, confirm=False)

destructive

Supprime une série

Radarr

Tool

Type

Description

radarr_system_status

read

Statut et version de Radarr

radarr_list_movies

read

Liste des films suivis

radarr_lookup_movie(term)

read

Recherche un film (pour ajout)

radarr_quality_profiles

read

Profils de qualité disponibles

radarr_root_folders

read

Dossiers racine configurés

radarr_queue

read

File de téléchargement + diagnostic des items bloqués (voir Sonarr)

radarr_disk_space

read

Espace disque par volume, le plus plein en premier

radarr_health

read

Avertissements de santé de l'instance

radarr_history(limit=20, event_type=None)

read

Événements récents (grab/import/…) avec downloadId ; filtre event_type optionnel (voir ci-dessous)

radarr_delete_queue_item(queue_id=None, download_id=None, remove_from_client=True, blocklist=False, confirm=False)

write

Retire un item (par queue_id) ou tous ceux d'un même download_id — exactement un des deux

radarr_upcoming(days=7)

read

Films à venir via calendrier

radarr_add_movie(tmdb_id, quality_profile_id, root_folder_path, confirm=False)

write

Ajoute un film

radarr_set_movie_monitoring(movie_id, monitored)

write

(Dé)monitore un film

radarr_search_movie(movie_id, confirm=False)

write

Lance la recherche d'un film

radarr_delete_movie_file(movie_id, confirm=False)

destructive

Supprime le fichier d'un film (garde le film suivi)

radarr_delete_movie(movie_id, delete_files=False, confirm=False)

destructive

Supprime un film

qBittorrent (via qui)

Accès uniquement via qui (le gestionnaire web multi-instance d'autobrr), jamais via l'API qBittorrent directe. Auth par header X-API-Key. Les tools ciblent l'instance résolue depuis QUI_INSTANCE (id ou nom) ; si vide et qu'une seule instance existe, elle est choisie automatiquement ; si plusieurs, une erreur liste les instances disponibles.

Tool

Type

Description

qbit_list_instances

read

Instances qBittorrent gérées par qui (id + nom)

qbit_list_torrents(filter=None)

read

Torrents de l'instance (nom, hash complet, état, %, taille, ratio, catégorie) ; filter = recherche libre (matche aussi le hash)

qbit_get_torrent(hash)

read

Détail d'un torrent par hash ou préfixe unique (pont avec le downloadId Sonarr/Radarr, insensible à la casse)

qbit_pause(hash)

control

Met un torrent en pause (réversible, pas de confirm)

qbit_resume(hash)

control

Reprend un torrent (réversible, pas de confirm)

qbit_delete_torrent(hash, delete_files=False, confirm=False)

destructive

Retire un torrent de qBittorrent, avec option suppression des fichiers

Les tools prenant un hash acceptent le hash complet (40 car., copiable depuis qbit_list_torrents) ou un préfixe unique ; un préfixe ambigu liste les candidats sans agir.

Le hash qBittorrent est la clé de liaison : c'est la valeur renvoyée par le downloadId de l'historique Sonarr/Radarr. La comparaison est insensible à la casse (qBit renvoie le hash en minuscules, les *arr souvent en majuscules).

Prowlarr (indexeurs)

Gestionnaire d'indexeurs Servarr — API en /api/v1 (et non v3), auth X-Api-Key. Orienté diagnostic des indexeurs.

Tool

Type

Description

prowlarr_system_status

read

Version de Prowlarr

prowlarr_list_indexers

read

Indexeurs configurés (id, nom, activé ✓/✗, protocole, privacy, catégories, tags), triés par nom

prowlarr_indexer_status

read

Indexeurs en échec / désactivés temporairement (+ disabledTill, dates d'échec) ; sinon « all indexers healthy »

prowlarr_health

read

Avertissements globaux Prowlarr (type/source/message)

prowlarr_test_indexer(indexer_id)

action

Teste la connectivité d'un indexeur → PASS/FAIL + message (pas de confirm)

prowlarr_test_all_indexers

action

Teste tous les indexeurs → résumé pass/fail, échecs mis en avant

prowlarr_search(query, indexer_ids=None, categories=None, limit=20)

read

Recherche cross-indexeurs (tout contenu) triée par seeders ; affiche guid+indexerId pour le grab

prowlarr_grab(guid, indexer_id, confirm=False)

acquisition

Envoie une release au download client de Prowlarr (dry-run/confirm)

prowlarr_indexer_status ne porte pas de message textuel de raison (l'API /indexerstatus n'expose que indexerId + horodatages) : il croise la liste des indexeurs pour le nom et affiche la date de reprise (disabledTill). Pour le « pourquoi » global, voir prowlarr_health.

Recherche & grab (contenu hors-*arr : ebooks, manga, logiciels…)

prowlarr_search interroge tous les indexeurs et renvoie, par release, la référence de grab (guid + indexerId) à passer à prowlarr_grab. Les résultats sont triés par seeders décroissant (le limit de Prowlarr n'étant pas un vrai plafond, la coupe est faite côté client).

prowlarr_grab envoie la release au download client configuré dans Prowlarr (dry-run par défaut ; confirm=True pour exécuter). Aucune catégorie n'est passée par le MCP : le classement final dans qBittorrent (ebook / logiciel / autre) est décidé par les Mapped Categories du download client, à configurer dans l'UI Prowlarr (Settings → Download Clients). S'il n'y a aucun download client, le grab renvoie un message clair (à ajouter d'abord dans l'UI). La recherche/le grab avec catégorie explicite restent gérés côté Prowlarr, pas ici.

Jellyfin (collections curatives / BoxSets)

Serveur média Jellyfin — endpoints à la racine du serveur (pas de préfixe /api/vN), auth par header Authorization: MediaBrowser Token="<clé>". Objectif : créer et gérer des collections curatives (BoxSets) avec description, pilotables en langage naturel.

Client autonome (ne dérive PAS d'ArrClient, comme QuiClient) : Jellyfin n'est pas une API *arr. Le userId requis par les endpoints d'items est résolu une fois (premier compte Policy.IsAdministrator via GET /Users) puis mis en cache pour la durée du process.

Tool

Type

Description

jellyfin_system_status

read

Nom + version du serveur (valide la clé API)

jellyfin_list_movies

read

Films de la bibliothèque (short id, titre, année, tmdbId)

jellyfin_list_collections

read

Collections/BoxSets (short id, nom, nb d'items, description tronquée)

jellyfin_collection_items(collection_ref)

read

Contenu d'une collection (par nom ou id)

jellyfin_playback_stats(days=7)

read

Stats de visionnage par utilisateur sur days jours — nécessite le plugin Playback Reporting (voir ci-dessous)

jellyfin_active_sessions()

read

Qui regarde quoi maintenant : utilisateur, appareil/client, item, état, progression, direct play/transcode

jellyfin_item_history(item, days=90)

read

Historique de lecture d'un item (qui, quand, combien de fois, combien de temps) — nécessite Playback Reporting

jellyfin_scan_library(library=None, confirm=False)

action

Déclenche un scan de bibliothèque : global (library=None) ou ciblé sur une bibliothèque

jellyfin_create_collection(name, movies, overview=None, confirm=False)

write

Crée une collection depuis une liste de films ; option description (verrouillée)

jellyfin_add_to_collection(collection_ref, movies, confirm=False)

write

Ajoute des films à une collection

jellyfin_remove_from_collection(collection_ref, movies, confirm=False)

write

Retire des films d'une collection (les films restent en bibliothèque)

jellyfin_set_overview(item_ref, overview, lock=True, confirm=False)

write

Écrit la description d'un item (collection ou film) ; lock la protège d'un refresh

jellyfin_delete_collection(collection_ref, confirm=False)

destructive

Supprime le conteneur collection (les films sont conservés)

Résolution des films (movies) et références (collection_ref / item_ref)

Le paramètre movies accepte une liste mixte : tmdbId numériques, ids Jellyfin (ou préfixe unique de 8 car.), ou titres approximatifs (casse/accents/articles/ponctuation normalisés — « Le Solitaire » ≈ « solitaire »). La résolution est une cascade qui s'arrête au premier niveau donnant un match unique :

  1. tmdbId exact (via ProviderIds.Tmdb)

  2. id Jellyfin, ou préfixe unique

  3. Name Jellyfin normalisé

  4. OriginalTitle Jellyfin normalisé

  5. repli Radarr — Radarr connaît les titres localisés/alternatifs (title, originalTitle, alternateTitles) que Jellyfin n'indexe parfois que sous un titre anglais. Le titre demandé y est matché, son tmdbId récupéré, puis rebranché sur Jellyfin par tmdbId. Utilise le client Radarr interne (jamais un appel vers nos propres tools MCP) ; si Radarr n'est pas configuré ou est injoignable, le niveau 5 est simplement sauté (not_found propre, aucune exception).

La règle est identique à chaque niveau : un seul candidat → matched ; plusieurs → ambiguous (candidats remontés, jamais un choix arbitraire) ; aucun → niveau suivant.

Chaque movies déclenche au plus un fetch bibliothèque Jellyfin + au plus un fetch Radarr (ce dernier uniquement si une référence atteint le niveau 5, en lazy). Les dry-runs (confirm=False) affichent exactement les films matched / ambiguous / not found avant toute écriture, avec une colonne indiquant le moyen de résolution (tmdb / id / title / original-title / via-radarr) ; un match via-radarr (le plus faillible) affiche en clair le titre Radarr ET le titre Jellyfin retenus. Sur confirm=True, une création/modification refuse de procéder si des références restent non résolues (pas de collection partielle en silence). ProviderIds.Tmdb est le pont fiable avec le tmdbId Radarr (jamais de match sur le titre en interne quand un tmdbId existe).

Sessions actives (jellyfin_active_sessions)

GET /Sessions renvoie tous les clients connectés, y compris ceux qui ne lisent rien : dans ce cas NowPlayingItem est absent (pas null) et PlayState ne contient que CanSeek/IsPaused/IsMuted/RepeatMode/PlaybackOrder. Le tool ne liste donc que les sessions avec NowPlayingItem et se contente de compter les clients connectés inactifs (« No active playback sessions (3 client(s) connected but idle) »). Par session : utilisateur, client + appareil, item (épisodes rendus « Série — S11E06 — Titre »), état playing/paused, progression position / durée (%) depuis les ticks (100 ns), et méthode de lecture (PlayState.PlayMethod : DirectPlay / DirectStream / Transcode) enrichie de TranscodingInfo (codecs, TranscodeReasons) quand ce bloc est présent. Le tool décrit l'état, il n'en tire aucune conclusion (l'agent décide, par exemple, s'il est prudent de lancer une suppression).

Limite assumée : lors de la découverte aucune session n'était en cours de lecture (3 clients connectés, 0 en lecture). Les champs propres à une lecture active (PlayState.PositionTicks, PlayState.PlayMethod, TranscodingInfo) sont donc issus du contrat Jellyfin, pas d'une capture live — et l'OpenAPI de ce serveur répond 500, un plugin cassant sa génération. Ils sont tous lus défensivement (absents → ? / unknown, jamais d'exception), ce que le cas idle exerce déjà en vrai et qu'un test couvre explicitement.

Scan de bibliothèque (jellyfin_scan_library)

Deux routes, existence vérifiée sans effet de bord (un GET sur une route POST-only répond 405 Method Not Allowed = la route existe ; 404 = elle n'existe pas) :

Cas

Route

Effet

library=None

POST /Library/Refresh

Scan global, aucun paramètre

library="Films"

POST /Items/{ItemId}/Refresh

Scan ciblé sur une bibliothèque

La bibliothèque est résolue via GET /Library/VirtualFolders (par nom, accents/casse normalisés — « series » trouve « Séries » —, ou par id/préfixe 8 car.). Ces entrées exposent leur id sous ItemId (pas Id), d'où un résolveur dédié dans jellyfin_resolve.py. Introuvable ou ambigu → message clair listant les bibliothèques disponibles, aucune action.

Paramètres de /Items/{id}/Refresh confirmés en live (valeur invalide → 400 nommant le paramètre) : metadataRefreshMode et imageRefreshMode sont des enums validés (Default | None | ValidationOnly | FullRefresh), replaceAllMetadata, replaceAllImages et regenerateTrickplay sont des booléens bindés. Il n'existe PAS de paramètre recursive (il est ignoré : rafraîchir un dossier parcourt déjà ses enfants). Le tool envoie Default/Default avec replaceAll*=false — la sémantique « chercher les nouveaux/anciens fichiers » de l'UI, qui conserve métadonnées et images.

confirm=False (défaut) est un dry-run strict : il annonce global (avec la liste des bibliothèques) ou ciblé (nom, id, type, chemins) et n'émet aucun POST. confirm=True déclenche ; Jellyfin exécute ensuite le scan de façon asynchrone (suivi dans Dashboard > Scheduled Tasks), la réponse confirme donc le déclenchement, pas la fin du scan.

Historique par item (jellyfin_item_history)

Il n'existe aucun filtre par item côté API : les paramètres item_id/itemId passés à user_activity sont acceptés (HTTP 200) mais ignorés — vérifié en live, payload identique. Le seul chemin réel est l'endpoint SQL du plugin, POST /user_usage_stats/submit_custom_query, qui interroge sa table PlaybackActivity (DateCreated, UserId, ItemId, ItemType, ItemName, PlaybackMethod, ClientName, DeviceName, PlayDuration en secondes).

Pièges de cet endpoint, tous confirmés en live et gérés :

  • Pas de requête paramétrée : la requête est du SQL brut. Chaque id est donc validé contre la forme GUID 32 hex avant interpolation (et provient toujours d'une réponse Jellyfin, jamais d'une saisie brute) ; tout le reste est écarté. Un test vérifie qu'une chaîne d'injection ne produit aucun appel HTTP.

  • UserName n'est pas une colonne : avec "ReplaceUserId": true, le plugin remplace a posteriori les valeurs de la colonne UserId par des noms et renomme l'en-tête en UserName. Le SQL doit donc sélectionner UserId ; sélectionner UserName échoue en « no such column ».

  • La clé de réponse est colums (typo du plugin), à côté de results (liste de listes de chaînes — y compris les compteurs) et message.

  • Les erreurs SQL arrivent en HTTP 200, colums/results vides et un message commençant par « Error Running Query » suivi d'une stack trace .NET. Un résultat légitimement vide est lui aussi vide mais son message est « Query executed, no data returned. ». Les deux sont distingués : le premier remonte une erreur propre (stack trace retirée), le second un « no playback recorded ».

  • Un id de série ne matche rien : Playback Reporting enregistre l'id de la feuille lue. Vérifié : l'id de « Grey's Anatomy » → 0 ligne, ses 97 ids d'épisodes → 38 lignes. Le tool développe donc les conteneurs (Series/Season/BoxSet) en leurs descendants, plafonné à 500 ids par requête (501 testés OK) — et annonce la troncature le cas échéant.

item est résolu sur les films ET les séries (resolve_media_item) avec la cascade de titres déjà en place (tmdbId → id/préfixe → Name → OriginalTitle, accents/articles normalisés) ; un titre ambigu liste les candidats (id + titre) sans rien faire. Sortie : un agrégat par utilisateur (lectures, temps cumulé, dernière lecture) sur la totalité des lectures, puis les 20 lectures les plus récentes en détail (le plafond est affiché).

Limite : l'item doit exister dans la bibliothèque pour être résolu. Playback Reporting conserve l'historique des items supprimés depuis (constaté en live), qui reste donc inatteignable par titre — passer directement l'id le retrouve.

Stats de visionnage — dépendance au plugin Playback Reporting

jellyfin_playback_stats(days=7) ne lit pas Jellyfin core : les statistiques viennent du plugin Playback Reporting (Dashboard > Plugins > Catalogue), qui expose ses propres routes sous le préfixe /user_usage_stats. Plugin absent/désactivé → 404 ; c'est traduit en JellyfinPluginMissingError (sous-classe de JellyfinClientError) et le tool renvoie un message actionnable — jamais une exception. Aucune variable d'env supplémentaire : le tool réutilise JELLYFIN_URL / JELLYFIN_API_KEY et l'auth existante (header MediaBrowser Token, confirmé en live sur l'endpoint plugin ; la variante dépréciée ?api_key= fonctionne aussi mais n'est pas utilisée).

Comportement de GET /user_usage_stats/user_activity vérifié en live (Playback Reporting 17.0.0.0 / Jellyfin 10.11.10) :

  • days est le seul paramètre qui filtre réellement. end_date et filter sont acceptés (HTTP 200) mais silencieusement ignorés — payload identique quelle que soit leur valeur ; ils ne sont donc pas exposés. Sans aucun paramètre l'endpoint renvoie [], d'où le refus explicite de days < 1 (qui se lirait à tort « aucune activité »).

  • La réponse est une liste avec une ligne PAR UTILISATEUR (pas par item) : user_name/user_id, total_count (nb de lectures), total_time (secondes), total_play_time (chaîne lisible du plugin), et item_name/client_name/latest_date/ last_seen qui décrivent uniquement la lecture la plus récente de cet utilisateur. Le tool trie par nombre de lectures décroissant et l'annonce dans sa sortie.

  • Piège total_time : le plugin accumule les durées sur un compteur 32 bits et une seule ligne corrompue fait déborder le total en négatif (observé en live : -2147441290, soit ≈ int32.min, sur un utilisateur dont le total_count était pourtant correct). Son propre total_play_time est calculé depuis cette même valeur, donc tout aussi faux (« < 1 minute »). Les deux sont rejetés : la durée s'affiche n/a avec une note nommant les utilisateurs concernés, plutôt qu'une durée plausible mais fausse. Les compteurs de lectures, eux, restent fiables.

Cadrage : jellyfin_item_history s'est greffé sur ce socle (via _playback_report_post). Les tools restants (plus regardés, « pas vu depuis N jours »…) ne sont pas dans cette itération mais la place est prête — même JellyfinClient._playback_report(), mapping 404 → plugin manquant déjà mutualisé. Routes sœurs confirmées en live sur le même préfixe : /GetTvShowsReport (par série, count + time, non affecté par le débordement par utilisateur), /PlayActivity (par jour), /HourlyReport, /user_list, /type_filter_list, et /submit_custom_query pour tout ce que les routes figées ne couvrent pas.

Pièges Jellyfin gérés

  • POST /Items/{id} = GET-modify-POST du BaseItemDto complet (pas de PATCH). Un DTO partiel renvoie 400 et peut corrompre l'item jusqu'au prochain rescan (champs collection null passés à .ToList()). Avant tout envoi, les champs tableau (Tags, Genres, Studios, People, LockedFields, GenreItems, TagItems…) sont normalisés en [] (jamais null) et ProviderIds en {} (map, pas liste). Un test respx vérifie explicitement qu'aucun null ne part dans un champ tableau.

  • Verrouillage : après écriture d'un Overview, "Overview" est ajouté à LockedFields (verrou au niveau champ) pour qu'un refresh de métadonnées n'écrase pas la description. Comportement par défaut, désactivable via lock=False.

  • Bibliothèque « Collections » absente : POST /Collections peut renvoyer une 500 (Sequence contains no elements) ; c'est traduit en message actionnable (créer une première collection depuis l'UI web) plutôt qu'une erreur brute.

Cadrage : l'upload d'affiche (jellyfin_set_collection_image) n'est pas dans cette itération — la place est prévue dans l'architecture (POST /Items/{id}/Images/Primary, corps base64 + Content-Type réel), à ajouter ensuite.

Tools coordonnés — purge « partout »

Suppriment, en un geste avec aperçu et confirm, les fichiers bibliothèque (Sonarr/Radarr) ET le(s) torrent(s) correspondants côté qBittorrent-via-qui, cross-seeds inclus.

Tool

Type

Description

sonarr_purge_season(series_id, season_number, delete_torrent_files=True, include_loose_matches=True, confirm=False)

destructive

Purge une saison partout (fichiers Sonarr + torrents + cross-seeds)

radarr_purge_movie(movie_id, delete_torrent_files=True, include_loose_matches=True, confirm=False)

destructive

Purge un film partout (fichier Radarr + torrents + cross-seeds)

Flux :

  1. Lister les fichiers concernés côté *arr (saison / film) → nombre + taille.

  2. Extraire les downloadId depuis l'historique *arr (/history/series, /history/movie) → ensemble des hash d'origine (dédupliqués ; un season pack partage un seul downloadId).

  3. Côté qui, pour chaque origine : résoudre le torrent, puis local-matches?strict=true → cross-seeds (siblings).

  4. Ensemble à supprimer = origines présentes ∪ siblings, dédupliqué par hash. include_loose_matches=False exclut les siblings match_type ∈ {name, release} (garde les matches content_path) et indique combien ont été exclus.

  5. Dry-run (confirm=False) : aperçu exhaustif des deux côtés, rien supprimé. confirm=True : suppression des fichiers *arr puis un seul bulk-action delete (avec deleteFiles selon delete_torrent_files) sur tous les hash ; rapport combiné.

Cas limites gérés (sans planter) : aucun downloadId (historique purgé → suppression biblio seule, torrents à gérer à la main) ; origine absente de qBit (ignorée, signalée) ; saison/film sans fichier (torrents traités quand même) ; cross-seed indispo (repli sur les origines seules).

Honnêteté sur l'espace disque : les tailles bibliothèque et torrents ne sont jamais additionnées — hardlinkées, ce sont généralement les mêmes octets. L'aperçu les montre séparément et rappelle que, comme on supprime les deux côtés (+ cross-seeds), l'espace de ce contenu sera cette fois réellement libéré (≈ la plus grande des deux tailles, pas la somme).

Pattern dry-run / confirm

Toutes les actions à effet de bord (add_*, delete_*, search_*) acceptent un paramètre confirm:

  • confirm=False (défaut) → aperçu sans exécution (dry-run)

  • confirm=True → exécution réelle

Note hardlink : les tools de suppression de fichiers (sonarr_delete_season, sonarr_delete_episode_file, radarr_delete_movie_file) retirent les fichiers côté Sonarr/Radarr uniquement. Si les fichiers sont en hardlink avec un client torrent, l'espace disque n'est pas libéré tant que le torrent n'est pas aussi supprimé côté client. L'aperçu dry-run le rappelle.

Filtre event_type de *_history

L'API attend un entier pour son query param eventType, donc le filtrage est fait côté client sur le champ texte eventType de chaque événement. event_type accepte :

Alias

Correspond à (eventType canonique)

grabbed

grabbed

imported

downloadFolderImported

failed

downloadFailed

deleted

episodeFileDeleted (Sonarr) / movieFileDeleted (Radarr)

renamed

episodeFileRenamed (Sonarr) / movieFileRenamed (Radarr)

ignored

downloadIgnored

La chaîne canonique exacte est aussi acceptée (ex. event_type="downloadFolderImported"). Une valeur inconnue renvoie un message listant les valeurs valides, sans appel API. Comme le filtrage est côté client sur une fenêtre élargie (une requête, pageSize = max(limit*5, 100)), un résultat filtré partiel ajoute une note showing N of up to {limit} (searched the {window} most recent events).

Diagnostic & regroupement de *_queue

sonarr_queue / radarr_queue surfacent, pour chaque item, pourquoi il est bloqué : trackedDownloadStatus / trackedDownloadState (ex. warning / importBlocked), le texte des statusMessages et l'errorMessage éventuel. Les messages par item sont bornés ((+N more)) pour rester lisibles ; un champ absent/null est géré sans erreur.

Les items partageant le même downloadId (un season pack = un torrent, N lignes) sont regroupés en une entrée [×N] affichant le downloadId (le pont vers qBittorrent) et la ligne ids: … (les queue IDs individuels du groupe, tronquée si trop longue). Les items sans downloadId restent individuels et conservent taille/ETA.

*_delete_queue_item accepte exactement un de queue_id (un item) ou download_id (tous les items du download, retirés en un seul DELETE /queue/bulk) ; en dry-run il liste le nombre d'items, leur(s) titre(s) et les IDs ciblés avant toute suppression.

Déploiement

⚠️ Sécurité — l'image GHCR est PUBLIQUE

  • Ne jamais mettre de secret dans l'image, le Dockerfile, un workflow ou un fichier suivi par git. Toutes les clés et URLs arrivent au runtime (env_file / environment / docker run -e). Le Dockerfile ne déclare que MCP_TRANSPORT, HOST et PORT.

  • .dockerignore exclut .env*, .git, tests/, .venv… : rien de sensible n'entre dans le build context.

  • .gitignore exclut .env, ses variantes et le vrai docker-compose.yml. Seuls .env.example et docker-compose.example.yml (placeholders) sont committés.

  • Les URLs configurées doivent être les URLs INTERNES du homelab (http://sonarr:8989, http://qui:7476, http://jellyfin:8096…), jamais les URLs Cloudflare/publiques : elles ne doivent ni fuiter ni faire transiter le trafic par l'extérieur. Cela vaut pour tous les tools Jellyfin, les nouveaux compris (jellyfin_active_sessions, jellyfin_scan_library, jellyfin_item_history) : ils n'introduisent aucune variable d'environnement supplémentaire et réutilisent JELLYFIN_URL / JELLYFIN_API_KEY fournies au runtime du conteneur (ZimaOS : env_file / environment, jamais dans l'image). Si les tools Jellyfin actuels fonctionnent en déployé, ceux-ci fonctionnent sans reconfiguration.

  • Le serveur MCP n'a aucune authentification : ne pas publier son port hors du homelab.

Transports

MCP_TRANSPORT

Transport FastMCP

Usage

stdio (défaut)

stdio

Dev local, Claude Desktop

http

streamable-http

Service Docker — endpoint /mcp

sse

sse

Clients MCP qui ne parlent que l'ancien transport — endpoint /sse

Le défaut reste stdio : la config Claude Desktop existante fonctionne sans changement. Une valeur inconnue fait échouer le démarrage avec la liste des valeurs acceptées.

Build & run Docker

docker build -t media-mcp:local .

# L'image démarre en HTTP sur 8080 (MCP_TRANSPORT=http est le défaut DANS l'image)
docker run --rm -p 127.0.0.1:8080:8080 --env-file .env media-mcp:local

L'image est multi-stage (deps résolues par uv, puis seul le venv est copié), tourne en non-root (uid 10001) et n'embarque ni les tests, ni .env, ni .git.

docker-compose (homelab)

cp docker-compose.example.yml docker-compose.yml   # le vrai compose est gitignoré
cp .env.example .env                                # puis remplir avec les URLs INTERNES
docker compose up -d

Réseau : media-mcp doit être sur le même réseau Docker que les services *arr / qui / Prowlarr / Jellyfin pour les joindre par nom de conteneur. Le compose d'exemple s'attache à un réseau external — le remplacer par le réseau réel (docker network ls). Si le client MCP (Hermes) tourne dans ce même réseau, il joint http://media-mcp:8080/mcp directement : inutile de publier le port.

CI/CD (GitHub Actions)

Workflow

Déclencheur

Ce qu'il fait

ci.yml

PR vers main

uv sync → ruff check → pytest, puis build de l'image sans push

release.yml

push sur main

build et push vers ghcr.io/<owner>/<repo>, tags latest + SHA du commit

L'auth GHCR passe par le GITHUB_TOKEN intégré (permissions: packages: write) : aucun PAT ni secret perso à stocker. Les workflows ne contiennent aucun secret applicatif — ils buildent l'image, ils ne la font pas tourner.

Rendre le package public (une seule fois, après le premier push) : GitHub → onglet Packages → media-mcp → Package settings → Change visibility → Public. Les packages GHCR sont privés par défaut.

Développement

# Lint & format
uv run ruff check src tests
uv run ruff format src tests

# Tests
uv run pytest

Architecture

src/media_mcp/
  config.py          # pydantic-settings — lit les variables d'env
  models.py          # modèles pydantic pour les réponses simplifiées
  coordinated.py     # service d'orchestration purge (arr + qui), logique lourde
  jellyfin_resolve.py   # résolution en cascade (tmdbId/id/Name/OriginalTitle + repli Radarr injecté)
  server.py          # instancie FastMCP (host/port) et enregistre tous les tools
  __main__.py        # entrypoint: python -m media_mcp — résout MCP_TRANSPORT
  clients/
    base.py          # ArrClient: httpx async, gestion des erreurs
    sonarr.py        # SonarrClient(ArrClient)
    radarr.py        # RadarrClient(ArrClient)
    prowlarr.py      # ProwlarrClient(ArrClient) — /api/v1
    qui.py           # QuiClient: httpx async, header X-API-Key (NE dérive PAS d'ArrClient)
    jellyfin.py      # JellyfinClient: root path, MediaBrowser Token (NE dérive PAS d'ArrClient)
  tools/
    sonarr_tools.py  # @mcp.tool pour Sonarr
    radarr_tools.py  # @mcp.tool pour Radarr
    qbit_tools.py    # @mcp.tool pour qBittorrent via qui
    prowlarr_tools.py     # @mcp.tool pour Prowlarr (indexeurs)
    coordinated_tools.py  # @mcp.tool purge saison/film "partout" (arr + qui)
    jellyfin_tools.py     # @mcp.tool pour Jellyfin (collections curatives / BoxSets)

Déploiement :

Dockerfile                  # image multi-stage (uv -> venv), non-root, http:8080
.dockerignore               # garde secrets/tests/.git hors du build context
docker-compose.example.yml  # modèle homelab (le vrai docker-compose.yml est gitignoré)
.github/workflows/
  ci.yml                    # PR : lint + tests + build sans push
  release.yml               # main : build + push GHCR (latest + SHA)

Ajouter un nouveau service (ex. Jellyseerr) : créer clients/jellyseerr.py et tools/jellyseerr_tools.py, puis enregistrer dans server.py.

Available Tools

60 tools
jellyfin_add_to_collectionA

Add movies to an existing collection (by collection name or id).

Same resolution ergonomics as jellyfin_create_collection. Movies already in the collection are reported and skipped. With confirm=False nothing is written; with confirm=True the add proceeds only if every reference resolved.

Inputs: movies (required), collection_ref (required), confirm. Returns: result

jellyfin_collection_itemsA

List the movies inside a collection (by collection name or id).

collection_ref accepts a collection name or a Jellyfin id (or its unique prefix).

Inputs: collection_ref (required). Returns: result

jellyfin_create_collectionA

Create a curated collection (BoxSet) from a list of movie references.

movies accepts a mixed list of tmdbIds, Jellyfin ids (or unique prefixes) and approximate titles; each is resolved and, in a dry-run, shown as matched / ambiguous / not found. Ambiguous or unknown references are NEVER guessed. With confirm=False (default) nothing is written. With confirm=True the collection is created only if EVERY reference resolved, so a partial collection is never created silently. Set overview to also write (and lock) a description on the new collection.

Inputs: name (required), movies (required), confirm, overview. Returns: result

jellyfin_delete_collectionA

Delete a collection/BoxSet (by name or id). The movies themselves are kept.

confirm=False previews; confirm=True deletes the collection container only.

Inputs: collection_ref (required), confirm. Returns: result

jellyfin_list_collectionsA

List Jellyfin collections/BoxSets (short id, name, item count, description).

Inputs: no named parameters. Returns: result

jellyfin_list_moviesA

List movies in the Jellyfin library (short id, title, year, tmdbId).

Inputs: no named parameters. Returns: result

jellyfin_remove_from_collectionA

Remove movies from a collection (by collection name or id).

The movies stay in the library; only their membership in the collection is removed. Same resolution ergonomics; references resolving to movies not currently in the collection are reported and skipped. confirm=False previews; confirm=True proceeds only if every reference resolved.

Inputs: movies (required), collection_ref (required), confirm. Returns: result

jellyfin_set_overviewA

Set an item's Overview/description (a collection or a movie, by name or id).

By default lock=True adds "Overview" to the item's LockedFields so a later metadata refresh cannot overwrite the curated text; set lock=False to leave it unlocked. confirm=False previews; confirm=True writes.

Inputs: item_ref (required), overview (required), lock, confirm. Returns: result

jellyfin_system_statusA

Return Jellyfin server name + version (validates the API key).

Inputs: no named parameters. Returns: result

prowlarr_grabA

Send a release to Prowlarr's download client (acquisition side effect).

No category is passed: Prowlarr routes the download and its qBittorrent category comes from Prowlarr's Mapped Categories (configured in the Prowlarr UI). Set confirm=True to actually grab; omit or set False for a dry-run preview. Use the guid + indexerId shown by prowlarr_search.

Inputs: guid (required), indexer_id (required), confirm. Returns: result

prowlarr_healthA

Show Prowlarr global health warnings (type/source/message).

Inputs: no named parameters. Returns: result

prowlarr_indexer_statusA

List indexers currently failing / temporarily disabled, with the retry time.

Prowlarr disables an indexer after repeated failures until disabledTill. Status entries carry no textual reason (see prowlarr_health for that), so timestamps are shown. Returns a clear message when every indexer is healthy.

Inputs: no named parameters. Returns: result

prowlarr_list_indexersA

List indexers configured in Prowlarr.

Per indexer: id, name, enabled (✓/✗), protocol, privacy, main categories, tags. Sorted by name.

Inputs: no named parameters. Returns: result

prowlarr_system_statusA

Return Prowlarr system status (version).

Inputs: no named parameters. Returns: result

prowlarr_test_all_indexersA

Test every indexer and summarize pass/fail, highlighting the failures.

Inputs: no named parameters. Returns: result

prowlarr_test_indexerA

Test connectivity of a single indexer. Returns pass/fail + message(s).

Benign side effect (no confirm). Unknown indexer_id -> clear message.

Inputs: indexer_id (required). Returns: result

qbit_delete_torrentA

Remove a torrent from qBittorrent by hash (via qui).

Accepts a full hash or a unique prefix. Set delete_files=True to also delete its downloaded files from disk. Set confirm=True to actually remove; omit or set False for a dry-run preview.

Inputs: hash (required), confirm, delete_files. Returns: result

qbit_get_torrentA

Show a single torrent by its hash (via qui).

The hash is the same value as the Sonarr/Radarr history downloadId, so this is the bridge to locate a release's torrent. Accepts a full hash or a unique prefix; matching is case-insensitive.

Inputs: hash (required). Returns: result

qbit_list_instancesA

List the qBittorrent instances managed by qui (id + name).

Useful to find the id/name to put in QUI_INSTANCE when several exist. Access goes through qui, never qBittorrent directly.

Inputs: no named parameters. Returns: result

qbit_list_torrentsA

List torrents of the target qBittorrent instance (via qui).

Optional filter is a free-text search (name, and also matches the hash). Each line shows: state, name, short hash, progress %, size, ratio, category.

Inputs: filter. Returns: result

qbit_pauseA

Pause a torrent by hash or unique prefix (via qui).

Reversible, so no confirm required.

Inputs: hash (required). Returns: result

qbit_resumeC

Resume a torrent by hash or unique prefix (via qui).

Reversible, so no confirm required.

Inputs: hash (required). Returns: result

radarr_add_movieA

Add a movie to Radarr by tmdbId.

Use radarr_lookup_movie to get tmdb_id, radarr_quality_profiles for quality_profile_id, and radarr_root_folders for root_folder_path.

Set confirm=True to actually add; omit or set False for a dry-run preview.

Inputs: tmdb_id (required), root_folder_path (required), quality_profile_id (required), confirm. Returns: result

radarr_delete_movieA

Delete a movie from Radarr by its internal id.

Set delete_files=True to also remove downloaded files from disk. Set confirm=True to actually delete; omit or set False for a dry-run preview.

Inputs: movie_id (required), confirm, delete_files. Returns: result

radarr_delete_movie_fileA

Delete the movie's file but KEEP the movie tracked in Radarr (destructive).

Unlike radarr_delete_movie, this only removes the downloaded file, so the movie stays monitored for re-download/upgrade. Set confirm=True to actually delete; omit or set False for a dry-run preview.

Inputs: movie_id (required), confirm. Returns: result

radarr_delete_queue_itemA

Remove item(s) from the Radarr download queue (stuck/failed download).

Provide EXACTLY ONE of:

  • queue_id: a single queue item;

  • download_id: ALL items sharing that downloadId, removed in one gesture. remove_from_client also deletes the download from the torrent/usenet client; blocklist prevents the same release from being grabbed again. Set confirm=True to actually remove; omit or set False for a dry-run preview.

Inputs: confirm, queue_id, blocklist, download_id, +1 more. Returns: result

radarr_disk_spaceA

Show free disk space per volume known to Radarr, fullest volume first.

Inputs: no named parameters. Returns: result

radarr_healthA

Show Radarr instance health checks (notices, warnings, errors).

Inputs: no named parameters. Returns: result

radarr_historyA

Show recent Radarr history events (grab/import/deletion...).

Surfaces the downloadId (the torrent hash) when present, which later links a release to its torrent in the download client. Optionally filter by event_type, accepting a readable alias (grabbed, imported, failed, deleted, renamed, ignored) or an exact canonical eventType (e.g. downloadFolderImported). Filtering is done client-side, so a filtered result may include a note when the window is not filled.

Inputs: limit, event_type. Returns: result

radarr_list_moviesA

List all movies tracked in Radarr.

Inputs: no named parameters. Returns: result

radarr_lookup_movieA

Search Radarr for a movie by title (for adding a new movie).

Returns tmdbId needed for radarr_add_movie.

Inputs: term (required). Returns: result

radarr_purge_movieA

Purge a movie everywhere: delete its file in Radarr AND remove the matching torrent(s) in qBittorrent-via-qui, cross-seeds included (destructive).

The torrents are located via the movie's history downloadId(s) and their qui cross-seed siblings. include_loose_matches=False drops name/release-matched siblings (keeps content_path matches). delete_torrent_files also deletes the torrents' files from disk. Set confirm=True to execute; False for a dry-run.

Inputs: movie_id (required), confirm, delete_torrent_files, include_loose_matches. Returns: result

radarr_quality_profilesA

List available quality profiles in Radarr (id + name needed for radarr_add_movie).

Inputs: no named parameters. Returns: result

radarr_queueA

Show current download queue in Radarr, with diagnostics for stuck items.

Surfaces trackedDownloadStatus/State and statusMessages/errorMessage (the "why"), and groups items sharing a downloadId.

Inputs: no named parameters. Returns: result

radarr_root_foldersA

List configured root folders in Radarr (path needed for radarr_add_movie).

Inputs: no named parameters. Returns: result

radarr_search_movieA

Trigger a search/download for an already-added movie.

Set confirm=True to actually launch the search; omit or set False for a dry-run preview.

Inputs: movie_id (required), confirm. Returns: result

radarr_set_movie_monitoringA

Enable or disable monitoring for a single movie.

Reversible action with no immediate grab, so no confirm is required. Returns the movie state after the change.

Inputs: movie_id (required), monitored (required). Returns: result

radarr_system_statusA

Return Radarr system status (version, health).

Inputs: no named parameters. Returns: result

radarr_upcomingA

Show movies releasing in the next N days (default 7) via Radarr calendar.

Uses the most relevant release date available (digital > physical > cinema).

Inputs: days. Returns: result

sonarr_add_seriesA

Add a series to Sonarr by tvdbId.

Use sonarr_lookup_series to get tvdb_id, sonarr_quality_profiles for quality_profile_id, and sonarr_root_folders for root_folder_path.

Set confirm=True to actually add; omit or set False for a dry-run preview.

Inputs: tvdb_id (required), root_folder_path (required), quality_profile_id (required), confirm. Returns: result

sonarr_delete_episode_fileA

Delete a single episode file by its id (destructive).

Set confirm=True to actually delete; omit or set False for a dry-run preview. Note: files are removed from Sonarr only — hardlinked files are not freed on disk until the torrent is removed too.

Inputs: episode_file_id (required), confirm. Returns: result

sonarr_delete_queue_itemA

Remove item(s) from the Sonarr download queue (stuck/failed download).

Provide EXACTLY ONE of:

  • queue_id: a single queue item;

  • download_id: ALL items sharing that downloadId (a season pack = one torrent, many rows) removed in one gesture. remove_from_client also deletes the download from the torrent/usenet client; blocklist prevents the same release from being grabbed again. Set confirm=True to actually remove; omit or set False for a dry-run preview.

Inputs: confirm, queue_id, blocklist, download_id, +1 more. Returns: result

sonarr_delete_seasonA

Delete ALL episode files of a single season (destructive).

Set confirm=True to actually delete; omit or set False for a dry-run preview. Handles one season per call. Note: files are removed from Sonarr only — hardlinked files are not freed on disk until the torrent is removed too.

Inputs: series_id (required), season_number (required), confirm. Returns: result

sonarr_delete_seriesA

Delete a series from Sonarr by its internal id.

Set delete_files=True to also remove downloaded files from disk. Set confirm=True to actually delete; omit or set False for a dry-run preview.

Inputs: series_id (required), confirm, delete_files. Returns: result

sonarr_disk_spaceA

Show free disk space per volume known to Sonarr, fullest volume first.

Inputs: no named parameters. Returns: result

sonarr_healthA

Show Sonarr instance health checks (notices, warnings, errors).

Inputs: no named parameters. Returns: result

sonarr_historyA

Show recent Sonarr history events (grab/import/deletion...).

Surfaces the downloadId (the torrent hash) when present, which later links a release to its torrent in the download client. Optionally filter by event_type, accepting a readable alias (grabbed, imported, failed, deleted, renamed, ignored) or an exact canonical eventType (e.g. downloadFolderImported). Filtering is done client-side, so a filtered result may include a note when the window is not filled.

Inputs: limit, event_type. Returns: result

sonarr_list_seriesA

List all monitored and unmonitored series tracked in Sonarr.

Inputs: no named parameters. Returns: result

sonarr_lookup_seriesA

Search Sonarr for series by title (for adding a new series).

Returns tvdbId needed for sonarr_add_series.

Inputs: term (required). Returns: result

sonarr_purge_seasonA

Purge a whole season everywhere: delete its episode files in Sonarr AND remove the matching torrent(s) in qBittorrent-via-qui, cross-seeds included (destructive).

The torrents are located via the season's history downloadId(s) and their qui cross-seed siblings. include_loose_matches=False drops name/release-matched siblings (keeps content_path matches). delete_torrent_files also deletes the torrents' files from disk. Set confirm=True to execute; False for a dry-run.

Inputs: series_id (required), season_number (required), confirm, delete_torrent_files, +1 more. Returns: result

sonarr_quality_profilesA

List available quality profiles in Sonarr (id + name needed for sonarr_add_series).

Inputs: no named parameters. Returns: result

sonarr_queueA

Show current download queue in Sonarr, with diagnostics for stuck items.

Surfaces trackedDownloadStatus/State and statusMessages/errorMessage (the "why"), and groups items sharing a downloadId (a season pack is one torrent, many rows).

Inputs: no named parameters. Returns: result

sonarr_root_foldersA

List configured root folders in Sonarr (path needed for sonarr_add_series).

Inputs: no named parameters. Returns: result

sonarr_search_seasonB

Trigger a search/download for a whole season.

Set confirm=True to actually launch the search; omit or set False for a dry-run preview of what would be searched.

Inputs: series_id (required), season_number (required), confirm. Returns: result

sonarr_season_episodesA

List the episodes of one season with their file/monitoring status.

Per episode: E-number, title, hasFile (✓/✗), monitored (✓/✗), episode id, and episodeFileId when present. Useful to see exactly what is present/missing (e.g. confirm a phantom episode with no file). Sorted by episode number.

Inputs: series_id (required), season_number (required). Returns: result

sonarr_series_seasonsA

Show a season-by-season breakdown of an already-tracked series.

For each season: number, whether it is monitored, episodes present/total, and whether it is complete. Season 0 is labelled as Specials.

Inputs: series_id (required). Returns: result

sonarr_set_season_monitoringA

Enable or disable monitoring for a single season of a series.

Reversible action with no immediate grab, so no confirm is required. Returns the season state after the change.

Inputs: monitored (required), series_id (required), season_number (required). Returns: result

sonarr_system_statusA

Return Sonarr system status (version, health).

Inputs: no named parameters. Returns: result

sonarr_upcomingB

Show episodes airing in the next N days (default 7) via Sonarr calendar.

Inputs: days. Returns: result

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A3.9/5.0

    Scored across 60 tools

    Disambiguation5/5

    All 60 tools are clearly grouped by service (qbit_, prowlarr_, jellyfin_, radarr_, sonarr_) and within each group, each tool has a distinct purpose. There is no ambiguity between tools, even across similar operations on different services.

    Naming Consistency5/5

    Tool names follow a consistent pattern of service_verb_noun in snake_case (e.g., sonarr_add_series, radarr_delete_movie). The naming is predictable and uniform across all services, with no mixing of styles.

    Tool Count4/5

    With 60 tools across 5 services, the count is on the higher side but appropriate for the breadth of functionality. Each service has a reasonable number of tools (e.g., ~6 for qBittorrent, ~18 for Sonarr) covering essential operations without being excessive.

    Completeness4/5

    The tool set covers core workflows for each service: CRUD for movies/series, collections, queue management, search, and system status. Minor gaps exist (e.g., no tag management or custom format operations), but the surface is comprehensive enough for most media management tasks.

    Maintenance

    ActivityInactive
    ResponsivenessOutdated data

    Related MCP Connectors

    Related MCP Servers