ai-flashmob-mcp
Version:
MCP server for AI-powered flashcard generation
286 lines (235 loc) ⢠9.09 kB
JavaScript
/**
* Flashcard Generator Tool
*
* Implements MCP tools for generating flashcards from text and images
* using the AI Flashmob backend API with HMAC authentication.
*/
import {
signRequest,
validateCredentials,
getApiBaseUrl,
logRequest
} from '../utils/auth.js';
export class FlashcardGenerator {
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);
}
/**
* Generates flashcards from text content
*
* @param {Object} args - Arguments from MCP tool call
* @param {string} args.text - Text content to generate flashcards from
* @param {number} [args.maxCards=5] - Maximum number of flashcards to generate
* @param {number} [args.deckId] - Optional deck ID
* @returns {Object} MCP tool response
*/
async generateFromText(args) {
try {
const { text, maxCards = 5, deckId } = args;
// Validate input
if (!text || typeof text !== 'string') {
throw new Error('Text content is required and must be a string');
}
if (text.length < 10 || text.length > 4000) {
throw new Error('Text must be between 10 and 4000 characters');
}
if (maxCards && (maxCards < 1 || maxCards > 10)) {
throw new Error('maxCards must be between 1 and 10');
}
if (deckId && (!Number.isInteger(deckId) || deckId < 1)) {
throw new Error('deckId must be a positive integer');
}
// Prepare request body
const requestBody = {
text,
maxCards,
...(deckId && { deckId })
};
// Sign the request
const headers = signRequest(this.publicUserId, this.secretKey, requestBody);
// Log request for debugging
logRequest('POST', `${this.apiBaseUrl}/api/v1/external/ai/generate-cards`, requestBody);
// Make API request
const response = await fetch(`${this.apiBaseUrl}/api/v1/external/ai/generate-cards`, {
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.formatSuccessResponse(result, 'text')
}
]
};
} catch (error) {
return this.formatErrorResponse(error);
}
}
/**
* Generates flashcards from image content
*
* @param {Object} args - Arguments from MCP tool call
* @param {string} args.image - Base64-encoded image data
* @param {string} [args.imageFormat='png'] - Image format
* @param {number} [args.maxCards=5] - Maximum number of flashcards to generate
* @param {number} [args.deckId] - Optional deck ID
* @returns {Object} MCP tool response
*/
async generateFromImage(args) {
try {
const { image, imageFormat = 'png', maxCards = 5, deckId } = args;
// Validate input
if (!image || typeof image !== 'string') {
throw new Error('Image data is required and must be a base64 string');
}
if (image.length < 100) {
throw new Error('Image data appears to be too short. Please provide valid base64 image data.');
}
if (!['png', 'jpeg', 'jpg', 'webp'].includes(imageFormat)) {
throw new Error('imageFormat must be one of: png, jpeg, jpg, webp');
}
if (maxCards && (maxCards < 1 || maxCards > 10)) {
throw new Error('maxCards must be between 1 and 10');
}
if (deckId && (!Number.isInteger(deckId) || deckId < 1)) {
throw new Error('deckId must be a positive integer');
}
// Prepare request body
const requestBody = {
image,
imageFormat,
maxCards,
...(deckId && { deckId })
};
// Sign the request
const headers = signRequest(this.publicUserId, this.secretKey, requestBody);
// Log request for debugging (without full image data)
logRequest('POST', `${this.apiBaseUrl}/api/v1/external/ai/generate-cards`, {
...requestBody,
image: `[base64 image: ${image.length} chars]`
});
// Make API request
const response = await fetch(`${this.apiBaseUrl}/api/v1/external/ai/generate-cards`, {
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.formatSuccessResponse(result, 'image')
}
]
};
} catch (error) {
return this.formatErrorResponse(error);
}
}
/**
* 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 402:
throw new Error(`Insufficient credits: ${responseData.message || 'You need more AI credits to generate flashcards'}`);
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 || 'AI 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'}`);
}
if (!responseData.flashcards || !Array.isArray(responseData.flashcards)) {
throw new Error('Invalid API response: missing or invalid flashcards data');
}
return responseData;
}
/**
* Formats a successful API response for MCP display
*
* @param {Object} result - API response data
* @param {string} source - Source type ('text' or 'image')
* @returns {string} Formatted response text
*/
formatSuccessResponse(result, source) {
const { flashcards, creditsUsed, remainingCredits, deckId, siblingGroupId } = result;
let output = `š Successfully generated ${flashcards.length} flashcard${flashcards.length === 1 ? '' : 's'} from ${source}!\n\n`;
// Display each flashcard
flashcards.forEach((card, index) => {
output += `š **Flashcard ${index + 1}**${card.type === 'summary' ? ' (Summary)' : ''}\n`;
output += `**Q:** ${card.front}\n`;
output += `**A:** ${card.back}\n\n`;
});
// Display metadata
output += `š **Generation Details:**\n`;
output += `⢠AI Credits Used: ${creditsUsed}\n`;
output += `⢠Remaining Credits: ${remainingCredits}\n`;
if (deckId) {
output += `⢠Deck ID: ${deckId}\n`;
}
if (siblingGroupId) {
output += `⢠Group ID: ${siblingGroupId}\n`;
}
output += `\nā
All flashcards have been saved to your account and are ready for study!`;
return output;
}
/**
* Formats an error response for MCP display
*
* @param {Error} error - The error that occurred
* @returns {Object} MCP error response
*/
formatErrorResponse(error) {
const errorMessage = error.message || 'Unknown error occurred';
// Log error for debugging
console.error(`Flashcard generation error: ${errorMessage}`);
return {
content: [
{
type: 'text',
text: `ā **Flashcard Generation Failed**\n\n${errorMessage}\n\nš§ **Troubleshooting Tips:**\n⢠Check your API credentials (PUBLIC_USER_ID and SECRET_KEY)\n⢠Ensure you have sufficient AI credits\n⢠Verify your internet connection\n⢠Try again with different content if the issue persists`
}
],
isError: true
};
}
}