UNPKG

ai-flashmob-mcp

Version:

MCP server for AI-powered flashcard generation

530 lines (437 loc) 17.1 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\nTIP: Decks help organize your flashcards for better study sessions!`; } let output = `**Your Decks** (${totalDecks} total)\n\n`; // Display each deck decks.forEach((deck, index) => { output += `**${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; let output = `**${deck.name}** - Flashcards (${totalFlashcards} total)\n\n`; if (!flashcards || flashcards.length === 0) { output += `No flashcards found in this deck.\n\nTIP: 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 bookmarkMark = card.isBookmarked ? '[BOOKMARKED] ' : ''; output += `${bookmarkMark}**Card ${cardNumber}** [${card.status}]\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) { output += `${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: `ERROR: 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 }; } /** * 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; } /** * Creates a new deck (non-AI) * * @param {Object} args - Arguments from MCP tool call * @param {string} args.name - Deck name (required, 1-100 chars) * @param {string} [args.description] - Deck description (optional, max 500 chars) * @param {string} [args.colorTheme] - Color theme (optional: blue, green, red, yellow, purple, orange) * @returns {Object} MCP tool response */ async createDeck(args) { try { const { name, description, colorTheme = 'blue' } = args; // Validate input if (!name || typeof name !== 'string') { throw new Error('name is required and must be a string'); } if (name.trim().length < 1 || name.trim().length > 100) { throw new Error('name must be between 1 and 100 characters'); } if (description && typeof description !== 'string') { throw new Error('description must be a string'); } if (description && description.length > 500) { throw new Error('description must be 500 characters or less'); } console.log(`Creating deck: "${name}"...`); // Prepare request body const requestBody = { name: name.trim(), ...(description && { description: description.trim() }), ...(colorTheme && { colorTheme }) }; // Sign the request const headers = signRequest(this.publicUserId, this.secretKey, requestBody); // Log request for debugging logRequest('POST', `${this.apiBaseUrl}/api/v1/external/decks`, requestBody); // Make API request const response = await fetch(`${this.apiBaseUrl}/api/v1/external/decks`, { method: 'POST', headers, body: JSON.stringify(requestBody) }); // Handle response const result = await this.handleApiResponse(response); // Format success response for MCP return { content: [ { type: 'text', text: this.formatCreateDeckResponse(result) } ] }; } catch (error) { return this.formatErrorResponse(error, 'create deck'); } } /** * Bulk creates flashcards (non-AI) from user-provided content * * @param {Object} args - Arguments from MCP tool call * @param {Array} args.flashcards - Array of flashcard objects (required, 1-3000 items) * @param {string} args.flashcards[].frontText - Question/prompt (1-500 chars) * @param {string} args.flashcards[].backText - Answer/content (1-10000 chars) * @param {number[]} [args.deckIds] - Optional array of deck IDs to assign cards to * @returns {Object} MCP tool response */ async createCardsBulk(args) { try { const { flashcards, deckIds = [] } = args; // Validate input if (!flashcards || !Array.isArray(flashcards)) { throw new Error('flashcards is required and must be an array'); } if (flashcards.length < 1 || flashcards.length > 3000) { throw new Error('flashcards must contain between 1 and 3000 items'); } // Validate each flashcard flashcards.forEach((card, index) => { if (!card.frontText || typeof card.frontText !== 'string') { throw new Error(`flashcards[${index}].frontText is required and must be a string`); } if (card.frontText.trim().length < 1 || card.frontText.trim().length > 500) { throw new Error(`flashcards[${index}].frontText must be between 1 and 500 characters`); } if (!card.backText || typeof card.backText !== 'string') { throw new Error(`flashcards[${index}].backText is required and must be a string`); } if (card.backText.trim().length < 1 || card.backText.trim().length > 10000) { throw new Error(`flashcards[${index}].backText must be between 1 and 10000 characters`); } }); if (deckIds && !Array.isArray(deckIds)) { throw new Error('deckIds must be an array'); } if (deckIds && deckIds.some(id => !Number.isInteger(id) || id < 1)) { throw new Error('all deckIds must be positive integers'); } console.log(`Creating ${flashcards.length} flashcards${deckIds.length > 0 ? ` in ${deckIds.length} deck(s)` : ''}...`); // Prepare request body const requestBody = { flashcards: flashcards.map(card => ({ frontText: card.frontText.trim(), backText: card.backText.trim() })), ...(deckIds.length > 0 && { deckIds }) }; // Sign the request const headers = signRequest(this.publicUserId, this.secretKey, requestBody); // Log request for debugging (without full card content) logRequest('POST', `${this.apiBaseUrl}/api/v1/external/cards/bulk`, { flashcards: `[${flashcards.length} cards]`, deckIds }); // Make API request const response = await fetch(`${this.apiBaseUrl}/api/v1/external/cards/bulk`, { method: 'POST', headers, body: JSON.stringify(requestBody) }); // Handle response const result = await this.handleApiResponse(response); // Format success response for MCP return { content: [ { type: 'text', text: this.formatCreateCardsResponse(result) } ] }; } catch (error) { return this.formatErrorResponse(error, 'bulk create flashcards'); } } /** * Formats create deck response for MCP display * * @param {Object} result - API response data * @returns {string} Formatted response text */ formatCreateDeckResponse(result) { const { deck } = result; let output = `**Deck Created Successfully!**\n\n`; output += `**${deck.name}**\n`; output += `• ID: ${deck.id}\n`; if (deck.description) { output += `• Description: ${deck.description}\n`; } output += `• Created: ${new Date(deck.createdAt).toLocaleDateString()}\n\n`; output += `**Next Steps:**\n`; output += `• Use \`create_cards_bulk\` with deckIds: [${deck.id}] to add flashcards\n`; output += `• Or use \`get_decks\` to see all your decks\n`; return output; } /** * Formats create cards response for MCP display * * @param {Object} result - API response data * @returns {string} Formatted response text */ formatCreateCardsResponse(result) { const { flashcards, flashcardIds, siblingGroupId, decksAssigned, deckAssociationsCreated } = result; let output = `**${flashcards.length} Flashcard${flashcards.length === 1 ? '' : 's'} Created Successfully!**\n\n`; // Show first few cards as preview const previewCount = Math.min(3, flashcards.length); output += `**Preview (showing ${previewCount} of ${flashcards.length}):**\n\n`; flashcards.slice(0, previewCount).forEach((card, index) => { output += `**Card ${index + 1}** (ID: ${card.id})\n`; output += `Q: ${card.frontText.substring(0, 80)}${card.frontText.length > 80 ? '...' : ''}\n`; output += `A: ${card.backText.substring(0, 80)}${card.backText.length > 80 ? '...' : ''}\n\n`; }); if (flashcards.length > previewCount) { output += `... and ${flashcards.length - previewCount} more card${flashcards.length - previewCount === 1 ? '' : 's'}\n\n`; } output += `**Summary:**\n`; output += `• Total Cards: ${flashcards.length}\n`; output += `• Card IDs: ${flashcardIds.slice(0, 5).join(', ')}${flashcardIds.length > 5 ? '...' : ''}\n`; if (decksAssigned > 0) { output += `• Assigned to ${decksAssigned} deck${decksAssigned === 1 ? '' : 's'} (${deckAssociationsCreated} associations created)\n`; } output += `• Group ID: ${siblingGroupId}\n\n`; output += `**Next Steps:**\n`; output += `• Use \`get_decks\` to view your decks\n`; output += `• Use \`get_deck_cards\` to see cards in a specific deck\n`; return output; } }