image-utility.cs

Fast image resize/convert/compress, metadata stripping, and thumbnail generation for everyday assets.

images media utilities
View on GitHub

Available Tools

ImageResize

Resize an image with various fit modes

ImageConvert

Convert image to different format

Source Code

#:package [email protected]
#:package [email protected]
#:package [email protected]
#:property PublishAot=false
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
using System.ComponentModel;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.Processing;

var builder = Host.CreateApplicationBuilder(args);
builder.Logging.AddConsole(consoleLogOptions =>
{
    // Configure all logs to go to stderr
    consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace;
});

// Register the MCP server
builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly();

// Build and run the MCP Server Application
await builder.Build().RunAsync();

//====== TOOLS ======
public record ImageProcessResult(string Output, int Width, int Height);
public record ImageConvertResult(string Output);
public record ImageStripMetadataResult(string[] RemovedTags);

[McpServerToolType]
public static class ImageTools
{
         [McpServerTool, Description("Resize an image with various fit modes")]
        public static ImageProcessResult ImageResize(
            [Description("Input image path")] string input,
            [Description("Output image path")] string output,
            [Description("Target width in pixels")] int? width = null,
            [Description("Target height in pixels")] int? height = null,
            [Description("Fit mode: contain, cover, fill, fitWidth, fitHeight")] string fit = "contain",
            [Description("Keep EXIF metadata")] bool? keepExif = false,
            [Description("JPEG quality 1-100")] int? quality = 80)
        {
            ImageUtils.ValidateInputFile(input);
            ImageUtils.ValidateOutputPath(output, true);

            var inputPath = ImageUtils.NormalizePath(input);
            var outputPath = ImageUtils.NormalizePath(output);

            using var image = Image.Load(inputPath);

            // Validate source dimensions
            ImageUtils.ValidateImageDimensions(image.Width, image.Height);

            // Auto-rotate based on EXIF
            image.Mutate(x => x.AutoOrient());

            // Calculate target size
            var originalSize = new Size(image.Width, image.Height);
            var targetSize = ImageUtils.CalculateResizeSize(originalSize, width, height, fit);

            // Validate target dimensions
            ImageUtils.ValidateImageDimensions(targetSize.Width, targetSize.Height);

            // Resize image
            if (targetSize != originalSize)
            {
                image.Mutate(x => x.Resize(new ResizeOptions
                {
                    Size = targetSize,
                    Mode = fit.ToLowerInvariant() switch
                    {
                        "contain" => ResizeMode.Max,
                        "cover" => ResizeMode.Crop,
                        "fill" => ResizeMode.Stretch,
                        "fitwidth" or "fitheight" => ResizeMode.Max,
                        _ => ResizeMode.Max
                    }
                }));
            }

            // Remove metadata if requested
            if (!keepExif.GetValueOrDefault())
            {
                image.Metadata.ExifProfile = null;
                image.Metadata.XmpProfile = null;
                image.Metadata.IptcProfile = null;
            }

            // Determine output format from extension
            var extension = Path.GetExtension(output).TrimStart('.').ToLowerInvariant();
            var encoder = ImageUtils.GetEncoder(extension, quality);

            image.Save(outputPath, encoder);

            return new ImageProcessResult(output, image.Width, image.Height);
        }

    [McpServerTool, Description("Convert image to different format")]
    public static ImageConvertResult ImageConvert(
        [Description("Input image path")] string input,
        [Description("Output image path")] string output,
        [Description("Target format: webp, jpeg, png")] string format,
        [Description("Quality for lossy formats (1-100)")] int? quality = 85)
    {
        ImageUtils.ValidateInputFile(input);
        ImageUtils.ValidateOutputPath(output, true);

        var inputPath = ImageUtils.NormalizePath(input);
        var outputPath = ImageUtils.NormalizePath(output);

        using var image = Image.Load(inputPath);

        // Validate dimensions
        ImageUtils.ValidateImageDimensions(image.Width, image.Height);

        // Auto-rotate based on EXIF
        image.Mutate(x => x.AutoOrient());

        var encoder = ImageUtils.GetEncoder(format, quality);
        image.Save(outputPath, encoder);

        return new ImageConvertResult(output);
    }
}

internal static class ImageUtils
{
    internal static string? ConfiguredRoot { get; set; } = Directory.GetCurrentDirectory();
    internal const int MaxDimensionLimit = 12000; // 12k pixels max to prevent OOM

    internal static string NormalizePath(string path)
    {
        // Convert to absolute path and normalize
        var fullPath = Path.GetFullPath(path, ConfiguredRoot ?? Directory.GetCurrentDirectory());

        // Security check: ensure path is within configured root
        var rootPath = Path.GetFullPath(ConfiguredRoot ?? Directory.GetCurrentDirectory());
        if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
            throw new UnauthorizedAccessException($"Path outside configured root: {path}");

        return fullPath;
    }

    internal static void ValidateInputFile(string path)
    {
        var normalizedPath = NormalizePath(path);
        if (!File.Exists(normalizedPath))
            throw new FileNotFoundException($"Input file not found: {path}");
    }

    internal static void ValidateOutputPath(string path, bool overwrite = false)
    {
        var normalizedPath = NormalizePath(path);
        if (!overwrite && File.Exists(normalizedPath))
            throw new InvalidOperationException($"Output file already exists: {path}. Enable overwrite if needed.");

        // Ensure output directory exists
        var outputDir = Path.GetDirectoryName(normalizedPath);
        if (!string.IsNullOrEmpty(outputDir))
            Directory.CreateDirectory(outputDir);
    }

    internal static void ValidateImageDimensions(int width, int height)
    {
        if (width > MaxDimensionLimit || height > MaxDimensionLimit)
            throw new ArgumentException($"Image dimension exceeds maximum limit of {MaxDimensionLimit} pixels (got {width}x{height}). This prevents out-of-memory errors.");
    }

    internal static IImageEncoder GetEncoder(string format, int? quality = null)
    {
        return format.ToLowerInvariant() switch
        {
            "jpeg" or "jpg" => new JpegEncoder { Quality = quality ?? 80 },
            "png" => new PngEncoder(),
            "webp" => new WebpEncoder { Quality = quality ?? 85 },
            _ => throw new ArgumentException($"Unsupported format: {format}. Supported: jpeg, png, webp")
        };
    }

    internal static Size CalculateResizeSize(Size original, int? targetWidth, int? targetHeight, string fitMode)
    {
        if (!targetWidth.HasValue && !targetHeight.HasValue)
            return original;

        return fitMode.ToLowerInvariant() switch
        {
            "contain" => CalculateContain(original, targetWidth, targetHeight),
            "cover" => CalculateCover(original, targetWidth, targetHeight),
            "fill" => new SixLabors.ImageSharp.Size(targetWidth ?? original.Width, targetHeight ?? original.Height),
            "fitwidth" => targetWidth.HasValue ? new Size(targetWidth.Value, (int)(original.Height * (double)targetWidth.Value / original.Width)) : original,
            "fitheight" => targetHeight.HasValue ? new Size((int)(original.Width * (double)targetHeight.Value / original.Height), targetHeight.Value) : original,
            _ => throw new ArgumentException($"Invalid fit mode: {fitMode}. Supported: contain, cover, fill, fitWidth, fitHeight")
        };
    }

    private static Size CalculateContain(Size original, int? maxWidth, int? maxHeight)
    {
        var scaleX = maxWidth.HasValue ? (double)maxWidth.Value / original.Width : double.MaxValue;
        var scaleY = maxHeight.HasValue ? (double)maxHeight.Value / original.Height : double.MaxValue;
        var scale = Math.Min(scaleX, scaleY);

        if (scale >= 1) return original;

        return new Size((int)(original.Width * scale), (int)(original.Height * scale));
    }

    private static Size CalculateCover(Size original, int? targetWidth, int? targetHeight)
    {
        if (!targetWidth.HasValue || !targetHeight.HasValue)
            throw new ArgumentException("Both width and height required for cover mode");

        var scaleX = (double)targetWidth.Value / original.Width;
        var scaleY = (double)targetHeight.Value / original.Height;
        var scale = Math.Max(scaleX, scaleY);

        return new Size((int)(original.Width * scale), (int)(original.Height * scale));
    }
}

How to Run

1. Save the server code

Put it in your project, in a .mcp-servers/ directory. Copy the code above, or download it from /servers/image-utility/image-utility.cs. Commit it: the file is the whole server.

2. Build it once

The first build restores the packages. It takes longer than the startup timeout of most clients, so run it by hand before you go on:

dotnet build .mcp-servers/image-utility.cs -v q

This adds no bin or obj directory to your project. A file-based app is not a project, so the SDK caches the build under your temp directory instead. Your .gitignore needs no new line.

3. Configure your MCP client

Add this to .mcp.json in your project root, and commit it. Everyone who clones the project then gets the server.

Note: This example is for Claude Code, Claude Desktop, Cursor, and Windsurf. Visual Studio and VS Code use servers instead of mcpServers.

{
  "mcpServers": {
    "image-utility": {
      "type": "stdio",
      "command": "dotnet",
      "args": ["run", "./.mcp-servers/image-utility.cs", "-v", "q"]
    }
  }
}

The path is relative to the project, so it works for everyone who clones it, on every operating system. -v q is required: standard output carries the JSON-RPC stream, and without it the build output can reach that stream and break the connection.

With Claude Code, claude mcp add image-utility --scope project -- dotnet run ./.mcp-servers/image-utility.cs -v q writes the same entry for you. Add --scope user instead, with an absolute path, to install it for every project.

4. Restart your LLM client

Restart your client in the project directory, then check that image-utility shows as connected. Claude Code asks you to approve a project server the first time it reads .mcp.json.

Statistics

Version 1.0.0
License MIT

Author

XAKPC Dev Labs

Maintained by the AnyMCP community