mcp-tung-shing

by baranwang
Verified
  • build
#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import ffmpeg from 'fluent-ffmpeg'; import { homedir } from 'os'; import { join, isAbsolute, resolve, dirname } from 'path'; import { promises as fs } from 'fs'; import { execSync } from 'node:child_process'; // Create server instance const server = new McpServer({ name: "media-processor", version: "1.0.0", }); const downloadsPath = join(homedir(), 'Downloads'); // Helper function to ensure directory exists async function ensureDirectoryExists(dirPath) { try { await fs.access(dirPath); } catch { await fs.mkdir(dirPath, { recursive: true }); } } // Helper function to convert path to absolute path async function getAbsolutePath(inputPath) { if (isAbsolute(inputPath)) { return inputPath; } // FIXME: But it's not working, because the server is running in a different directory const absolutePath = resolve(process.cwd(), inputPath); try { await fs.access(absolutePath); return absolutePath; } catch (error) { throw new Error(`Input file not found: ${inputPath}`); } } // Helper function to get output path async function getOutputPath(outputPath, defaultFilename) { if (!outputPath) { // If no output path provided, use Downloads directory await ensureDirectoryExists(downloadsPath); return join(downloadsPath, defaultFilename); } // If output path is provided const absoluteOutputPath = isAbsolute(outputPath) ? outputPath : resolve(process.cwd(), outputPath); const outputDir = dirname(absoluteOutputPath); // Ensure output directory exists await ensureDirectoryExists(outputDir); return absoluteOutputPath; } // Helper function to check if ImageMagick is installed async function checkImageMagick() { try { execSync('convert -version'); return true; } catch (error) { throw new Error('ImageMagick is not installed. Please install it first.'); } } // Helper function to execute FFmpeg command const executeFFmpeg = (command) => { return new Promise((resolve, reject) => { command .on('end', () => resolve()) .on('error', (err) => reject(err)) .run(); }); }; // Register video processing tools server.tool("execute-ffmpeg", "Execute any FFmpeg command with custom options", { inputPath: z.string().describe("Absolute path to input video file"), outputPath: z.string().optional().describe("Optional absolute path for output file. If not provided, file will be saved in Downloads folder"), outputFilename: z.string().optional().describe("Output filename (only used if outputPath is not provided)"), options: z.array(z.string()).describe("Array of FFmpeg command options (e.g. ['-c:v', 'libx264', '-crf', '23'])") }, async ({ inputPath, outputPath, outputFilename, options }) => { try { const absoluteInputPath = await getAbsolutePath(inputPath); const finalOutputPath = await getOutputPath(outputPath, outputFilename || 'output.mp4'); let command = ffmpeg(absoluteInputPath); // Add all options in pairs for (let i = 0; i < options.length; i += 2) { if (i + 1 < options.length) { command = command.addOption(options[i], options[i + 1]); } } command.save(finalOutputPath); await executeFFmpeg(command); return { content: [ { type: "text", text: `Video processing completed successfully. Output saved to: ${finalOutputPath}`, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [ { type: "text", text: `Error processing video: ${errorMessage}`, }, ], }; } }); server.tool("convert-video", "Convert video to different format", { inputPath: z.string().describe("Absolute path to input video file"), outputFormat: z.string().describe("Desired output format (e.g., mp4, mkv, avi)"), outputPath: z.string().optional().describe("Optional absolute path for output file. If not provided, file will be saved in Downloads folder"), outputFilename: z.string().optional().describe("Output filename (only used if outputPath is not provided)") }, async ({ inputPath, outputFormat, outputPath, outputFilename }) => { try { const absoluteInputPath = await getAbsolutePath(inputPath); const inputFileName = absoluteInputPath.split('/').pop()?.split('.')[0] || 'output'; const defaultFilename = outputFilename || `${inputFileName}_converted.${outputFormat}`; const finalOutputPath = await getOutputPath(outputPath, defaultFilename); const command = ffmpeg(absoluteInputPath) .toFormat(outputFormat) .save(finalOutputPath); await executeFFmpeg(command); return { content: [ { type: "text", text: `Video successfully converted and saved to: ${finalOutputPath}`, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [ { type: "text", text: `Error converting video: ${errorMessage}`, }, ], }; } }); server.tool("compress-video", "Compress video file", { inputPath: z.string().describe("Absolute path to input video file"), quality: z.number().min(1).max(51).default(23).describe("Compression quality (1-51, lower is better quality but larger file)"), outputPath: z.string().optional().describe("Optional absolute path for output file. If not provided, file will be saved in Downloads folder"), outputFilename: z.string().optional().describe("Output filename (only used if outputPath is not provided)") }, async ({ inputPath, quality, outputPath, outputFilename }) => { try { const absoluteInputPath = await getAbsolutePath(inputPath); const inputFileName = absoluteInputPath.split('/').pop()?.split('.')[0] || 'output'; const defaultFilename = outputFilename || `${inputFileName}_compressed.mp4`; const finalOutputPath = await getOutputPath(outputPath, defaultFilename); const command = ffmpeg(absoluteInputPath) .videoCodec('libx264') .addOption('-crf', quality.toString()) .save(finalOutputPath); await executeFFmpeg(command); return { content: [ { type: "text", text: `Video successfully compressed and saved to: ${finalOutputPath}`, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [ { type: "text", text: `Error compressing video: ${errorMessage}`, }, ], }; } }); server.tool("trim-video", "Trim video to specified duration", { inputPath: z.string().describe("Absolute path to input video file"), startTime: z.string().describe("Start time in format HH:MM:SS"), duration: z.string().describe("Duration in format HH:MM:SS"), outputPath: z.string().optional().describe("Optional absolute path for output file. If not provided, file will be saved in Downloads folder"), outputFilename: z.string().optional().describe("Output filename (only used if outputPath is not provided)") }, async ({ inputPath, startTime, duration, outputPath, outputFilename }) => { try { const absoluteInputPath = await getAbsolutePath(inputPath); const inputFileName = absoluteInputPath.split('/').pop()?.split('.')[0] || 'output'; const defaultFilename = outputFilename || `${inputFileName}_trimmed.mp4`; const finalOutputPath = await getOutputPath(outputPath, defaultFilename); const command = ffmpeg(absoluteInputPath) .setStartTime(startTime) .setDuration(duration) .save(finalOutputPath); await executeFFmpeg(command); return { content: [ { type: "text", text: `Video successfully trimmed and saved to: ${finalOutputPath}`, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [ { type: "text", text: `Error trimming video: ${errorMessage}`, }, ], }; } }); server.tool("compress-image", "Compress PNG image using ImageMagick", { inputPath: z.string().describe("Absolute path to input PNG image"), quality: z.number().min(1).max(100).default(80).describe("Compression quality (1-100)"), outputPath: z.string().optional().describe("Optional absolute path for output file. If not provided, file will be saved in Downloads folder"), outputFilename: z.string().optional().describe("Output filename (only used if outputPath is not provided)") }, async ({ inputPath, quality, outputPath, outputFilename }) => { try { // Check if ImageMagick is installed await checkImageMagick(); const absoluteInputPath = await getAbsolutePath(inputPath); // Verify input file is PNG if (!absoluteInputPath.toLowerCase().endsWith('.png')) { throw new Error('Input file must be a PNG image'); } const inputFileName = absoluteInputPath.split('/').pop()?.split('.')[0] || 'output'; const defaultFilename = outputFilename || `${inputFileName}_compressed.png`; const finalOutputPath = await getOutputPath(outputPath, defaultFilename); // Run ImageMagick convert command with quality setting const command = `convert "${absoluteInputPath}" -quality ${quality} -define png:compression-level=9 "${finalOutputPath}"`; const output = await execSync(command); return { content: [ { type: "text", text: `Image successfully compressed and saved to: ${finalOutputPath}`, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [ { type: "text", text: `Error compressing image: ${errorMessage}`, }, ], }; } }); server.tool("convert-image", "Convert image to different format", { inputPath: z.string().describe("Absolute path to input image file"), outputFormat: z.string().describe("Desired output format (e.g., jpg, png, webp, gif)"), outputPath: z.string().optional().describe("Optional absolute path for output file. If not provided, file will be saved in Downloads folder"), outputFilename: z.string().optional().describe("Output filename (only used if outputPath is not provided)") }, async ({ inputPath, outputFormat, outputPath, outputFilename }) => { try { await checkImageMagick(); const absoluteInputPath = await getAbsolutePath(inputPath); const inputFileName = absoluteInputPath.split('/').pop()?.split('.')[0] || 'output'; const defaultFilename = outputFilename || `${inputFileName}_converted.${outputFormat}`; const finalOutputPath = await getOutputPath(outputPath, defaultFilename); const command = `convert "${absoluteInputPath}" "${finalOutputPath}"`; await execSync(command); return { content: [ { type: "text", text: `Image successfully converted and saved to: ${finalOutputPath}`, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [ { type: "text", text: `Error converting image: ${errorMessage}`, }, ], }; } }); server.tool("resize-image", "Resize image to specified dimensions", { inputPath: z.string().describe("Absolute path to input image file"), width: z.number().optional().describe("Target width in pixels"), height: z.number().optional().describe("Target height in pixels"), maintainAspectRatio: z.boolean().default(true).describe("Whether to maintain aspect ratio when resizing"), outputPath: z.string().optional().describe("Optional absolute path for output file. If not provided, file will be saved in Downloads folder"), outputFilename: z.string().optional().describe("Output filename (only used if outputPath is not provided)") }, async ({ inputPath, width, height, maintainAspectRatio, outputPath, outputFilename }) => { try { await checkImageMagick(); const absoluteInputPath = await getAbsolutePath(inputPath); const inputFileName = absoluteInputPath.split('/').pop()?.split('.')[0] || 'output'; const extension = absoluteInputPath.split('.').pop() || 'png'; const defaultFilename = outputFilename || `${inputFileName}_resized.${extension}`; const finalOutputPath = await getOutputPath(outputPath, defaultFilename); let dimensions = ''; if (width && height) { dimensions = maintainAspectRatio ? `${width}x${height}` : `${width}x${height}!`; } else if (width) { dimensions = `${width}x`; } else if (height) { dimensions = `x${height}`; } else { throw new Error('Either width or height must be specified'); } const command = `convert "${absoluteInputPath}" -resize "${dimensions}" "${finalOutputPath}"`; await execSync(command); return { content: [ { type: "text", text: `Image successfully resized and saved to: ${finalOutputPath}`, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [ { type: "text", text: `Error resizing image: ${errorMessage}`, }, ], }; } }); server.tool("rotate-image", "Rotate image by specified degrees", { inputPath: z.string().describe("Absolute path to input image file"), degrees: z.number().describe("Rotation angle in degrees"), outputPath: z.string().optional().describe("Optional absolute path for output file. If not provided, file will be saved in Downloads folder"), outputFilename: z.string().optional().describe("Output filename (only used if outputPath is not provided)") }, async ({ inputPath, degrees, outputPath, outputFilename }) => { try { await checkImageMagick(); const absoluteInputPath = await getAbsolutePath(inputPath); const inputFileName = absoluteInputPath.split('/').pop()?.split('.')[0] || 'output'; const extension = absoluteInputPath.split('.').pop() || 'png'; const defaultFilename = outputFilename || `${inputFileName}_rotated.${extension}`; const finalOutputPath = await getOutputPath(outputPath, defaultFilename); const command = `convert "${absoluteInputPath}" -rotate ${degrees} "${finalOutputPath}"`; await execSync(command); return { content: [ { type: "text", text: `Image successfully rotated and saved to: ${finalOutputPath}`, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [ { type: "text", text: `Error rotating image: ${errorMessage}`, }, ], }; } }); server.tool("add-watermark", "Add watermark to image", { inputPath: z.string().describe("Absolute path to input image file"), watermarkPath: z.string().describe("Absolute path to watermark image file"), position: z.enum(['northwest', 'north', 'northeast', 'west', 'center', 'east', 'southwest', 'south', 'southeast']).default('southeast').describe("Position of watermark"), opacity: z.number().min(0).max(100).default(50).describe("Watermark opacity (0-100)"), outputPath: z.string().optional().describe("Optional absolute path for output file. If not provided, file will be saved in Downloads folder"), outputFilename: z.string().optional().describe("Output filename (only used if outputPath is not provided)") }, async ({ inputPath, watermarkPath, position, opacity, outputPath, outputFilename }) => { try { await checkImageMagick(); const absoluteInputPath = await getAbsolutePath(inputPath); const absoluteWatermarkPath = await getAbsolutePath(watermarkPath); const inputFileName = absoluteInputPath.split('/').pop()?.split('.')[0] || 'output'; const extension = absoluteInputPath.split('.').pop() || 'png'; const defaultFilename = outputFilename || `${inputFileName}_watermarked.${extension}`; const finalOutputPath = await getOutputPath(outputPath, defaultFilename); // Convert opacity from 0-100 to 0-1 for ImageMagick const normalizedOpacity = opacity / 100; const command = `convert "${absoluteInputPath}" \\( "${absoluteWatermarkPath}" -alpha set -channel A -evaluate multiply ${normalizedOpacity} \\) -gravity ${position} -composite "${finalOutputPath}"`; await execSync(command); return { content: [ { type: "text", text: `Watermark successfully added and saved to: ${finalOutputPath}`, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [ { type: "text", text: `Error adding watermark: ${errorMessage}`, }, ], }; } }); server.tool("apply-effect", "Apply visual effect to image", { inputPath: z.string().describe("Absolute path to input image file"), effect: z.enum(['blur', 'sharpen', 'edge', 'emboss', 'grayscale', 'sepia', 'negate']).describe("Effect to apply"), intensity: z.number().min(0).max(100).default(50).describe("Effect intensity (0-100, not applicable for some effects)"), outputPath: z.string().optional().describe("Optional absolute path for output file. If not provided, file will be saved in Downloads folder"), outputFilename: z.string().optional().describe("Output filename (only used if outputPath is not provided)") }, async ({ inputPath, effect, intensity, outputPath, outputFilename }) => { try { await checkImageMagick(); const absoluteInputPath = await getAbsolutePath(inputPath); const inputFileName = absoluteInputPath.split('/').pop()?.split('.')[0] || 'output'; const extension = absoluteInputPath.split('.').pop() || 'png'; const defaultFilename = outputFilename || `${inputFileName}_${effect}.${extension}`; const finalOutputPath = await getOutputPath(outputPath, defaultFilename); let command = ''; switch (effect) { case 'blur': command = `convert "${absoluteInputPath}" -blur 0x${intensity / 5} "${finalOutputPath}"`; break; case 'sharpen': command = `convert "${absoluteInputPath}" -sharpen 0x${intensity / 10} "${finalOutputPath}"`; break; case 'edge': command = `convert "${absoluteInputPath}" -edge ${intensity / 10} "${finalOutputPath}"`; break; case 'emboss': command = `convert "${absoluteInputPath}" -emboss ${intensity / 10} "${finalOutputPath}"`; break; case 'grayscale': command = `convert "${absoluteInputPath}" -colorspace Gray "${finalOutputPath}"`; break; case 'sepia': command = `convert "${absoluteInputPath}" -sepia-tone ${intensity}% "${finalOutputPath}"`; break; case 'negate': command = `convert "${absoluteInputPath}" -negate "${finalOutputPath}"`; break; } await execSync(command); return { content: [ { type: "text", text: `Effect successfully applied and saved to: ${finalOutputPath}`, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [ { type: "text", text: `Error applying effect: ${errorMessage}`, }, ], }; } }); async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Media Processor MCP Server running on stdio"); } main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); });