Skip to main content
Glama
index.ts78.1 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import axios, { AxiosInstance } from 'axios'; import { CacheManager } from './utils/cache.js'; import { normalizeTitle, extractSeasonNumber, inferExpectedMediaType, selectBestMatch, encodeSearchQuery } from './utils/normalize.js'; import { withRetry, batchWithRetry } from './utils/retry.js'; import { SearchResult, MediaRequest, MediaDetails, SearchMediaArgs, RequestMediaArgs, ManageRequestsArgs, GetDetailsArgs, DedupeResult, CompactMediaResult, MediaInfo, DedupeDetails, } from './types.js'; // Field mapping for includeDetails feature type FieldMapper = (item: { mediaType: string; id: number }, details: MediaDetails) => any; const FIELD_MAP: Record<string, FieldMapper> = { // Basic info (from search results, no API call needed) 'mediaType': (item) => item.mediaType, 'year': (item, details) => details.releaseDate?.substring(0, 4) || details.firstAirDate?.substring(0, 4), 'posterPath': (item, details) => details.posterPath, // Standard details (from MediaDetails API) 'rating': (item, details) => details.voteAverage, 'overview': (item, details) => details.overview, 'genres': (item, details) => details.genres, 'runtime': (item, details) => details.runtime, // TV-specific 'numberOfSeasons': (item, details) => details.numberOfSeasons, 'numberOfEpisodes': (item, details) => details.numberOfEpisodes, 'seasons': (item, details) => enrichSeasons(details), // Advanced details 'releaseDate': (item, details) => details.releaseDate, 'firstAirDate': (item, details) => details.firstAirDate, 'originalTitle': (item, details) => (details as any).originalTitle, 'originalName': (item, details) => (details as any).originalName, 'popularity': (item, details) => (details as any).popularity, 'backdropPath': (item, details) => (details as any).backdropPath, 'homepage': (item, details) => (details as any).homepage, 'status': (item, details) => (details as any).status, 'tagline': (item, details) => (details as any).tagline, // Availability info (from mediaInfo) 'mediaStatus': (item, details) => details.mediaInfo?.status, 'hasRequests': (item, details) => (details.mediaInfo?.requests?.length || 0) > 0, 'requestCount': (item, details) => details.mediaInfo?.requests?.length || 0, }; /** * Enriches seasons array with availability status */ function enrichSeasons(details: MediaDetails): DedupeDetails['seasons'] { if (!details.seasons || !Array.isArray(details.seasons)) { return undefined; } return details.seasons.map(season => { // Find status for this season from mediaInfo let status = 'NOT_REQUESTED'; if (details.mediaInfo?.seasons) { const seasonInfo = details.mediaInfo.seasons.find(s => s.seasonNumber === season.seasonNumber); if (seasonInfo) { if (seasonInfo.status === 5) { status = 'AVAILABLE'; } else if (seasonInfo.status === 4) { status = 'PARTIALLY_AVAILABLE'; } else if (seasonInfo.status === 3) { status = 'PROCESSING'; } else if (seasonInfo.status === 2) { status = 'PENDING'; } } } // Check if this season has been requested if (details.mediaInfo?.requests) { const hasRequest = details.mediaInfo.requests.some(req => req.media.seasons?.some(s => s.seasonNumber === season.seasonNumber) ); if (hasRequest && status === 'NOT_REQUESTED') { status = 'REQUESTED'; } } return { seasonNumber: season.seasonNumber, episodeCount: season.episodeCount, airDate: season.airDate, status }; }); } // Validation functions function validateOverseerrUrl(url: string): { valid: boolean; error?: string } { if (!url || typeof url !== 'string') { return { valid: false, error: 'OVERSEERR_URL must be a non-empty string' }; } try { const parsed = new URL(url); if (!['http:', 'https:'].includes(parsed.protocol)) { return { valid: false, error: 'OVERSEERR_URL must use http:// or https:// protocol' }; } return { valid: true }; } catch (error) { return { valid: false, error: 'OVERSEERR_URL must be a valid URL (e.g., https://overseerr.example.com)' }; } } function validateApiKey(key: string): { valid: boolean; error?: string } { if (!key || typeof key !== 'string') { return { valid: false, error: 'OVERSEERR_API_KEY must be a non-empty string' }; } // API keys should be at least 20 characters and Base64-compatible if (key.length < 20) { return { valid: false, error: 'OVERSEERR_API_KEY appears to be too short (expected at least 20 characters)' }; } if (!/^[a-zA-Z0-9\-_+/=]+$/.test(key)) { return { valid: false, error: 'OVERSEERR_API_KEY contains invalid characters. It should be a Base64-compatible string.' }; } return { valid: true }; } const OVERSEERR_URL = process.env.OVERSEERR_URL; const OVERSEERR_API_KEY = process.env.OVERSEERR_API_KEY; if (!OVERSEERR_URL || !OVERSEERR_API_KEY) { throw new Error( 'OVERSEERR_URL and OVERSEERR_API_KEY environment variables are required' ); } // Validate URL format const urlValidation = validateOverseerrUrl(OVERSEERR_URL); if (!urlValidation.valid) { throw new Error(`Invalid OVERSEERR_URL: ${urlValidation.error}`); } // Validate API key format const keyValidation = validateApiKey(OVERSEERR_API_KEY); if (!keyValidation.valid) { throw new Error(`Invalid OVERSEERR_API_KEY: ${keyValidation.error}`); } class OverseerrServer { private server: Server; private axiosInstance: AxiosInstance; private cache: CacheManager; constructor() { this.server = new Server( { name: 'overseerr-mcp', version: '1.2.3', }, { capabilities: { tools: {}, }, } ); this.axiosInstance = axios.create({ baseURL: `${OVERSEERR_URL}/api/v1`, headers: { 'X-Api-Key': OVERSEERR_API_KEY, 'Content-Type': 'application/json', }, }); this.cache = new CacheManager(); this.setupToolHandlers(); this.server.onerror = (error: Error) => console.error('[MCP Error]', error); process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } /** * Enriches a dedupe result with requested detail fields */ private enrichDedupeResult( baseResult: DedupeResult, item: { mediaType: string; id: number }, details: MediaDetails, requestedFields: string[], seasonNumber?: number | null, includeSeason: boolean = true ): DedupeResult { if (!requestedFields || requestedFields.length === 0) { return baseResult; } const enrichedDetails: DedupeDetails = {}; // Extract requested fields using field mappers for (const field of requestedFields) { const mapper = FIELD_MAP[field]; if (mapper) { const value = mapper(item, details); if (value !== undefined && value !== null) { (enrichedDetails as any)[field] = value; } } } // Auto-add targetSeason for TV shows with season number if (includeSeason && seasonNumber && item.mediaType === 'tv' && details.seasons) { const targetSeasonData = details.seasons.find(s => s.seasonNumber === seasonNumber); if (targetSeasonData) { // Determine season status let seasonStatus = 'NOT_REQUESTED'; if (details.mediaInfo?.seasons) { const seasonInfo = details.mediaInfo.seasons.find(s => s.seasonNumber === seasonNumber); if (seasonInfo) { if (seasonInfo.status === 5) { seasonStatus = 'AVAILABLE'; } else if (seasonInfo.status === 4) { seasonStatus = 'PARTIALLY_AVAILABLE'; } else if (seasonInfo.status === 3) { seasonStatus = 'PROCESSING'; } else if (seasonInfo.status === 2) { seasonStatus = 'PENDING'; } } } // Check if requested if (details.mediaInfo?.requests) { const hasRequest = details.mediaInfo.requests.some(req => req.media.seasons?.some(s => s.seasonNumber === seasonNumber) ); if (hasRequest && seasonStatus === 'NOT_REQUESTED') { seasonStatus = 'REQUESTED'; } } enrichedDetails.targetSeason = { seasonNumber: targetSeasonData.seasonNumber, episodeCount: targetSeasonData.episodeCount, airDate: targetSeasonData.airDate, status: seasonStatus, }; } } // Only add details object if it has at least one field if (Object.keys(enrichedDetails).length > 0) { return { ...baseResult, details: enrichedDetails, }; } return baseResult; } private filterDetailsByLevel( details: MediaDetails, level: string, fields?: string[] ): any { // If specific fields requested, return only those if (fields && fields.length > 0) { const filtered: any = {}; const item = { mediaType: details.mediaType || 'movie', id: details.id }; fields.forEach(field => { const mapper = FIELD_MAP[field]; if (mapper) { const value = mapper(item, details); if (value !== undefined) { filtered[field] = value; } } }); return filtered; } // Level-based filtering switch (level) { case 'basic': return { id: details.id, mediaType: details.mediaType, title: details.title || details.name, overview: details.overview, year: details.releaseDate?.substring(0, 4) || details.firstAirDate?.substring(0, 4), rating: details.voteAverage, mediaInfo: details.mediaInfo ? { status: this.getMediaStatusString(details.mediaInfo.status), hasRequests: (details.mediaInfo.requests?.length || 0) > 0, } : undefined, }; case 'standard': return { mediaType: details.mediaType, id: details.id, title: details.title || details.name, overview: details.overview, releaseDate: details.releaseDate || details.firstAirDate, genres: details.genres, voteAverage: details.voteAverage, runtime: details.runtime, numberOfSeasons: details.numberOfSeasons, numberOfEpisodes: details.numberOfEpisodes, seasons: details.seasons, mediaInfo: details.mediaInfo, }; case 'full': default: return details; } } private setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'search_media', description: 'Search movies/TV with single/batch/dedupe modes. Dedupe returns actionable status for batch processing.\n' + 'Status: NOT_FOUND | ALREADY_AVAILABLE | ALREADY_REQUESTED | SEASON_AVAILABLE | SEASON_REQUESTED | AVAILABLE_FOR_REQUEST', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Single search query', }, queries: { type: 'array', items: { type: 'string' }, description: 'Multiple search queries (batch mode)', }, dedupeMode: { type: 'boolean', description: 'Batch dedupe with availability check', default: false, }, titles: { type: 'array', items: { type: 'string' }, description: 'Titles to check (dedupe mode)', }, autoNormalize: { type: 'boolean', description: 'Strip "Season N"/"Part N" from titles', default: false, }, autoRequest: { type: 'boolean', description: 'Auto-request passing items (requires dedupeMode)', default: false, }, requestOptions: { type: 'object', description: 'AutoRequest options', properties: { seasons: { oneOf: [ { type: 'array', items: { type: 'number' } }, { type: 'string', enum: ['all'] }, ], description: 'TV seasons. "all"=no season 0 (specials); [0,1,2]=with specials', }, is4k: { type: 'boolean', description: 'Request 4K', default: false, }, serverId: { type: 'number' }, profileId: { type: 'number' }, rootFolder: { type: 'string' }, dryRun: { type: 'boolean', description: 'Preview only', default: false, }, }, }, checkAvailability: { type: 'boolean', description: 'Check status (slower, fetches per-result details)', default: false, }, format: { type: 'string', enum: ['compact', 'standard', 'full'], description: 'Response format', default: 'compact', }, limit: { type: 'number', description: 'Max results', }, page: { type: 'number', description: 'Page number', default: 1, }, language: { type: 'string', description: 'Language code', default: 'en', }, includeDetails: { type: 'object', description: 'Add details to dedupe results (dedupe only)', properties: { fields: { type: 'array', items: { type: 'string' }, description: 'Basic: mediaType,year,posterPath | Standard: rating,overview,genres,runtime | ' + 'TV: numberOfSeasons,numberOfEpisodes,seasons | Advanced: releaseDate,firstAirDate,originalTitle,originalName,popularity,backdropPath,homepage,status,tagline | ' + 'Availability: mediaStatus,hasRequests,requestCount | targetSeason auto-adds for season numbers', }, includeSeason: { type: 'boolean', description: 'Auto-add targetSeason for TV with season in title', default: true, }, }, }, }, }, }, { name: 'request_media', description: 'Request media with auto-confirm for TV ≤24 eps. Single/batch with validation.\n' + 'Confirm: Movies auto | TV ≤24 eps auto | TV >24 eps needs confirmed:true\n' + 'TV needs seasons (array or "all"). "all"=no specials; [0,1,2]=with specials', inputSchema: { type: 'object', properties: { mediaType: { type: 'string', enum: ['movie', 'tv'], description: 'Media type (single)', }, mediaId: { type: 'number', description: 'TMDB ID (single)', }, items: { type: 'array', items: { type: 'object', properties: { mediaType: { type: 'string', enum: ['movie', 'tv'] }, mediaId: { type: 'number' }, seasons: { oneOf: [ { type: 'array', items: { type: 'number' } }, { type: 'string', enum: ['all'] }, ], description: 'TV seasons (REQUIRED). "all"=no season 0 (specials); [0,1,2]=with specials', }, is4k: { type: 'boolean' }, }, required: ['mediaType', 'mediaId'], }, description: 'Batch items', }, seasons: { oneOf: [ { type: 'array', items: { type: 'number' } }, { type: 'string', enum: ['all'] }, ], description: 'TV seasons. "all"=no season 0 (specials); [0,1,2]=with specials', }, is4k: { type: 'boolean', description: 'Request 4K', default: false, }, serverId: { type: 'number' }, profileId: { type: 'number' }, rootFolder: { type: 'string' }, validateFirst: { type: 'boolean', description: 'Check existing', default: true, }, dryRun: { type: 'boolean', description: 'Preview only', default: false, }, confirmed: { type: 'boolean', description: 'Confirm multi-season', default: false, }, }, }, }, { name: 'manage_media_requests', description: 'Manage requests: get/list/approve/decline/delete. Supports filters and batching.\n' + 'Filters: all|pending|approved|available|processing|unavailable|failed', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['get', 'list', 'approve', 'decline', 'delete'], description: 'Action', }, requestId: { type: 'number', description: 'Request ID (single)', }, requestIds: { type: 'array', items: { type: 'number' }, description: 'Request IDs (batch)', }, format: { type: 'string', enum: ['compact', 'standard', 'full'], default: 'compact', }, summary: { type: 'boolean', description: 'Stats instead of list', default: false, }, filter: { type: 'string', enum: ['all', 'pending', 'approved', 'available', 'processing', 'unavailable', 'failed'], default: 'all', }, take: { type: 'number', default: 20 }, skip: { type: 'number', default: 0 }, sort: { type: 'string', enum: ['added', 'modified'], default: 'added', }, }, required: ['action'], }, }, { name: 'get_media_details', description: 'Get media details. Single/batch with level control (basic/standard/full).', inputSchema: { type: 'object', properties: { mediaType: { type: 'string', enum: ['movie', 'tv'], description: 'Media type (single)', }, mediaId: { type: 'number', description: 'TMDB ID (single)', }, items: { type: 'array', items: { type: 'object', properties: { mediaType: { type: 'string', enum: ['movie', 'tv'] }, mediaId: { type: 'number' }, }, required: ['mediaType', 'mediaId'], }, description: 'Batch items', }, level: { type: 'string', enum: ['basic', 'standard', 'full'], description: 'Detail level', default: 'standard', }, fields: { type: 'array', items: { type: 'string' }, description: 'Specific fields', }, format: { type: 'string', enum: ['compact', 'standard', 'full'], default: 'compact', }, language: { type: 'string', description: 'Language code', default: 'en', }, }, }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request: any) => { try { switch (request.params.name) { case 'search_media': return await this.handleSearchMedia(request.params.arguments); case 'request_media': return await this.handleRequestMedia(request.params.arguments); case 'manage_media_requests': return await this.handleManageRequests(request.params.arguments); case 'get_media_details': return await this.handleGetDetails(request.params.arguments); default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } } catch (error) { if (axios.isAxiosError(error)) { const status = (error as any).response?.status; const message = (error as any).response?.data?.message || (error as any).message; return { content: [ { type: 'text', text: `Overseerr API error (${status}): ${message}`, }, ], isError: true, }; } throw error; } }); } // Tool implementations will continue in the next section... // Due to character limits, I'll create a new file to continue private async handleSearchMedia(args: SearchMediaArgs) { const searchArgs = args as SearchMediaArgs; // Dedupe mode - batch check multiple titles if (searchArgs.dedupeMode && searchArgs.titles) { return this.handleDedupeMode(searchArgs); } // Batch mode - multiple queries if (searchArgs.queries && searchArgs.queries.length > 0) { return this.handleBatchSearch(searchArgs); } // Single search mode if (searchArgs.query) { return this.handleSingleSearch(searchArgs); } throw new McpError( ErrorCode.InvalidParams, 'Must provide either query, queries, or (dedupeMode + titles)' ); } private async handleSingleSearch(args: SearchMediaArgs) { const query = args.query!; const cacheKey = { query, page: args.page || 1, language: args.language || 'en' }; // Check cache const cached = this.cache.get<SearchResult>('search', cacheKey); if (cached) { return this.formatSearchResponse(cached, args.format || 'compact', args.limit); } // Search with retry - build URL manually with encoded query const result = await withRetry(async () => { const encodedQuery = encodeSearchQuery(query); const page = args.page || 1; const language = args.language || 'en'; const url = `/search?query=${encodedQuery}&page=${page}&language=${language}`; const response = await this.axiosInstance.get<SearchResult>(url); return response.data; }); // Cache result this.cache.set('search', cacheKey, result); return this.formatSearchResponse(result, args.format || 'compact', args.limit); } private async handleBatchSearch(args: SearchMediaArgs) { const queries = args.queries!; const results = await batchWithRetry( queries, async (query) => { const cacheKey = { query, page: 1, language: args.language || 'en' }; const cached = this.cache.get<SearchResult>('search', cacheKey); if (cached) return cached; // Build URL manually with encoded query const encodedQuery = encodeSearchQuery(query); const language = args.language || 'en'; const url = `/search?query=${encodedQuery}&page=1&language=${language}`; const response = await this.axiosInstance.get<SearchResult>(url); const data = response.data; this.cache.set('search', cacheKey, data); return data; } ); const successful = results.filter(r => r.success); const failed = results.filter(r => !r.success); return { content: [ { type: 'text', text: JSON.stringify({ summary: { total: queries.length, successful: successful.length, failed: failed.length, }, results: successful.map(r => ({ query: r.item, results: this.limitResults(r.result!.results, args.limit).map(item => this.formatCompactResult(item) ), })), errors: failed.map(r => ({ query: r.item, error: r.error?.message || 'Unknown error', })), }, null, 2), }, ], }; } private async handleDedupeMode(args: SearchMediaArgs) { const titles = args.titles!; const autoNormalize = args.autoNormalize || false; const autoRequest = args.autoRequest || false; const includeDetails = args.includeDetails; const requestedFields = includeDetails?.fields || []; const includeSeason = includeDetails?.includeSeason !== false; // default true const dedupeResults: DedupeResult[] = []; const autoRequestQueue: Array<{ mediaType: 'movie' | 'tv'; mediaId: number; seasons?: number[] | 'all' }> = []; const processedTitles = await batchWithRetry( titles, async (originalTitle) => { // Normalize title if requested const searchTitle = autoNormalize ? normalizeTitle(originalTitle) : originalTitle; const seasonNumber = extractSeasonNumber(originalTitle); // Search for the title - build URL manually with encoded query const cacheKey = { query: searchTitle, page: 1, language: args.language || 'en' }; let searchResult = this.cache.get<SearchResult>('search', cacheKey); if (!searchResult) { const encodedQuery = encodeSearchQuery(searchTitle); const language = args.language || 'en'; const url = `/search?query=${encodedQuery}&page=1&language=${language}`; const response = await this.axiosInstance.get<SearchResult>(url); searchResult = response.data; this.cache.set('search', cacheKey, searchResult); } // If no results, it's a pass (not in system) if (!searchResult.results || searchResult.results.length === 0) { // Not found since NOT_FOUND items cannot be requested const baseResult: DedupeResult = { title: originalTitle, id: 0, mediaType: undefined, // Unknown since not found status: 'blocked' as const, reasonCode: 'NOT_FOUND', isActionable: false, note: 'Not found in TMDB', }; // No enrichment for not found items return baseResult; } // Smart result selection with media type validation const expectedType = inferExpectedMediaType(originalTitle); const selection = selectBestMatch(searchResult.results, expectedType, searchTitle); let bestMatch = selection.match; let alternates = selection.alternates; // Log low confidence matches for debugging if (selection.confidence === 'low') { console.error(`[WARN] Low confidence match for "${originalTitle}": expected ${expectedType}, got ${bestMatch.mediaType} (${bestMatch.title || bestMatch.name})`); } // For season-specific queries, validate season number exists in matched series if (seasonNumber && bestMatch.mediaType === 'tv') { // Fetch details to check numberOfSeasons const detailsCacheKey = { mediaType: 'tv', mediaId: bestMatch.id }; let details = this.cache.get<MediaDetails>('mediaDetails', detailsCacheKey); if (!details) { const detailsResponse = await this.axiosInstance.get<MediaDetails>( `/tv/${bestMatch.id}` ); details = detailsResponse.data; details.mediaType = 'tv'; this.cache.set('mediaDetails', detailsCacheKey, details); } // Validate: if requested season > total seasons, try alternates if (details.numberOfSeasons && seasonNumber > details.numberOfSeasons) { console.error(`[WARN] Season ${seasonNumber} requested but "${bestMatch.title || bestMatch.name}" only has ${details.numberOfSeasons} seasons. Trying alternates...`); // Try each alternate let foundValid = false; for (const alternate of alternates) { if (alternate.mediaType !== 'tv') continue; const altCacheKey = { mediaType: 'tv', mediaId: alternate.id }; let altDetails = this.cache.get<MediaDetails>('mediaDetails', altCacheKey); if (!altDetails) { const altResponse = await this.axiosInstance.get<MediaDetails>(`/tv/${alternate.id}`); altDetails = altResponse.data; altDetails.mediaType = 'tv'; this.cache.set('mediaDetails', altCacheKey, altDetails); } // Check if this alternate has enough seasons if (altDetails.numberOfSeasons && seasonNumber <= altDetails.numberOfSeasons) { console.error(`[INFO] Found valid alternate: "${alternate.title || alternate.name}" with ${altDetails.numberOfSeasons} seasons`); bestMatch = alternate; foundValid = true; break; } } // If no valid match found, return NOT_FOUND if (!foundValid) { return { title: originalTitle, id: 0, mediaType: undefined, status: 'blocked' as const, reasonCode: 'NOT_FOUND', isActionable: false, note: `Season ${seasonNumber} not found - no matching series with that many seasons` }; } } } // Check if it's TV and we need details for season checking if (bestMatch.mediaType === 'tv') { const detailsCacheKey = { mediaType: 'tv', mediaId: bestMatch.id }; let details = this.cache.get<MediaDetails>('mediaDetails', detailsCacheKey); if (!details) { const detailsResponse = await this.axiosInstance.get<MediaDetails>( `/tv/${bestMatch.id}` ); details = detailsResponse.data; // Add mediaType to details for enrichment details.mediaType = 'tv'; this.cache.set('mediaDetails', detailsCacheKey, details); } // Get media info for status checking const mediaInfo = details.mediaInfo; // CASE 1: Specific season mentioned in title if (seasonNumber) { // Check if this specific season is in library (PENDING, PROCESSING, PARTIALLY_AVAILABLE, or AVAILABLE) // Do NOT block: UNKNOWN(1), DELETED(6), or missing const targetSeasonInfo = mediaInfo?.seasons?.find(s => s.seasonNumber === seasonNumber); if (targetSeasonInfo && [2, 3, 4, 5].includes(targetSeasonInfo.status)) { const statusStr = this.getMediaStatusString(targetSeasonInfo.status); const baseResult: DedupeResult = { title: originalTitle, id: bestMatch.id, mediaType: 'tv', status: 'blocked' as const, reason: `Season ${seasonNumber} is ${statusStr.toLowerCase()}`, reasonCode: 'SEASON_AVAILABLE', isActionable: false, franchiseInfo: `Season ${seasonNumber} of ${details.name || bestMatch.name}`, }; if (requestedFields.length > 0) { return { result: this.enrichDedupeResult( baseResult, { mediaType: 'tv', id: bestMatch.id }, details, requestedFields, seasonNumber, includeSeason ) }; } return { result: baseResult }; } // Check if this specific season is requested const seasonRequested = mediaInfo?.requests?.some(req => req.media.seasons?.some(s => s.seasonNumber === seasonNumber) ); if (seasonRequested) { const baseResult: DedupeResult = { title: originalTitle, id: bestMatch.id, mediaType: 'tv', status: 'blocked' as const, reason: `Season ${seasonNumber} is already requested`, reasonCode: 'SEASON_REQUESTED', isActionable: false, franchiseInfo: `Season ${seasonNumber} of ${details.name || bestMatch.name}`, }; if (requestedFields.length > 0) { return { result: this.enrichDedupeResult( baseResult, { mediaType: 'tv', id: bestMatch.id }, details, requestedFields, seasonNumber, includeSeason ) }; } return { result: baseResult }; } // Specific season not in library/requested - it's a pass const baseResult: DedupeResult = { title: originalTitle, id: bestMatch.id, mediaType: 'tv', status: 'pass' as const, reasonCode: 'AVAILABLE_FOR_REQUEST', isActionable: true, franchiseInfo: `Season ${seasonNumber} of ${details.name || bestMatch.name}`, }; // Auto-add enhanced details if requested if (requestedFields.length > 0) { return { result: this.enrichDedupeResult( baseResult, { mediaType: 'tv', id: bestMatch.id }, details, requestedFields, seasonNumber, includeSeason ) }; } return { result: baseResult }; } // CASE 2: No specific season mentioned - check base series availability // BUG FIX: Check show-level status FIRST before checking individual seasons // This catches shows marked as AVAILABLE at show level even without complete season data if (mediaInfo && [5].includes(mediaInfo.status)) { const baseResult: DedupeResult = { title: originalTitle, id: bestMatch.id, mediaType: 'tv', status: 'blocked' as const, reason: `Already in library (show-level)`, reasonCode: 'ALREADY_AVAILABLE', isActionable: false, franchiseInfo: `${details.name || bestMatch.name}`, }; if (requestedFields.length > 0) { return this.enrichDedupeResult(baseResult, { mediaType: 'tv', id: bestMatch.id }, details, requestedFields, null, includeSeason); } return { result: baseResult }; } // Check if there are show-level requests (not season-specific) if (mediaInfo?.requests && mediaInfo.requests.length > 0) { const hasShowLevelRequest = mediaInfo.requests.some(req => !req.media.seasons || req.media.seasons.length === 0 ); if (hasShowLevelRequest) { const baseResult: DedupeResult = { title: originalTitle, id: bestMatch.id, mediaType: 'tv', status: 'blocked' as const, reason: 'Already requested (show-level)', reasonCode: 'ALREADY_REQUESTED', isActionable: false, franchiseInfo: `${details.name || bestMatch.name}`, }; if (requestedFields.length > 0) { return this.enrichDedupeResult(baseResult, { mediaType: 'tv', id: bestMatch.id }, details, requestedFields, null, includeSeason); } return { result: baseResult }; } } // Now check individual seasons const regularSeasons = details.seasons?.filter(s => s.seasonNumber > 0) || []; if (regularSeasons.length > 0) { // Check if ALL regular seasons are in library (statuses 2-5) const allSeasonsAvailable = regularSeasons.every(season => { const seasonInfo = mediaInfo?.seasons?.find(s => s.seasonNumber === season.seasonNumber); return seasonInfo && [2, 3, 4, 5].includes(seasonInfo.status); }); if (allSeasonsAvailable && mediaInfo?.seasons && mediaInfo.seasons.length > 0) { const availableSeasons = mediaInfo.seasons.filter(s => s.seasonNumber > 0 && [2, 3, 4, 5].includes(s.status)).map(s => s.seasonNumber).sort((a, b) => a - b); const baseResult: DedupeResult = { title: originalTitle, id: bestMatch.id, mediaType: 'tv', status: 'blocked' as const, reason: `All regular seasons already in library (${availableSeasons.join(', ')})`, reasonCode: 'ALREADY_AVAILABLE', isActionable: false, franchiseInfo: `${details.name || bestMatch.name} - All ${availableSeasons.length} seasons in library (S${availableSeasons.join(', S')})`, }; if (requestedFields.length > 0) { return this.enrichDedupeResult(baseResult, { mediaType: 'tv', id: bestMatch.id }, details, requestedFields, null, includeSeason); } return { result: baseResult }; } // Check if ALL regular seasons are requested const allSeasonsRequested = regularSeasons.every(season => { return mediaInfo?.requests?.some(req => req.media.seasons?.some(s => s.seasonNumber === season.seasonNumber) ); }); if (allSeasonsRequested && mediaInfo?.requests && mediaInfo.requests.length > 0) { const requestedSeasons = regularSeasons.filter(season => mediaInfo.requests?.some(req => req.media.seasons?.some(s => s.seasonNumber === season.seasonNumber)) ).map(s => s.seasonNumber).sort((a, b) => a - b); const baseResult: DedupeResult = { title: originalTitle, id: bestMatch.id, mediaType: 'tv', status: 'blocked' as const, reason: `All regular seasons already requested (${requestedSeasons.join(', ')})`, reasonCode: 'ALREADY_REQUESTED', isActionable: false, franchiseInfo: `${details.name || bestMatch.name} - All ${requestedSeasons.length} seasons requested (S${requestedSeasons.join(', S')})`, }; if (requestedFields.length > 0) { return this.enrichDedupeResult(baseResult, { mediaType: 'tv', id: bestMatch.id }, details, requestedFields, null, includeSeason); } return { result: baseResult }; } // Partial availability/requests - check enhanced franchise info let availableSeasons = mediaInfo?.seasons?.filter(s => s.seasonNumber > 0 && [2, 3, 4, 5].includes(s.status)).map(s => s.seasonNumber).sort((a, b) => a - b) || []; let requestedSeasons = regularSeasons.filter(season => mediaInfo?.requests?.some(req => req.media.seasons?.some(s => s.seasonNumber === season.seasonNumber)) ).map(s => s.seasonNumber).sort((a, b) => a - b); // Build enhanced franchise info let franchiseInfo = `${details.name || bestMatch.name}`; if (availableSeasons.length > 0 || requestedSeasons.length > 0) { const statusParts = []; if (availableSeasons.length > 0) { statusParts.push(`${availableSeasons.length} in library (S${availableSeasons.join(', S')})`); } if (requestedSeasons.length > 0) { statusParts.push(`${requestedSeasons.length} requested (S${requestedSeasons.join(', S')})`); } franchiseInfo += ` - ${statusParts.join(', ')}`; } // Some seasons in library/requested, but not all - it's a pass const baseResult: DedupeResult = { title: originalTitle, id: bestMatch.id, mediaType: 'tv', status: 'pass' as const, reasonCode: 'AVAILABLE_FOR_REQUEST', isActionable: true, franchiseInfo: franchiseInfo, }; // Auto-add enhanced details if requested if (requestedFields.length > 0) { return this.enrichDedupeResult(baseResult, { mediaType: 'tv', id: bestMatch.id }, details, requestedFields, null, includeSeason); } return { result: baseResult }; } // Fallback: No seasons info available, check overall status if (mediaInfo && [2, 3, 4, 5].includes(mediaInfo.status)) { const statusStr = this.getMediaStatusString(mediaInfo.status); const baseResult: DedupeResult = { title: originalTitle, id: bestMatch.id, mediaType: 'tv', status: 'blocked' as const, reason: `Already in library (${statusStr.toLowerCase()})`, reasonCode: 'ALREADY_AVAILABLE', isActionable: false, }; // Enrich if details requested if (requestedFields.length > 0) { return { result: this.enrichDedupeResult( baseResult, { mediaType: 'tv', id: bestMatch.id }, details, requestedFields, null, includeSeason ) }; } return { result: baseResult }; } if (mediaInfo?.requests && mediaInfo.requests.length > 0) { const baseResult: DedupeResult = { title: originalTitle, id: bestMatch.id, mediaType: 'tv', status: 'blocked' as const, reason: 'Already requested', reasonCode: 'ALREADY_REQUESTED', isActionable: false, }; // Enrich if details requested if (requestedFields.length > 0) { return { result: this.enrichDedupeResult( baseResult, { mediaType: 'tv', id: bestMatch.id }, details, requestedFields, null, includeSeason ) }; } return { result: baseResult }; } // Not requested - it's a pass const baseResult: DedupeResult = { title: originalTitle, id: bestMatch.id, mediaType: 'tv', status: 'pass' as const, reasonCode: 'AVAILABLE_FOR_REQUEST', isActionable: true, }; // Enrich if details requested if (requestedFields.length > 0) { return { result: this.enrichDedupeResult( baseResult, { mediaType: 'tv', id: bestMatch.id }, details, requestedFields, null, includeSeason ) }; } return { result: baseResult }; } else { // Movie - simpler check const detailsCacheKey = { mediaType: 'movie', mediaId: bestMatch.id }; let details = this.cache.get<MediaDetails>('mediaDetails', detailsCacheKey); if (!details) { const detailsResponse = await this.axiosInstance.get<MediaDetails>( `/movie/${bestMatch.id}` ); details = detailsResponse.data; // Add mediaType to details for enrichment details.mediaType = 'movie'; this.cache.set('mediaDetails', detailsCacheKey, details); } const mediaInfo = details.mediaInfo; if (mediaInfo && mediaInfo.status) { const statusStr = this.getMediaStatusString(mediaInfo.status); // Check if movie is in library (statuses 2-5) if ([2, 3, 4, 5].includes(mediaInfo.status)) { const baseResult: DedupeResult = { title: originalTitle, id: bestMatch.id, mediaType: 'movie', status: 'blocked' as const, reason: `Already in library (${statusStr.toLowerCase()})`, reasonCode: 'ALREADY_AVAILABLE', isActionable: false, }; // Enrich if details requested if (requestedFields.length > 0) { return { result: this.enrichDedupeResult( baseResult, { mediaType: 'movie', id: bestMatch.id }, details, requestedFields, null, includeSeason ) }; } return { result: baseResult }; } if (mediaInfo.requests && mediaInfo.requests.length > 0) { const baseResult: DedupeResult = { title: originalTitle, id: bestMatch.id, mediaType: 'movie', status: 'blocked' as const, reason: 'Already requested', reasonCode: 'ALREADY_REQUESTED', isActionable: false, }; // Enrich if details requested if (requestedFields.length > 0) { return { result: this.enrichDedupeResult( baseResult, { mediaType: 'movie', id: bestMatch.id }, details, requestedFields, null, includeSeason ) }; } return { result: baseResult }; } } // Not requested - it's a pass const baseResult: DedupeResult = { title: originalTitle, id: bestMatch.id, mediaType: 'movie', status: 'pass' as const, reasonCode: 'AVAILABLE_FOR_REQUEST', isActionable: true, }; // Enrich if details requested if (requestedFields.length > 0) { return { result: this.enrichDedupeResult( baseResult, { mediaType: 'movie', id: bestMatch.id }, details, requestedFields, null, includeSeason ) }; } return { result: baseResult }; } } ); // Collect results processedTitles.forEach(result => { if (result.success && result.result) { const dedupeItem = result.result as DedupeResult; dedupeResults.push(dedupeItem); // If autoRequest enabled, queue this item for requesting if (autoRequest && dedupeItem.isActionable === true && dedupeItem.mediaType === 'tv') { const seasonNumber = extractSeasonNumber(result.item); // For TV shows, determine which seasons to request let seasonsToRequest: number[] | 'all' | undefined; if (seasonNumber) { // Specific season mentioned in title seasonsToRequest = [seasonNumber]; } else if (args.requestOptions?.seasons) { // Use requestOptions.seasons for TV shows without specific season seasonsToRequest = args.requestOptions.seasons; } else { // Default to 'all' if no season specified seasonsToRequest = 'all'; } autoRequestQueue.push({ mediaType: dedupeItem.mediaType, mediaId: dedupeItem.id, seasons: seasonsToRequest, }); } else if (autoRequest && dedupeItem.isActionable === true && dedupeItem.mediaType === 'movie') { // Movies don't need seasons autoRequestQueue.push({ mediaType: dedupeItem.mediaType, mediaId: dedupeItem.id, }); } } }); const passCount = dedupeResults.filter(r => r.status === 'pass').length; const blockedCount = dedupeResults.filter(r => r.status === 'blocked').length; const actionableCount = dedupeResults.filter(r => r.isActionable === true).length; // If autoRequest enabled and there are items to request, process them let autoRequestResults; if (autoRequest && autoRequestQueue.length > 0) { // Check if this is a dry run const isDryRun = args.requestOptions?.dryRun === true; if (isDryRun) { // Dry run - don't actually request, just show what would be requested autoRequestResults = { dryRun: true, totalQueued: autoRequestQueue.length, wouldRequest: autoRequestQueue.map(item => ({ mediaType: item.mediaType, mediaId: item.mediaId, seasons: item.seasons, })), message: 'Dry run - no requests were made. Remove "dryRun: true" from requestOptions to actually request.', }; } else { // Actually make the requests const requestResults = await batchWithRetry( autoRequestQueue, async (item) => { try { // Expand "all" to actual season numbers (excluding season 0 - specials) let seasonsToRequest = item.seasons; if (item.mediaType === 'tv' && item.seasons === 'all') { const detailsResponse = await this.axiosInstance.get<MediaDetails>(`/tv/${item.mediaId}`); const details = detailsResponse.data; // Get all regular seasons excluding season 0 (specials) const regularSeasons = details.seasons?.filter(s => s.seasonNumber > 0) || []; seasonsToRequest = regularSeasons.map(s => s.seasonNumber); // If no regular seasons found, fall back to numberOfSeasons if (seasonsToRequest.length === 0 && details.numberOfSeasons) { seasonsToRequest = Array.from({ length: details.numberOfSeasons }, (_, i) => i + 1); // Filter out season 0 if it's in the list seasonsToRequest = seasonsToRequest.filter(s => s > 0); } } const requestBody: any = { mediaType: item.mediaType, mediaId: item.mediaId, is4k: args.requestOptions?.is4k || false, }; if (item.mediaType === 'tv' && seasonsToRequest) { requestBody.seasons = seasonsToRequest; } if (args.requestOptions?.serverId) requestBody.serverId = args.requestOptions.serverId; if (args.requestOptions?.profileId) requestBody.profileId = args.requestOptions.profileId; if (args.requestOptions?.rootFolder) requestBody.rootFolder = args.requestOptions.rootFolder; const response = await this.axiosInstance.post('/request', requestBody); // Invalidate caches this.cache.invalidate('requests'); this.cache.invalidate('mediaDetails'); return { success: true, requestId: response.data.id, mediaId: item.mediaId, mediaType: item.mediaType, seasons: seasonsToRequest, status: response.data.status }; } catch (error: any) { return { success: false, mediaId: item.mediaId, mediaType: item.mediaType, error: (error as any).response?.data?.message || (error as any).message || 'Unknown error', }; } } ); const successfulRequests = requestResults.filter(r => r.success && r.result?.success); const failedRequests = requestResults.filter(r => !r.success || !r.result?.success); autoRequestResults = { enqueue: true, totalRequested: autoRequestQueue.length, successful: successfulRequests.length, failed: failedRequests.length, requests: successfulRequests.map(r => r.result), errors: failedRequests.map(r => ({ mediaId: r.item.mediaId, mediaType: r.item.mediaType, error: r.error?.message || r.result?.error || 'Unknown error', })), }; } } const response: any = { summary: { total: titles.length, pass: passCount, blocked: blockedCount, actionable: actionableCount, passRate: `${((passCount / titles.length) * 100).toFixed(1)}%`, }, results: dedupeResults, }; if (autoRequestResults) { response.autoRequests = autoRequestResults; } return { content: [ { type: 'text', text: JSON.stringify(response, null, 2), }, ], }; } private async handleRequestMedia(args: any) { const requestArgs = args as RequestMediaArgs; // Batch mode if (requestArgs.items && requestArgs.items.length > 0) { return this.handleBatchRequest(requestArgs); } // Single mode if (!requestArgs.mediaType || !requestArgs.mediaId) { throw new McpError( ErrorCode.InvalidParams, 'Must provide mediaType and mediaId (or items array for batch)' ); } return this.handleSingleRequest(requestArgs); } private async handleSingleRequest(args: RequestMediaArgs) { const { mediaType, mediaId, seasons, is4k, validateFirst, dryRun, confirmed } = args; // Validate TV show requests have seasons specified if (mediaType === 'tv' && !seasons) { throw new McpError( ErrorCode.InvalidParams, 'seasons parameter is required for TV show requests. Use seasons: [1,2,3] for specific seasons or seasons: "all" for all seasons.' ); } // Expand "all" to actual season numbers (excluding season 0) early in the function let expandedSeasons: number[] | undefined = undefined; if (mediaType === 'tv' && seasons) { if (seasons === 'all') { const response = await this.axiosInstance.get<MediaDetails>(`/tv/${mediaId}`); const details = response.data; // Get all regular seasons (exclude season 0 - specials) const regularSeasons = details.seasons?.filter(s => s.seasonNumber > 0) || []; expandedSeasons = regularSeasons.map(s => s.seasonNumber); // If no regular seasons found, fall back to numberOfSeasons if (expandedSeasons.length === 0 && details.numberOfSeasons) { expandedSeasons = Array.from({ length: details.numberOfSeasons }, (_, i) => i + 1); } } else { // Already an array, use as-is expandedSeasons = seasons as number[]; } } // Validate first if requested if (validateFirst) { const detailsCacheKey = { mediaType, mediaId }; let details = this.cache.get<MediaDetails>('mediaDetails', detailsCacheKey); if (!details) { const response = await this.axiosInstance.get<MediaDetails>( `/${mediaType}/${mediaId}` ); details = response.data; this.cache.set('mediaDetails', detailsCacheKey, details); } const mediaInfo = details.mediaInfo; if (mediaInfo?.requests && mediaInfo.requests.length > 0) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, status: 'ALREADY_REQUESTED', message: `${details.title || details.name} is already requested`, existingRequests: mediaInfo.requests.map(r => ({ id: r.id, status: this.getStatusString(r.status), requestedBy: r.requestedBy.displayName || r.requestedBy.email, createdAt: r.createdAt, })), }, null, 2), }, ], }; } if (mediaInfo?.status === 5) { // AVAILABLE return { content: [ { type: 'text', text: JSON.stringify({ success: false, status: 'ALREADY_AVAILABLE', message: `${details.title || details.name} is already available`, }, null, 2), }, ], }; } } // Multi-season confirmation check if (mediaType === 'tv' && !confirmed && expandedSeasons) { const requireConfirm = process.env.REQUIRE_MULTI_SEASON_CONFIRM !== 'false'; if (requireConfirm) { // Get details to calculate episode count const response = await this.axiosInstance.get<MediaDetails>(`/tv/${mediaId}`); const details = response.data; const totalSeasons = details.numberOfSeasons || 0; const seasonsToRequest = expandedSeasons; // Calculate total episode count for requested seasons let totalEpisodes = 0; if (details.seasons) { seasonsToRequest.forEach((seasonNum: number) => { const seasonData = details.seasons?.find(s => s.seasonNumber === seasonNum); if (seasonData) { totalEpisodes += seasonData.episodeCount; } }); } // Only require confirmation if episode count exceeds threshold (24) const EPISODE_THRESHOLD = 24; if (totalEpisodes > EPISODE_THRESHOLD) { // Build message including episode count for context return { content: [ { type: 'text', text: JSON.stringify({ requiresConfirmation: true, media: { totalSeasons, totalEpisodes: details.numberOfEpisodes, requestingSeasons: seasonsToRequest, requestingEpisodes: totalEpisodes, threshold: EPISODE_THRESHOLD, }, message: `This will request ${seasonsToRequest.length} season(s) with ${totalEpisodes} episodes of ${details.name}. Add "confirmed: true" to proceed.`, confirmWith: { ...args, confirmed: true, }, }, null, 2), }, ], }; } } } // Dry run - don't actually request if (dryRun) { const response = await this.axiosInstance.get<MediaDetails>( `/${mediaType}/${mediaId}` ); const details = response.data; return { content: [ { type: 'text', text: JSON.stringify({ dryRun: true, wouldRequest: { title: details.title || details.name, mediaType, mediaId, seasons: mediaType === 'tv' ? expandedSeasons : undefined, is4k: is4k || false, }, message: 'Dry run - no request was made. Remove "dryRun: true" to actually request.', }, null, 2), }, ], }; } // Actually make the request const requestBody: any = { mediaType, mediaId, is4k: is4k || false, }; // Use expandedSeasons (array) to ensure season 0 is not included if (mediaType === 'tv' && expandedSeasons) { requestBody.seasons = expandedSeasons; } if (args.serverId) requestBody.serverId = args.serverId; if (args.profileId) requestBody.profileId = args.profileId; if (args.rootFolder) requestBody.rootFolder = args.rootFolder; const response = await withRetry(async () => { return await this.axiosInstance.post('/request', requestBody); }); // Invalidate caches this.cache.invalidate('requests'); this.cache.invalidate('mediaDetails'); return { content: [ { type: 'text', text: JSON.stringify({ success: true, requestId: response.data.id, status: this.getStatusString(response.data.status), message: `Successfully requested ${response.data.media.title || response.data.media.name}`, seasonsRequested: response.data.seasons?.map((s: any) => s.seasonNumber), }, null, 2), }, ], }; } private async handleBatchRequest(args: RequestMediaArgs) { const items = args.items!; const results = await batchWithRetry( items, async (item) => { const singleArgs = { ...args, mediaType: item.mediaType, mediaId: item.mediaId, seasons: item.seasons, is4k: item.is4k, items: undefined, }; const result = await this.handleSingleRequest(singleArgs); return JSON.parse(result.content[0].text); } ); const successful = results.filter(r => r.success && r.result?.success); const failed = results.filter(r => !r.success || !r.result?.success); return { content: [ { type: 'text', text: JSON.stringify({ summary: { total: items.length, successful: successful.length, failed: failed.length, }, results: successful.map(r => r.result), errors: failed.map(r => ({ item: r.item, error: r.error?.message || r.result?.message || 'Unknown error', })), }, null, 2), }, ], }; } private async handleManageRequests(args: any) { const manageArgs = args as ManageRequestsArgs; switch (manageArgs.action) { case 'get': return this.handleGetRequest(manageArgs); case 'list': return this.handleListRequests(manageArgs); case 'approve': return this.handleApproveRequests(manageArgs); case 'decline': return this.handleDeclineRequests(manageArgs); case 'delete': return this.handleDeleteRequests(manageArgs); default: throw new McpError( ErrorCode.InvalidParams, `Unknown action: ${manageArgs.action}` ); } } private async handleGetRequest(args: ManageRequestsArgs) { if (!args.requestId) { throw new McpError(ErrorCode.InvalidParams, 'requestId is required for get action'); } const cacheKey = { requestId: args.requestId }; let request = this.cache.get<MediaRequest>('requests', cacheKey); if (!request) { const response = await this.axiosInstance.get<MediaRequest>( `/request/${args.requestId}` ); request = response.data; this.cache.set('requests', cacheKey, request); } return { content: [ { type: 'text', text: JSON.stringify( args.format === 'full' ? request : this.formatCompactRequest(request), null, 2 ), }, ], }; } private async handleListRequests(args: ManageRequestsArgs) { const { filter, take, skip, sort, summary } = args; // If summary mode, fetch all results (don't use pagination) if (summary) { const params: any = { take: 1000, // Fetch large batch to get all/most results skip: 0, sort: sort || 'added', }; if (filter && filter !== 'all') { params.filter = filter; } // Don't cache summary queries as they need fresh data const response = await this.axiosInstance.get('/requests', { params }); const requests = response.data; const statusCounts: Record<string, number> = {}; requests.results.forEach((r: MediaRequest) => { const status = this.getStatusString(r.status); statusCounts[status] = (statusCounts[status] || 0) + 1; }); return { content: [ { type: 'text', text: JSON.stringify({ total: requests.results.length, statusBreakdown: statusCounts, filter: filter || 'all', }, null, 2), }, ], }; } // Regular list mode - use pagination const cacheKey = { filter, take, skip, sort }; let requests = this.cache.get<{ results: MediaRequest[]; PageInfo: any }>('requests', cacheKey); if (!requests) { const params: any = { take: take || 20, skip: skip || 0, sort: sort || 'added', }; if (filter && filter !== 'all') { params.filter = filter; } const response = await this.axiosInstance.get('/requests', { params }); requests = response.data; this.cache.set('requests', cacheKey, requests); } const formatted = requests ? requests.results.map(r => args.format === 'full' ? r : this.formatCompactRequest(r) ) : []; return { content: [ { type: 'text', text: JSON.stringify({ results: formatted, pageInfo: requests?.PageInfo, }, null, 2), }, ], }; } private async handleApproveRequests(args: ManageRequestsArgs) { const ids = args.requestIds || (args.requestId ? [args.requestId] : []); if (ids.length === 0) { throw new McpError( ErrorCode.InvalidParams, 'requestId or requestIds required for approve' ); } const results = await batchWithRetry(ids, async (id) => { await this.axiosInstance.post(`/request/${id}/approve`); return { id, status: 'APPROVED' }; }); this.cache.invalidate('requests'); const successful = results.filter(r => r.success); const failed = results.filter(r => !r.success); return { content: [ { type: 'text', text: JSON.stringify({ summary: { total: ids.length, approved: successful.length, failed: failed.length, }, results: successful.map(r => r.result), errors: failed.map(r => ({ id: r.item, error: r.error?.message })), }, null, 2), }, ], }; } private async handleDeclineRequests(args: ManageRequestsArgs) { const ids = args.requestIds || (args.requestId ? [args.requestId] : []); if (ids.length === 0) { throw new McpError( ErrorCode.InvalidParams, 'requestId or requestIds required for decline' ); } const results = await batchWithRetry(ids, async (id) => { await this.axiosInstance.post(`/request/${id}/decline`); return { id, status: 'DECLINED' }; }); this.cache.invalidate('requests'); const successful = results.filter(r => r.success); const failed = results.filter(r => !r.success); return { content: [ { type: 'text', text: JSON.stringify({ summary: { total: ids.length, declined: successful.length, failed: failed.length, }, results: successful.map(r => r.result), errors: failed.map(r => ({ id: r.item, error: r.error?.message })), }, null, 2), }, ], }; } private async handleDeleteRequests(args: ManageRequestsArgs) { const ids = args.requestIds || (args.requestId ? [args.requestId] : []); if (ids.length === 0) { throw new McpError( ErrorCode.InvalidParams, 'requestId or requestIds required for delete' ); } const results = await batchWithRetry(ids, async (id) => { await this.axiosInstance.delete(`/request/${id}`); return { id, deleted: true }; }); this.cache.invalidate('requests'); const successful = results.filter(r => r.success); const failed = results.filter(r => !r.success); return { content: [ { type: 'text', text: JSON.stringify({ summary: { total: ids.length, deleted: successful.length, failed: failed.length, }, results: successful.map(r => r.result), errors: failed.map(r => ({ id: r.item, error: r.error?.message })), }, null, 2), }, ], }; } private async handleGetDetails(args: GetDetailsArgs) { const detailsArgs = args as GetDetailsArgs; // Batch mode if (detailsArgs.items && detailsArgs.items.length > 0) { return this.handleBatchDetails(detailsArgs); } // Single mode if (!detailsArgs.mediaType || !detailsArgs.mediaId) { throw new McpError( ErrorCode.InvalidParams, 'Must provide mediaType and mediaId (or items array for batch)' ); } return this.handleSingleDetails(detailsArgs); } private async handleSingleDetails(args: GetDetailsArgs) { const { mediaType, mediaId, level, fields, language } = args; const cacheKey = { mediaType, mediaId, language: language || 'en' }; let details = this.cache.get<MediaDetails>('mediaDetails', cacheKey); if (!details) { const params = language ? { language } : {}; const response = await this.axiosInstance.get<MediaDetails>( `/${mediaType}/${mediaId}`, { params } ); details = response.data; this.cache.set('mediaDetails', cacheKey, details); } // Apply level filtering const filtered = this.filterDetailsByLevel(details, level || 'standard', fields); return { content: [ { type: 'text', text: JSON.stringify(filtered, null, 2), }, ], }; } private async handleBatchDetails(args: GetDetailsArgs) { const items = args.items!; const results = await batchWithRetry( items, async (item) => { const cacheKey = { mediaType: item.mediaType, mediaId: item.mediaId, language: args.language || 'en' }; let details = this.cache.get<MediaDetails>('mediaDetails', cacheKey); if (!details) { const response = await this.axiosInstance.get<MediaDetails>( `/${item.mediaType}/${item.mediaId}`, { params: args.language ? { language: args.language } : {} } ); details = response.data; this.cache.set('mediaDetails', cacheKey, details); } return this.filterDetailsByLevel(details, args.level || 'standard', args.fields); } ); const successful = results.filter(r => r.success); const failed = results.filter(r => !r.success); return { content: [ { type: 'text', text: JSON.stringify({ summary: { total: items.length, successful: successful.length, failed: failed.length }, results: successful.map(r => r.result), errors: failed.map(r => ({ item: r.item, error: r.error?.message || 'Unknown error' })) }, null, 2), } ] }; } private async formatSearchResponse(result: SearchResult, format: string, limit?: number) { const limitedResults = this.limitResults(result.results, limit); if (format === 'compact') { return { content: [ { type: 'text', text: JSON.stringify({ total: result.totalResults, results: limitedResults.map(item => this.formatCompactResult(item) ), }, null, 2), }, ], }; } return { content: [ { type: 'text', text: JSON.stringify({ ...result, results: limitedResults, }, null, 2), }, ], }; } private formatCompactRequest(request: MediaRequest): any { return { id: request.id, status: this.getStatusString(request.status), mediaStatus: this.getMediaStatusString(request.media.status), tmdbId: request.media.tmdbId, requestedBy: request.requestedBy.displayName || request.requestedBy.email, createdAt: request.createdAt, seasons: request.media.seasons?.map(s => ({ number: s.seasonNumber, status: this.getMediaStatusString(s.status), })), }; } private limitResults(results: any[], limit?: number): any[] { return limit ? results.slice(0, limit) : results; } private formatCompactResult(item: any, mediaInfo?: MediaInfo): CompactMediaResult { let status = 'NOT_REQUESTED'; if (mediaInfo) { if (mediaInfo.status === 5) { status = 'AVAILABLE'; } else if (mediaInfo.requests && mediaInfo.requests.length > 0) { const latestRequest = mediaInfo.requests[0]; status = this.getStatusString(latestRequest.status); } } return { id: item.id, type: item.mediaType, title: item.title || item.name || 'Unknown', year: item.releaseDate?.substring(0, 4) || item.firstAirDate?.substring(0, 4), rating: item.voteAverage, status: status, }; } private getStatusString(status: number): string { const statusMap: { [key: number]: string } = { 1: 'PENDING_APPROVAL', 2: 'APPROVED', 3: 'DECLINED', 4: 'PENDING', 5: 'AVAILABLE', 6: 'DELETED', }; return statusMap[status] || 'UNKNOWN'; } private getMediaStatusString(status: number): string { const statusMap: { [key: number]: string } = { 1: 'UNKNOWN', 2: 'PENDING', 3: 'PROCESSING', 4: 'PARTIALLY_AVAILABLE', 5: 'AVAILABLE', 6: 'DELETED', }; return statusMap[status] || 'UNKNOWN'; } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Overseerr MCP server v1.2.3 running on stdio'); } async runHttp(port: number = 8085) { const { SSEServerTransport } = await import('@modelcontextprotocol/sdk/server/sse.js'); const express = (await import('express')).default; const app = express(); app.get('/health', (_req: any, res: any) => { res.json({ status: 'ok', service: 'overseerr-mcp', version: '1.2.3' }); }); app.get('/cache/stats', (_req: any, res: any) => { res.json(this.cache.getStats()); }); app.post('/mcp', async (req: any, res: any) => { console.error('New MCP connection established'); const transport = new SSEServerTransport('/message', res); await this.server.connect(transport); req.on('close', () => { console.error('MCP connection closed'); }); }); app.listen(port, () => { console.error(`Overseerr MCP server v1.2.3 running on HTTP port ${port}`); console.error(`MCP endpoint: http://localhost:${port}/mcp`); console.error(`Health check: http://localhost:${port}/health`); console.error(`Cache stats: http://localhost:${port}/cache/stats`); }); } } const server = new OverseerrServer(); const httpMode = process.env.HTTP_MODE === 'true' || process.argv.includes('--http'); const port = process.env.PORT ? parseInt(process.env.PORT) : 8085; if (httpMode) { server.runHttp(port).catch(console.error); } else { server.run().catch(console.error); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jhomen368/overseerr-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server