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

348 lines 12.9 kB
/** * File management utilities for the Scryfall MCP server. */ import { readFile, stat, unlink, readdir } from 'node:fs/promises'; import { existsSync, statSync } from 'node:fs'; import { join, extname, basename } from 'node:path'; import { lookup } from 'mime-types'; import { v4 as uuidv4, validate as isValidUuid } from 'uuid'; import { createCardDatabase } from './database.js'; import { logger } from './logger.js'; export class FileManager { db = null; ownsDb = false; constructor(db) { this.db = db || null; this.ownsDb = !db; } /** * Initialize the file manager with database connection. */ async init() { if (!this.db) { this.db = await createCardDatabase(); } } /** * Generate a unique file ID. */ generateFileId() { return uuidv4(); } /** * Get the file path for a given file ID. */ async getFilePath(fileId) { if (!this.db) { await this.init(); } const filePath = this.db.getFilePathById(fileId); if (filePath) { // Security check: ensure the path exists and is a file if (existsSync(filePath)) { const stats = statSync(filePath); if (stats.isFile()) { return filePath; } } } return undefined; } /** * Determine the MIME type of a file. */ getMimeType(filePath) { const mimeType = lookup(filePath); if (mimeType) { return mimeType; } // Default MIME types for common extensions const extension = extname(filePath).toLowerCase(); const mimeMap = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', '.json': 'application/json', '.txt': 'text/plain' }; return mimeMap[extension] || 'application/octet-stream'; } /** * Read file content by file ID with enhanced security and error handling. */ async readFileContent(fileId) { try { // Validate access first const isAccessAllowed = await this.validateResourceAccess('download/card', fileId); if (!isAccessAllowed) { logger.warn(`Access denied for file ID: ${fileId}`); return undefined; } const filePath = await this.getFilePath(fileId); if (!filePath) { logger.warn(`File path not found for file ID: ${fileId}`); return undefined; } // Double-check file exists and is readable try { await stat(filePath); } catch (statError) { logger.error(`File not accessible: ${filePath} - ${statError.message}`); return undefined; } // Read file content with size validation const stats = await stat(filePath); const maxFileSize = 50 * 1024 * 1024; // 50MB limit if (stats.size > maxFileSize) { logger.error(`File size exceeds limit: ${stats.size} bytes for ${filePath}`); return undefined; } const content = await readFile(filePath); const mimeType = this.getMimeType(filePath); logger.info(`Successfully read file: ${basename(filePath)} (${content.length} bytes, ${mimeType})`); return [content, mimeType]; } catch (error) { logger.error(`Failed to read file content for ${fileId}: ${error.message}`); return undefined; } } /** * Register a file in the database and return its file ID with enhanced validation. */ async registerFile(filePath, cardName, cardId, setCode, imageUrl) { try { if (!this.db) { await this.init(); } // Validate inputs if (!filePath || !cardName) { throw new Error('File path and card name are required'); } // Verify file exists before registering if (!existsSync(filePath)) { throw new Error(`File does not exist: ${filePath}`); } // Validate file is actually a file (not directory) const stats = statSync(filePath); if (!stats.isFile()) { throw new Error(`Path is not a file: ${filePath}`); } // Validate file extension const extension = extname(filePath).toLowerCase(); const validExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.json', '.txt']; if (!validExtensions.includes(extension)) { logger.warn(`Registering file with unusual extension: ${extension}`); } const fileId = this.generateFileId(); try { this.db.addCard(cardName, filePath, cardId, setCode, imageUrl, fileId); logger.info(`File registered successfully: ${cardName} -> ${fileId} (${basename(filePath)})`); return fileId; } catch (dbError) { logger.error(`Database error during file registration: ${dbError.message}`); throw new Error(`Failed to register file in database: ${dbError.message}`); } } catch (error) { logger.error(`Failed to register file ${filePath}: ${error.message}`); throw error; } } /** * Clean up old temporary files. */ async cleanupTempFiles(directory, maxAgeHours = 24) { if (!existsSync(directory)) { return 0; } let deletedCount = 0; const currentTime = Date.now(); const maxAgeMs = maxAgeHours * 3600 * 1000; try { const files = await this.walkDirectory(directory); for (const filePath of files) { try { const stats = await stat(filePath); const fileAge = currentTime - stats.mtime.getTime(); if (fileAge > maxAgeMs) { await unlink(filePath); deletedCount++; } } catch (error) { // Ignore errors for individual files } } } catch (error) { logger.error('Failed to cleanup temp files:', error); } return deletedCount; } /** * Recursively walk a directory and return all file paths. */ async walkDirectory(directory) { const files = []; try { const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(directory, entry.name); if (entry.isFile()) { files.push(fullPath); } else if (entry.isDirectory()) { const subFiles = await this.walkDirectory(fullPath); files.push(...subFiles); } } } catch (error) { // Ignore errors for individual directories } return files; } /** * Validate that a resource access is allowed with comprehensive security checks. */ async validateResourceAccess(resourceType, fileId) { try { // Ensure file_id is a valid UUID format if (!isValidUuid(fileId)) { logger.warn(`Invalid UUID format for file access: ${fileId}`); return false; } // Validate resource type const allowedResourceTypes = ['download/card', 'download/art', 'download/metadata']; if (!allowedResourceTypes.includes(resourceType)) { logger.warn(`Invalid resource type: ${resourceType}`); return false; } // Check if file exists in database if (!this.db) { await this.init(); } const cardInfo = this.db.getCardByFileId(fileId); if (!cardInfo) { logger.warn(`File ID not found in database: ${fileId}`); return false; } // Check if physical file exists const filePath = cardInfo.filename; if (!existsSync(filePath)) { logger.warn(`Physical file does not exist: ${filePath}`); return false; } // Verify file is within allowed directories const resolvedPath = require('path').resolve(filePath); const allowedDirs = [ '/tmp/scryfall_downloads', '/tmp/scryfall_fallback', process.env['SCRYFALL_DATA_DIR'], require('os').tmpdir(), require('path').join(require('os').homedir(), '.local') ].filter(Boolean); const isInAllowedDir = allowedDirs.some(dir => { if (!dir) return false; const resolvedDir = require('path').resolve(dir); return resolvedPath.startsWith(resolvedDir); }); if (!isInAllowedDir) { logger.warn(`File path outside allowed directories: ${resolvedPath}`); return false; } // Check file size limits (reasonable limits for card images) const stats = statSync(filePath); const maxFileSize = 50 * 1024 * 1024; // 50MB limit if (stats.size > maxFileSize) { logger.warn(`File size exceeds limit: ${stats.size} bytes`); return false; } // Validate file extension for expected content types const extension = extname(filePath).toLowerCase(); const validExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.json', '.txt']; if (!validExtensions.includes(extension)) { logger.warn(`Invalid file extension: ${extension}`); return false; } return true; } catch (error) { logger.error(`Error validating resource access: ${error.message}`); return false; } } /** * Get comprehensive information about a resource, matching Python version functionality. */ async getResourceInfo(fileId) { try { if (!this.db) { await this.init(); } // Validate file ID format if (!isValidUuid(fileId)) { logger.warn(`Invalid UUID format for resource info: ${fileId}`); return undefined; } const cardInfo = this.db.getCardByFileId(fileId); if (!cardInfo) { logger.warn(`Card info not found for file ID: ${fileId}`); return undefined; } // Verify physical file exists const filePath = cardInfo.filename; if (!existsSync(filePath)) { logger.warn(`Physical file missing for resource info: ${filePath}`); return undefined; } // Get file stats for additional metadata let fileStats; try { fileStats = await stat(filePath); } catch (statError) { logger.error(`Failed to get file stats: ${statError.message}`); return undefined; } const resourceInfo = { card_name: cardInfo.card_name, filename: basename(cardInfo.filename), mime_type: this.getMimeType(cardInfo.filename), download_date: cardInfo.download_date, set_code: cardInfo.set_code || '', card_id: cardInfo.card_id || '' }; // Additional metadata that might be useful logger.info(`Resource info retrieved for ${fileId}: ${resourceInfo.card_name} (${resourceInfo.filename})`); return resourceInfo; } catch (error) { logger.error(`Failed to get resource info for ${fileId}: ${error.message}`); return undefined; } } /** * Close the database connection if we own it. */ close() { if (this.ownsDb && this.db) { this.db.close(); } } } /** * Create a file manager instance with automatic initialization. */ export async function createFileManager(db) { const fm = new FileManager(db); await fm.init(); return fm; } //# sourceMappingURL=fileManager.js.map