UNPKG

@kaminaduck/scryfall-mcp-server

Version:

A Model Context Protocol (MCP) server that provides access to the Scryfall API for Magic: The Gathering card data

724 lines 30.1 kB
/** * Main MCP server configuration for the Scryfall server. */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, ReadResourceRequestSchema, InitializeRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; // Import tool implementations import { mcpSearchCards, mcpGetCardArtwork } from './tools/searchTools.js'; import { mcpDownloadCard, mcpDownloadArtCrop } from './tools/downloadTools.js'; import { mcpVerifyDatabase, mcpScanDirectory, mcpCleanDatabase, mcpDatabaseReport } from './tools/databaseTools.js'; // Import resource implementations import { cardById, cardByName, randomCard } from './resources/cardResources.js'; import { databaseStats } from './resources/databaseResources.js'; import { serveCardImage, serveArtCrop, serveMetadata, serveCardImageFace, serveArtCropFace, serveMetadataFace } from './resources/fileResources.js'; import { logger } from './logger.js'; import { getStorageDirectory, ensureDirectoryPermissions, isMcpMode } from './config.js'; // Create the MCP server export const server = new Server({ name: 'scryfall-server', version: '1.0.0', capabilities: { resources: {}, tools: {} } }); // Track whether file operations are available (used for graceful degradation) let fileOperationsAvailable = false; // Export the file operations status for use in other modules export function isFileOperationsAvailable() { return fileOperationsAvailable; } // Initialize the server with resilient error handling and graceful degradation export async function initializeServer() { logger.info('[Setup] Initializing Scryfall MCP server...'); try { // Log initialization start with detailed environment info logger.info('[Setup] Environment details:', { platform: process.platform, nodeVersion: process.version, cwd: process.cwd(), mcp_mode: isMcpMode() }); // Enhanced pre-startup checks with graceful degradation if (isMcpMode()) { logger.info('[Setup] Running in MCP mode - performing storage validation'); // Validate storage directory with graceful degradation let storageDir = null; try { storageDir = await getStorageDirectory(); logger.info(`[Setup] Storage directory resolved: ${storageDir}`); } catch (storageError) { logger.warn(`[Setup] Failed to resolve storage directory: ${storageError.message}`); logger.warn('[Setup] Storage error details:', { error: storageError.message, code: storageError.code, path: storageError.path }); logger.warn('[Setup] File download functionality will be disabled'); fileOperationsAvailable = false; } // Verify directory permissions with graceful fallback if (storageDir) { try { logger.info('[Setup] Checking storage directory permissions...'); const hasPermissions = await ensureDirectoryPermissions(storageDir); if (!hasPermissions) { logger.warn(`[Setup] Cannot write to storage directory: ${storageDir}`); logger.warn('[Setup] File downloads will be disabled - server will continue with API-only functionality'); fileOperationsAvailable = false; } else { logger.info(`[Setup] Storage directory permissions verified: ${storageDir}`); fileOperationsAvailable = true; } } catch (permissionError) { logger.warn(`[Setup] Storage directory permission check failed: ${permissionError.message}`); logger.warn('[Setup] Permission error details:', { error: permissionError.message, code: permissionError.code, syscall: permissionError.syscall, path: permissionError.path }); logger.warn('[Setup] File downloads will be disabled - server will continue with API-only functionality'); fileOperationsAvailable = false; } } // Verify required subdirectories can be created (only if file operations are available) if (fileOperationsAvailable && storageDir) { try { logger.info('[Setup] Verifying subdirectory creation...'); const cardImagesDir = await getStorageDirectory('scryfall_card_images'); const artCropsDir = await getStorageDirectory('scryfall_images'); logger.info(`[Setup] Subdirectories verified: card images (${cardImagesDir}), art crops (${artCropsDir})`); } catch (subdirError) { logger.warn(`[Setup] Failed to create required subdirectories: ${subdirError.message}`); logger.warn('[Setup] Subdirectory error details:', { error: subdirError.message, code: subdirError.code, path: subdirError.path }); logger.warn('[Setup] File downloads will be disabled - server will continue with API-only functionality'); fileOperationsAvailable = false; } } // Log the final status of file operations if (fileOperationsAvailable) { logger.info('[Setup] ✓ File download functionality is available'); } else { logger.info('[Setup] ⚠ File download functionality is disabled - server will provide search and API access only'); } } else { logger.info('[Setup] Running in standalone mode'); try { // Even in standalone mode, verify storage is accessible const testDir = await getStorageDirectory(); await ensureDirectoryPermissions(testDir); fileOperationsAvailable = true; logger.info('[Setup] ✓ Standalone mode storage verified'); } catch (standaloneError) { logger.warn('[Setup] Standalone mode storage verification failed:', standaloneError.message); fileOperationsAvailable = false; } } // Additional system checks try { // Verify Node.js version compatibility const nodeVersion = process.version; const majorVersion = parseInt(nodeVersion.slice(1).split('.')[0] || '0'); if (majorVersion < 18) { logger.warn(`[Setup] Warning: Node.js version ${nodeVersion} detected. Version 18+ is recommended.`); } else { logger.info(`[Setup] Node.js version ${nodeVersion} is compatible`); } // Verify required environment variables if in MCP mode if (isMcpMode()) { const requiredEnvVars = ['MCP_SERVER_NAME', 'MCP_ENABLE_FILE_DOWNLOADS']; const missingVars = requiredEnvVars.filter(varName => !process.env[varName]); if (missingVars.length > 0) { logger.info(`[Setup] Optional environment variables not set: ${missingVars.join(', ')}`); } } // Log memory usage for diagnostics const memUsage = process.memoryUsage(); logger.info('[Setup] Memory usage:', { rss: `${Math.round(memUsage.rss / 1024 / 1024)}MB`, heapUsed: `${Math.round(memUsage.heapUsed / 1024 / 1024)}MB`, heapTotal: `${Math.round(memUsage.heapTotal / 1024 / 1024)}MB` }); } catch (systemCheckError) { logger.warn(`[Setup] System check warnings: ${systemCheckError.message}`); // Don't fail initialization for system check warnings } logger.info('[Setup] ✓ Scryfall MCP server initialized successfully'); logger.info('[Setup] ✓ Ready to handle MCP requests'); } catch (criticalError) { // Catch any unexpected errors during initialization logger.error('[Setup] CRITICAL: Unexpected error during server initialization:', criticalError); logger.error('[Setup] Critical error details:', { message: criticalError.message, stack: criticalError.stack, code: criticalError.code }); // Try to continue with minimal functionality fileOperationsAvailable = false; logger.warn('[Setup] Attempting to continue with minimal functionality (API-only mode)'); // Re-throw only if we can't recover at all if (criticalError.code === 'MODULE_NOT_FOUND' || criticalError.code === 'ERR_INVALID_ARG_TYPE') { throw criticalError; } } } // Handle initialize request server.setRequestHandler(InitializeRequestSchema, async (request) => { logger.info('[Initialize] Client initialize request received'); return { protocolVersion: '2024-11-05', capabilities: { resources: {}, tools: {} }, serverInfo: { name: 'scryfall-server', version: '1.0.0' } }; }); // Register tools server.setRequestHandler(ListToolsRequestSchema, async () => { // Always available tools (API-based) const tools = [ { name: 'mcp_search_cards', description: 'Search for Magic: The Gathering cards using the Scryfall API', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'The search query to use (e.g., "lightning bolt", "t:creature c:red")' } }, required: ['query'] } }, { name: 'mcp_get_card_artwork', description: 'Get the artwork for a specific Magic: The Gathering card', inputSchema: { type: 'object', properties: { card_id: { type: 'string', description: 'The Scryfall ID of the card' } }, required: ['card_id'] } } ]; // File operation dependent tools if (fileOperationsAvailable) { tools.push({ name: 'mcp_download_card', description: 'Download a high-resolution image of a specific Magic: The Gathering card. Supports transform cards (downloads all faces automatically)', inputSchema: { type: 'object', properties: { card_name: { type: 'string', description: 'The name of the card to download' }, set_code: { type: 'string', description: 'Optional set code to specify a particular printing (e.g., "m10", "znr")' }, collector_number: { type: 'string', description: 'Optional collector number to specify a particular printing' }, force_download: { type: 'boolean', description: 'Whether to force download even if the card already exists', default: false } }, required: ['card_name'] } }, { name: 'mcp_download_art_crop', description: 'Download an art crop image of a specific Magic: The Gathering card. Supports transform cards (downloads all faces automatically)', inputSchema: { type: 'object', properties: { card_name: { type: 'string', description: 'The name of the card to download' }, set_code: { type: 'string', description: 'Optional set code to specify a particular printing (e.g., "m10", "znr")' }, collector_number: { type: 'string', description: 'Optional collector number to specify a particular printing' }, force_download: { type: 'boolean', description: 'Whether to force download even if the card already exists', default: false } }, required: ['card_name'] } }, { name: 'mcp_verify_database', description: 'Verify database integrity by checking if all referenced files exist', inputSchema: { type: 'object', properties: {}, additionalProperties: false } }, { name: 'mcp_scan_directory', description: 'Scan a directory for image files and optionally add them to the database', inputSchema: { type: 'object', properties: { directory: { type: 'string', description: 'Directory to scan' }, update_db: { type: 'boolean', description: 'Whether to update the database with found files', default: false } }, required: ['directory'] } }, { name: 'mcp_clean_database', description: 'Clean the database by removing records for files that no longer exist', inputSchema: { type: 'object', properties: { execute: { type: 'boolean', description: 'If true, actually remove records; if false, only report what would be removed', default: false } }, additionalProperties: false } }, { name: 'mcp_database_report', description: 'Generate a comprehensive report on the database status', inputSchema: { type: 'object', properties: {}, additionalProperties: false } }); } return { tools }; }); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { if (!args) { throw new Error('Arguments are required but not provided'); } // Check if the requested tool requires file operations const fileOperationTools = [ 'mcp_download_card', 'mcp_download_art_crop', 'mcp_verify_database', 'mcp_scan_directory', 'mcp_clean_database', 'mcp_database_report' ]; if (fileOperationTools.includes(name) && !fileOperationsAvailable) { logger.warn(`[Tool] Tool ${name} requested but file operations are not available`); return { content: [ { type: 'text', text: JSON.stringify({ status: 'error', message: 'File operations are not available. This tool requires write access to the storage directory.', details: 'The server is running in API-only mode. You can still search for cards and get card information.', tool: name }, null, 2) } ], isError: true }; } switch (name) { case 'mcp_search_cards': return { content: [ { type: 'text', text: JSON.stringify(await mcpSearchCards(args?.['query']), null, 2) } ] }; case 'mcp_download_card': return { content: [ { type: 'text', text: JSON.stringify(await mcpDownloadCard(args?.['card_name'], args?.['set_code'], args?.['collector_number'], args?.['force_download'] || false), null, 2) } ] }; case 'mcp_download_art_crop': return { content: [ { type: 'text', text: JSON.stringify(await mcpDownloadArtCrop(args?.['card_name'], args?.['set_code'], args?.['collector_number'], args?.['force_download'] || false), null, 2) } ] }; case 'mcp_get_card_artwork': return { content: [ { type: 'text', text: JSON.stringify(await mcpGetCardArtwork(args?.['card_id']), null, 2) } ] }; case 'mcp_verify_database': return { content: [ { type: 'text', text: JSON.stringify(await mcpVerifyDatabase(), null, 2) } ] }; case 'mcp_scan_directory': return { content: [ { type: 'text', text: JSON.stringify(await mcpScanDirectory(args?.['directory'], args?.['update_db'] || false), null, 2) } ] }; case 'mcp_clean_database': return { content: [ { type: 'text', text: JSON.stringify(await mcpCleanDatabase(args?.['execute'] || false), null, 2) } ] }; case 'mcp_database_report': return { content: [ { type: 'text', text: JSON.stringify(await mcpDatabaseReport(), null, 2) } ] }; default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { logger.error(`[Tool] Error executing tool ${name}:`, error); logger.error(`[Tool] Tool arguments:`, args); logger.error(`[Tool] Error details:`, { message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, name: error instanceof Error ? error.name : 'Unknown' }); return { content: [ { type: 'text', text: JSON.stringify({ status: 'error', message: error instanceof Error ? error.message : 'Unknown error occurred', tool: name }) } ], isError: true }; } }); // Register resources server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: [ { uri: 'resource://card/{card_id}', name: 'Card by ID', description: 'Get detailed card information by Scryfall ID', mimeType: 'application/json' }, { uri: 'resource://card/name/{card_name}', name: 'Card by Name', description: 'Get detailed card information by name', mimeType: 'application/json' }, { uri: 'resource://random_card', name: 'Random Card', description: 'Get a random Magic: The Gathering card', mimeType: 'application/json' }, { uri: 'resource://database/stats', name: 'Database Statistics', description: 'Get statistics about the card database', mimeType: 'application/json' }, { uri: 'resource://download/card/{file_id}', name: 'Downloaded Card Image', description: 'Access downloaded card images by file ID', mimeType: 'image/*' }, { uri: 'resource://download/art/{file_id}', name: 'Downloaded Art Crop', description: 'Access downloaded art crop images by file ID', mimeType: 'image/*' }, { uri: 'resource://download/metadata/{file_id}', name: 'Card Metadata', description: 'Access JSON metadata for downloaded cards', mimeType: 'application/json' }, { uri: 'resource://download/card/{file_id}/face/{face_index}', name: 'Transform Card Face Image', description: 'Access specific face of a transform card by file ID and face index', mimeType: 'image/*' }, { uri: 'resource://download/art/{file_id}/face/{face_index}', name: 'Transform Card Art Crop Face', description: 'Access specific art crop face of a transform card by file ID and face index', mimeType: 'image/*' }, { uri: 'resource://download/metadata/{file_id}/face/{face_index}', name: 'Transform Card Face Metadata', description: 'Access face-specific metadata for transform cards', mimeType: 'application/json' } ] }; }); // Handle resource reads server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params; try { // Parse the URI and route to appropriate handler if (uri.startsWith('resource://card/name/')) { const cardName = uri.replace('resource://card/name/', ''); const [content, mimeType] = await cardByName(decodeURIComponent(cardName)); return { contents: [ { uri, mimeType, text: content } ] }; } else if (uri.startsWith('resource://card/')) { const cardId = uri.replace('resource://card/', ''); const [content, mimeType] = await cardById(cardId); return { contents: [ { uri, mimeType, text: content } ] }; } else if (uri === 'resource://random_card') { const [content, mimeType] = await randomCard(); return { contents: [ { uri, mimeType, text: content } ] }; } else if (uri === 'resource://database/stats') { const [content, mimeType] = await databaseStats(); return { contents: [ { uri, mimeType, text: content } ] }; } else if (uri.startsWith('resource://download/card/')) { // Check if this is a face-specific request const cardPath = uri.replace('resource://download/card/', ''); if (cardPath.includes('/face/')) { const [fileId, facePart] = cardPath.split('/face/'); const faceIndex = parseInt(facePart || '0', 10); if (isNaN(faceIndex)) { throw new Error(`Invalid face index: ${facePart}`); } const [content, mimeType] = await serveCardImageFace(fileId, faceIndex); return { contents: [ { uri, mimeType, blob: content instanceof Buffer ? content : Buffer.from(content) } ] }; } else { // Standard card image request const [content, mimeType] = await serveCardImage(cardPath); return { contents: [ { uri, mimeType, blob: content instanceof Buffer ? content : Buffer.from(content) } ] }; } } else if (uri.startsWith('resource://download/art/')) { // Check if this is a face-specific request const artPath = uri.replace('resource://download/art/', ''); if (artPath.includes('/face/')) { const [fileId, facePart] = artPath.split('/face/'); const faceIndex = parseInt(facePart || '0', 10); if (isNaN(faceIndex)) { throw new Error(`Invalid face index: ${facePart}`); } const [content, mimeType] = await serveArtCropFace(fileId, faceIndex); return { contents: [ { uri, mimeType, blob: content instanceof Buffer ? content : Buffer.from(content) } ] }; } else { // Standard art crop request const [content, mimeType] = await serveArtCrop(artPath); return { contents: [ { uri, mimeType, blob: content instanceof Buffer ? content : Buffer.from(content) } ] }; } } else if (uri.startsWith('resource://download/metadata/')) { // Check if this is a face-specific request const metadataPath = uri.replace('resource://download/metadata/', ''); if (metadataPath.includes('/face/')) { const [fileId, facePart] = metadataPath.split('/face/'); const faceIndex = parseInt(facePart || '0', 10); if (isNaN(faceIndex)) { throw new Error(`Invalid face index: ${facePart}`); } const [content, mimeType] = await serveMetadataFace(fileId, faceIndex); return { contents: [ { uri, mimeType, text: content } ] }; } else { // Standard metadata request const [content, mimeType] = await serveMetadata(metadataPath); return { contents: [ { uri, mimeType, text: content } ] }; } } else { throw new Error(`Unknown resource: ${uri}`); } } catch (error) { logger.error(`[Resource] Error reading resource ${uri}:`, error); logger.error(`[Resource] Error details:`, { message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, name: error instanceof Error ? error.name : 'Unknown' }); const errorResponse = JSON.stringify({ status: 'error', message: error instanceof Error ? error.message : 'Unknown error occurred', resource: uri }); return { contents: [ { uri, mimeType: 'application/json', text: errorResponse } ] }; } }); // Add prompts list handler (server doesn't provide prompts, but handler prevents JSON-RPC errors) server.setRequestHandler(ListPromptsRequestSchema, async () => { return { prompts: [] }; }); //# sourceMappingURL=server.js.map