index.ts•18.3 kB
#!/usr/bin/env node
/**
* MCP server for image processing and analysis.
* This server provides tools and resources for working with images:
* - List images in a directory as resources
* - Read image metadata and thumbnails
* - Analyze images (dimensions, format, size)
* - Process images (resize, convert)
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ErrorCode,
ListResourcesRequestSchema,
ListToolsRequestSchema,
McpError,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import * as fs from 'fs';
import * as path from 'path';
import * as fsExtra from 'fs-extra';
import sharp from 'sharp';
// Default directory to scan for images if not provided
const DEFAULT_IMAGE_DIR = process.env.IMAGE_DIR || process.cwd();
// Supported image formats
const SUPPORTED_FORMATS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.tiff', '.svg', '.bmp'];
// Cache for image metadata to avoid repeated processing
const imageMetadataCache = new Map<string, ImageMetadata>();
// Type for image metadata
interface ImageMetadata {
path: string;
filename: string;
format: string;
width?: number;
height?: number;
size: number;
created: Date;
modified: Date;
}
/**
* Get all image files in a directory
*/
async function getImageFiles(directory: string): Promise<string[]> {
try {
// Use fs.promises.readdir instead of fsExtra.readdir
const files = await fs.promises.readdir(directory);
return files
.filter(file => {
const ext = path.extname(file).toLowerCase();
return SUPPORTED_FORMATS.includes(ext);
})
.map(file => path.join(directory, file));
} catch (error) {
console.error(`Error reading directory ${directory}:`, error);
return [];
}
}
/**
* Get metadata for an image file
*/
async function getImageMetadata(imagePath: string): Promise<ImageMetadata> {
// Check cache first
if (imageMetadataCache.has(imagePath)) {
return imageMetadataCache.get(imagePath)!;
}
try {
// Use fs.promises.stat instead of fsExtra.stat
const stats = await fs.promises.stat(imagePath);
const metadata = await sharp(imagePath).metadata();
const imageMetadata: ImageMetadata = {
path: imagePath,
filename: path.basename(imagePath),
format: metadata.format || path.extname(imagePath).replace('.', ''),
width: metadata.width,
height: metadata.height,
size: stats.size,
created: stats.birthtime,
modified: stats.mtime
};
// Cache the metadata
imageMetadataCache.set(imagePath, imageMetadata);
return imageMetadata;
} catch (error) {
console.error(`Error getting metadata for ${imagePath}:`, error);
throw new McpError(
ErrorCode.InternalError,
`Failed to process image: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Generate a base64 thumbnail of an image
*/
async function generateThumbnail(imagePath: string, maxWidth = 300): Promise<string> {
try {
const buffer = await sharp(imagePath)
.resize({ width: maxWidth, withoutEnlargement: true })
.toBuffer();
return `data:image/${path.extname(imagePath).replace('.', '')};base64,${buffer.toString('base64')}`;
} catch (error) {
console.error(`Error generating thumbnail for ${imagePath}:`, error);
throw new McpError(
ErrorCode.InternalError,
`Failed to generate thumbnail: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Create an MCP server with capabilities for image processing
*/
const server = new Server(
{
name: "image-reader",
version: "0.1.0",
},
{
capabilities: {
resources: {},
tools: {},
},
}
);
/**
* Handler for listing available images as resources
*/
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const imageDir = process.env.IMAGE_DIR || DEFAULT_IMAGE_DIR;
const imageFiles = await getImageFiles(imageDir);
return {
resources: await Promise.all(imageFiles.map(async (imagePath) => {
try {
const metadata = await getImageMetadata(imagePath);
return {
uri: `image://${encodeURIComponent(imagePath)}`,
mimeType: `image/${metadata.format}`,
name: metadata.filename,
description: `Image: ${metadata.filename} (${metadata.width}x${metadata.height}, ${(metadata.size / 1024).toFixed(2)} KB)`
};
} catch (error) {
console.error(`Error processing ${imagePath}:`, error);
return {
uri: `image://${encodeURIComponent(imagePath)}`,
mimeType: "image/unknown",
name: path.basename(imagePath),
description: `Image: ${path.basename(imagePath)} (metadata unavailable)`
};
}
}))
};
});
/**
* Handler for reading image content and metadata
*/
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const uri = request.params.uri;
if (!uri.startsWith('image://')) {
throw new McpError(
ErrorCode.InvalidRequest,
`Invalid URI scheme: ${uri}`
);
}
const imagePath = decodeURIComponent(uri.replace('image://', ''));
try {
if (!fs.existsSync(imagePath)) {
throw new McpError(
ErrorCode.InvalidRequest,
`Image not found: ${imagePath}`
);
}
const metadata = await getImageMetadata(imagePath);
const thumbnail = await generateThumbnail(imagePath);
// Format metadata as JSON
const metadataJson = JSON.stringify({
filename: metadata.filename,
format: metadata.format,
dimensions: `${metadata.width}x${metadata.height}`,
size: `${(metadata.size / 1024).toFixed(2)} KB`,
created: metadata.created.toISOString(),
modified: metadata.modified.toISOString(),
path: metadata.path
}, null, 2);
return {
contents: [
{
uri: request.params.uri,
mimeType: "application/json",
text: metadataJson
},
{
uri: `${request.params.uri}#thumbnail`,
mimeType: `image/${metadata.format}`,
text: thumbnail
}
]
};
} catch (error) {
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
`Error processing image: ${error instanceof Error ? error.message : String(error)}`
);
}
});
/**
* Handler that lists available tools for image processing
*/
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "analyze_image",
description: "Get detailed metadata about an image",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the image file"
}
},
required: ["path"]
}
},
{
name: "resize_image",
description: "Resize an image and save to a new file",
inputSchema: {
type: "object",
properties: {
input: {
type: "string",
description: "Path to the input image file"
},
output: {
type: "string",
description: "Path to save the resized image"
},
width: {
type: "number",
description: "Target width in pixels"
},
height: {
type: "number",
description: "Target height in pixels (optional)"
},
fit: {
type: "string",
description: "Fit method: cover, contain, fill, inside, outside",
enum: ["cover", "contain", "fill", "inside", "outside"]
}
},
required: ["input", "output", "width"]
}
},
{
name: "convert_format",
description: "Convert an image to a different format",
inputSchema: {
type: "object",
properties: {
input: {
type: "string",
description: "Path to the input image file"
},
output: {
type: "string",
description: "Path to save the converted image"
},
format: {
type: "string",
description: "Target format: jpeg, png, webp, avif, tiff, etc.",
enum: ["jpeg", "png", "webp", "avif", "tiff", "gif"]
},
quality: {
type: "number",
description: "Quality level (1-100, for formats that support it)"
}
},
required: ["input", "output", "format"]
}
},
{
name: "scan_directory",
description: "Scan a directory for images and return metadata",
inputSchema: {
type: "object",
properties: {
directory: {
type: "string",
description: "Directory path to scan for images"
},
recursive: {
type: "boolean",
description: "Whether to scan subdirectories recursively"
}
},
required: ["directory"]
}
}
]
};
});
/**
* Handler for image processing tools
*/
server.setRequestHandler(CallToolRequestSchema, async (request) => {
switch (request.params.name) {
case "analyze_image": {
const { path: imagePath } = request.params.arguments as { path: string };
if (!imagePath) {
throw new McpError(ErrorCode.InvalidParams, "Image path is required");
}
if (!fs.existsSync(imagePath)) {
throw new McpError(ErrorCode.InvalidRequest, `Image not found: ${imagePath}`);
}
try {
const metadata = await getImageMetadata(imagePath);
const thumbnail = await generateThumbnail(imagePath);
return {
content: [
{
type: "text",
text: JSON.stringify({
filename: metadata.filename,
format: metadata.format,
dimensions: `${metadata.width}x${metadata.height}`,
size: {
bytes: metadata.size,
kilobytes: (metadata.size / 1024).toFixed(2),
megabytes: (metadata.size / (1024 * 1024)).toFixed(2)
},
created: metadata.created.toISOString(),
modified: metadata.modified.toISOString(),
path: metadata.path,
thumbnail
}, null, 2)
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error analyzing image: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
}
case "resize_image": {
const { input, output, width, height, fit = "cover" } =
request.params.arguments as { input: string, output: string, width: number, height?: number, fit?: string };
if (!input || !output || !width) {
throw new McpError(ErrorCode.InvalidParams, "Input path, output path, and width are required");
}
if (!fs.existsSync(input)) {
throw new McpError(ErrorCode.InvalidRequest, `Input image not found: ${input}`);
}
try {
// Create output directory if it doesn't exist
await fsExtra.ensureDir(path.dirname(output));
// Resize the image
await sharp(input)
.resize({
width,
height,
fit: fit as keyof sharp.FitEnum
})
.toFile(output);
// Get metadata of the resized image
const metadata = await getImageMetadata(output);
return {
content: [
{
type: "text",
text: `Image resized successfully:\n` +
`- Original: ${input}\n` +
`- Resized: ${output}\n` +
`- New dimensions: ${metadata.width}x${metadata.height}\n` +
`- Size: ${(metadata.size / 1024).toFixed(2)} KB`
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error resizing image: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
}
case "convert_format": {
const { input, output, format, quality = 80 } =
request.params.arguments as { input: string, output: string, format: string, quality?: number };
if (!input || !output || !format) {
throw new McpError(ErrorCode.InvalidParams, "Input path, output path, and format are required");
}
if (!fs.existsSync(input)) {
throw new McpError(ErrorCode.InvalidRequest, `Input image not found: ${input}`);
}
try {
// Create output directory if it doesn't exist
await fsExtra.ensureDir(path.dirname(output));
// Convert the image
let processor = sharp(input);
// Apply format-specific options
switch (format.toLowerCase()) {
case 'jpeg':
case 'jpg':
processor = processor.jpeg({ quality });
break;
case 'png':
processor = processor.png({ quality: Math.floor(quality / 100 * 9) });
break;
case 'webp':
processor = processor.webp({ quality });
break;
case 'avif':
processor = processor.avif({ quality });
break;
case 'tiff':
processor = processor.tiff({ quality });
break;
case 'gif':
processor = processor.gif();
break;
default:
throw new McpError(ErrorCode.InvalidParams, `Unsupported format: ${format}`);
}
await processor.toFile(output);
// Get metadata of the converted image
const metadata = await getImageMetadata(output);
return {
content: [
{
type: "text",
text: `Image converted successfully:\n` +
`- Original: ${input}\n` +
`- Converted: ${output}\n` +
`- Format: ${metadata.format}\n` +
`- Dimensions: ${metadata.width}x${metadata.height}\n` +
`- Size: ${(metadata.size / 1024).toFixed(2)} KB`
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error converting image: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
}
case "scan_directory": {
const { directory, recursive = false } =
request.params.arguments as { directory: string, recursive?: boolean };
if (!directory) {
throw new McpError(ErrorCode.InvalidParams, "Directory path is required");
}
if (!fs.existsSync(directory)) {
throw new McpError(ErrorCode.InvalidRequest, `Directory not found: ${directory}`);
}
try {
// Function to scan directory recursively
async function scanDir(dir: string, results: ImageMetadata[] = []): Promise<ImageMetadata[]> {
// Use fs instead of fsExtra for readdir
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && recursive) {
await scanDir(fullPath, results);
} else if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
if (SUPPORTED_FORMATS.includes(ext)) {
try {
const metadata = await getImageMetadata(fullPath);
results.push(metadata);
} catch (error) {
console.error(`Error processing ${fullPath}:`, error);
}
}
}
}
return results;
}
const images = await scanDir(directory);
return {
content: [
{
type: "text",
text: `Found ${images.length} images in ${directory}${recursive ? ' (including subdirectories)' : ''}:\n\n` +
JSON.stringify(images.map(img => ({
filename: img.filename,
path: img.path,
format: img.format,
dimensions: `${img.width}x${img.height}`,
size: `${(img.size / 1024).toFixed(2)} KB`
})), null, 2)
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error scanning directory: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
}
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
}
});
/**
* Start the server using stdio transport
*/
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Image Reader MCP server running on stdio');
// Error handling
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
});
process.on('SIGINT', async () => {
await server.close();
process.exit(0);
});
}
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});