UNPKG

ai-flashmob-mcp

Version:

MCP server for AI-powered flashcard generation

347 lines (290 loc) • 10.4 kB
/** * Flashcard Manager Tool * * Implements MCP tools for managing existing flashcards and decks * using the AI Flashmob backend API with HMAC authentication. */ import { signRequest, validateCredentials, getApiBaseUrl, logRequest } from '../utils/auth.js'; export class FlashcardManager { constructor() { this.apiBaseUrl = getApiBaseUrl(); this.publicUserId = process.env.PUBLIC_USER_ID; this.secretKey = process.env.SECRET_KEY; // Validate credentials on initialization validateCredentials(this.publicUserId, this.secretKey); } /** * Gets all decks for the authenticated user * * @param {Object} args - Arguments from MCP tool call (empty for this endpoint) * @returns {Object} MCP tool response */ async getDecks(args = {}) { try { console.log('šŸ“‚ Fetching user decks...'); // Create minimal request body for signing (GET requests still need signing) const requestBody = {}; // Sign the request const headers = signRequest(this.publicUserId, this.secretKey, requestBody); // Remove Content-Type for GET request delete headers['Content-Type']; // Log request for debugging logRequest('GET', `${this.apiBaseUrl}/api/v1/external/decks`, {}); // Make API request const response = await fetch(`${this.apiBaseUrl}/api/v1/external/decks`, { method: 'GET', headers }); // Handle response const result = await this.handleApiResponse(response); // Format success response for MCP return { content: [ { type: 'text', text: this.formatDecksResponse(result) } ] }; } catch (error) { return this.formatErrorResponse(error, 'fetching decks'); } } /** * Gets all flashcards from a specific deck * * @param {Object} args - Arguments from MCP tool call * @param {number} args.deckId - The deck ID to fetch cards from * @returns {Object} MCP tool response */ async getDeckCards(args) { try { const { deckId } = args; // Validate input if (!deckId || !Number.isInteger(deckId) || deckId < 1) { throw new Error('deckId is required and must be a positive integer'); } console.log(`šŸ“š Fetching flashcards for deck ${deckId}...`); // Create minimal request body for signing (GET requests still need signing) const requestBody = {}; // Sign the request const headers = signRequest(this.publicUserId, this.secretKey, requestBody); // Remove Content-Type for GET request delete headers['Content-Type']; // Log request for debugging logRequest('GET', `${this.apiBaseUrl}/api/v1/external/decks/${deckId}/cards`, {}); // Make API request const response = await fetch(`${this.apiBaseUrl}/api/v1/external/decks/${deckId}/cards`, { method: 'GET', headers }); // Handle response const result = await this.handleApiResponse(response); // Format success response for MCP return { content: [ { type: 'text', text: this.formatDeckCardsResponse(result) } ] }; } catch (error) { return this.formatErrorResponse(error, 'fetching deck flashcards'); } } /** * Handles API response and extracts data or throws appropriate errors * * @param {Response} response - Fetch response object * @returns {Object} Parsed response data * @throws {Error} If response indicates an error */ async handleApiResponse(response) { let responseData; try { responseData = await response.json(); } catch (parseError) { throw new Error(`Failed to parse API response: ${parseError.message}`); } // Handle error responses if (!response.ok) { switch (response.status) { case 401: throw new Error(`Authentication failed: ${responseData.message || 'Invalid credentials'}`); case 429: throw new Error(`Rate limit exceeded: ${responseData.message || 'Too many requests. Please wait before trying again.'}`); case 400: throw new Error(`Invalid request: ${responseData.message || 'Bad request parameters'}`); case 404: if (responseData.message && responseData.message.includes('Deck')) { throw new Error(`Deck not found: The specified deck does not exist or does not belong to your account`); } throw new Error(`Not found: ${responseData.message || 'Endpoint not found'}`); case 503: throw new Error(`Service unavailable: ${responseData.message || 'Service is temporarily unavailable'}`); default: throw new Error(`API error (${response.status}): ${responseData.message || response.statusText}`); } } // Validate successful response structure if (!responseData.success) { throw new Error(`API request failed: ${responseData.message || 'Unknown error'}`); } return responseData; } /** * Formats decks response for MCP display * * @param {Object} result - API response data * @returns {string} Formatted response text */ formatDecksResponse(result) { const { decks, totalDecks } = result; if (!decks || decks.length === 0) { return `šŸ“‚ **Your Decks**\n\nNo decks found. You can create decks when generating flashcards or through the web interface.\n\nšŸ’” **Tip:** Decks help organize your flashcards for better study sessions!`; } let output = `šŸ“‚ **Your Decks** (${totalDecks} total)\n\n`; // Display each deck decks.forEach((deck, index) => { const colorEmoji = this.getColorEmoji(deck.colorTheme); output += `${colorEmoji} **${deck.name}**\n`; output += ` • ID: ${deck.id}\n`; output += ` • Flashcards: ${deck.flashcardCount}\n`; output += ` • Created: ${new Date(deck.createdAt).toLocaleDateString()}\n\n`; }); output += `✨ Use \`get_deck_cards\` with a deck ID to view flashcards in that deck.`; return output; } /** * Formats deck cards response for MCP display * * @param {Object} result - API response data * @returns {string} Formatted response text */ formatDeckCardsResponse(result) { const { deck, flashcards, totalFlashcards } = result; const colorEmoji = this.getColorEmoji(deck.colorTheme); let output = `${colorEmoji} **${deck.name}** - Flashcards (${totalFlashcards} total)\n\n`; if (!flashcards || flashcards.length === 0) { output += `No flashcards found in this deck.\n\nšŸ’” **Tip:** Generate new flashcards and assign them to this deck, or move existing flashcards here through the web interface.`; return output; } // Group flashcards by sibling groups (if any) const groups = {}; flashcards.forEach(card => { const groupId = card.siblingGroupId || 'individual'; if (!groups[groupId]) { groups[groupId] = []; } groups[groupId].push(card); }); let cardNumber = 1; Object.entries(groups).forEach(([groupId, cards]) => { if (groupId !== 'individual' && cards.length > 1) { output += `šŸ“¦ **Group ${groupId}** (${cards.length} cards)\n`; } cards.forEach(card => { const statusEmoji = this.getStatusEmoji(card.status); const bookmarkEmoji = card.isBookmarked ? 'šŸ”– ' : ''; output += `${statusEmoji} ${bookmarkEmoji}**Card ${cardNumber}**\n`; output += `**Q:** ${card.front}\n`; output += `**A:** ${card.back}\n`; output += `*Created: ${new Date(card.createdAt).toLocaleDateString()}*\n\n`; cardNumber++; }); }); output += `\nšŸ“Š **Study Progress:**\n`; const statusCounts = this.getStatusCounts(flashcards); Object.entries(statusCounts).forEach(([status, count]) => { if (count > 0) { const emoji = this.getStatusEmoji(status); output += `${emoji} ${status}: ${count}\n`; } }); return output; } /** * Formats an error response for MCP display * * @param {Error} error - The error that occurred * @param {string} operation - The operation that failed * @returns {Object} MCP error response */ formatErrorResponse(error, operation) { const errorMessage = error.message || 'Unknown error occurred'; // Log error for debugging console.error(`Flashcard management error (${operation}): ${errorMessage}`); return { content: [ { type: 'text', text: `āŒ **Failed to ${operation}**\n\n${errorMessage}\n\nšŸ”§ **Troubleshooting Tips:**\n• Check your API credentials (PUBLIC_USER_ID and SECRET_KEY)\n• Verify your internet connection\n• Ensure the resource exists and belongs to your account\n• Try again in a few moments if rate limited` } ], isError: true }; } /** * Gets color emoji for deck color theme * * @param {string} colorTheme - The color theme * @returns {string} Corresponding emoji */ getColorEmoji(colorTheme) { const colorMap = { 'blue': 'šŸ”µ', 'green': '🟢', 'red': 'šŸ”“', 'yellow': '🟔', 'purple': '🟣', 'orange': '🟠', 'pink': '🩷', 'cyan': 'šŸ”·', 'gray': '⚫' }; return colorMap[colorTheme] || 'šŸ“‚'; } /** * Gets status emoji for flashcard status * * @param {string} status - The flashcard status * @returns {string} Corresponding emoji */ getStatusEmoji(status) { const statusMap = { 'New': 'šŸ†•', 'Learning': 'šŸ“–', 'Review': 'šŸ”„', 'Mastered': 'āœ…', 'Difficult': 'āš ļø' }; return statusMap[status] || 'šŸ“š'; } /** * Counts flashcards by status * * @param {Array} flashcards - Array of flashcards * @returns {Object} Status counts */ getStatusCounts(flashcards) { const counts = { 'New': 0, 'Learning': 0, 'Review': 0, 'Mastered': 0, 'Difficult': 0 }; flashcards.forEach(card => { if (counts.hasOwnProperty(card.status)) { counts[card.status]++; } }); return counts; } }