@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
JavaScript
#!/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