import express, { Request, Response } from "express";
import cors from "cors";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { parseAndValidateConfig } from "@smithery/sdk";
import { RendiClient } from './rendi-client.js';
import type { RendiConfig } from './types.js';
const app = express();
const PORT = process.env.PORT || 8081;
// CORS configuration for browser-based MCP clients
app.use(cors({
origin: '*',
credentials: true,
exposedHeaders: ['mcp-session-id', 'mcp-protocol-version'],
allowedHeaders: ['Content-Type', 'Authorization', '*'],
methods: ['GET', 'POST', 'OPTIONS']
}));
app.use(express.json());
// Define session configuration schema
export const configSchema = z.object({
rendiApiKey: z.string().describe('Your Rendi API key for FFmpeg cloud processing')
});
type Config = z.infer<typeof configSchema>;
// Lazy initialization of Rendi client
let rendiClient: RendiClient | null = null;
function getRendiClient(config: Config): RendiClient {
if (!rendiClient) {
const rendiConfig: RendiConfig = {
apiKey: config.rendiApiKey
};
rendiClient = new RendiClient(rendiConfig);
}
return rendiClient;
}
// Helper function to format file metadata
function formatFileMetadata(metadata: any): string {
const lines = [];
if (metadata.file_type) lines.push(`• Type: ${metadata.file_type}`);
if (metadata.file_format) lines.push(`• Format: ${metadata.file_format}`);
if (metadata.codec) lines.push(`• Codec: ${metadata.codec}`);
if (metadata.duration) lines.push(`• Duration: ${metadata.duration.toFixed(2)}s`);
if (metadata.width && metadata.height) lines.push(`• Resolution: ${metadata.width}x${metadata.height}`);
if (metadata.frame_rate) lines.push(`• Frame Rate: ${metadata.frame_rate} fps`);
if (metadata.bitrate_video_kb) lines.push(`• Video Bitrate: ${metadata.bitrate_video_kb.toFixed(2)} kb/s`);
if (metadata.bitrate_audio_kb) lines.push(`• Audio Bitrate: ${metadata.bitrate_audio_kb.toFixed(2)} kb/s`);
if (metadata.size_mbytes) lines.push(`• File Size: ${metadata.size_mbytes.toFixed(2)} MB`);
if (metadata.storage_url) lines.push(`• Download URL: ${metadata.storage_url}`);
return lines.join('\n');
}
// Helper function to format output files
function formatOutputFiles(outputFiles: Record<string, any>): string {
const entries = Object.entries(outputFiles);
if (entries.length === 0) return 'No output files generated';
return entries.map(([alias, metadata]) => {
return `📁 ${alias}:\n${formatFileMetadata(metadata)}`;
}).join('\n\n');
}
// Create MCP server with Rendi tools
export default function createServer({
config,
}: {
config: z.infer<typeof configSchema>;
}) {
const server = new McpServer({
name: "rendi-mcp-server",
version: "1.0.0",
});
// Test connection tool
server.registerTool("test_rendi_connection", {
title: "Test Rendi Connection",
description: "Test connectivity to Rendi FFmpeg API and validate your API key",
inputSchema: {}
}, async () => {
try {
const client = getRendiClient(config);
const isConnected = await client.testConnection();
if (isConnected) {
return {
content: [
{
type: 'text',
text: `✅ Rendi API Connection Successful!\n\n🔑 API Key: ${config.rendiApiKey.substring(0, 8)}...\n\n🚀 You can now run FFmpeg commands in the cloud using Rendi's infrastructure.\n\n📚 Available Tools:\n• run_ffmpeg_command - Execute a single FFmpeg command\n• run_chained_ffmpeg_commands - Execute multiple sequential FFmpeg commands\n• poll_ffmpeg_command - Check the status of a submitted command\n• delete_command_files - Delete output files from a completed command`
}
]
};
} else {
return {
content: [
{
type: 'text',
text: `❌ Connection test failed: Invalid API key or network error\n\n💡 Troubleshooting:\n• Verify your Rendi API key is correct\n• Check your internet connection\n• Visit https://rendi.dev to get or verify your API key`
}
]
};
}
} catch (error) {
return {
content: [
{
type: 'text',
text: `❌ Connection test failed: ${error instanceof Error ? error.message : 'Unknown error'}\n\n💡 Troubleshooting:\n• Verify your Rendi API key is correct\n• Check your internet connection\n• Visit https://rendi.dev to get or verify your API key`
}
]
};
}
});
// Run FFmpeg Command tool
server.registerTool("run_ffmpeg_command", {
title: "Run FFmpeg Command",
description: "Submit a single FFmpeg command for cloud processing. Use {{alias}} placeholders for files (e.g., {{in_1}}, {{out_1}}). Input files must start with 'in_' and be publicly accessible URLs. Output files must start with 'out_'.",
inputSchema: {
ffmpeg_command: z.string().describe('FFmpeg command using {{alias}} placeholders (e.g., "-i {{in_1}} -vf scale=1280:720 {{out_1}}")'),
input_files: z.record(z.string()).describe('Dictionary mapping input aliases to URLs (e.g., {"in_1": "https://example.com/video.mp4"})'),
output_files: z.record(z.string()).describe('Dictionary mapping output aliases to filenames (e.g., {"out_1": "output.mp4"})'),
max_command_run_seconds: z.number().optional().describe('Maximum runtime in seconds (default: 300)'),
vcpu_count: z.number().optional().describe('Number of vCPUs to use (default: 8)')
}
}, async ({ ffmpeg_command, input_files, output_files, max_command_run_seconds, vcpu_count }) => {
try {
const client = getRendiClient(config);
const result = await client.runFFmpegCommand({
ffmpeg_command,
input_files,
output_files,
max_command_run_seconds,
vcpu_count
});
return {
content: [
{
type: 'text',
text: `✅ FFmpeg command submitted successfully!\n\n🆔 Command ID: ${result.command_id}\n\n⏳ Your command is now being processed in the cloud. Use the "poll_ffmpeg_command" tool with this command ID to check the status and retrieve results.\n\n💡 Tip: Processing time varies based on video length and complexity. Most commands complete within 1-5 minutes.`
}
]
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `❌ Failed to submit FFmpeg command: ${error instanceof Error ? error.message : 'Unknown error'}\n\n💡 Common issues:\n• Input files must be publicly accessible URLs\n• Input file keys must start with "in_" (e.g., in_1, in_video)\n• Output file keys must start with "out_" (e.g., out_1, out_result)\n• Use {{alias}} format in the command (e.g., {{in_1}}, {{out_1}})\n• Verify your FFmpeg command syntax is correct`
}
]
};
}
});
// Run Chained FFmpeg Commands tool
server.registerTool("run_chained_ffmpeg_commands", {
title: "Run Chained FFmpeg Commands",
description: "Submit multiple sequential FFmpeg commands where outputs from earlier commands can be used as inputs in later commands. This is more efficient than running commands separately. Maximum 10 commands per chain.",
inputSchema: {
ffmpeg_commands: z.array(z.string()).describe('Array of FFmpeg command strings using {{alias}} placeholders'),
input_files: z.record(z.string()).describe('Dictionary mapping input aliases to URLs (e.g., {"in_1": "https://example.com/video.mp4"})'),
output_files: z.record(z.string()).describe('Dictionary mapping output aliases to filenames (e.g., {"out_1": "output.mp4", "out_2": "thumbnail.jpg"})'),
max_command_run_seconds: z.number().optional().describe('Maximum runtime per command in seconds (default: 300)'),
vcpu_count: z.number().optional().describe('Number of vCPUs to use (default: 8)')
}
}, async ({ ffmpeg_commands, input_files, output_files, max_command_run_seconds, vcpu_count }) => {
try {
const client = getRendiClient(config);
if (ffmpeg_commands.length > 10) {
return {
content: [
{
type: 'text',
text: `❌ Too many commands: Maximum 10 commands allowed per chain, but ${ffmpeg_commands.length} were provided.\n\n💡 Tip: Split your workflow into multiple chains if you need more than 10 commands.`
}
]
};
}
const result = await client.runChainedFFmpegCommands({
ffmpeg_commands,
input_files,
output_files,
max_command_run_seconds,
vcpu_count
});
return {
content: [
{
type: 'text',
text: `✅ Chained FFmpeg commands submitted successfully!\n\n🆔 Command ID: ${result.command_id}\n📊 Commands in chain: ${ffmpeg_commands.length}\n\n⏳ Your commands are now being processed sequentially in the cloud. Use the "poll_ffmpeg_command" tool with this command ID to check the status and retrieve results.\n\n💡 Tip: Chained commands share system resources, making them faster than running separately. Output files from earlier commands can be used as inputs in later commands.`
}
]
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `❌ Failed to submit chained FFmpeg commands: ${error instanceof Error ? error.message : 'Unknown error'}\n\n💡 Common issues:\n• Maximum 10 commands allowed per chain\n• All input files must be publicly accessible URLs\n• Input file keys must start with "in_" (e.g., in_1, in_video)\n• Output file keys must start with "out_" (e.g., out_1, out_result)\n• To use output from command 1 in command 2, reference it with -i {{out_1}}\n• Verify all FFmpeg command syntax is correct`
}
]
};
}
});
// Poll FFmpeg Command tool
server.registerTool("poll_ffmpeg_command", {
title: "Poll FFmpeg Command",
description: "Check the status of a previously submitted FFmpeg command (single or chained). Returns current status, processing time, and output files with download URLs when complete.",
inputSchema: {
command_id: z.string().describe('The command ID returned when the command was submitted')
}
}, async ({ command_id }) => {
try {
const client = getRendiClient(config);
const result = await client.pollCommand(command_id);
let statusText = '';
switch (result.status) {
case 'QUEUED':
statusText = `⏳ Status: QUEUED\n\nYour command is waiting in the queue to be processed. This usually takes just a few seconds.`;
break;
case 'PROCESSING':
statusText = `⚙️ Status: PROCESSING\n\nYour command is currently being executed. Please wait...`;
break;
case 'PREPARED_FFMPEG_COMMAND':
statusText = `🔧 Status: PREPARED\n\nYour command has been prepared and is about to start processing.`;
break;
case 'SUCCESS':
const outputText = result.output_files ? formatOutputFiles(result.output_files) : 'No output files';
statusText = `✅ Status: SUCCESS\n\n🆔 Command ID: ${result.command_id}\n⏱️ Processing Time: ${result.total_processing_seconds?.toFixed(2)}s\n⚡ FFmpeg Execution Time: ${result.ffmpeg_command_run_seconds?.toFixed(2)}s\n💻 vCPUs Used: ${result.vcpu_count || 'N/A'}\n\n📦 Output Files:\n${outputText}`;
break;
case 'FAILED':
statusText = `❌ Status: FAILED\n\n🆔 Command ID: ${result.command_id}\n🚫 Error Status: ${result.error_status || 'Unknown'}\n📝 Error Message: ${result.error_message || 'No error message provided'}\n\n💡 Common solutions:\n• Verify input file URLs are publicly accessible\n• Check FFmpeg command syntax\n• Ensure file formats are compatible\n• Try reducing video resolution or quality if timeout occurred`;
break;
default:
statusText = `ℹ️ Status: ${result.status}\n\nCommand ID: ${result.command_id}`;
}
return {
content: [
{
type: 'text',
text: statusText
}
]
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `❌ Failed to poll command status: ${error instanceof Error ? error.message : 'Unknown error'}\n\n💡 Tip: Verify the command ID is correct. Command IDs are UUIDs returned when you submit a command.`
}
]
};
}
});
// Delete Command Files tool
server.registerTool("delete_command_files", {
title: "Delete Command Files",
description: "Delete all output files associated with a completed command. This frees up storage space on Rendi's servers. Note: This action cannot be undone.",
inputSchema: {
command_id: z.string().describe('The command ID of the files to delete')
}
}, async ({ command_id }) => {
try {
const client = getRendiClient(config);
await client.deleteCommandFiles(command_id);
return {
content: [
{
type: 'text',
text: `✅ Files deleted successfully!\n\n🆔 Command ID: ${command_id}\n\n🗑️ All output files associated with this command have been permanently deleted from Rendi's storage.\n\n⚠️ Note: This action cannot be undone. Make sure you've downloaded any files you need before deleting.`
}
]
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `❌ Failed to delete command files: ${error instanceof Error ? error.message : 'Unknown error'}\n\n💡 Possible reasons:\n• Command ID not found\n• Files already deleted\n• Command still processing (wait for completion first)\n• Invalid command ID format`
}
]
};
}
});
return server.server;
}
// Handle MCP requests at /mcp endpoint
app.all('/mcp', async (req: Request, res: Response) => {
try {
const result = parseAndValidateConfig(req, configSchema);
if (result.error) {
return res.status(400).json({ error: 'Configuration validation failed' });
}
// Reset client on each request to use new config
rendiClient = null;
const server = createServer({ config: result.value });
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
// Clean up on request close
res.on('close', () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling MCP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
id: null,
});
}
}
});
// Handle OPTIONS preflight requests
app.options('/mcp', (req: Request, res: Response) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, *');
res.header('Access-Control-Expose-Headers', 'mcp-session-id, mcp-protocol-version');
res.header('Access-Control-Allow-Credentials', 'true');
res.sendStatus(200);
});
// Health check endpoint
app.get("/health", (req: Request, res: Response) => {
res.json({ status: "ok", service: "rendi-mcp-server" });
});
// Start server
if (process.env.TRANSPORT !== "stdio") {
app.listen(PORT, () => {
console.log(`Rendi MCP Server running on port ${PORT}`);
console.log(`Health check: http://localhost:${PORT}/health`);
console.log(`MCP endpoint: http://localhost:${PORT}/mcp`);
});
}