#!/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)
})