UNPKG

@jojihatzz/lemmeconvert

Version:

A comprehensive Model Context Protocol (MCP) server for advanced image processing, conversion, manipulation, filtering, watermarking, analysis, QR code generation, duplicate detection, and HEIC support with robust error handling

1,036 lines 70.3 kB
#!/usr/bin/env node // main mcp server for image conversion and manipulation import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { access, readdir, readFile, rename, writeFile } from "fs/promises"; import { basename, dirname, extname, isAbsolute, join, resolve } from "path"; import QRCode from 'qrcode'; import sharp from "sharp"; import { fileURLToPath } from 'url'; // @ts-ignore - heic-convert doesn't have proper type definitions import heicConvert from 'heic-convert'; // @ts-ignore - image-hash doesn't have proper type definitions import { imageHash } from 'image-hash'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // ignoring typescript errors since png-to-ico doesnt have proper type definitions import pngToIco from "png-to-ico"; // create the mcp server instance const server = new Server({ name: "lemmeconvert", version: "1.2.0" }, { capabilities: { tools: {} } }); // tool for resizing images to specific dimensions const resizeToolDefinition = { name: "resize_image", description: "Resize an image to specified dimensions", inputSchema: { type: "object", required: ["filepaths", "width", "height"], properties: { filepaths: { type: "array", items: { type: "string" }, description: "Paths to the input image files" }, width: { type: "number", description: "The target width of the image in pixels" }, height: { type: "number", description: "The target height of the image in pixels" }, output_filename: { type: "string", description: "Optional custom name for the output file (without extension)." } }, }, }; // tool for converting images between different formats const convertToolDefinition = { name: "convert_image", description: "Convert an image to a different format", inputSchema: { type: "object", required: ["filepaths", "target_format"], properties: { filepaths: { type: "array", items: { type: "string" }, description: "Paths to the input image files" }, target_format: { type: "string", description: "Target format (e.g. jpg, png, webp, bmp, etc.)", }, output_filename: { type: "string", description: "Optional custom name for the output file (without extension)." } }, }, }; // helper function to find a unique output path when file already exists async function getUniqueOutputPath(directory, basename, extension) { try { // validate inputs if (!directory || !basename || !extension) { throw new Error('directory basename and extension are required for unique path generation'); } let outputPath = join(directory, `${basename}${extension}`); try { await access(outputPath); // file already exists so we need to find a unique name with a counter let counter = 1; const maxAttempts = 1000; // prevent infinite loops while (counter <= maxAttempts) { outputPath = join(directory, `${basename}_(${counter++})${extension}`); try { await access(outputPath); // this name also exists keep trying } catch (error) { // found a unique name return outputPath; } } throw new Error(`could not find unique filename after ${maxAttempts} attempts`); } catch (error) { // original filename is available return outputPath; } } catch (error) { throw new Error(`failed to generate unique output path: ${error instanceof Error ? error.message : 'unknown path error'}`); } } const compressToolDefinition = { name: "compress_image", description: "Compress an image to reduce file size", inputSchema: { type: "object", required: ["filepaths"], properties: { filepaths: { type: "array", items: { type: "string" }, description: "Paths to the input image files" }, jpeg_quality: { type: "number", description: "JPEG quality (1-100)", minimum: 1, maximum: 100 }, webp_lossless: { type: "boolean", description: "Use lossless compression for WebP" }, output_filename: { type: "string", description: "Optional custom name for the output file (without extension)." } }, }, }; const rotateToolDefinition = { name: "rotate_image", description: "Rotate an image", inputSchema: { type: "object", required: ["filepaths", "angle"], properties: { filepaths: { type: "array", items: { type: "string" }, description: "Paths to the input image files" }, angle: { type: "number", description: "Rotation angle", enum: [90, 180, 270] }, output_filename: { type: "string", description: "Optional custom name for the output file (without extension)." } }, }, }; const flipToolDefinition = { name: "flip_image", description: "Flip an image horizontally or vertically", inputSchema: { type: "object", required: ["filepaths", "direction"], properties: { filepaths: { type: "array", items: { type: "string" }, description: "Paths to the input image files" }, direction: { type: "string", description: "Flip direction", enum: ["horizontal", "vertical"] }, output_filename: { type: "string", description: "Optional custom name for the output file (without extension)." } }, }, }; const bulkRenameToolDefinition = { name: "bulk_rename", description: "Bulk rename files in a directory with various naming patterns", inputSchema: { type: "object", required: ["directory", "pattern"], properties: { directory: { type: "string", description: "Path to the directory containing files to rename" }, pattern: { type: "string", description: "Naming pattern. Use {name} for original name, {ext} for extension, {index} for sequential number, {date} for current date (YYYY-MM-DD), {time} for current time (HHMMSS)" }, file_filter: { type: "string", description: "File extension filter (e.g., 'jpg', 'png', '*' for all files). Default is '*'" }, start_index: { type: "number", description: "Starting number for {index} placeholder. Default is 1" }, preserve_case: { type: "boolean", description: "Whether to preserve the original case of file names. Default is true" }, dry_run: { type: "boolean", description: "Preview the rename operation without actually renaming files. Default is false" } }, }, }; const removeMetadataToolDefinition = { name: "remove_metadata", description: "Remove metadata (EXIF, XMP, IPTC) from image files to reduce file size and protect privacy", inputSchema: { type: "object", required: ["filepaths"], properties: { filepaths: { type: "array", items: { type: "string" }, description: "Paths to the input image files" }, output_filename: { type: "string", description: "Optional custom name for the output file (without extension)." }, keep_color_profile: { type: "boolean", description: "Keep ICC color profile data. Default is false (removes all metadata including color profiles)" }, dry_run: { type: "boolean", description: "Preview what metadata would be removed without actually processing files. Default is false" } }, }, }; // advanced cropping tool const cropToolDefinition = { name: "crop_image", description: "Crop images with manual coordinates, aspect ratios, or smart cropping", inputSchema: { type: "object", required: ["filepaths"], properties: { filepaths: { type: "array", items: { type: "string" }, description: "Paths to the input image files" }, x: { type: "number", description: "X coordinate for manual crop (left edge)" }, y: { type: "number", description: "Y coordinate for manual crop (top edge)" }, width: { type: "number", description: "Width for manual crop" }, height: { type: "number", description: "Height for manual crop" }, aspect_ratio: { type: "string", description: "Aspect ratio for smart crop (e.g., '16:9', '4:3', '1:1', 'square')" }, smart_crop: { type: "boolean", description: "Use smart cropping to focus on important areas" }, output_filename: { type: "string", description: "Optional custom name for the output file (without extension)" } }, }, }; // image filters and effects tool const filtersToolDefinition = { name: "apply_filters", description: "Apply various filters and effects to images", inputSchema: { type: "object", required: ["filepaths"], properties: { filepaths: { type: "array", items: { type: "string" }, description: "Paths to the input image files" }, blur: { type: "number", description: "Gaussian blur radius (0.3-1000)", minimum: 0.3, maximum: 1000 }, sharpen: { type: "number", description: "Sharpen amount (0.5-10)", minimum: 0.5, maximum: 10 }, brightness: { type: "number", description: "Brightness adjustment (-100 to 100)", minimum: -100, maximum: 100 }, contrast: { type: "number", description: "Contrast adjustment (-100 to 100)", minimum: -100, maximum: 100 }, saturation: { type: "number", description: "Saturation adjustment (-100 to 100)", minimum: -100, maximum: 100 }, grayscale: { type: "boolean", description: "Convert to grayscale" }, sepia: { type: "boolean", description: "Apply sepia effect" }, invert: { type: "boolean", description: "Invert colors" }, output_filename: { type: "string", description: "Optional custom name for the output file (without extension)" } }, }, }; // watermarking tool const watermarkToolDefinition = { name: "add_watermark", description: "Add text or image watermarks to images", inputSchema: { type: "object", required: ["filepaths"], properties: { filepaths: { type: "array", items: { type: "string" }, description: "Paths to the input image files" }, text: { type: "string", description: "Text watermark content" }, watermark_image: { type: "string", description: "Path to watermark image file" }, position: { type: "string", description: "Watermark position", enum: ["top-left", "top-center", "top-right", "center-left", "center", "center-right", "bottom-left", "bottom-center", "bottom-right"] }, opacity: { type: "number", description: "Watermark opacity (0-100)", minimum: 0, maximum: 100 }, size: { type: "number", description: "Text size or image scale percentage (10-200)", minimum: 10, maximum: 200 }, color: { type: "string", description: "Text color (hex format, e.g., '#FFFFFF')" }, output_filename: { type: "string", description: "Optional custom name for the output file (without extension)" } }, }, }; // image analysis tool const analyzeToolDefinition = { name: "analyze_image", description: "Analyze images to get dimensions, file size, color space info, and dominant colors", inputSchema: { type: "object", required: ["filepaths"], properties: { filepaths: { type: "array", items: { type: "string" }, description: "Paths to the input image files" }, get_dominant_colors: { type: "boolean", description: "Extract dominant colors from the image" }, color_count: { type: "number", description: "Number of dominant colors to extract (1-10)", minimum: 1, maximum: 10 } }, }, }; // qr code generation tool const generateQRToolDefinition = { name: "generate_qr", description: "Generate QR codes with custom styling", inputSchema: { type: "object", required: ["data"], properties: { data: { type: "string", description: "The data to encode in the QR code (text, URL, etc.)" }, output_filename: { type: "string", description: "Optional custom name for the output file (without extension). Default is 'qr_code'" }, size: { type: "number", description: "Size of the QR code in pixels (default: 200)", minimum: 50, maximum: 2000 }, error_correction: { type: "string", description: "Error correction level", enum: ["L", "M", "Q", "H"] }, margin: { type: "number", description: "Margin around the QR code (default: 4)", minimum: 0, maximum: 10 }, dark_color: { type: "string", description: "Color of dark modules (hex format, e.g., '#000000')" }, light_color: { type: "string", description: "Color of light modules (hex format, e.g., '#FFFFFF')" }, output_directory: { type: "string", description: "Directory where to save the QR code. Default is current directory" } }, }, }; // duplicate detection tool const detectDuplicatesToolDefinition = { name: "detect_duplicates", description: "Find similar or duplicate images in folders", inputSchema: { type: "object", required: ["directory"], properties: { directory: { type: "string", description: "Path to the directory to scan for duplicate images" }, similarity_threshold: { type: "number", description: "Similarity threshold for detecting duplicates (0-100, higher = more strict). Default is 90", minimum: 0, maximum: 100 }, include_subdirectories: { type: "boolean", description: "Whether to scan subdirectories recursively. Default is true" }, output_report: { type: "boolean", description: "Generate a detailed report file. Default is false" }, file_extensions: { type: "array", items: { type: "string" }, description: "File extensions to include (e.g., ['jpg', 'png', 'webp']). If not provided, all image formats are included" } }, }, }; // heic conversion tool const convertHeicToolDefinition = { name: "convert_heic", description: "HEIC/HEIF format support - convert HEIC files to other formats", inputSchema: { type: "object", required: ["filepaths"], properties: { filepaths: { type: "array", items: { type: "string" }, description: "Paths to the input HEIC/HEIF files" }, target_format: { type: "string", description: "Target format (jpg, png, webp). Default is 'jpg'", enum: ["jpg", "png", "webp"] }, quality: { type: "number", description: "Quality for JPEG output (1-100). Default is 90", minimum: 1, maximum: 100 }, output_filename: { type: "string", description: "Optional custom name for the output file (without extension)" } }, }, }; // register our available tools with the mcp server server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [convertToolDefinition, resizeToolDefinition, compressToolDefinition, rotateToolDefinition, flipToolDefinition, bulkRenameToolDefinition, removeMetadataToolDefinition, cropToolDefinition, filtersToolDefinition, watermarkToolDefinition, analyzeToolDefinition, generateQRToolDefinition, detectDuplicatesToolDefinition, convertHeicToolDefinition], })); // handle tool execution requests server.setRequestHandler(CallToolRequestSchema, async (req) => { try { if (!["convert_image", "resize_image", "compress_image", "rotate_image", "flip_image", "bulk_rename", "remove_metadata", "crop_image", "apply_filters", "add_watermark", "analyze_image", "generate_qr", "detect_duplicates", "convert_heic"].includes(req.params.name)) { throw new Error(`tool not found: ${req.params.name}`); } if (!req.params.arguments) { throw new Error("arguments are required"); } const results = []; const processFile = async (filepath, operation) => { let resolvedFilePath; try { // validate filepath input if (!filepath || typeof filepath !== 'string') { throw new Error('filepath must be a non-empty string'); } if (isAbsolute(filepath)) { resolvedFilePath = filepath; } else { const projectRoot = resolve(__dirname, '..', '..'); resolvedFilePath = resolve(projectRoot, filepath); } // check if file exists and is readable try { await access(resolvedFilePath); } catch (accessError) { throw new Error(`file not found or not accessible: ${filepath}`); } const ext = extname(resolvedFilePath).toLowerCase(); const supportedExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tiff', '.gif', '.ico']; if (!supportedExtensions.includes(ext)) { throw new Error(`unsupported file type: ${ext} supported formats: ${supportedExtensions.join(', ')}`); } const inputBuffer = await readFile(resolvedFilePath); if (inputBuffer.length === 0) { throw new Error('file is empty or corrupted'); } // add reasonable file size limit (100mb) const maxFileSize = 100 * 1024 * 1024; if (inputBuffer.length > maxFileSize) { throw new Error(`file too large: ${Math.round(inputBuffer.length / 1024 / 1024)}mb exceeds 100mb limit`); } const output = sharp(inputBuffer); await operation(output, resolvedFilePath, ext, inputBuffer); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'unknown processing error'; results.push({ type: "text", text: `❌ failed to process ${filepath}: ${errorMessage}` }); } }; if (req.params.name === "convert_image") { const { filepaths, target_format, output_filename } = req.params.arguments; // validate required parameters if (!filepaths || !Array.isArray(filepaths) || filepaths.length === 0) { return { content: [{ type: "text", text: "❌ filepaths is required and must be a non-empty array" }] }; } if (filepaths.length > 50) { return { content: [{ type: "text", text: "❌ too many files: maximum 50 files per batch" }] }; } if (!target_format || typeof target_format !== 'string') { return { content: [{ type: "text", text: "❌ target_format is required and must be a string" }] }; } for (const filepath of filepaths) { await processFile(filepath, async (output, resolvedFilePath, ext, inputBuffer) => { const outputBasename = output_filename ? output_filename : basename(resolvedFilePath, ext); const outputPath = await getUniqueOutputPath(dirname(resolvedFilePath), outputBasename, `.${target_format}`); if (target_format.toLowerCase() === 'ico') { // special handling for ico format since sharp doesnt support it directly try { const pngBuffer = await output.png().toBuffer(); const icoBuffer = await pngToIco(pngBuffer); await writeFile(outputPath, icoBuffer); } catch (icoError) { throw new Error(`failed to convert to ico format: ${icoError instanceof Error ? icoError.message : 'unknown ico error'}`); } } else { try { await output.toFormat(target_format).toFile(outputPath); } catch (formatError) { throw new Error(`failed to convert to ${target_format} format: ${formatError instanceof Error ? formatError.message : 'unknown format error'}`); } } results.push({ type: "text", text: `✅ Converted ${filepath} to ${outputPath}` }); }); } } else if (req.params.name === "resize_image") { const { filepaths, width, height, output_filename } = req.params.arguments; // validate required parameters if (!filepaths || !Array.isArray(filepaths) || filepaths.length === 0) { return { content: [{ type: "text", text: "❌ filepaths is required and must be a non-empty array" }] }; } if (typeof width !== 'number' || width <= 0 || width > 50000) { return { content: [{ type: "text", text: "❌ width must be a positive number and not exceed 50000 pixels" }] }; } if (typeof height !== 'number' || height <= 0 || height > 50000) { return { content: [{ type: "text", text: "❌ height must be a positive number and not exceed 50000 pixels" }] }; } for (const filepath of filepaths) { await processFile(filepath, async (output, resolvedFilePath, ext) => { const outputBasename = output_filename ? output_filename : `${basename(resolvedFilePath, ext)}_resized`; const outputPath = await getUniqueOutputPath(dirname(resolvedFilePath), outputBasename, ext); await output.resize(width, height).toFile(outputPath); results.push({ type: "text", text: `✅ Resized ${filepath} and saved to ${outputPath}` }); }); } } else if (req.params.name === "compress_image") { const { filepaths, jpeg_quality, webp_lossless, output_filename } = req.params.arguments; // validate required parameters if (!filepaths || !Array.isArray(filepaths) || filepaths.length === 0) { return { content: [{ type: "text", text: "❌ filepaths is required and must be a non-empty array" }] }; } if (jpeg_quality !== undefined && (typeof jpeg_quality !== 'number' || jpeg_quality < 1 || jpeg_quality > 100)) { return { content: [{ type: "text", text: "❌ jpeg_quality must be a number between 1 and 100" }] }; } for (const filepath of filepaths) { await processFile(filepath, async (_, resolvedFilePath, ext, inputBuffer) => { const outputBasename = output_filename ? output_filename : `${basename(resolvedFilePath, ext)}_compressed`; const outputPath = await getUniqueOutputPath(dirname(resolvedFilePath), outputBasename, ext); const sharpInstance = sharp(inputBuffer); if (ext === '.jpeg' || ext === '.jpg') { sharpInstance.jpeg({ quality: jpeg_quality || 80 }); } else if (ext === '.webp') { sharpInstance.webp({ lossless: webp_lossless || false }); } await sharpInstance.toFile(outputPath); results.push({ type: "text", text: `✅ Compressed ${filepath} and saved to ${outputPath}` }); }); } } else if (req.params.name === "rotate_image") { const { filepaths, angle, output_filename } = req.params.arguments; // validate required parameters if (!filepaths || !Array.isArray(filepaths) || filepaths.length === 0) { return { content: [{ type: "text", text: "❌ filepaths is required and must be a non-empty array" }] }; } const validAngles = [90, 180, 270]; if (!validAngles.includes(angle)) { return { content: [{ type: "text", text: `❌ angle must be one of: ${validAngles.join(', ')}` }] }; } for (const filepath of filepaths) { await processFile(filepath, async (output, resolvedFilePath, ext) => { const outputBasename = output_filename ? output_filename : `${basename(resolvedFilePath, ext)}_rotated`; const outputPath = await getUniqueOutputPath(dirname(resolvedFilePath), outputBasename, ext); await output.rotate(angle).toFile(outputPath); results.push({ type: "text", text: `✅ Rotated ${filepath} and saved to ${outputPath}` }); }); } } else if (req.params.name === "flip_image") { const { filepaths, direction, output_filename } = req.params.arguments; // validate required parameters if (!filepaths || !Array.isArray(filepaths) || filepaths.length === 0) { return { content: [{ type: "text", text: "❌ filepaths is required and must be a non-empty array" }] }; } const validDirections = ['horizontal', 'vertical']; if (!validDirections.includes(direction)) { return { content: [{ type: "text", text: `❌ direction must be one of: ${validDirections.join(', ')}` }] }; } for (const filepath of filepaths) { await processFile(filepath, async (output, resolvedFilePath, ext) => { const outputBasename = output_filename ? output_filename : `${basename(resolvedFilePath, ext)}_flipped`; const outputPath = await getUniqueOutputPath(dirname(resolvedFilePath), outputBasename, ext); if (direction === 'horizontal') { output.flop(); } else { output.flip(); } await output.toFile(outputPath); results.push({ type: "text", text: `✅ Flipped ${filepath} and saved to ${outputPath}` }); }); } } else if (req.params.name === "bulk_rename") { const { directory, pattern, file_filter, start_index, preserve_case, dry_run } = req.params.arguments; // validate required parameters if (!directory || typeof directory !== 'string') { return { content: [{ type: "text", text: "❌ directory is required and must be a string" }] }; } if (!pattern || typeof pattern !== 'string') { return { content: [{ type: "text", text: "❌ pattern is required and must be a string" }] }; } try { // figure out where the directory is let resolvedDirectory; if (isAbsolute(directory)) { resolvedDirectory = directory; } else { const projectRoot = resolve(__dirname, '..', '..'); resolvedDirectory = resolve(projectRoot, directory); } await access(resolvedDirectory); const files = await readdir(resolvedDirectory); // filter files based on what type of extension we want const filterExt = (file_filter || '*').toLowerCase(); const filteredFiles = files.filter(file => { if (filterExt === '*') return true; const fileExt = extname(file).toLowerCase().substring(1); // take off the dot return fileExt === filterExt; }); if (filteredFiles.length === 0) { results.push({ type: "text", text: `❌ No files found matching filter '${file_filter || '*'}' in ${directory}` }); return { content: results }; } const currentDate = new Date(); const dateString = currentDate.toISOString().split('T')[0]; // format yyyy-mm-dd const timeString = currentDate.toTimeString().split(' ')[0].replace(/:/g, ''); // format hhmmss let index = start_index || 1; const renameOperations = []; for (const file of filteredFiles) { const oldPath = join(resolvedDirectory, file); const originalName = basename(file, extname(file)); const extension = extname(file); // create new filename based on the pattern they gave us let newName = pattern .replace(/\{name\}/g, preserve_case !== false ? originalName : originalName.toLowerCase()) .replace(/\{ext\}/g, extension.substring(1)) // take off the dot from extension .replace(/\{index\}/g, index.toString().padStart(3, '0')) .replace(/\{date\}/g, dateString) .replace(/\{time\}/g, timeString); const newPath = join(resolvedDirectory, newName + extension); renameOperations.push({ oldPath, newPath, oldName: file, newName: newName + extension }); index++; } if (dry_run) { results.push({ type: "text", text: `🔍 DRY RUN - Preview of rename operations:` }); for (const op of renameOperations) { results.push({ type: "text", text: ` ${op.oldName} → ${op.newName}` }); } results.push({ type: "text", text: `Total files to rename: ${renameOperations.length}` }); } else { // make sure we don't have any naming conflicts const conflicts = renameOperations.filter(op => { return renameOperations.some(other => other !== op && other.newPath === op.newPath); }); if (conflicts.length > 0) { results.push({ type: "text", text: `❌ Naming conflicts detected. The following files would have duplicate names:` }); for (const conflict of conflicts) { results.push({ type: "text", text: ` ${conflict.newName}` }); } return { content: results }; } // actually do the renaming now let successCount = 0; for (const op of renameOperations) { try { await rename(op.oldPath, op.newPath); results.push({ type: "text", text: `✅ Renamed: ${op.oldName} → ${op.newName}` }); successCount++; } catch (error) { results.push({ type: "text", text: `❌ Failed to rename ${op.oldName}: ${error instanceof Error ? error.message : 'Unknown error'}` }); } } results.push({ type: "text", text: `📊 Bulk rename complete: ${successCount}/${renameOperations.length} files renamed successfully` }); } } catch (error) { results.push({ type: "text", text: `❌ Failed to process directory ${directory}: ${error instanceof Error ? error.message : 'Unknown error'}` }); } } else if (req.params.name === "remove_metadata") { const { filepaths, output_filename, keep_color_profile, dry_run } = req.params.arguments; // validate required parameters if (!filepaths || !Array.isArray(filepaths) || filepaths.length === 0) { return { content: [{ type: "text", text: "❌ filepaths is required and must be a non-empty array" }] }; } for (const filepath of filepaths) { try { let resolvedFilePath; if (isAbsolute(filepath)) { resolvedFilePath = filepath; } else { const projectRoot = resolve(__dirname, '..', '..'); resolvedFilePath = resolve(projectRoot, filepath); } await access(resolvedFilePath); const ext = extname(resolvedFilePath).toLowerCase(); const supportedExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.tiff', '.tif']; if (!supportedExtensions.includes(ext)) { results.push({ type: "text", text: `❌ Unsupported file type for metadata removal: ${ext}. Supported: ${supportedExtensions.join(', ')}` }); continue; } if (dry_run) { // just analyze what metadata is there without actually changing anything const inputBuffer = await readFile(resolvedFilePath); const image = sharp(inputBuffer); const metadata = await image.metadata(); const metadataInfo = []; if (metadata.exif) metadataInfo.push('EXIF'); if (metadata.icc) metadataInfo.push('ICC Color Profile'); if (metadata.xmp) metadataInfo.push('XMP'); if (metadata.iptc) metadataInfo.push('IPTC'); if (metadataInfo.length > 0) { results.push({ type: "text", text: `🔍 ${filepath} contains: ${metadataInfo.join(', ')}` }); } else { results.push({ type: "text", text: `🔍 ${filepath} has no detectable metadata` }); } } else { // actually process the file and strip out the metadata const inputBuffer = await readFile(resolvedFilePath); const image = sharp(inputBuffer); const originalMetadata = await image.metadata(); // set up what metadata we want to remove or keep const metadataOptions = { exif: {}, // remove exif data // note sharp automatically removes xmp and iptc when withMetadata is used }; // only keep the color profile if they specifically asked for it if (keep_color_profile && originalMetadata.icc) { metadataOptions.icc = originalMetadata.icc; } let processedImage = image.withMetadata(metadataOptions); const outputBasename = output_filename ? output_filename : `${basename(resolvedFilePath, ext)}_no_metadata`; const outputPath = await getUniqueOutputPath(dirname(resolvedFilePath), outputBasename, ext); await processedImage.toFile(outputPath); // let user know what metadata we removed const removedMetadata = []; if (originalMetadata.exif) removedMetadata.push('EXIF'); if (originalMetadata.xmp) removedMetadata.push('XMP'); if (originalMetadata.iptc) removedMetadata.push('IPTC'); if (!keep_color_profile && originalMetadata.icc) removedMetadata.push('ICC Color Profile'); if (removedMetadata.length > 0) { results.push({ type: "text", text: `✅ Removed metadata from ${filepath} (${removedMetadata.join(', ')}) and saved to ${outputPath}` }); } else { results.push({ type: "text", text: `✅ No metadata found to remove in ${filepath}, saved copy to ${outputPath}` }); } } } catch (error) { results.push({ type: "text", text: `❌ Failed to process ${filepath}: ${error instanceof Error ? error.message : 'Unknown error'}` }); } } } else if (req.params.name === "crop_image") { const { filepaths, x, y, width, height, aspect_ratio, smart_crop, output_filename } = req.params.arguments; // validate required parameters if (!filepaths || !Array.isArray(filepaths) || filepaths.length === 0) { return { content: [{ type: "text", text: "❌ filepaths is required and must be a non-empty array" }] }; } for (const filepath of filepaths) { await processFile(filepath, async (output, resolvedFilePath, ext) => { let sharpInstance = output; let outputBasename = output_filename ? output_filename : basename(resolvedFilePath, ext); if (smart_crop) { if (aspect_ratio) { const [arWidth, arHeight] = aspect_ratio.split(':').map(Number); if (isNaN(arWidth) || isNaN(arHeight) || arWidth <= 0 || arHeight <= 0) { throw new Error(`Invalid aspect ratio: ${aspect_ratio}`); } // for smart cropping with aspect ratio resize with cover fit and entropy position const metadata = await sharpInstance.metadata(); const targetWidth = metadata.width; const targetHeight = Math.round(targetWidth / (arWidth / arHeight)); sharpInstance = sharpInstance.resize(targetWidth, targetHeight, { fit: 'cover', position: 'entropy' }); outputBasename += `_smart_cropped${aspect_ratio ? `_${aspect_ratio.replace(':', 'x')}` : ''}`; } } else if (x !== undefined && y !== undefined && width !== undefined && height !== undefined) { sharpInstance = sharpInstance.extract({ left: x, top: y, width, height }); outputBasename += `_cropped_${x}_${y}_${width}_${height}`; } else { throw new Error("For cropping, either provide x, y, width, height for manual crop, or set smart_crop to true (optionally with aspect_ratio)."); } const outputPath = await getUniqueOutputPath(dirname(resolvedFilePath), outputBasename, ext); await sharpInstance.toFile(outputPath); results.push({ type: "text", text: `✅ Cropped ${filepath} and saved to ${outputPath}` }); }); } } else if (req.params.name === "apply_filters") { const { filepaths, blur, sharpen, brightness, contrast, saturation, grayscale, sepia, invert, output_filename } = req.params.arguments; // validate required parameters if (!filepaths || !Array.isArray(filepaths) || filepaths.length === 0) { return { content: [{ type: "text", text: "❌ filepaths is required and must be a non-empty array" }] }; } for (const filepath of filepaths) { await processFile(filepath, async (output, resolvedFilePath, ext) => { let sharpInstance = output; let outputBasename = output_filename ? output_filename : basename(resolvedFilePath, ext); if (blur !== undefined) { sharpInstance = sharpInstance.blur(blur); outputBasename += `_blurred_${blur}`; } if (sharpen !== undefined) { sharpInstance = sharpInstance.sharpen(sharpen); outputBasename += `_sharpened_${sharpen}`; } if (brightness !== undefined) { sharpInstance = sharpInstance.modulate({ brightness: brightness / 100 + 1 }); outputBasename += `_bright_${brightness}`; } if (contrast !== undefined) { // sharps modulate does not have a contrast property use linear for contrast adjustment // a simple linear transformation for contrast output = a * input + b // for contrast we can adjust the slope a and b is for brightness // a value of 1 for a means no change >1 increases contrast <1 decreases // this is a simplified approach a more robust solution might involve gamma correction or histogram equalization const a = 1 + (contrast / 100); // scale factor for contrast sharpInstance = sharpInstance.linear(a, 0); outputBasename += `_contrast_${contrast}`; } if (saturation !== undefined) { sharpInstance = sharpInstance.modulate({ saturation: saturation / 100 + 1 }); outputBasename += `_saturate_${saturation}`; } if (grayscale) { sharpInstance = sharpInstance.grayscale(); outputBasename += `_grayscale`; } if (sepia) { sharpInstance = sharpInstance.tint({ r: 112, g: 66, b: 20 }); // a common sepia tone outputBasename += `_sepia`; } if (invert) { sharpInstance = sharpInstance.negate(); outputBasename += `_inverted`; } const outputPath = await getUniqueOutputPath(dirname(resolvedFilePath), outputBasename, ext); await sharpInstance.toFile(outputPath); results.push({ type: "text", text: `✅ Applied filters to ${filepath} and saved to ${outputPath}` }); }); } } else if (req.params.name === "add_watermark") { const { filepaths, text, watermark_image, position, opacity, size, color, output_filename } = req.params.arguments; // validate required parameters if (!filepaths || !Array.isArray(filepaths) || filepaths.length === 0) { return { content: [{ type: "text", text: "❌ filepaths is required and must be a non-empty array" }] }; } if (!text && !watermark_image) { return { content: [{ type: "text", text: "❌ either text or watermark_image must be provided" }] }; } for (const filepath of filepaths) { await processFile(filepath, async (output, resolvedFilePath, ext, inputBuffer) => { let sharpInstance = output; let outputBasename = output_filename ? output_filename : basename(resolvedFilePath, ext); if (text) { const svgText = `<svg width="${(await sharpInstance.metadata()).width}" height="${(await sharpInstance.metadata()).height}"> <text x="50%" y="50%" font-family="Arial" font-size="${size || 50}" fill="${color || '#000000'}" fill-opacity="${(opacity || 100) / 100}" text-anchor="middle" dominant-baseline="middle">${text}</text> </svg>`; const svgBuffer = Buffer.from(svgText); sharpInstance = sharpInstance.composite([{ input: svgBuffer, gravity: { 'top-left': 'northwest', 'top-center': 'north', 'top-right': 'northeast', 'center-left': 'west', 'center': 'center', 'center-right': 'east', 'bottom-left': 'southwest', 'bottom-center': 'south', 'bottom-right': 'southeast', }[position] || 'center', }]); outputBasename += `_watermarked_text`; } else if (watermark_image) { let watermarkBuffer; try { watermarkBuffer = await readFile(watermark_image); if (watermarkBuffer.length === 0) { throw new Error('watermark image file is empty'); } } catch (watermarkError) { throw new Error(`failed to load watermark image: ${watermarkError instanceof Error ? watermarkError.message : 'unknown water