ai-flashmob-mcp
Version:
MCP server for AI-powered flashcard generation
347 lines (290 loc) ⢠10.4 kB
JavaScript
/**
* 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;
}
}