UNPKG

@boomlinkai/image-resize-mcp

Version:

MCP server for image resizing

320 lines (319 loc) 11.9 kB
"use strict"; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); const types_js = require("@modelcontextprotocol/sdk/types.js"); const fetch = require("node-fetch"); const mcp_js = require("@modelcontextprotocol/sdk/server/mcp.js"); const stdio_js = require("@modelcontextprotocol/sdk/server/stdio.js"); const fs = require("fs"); const sharp = require("sharp"); const zod = require("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 types_js.McpError( types_js.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 types_js.McpError( types_js.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 types_js.McpError) { throw error; } throw new types_js.McpError( types_js.ErrorCode.InternalError, `Error fetching image from URL: ${error instanceof Error ? error.message : String(error)}` ); } } const resizeImageSchema = { imagePath: zod.z.string({ description: "Path to image" }).optional(), imageUrl: zod.z.string({ description: "URL to image" }).optional(), base64Image: zod.z.string({ description: "Base64-encoded image data (with or without data URL prefix)" }).optional(), format: zod.z.enum(SUPPORTED_OUTPUT_FORMATS).optional(), width: zod.z.number().min(1).max(1e4).optional(), height: zod.z.number().min(1).max(1e4).optional(), quality: zod.z.number().min(1).max(100).optional(), fit: zod.z.enum(["cover", "contain", "fill", "inside", "outside"]).optional(), position: zod.z.enum(["centre", "center", "north", "east", "south", "west", "northeast", "southeast", "southwest", "northwest"]).optional(), background: zod.z.string().optional(), withoutEnlargement: zod.z.boolean().optional(), withoutReduction: zod.z.boolean().optional(), rotate: zod.z.number().optional(), flip: zod.z.boolean().optional(), flop: zod.z.boolean().optional(), grayscale: zod.z.boolean().optional(), blur: zod.z.number().min(0.3).max(1e3).optional(), sharpen: zod.z.number().min(0.3).max(1e3).optional(), gamma: zod.z.number().min(1).max(3).optional(), negate: zod.z.boolean().optional(), normalize: zod.z.boolean().optional(), threshold: zod.z.number().min(0).max(255).optional(), trim: zod.z.boolean().optional(), outputPath: zod.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 mcp_js.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 types_js.McpError(types_js.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 types_js.McpError( types_js.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 types_js.McpError(types_js.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 types_js.McpError(types_js.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 types_js.McpError(types_js.ErrorCode.InvalidParams, `Unsupported output format: ${outputFormat}`); } if (validatedArgs.outputPath) { try { const normalizedOutputPath = normalizeFilePath(validatedArgs.outputPath); fs.writeFileSync(normalizedOutputPath, outputBuffer); } catch (writeError) { throw new types_js.McpError( types_js.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 zod.z.ZodError) { return { content: [ { type: "text", text: `Validation error: ${JSON.stringify(error.format(), null, 2)}` } ], isError: true }; } if (error instanceof types_js.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 stdio_js.StdioServerTransport(); await this.server.connect(transport); console.error("Image Resize MCP server running on stdio"); } } exports.DEFAULT_HEIGHT = DEFAULT_HEIGHT; exports.DEFAULT_QUALITY = DEFAULT_QUALITY; exports.DEFAULT_WIDTH = DEFAULT_WIDTH; exports.ImageResizeMcpServer = ImageResizeMcpServer; exports.SUPPORTED_INPUT_FORMATS = SUPPORTED_INPUT_FORMATS; exports.SUPPORTED_OUTPUT_FORMATS = SUPPORTED_OUTPUT_FORMATS; exports.VERSION = VERSION; exports.base64ToBuffer = base64ToBuffer; exports.bufferToBase64 = bufferToBase64; exports.fetchImageFromUrl = fetchImageFromUrl; exports.getFileExtension = getFileExtension; exports.isValidDimensions = isValidDimensions; exports.isValidInputFormat = isValidInputFormat; exports.isValidOutputFormat = isValidOutputFormat; exports.isValidQuality = isValidQuality; exports.normalizeFilePath = normalizeFilePath;