UNPKG

@boomlinkai/image-resize-mcp

Version:

MCP server for image resizing

320 lines (319 loc) 11.2 kB
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import fetch from "node-fetch"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import fs from "fs"; import sharp from "sharp"; import { z } from "zod"; const SUPPORTED_INPUT_FORMATS = ["jpeg", "jpg", "png", "webp", "avif", "tiff", "gif"]; const SUPPORTED_OUTPUT_FORMATS = ["jpeg", "png", "webp", "avif"]; const DEFAULT_QUALITY = 80; const DEFAULT_WIDTH = 800; const DEFAULT_HEIGHT = 600; const VERSION = "0.0.1"; function isValidInputFormat(format) { return SUPPORTED_INPUT_FORMATS.includes(format.toLowerCase()); } function isValidOutputFormat(format) { return SUPPORTED_OUTPUT_FORMATS.includes(format.toLowerCase()); } function isValidDimensions(width, height) { if (width !== void 0 && (width <= 0 || width > 1e4)) { return false; } if (height !== void 0 && (height <= 0 || height > 1e4)) { return false; } return true; } function isValidQuality(quality) { if (quality !== void 0 && (quality < 1 || quality > 100)) { return false; } return true; } function getFileExtension(filename) { const parts = filename.split("."); return parts.length > 1 ? parts.pop().toLowerCase() : ""; } function base64ToBuffer(base64) { const base64Data = base64.replace(/^data:image\/\w+;base64,/, ""); return Buffer.from(base64Data, "base64"); } function bufferToBase64(buffer, mimeType) { return `data:${mimeType};base64,${buffer.toString("base64")}`; } function normalizeFilePath(filePath) { let normalizedPath = filePath.replace(/\\ /g, " "); normalizedPath = normalizedPath.replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\`/g, "`").replace(/\\\(/g, "(").replace(/\\\)/g, ")").replace(/\\\[/g, "[").replace(/\\\]/g, "]").replace(/\\\{/g, "{").replace(/\\\}/g, "}"); return normalizedPath; } async function fetchImageFromUrl(url) { try { const response = await fetch(url); if (!response.ok) { throw new McpError( ErrorCode.InvalidParams, `Failed to fetch image from URL: ${url}, status code: ${response.status}` ); } const contentType = response.headers.get("content-type"); if (!contentType || !contentType.startsWith("image/")) { throw new McpError( ErrorCode.InvalidParams, `URL does not point to an image: ${url}, content-type: ${contentType}` ); } const arrayBuffer = await response.arrayBuffer(); return Buffer.from(arrayBuffer); } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError( ErrorCode.InternalError, `Error fetching image from URL: ${error instanceof Error ? error.message : String(error)}` ); } } const resizeImageSchema = { imagePath: z.string({ description: "Path to image" }).optional(), imageUrl: z.string({ description: "URL to image" }).optional(), base64Image: z.string({ description: "Base64-encoded image data (with or without data URL prefix)" }).optional(), format: z.enum(SUPPORTED_OUTPUT_FORMATS).optional(), width: z.number().min(1).max(1e4).optional(), height: z.number().min(1).max(1e4).optional(), quality: z.number().min(1).max(100).optional(), fit: z.enum(["cover", "contain", "fill", "inside", "outside"]).optional(), position: z.enum(["centre", "center", "north", "east", "south", "west", "northeast", "southeast", "southwest", "northwest"]).optional(), background: z.string().optional(), withoutEnlargement: z.boolean().optional(), withoutReduction: z.boolean().optional(), rotate: z.number().optional(), flip: z.boolean().optional(), flop: z.boolean().optional(), grayscale: z.boolean().optional(), blur: z.number().min(0.3).max(1e3).optional(), sharpen: z.number().min(0.3).max(1e3).optional(), gamma: z.number().min(1).max(3).optional(), negate: z.boolean().optional(), normalize: z.boolean().optional(), threshold: z.number().min(0).max(255).optional(), trim: z.boolean().optional(), outputPath: z.string({ description: "Path to save the resized image (if not provided, image will only be returned as base64)" }).optional() }; class ImageResizeMcpServer { constructor() { this.server = new McpServer({ name: "image-resize-mcp-server", version: VERSION }); this.setupToolHandlers(); process.on("SIGINT", async () => { await this.server.close(); process.exit(0); }); } setupToolHandlers() { this.server.tool("resize_image", "Resize and transform images", resizeImageSchema, async (validatedArgs) => { try { if (!validatedArgs.imagePath && !validatedArgs.imageUrl && !validatedArgs.base64Image) { throw new McpError(ErrorCode.InvalidParams, "One of imagePath, imageUrl, or base64Image must be provided"); } let inputBuffer; if (validatedArgs.imagePath) { try { const normalizedPath = normalizeFilePath(validatedArgs.imagePath); inputBuffer = fs.readFileSync(normalizedPath); } catch (error) { throw new McpError( ErrorCode.InvalidParams, `Failed to read image from path: ${validatedArgs.imagePath}. ${error instanceof Error ? error.message : String(error)}` ); } } else if (validatedArgs.imageUrl) { inputBuffer = await fetchImageFromUrl(validatedArgs.imageUrl); } else if (validatedArgs.base64Image) { inputBuffer = base64ToBuffer(validatedArgs.base64Image); } else { throw new McpError(ErrorCode.InvalidParams, "One of imagePath, imageUrl, or base64Image must be provided"); } let image = sharp(inputBuffer); const metadata = await image.metadata(); const inputFormat = metadata.format; if (!inputFormat || !isValidInputFormat(inputFormat)) { throw new McpError(ErrorCode.InvalidParams, `Unsupported input format: ${inputFormat}`); } let width = validatedArgs.width; let height = validatedArgs.height; let fit = validatedArgs.fit; if (width && !height) { height = void 0; } else if (!width && height) { width = void 0; } else { width = width || DEFAULT_WIDTH; height = height || DEFAULT_HEIGHT; if (!fit && width && height) { fit = "contain"; } } image = image.resize({ width, height, fit, position: validatedArgs.position, background: validatedArgs.background, withoutEnlargement: validatedArgs.withoutEnlargement, withoutReduction: validatedArgs.withoutReduction }); if (validatedArgs.rotate) { image = image.rotate(validatedArgs.rotate); } if (validatedArgs.flip) { image = image.flip(); } if (validatedArgs.flop) { image = image.flop(); } if (validatedArgs.grayscale) { image = image.grayscale(); } if (validatedArgs.blur) { image = image.blur(validatedArgs.blur); } if (validatedArgs.sharpen) { image = image.sharpen(validatedArgs.sharpen); } if (validatedArgs.gamma) { image = image.gamma(validatedArgs.gamma); } if (validatedArgs.negate) { image = image.negate(); } if (validatedArgs.normalize) { image = image.normalize(); } if (validatedArgs.threshold) { image = image.threshold(validatedArgs.threshold); } if (validatedArgs.trim) { image = image.trim(); } const outputFormat = validatedArgs.format || inputFormat; const quality = validatedArgs.quality || DEFAULT_QUALITY; let outputBuffer; let mimeType; switch (outputFormat) { case "jpeg": case "jpg": outputBuffer = await image.jpeg({ quality }).toBuffer(); mimeType = "image/jpeg"; break; case "png": outputBuffer = await image.png({ quality }).toBuffer(); mimeType = "image/png"; break; case "webp": outputBuffer = await image.webp({ quality }).toBuffer(); mimeType = "image/webp"; break; case "avif": outputBuffer = await image.avif({ quality }).toBuffer(); mimeType = "image/avif"; break; default: throw new McpError(ErrorCode.InvalidParams, `Unsupported output format: ${outputFormat}`); } if (validatedArgs.outputPath) { try { const normalizedOutputPath = normalizeFilePath(validatedArgs.outputPath); fs.writeFileSync(normalizedOutputPath, outputBuffer); } catch (writeError) { throw new McpError( ErrorCode.InternalError, `Failed to save image to ${validatedArgs.outputPath}: ${writeError instanceof Error ? writeError.message : String(writeError)}` ); } } const outputBase64 = bufferToBase64(outputBuffer, mimeType); const finalMetadata = await sharp(outputBuffer).metadata(); return { content: [ { type: "text", text: JSON.stringify( { image: outputBase64, format: outputFormat, width: finalMetadata.width, height: finalMetadata.height, size: outputBuffer.length, savedTo: validatedArgs.outputPath || null, source: validatedArgs.imagePath ? "file" : validatedArgs.imageUrl ? "url" : "base64" }, null, 2 ) } ] }; } catch (error) { if (error instanceof z.ZodError) { return { content: [ { type: "text", text: `Validation error: ${JSON.stringify(error.format(), null, 2)}` } ], isError: true }; } if (error instanceof McpError) { throw error; } return { content: [ { type: "text", text: `Error processing image: ${error instanceof Error ? error.message : String(error)}` } ], isError: true }; } }); } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error("Image Resize MCP server running on stdio"); } } export { DEFAULT_HEIGHT, DEFAULT_QUALITY, DEFAULT_WIDTH, ImageResizeMcpServer, SUPPORTED_INPUT_FORMATS, SUPPORTED_OUTPUT_FORMATS, VERSION, base64ToBuffer, bufferToBase64, fetchImageFromUrl, getFileExtension, isValidDimensions, isValidInputFormat, isValidOutputFormat, isValidQuality, normalizeFilePath };