ai-flashmob-mcp
Version:
MCP server for AI-powered flashcard generation
530 lines (437 loc) • 17.1 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\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;
}
}