@boomlinkai/image-worker-mcp
Version:
MCP server for image worker - Resize, transform, etc...
912 lines (911 loc) • 33.9 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => {
__defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
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 { z } from "zod";
import fs from "fs";
import sharp from "sharp";
import libheif from "libheif-js/wasm-bundle.js";
import { S3Client, PutObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
import { Storage } from "@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 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().describe("Output image format"),
width: z.number().min(1).max(1e4).optional().describe("Width of the resized image in pixels"),
height: z.number().min(1).max(1e4).optional().describe("Height of the resized image in pixels"),
quality: z.number().min(1).max(100).optional().describe("Quality of the output image (1-100)"),
fit: z.enum(["cover", "contain", "fill", "inside", "outside"]).optional().describe("How the image should be resized to fit both provided dimensions"),
position: z.enum(["top", "right top", "right", "right bottom", "bottom", "left bottom", "left", "left top"]).optional().describe("Position when using fit 'cover' or 'contain'"),
background: z.string().optional().describe("Background color when using fit 'contain' or 'cover', or when extending. Accepts hex, rgb, rgba, or CSS color names"),
withoutEnlargement: z.boolean().optional().describe("Do not enlarge if the width or height are already less than the specified dimensions"),
withoutReduction: z.boolean().optional().describe("Do not reduce if the width or height are already greater than the specified dimensions"),
rotate: z.number().optional().describe("Angle of rotation (positive for clockwise, negative for counter-clockwise)"),
flip: z.boolean().optional().describe("Flip the image vertically"),
flop: z.boolean().optional().describe("Flop the image horizontally"),
grayscale: z.boolean().optional().describe("Convert the image to grayscale"),
blur: 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: z.number().min(0.3).max(1e3).optional().describe("Apply a sharpening. Value is the sigma of the Gaussian kernel (0.3-1000)"),
gamma: z.number().min(1).max(3).optional().describe("Apply gamma correction (1.0-3.0)"),
negate: z.boolean().optional().describe("Produce a negative of the image"),
normalize: z.boolean().optional().describe("Enhance image contrast by stretching its intensity levels"),
threshold: 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: z.boolean().optional().describe("Trim 'boring' pixels from all edges that contain values similar to the top-left pixel"),
outputImage: z.boolean().optional().default(false).describe("Whether to include the base64-encoded image in the output response"),
outputPath: 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 McpError(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 McpError(
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 McpError(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 McpError(
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 McpError(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 McpError(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 McpError(
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 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 function resizeImageTool(validatedArgs) {
const processor = new ImageProcessor(validatedArgs);
return processor.exec();
}
const uploadImageSchema = {
imagePath: z.string({
description: "Path to image file to upload"
}).optional(),
imageUrl: z.string({
description: "URL to image to download and upload"
}).optional(),
base64Image: z.string({
description: "Base64-encoded image data (with or without data URL prefix)"
}).optional(),
filename: z.string().optional().describe("Custom filename for the uploaded image (without extension)"),
folder: z.string().optional().describe("Folder/directory to upload to (service-specific)"),
public: z.boolean().optional().default(true).describe("Whether the uploaded image should be publicly accessible"),
overwrite: z.boolean().optional().default(false).describe("Whether to overwrite existing files with the same name"),
tags: z.array(z.string()).optional().describe("Tags to associate with the uploaded image (service-specific)"),
metadata: z.record(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 McpError(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 McpError(
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 McpError(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 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 uploading image: ${error instanceof Error ? error.message : String(error)}` }],
isError: true
};
}
}
}
class BaseUploadService {
constructor(config) {
__publicField(this, "config");
this.config = config;
}
}
const S3EnvConfigSchema = z.object({
UPLOAD_SERVICE: z.literal("s3").optional().default("s3"),
S3_BUCKET: z.string().min(1, "S3_BUCKET is required and cannot be empty"),
AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(),
S3_REGION: z.string().optional(),
S3_ENDPOINT: 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 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: HeadObjectCommand2 } = await import("@aws-sdk/client-s3");
const headCommand = new HeadObjectCommand2({
Bucket: this.config.bucket,
Key: key
});
await this.s3Client.send(headCommand);
throw new McpError(
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 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 McpError) {
throw error;
}
throw new McpError(
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 = z.object({
UPLOAD_SERVICE: z.literal("cloudflare").optional().default("cloudflare"),
CLOUDFLARE_R2_BUCKET: z.string().min(1, "CLOUDFLARE_R2_BUCKET is required"),
CLOUDFLARE_R2_ACCESS_KEY_ID: z.string().min(1, "CLOUDFLARE_R2_ACCESS_KEY_ID is required"),
CLOUDFLARE_R2_SECRET_ACCESS_KEY: z.string().min(1, "CLOUDFLARE_R2_SECRET_ACCESS_KEY is required"),
CLOUDFLARE_R2_REGION: z.string().optional(),
CLOUDFLARE_R2_ENDPOINT: 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 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 HeadObjectCommand({
Bucket: this.config.bucket,
Key: key
});
await this.s3Client.send(headCommand);
throw new McpError(
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 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 McpError) {
throw error;
}
throw new McpError(
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 = z.object({
UPLOAD_SERVICE: z.literal("gcloud").optional().default("gcloud"),
GCLOUD_BUCKET: z.string().min(1, "GCLOUD_BUCKET is required"),
GCLOUD_PROJECT_ID: z.string().min(1, "GCLOUD_PROJECT_ID is required"),
GCLOUD_CREDENTIALS_PATH: 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(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 McpError(
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 McpError) {
throw error;
}
throw new McpError(
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 McpError(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 McpError(ErrorCode.InvalidParams, `Unsupported upload service`);
}
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessage = error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join("; ");
throw new McpError(
ErrorCode.InvalidParams,
`S3 configuration validation failed: ${errorMessage}`
);
}
throw error;
}
}
}
class ImageWorkerMcpServer {
constructor() {
__publicField(this, "server");
__publicField(this, "uploadService");
this.server = new 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 StdioServerTransport();
await this.server.connect(transport);
console.error("Image Worker MCP server running on stdio");
}
}
export {
DEFAULT_HEIGHT,
DEFAULT_QUALITY,
DEFAULT_UPLOAD_SERVICE,
DEFAULT_WIDTH,
ImageWorkerMcpServer,
SUPPORTED_INPUT_FORMATS,
SUPPORTED_OUTPUT_FORMATS,
SUPPORTED_UPLOAD_SERVICES,
VERSION,
base64ToBuffer,
bufferToBase64,
fetchImageFromUrl,
getFileExtension,
isValidDimensions,
isValidInputFormat,
isValidOutputFormat,
isValidQuality,
normalizeFilePath
};