UNPKG

plex-mcp

Version:

A Model Context Protocol (MCP) server that enables Claude to query and manage Plex media libraries.

1,365 lines (1,288 loc) 226 kB
#!/usr/bin/env node const { Server } = require('@modelcontextprotocol/sdk/server/index.js'); const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); const { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema } = require('@modelcontextprotocol/sdk/types.js'); const _axios = require('axios'); const { PlexOauth } = require('plex-oauth'); const fs = require('fs'); const path = require('path'); const os = require('os'); const { HttpLogger } = require('./http-logger'); const { buildEnhancedPrompt, validateTokenLimits, validateResponse, handleLLMError } = require('./llm-utils'); // Initialize HTTP logger with Plex MCP specific configuration const httpLogger = new HttpLogger({ serviceName: 'plex-mcp', debug: process.env.MCP_HTTP_DEBUG === 'true' }); // Create axios instance with logging const _axiosWithLogging = httpLogger.createAxiosInstance(); class PlexAuthManager { constructor() { this.authToken = null; this.plexOauth = null; this.currentPinId = null; this.tokenFilePath = path.join(os.homedir(), '.plex-mcp-token'); } async loadPersistedToken() { try { if (fs.existsSync(this.tokenFilePath)) { const tokenData = fs.readFileSync(this.tokenFilePath, 'utf8'); const parsed = JSON.parse(tokenData); if (parsed.token && parsed.timestamp) { // Check if token is less than 1 year old const tokenAge = Date.now() - parsed.timestamp; const oneYear = 365 * 24 * 60 * 60 * 1000; if (tokenAge < oneYear) { this.authToken = parsed.token; return parsed.token; } } } } catch (_error) { // If there's any error reading the token, just continue without it console.error('Error loading persisted token:', _error.message); } return null; } async saveToken(token) { try { const tokenData = { token: token, timestamp: Date.now() }; fs.writeFileSync(this.tokenFilePath, JSON.stringify(tokenData, null, 2), 'utf8'); } catch (_error) { console.error('Error saving token:', _error.message); } } async clearPersistedToken() { try { if (fs.existsSync(this.tokenFilePath)) { fs.unlinkSync(this.tokenFilePath); } } catch (_error) { console.error('Error clearing persisted token:', _error.message); } } async getAuthToken() { // Try static token first const staticToken = process.env.PLEX_TOKEN; if (staticToken) { return staticToken; } // Return stored OAuth token if available if (this.authToken) { return this.authToken; } // Try to load persisted token const persistedToken = await this.loadPersistedToken(); if (persistedToken) { return persistedToken; } throw new Error('No authentication token available. Please authenticate first using the authenticate_plex tool or set PLEX_TOKEN environment variable.'); } initializeOAuth() { if (this.plexOauth) { return this.plexOauth; } const clientInfo = { clientIdentifier: process.env.PLEX_CLIENT_ID || 'plex-mcp-client', product: process.env.PLEX_PRODUCT || 'PlexMCP', device: process.env.PLEX_DEVICE || 'PlexMCP', version: process.env.PLEX_VERSION || '1.0.0', forwardUrl: process.env.PLEX_REDIRECT_URL || 'https://app.plex.tv/auth#!', platform: process.env.PLEX_PLATFORM || 'Web', urlencode: true // Ensure proper URL encoding by the OAuth library }; this.plexOauth = new PlexOauth(clientInfo); return this.plexOauth; } async requestAuthUrl() { const oauth = this.initializeOAuth(); try { const [hostedUILink, pinId] = await oauth.requestHostedLoginURL(); this.currentPinId = pinId; return { loginUrl: hostedUILink, pinId }; } catch (_error) { throw new Error(`Failed to request authentication URL: ${_error.message}`); } } async checkAuthToken(pinId = null) { const oauth = this.initializeOAuth(); const pin = pinId || this.currentPinId; if (!pin) { throw new Error('No pin ID available. Please request authentication first.'); } try { const authToken = await oauth.checkForAuthToken(pin); if (authToken) { this.authToken = authToken; await this.saveToken(authToken); return authToken; } return null; } catch (_error) { throw new Error(`Failed to check authentication token: ${_error.message}`); } } async clearAuth() { this.authToken = null; this.currentPinId = null; await this.clearPersistedToken(); } } class PlexMCPServer { constructor(options = {}) { this.server = new Server( { name: 'plex-search-server', version: '0.1.0' }, { capabilities: { tools: {}, resources: {}, prompts: {} } } ); this.authManager = new PlexAuthManager(); this.connectionVerified = false; // Allow dependency injection for testing this.axios = options.axios || _axiosWithLogging; this.setupToolHandlers(); this.setupResourceHandlers(); this.setupPromptHandlers(); } async verifyConnection() { try { const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv'; const plexToken = await this.authManager.getAuthToken(); if (!plexToken) { return { verified: false, error: 'No authentication token available. Please authenticate first.', needsAuth: true }; } // Test basic connectivity with identity endpoint const identityUrl = `${plexUrl}/identity`; const response = await this.axios.get(identityUrl, { params: { 'X-Plex-Token': plexToken }, httpsAgent: this.getHttpsAgent(), timeout: 10000 }); if (response.status === 200) { this.connectionVerified = true; return { verified: true, server: response.data?.MediaContainer?.machineIdentifier || 'Unknown', url: plexUrl }; } return { verified: false, error: `Server responded with status ${response.status}`, needsAuth: false }; } catch (_error) { let errorMessage = _error.message; let needsAuth = false; if (_error.response?.status === 401) { errorMessage = 'Authentication failed. Token may be expired.'; needsAuth = true; } else if (_error.code === 'ECONNREFUSED' || _error.code === 'ENOTFOUND' || _error.code === 'ETIMEDOUT') { errorMessage = `Cannot connect to Plex server at ${process.env.PLEX_URL || 'https://app.plex.tv'}. Check network connectivity and server URL.`; } return { verified: false, error: errorMessage, needsAuth: needsAuth }; } } async ensureConnection() { if (this.connectionVerified) { return { success: true }; } const verification = await this.verifyConnection(); if (!verification.verified) { const errorContent = verification.needsAuth ? `🔑 **Authentication Required** ${verification.error} **📋 Complete Authentication Flow:** **Step 1:** Run \`authenticate_plex\` to start the authentication process **Step 2:** Open the provided URL in your browser and sign into Plex **Step 3:** Grant permission to PlexMCP when prompted **Step 4:** Return here and run \`check_auth_status\` to complete authentication **Step 5:** Try your Plex operation again **⚡ Quick Alternative:** Set the \`PLEX_TOKEN\` environment variable for persistent authentication (skips OAuth flow). **💡 Need your token?** Find it at: https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/` : `❌ **Connection Failed** ${verification.error} **🔧 Troubleshooting:** - Verify \`PLEX_URL\` environment variable is correct and accessible - Check if your Plex server is running and reachable from this network - Ensure network connectivity and firewall settings allow access - Try testing the URL directly in a browser **🌐 Current Server:** ${process.env.PLEX_URL || 'https://app.plex.tv (default)'}`; return { success: false, response: { content: [ { type: 'text', text: errorContent } ], isError: true } }; } return { success: true }; } getHttpsAgent() { const verifySSL = process.env.PLEX_VERIFY_SSL !== 'false'; const https = require('https'); const tls = require('tls'); return new https.Agent({ rejectUnauthorized: verifySSL, minVersion: 'TLSv1.2', checkServerIdentity: (hostname, cert) => { // Always trust publicly verifiable certificates for *.*.plex.direct domains if (hostname.match(/^[^.]+\.[^.]+\.plex\.direct$/)) { // Let Node.js perform standard certificate verification // This allows publicly trusted certificates to work return undefined; } // Check if certificate is for a plex.direct domain (common case) // Plex servers always use plex.direct certificates regardless of hostname const certSubject = cert.subject?.CN || ''; const certSANs = cert.subjectaltname || ''; const isPlexDirectCert = certSubject.includes('plex.direct') || certSANs.includes('plex.direct'); if (isPlexDirectCert && !verifySSL) { // Allow plex.direct certificates when SSL verification is disabled return undefined; } // For non-plex.direct domains, use default behavior if (verifySSL) { return tls.checkServerIdentity(hostname, cert); } // If SSL verification is disabled, skip all checks return undefined; } }); } // =========================== // RANDOMIZATION HELPER METHODS // =========================== /** * Detect if a query suggests randomization is needed * @param {string} query - The search query to analyze * @returns {boolean} - True if randomization patterns detected */ detectRandomizationIntent(query) { if (!query || typeof query !== 'string') { return false; } const randomPatterns = [ // Direct randomization requests /\b(some|random|variety|mix|selection|surprise)\s+(songs?|tracks?|albums?|movies?|shows?|episodes?|music)/i, /\b(surprise\s+me|shuffle|mixed\s+bag|something\s+different)/i, /\b(pick|choose|select)\s+(some|a\s+few|several)/i, // Indefinite quantities suggesting variety /\b(some|any|various|assorted|different)\s+(songs?|tracks?|albums?|movies?|shows?|artists?)/i, /\b(give\s+me\s+)?(some|a\s+few|several)\b/i, // Discovery patterns /\b(discover|explore|find\s+me)\s+(new|different)/i, /\b(what|show\s+me)\s+(some|random)/i ]; return randomPatterns.some(pattern => pattern.test(query)); } /** * Determine appropriate randomization settings based on query and content type * @param {string} query - The search query * @param {string} type - Content type (movie, show, track, etc.) * @param {Object} existingParams - Existing search parameters * @returns {Object} - Modified parameters with randomization settings */ applyRandomizationSettings(query, type = null, existingParams = {}) { if (!this.detectRandomizationIntent(query)) { return existingParams; } const params = { ...existingParams }; // Always use random sort when randomization is detected params.sort = 'random'; // Adjust default limits for variety (unless user specified a specific limit) if (!params.limit || params.limit === 10) { // Default limits switch (type) { case 'track': case 'music': params.limit = Math.min(25, params.limit || 25); // More songs for variety break; case 'movie': case 'show': params.limit = Math.min(15, params.limit || 15); // Moderate for viewing break; case 'album': case 'artist': params.limit = Math.min(12, params.limit || 12); // Good album variety break; default: params.limit = Math.min(20, params.limit || 20); // General variety } } // For randomization, prefer to start from beginning (no offset) if (params.offset && params.offset > 0) { params.offset = 0; } return params; } /** * Apply client-side randomization when server-side isn't sufficient * @param {Array} items - Array of items to randomize * @param {number} maxItems - Maximum number of items to return * @returns {Array} - Shuffled subset of items */ applyClientSideRandomization(items, maxItems = null) { if (!Array.isArray(items) || items.length === 0) { return items; } // Simple Fisher-Yates shuffle implementation const shuffled = [...items]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } // Return subset if maxItems specified if (maxItems && maxItems < shuffled.length) { return shuffled.slice(0, maxItems); } return shuffled; } /** * Create a randomized subset from multiple categories * @param {Object} categorizedItems - Object with category keys and item arrays * @param {number} totalLimit - Total number of items to return * @returns {Array} - Mixed randomized results */ createRandomizedMix(categorizedItems, totalLimit = 20) { const categories = Object.keys(categorizedItems); if (categories.length === 0) { return []; } const result = []; const itemsPerCategory = Math.floor(totalLimit / categories.length); const remainder = totalLimit % categories.length; // Get items from each category categories.forEach((category, index) => { const items = categorizedItems[category] || []; const categoryLimit = itemsPerCategory + (index < remainder ? 1 : 0); const randomItems = this.applyClientSideRandomization(items, categoryLimit); result.push(...randomItems); }); // Final shuffle of the mixed results return this.applyClientSideRandomization(result); } /** * Generate random discovery suggestions when no specific query provided * @param {Array} libraries - Available libraries * @returns {Object} - Random discovery parameters */ generateRandomDiscoveryParams(_libraries = []) { const _currentYear = new Date().getFullYear(); const decades = ['1970s', '1980s', '1990s', '2000s', '2010s', '2020s']; const randomDecade = decades[Math.floor(Math.random() * decades.length)]; const discoveryPatterns = [ { query: `music from the ${randomDecade}`, limit: 15 }, { query: 'highly rated albums', rating_min: 8, limit: 12 }, { query: 'unheard songs', never_played: true, limit: 20 }, { query: 'recent additions', sort: 'addedAt', limit: 15 }, { query: 'forgotten favorites', play_count_min: 1, last_played_before: '2023-01-01', limit: 10 } ]; const randomPattern = discoveryPatterns[Math.floor(Math.random() * discoveryPatterns.length)]; return { ...randomPattern, sort: 'random' }; } setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async() => { return { tools: [ { name: 'search_plex', description: 'Search for movies, TV shows, and other content in Plex libraries', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'The search query (movie title, show name, etc.)' }, type: { type: 'string', enum: ['movie', 'show', 'episode', 'artist', 'album', 'track'], description: 'Type of content to search for (optional)' }, limit: { type: 'number', description: 'Maximum number of results to return (default: 10)', default: 10 }, play_count_min: { type: 'number', description: 'Minimum play count for results' }, play_count_max: { type: 'number', description: 'Maximum play count for results' }, last_played_after: { type: 'string', description: 'Filter items played after this date (YYYY-MM-DD format)' }, last_played_before: { type: 'string', description: 'Filter items played before this date (YYYY-MM-DD format)' }, played_in_last_days: { type: 'number', description: 'Filter items played in the last N days' }, never_played: { type: 'boolean', description: 'Filter to only show never played items' }, content_rating: { type: 'string', description: 'Filter by content rating (G, PG, PG-13, R, etc.)' }, resolution: { type: 'string', enum: ['4k', '1080', '720', '480', 'sd'], description: 'Filter by video resolution' }, audio_format: { type: 'string', enum: ['lossless', 'lossy', 'mp3', 'flac', 'aac'], description: 'Filter by audio format (for music)' }, bpmMin: { type: 'number', description: 'Minimum BPM (beats per minute)' }, bpmMax: { type: 'number', description: 'Maximum BPM (beats per minute)' }, musical_key: { type: 'string', description: "Filter by musical key (e.g., 'C', 'G', 'Am', 'F#m')" }, dynamic_range_min: { type: 'number', description: 'Minimum dynamic range in dB' }, dynamic_range_max: { type: 'number', description: 'Maximum dynamic range in dB' }, loudness_min: { type: 'number', description: 'Minimum loudness in LUFS' }, loudness_max: { type: 'number', description: 'Maximum loudness in LUFS' }, mood: { type: 'string', enum: ['energetic', 'calm', 'aggressive', 'melancholic', 'uplifting', 'dark', 'romantic', 'mysterious'], description: 'Filter by mood/energy classification' }, acoustic_ratio_min: { type: 'number', description: 'Minimum acoustic content ratio (0-1, where 1 is fully acoustic)' }, acoustic_ratio_max: { type: 'number', description: 'Maximum acoustic content ratio (0-1, where 1 is fully acoustic)' }, file_size_min: { type: 'number', description: 'Minimum file size in MB' }, file_size_max: { type: 'number', description: 'Maximum file size in MB' }, genre: { type: 'string', description: 'Filter by genre (e.g., Action, Comedy, Rock, Jazz)' }, year: { type: 'number', description: 'Filter by release year' }, year_min: { type: 'number', description: 'Filter by minimum release year' }, year_max: { type: 'number', description: 'Filter by maximum release year' }, studio: { type: 'string', description: 'Filter by studio/label (e.g., Warner Bros, Sony Music)' }, director: { type: 'string', description: 'Filter by director name' }, writer: { type: 'string', description: 'Filter by writer name' }, actor: { type: 'string', description: 'Filter by actor/cast member name' }, rating_min: { type: 'number', description: 'Minimum rating (0-10 scale)' }, rating_max: { type: 'number', description: 'Maximum rating (0-10 scale)' }, duration_min: { type: 'number', description: 'Minimum duration in minutes' }, duration_max: { type: 'number', description: 'Maximum duration in minutes' }, added_after: { type: 'string', description: 'Filter items added to library after this date (YYYY-MM-DD format)' }, added_before: { type: 'string', description: 'Filter items added to library before this date (YYYY-MM-DD format)' } }, required: ['query'] } }, { name: 'browse_libraries', description: 'List all available Plex libraries (Movies, TV Shows, Music, etc.)', inputSchema: { type: 'object', properties: {}, required: [] } }, { name: 'browse_library', description: 'Browse content within a specific Plex library with filtering and sorting options', inputSchema: { type: 'object', properties: { library_id: { type: 'string', description: 'The library ID (key) to browse' }, sort: { type: 'string', enum: ['titleSort', 'addedAt', 'originallyAvailableAt', 'rating', 'viewCount', 'lastViewedAt'], description: 'Sort order (default: titleSort)', default: 'titleSort' }, genre: { type: 'string', description: 'Filter by genre' }, year: { type: 'number', description: 'Filter by release year' }, limit: { type: 'number', description: 'Maximum number of results to return (default: 20)', default: 20 }, offset: { type: 'number', description: 'Number of results to skip (for pagination, default: 0)', default: 0 }, play_count_min: { type: 'number', description: 'Minimum play count for results' }, play_count_max: { type: 'number', description: 'Maximum play count for results' }, last_played_after: { type: 'string', description: 'Filter items played after this date (YYYY-MM-DD format)' }, last_played_before: { type: 'string', description: 'Filter items played before this date (YYYY-MM-DD format)' }, played_in_last_days: { type: 'number', description: 'Filter items played in the last N days' }, never_played: { type: 'boolean', description: 'Filter to only show never played items' }, content_rating: { type: 'string', description: 'Filter by content rating (G, PG, PG-13, R, etc.)' }, resolution: { type: 'string', enum: ['4k', '1080', '720', '480', 'sd'], description: 'Filter by video resolution' }, audio_format: { type: 'string', enum: ['lossless', 'lossy', 'mp3', 'flac', 'aac'], description: 'Filter by audio format (for music)' }, bpmMin: { type: 'number', description: 'Minimum BPM (beats per minute)' }, bpmMax: { type: 'number', description: 'Maximum BPM (beats per minute)' }, musical_key: { type: 'string', description: "Filter by musical key (e.g., 'C', 'G', 'Am', 'F#m')" }, dynamic_range_min: { type: 'number', description: 'Minimum dynamic range in dB' }, dynamic_range_max: { type: 'number', description: 'Maximum dynamic range in dB' }, loudness_min: { type: 'number', description: 'Minimum loudness in LUFS' }, loudness_max: { type: 'number', description: 'Maximum loudness in LUFS' }, mood: { type: 'string', enum: ['energetic', 'calm', 'aggressive', 'melancholic', 'uplifting', 'dark', 'romantic', 'mysterious'], description: 'Filter by mood/energy classification' }, acoustic_ratio_min: { type: 'number', description: 'Minimum acoustic content ratio (0-1, where 1 is fully acoustic)' }, acoustic_ratio_max: { type: 'number', description: 'Maximum acoustic content ratio (0-1, where 1 is fully acoustic)' }, file_size_min: { type: 'number', description: 'Minimum file size in MB' }, file_size_max: { type: 'number', description: 'Maximum file size in MB' }, year_min: { type: 'number', description: 'Filter by minimum release year' }, year_max: { type: 'number', description: 'Filter by maximum release year' }, studio: { type: 'string', description: 'Filter by studio/label (e.g., Warner Bros, Sony Music)' }, director: { type: 'string', description: 'Filter by director name' }, writer: { type: 'string', description: 'Filter by writer name' }, actor: { type: 'string', description: 'Filter by actor/cast member name' }, rating_min: { type: 'number', description: 'Minimum rating (0-10 scale)' }, rating_max: { type: 'number', description: 'Maximum rating (0-10 scale)' }, duration_min: { type: 'number', description: 'Minimum duration in minutes' }, duration_max: { type: 'number', description: 'Maximum duration in minutes' }, added_after: { type: 'string', description: 'Filter items added to library after this date (YYYY-MM-DD format)' }, added_before: { type: 'string', description: 'Filter items added to library before this date (YYYY-MM-DD format)' } }, required: ['library_id'] } }, { name: 'get_recently_added', description: 'Get recently added content from Plex libraries', inputSchema: { type: 'object', properties: { library_id: { type: 'string', description: 'Specific library ID to get recent content from (optional, defaults to all libraries)' }, limit: { type: 'number', description: 'Maximum number of results to return (default: 15)', default: 15 }, chunk_size: { type: 'number', description: 'Number of items to return per chunk for pagination (optional)' }, chunk_offset: { type: 'number', description: 'Offset for pagination, number of items to skip (optional)' } }, required: [] } }, { name: 'get_watch_history', description: 'Get playback history for the Plex server', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Maximum number of history items to return (default: 20)', default: 20 }, account_id: { type: 'string', description: 'Filter by specific account/user ID (optional)' }, chunk_size: { type: 'number', description: 'Number of items to return per chunk for pagination (optional)' }, chunk_offset: { type: 'number', description: 'Offset for pagination, number of items to skip (optional)' } }, required: [] } }, { name: 'get_on_deck', description: "Get 'On Deck' items (continue watching) for users", inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Maximum number of items to return (default: 15)', default: 15 } }, required: [] } }, { name: 'list_playlists', description: 'List all playlists on the Plex server', inputSchema: { type: 'object', properties: { playlist_type: { type: 'string', enum: ['audio', 'video', 'photo'], description: 'Filter by playlist type (optional)' } }, required: [] } }, { name: 'browse_playlist', description: 'Browse and view the contents of a specific playlist with full track metadata', inputSchema: { type: 'object', properties: { playlist_id: { type: 'string', description: 'The ID of the playlist to browse' }, limit: { type: 'number', description: 'Maximum number of items to return (default: 50)', default: 50 } }, required: ['playlist_id'] } }, { name: 'create_playlist', description: 'Create a new regular playlist on the Plex server. Requires an initial item (item_key parameter) to be created successfully. Smart playlists are not supported due to their complex filter requirements.', inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'The title/name for the new playlist' }, type: { type: 'string', enum: ['audio', 'video', 'photo'], description: 'The type of playlist to create' }, item_key: { type: 'string', description: 'The key of an initial item to add to the playlist. Required for playlist creation. Get item keys from search_plex or browse_library results.' } }, required: ['title', 'type', 'item_key'] } }, // TEMPORARILY DISABLED - Smart playlist filtering is broken // { // name: "create_smart_playlist", // description: "Create a new smart playlist with filter criteria. Smart playlists automatically populate based on specified conditions.", // inputSchema: { // type: "object", // properties: { // title: { // type: "string", // description: "The title/name for the new smart playlist", // }, // type: { // type: "string", // enum: ["audio", "video", "photo"], // description: "The type of content for the smart playlist", // }, // library_id: { // type: "string", // description: "The library ID to create the smart playlist in. Use browse_libraries to get library IDs.", // }, // filters: { // type: "array", // description: "Array of filter conditions for the smart playlist", // items: { // type: "object", // properties: { // field: { // type: "string", // enum: ["artist.title", "album.title", "track.title", "genre.tag", "year", "rating", "addedAt", "lastViewedAt", "viewCount"], // description: "The field to filter on" // }, // operator: { // type: "string", // enum: ["is", "isnot", "contains", "doesnotcontain", "beginswith", "endswith", "gt", "gte", "lt", "lte"], // description: "The comparison operator" // }, // value: { // type: "string", // description: "The value to compare against" // } // }, // required: ["field", "operator", "value"] // }, // minItems: 1 // }, // sort: { // type: "string", // enum: ["artist.titleSort", "album.titleSort", "track.titleSort", "addedAt", "year", "rating", "lastViewedAt", "random"], // description: "How to sort the smart playlist results (optional)", // default: "artist.titleSort" // }, // limit: { // type: "integer", // description: "Maximum number of items in the smart playlist (optional)", // minimum: 1, // maximum: 1000, // default: 100 // } // }, // required: ["title", "type", "library_id", "filters"], // }, // }, { name: 'add_to_playlist', description: 'Add items to an existing playlist', inputSchema: { type: 'object', properties: { playlist_id: { type: 'string', description: 'The playlist ID (ratingKey) to add items to' }, item_keys: { type: 'array', items: { type: 'string' }, description: 'Array of item keys (ratingKey) to add to the playlist' } }, required: ['playlist_id', 'item_keys'] } }, // DISABLED: remove_from_playlist - PROBLEMATIC due to Plex API limitations // This operation removes ALL instances of matching items, not just one // Uncomment only after implementing safer removal patterns /* { name: "remove_from_playlist", description: "Remove items from an existing playlist", inputSchema: { type: "object", properties: { playlist_id: { type: "string", description: "The playlist ID (ratingKey) to remove items from", }, item_keys: { type: "array", items: { type: "string" }, description: "Array of item keys (ratingKey) to remove from the playlist", }, }, required: ["playlist_id", "item_keys"], }, }, */ { name: 'delete_playlist', description: 'Delete an existing playlist', inputSchema: { type: 'object', properties: { playlist_id: { type: 'string', description: 'The playlist ID (ratingKey) to delete' } }, required: ['playlist_id'] } }, { name: 'get_watched_status', description: 'Check watch status and progress for specific content items', inputSchema: { type: 'object', properties: { item_keys: { type: 'array', items: { type: 'string' }, description: 'Array of item keys (ratingKey) to check watch status for' }, account_id: { type: 'string', description: 'Specific account/user ID to check status for (optional)' } }, required: ['item_keys'] } }, { name: 'get_collections', description: 'List all collections available on the Plex server', inputSchema: { type: 'object', properties: { library_id: { type: 'string', description: 'Filter collections by specific library ID (optional)' } }, required: [] } }, { name: 'browse_collection', description: 'Browse content within a specific collection', inputSchema: { type: 'object', properties: { collection_id: { type: 'string', description: 'The collection ID (ratingKey) to browse' }, sort: { type: 'string', enum: ['titleSort', 'addedAt', 'originallyAvailableAt', 'rating', 'viewCount', 'lastViewedAt'], description: 'Sort order (default: titleSort)', default: 'titleSort' }, limit: { type: 'number', description: 'Maximum number of results to return (default: 20)', default: 20 }, offset: { type: 'number', description: 'Number of results to skip (for pagination, default: 0)', default: 0 } }, required: ['collection_id'] } }, { name: 'get_media_info', description: 'Get detailed technical information about media files (codecs, bitrates, file sizes, etc.)', inputSchema: { type: 'object', properties: { item_key: { type: 'string', description: 'The item key (ratingKey) to get media information for' } }, required: ['item_key'] } }, { name: 'get_library_stats', description: 'Get comprehensive statistics about Plex libraries (storage usage, file counts, content breakdown, etc.)', inputSchema: { type: 'object', properties: { library_id: { type: 'string', description: 'Specific library ID to get stats for (optional, defaults to all libraries)' }, include_details: { type: 'boolean', description: 'Include detailed breakdowns by file type, resolution, codec, etc. (default: false)', default: false } }, required: [] } }, { name: 'get_listening_stats', description: 'Get detailed listening statistics and music recommendations based on play history and patterns', inputSchema: { type: 'object', properties: { account_id: { type: 'string', description: 'Specific account/user ID to analyze (optional, defaults to all users)' }, time_period: { type: 'string', enum: ['week', 'month', 'quarter', 'year', 'all'], description: 'Time period to analyze (default: month)', default: 'month' }, include_recommendations: { type: 'boolean', description: 'Include music recommendations based on listening patterns (default: true)', default: true }, music_library_id: { type: 'string', description: 'Specific music library ID to analyze (optional, auto-detects music libraries)' } }, required: [] } }, { name: 'discover_music', description: 'Natural language music discovery with smart recommendations based on your preferences and library', inputSchema: { type: 'object', properties: { query: { type: 'string', description: "Natural language query (e.g., 'songs from the 90s', 'rock bands I haven't heard', 'something like Modest Mouse')" }, context: { type: 'string', description: 'Additional context for the search (optional)' }, limit: { type: 'number', description: 'Maximum number of results to return (default: 10)', default: 10 } }, required: ['query'] } }, { name: 'authenticate_plex', description: 'Initiate Plex OAuth authentication flow to get user login URL', inputSchema: { type: 'object', properties: {}, required: [] } }, { name: 'check_auth_status', description: 'Check if Plex authentication is complete and retrieve the auth token', inputSchema: { type: 'object', properties: { pin_id: { type: 'string', description: 'Optional pin ID to check. If not provided, uses the last requested pin.' } }, required: [] } }, { name: 'clear_auth', description: 'Clear stored authentication credentials', inputSchema: { type: 'object', properties: {}, required: [] } }, { name: 'validate_llm_response', description: 'Validate LLM response format and content against expected schemas for different prompt types', inputSchema: { type: 'object', properties: { response: { type: 'object', description: 'The LLM response object to validate' }, prompt_type: { type: 'string', enum: ['playlist_description', 'content_recommendation', 'smart_playlist_rules', 'media_analysis'], description: 'The type of prompt that generated this response' } }, required: ['response', 'prompt_type'] } } ] }; }); this.server.setRequestHandler(CallToolRequestSchema, async(request) => { switch (request.params.name) { case 'search_plex': return await this.handlePlexSearch(request.params.arguments); case 'browse_libraries': return await this.handleBrowseLibraries(request.params.arguments); case 'browse_library': return await this.handleBrowseLibrary(request.params.arguments); case 'get_recently_added': return await this.handleRecentlyAdded(request.params.arguments); case 'get_watch_history': return await this.handleWatchHistory(request.params.arguments); case 'get_on_deck': return await this.handleOnDeck(request.params.arguments); case 'list_playlists': return await this.handleListPlaylists(request.params.arguments); case 'browse_playlist': return await this.handleBrowsePlaylist(request.params.arguments); case 'create_playlist': return await this.handleCreatePlaylist(request.params.arguments); // TEMPORARILY DISABLED - Smart playlist filtering is broken // case "create_smart_playlist": // return await this.handleCreateSmartPlaylist(request.params.arguments); case 'add_to_playlist': return await this.handleAddToPlaylist(request.params.arguments); // DISABLED: remove_from_playlist - PROBLEMATIC operation // case "remove_from_playlist": // return await this.handleRemoveFromPlaylist(request.params.arguments); case 'delete_playlist': return await this.handleDeletePlaylist(request.params.arguments); case 'get_watched_status': return await this.handleWatchedStatus(request.params.arguments); case 'get_collections': return await this.handleGetCollections(request.params.arguments); case 'browse_collection': return await this.handleBrowseCollection(request.params.arguments); case 'get_media_info': return await this.handleGetMediaInfo(request.params.arguments); case 'get_library_stats': return await this.handleGetLibraryStats(request.params.arguments); case 'get_listening_stats': return await this.handleGetListeningStats(request.params.arguments); case 'discover_music': return await this.handleDiscoverMusic(request.params.arguments); case 'authenticate_plex': return await this.handleAuthenticatePlex(request.params.arguments); case 'check_auth_status': return await this.handleCheckAuthStatus(request.params.arguments); case 'clear_auth': return await this.handleClearAuth(request.params.arguments); case 'validate_llm_response': return await this.handleValidateLLMResponse(request.params.arguments); default: throw new Error(`Unknown tool: ${request.params.name}`); } }); } async handleValidateLLMResponse(args) { const { response, prompt_type: promptType } = args; try { const validation = validateResponse(response, promptType); return { content: [ { type: 'text', text: `🔍 **LLM Response Validation Results** **Prompt Type:** ${promptType} **Valid:** ${validation.valid ? '✅ Yes' : '❌ No'} ${validation.errors.length > 0 ? `**❌