plex-mcp 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.js +496 -115
  2. package/llm-utils.js +393 -0
  3. package/package.json +1 -1
package/index.js CHANGED
@@ -16,6 +16,12 @@ const fs = require('fs');
16
16
  const path = require('path');
17
17
  const os = require('os');
18
18
  const { HttpLogger } = require('./http-logger');
19
+ const {
20
+ buildEnhancedPrompt,
21
+ validateTokenLimits,
22
+ validateResponse,
23
+ handleLLMError
24
+ } = require('./llm-utils');
19
25
 
20
26
  // Initialize HTTP logger with Plex MCP specific configuration
21
27
  const httpLogger = new HttpLogger({
@@ -110,7 +116,8 @@ class PlexAuthManager {
110
116
  device: process.env.PLEX_DEVICE || 'PlexMCP',
111
117
  version: process.env.PLEX_VERSION || '1.0.0',
112
118
  forwardUrl: process.env.PLEX_REDIRECT_URL || 'https://app.plex.tv/auth#!',
113
- platform: process.env.PLEX_PLATFORM || 'Web'
119
+ platform: process.env.PLEX_PLATFORM || 'Web',
120
+ urlencode: true // Ensure proper URL encoding by the OAuth library
114
121
  };
115
122
 
116
123
  this.plexOauth = new PlexOauth(clientInfo);
@@ -175,7 +182,7 @@ class PlexMCPServer {
175
182
  this.authManager = new PlexAuthManager();
176
183
  this.connectionVerified = false;
177
184
  // Allow dependency injection for testing
178
- this.axios = options.axios || this.axios;
185
+ this.axios = options.axios || _axiosWithLogging;
179
186
  this.setupToolHandlers();
180
187
  this.setupResourceHandlers();
181
188
  this.setupPromptHandlers();
@@ -248,15 +255,28 @@ class PlexMCPServer {
248
255
 
249
256
  ${verification.error}
250
257
 
251
- Please run \`check_auth_status\` and follow the authentication process.` :
258
+ **📋 Complete Authentication Flow:**
259
+
260
+ **Step 1:** Run \`authenticate_plex\` to start the authentication process
261
+ **Step 2:** Open the provided URL in your browser and sign into Plex
262
+ **Step 3:** Grant permission to PlexMCP when prompted
263
+ **Step 4:** Return here and run \`check_auth_status\` to complete authentication
264
+ **Step 5:** Try your Plex operation again
265
+
266
+ **⚡ Quick Alternative:** Set the \`PLEX_TOKEN\` environment variable for persistent authentication (skips OAuth flow).
267
+
268
+ **💡 Need your token?** Find it at: https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/` :
252
269
  `❌ **Connection Failed**
253
270
 
254
271
  ${verification.error}
255
272
 
256
- **Troubleshooting:**
257
- - Verify PLEX_URL is correct and accessible
258
- - Check if server is running and reachable
259
- - Ensure network connectivity`;
273
+ **🔧 Troubleshooting:**
274
+ - Verify \`PLEX_URL\` environment variable is correct and accessible
275
+ - Check if your Plex server is running and reachable from this network
276
+ - Ensure network connectivity and firewall settings allow access
277
+ - Try testing the URL directly in a browser
278
+
279
+ **🌐 Current Server:** ${process.env.PLEX_URL || 'https://app.plex.tv (default)'}`;
260
280
 
261
281
  return {
262
282
  success: false,
@@ -278,6 +298,7 @@ ${verification.error}
278
298
  getHttpsAgent() {
279
299
  const verifySSL = process.env.PLEX_VERIFY_SSL !== 'false';
280
300
  const https = require('https');
301
+ const tls = require('tls');
281
302
 
282
303
  return new https.Agent({
283
304
  rejectUnauthorized: verifySSL,
@@ -290,9 +311,20 @@ ${verification.error}
290
311
  return undefined;
291
312
  }
292
313
 
314
+ // Check if certificate is for a plex.direct domain (common case)
315
+ // Plex servers always use plex.direct certificates regardless of hostname
316
+ const certSubject = cert.subject?.CN || '';
317
+ const certSANs = cert.subjectaltname || '';
318
+ const isPlexDirectCert = certSubject.includes('plex.direct') || certSANs.includes('plex.direct');
319
+
320
+ if (isPlexDirectCert && !verifySSL) {
321
+ // Allow plex.direct certificates when SSL verification is disabled
322
+ return undefined;
323
+ }
324
+
293
325
  // For non-plex.direct domains, use default behavior
294
326
  if (verifySSL) {
295
- return https.globalAgent.options.checkServerIdentity(hostname, cert);
327
+ return tls.checkServerIdentity(hostname, cert);
296
328
  }
297
329
 
298
330
  // If SSL verification is disabled, skip all checks
@@ -1233,6 +1265,25 @@ ${verification.error}
1233
1265
  properties: {},
1234
1266
  required: []
1235
1267
  }
1268
+ },
1269
+ {
1270
+ name: 'validate_llm_response',
1271
+ description: 'Validate LLM response format and content against expected schemas for different prompt types',
1272
+ inputSchema: {
1273
+ type: 'object',
1274
+ properties: {
1275
+ response: {
1276
+ type: 'object',
1277
+ description: 'The LLM response object to validate'
1278
+ },
1279
+ prompt_type: {
1280
+ type: 'string',
1281
+ enum: ['playlist_description', 'content_recommendation', 'smart_playlist_rules', 'media_analysis'],
1282
+ description: 'The type of prompt that generated this response'
1283
+ }
1284
+ },
1285
+ required: ['response', 'prompt_type']
1286
+ }
1236
1287
  }
1237
1288
  ]
1238
1289
  };
@@ -1288,45 +1339,191 @@ ${verification.error}
1288
1339
  return await this.handleCheckAuthStatus(request.params.arguments);
1289
1340
  case 'clear_auth':
1290
1341
  return await this.handleClearAuth(request.params.arguments);
1342
+ case 'validate_llm_response':
1343
+ return await this.handleValidateLLMResponse(request.params.arguments);
1291
1344
  default:
1292
1345
  throw new Error(`Unknown tool: ${request.params.name}`);
1293
1346
  }
1294
1347
  });
1295
1348
  }
1296
1349
 
1350
+ async handleValidateLLMResponse(args) {
1351
+ const { response, prompt_type: promptType } = args;
1352
+
1353
+ try {
1354
+ const validation = validateResponse(response, promptType);
1355
+
1356
+ return {
1357
+ content: [
1358
+ {
1359
+ type: 'text',
1360
+ text: `🔍 **LLM Response Validation Results**
1361
+
1362
+ **Prompt Type:** ${promptType}
1363
+ **Valid:** ${validation.valid ? '✅ Yes' : '❌ No'}
1364
+
1365
+ ${validation.errors.length > 0 ? `**❌ Errors:**\n${validation.errors.map(e => `- ${e}`).join('\n')}\n` : ''}
1366
+ ${validation.warnings.length > 0 ? `**⚠️ Warnings:**\n${validation.warnings.map(w => `- ${w}`).join('\n')}\n` : ''}
1367
+
1368
+ ${validation.valid ?
1369
+ '✅ **Response is valid and ready to use!**' :
1370
+ '❌ **Response needs to be corrected before use.**'
1371
+ }
1372
+
1373
+ **💡 Tips for Better Responses:**
1374
+ - Ensure all required fields are present
1375
+ - Check data types match expected formats
1376
+ - Verify content meets length requirements
1377
+ - Follow the specified JSON structure
1378
+
1379
+ **🔧 Response Structure for ${promptType}:**
1380
+ ${this.getResponseStructureHelp(promptType)}`
1381
+ }
1382
+ ]
1383
+ };
1384
+ } catch (error) {
1385
+ return {
1386
+ content: [
1387
+ {
1388
+ type: 'text',
1389
+ text: `❌ **Validation Error**
1390
+
1391
+ ${error.message}
1392
+
1393
+ **Available prompt types for validation:**
1394
+ - \`playlist_description\` - Validates playlist description responses
1395
+ - \`content_recommendation\` - Validates content recommendation lists
1396
+ - \`smart_playlist_rules\` - Validates smart playlist criteria
1397
+ - \`media_analysis\` - Validates media library analysis results
1398
+
1399
+ **Usage Example:**
1400
+ \`\`\`json
1401
+ {
1402
+ "response": {"description": "Your playlist description here"},
1403
+ "prompt_type": "playlist_description"
1404
+ }
1405
+ \`\`\``
1406
+ }
1407
+ ],
1408
+ isError: true
1409
+ };
1410
+ }
1411
+ }
1412
+
1413
+ getResponseStructureHelp(promptType) {
1414
+ switch (promptType) {
1415
+ case 'playlist_description':
1416
+ return `\`\`\`json
1417
+ {
1418
+ "description": "2-3 sentence engaging description (50-500 chars)"
1419
+ }
1420
+ \`\`\``;
1421
+
1422
+ case 'content_recommendation':
1423
+ return `\`\`\`json
1424
+ {
1425
+ "recommendations": [
1426
+ {
1427
+ "title": "Content Title",
1428
+ "year": 2023,
1429
+ "reason": "Why it's similar (20+ chars)",
1430
+ "appeal": "What makes it appealing",
1431
+ "features": ["Notable feature 1", "Feature 2"]
1432
+ }
1433
+ ]
1434
+ }
1435
+ \`\`\``;
1436
+
1437
+ case 'smart_playlist_rules':
1438
+ return `\`\`\`json
1439
+ {
1440
+ "criteria": {
1441
+ "filters": [{"type": "genre", "value": "rock"}],
1442
+ "sorting": {"field": "rating", "order": "desc"},
1443
+ "advanced_criteria": ["Recent additions", "High rated"],
1444
+ "tips": ["Keep playlist fresh", "Review periodically"]
1445
+ }
1446
+ }
1447
+ \`\`\``;
1448
+
1449
+ case 'media_analysis':
1450
+ return `\`\`\`json
1451
+ {
1452
+ "insights": {
1453
+ "patterns": ["Pattern 1", "Pattern 2"],
1454
+ "recommendations": ["Suggestion 1", "Suggestion 2"],
1455
+ "statistics": {"total_items": 100, "genres": 15},
1456
+ "trends": ["Trend 1", "Trend 2"]
1457
+ }
1458
+ }
1459
+ \`\`\``;
1460
+
1461
+ default:
1462
+ return 'Unknown prompt type - no structure help available';
1463
+ }
1464
+ }
1465
+
1297
1466
  async handleAuthenticatePlex(_args) {
1298
1467
  try {
1299
1468
  const { loginUrl, pinId } = await this.authManager.requestAuthUrl();
1300
1469
 
1470
+ // Properly encode the entire URL to handle all special characters
1471
+ const encodedUrl = encodeURI(loginUrl);
1472
+
1301
1473
  return {
1302
1474
  content: [
1303
1475
  {
1304
1476
  type: 'text',
1305
- text: `Plex Authentication Started
1477
+ text: `🔑 Plex Authentication Started
1306
1478
 
1307
- **Next Steps:**
1308
- 1. Open this URL in your browser:
1479
+ **📋 Step-by-Step Instructions:**
1480
+
1481
+ 1. **Copy and open this authentication URL in your browser:**
1309
1482
 
1310
1483
  \`\`\`
1311
- ${loginUrl.replace(/\[/g, '%5B').replace(/\]/g, '%5D').replace(/!/g, '%21')}
1484
+ ${encodedUrl}
1312
1485
  \`\`\`
1313
1486
 
1314
- 2. Sign into your Plex account when prompted
1315
- 3. **IMPORTANT:** After signing in, you MUST return here and run the \`check_auth_status\` tool to complete the authentication process
1316
- 4. Only after running \`check_auth_status\` will your token be saved and ready for use
1487
+ 2. **Sign into your Plex account** when prompted
1488
+ 3. **Grant permission** to PlexMCP when asked
1489
+ 4. **Return here and run \`check_auth_status\`** to complete authentication
1490
+ 5. **Verify success** - you'll see a confirmation message when complete
1491
+
1492
+ **🔢 Pin ID:** \`${pinId}\`
1493
+ **⏰ Session Timeout:** ~15 minutes
1317
1494
 
1318
- **Pin ID:** ${pinId}
1495
+ ⚠️ **CRITICAL:** Authentication is not complete until you run \`check_auth_status\` and receive confirmation!
1319
1496
 
1320
- ⚠️ **Don't forget:** The authentication is not complete until you return and run \`check_auth_status\`!`
1497
+ **💡 Alternative:** For persistent authentication, set the \`PLEX_TOKEN\` environment variable instead.`
1321
1498
  }
1322
1499
  ]
1323
1500
  };
1324
1501
  } catch (_error) {
1502
+ let errorMessage = `Failed to start authentication: ${_error.message}`;
1503
+
1504
+ // Provide specific troubleshooting based on error type
1505
+ if (_error.message.includes('network') || _error.message.includes('connect')) {
1506
+ errorMessage += `
1507
+
1508
+ **🔧 Troubleshooting:**
1509
+ - Check your internet connection
1510
+ - Verify Plex services are accessible
1511
+ - Try again in a few moments`;
1512
+ } else if (_error.message.includes('client')) {
1513
+ errorMessage += `
1514
+
1515
+ **🔧 Troubleshooting:**
1516
+ - Check PLEX_CLIENT_ID environment variable
1517
+ - Verify client configuration is correct`;
1518
+ }
1519
+
1325
1520
  return {
1326
1521
  content: [
1327
1522
  {
1328
1523
  type: 'text',
1329
- text: `❌ Authentication Error: ${_error.message}`
1524
+ text: `❌ **Authentication Error**
1525
+
1526
+ ${errorMessage}`
1330
1527
  }
1331
1528
  ],
1332
1529
  isError: true
@@ -1342,42 +1539,119 @@ ${loginUrl.replace(/\[/g, '%5B').replace(/\]/g, '%5D').replace(/!/g, '%21')}
1342
1539
 
1343
1540
  if (authToken) {
1344
1541
  this.connectionVerified = false; // Reset so next tool use will verify the new token
1345
- return {
1346
- content: [
1347
- {
1348
- type: 'text',
1349
- text: `✅ Plex Authentication Successful!
1350
1542
 
1351
- Your authentication token has been stored and will be used for all Plex API requests. You can now use all Plex tools without needing the PLEX_TOKEN environment variable.
1543
+ // Verify the token works by testing connection
1544
+ try {
1545
+ const verification = await this.verifyConnection();
1546
+ if (verification.verified) {
1547
+ return {
1548
+ content: [
1549
+ {
1550
+ type: 'text',
1551
+ text: `✅ **Plex Authentication Successful!**
1552
+
1553
+ 🎉 **You're all set!** Your authentication token has been saved and verified.
1352
1554
 
1353
- **Note:** This token is stored only for this session. For persistent authentication, consider setting the PLEX_TOKEN environment variable.`
1354
- }
1355
- ]
1356
- };
1555
+ **✓ Token Status:** Active and working
1556
+ **✓ Connection:** Verified to Plex server
1557
+ **✓ Ready to use:** All Plex tools are now available
1558
+
1559
+ **🔄 Session Storage:** Token saved for this session
1560
+ **💾 Persistent Option:** Set \`PLEX_TOKEN\` environment variable for permanent authentication
1561
+
1562
+ **Next Steps:** You can now use any Plex tool like \`search_plex\`, \`browse_libraries\`, etc.`
1563
+ }
1564
+ ]
1565
+ };
1566
+ } else {
1567
+ return {
1568
+ content: [
1569
+ {
1570
+ type: 'text',
1571
+ text: `⚠️ **Authentication Completed with Issues**
1572
+
1573
+ Your token was retrieved successfully, but there's a connection issue:
1574
+
1575
+ **Issue:** ${verification.error}
1576
+
1577
+ **Next Steps:**
1578
+ 1. Check your Plex server settings
1579
+ 2. Verify PLEX_URL is correctly configured
1580
+ 3. Try using Plex tools - they may still work`
1581
+ }
1582
+ ]
1583
+ };
1584
+ }
1585
+ } catch (verifyError) {
1586
+ // Token was saved but connection verification failed - still a success
1587
+ return {
1588
+ content: [
1589
+ {
1590
+ type: 'text',
1591
+ text: `✅ **Authentication Token Saved**
1592
+
1593
+ Your Plex authentication token has been successfully retrieved and stored.
1594
+
1595
+ **⚠️ Connection Test:** Could not verify server connection (${verifyError.message})
1596
+
1597
+ **This is likely fine** - try using Plex tools to confirm everything works.`
1598
+ }
1599
+ ]
1600
+ };
1601
+ }
1357
1602
  } else {
1603
+ const pinInfo = pin_id ? ` (Pin: ${pin_id})` : '';
1358
1604
  return {
1359
1605
  content: [
1360
1606
  {
1361
1607
  type: 'text',
1362
- text: `⏳ Authentication Pending
1608
+ text: `⏳ **Authentication Pending**${pinInfo}
1609
+
1610
+ The authentication process is not yet complete. Please:
1363
1611
 
1364
- The user has not yet completed the authentication process. Please:
1612
+ **📋 Steps to Complete:**
1613
+ 1. ✅ Open the authentication URL from \`authenticate_plex\`
1614
+ 2. ✅ Sign into your Plex account in the browser
1615
+ 3. ⏸️ **Grant permission** to PlexMCP when prompted
1616
+ 4. 🔄 Return here and run \`check_auth_status\` again
1365
1617
 
1366
- 1. Make sure you've visited the login URL from the authenticate_plex tool
1367
- 2. Sign into your Plex account in the browser
1368
- 3. Try checking the auth status again in a few moments
1618
+ **⏰ Timeout:** Authentication links expire after ~15 minutes
1619
+ **🔄 Still waiting?** Try running \`check_auth_status\` again in 30 seconds
1369
1620
 
1370
- You can run check_auth_status again to check if authentication is complete.`
1621
+ **💡 Tip:** If the browser window closed, you may need to restart with \`authenticate_plex\``
1371
1622
  }
1372
1623
  ]
1373
1624
  };
1374
1625
  }
1375
1626
  } catch (_error) {
1627
+ let errorMessage = _error.message;
1628
+ let troubleshooting = '';
1629
+
1630
+ // Provide specific guidance based on error type
1631
+ if (_error.message.includes('No pin ID')) {
1632
+ troubleshooting = `
1633
+
1634
+ **🔧 Solution:** Run \`authenticate_plex\` first to get a new authentication URL`;
1635
+ } else if (_error.message.includes('expired') || _error.message.includes('timeout')) {
1636
+ troubleshooting = `
1637
+
1638
+ **🔧 Solution:** The authentication session expired. Run \`authenticate_plex\` to start over`;
1639
+ } else if (_error.message.includes('network') || _error.message.includes('connect')) {
1640
+ troubleshooting = `
1641
+
1642
+ **🔧 Troubleshooting:**
1643
+ - Check your internet connection
1644
+ - Verify Plex services are accessible
1645
+ - Try again in a few moments`;
1646
+ }
1647
+
1376
1648
  return {
1377
1649
  content: [
1378
1650
  {
1379
1651
  type: 'text',
1380
- text: `❌ Auth Status Check Error: ${_error.message}`
1652
+ text: `❌ **Authentication Check Failed**
1653
+
1654
+ ${errorMessage}${troubleshooting}`
1381
1655
  }
1382
1656
  ],
1383
1657
  isError: true
@@ -1394,21 +1668,52 @@ You can run check_auth_status again to check if authentication is complete.`
1394
1668
  content: [
1395
1669
  {
1396
1670
  type: 'text',
1397
- text: `🔄 Authentication Cleared
1671
+ text: `🔄 **Authentication Cleared Successfully**
1672
+
1673
+ All stored authentication credentials have been removed from this session.
1674
+
1675
+ **🧹 What was cleared:**
1676
+ - Session authentication token
1677
+ - Stored OAuth credentials
1678
+ - Cached connection verification
1679
+
1680
+ **🔐 To authenticate again, choose one option:**
1681
+
1682
+ **Option 1 - Interactive OAuth (Recommended):**
1683
+ 1. Run \`authenticate_plex\` to get a new login URL
1684
+ 2. Follow the authentication flow in your browser
1685
+ 3. Run \`check_auth_status\` to complete the process
1398
1686
 
1399
- All stored authentication credentials have been cleared. To use Plex tools again, you'll need to either:
1687
+ **Option 2 - Environment Variable:**
1688
+ 1. Set \`PLEX_TOKEN\` environment variable with your token
1689
+ 2. Restart the MCP server to use the new token
1400
1690
 
1401
- 1. Set the PLEX_TOKEN environment variable, or
1402
- 2. Run the authenticate_plex tool to sign in again`
1691
+ **💡 Note:** Environment variable tokens persist across sessions and don't require re-authentication.`
1403
1692
  }
1404
1693
  ]
1405
1694
  };
1406
1695
  } catch (_error) {
1696
+ let errorMessage = _error.message;
1697
+ let troubleshooting = '';
1698
+
1699
+ if (_error.message.includes('file') || _error.message.includes('permission')) {
1700
+ troubleshooting = `
1701
+
1702
+ **🔧 Troubleshooting:**
1703
+ - Check file system permissions
1704
+ - Verify home directory is accessible
1705
+ - The session token was still cleared from memory`;
1706
+ }
1707
+
1407
1708
  return {
1408
1709
  content: [
1409
1710
  {
1410
1711
  type: 'text',
1411
- text: `❌ Clear Auth Error: ${_error.message}`
1712
+ text: `❌ **Clear Authentication Error**
1713
+
1714
+ ${errorMessage}${troubleshooting}
1715
+
1716
+ **ℹ️ Note:** In-memory authentication may still be cleared even if file operations failed.`
1412
1717
  }
1413
1718
  ],
1414
1719
  isError: true
@@ -5876,20 +6181,25 @@ The smart playlist has been created and is now available in your Plex library!`
5876
6181
  this.server.setRequestHandler(GetPromptRequestSchema, async(request) => {
5877
6182
  const { name, arguments: args } = request.params;
5878
6183
 
5879
- switch (name) {
5880
- case 'playlist_description':
5881
- const playlistName = args?.playlist_name || 'Your Playlist';
5882
- const genre = args?.genre || '';
5883
- const contentInfo = args?.content_info || '';
6184
+ try {
6185
+ // Build enhanced prompt with validation and context
6186
+ const promptInfo = buildEnhancedPrompt(name, args, {
6187
+ userLibrarySize: args?.library_size,
6188
+ preferredGenres: args?.preferred_genres,
6189
+ recentActivity: args?.recent_activity
6190
+ });
5884
6191
 
5885
- return {
5886
- description: `Generate a creative and engaging description for the playlist "${playlistName}"`,
5887
- messages: [
5888
- {
5889
- role: 'user',
5890
- content: {
5891
- type: 'text',
5892
- text: `Create a creative, engaging description for a playlist called "${playlistName}"${genre ? ` in the ${genre} genre` : ''}${contentInfo ? `.\n\nPlaylist content information:\n${contentInfo}` : ''}.
6192
+ switch (name) {
6193
+ case 'playlist_description':
6194
+ const playlistName = args?.playlist_name || 'Your Playlist';
6195
+ const genre = args?.genre || '';
6196
+ const contentInfo = args?.content_info || '';
6197
+
6198
+ // Check token limits for content
6199
+ const tokenCheck = validateTokenLimits(contentInfo, 'claude-3-sonnet');
6200
+ const finalContentInfo = tokenCheck.withinLimits ? contentInfo : tokenCheck.content;
6201
+
6202
+ const promptText = `Create a creative, engaging description for a playlist called "${playlistName}"${genre ? ` in the ${genre} genre` : ''}${finalContentInfo ? `.\n\nPlaylist content information:\n${finalContentInfo}` : ''}${promptInfo.context}.
5893
6203
 
5894
6204
  The description should:
5895
6205
  - Be 2-3 sentences long
@@ -5898,25 +6208,41 @@ The description should:
5898
6208
  - Use vivid, descriptive language
5899
6209
  ${genre ? `- Reflect the ${genre} genre characteristics` : ''}
5900
6210
 
5901
- Make it sound compelling and professional, like something you'd see on a streaming service.`
6211
+ Make it sound compelling and professional, like something you'd see on a streaming service.
6212
+
6213
+ **Response Format:** Return a JSON object with a "description" field containing the playlist description.`;
6214
+
6215
+ return {
6216
+ description: `Generate a creative and engaging description for the playlist "${playlistName}"`,
6217
+ messages: [
6218
+ {
6219
+ role: 'user',
6220
+ content: {
6221
+ type: 'text',
6222
+ text: promptText
6223
+ }
5902
6224
  }
6225
+ ],
6226
+ // Include LLM-specific metadata
6227
+ _metadata: {
6228
+ promptType: name,
6229
+ modelParameters: promptInfo.modelParameters,
6230
+ tokenEstimate: promptInfo.tokenValidation.estimatedTokens,
6231
+ withinLimits: promptInfo.tokenValidation.withinLimits,
6232
+ validationSchema: 'playlist_description'
5903
6233
  }
5904
- ]
5905
- };
6234
+ };
5906
6235
 
5907
- case 'content_recommendation':
5908
- const likedContent = args?.liked_content || '';
5909
- const contentType = args?.content_type || 'content';
5910
- const moodOrGenre = args?.mood_or_genre || '';
6236
+ case 'content_recommendation':
6237
+ const likedContent = args?.liked_content || '';
6238
+ const contentType = args?.content_type || 'content';
6239
+ const moodOrGenre = args?.mood_or_genre || '';
5911
6240
 
5912
- return {
5913
- description: 'Generate personalized content recommendations',
5914
- messages: [
5915
- {
5916
- role: 'user',
5917
- content: {
5918
- type: 'text',
5919
- text: `Based on these titles I've enjoyed: ${likedContent}
6241
+ // Validate and truncate liked content if needed
6242
+ const likedContentCheck = validateTokenLimits(likedContent, 'claude-3-sonnet', 2048);
6243
+ const finalLikedContent = likedContentCheck.withinLimits ? likedContent : likedContentCheck.content;
6244
+
6245
+ const recommendationPrompt = `Based on these titles I've enjoyed: ${finalLikedContent}${promptInfo.context}
5920
6246
 
5921
6247
  Please recommend similar ${contentType}${moodOrGenre ? ` in the ${moodOrGenre} style` : ''}.
5922
6248
 
@@ -5926,24 +6252,42 @@ For each recommendation, provide:
5926
6252
  - What makes it appealing
5927
6253
  - Any notable cast, creators, or standout features
5928
6254
 
5929
- Focus on finding hidden gems and quality content that matches my taste preferences shown in the titles I mentioned.`
6255
+ Focus on finding hidden gems and quality content that matches my taste preferences shown in the titles I mentioned.
6256
+
6257
+ **Response Format:** Return a JSON object with a "recommendations" array, where each recommendation has fields: title, year, reason, appeal, and features.`;
6258
+
6259
+ return {
6260
+ description: 'Generate personalized content recommendations',
6261
+ messages: [
6262
+ {
6263
+ role: 'user',
6264
+ content: {
6265
+ type: 'text',
6266
+ text: recommendationPrompt
6267
+ }
5930
6268
  }
6269
+ ],
6270
+ _metadata: {
6271
+ promptType: name,
6272
+ modelParameters: promptInfo.modelParameters,
6273
+ tokenEstimate: promptInfo.tokenValidation.estimatedTokens,
6274
+ withinLimits: likedContentCheck.withinLimits,
6275
+ validationSchema: 'content_recommendation'
5931
6276
  }
5932
- ]
5933
- };
6277
+ };
5934
6278
 
5935
- case 'smart_playlist_rules':
5936
- const intent = args?.intent || '';
5937
- const libraryType = args?.library_type || 'media';
6279
+ case 'smart_playlist_rules':
6280
+ const intent = args?.intent || '';
6281
+ const libraryType = args?.library_type || 'media';
5938
6282
 
5939
- return {
5940
- description: 'Generate smart playlist criteria and filtering rules',
5941
- messages: [
5942
- {
5943
- role: 'user',
5944
- content: {
5945
- type: 'text',
5946
- text: `I want to create a smart playlist for: ${intent}
6283
+ return {
6284
+ description: 'Generate smart playlist criteria and filtering rules',
6285
+ messages: [
6286
+ {
6287
+ role: 'user',
6288
+ content: {
6289
+ type: 'text',
6290
+ text: `I want to create a smart playlist for: ${intent}
5947
6291
 
5948
6292
  Library type: ${libraryType}
5949
6293
 
@@ -5954,23 +6298,23 @@ Please suggest specific filtering criteria and rules that would work well for th
5954
6298
  - Tips for keeping the playlist fresh and relevant
5955
6299
 
5956
6300
  Provide practical, actionable suggestions that would create a great automated playlist.`
6301
+ }
5957
6302
  }
5958
- }
5959
- ]
5960
- };
6303
+ ]
6304
+ };
5961
6305
 
5962
- case 'media_analysis':
5963
- const contentData = args?.content_data || '';
5964
- const analysisType = args?.analysis_type || 'general analysis';
6306
+ case 'media_analysis':
6307
+ const contentData = args?.content_data || '';
6308
+ const analysisType = args?.analysis_type || 'general analysis';
5965
6309
 
5966
- return {
5967
- description: 'Analyze media library content and patterns',
5968
- messages: [
5969
- {
5970
- role: 'user',
5971
- content: {
5972
- type: 'text',
5973
- text: `Please analyze this media library data for ${analysisType}:
6310
+ return {
6311
+ description: 'Analyze media library content and patterns',
6312
+ messages: [
6313
+ {
6314
+ role: 'user',
6315
+ content: {
6316
+ type: 'text',
6317
+ text: `Please analyze this media library data for ${analysisType}:
5974
6318
 
5975
6319
  ${contentData}
5976
6320
 
@@ -5982,23 +6326,23 @@ Provide insights about:
5982
6326
  - Any interesting observations about the content
5983
6327
 
5984
6328
  Format the analysis in a clear, organized way that's easy to understand and actionable.`
6329
+ }
5985
6330
  }
5986
- }
5987
- ]
5988
- };
6331
+ ]
6332
+ };
5989
6333
 
5990
- case 'server_troubleshooting':
5991
- const errorDetails = args?.error_details || '';
5992
- const serverInfo = args?.server_info || '';
6334
+ case 'server_troubleshooting':
6335
+ const errorDetails = args?.error_details || '';
6336
+ const serverInfo = args?.server_info || '';
5993
6337
 
5994
- return {
5995
- description: 'Help diagnose and resolve Plex server issues',
5996
- messages: [
5997
- {
5998
- role: 'user',
5999
- content: {
6000
- type: 'text',
6001
- text: `I'm having an issue with my Plex server. Here are the details:
6338
+ return {
6339
+ description: 'Help diagnose and resolve Plex server issues',
6340
+ messages: [
6341
+ {
6342
+ role: 'user',
6343
+ content: {
6344
+ type: 'text',
6345
+ text: `I'm having an issue with my Plex server. Here are the details:
6002
6346
 
6003
6347
  Error/Issue: ${errorDetails}
6004
6348
 
@@ -6011,13 +6355,50 @@ Please help me:
6011
6355
  4. Identify if this is a common issue with known solutions
6012
6356
 
6013
6357
  Focus on practical, actionable solutions that don't require advanced technical expertise.`
6358
+ }
6014
6359
  }
6015
- }
6016
- ]
6017
- };
6360
+ ]
6361
+ };
6018
6362
 
6019
- default:
6020
- throw new Error(`Unknown prompt: ${name}`);
6363
+ default:
6364
+ throw new Error(`Unknown prompt: ${name}`);
6365
+ }
6366
+ } catch (error) {
6367
+ // Handle prompt-specific errors with enhanced error information
6368
+ const errorInfo = handleLLMError(error, name);
6369
+
6370
+ return {
6371
+ description: `Error generating prompt for ${name}`,
6372
+ messages: [
6373
+ {
6374
+ role: 'system',
6375
+ content: {
6376
+ type: 'text',
6377
+ text: `❌ **Prompt Generation Error**
6378
+
6379
+ ${errorInfo.message}
6380
+
6381
+ **Error Type:** ${errorInfo.error}
6382
+ **Retryable:** ${errorInfo.retryable ? 'Yes' : 'No'}
6383
+ ${errorInfo.retryAfter ? `**Retry After:** ${errorInfo.retryAfter} seconds` : ''}
6384
+
6385
+ **Troubleshooting:**
6386
+ - Check that all required arguments are provided
6387
+ - Verify argument formats match expected types
6388
+ - Ensure content length is within reasonable limits
6389
+ - Try simplifying the request if it's too complex
6390
+
6391
+ **Available Prompts:** playlist_description, content_recommendation, smart_playlist_rules, media_analysis, server_troubleshooting`
6392
+ }
6393
+ }
6394
+ ],
6395
+ _metadata: {
6396
+ error: true,
6397
+ errorType: errorInfo.error,
6398
+ retryable: errorInfo.retryable,
6399
+ originalError: error.message
6400
+ }
6401
+ };
6021
6402
  }
6022
6403
  });
6023
6404
  }
package/llm-utils.js ADDED
@@ -0,0 +1,393 @@
1
+ /**
2
+ * LLM Utilities and Best Practices Framework
3
+ * Provides tools for proper LLM interaction patterns including
4
+ * response validation, token management, error handling, and prompt optimization
5
+ */
6
+
7
+ /**
8
+ * Estimate token count for text content
9
+ * Uses a simple approximation: 1 token ≈ 4 characters
10
+ * More accurate tokenization would require model-specific tokenizers
11
+ */
12
+ function estimateTokenCount(text) {
13
+ if (typeof text !== 'string') { return 0; }
14
+ // Rough approximation: 1 token per 4 characters
15
+ // This varies by model and language, but provides a baseline
16
+ return Math.ceil(text.length / 4);
17
+ }
18
+
19
+ /**
20
+ * Token limit configurations for different model types
21
+ */
22
+ const TOKEN_LIMITS = {
23
+ 'gpt-3.5-turbo': { input: 4096, output: 4096 },
24
+ 'gpt-4': { input: 8192, output: 8192 },
25
+ 'gpt-4-32k': { input: 32768, output: 32768 },
26
+ 'claude-3-sonnet': { input: 200000, output: 4096 },
27
+ 'claude-3-opus': { input: 200000, output: 4096 },
28
+ 'claude-3-haiku': { input: 200000, output: 4096 },
29
+ default: { input: 4096, output: 1024 }
30
+ };
31
+
32
+ /**
33
+ * Validate and potentially truncate content to fit within token limits
34
+ */
35
+ function validateTokenLimits(content, modelType = 'default', reserveOutputTokens = 1024) {
36
+ const limits = TOKEN_LIMITS[modelType] || TOKEN_LIMITS.default;
37
+ const maxInputTokens = limits.input - reserveOutputTokens;
38
+
39
+ const estimatedTokens = estimateTokenCount(content);
40
+
41
+ if (estimatedTokens <= maxInputTokens) {
42
+ return {
43
+ content,
44
+ withinLimits: true,
45
+ estimatedTokens,
46
+ maxInputTokens
47
+ };
48
+ }
49
+
50
+ // Calculate truncation point (leave some buffer)
51
+ const targetChars = Math.floor(maxInputTokens * 4 * 0.9); // 90% of limit for safety
52
+ const truncatedContent = content.substring(0, targetChars) + '...\n[Content truncated to fit token limits]';
53
+
54
+ return {
55
+ content: truncatedContent,
56
+ withinLimits: false,
57
+ estimatedTokens: estimateTokenCount(truncatedContent),
58
+ originalTokens: estimatedTokens,
59
+ maxInputTokens,
60
+ truncated: true
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Response validation schemas for different prompt types
66
+ */
67
+ const RESPONSE_SCHEMAS = {
68
+ playlist_description: {
69
+ type: 'object',
70
+ required: ['description'],
71
+ properties: {
72
+ description: {
73
+ type: 'string',
74
+ minLength: 50,
75
+ maxLength: 500
76
+ }
77
+ }
78
+ },
79
+
80
+ content_recommendation: {
81
+ type: 'object',
82
+ required: ['recommendations'],
83
+ properties: {
84
+ recommendations: {
85
+ type: 'array',
86
+ minItems: 1,
87
+ maxItems: 10,
88
+ items: {
89
+ type: 'object',
90
+ required: ['title', 'reason'],
91
+ properties: {
92
+ title: { type: 'string', minLength: 1 },
93
+ year: { type: 'number', minimum: 1900, maximum: 2100 },
94
+ reason: { type: 'string', minLength: 20 },
95
+ appeal: { type: 'string' },
96
+ features: { type: 'array', items: { type: 'string' } }
97
+ }
98
+ }
99
+ }
100
+ }
101
+ },
102
+
103
+ smart_playlist_rules: {
104
+ type: 'object',
105
+ required: ['criteria'],
106
+ properties: {
107
+ criteria: {
108
+ type: 'object',
109
+ properties: {
110
+ filters: { type: 'array', items: { type: 'object' } },
111
+ sorting: { type: 'object' },
112
+ advanced_criteria: { type: 'array' },
113
+ tips: { type: 'array', items: { type: 'string' } }
114
+ }
115
+ }
116
+ }
117
+ },
118
+
119
+ media_analysis: {
120
+ type: 'object',
121
+ required: ['insights'],
122
+ properties: {
123
+ insights: {
124
+ type: 'object',
125
+ properties: {
126
+ patterns: { type: 'array' },
127
+ recommendations: { type: 'array' },
128
+ statistics: { type: 'object' },
129
+ trends: { type: 'array' }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ };
135
+
136
+ /**
137
+ * Validate LLM response against expected schema
138
+ */
139
+ function validateResponse(response, promptType) {
140
+ const schema = RESPONSE_SCHEMAS[promptType];
141
+ if (!schema) {
142
+ return {
143
+ valid: true,
144
+ warnings: [`No validation schema defined for prompt type: ${promptType}`]
145
+ };
146
+ }
147
+
148
+ const errors = [];
149
+ const warnings = [];
150
+
151
+ try {
152
+ // Basic structure validation
153
+ if (typeof response !== 'object' || response === null) {
154
+ errors.push('Response must be a valid object');
155
+ return { valid: false, errors, warnings };
156
+ }
157
+
158
+ // Check required fields
159
+ if (schema.required) {
160
+ for (const field of schema.required) {
161
+ if (!(field in response)) {
162
+ errors.push(`Missing required field: ${field}`);
163
+ }
164
+ }
165
+ }
166
+
167
+ // Validate specific fields based on prompt type
168
+ switch (promptType) {
169
+ case 'playlist_description':
170
+ if (response.description && response.description.length < 50) {
171
+ warnings.push('Description is quite short, consider expanding');
172
+ }
173
+ break;
174
+
175
+ case 'content_recommendation':
176
+ if (response.recommendations && response.recommendations.length === 0) {
177
+ errors.push('At least one recommendation is required');
178
+ }
179
+ break;
180
+
181
+ case 'smart_playlist_rules':
182
+ if (response.criteria && !response.criteria.filters) {
183
+ warnings.push('No specific filters provided in criteria');
184
+ }
185
+ break;
186
+ }
187
+ } catch (error) {
188
+ errors.push(`Validation error: ${error.message}`);
189
+ }
190
+
191
+ return {
192
+ valid: errors.length === 0,
193
+ errors,
194
+ warnings
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Retry logic with exponential backoff for external API calls
200
+ */
201
+ async function retryWithBackoff(fn, maxRetries = 3, baseDelay = 1000) {
202
+ let lastError;
203
+
204
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
205
+ try {
206
+ return await fn();
207
+ } catch (error) {
208
+ lastError = error;
209
+
210
+ // Don't retry on certain error types
211
+ if (error.code === 'INVALID_REQUEST' || error.status === 400) {
212
+ throw error;
213
+ }
214
+
215
+ // If this is the last attempt, throw the error
216
+ if (attempt === maxRetries - 1) {
217
+ throw error;
218
+ }
219
+
220
+ // Calculate delay with exponential backoff and jitter
221
+ const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
222
+ await new Promise(resolve => setTimeout(resolve, delay));
223
+ }
224
+ }
225
+
226
+ throw lastError;
227
+ }
228
+
229
+ /**
230
+ * Model parameter configurations for different use cases
231
+ */
232
+ const MODEL_PARAMETERS = {
233
+ creative: {
234
+ temperature: 0.8,
235
+ top_p: 0.9,
236
+ frequency_penalty: 0.1,
237
+ presence_penalty: 0.1
238
+ },
239
+ analytical: {
240
+ temperature: 0.3,
241
+ top_p: 0.8,
242
+ frequency_penalty: 0.0,
243
+ presence_penalty: 0.0
244
+ },
245
+ balanced: {
246
+ temperature: 0.6,
247
+ top_p: 0.85,
248
+ frequency_penalty: 0.05,
249
+ presence_penalty: 0.05
250
+ },
251
+ conservative: {
252
+ temperature: 0.1,
253
+ top_p: 0.7,
254
+ frequency_penalty: 0.0,
255
+ presence_penalty: 0.0
256
+ }
257
+ };
258
+
259
+ /**
260
+ * Get recommended model parameters for a prompt type
261
+ */
262
+ function getModelParameters(promptType, style = 'balanced') {
263
+ const baseParams = MODEL_PARAMETERS[style] || MODEL_PARAMETERS.balanced;
264
+
265
+ // Adjust parameters based on prompt type
266
+ switch (promptType) {
267
+ case 'playlist_description':
268
+ return { ...baseParams, temperature: 0.8 }; // More creative
269
+
270
+ case 'content_recommendation':
271
+ return { ...baseParams, temperature: 0.6 }; // Balanced
272
+
273
+ case 'smart_playlist_rules':
274
+ return { ...baseParams, temperature: 0.3 }; // More analytical
275
+
276
+ case 'media_analysis':
277
+ return { ...baseParams, temperature: 0.2 }; // Very analytical
278
+
279
+ default:
280
+ return baseParams;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Enhanced prompt builder with context inclusion and validation
286
+ */
287
+ function buildEnhancedPrompt(promptType, args, context = {}) {
288
+ const errors = [];
289
+
290
+ // Validate required arguments based on prompt type
291
+ switch (promptType) {
292
+ case 'playlist_description':
293
+ if (!args.playlist_name) { errors.push('playlist_name is required'); }
294
+ break;
295
+ case 'content_recommendation':
296
+ if (!args.liked_content) { errors.push('liked_content is required'); }
297
+ break;
298
+ case 'smart_playlist_rules':
299
+ if (!args.intent) { errors.push('intent is required'); }
300
+ break;
301
+ case 'media_analysis':
302
+ if (!args.content_data) { errors.push('content_data is required'); }
303
+ break;
304
+ }
305
+
306
+ if (errors.length > 0) {
307
+ throw new Error(`Prompt validation failed: ${errors.join(', ')}`);
308
+ }
309
+
310
+ // Include relevant context
311
+ const contextInfo = [];
312
+ if (context.userLibrarySize) {
313
+ contextInfo.push(`User has ${context.userLibrarySize} items in their library`);
314
+ }
315
+ if (context.preferredGenres) {
316
+ contextInfo.push(`Preferred genres: ${context.preferredGenres.join(', ')}`);
317
+ }
318
+ if (context.recentActivity) {
319
+ contextInfo.push(`Recent activity: ${context.recentActivity}`);
320
+ }
321
+
322
+ const contextString = contextInfo.length > 0 ?
323
+ `\n\nContext: ${contextInfo.join('. ')}\n` :
324
+ '';
325
+
326
+ return {
327
+ promptType,
328
+ args,
329
+ context: contextString,
330
+ modelParameters: getModelParameters(promptType),
331
+ tokenValidation: validateTokenLimits(JSON.stringify(args) + contextString)
332
+ };
333
+ }
334
+
335
+ /**
336
+ * Error handling patterns for LLM interactions
337
+ */
338
+ function handleLLMError(error, promptType) {
339
+ const baseMessage = `Error in ${promptType} prompt`;
340
+
341
+ if (error.code === 'rate_limit_exceeded') {
342
+ return {
343
+ error: 'Rate limit exceeded',
344
+ message: `${baseMessage}: Too many requests. Please try again later.`,
345
+ retryable: true,
346
+ retryAfter: error.retry_after || 60
347
+ };
348
+ }
349
+
350
+ if (error.code === 'insufficient_quota') {
351
+ return {
352
+ error: 'Quota exceeded',
353
+ message: `${baseMessage}: API quota exceeded. Check your billing.`,
354
+ retryable: false
355
+ };
356
+ }
357
+
358
+ if (error.code === 'model_overloaded') {
359
+ return {
360
+ error: 'Model overloaded',
361
+ message: `${baseMessage}: Model is overloaded. Try again shortly.`,
362
+ retryable: true,
363
+ retryAfter: 30
364
+ };
365
+ }
366
+
367
+ if (error.code === 'invalid_request_error') {
368
+ return {
369
+ error: 'Invalid request',
370
+ message: `${baseMessage}: Request format is invalid. Check prompt structure.`,
371
+ retryable: false
372
+ };
373
+ }
374
+
375
+ return {
376
+ error: 'Unknown error',
377
+ message: `${baseMessage}: ${error.message}`,
378
+ retryable: true
379
+ };
380
+ }
381
+
382
+ module.exports = {
383
+ estimateTokenCount,
384
+ validateTokenLimits,
385
+ validateResponse,
386
+ retryWithBackoff,
387
+ getModelParameters,
388
+ buildEnhancedPrompt,
389
+ handleLLMError,
390
+ TOKEN_LIMITS,
391
+ MODEL_PARAMETERS,
392
+ RESPONSE_SCHEMAS
393
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plex-mcp",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "A Model Context Protocol (MCP) server that enables Claude to query and manage Plex media libraries.",
5
5
  "main": "index.js",
6
6
  "bin": {