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
JavaScript
#!/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 ? `**❌