Skip to main content
Glama
index.ts14 kB
#!/usr/bin/env node /** * MCP Server for TinyPNG image optimization. * This server provides tools and resources for image compression using TinyPNG API. */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' import * as fs from 'fs' import * as path from 'path' import tinify from 'tinify' import { type CompressionRecord, type ConvertOptions, type ImageFormat, RESIZE_METHOD_PROMPT_TEXT, type ResizeMethod, SUPPORTED_EXTENSIONS, calculateFileHash, compressImage, convertImage, getImageFiles, isSupportedImageFile, readCompressionRecord, resizeImage, validateApiKey, writeCompressionRecord, } from './utils/index.js' /** * Parse command line arguments to extract API key. * Supports both --TINIFY_API_KEY=value and --TINIFY_API_KEY value formats. * * @returns The API key from command line arguments, or null if not found. */ function parseApiKeyFromArgs(): string | null { const args = process.argv.slice(2) for (let i = 0; i < args.length; i++) { const arg = args[i] // Handle --TINIFY_API_KEY=value format if (arg.startsWith('--TINIFY_API_KEY=')) { return arg.split('=')[1] } // Handle --TINIFY_API_KEY value format if (arg === '--TINIFY_API_KEY' && i + 1 < args.length) { return args[i + 1] } } return null } /** * Get API key from command line arguments or environment variable. * Command line arguments take precedence over environment variables. * * @returns The API key string. * @throws Error if no API key is found. */ function getApiKey(): string { // First try to get from command line arguments const cliApiKey = parseApiKeyFromArgs() if (cliApiKey) { return cliApiKey } // Fallback to environment variable for backward compatibility const envApiKey = process.env.TINIFY_API_KEY if (envApiKey) { return envApiKey } throw new Error( 'TINIFY_API_KEY is required. ' + 'Provide it via command line argument (--TINIFY_API_KEY=your_key) ' + 'or environment variable (TINIFY_API_KEY=your_key)', ) } /** * Main function to initialize and start the MCP server. * Sets up the server with stdio transport for communication. */ async function main(): Promise<void> { // Get API key from command line arguments or environment variable const apiKey = getApiKey() // Validate API key on startup try { await validateApiKey(apiKey) console.error('✅ TinyPNG API key validated successfully') } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' throw new Error(`Failed to validate TinyPNG API key: ${errorMessage}`) } /** * Create an MCP server instance with project-specific configuration. */ const server = new McpServer({ name: 'joyme-mcp-tinify-image', version: '1.1.11', }) /** * Add a basic ping tool for testing server connectivity. */ server.tool( 'ping', {}, async () => ({ content: [{ type: 'text', text: 'pong' }], }), ) /** * Add prompt for selecting image resize method. */ server.prompt( 'select_resize_method', {}, async () => ({ description: 'Select image resize method with detailed explanations', messages: [ { role: 'user', content: { type: 'text', text: RESIZE_METHOD_PROMPT_TEXT, }, }, ], }), ) /** * Add image resize tool. */ server.tool( 'resize_image', { imagePath: z.string().describe('Path to the image file to resize'), width: z.number().optional().describe('Target width in pixels'), height: z.number().optional().describe('Target height in pixels'), method: z.enum(['fit', 'scale', 'cover', 'thumb']).describe('Resize method (required). Use select_resize_method prompt for detailed explanations'), outputPath: z.string().optional().describe('Output path for resized image'), }, async ({ imagePath, width, height, method, outputPath }) => { try { if (!method) { throw new Error('Resize method is required. Use select_resize_method prompt for detailed explanations') } if (!width && !height) { throw new Error('Either width or height must be specified') } if (!fs.existsSync(imagePath)) { throw new Error(`Image file does not exist: ${imagePath}`) } const stats = await fs.promises.stat(imagePath) if (!stats.isFile()) { throw new Error(`Path is not a file: ${imagePath}`) } const fileName = path.basename(imagePath) if (!isSupportedImageFile(fileName)) { throw new Error(`Unsupported image format. Supported: ${SUPPORTED_EXTENSIONS.join(', ')}`) } const finalOutputPath = outputPath ?? imagePath const originalSize = stats.size await resizeImage(imagePath, width ?? 0, height ?? 0, method as ResizeMethod, finalOutputPath) const resizedStats = await fs.promises.stat(finalOutputPath) const resizedSize = resizedStats.size const sizeChange = resizedSize - originalSize const sizeChangePercent = parseFloat(((sizeChange / originalSize) * 100).toFixed(1)) const results = [ `✅ Image resized successfully: ${fileName}`, `�� Method: ${method}`, `📊 Size: ${originalSize} → ${resizedSize} bytes (${sizeChangePercent > 0 ? '+' : ''}${sizeChangePercent}%)`, outputPath && outputPath !== imagePath ? `💾 Saved to: ${outputPath}` : '💾 Original file updated', `📊 Total API calls used: ${tinify.compressionCount}`, ] return { content: [{ type: 'text', text: results.join('\n') }] } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' return { content: [{ type: 'text', text: `❌ Resize failed: ${errorMessage}` }] } } }, ) /** * Add image format conversion tool. */ server.tool( 'convert_image', { imagePath: z.string().describe('Path to the image file to convert'), targetFormats: z.union([ z.enum(['image/avif', 'image/webp', 'image/jpeg', 'image/png']), z.array(z.enum(['image/avif', 'image/webp', 'image/jpeg', 'image/png'])), ]).describe('Target image format(s). Can be a single format or array of formats. When multiple formats are provided, the smallest result will be used.'), backgroundColor: z.string().optional().describe('Background color for transparent images when converting to formats that don\'t support transparency (e.g., "white", "black", "#000000")'), outputPath: z.string().optional().describe('Output path for converted image. If not provided, will use original filename with new extension'), }, async ({ imagePath, targetFormats, backgroundColor, outputPath }) => { try { if (!fs.existsSync(imagePath)) { throw new Error(`Image file does not exist: ${imagePath}`) } const stats = await fs.promises.stat(imagePath) if (!stats.isFile()) { throw new Error(`Path is not a file: ${imagePath}`) } const fileName = path.basename(imagePath) if (!isSupportedImageFile(fileName)) { throw new Error(`Unsupported image format. Supported: ${SUPPORTED_EXTENSIONS.join(', ')}`) } const convertOptions: ConvertOptions = { targetFormats: targetFormats as ImageFormat | ImageFormat[], backgroundColor, } const result = await convertImage(imagePath, convertOptions, outputPath) const formatList = Array.isArray(targetFormats) ? targetFormats.join(', ') : targetFormats const results = [ `✅ Image converted successfully: ${fileName}`, `🔄 ${result.originalFormat} → ${result.convertedFormat}`, `📊 Size: ${result.originalSize} → ${result.convertedSize} bytes (${result.savings > 0 ? '+' : ''}${result.savings}%)`, `🎯 Target format(s): ${formatList}`, backgroundColor ? `🎨 Background: ${backgroundColor}` : '', `💾 Saved to: ${result.outputPath}`, `📊 Total API calls used: ${tinify.compressionCount}`, ].filter(Boolean) return { content: [{ type: 'text', text: results.join('\n') }] } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' return { content: [{ type: 'text', text: `❌ Conversion failed: ${errorMessage}` }] } } }, ) /** * Add image minification tool. */ server.tool( 'minify_image', { directoryPath: z.string().describe('Path to directory containing images to compress'), }, async ({ directoryPath }) => { try { if (!fs.existsSync(directoryPath)) { throw new Error(`Directory does not exist: ${directoryPath}`) } const stats = await fs.promises.stat(directoryPath) if (!stats.isDirectory()) { throw new Error(`Path is not a directory: ${directoryPath}`) } const { allImageFiles, filesToCompress, alreadyCompressed, renamedFiles, replacedFiles } = await getImageFiles(directoryPath) if (allImageFiles.length === 0) { return { content: [{ type: 'text', text: `No supported image files found in: ${directoryPath}\nSupported formats: ${SUPPORTED_EXTENSIONS.join(', ')}`, }], } } const results = [`📊 Found ${allImageFiles.length} image files total`] if (alreadyCompressed.length > 0) { results.push(`⏭️ Skipping ${alreadyCompressed.length} already compressed files`) } if (renamedFiles.length > 0) { results.push(`🔄 Detected ${renamedFiles.length} renamed/moved files`) } if (replacedFiles.length > 0) { results.push(`⚠️ Detected ${replacedFiles.length} replaced files`) } if (filesToCompress.length === 0) { results.push('✅ All images have already been compressed!') return { content: [{ type: 'text', text: results.join('\n') }] } } results.push(`🔄 Compressing ${filesToCompress.length} new files...`) let successCount = 0 let errorCount = 0 const newlyCompressedFiles = [] for (const imageFile of filesToCompress) { try { const { fullPath, relativePath } = imageFile const originalStats = await fs.promises.stat(fullPath) const originalSize = originalStats.size const fileHash = await calculateFileHash(fullPath) await compressImage(fullPath) const compressedStats = await fs.promises.stat(fullPath) const compressedSize = compressedStats.size const savings = parseFloat(((originalSize - compressedSize) / originalSize * 100).toFixed(1)) results.push(`✅ ${relativePath}: ${originalSize} → ${compressedSize} bytes (${savings}% reduction)`) successCount++ newlyCompressedFiles.push({ relativePath, hash: fileHash, originalSize, compressedSize, mtime: compressedStats.mtime.getTime(), compressedAt: new Date().toISOString(), savings, }) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' results.push(`❌ ${imageFile.relativePath}: ${errorMessage}`) errorCount++ } } // Update compression record if (newlyCompressedFiles.length > 0) { try { const existingRecord = await readCompressionRecord(directoryPath) const existingCompressedFiles = existingRecord?.compressedFiles ?? {} for (const newFile of newlyCompressedFiles) { existingCompressedFiles[newFile.relativePath] = { hash: newFile.hash, size: newFile.compressedSize, mtime: newFile.mtime, compressedAt: newFile.compressedAt, originalSize: newFile.originalSize, compressedSize: newFile.compressedSize, savings: newFile.savings, } } const updatedRecord: CompressionRecord = { compressedFiles: existingCompressedFiles, lastUpdated: new Date().toISOString(), version: '2.0', } await writeCompressionRecord(directoryPath, updatedRecord) results.push(`💾 Updated compression record: ${newlyCompressedFiles.length} new files recorded`) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' results.push(`⚠️ Warning: Failed to update compression record: ${errorMessage}`) } } results.push(`\n📊 Total compressions used this month: ${tinify.compressionCount}`) results.push(`✅ Successfully compressed: ${successCount} images`) if (errorCount > 0) { results.push(`❌ Failed to compress: ${errorCount} images`) } return { content: [{ type: 'text', text: results.join('\n') }] } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' return { content: [{ type: 'text', text: `❌ Error: ${errorMessage}` }] } } }, ) const transport = new StdioServerTransport() await server.connect(transport) console.error('joyme-mcp-tinify-image server started and listening on stdio') } main().catch((error) => { console.error('Failed to start MCP server:', error) process.exit(1) })

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Alvinnn1/tinify-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server