UNPKG

@boomlinkai/image-worker-mcp

Version:

MCP server for image worker - Resize, transform, etc...

933 lines (932 loc) 36.2 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __publicField = (obj, key, value) => { __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); return value; }; 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 zod = require("zod"); const fs = require("fs"); const sharp = require("sharp"); const libheif = require("libheif-js/wasm-bundle.js"); const clientS3 = require("@aws-sdk/client-s3"); const storage = require("@google-cloud/storage"); const SUPPORTED_INPUT_FORMATS = ["jpeg", "jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif"]; const SUPPORTED_OUTPUT_FORMATS = ["jpeg", "png", "webp", "avif"]; const DEFAULT_QUALITY = 80; const DEFAULT_WIDTH = 800; const DEFAULT_HEIGHT = 600; const SUPPORTED_UPLOAD_SERVICES = ["s3", "cloudflare", "gcloud"]; const DEFAULT_UPLOAD_SERVICE = "s3"; const VERSION = "0.0.6"; 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(new RegExp("\\\\+'", "g"), "'").replace(new RegExp('\\\\+"', "g"), '"').replace(new RegExp("\\\\+`", "g"), "`").replace(new RegExp("\\\\+\\(", "g"), "(").replace(new RegExp("\\\\+\\)", "g"), ")").replace(new RegExp("\\\\+\\[", "g"), "[").replace(new RegExp("\\\\+\\]", "g"), "]").replace(new RegExp("\\\\+\\{", "g"), "{").replace(new RegExp("\\\\+\\}", "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().describe("Output image format"), width: zod.z.number().min(1).max(1e4).optional().describe("Width of the resized image in pixels"), height: zod.z.number().min(1).max(1e4).optional().describe("Height of the resized image in pixels"), quality: zod.z.number().min(1).max(100).optional().describe("Quality of the output image (1-100)"), fit: zod.z.enum(["cover", "contain", "fill", "inside", "outside"]).optional().describe("How the image should be resized to fit both provided dimensions"), position: zod.z.enum(["top", "right top", "right", "right bottom", "bottom", "left bottom", "left", "left top"]).optional().describe("Position when using fit 'cover' or 'contain'"), background: zod.z.string().optional().describe("Background color when using fit 'contain' or 'cover', or when extending. Accepts hex, rgb, rgba, or CSS color names"), withoutEnlargement: zod.z.boolean().optional().describe("Do not enlarge if the width or height are already less than the specified dimensions"), withoutReduction: zod.z.boolean().optional().describe("Do not reduce if the width or height are already greater than the specified dimensions"), rotate: zod.z.number().optional().describe("Angle of rotation (positive for clockwise, negative for counter-clockwise)"), flip: zod.z.boolean().optional().describe("Flip the image vertically"), flop: zod.z.boolean().optional().describe("Flop the image horizontally"), grayscale: zod.z.boolean().optional().describe("Convert the image to grayscale"), blur: zod.z.number().min(0.3).max(1e3).optional().describe("Apply a Gaussian blur. Value is the sigma of the Gaussian kernel (0.3-1000)"), sharpen: zod.z.number().min(0.3).max(1e3).optional().describe("Apply a sharpening. Value is the sigma of the Gaussian kernel (0.3-1000)"), gamma: zod.z.number().min(1).max(3).optional().describe("Apply gamma correction (1.0-3.0)"), negate: zod.z.boolean().optional().describe("Produce a negative of the image"), normalize: zod.z.boolean().optional().describe("Enhance image contrast by stretching its intensity levels"), threshold: zod.z.number().min(0).max(255).optional().describe("Apply a threshold to the image, turning pixels above the threshold white and below black (0-255)"), trim: zod.z.boolean().optional().describe("Trim 'boring' pixels from all edges that contain values similar to the top-left pixel"), outputImage: zod.z.boolean().optional().default(false).describe("Whether to include the base64-encoded image in the output response"), outputPath: zod.z.string({ description: "Path to save the resized image (if not provided, image will only be returned as base64)" }).optional() }; class ImageProcessor { constructor(validatedArgs) { __publicField(this, "args"); __publicField(this, "inputFormat"); this.args = validatedArgs; } async getInputBuffer() { if (!this.args.imagePath && !this.args.imageUrl && !this.args.base64Image) { throw new types_js.McpError(types_js.ErrorCode.InvalidParams, "One of imagePath, imageUrl, or base64Image must be provided"); } let inputBuffer; if (this.args.imagePath) { try { const normalizedPath = normalizeFilePath(this.args.imagePath); inputBuffer = fs.readFileSync(normalizedPath); } catch (error) { throw new types_js.McpError( types_js.ErrorCode.InvalidParams, `Failed to read image from path: ${this.args.imagePath}. ${error instanceof Error ? error.message : String(error)}` ); } } else if (this.args.imageUrl) { inputBuffer = await fetchImageFromUrl(this.args.imageUrl); } else if (this.args.base64Image) { inputBuffer = base64ToBuffer(this.args.base64Image); } else { throw new types_js.McpError(types_js.ErrorCode.InternalError, "No image source provided despite initial validation."); } return inputBuffer; } isHeif(buffer) { const signature = buffer.toString("ascii", 4, 12); return ["ftypheic", "ftypheix", "ftyphevc", "ftyphevx", "ftypmif1", "ftypmsf1"].some((s) => signature.includes(s)); } async validateAndInitializeSharp(inputBuffer) { if (this.isHeif(inputBuffer)) { try { const decoder = new libheif.HeifDecoder(); const decodedImages = decoder.decode(inputBuffer); if (!decodedImages || decodedImages.length === 0) { throw new Error("HEIF decoding failed or produced no images."); } const heifImage = decodedImages[0]; const width = heifImage.get_width(); const height = heifImage.get_height(); const imageData = await new Promise((resolve, reject) => { heifImage.display({ data: new Uint8ClampedArray(width * height * 4), width, height }, (displayData) => { if (!displayData) { return reject(new Error("HEIF processing error")); } resolve(displayData); }); }); const { data } = imageData; const pixelBuffer = Buffer.from(data); this.inputFormat = "heic"; return sharp(pixelBuffer, { raw: { width, height, channels: 4 // Assuming RGBA, common for HEIF decoders } }); } catch (error) { throw new types_js.McpError( types_js.ErrorCode.InvalidParams, `Failed to decode HEIF image: ${error instanceof Error ? error.message : String(error)}` ); } } else { const image = sharp(inputBuffer); const metadata = await image.metadata(); this.inputFormat = metadata.format; if (!this.inputFormat || !isValidInputFormat(this.inputFormat)) { throw new types_js.McpError(types_js.ErrorCode.InvalidParams, `Unsupported input format: ${this.inputFormat}`); } return image; } } applyResize(image) { let width = this.args.width; let height = this.args.height; let fit = this.args.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"; } } return image.resize({ width, height, fit, position: this.args.position, background: this.args.background, withoutEnlargement: this.args.withoutEnlargement, withoutReduction: this.args.withoutReduction }); } applyTransformations(image) { let transformedImage = image; if (this.args.rotate) { transformedImage = transformedImage.rotate(this.args.rotate); } if (this.args.flip) { transformedImage = transformedImage.flip(); } if (this.args.flop) { transformedImage = transformedImage.flop(); } if (this.args.grayscale) { transformedImage = transformedImage.grayscale(); } if (this.args.blur) { transformedImage = transformedImage.blur(this.args.blur); } if (this.args.sharpen) { transformedImage = transformedImage.sharpen(this.args.sharpen); } if (this.args.gamma) { transformedImage = transformedImage.gamma(this.args.gamma); } if (this.args.negate) { transformedImage = transformedImage.negate(); } if (this.args.normalize) { transformedImage = transformedImage.normalize(); } if (this.args.threshold) { transformedImage = transformedImage.threshold(this.args.threshold); } if (this.args.trim) { transformedImage = transformedImage.trim(); } return transformedImage; } async formatOutput(image) { const outputFormat = this.args.format || this.inputFormat || "jpeg"; const quality = this.args.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}`); } return { outputBuffer, mimeType, outputFormat }; } async saveToFile(outputBuffer) { if (this.args.outputPath) { try { const normalizedOutputPath = normalizeFilePath(this.args.outputPath); fs.writeFileSync(normalizedOutputPath, outputBuffer); } catch (writeError) { throw new types_js.McpError( types_js.ErrorCode.InternalError, `Failed to save image to ${this.args.outputPath}: ${writeError instanceof Error ? writeError.message : String(writeError)}` ); } } } async exec() { try { const inputBuffer = await this.getInputBuffer(); let image = await this.validateAndInitializeSharp(inputBuffer); image = this.applyResize(image); image = this.applyTransformations(image); const { outputBuffer, mimeType, outputFormat } = await this.formatOutput(image); await this.saveToFile(outputBuffer); const outputBase64 = bufferToBase64(outputBuffer, mimeType); const finalMetadata = await sharp(outputBuffer).metadata(); return { content: [ { type: "text", text: JSON.stringify( { ...this.args.outputImage ? { image: outputBase64 } : {}, format: outputFormat, width: finalMetadata.width, height: finalMetadata.height, size: outputBuffer.length, savedTo: this.args.outputPath || null, source: this.args.imagePath ? "file" : this.args.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 function resizeImageTool(validatedArgs) { const processor = new ImageProcessor(validatedArgs); return processor.exec(); } const uploadImageSchema = { imagePath: zod.z.string({ description: "Path to image file to upload" }).optional(), imageUrl: zod.z.string({ description: "URL to image to download and upload" }).optional(), base64Image: zod.z.string({ description: "Base64-encoded image data (with or without data URL prefix)" }).optional(), filename: zod.z.string().optional().describe("Custom filename for the uploaded image (without extension)"), folder: zod.z.string().optional().describe("Folder/directory to upload to (service-specific)"), public: zod.z.boolean().optional().default(true).describe("Whether the uploaded image should be publicly accessible"), overwrite: zod.z.boolean().optional().default(false).describe("Whether to overwrite existing files with the same name"), tags: zod.z.array(zod.z.string()).optional().describe("Tags to associate with the uploaded image (service-specific)"), metadata: zod.z.record(zod.z.string()).optional().describe("Additional metadata to store with the image (service-specific)") }; class UploadTool { constructor(args, uploadService) { __publicField(this, "args"); __publicField(this, "uploadService"); this.args = args; this.uploadService = uploadService; } async getInputBuffer() { var _a; if (!this.args.imagePath && !this.args.imageUrl && !this.args.base64Image) { throw new types_js.McpError(types_js.ErrorCode.InvalidParams, "One of imagePath, imageUrl, or base64Image must be provided"); } let buffer; let originalFilename; if (this.args.imagePath) { try { const normalizedPath = normalizeFilePath(this.args.imagePath); buffer = fs.readFileSync(normalizedPath); originalFilename = normalizedPath.split("/").pop(); } catch (error) { throw new types_js.McpError( types_js.ErrorCode.InvalidParams, `Failed to read image from path: ${this.args.imagePath}. ${error instanceof Error ? error.message : String(error)}` ); } } else if (this.args.imageUrl) { buffer = await fetchImageFromUrl(this.args.imageUrl); originalFilename = (_a = this.args.imageUrl.split("/").pop()) == null ? void 0 : _a.split("?")[0]; } else if (this.args.base64Image) { buffer = base64ToBuffer(this.args.base64Image); originalFilename = "image"; } else { throw new types_js.McpError(types_js.ErrorCode.InternalError, "No image source provided despite initial validation."); } return { buffer, originalFilename }; } generateFilename(originalFilename) { if (this.args.filename) { const hasExtension = this.args.filename.includes("."); if (hasExtension) { return this.args.filename; } else { const extension = (originalFilename == null ? void 0 : originalFilename.split(".").pop()) || "jpg"; return `${this.args.filename}.${extension}`; } } if (originalFilename) { return originalFilename; } const timestamp = Date.now(); const randomSuffix = Math.random().toString(36).substring(2, 8); return `image_${timestamp}_${randomSuffix}.jpg`; } async exec() { try { const { buffer, originalFilename } = await this.getInputBuffer(); const filename = this.generateFilename(originalFilename); const result = await this.uploadService.upload(buffer, filename, this.args); return { content: [ { type: "text", text: JSON.stringify( { success: true, url: result.url, filename: result.filename, size: result.size, format: result.format, service: result.service, ...result.width && { width: result.width }, ...result.height && { height: result.height }, ...result.publicId && { publicId: result.publicId }, ...result.metadata && { metadata: result.metadata }, uploadedAt: (/* @__PURE__ */ new Date()).toISOString() }, 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 uploading image: ${error instanceof Error ? error.message : String(error)}` }], isError: true }; } } } class BaseUploadService { constructor(config) { __publicField(this, "config"); this.config = config; } } const S3EnvConfigSchema = zod.z.object({ UPLOAD_SERVICE: zod.z.literal("s3").optional().default("s3"), S3_BUCKET: zod.z.string().min(1, "S3_BUCKET is required and cannot be empty"), AWS_ACCESS_KEY_ID: zod.z.string().optional(), AWS_SECRET_ACCESS_KEY: zod.z.string().optional(), S3_REGION: zod.z.string().optional(), S3_ENDPOINT: zod.z.string().url("S3_ENDPOINT must be a valid URL if provided").optional() }).refine((data) => data.AWS_ACCESS_KEY_ID && data.AWS_SECRET_ACCESS_KEY || !data.AWS_ACCESS_KEY_ID && !data.AWS_SECRET_ACCESS_KEY, { message: "If AWS_ACCESS_KEY_ID is provided, AWS_SECRET_ACCESS_KEY must also be provided, and vice-versa.", path: ["AWS_ACCESS_KEY_ID"] }); class S3UploadService extends BaseUploadService { constructor(validatedEnvConfig) { const serviceConfig = { service: "s3", // Explicitly set bucket: validatedEnvConfig.S3_BUCKET, apiKey: validatedEnvConfig.AWS_ACCESS_KEY_ID, apiSecret: validatedEnvConfig.AWS_SECRET_ACCESS_KEY, region: validatedEnvConfig.S3_REGION, endpoint: validatedEnvConfig.S3_ENDPOINT }; super(serviceConfig); __publicField(this, "s3Client"); const s3ClientParams = { region: this.config.region || "us-east-1", ...this.config.endpoint && { endpoint: this.config.endpoint } }; if (this.config.apiKey && this.config.apiSecret) { s3ClientParams.credentials = { accessKeyId: this.config.apiKey, secretAccessKey: this.config.apiSecret }; } this.s3Client = new clientS3.S3Client(s3ClientParams); } async upload(buffer, filename, args) { var _a; try { const key = args.folder ? `${args.folder}/${filename}` : filename; const parts = filename.split("."); const fileExtension = parts.length > 1 ? ((_a = parts.pop()) == null ? void 0 : _a.toLowerCase()) || "jpg" : "jpg"; const contentType = this.getContentType(fileExtension); if (!args.overwrite) { try { const { HeadObjectCommand } = await import("@aws-sdk/client-s3"); const headCommand = new HeadObjectCommand({ Bucket: this.config.bucket, Key: key }); await this.s3Client.send(headCommand); throw new types_js.McpError( types_js.ErrorCode.InvalidParams, `File ${key} already exists. Set overwrite=true to replace it.` ); } catch (error) { if (error.name !== "NotFound" && error.name !== "NoSuchKey") { throw error; } } } const putCommand = new clientS3.PutObjectCommand({ Bucket: this.config.bucket, Key: key, Body: buffer, ContentType: contentType, ACL: args.public ? "public-read" : "private", Metadata: args.metadata || {}, ...args.tags && args.tags.length > 0 && { Tagging: args.tags.map((tag) => `tag=${encodeURIComponent(tag)}`).join("&") } }); const result = await this.s3Client.send(putCommand); const url = this.generateUrl(key); return { url, filename, size: buffer.length, format: fileExtension, service: "s3", metadata: { bucket: this.config.bucket, key, region: this.config.region, etag: result.ETag, versionId: result.VersionId } }; } catch (error) { if (error instanceof types_js.McpError) { throw error; } throw new types_js.McpError( types_js.ErrorCode.InternalError, `S3 upload failed: ${error instanceof Error ? error.message : String(error)}` ); } } getContentType(extension) { const mimeTypes = { "jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp", "avif": "image/avif", "tiff": "image/tiff", "heic": "image/heic", "heif": "image/heif" }; return mimeTypes[extension] || "application/octet-stream"; } generateUrl(key) { if (this.config.endpoint) { return `${this.config.endpoint}/${this.config.bucket}/${key}`; } else { return `https://${this.config.bucket}.s3.${this.config.region}.amazonaws.com/${key}`; } } } const CloudflareEnvConfigSchema = zod.z.object({ UPLOAD_SERVICE: zod.z.literal("cloudflare").optional().default("cloudflare"), CLOUDFLARE_R2_BUCKET: zod.z.string().min(1, "CLOUDFLARE_R2_BUCKET is required"), CLOUDFLARE_R2_ACCESS_KEY_ID: zod.z.string().min(1, "CLOUDFLARE_R2_ACCESS_KEY_ID is required"), CLOUDFLARE_R2_SECRET_ACCESS_KEY: zod.z.string().min(1, "CLOUDFLARE_R2_SECRET_ACCESS_KEY is required"), CLOUDFLARE_R2_REGION: zod.z.string().optional(), CLOUDFLARE_R2_ENDPOINT: zod.z.string().url("CLOUDFLARE_R2_ENDPOINT must be a valid URL") }); class CloudflareUploadService extends BaseUploadService { constructor(validatedEnvConfig) { const serviceConfig = { service: "cloudflare", // Explicitly set bucket: validatedEnvConfig.CLOUDFLARE_R2_BUCKET, apiKey: validatedEnvConfig.CLOUDFLARE_R2_ACCESS_KEY_ID, apiSecret: validatedEnvConfig.CLOUDFLARE_R2_SECRET_ACCESS_KEY, region: validatedEnvConfig.CLOUDFLARE_R2_REGION || "auto", endpoint: validatedEnvConfig.CLOUDFLARE_R2_ENDPOINT }; super(serviceConfig); __publicField(this, "s3Client"); this.s3Client = new clientS3.S3Client({ region: this.config.region, credentials: { accessKeyId: this.config.apiKey, secretAccessKey: this.config.apiSecret }, endpoint: this.config.endpoint, forcePathStyle: true }); } async upload(buffer, filename, args) { var _a; try { const key = args.folder ? `${args.folder}/${filename}` : filename; const parts = filename.split("."); const fileExtension = parts.length > 1 ? ((_a = parts.pop()) == null ? void 0 : _a.toLowerCase()) || "jpg" : "jpg"; const contentType = this.getContentType(fileExtension); if (!args.overwrite) { try { const headCommand = new clientS3.HeadObjectCommand({ Bucket: this.config.bucket, Key: key }); await this.s3Client.send(headCommand); throw new types_js.McpError( types_js.ErrorCode.InvalidParams, `File ${key} already exists. Set overwrite=true to replace it.` ); } catch (error) { if (error.name !== "NotFound" && error.name !== "NoSuchKey") { throw error; } } } const putCommand = new clientS3.PutObjectCommand({ Bucket: this.config.bucket, Key: key, Body: buffer, ContentType: contentType, Metadata: args.metadata || {} // Note: R2 doesn't support ACL in the same way as S3 // Public access is controlled via bucket settings or custom domains }); const result = await this.s3Client.send(putCommand); const url = this.generateUrl(key); return { url, filename, size: buffer.length, format: fileExtension, service: "cloudflare", metadata: { bucket: this.config.bucket, key, endpoint: this.config.endpoint, etag: result.ETag, versionId: result.VersionId } }; } catch (error) { if (error instanceof types_js.McpError) { throw error; } throw new types_js.McpError( types_js.ErrorCode.InternalError, `Cloudflare R2 upload failed: ${error instanceof Error ? error.message : String(error)}` ); } } getContentType(extension) { const mimeTypes = { "jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp", "avif": "image/avif", "tiff": "image/tiff", "heic": "image/heic", "heif": "image/heif" }; return mimeTypes[extension] || "application/octet-stream"; } generateUrl(key) { if (this.config.baseUrl) { return `${this.config.baseUrl}/${key}`; } return `${this.config.endpoint}/${this.config.bucket}/${key}`; } } const GCloudEnvConfigSchema = zod.z.object({ UPLOAD_SERVICE: zod.z.literal("gcloud").optional().default("gcloud"), GCLOUD_BUCKET: zod.z.string().min(1, "GCLOUD_BUCKET is required"), GCLOUD_PROJECT_ID: zod.z.string().min(1, "GCLOUD_PROJECT_ID is required"), GCLOUD_CREDENTIALS_PATH: zod.z.string().optional() }); class GCloudUploadService extends BaseUploadService { constructor(validatedEnvConfig) { const serviceConfig = { service: "gcloud", bucket: validatedEnvConfig.GCLOUD_BUCKET, projectId: validatedEnvConfig.GCLOUD_PROJECT_ID // clientEmail: validatedEnvConfig.GCLOUD_CLIENT_EMAIL, // privateKey: validatedEnvConfig.GCLOUD_PRIVATE_KEY, }; super(serviceConfig); __publicField(this, "storage"); __publicField(this, "bucket"); const storageOptions = { projectId: this.config.projectId }; if (validatedEnvConfig.GCLOUD_CREDENTIALS_PATH) { storageOptions.keyFilename = validatedEnvConfig.GCLOUD_CREDENTIALS_PATH; } this.storage = new storage.Storage(storageOptions); this.bucket = this.storage.bucket(this.config.bucket); } async upload(buffer, filename, args) { var _a; try { const key = args.folder ? `${args.folder}/${filename}` : filename; const parts = filename.split("."); const fileExtension = parts.length > 1 ? ((_a = parts.pop()) == null ? void 0 : _a.toLowerCase()) || "jpg" : "jpg"; const contentType = this.getContentType(fileExtension); const file = this.bucket.file(key); if (!args.overwrite) { const exists = await file.exists(); if (exists[0]) { throw new types_js.McpError( types_js.ErrorCode.InvalidParams, `File ${key} already exists. Set overwrite=true to replace it.` ); } } await file.save(buffer, { contentType, metadata: args.metadata || {}, public: args.public }); const url = this.generateUrl(key); return { url, filename, size: buffer.length, format: fileExtension, service: "gcloud", metadata: { bucket: this.config.bucket, key, projectId: this.config.projectId } }; } catch (error) { if (error instanceof types_js.McpError) { throw error; } throw new types_js.McpError( types_js.ErrorCode.InternalError, `GCloud upload failed: ${error instanceof Error ? error.message : String(error)}` ); } } getContentType(extension) { const mimeTypes = { "jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp", "avif": "image/avif", "tiff": "image/tiff", "heic": "image/heic", "heif": "image/heif" }; return mimeTypes[extension] || "application/octet-stream"; } generateUrl(key) { return `https://storage.googleapis.com/${this.config.bucket}/${key}`; } } function loadUploadConfig(service) { const selectedService = service || process.env.UPLOAD_SERVICE || "s3"; if (selectedService === "s3") { const s3RawConfig = S3EnvConfigSchema.parse({ UPLOAD_SERVICE: "s3", S3_BUCKET: process.env.S3_BUCKET, AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY, S3_REGION: process.env.S3_REGION || "us-east-1", S3_ENDPOINT: process.env.S3_ENDPOINT }); return s3RawConfig; } else if (selectedService === "cloudflare") { const cfRawConfig = CloudflareEnvConfigSchema.parse({ UPLOAD_SERVICE: "cloudflare", CLOUDFLARE_R2_BUCKET: process.env.CLOUDFLARE_R2_BUCKET, CLOUDFLARE_R2_ACCESS_KEY_ID: process.env.CLOUDFLARE_R2_ACCESS_KEY_ID, CLOUDFLARE_R2_SECRET_ACCESS_KEY: process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY, CLOUDFLARE_R2_REGION: process.env.CLOUDFLARE_R2_REGION || "auto", CLOUDFLARE_R2_ENDPOINT: process.env.CLOUDFLARE_R2_ENDPOINT }); return cfRawConfig; } else if (selectedService === "gcloud") { const gcloudRawConfig = GCloudEnvConfigSchema.parse({ UPLOAD_SERVICE: "gcloud", GCLOUD_BUCKET: process.env.GCLOUD_BUCKET, GCLOUD_PROJECT_ID: process.env.GCLOUD_PROJECT_ID, GCLOUD_CREDENTIALS_PATH: process.env.GCLOUD_CREDENTIALS_PATH }); return gcloudRawConfig; } else { throw new types_js.McpError(types_js.ErrorCode.InvalidParams, `Unsupported upload service`); } } class UploadServiceFactory { static create(service) { try { const config = loadUploadConfig(service); switch (config.UPLOAD_SERVICE) { case "s3": return new S3UploadService(config); case "cloudflare": return new CloudflareUploadService(config); case "gcloud": return new GCloudUploadService(config); default: throw new types_js.McpError(types_js.ErrorCode.InvalidParams, `Unsupported upload service`); } } catch (error) { if (error instanceof zod.z.ZodError) { const errorMessage = error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join("; "); throw new types_js.McpError( types_js.ErrorCode.InvalidParams, `S3 configuration validation failed: ${errorMessage}` ); } throw error; } } } class ImageWorkerMcpServer { constructor() { __publicField(this, "server"); __publicField(this, "uploadService"); this.server = new mcp_js.McpServer({ name: "image-worker-mcp-server", version: VERSION }); this.uploadService = UploadServiceFactory.create(); this.setupToolHandlers(); process.on("SIGINT", async () => { await this.server.close(); process.exit(0); }); } setupToolHandlers() { this.server.tool("resize_image", "Resize and transform images", resizeImageSchema, resizeImageTool); this.server.tool( "upload_image", "Upload images to cloud storage services", uploadImageSchema, (args) => new UploadTool(args, this.uploadService).exec() ); } async run() { const transport = new stdio_js.StdioServerTransport(); await this.server.connect(transport); console.error("Image Worker MCP server running on stdio"); } } exports.DEFAULT_HEIGHT = DEFAULT_HEIGHT; exports.DEFAULT_QUALITY = DEFAULT_QUALITY; exports.DEFAULT_UPLOAD_SERVICE = DEFAULT_UPLOAD_SERVICE; exports.DEFAULT_WIDTH = DEFAULT_WIDTH; exports.ImageWorkerMcpServer = ImageWorkerMcpServer; exports.SUPPORTED_INPUT_FORMATS = SUPPORTED_INPUT_FORMATS; exports.SUPPORTED_OUTPUT_FORMATS = SUPPORTED_OUTPUT_FORMATS; exports.SUPPORTED_UPLOAD_SERVICES = SUPPORTED_UPLOAD_SERVICES; 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;