UNPKG

@mediacat/mcp

Version:

A Model Context Protocol (MCP) server for MediaCAT's subtitle generation workflow with XL8.ai integration. Supports local file processing, real-time SSE updates, and dynamic language detection.

374 lines 16.8 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import { SSEManager } from './utils/sse-manager.js'; import { SyncService } from './services/sync.js'; import { readFileSync, mkdirSync, createWriteStream } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { homedir } from 'os'; // Get configuration from environment variables - must be before setupFileLogging const SERVER_API_KEY = process.env.XL8_API_KEY; const DEFAULT_WEBHOOK_URL = process.env.DEFAULT_WEBHOOK_URL; const LOG_PATH = process.env.LOG_PATH; // Setup file logging function - must be defined before use function setupFileLogging() { const logDir = LOG_PATH || join(homedir(), '.mediacat-mcp', 'logs'); mkdirSync(logDir, { recursive: true }); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const logFile = join(logDir, `mediacat-mcp-${timestamp}.log`); const logStream = createWriteStream(logFile, { flags: 'a' }); const log = (level, ...args) => { const timestamp = new Date().toISOString(); const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)).join(' '); const logLine = `[${timestamp}] [${level}] ${message}\n`; logStream.write(logLine); // Force flush immediately for each log entry logStream.uncork(); }; // Ensure log stream is properly closed on process exit const cleanup = () => { logStream.end(); }; process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); process.on('SIGQUIT', cleanup); process.on('exit', cleanup); return { log: (...args) => log('INFO', ...args), error: (...args) => log('ERROR', ...args), warn: (...args) => log('WARN', ...args), cleanup, logFile }; } // Detect stdio mode by checking if stdout is piped (not a TTY) const isStdioMode = !process.stdout.isTTY && !process.env.HTTP_ONLY && !process.env.DISABLE_MCP_STDIO; // Setup file logging FIRST if in stdio mode let fileLogger = null; if (isStdioMode) { fileLogger = setupFileLogging(); // Redirect all console output to log file immediately to prevent JSON-RPC pollution console.log = fileLogger.log; console.error = fileLogger.error; console.warn = fileLogger.warn; console.log('Starting MediaCat MCP Server in stdio mode'); } const app = express(); let HOST = process.env.HOST || 'localhost'; const PORT = Number(process.env.PORT) || 3000; const SSE_ENDPOINT_PATH = '/sse'; let BASE_URL = `http://${HOST}:${PORT}`; // Read version from package.json const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8')); const VERSION = packageJson.version; app.use(helmet({ contentSecurityPolicy: false, crossOriginOpenerPolicy: false, })); app.use(cors({ origin: true, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'Cache-Control', 'X-Api-Key', 'X-Client-Id', 'X-Filename', 'X-Language', 'X-Format', 'X-Session-Id', 'X-File-Path', 'Last-Event-ID'] })); // Apply JSON middleware to all routes except /messages app.use((req, res, next) => { if (req.path === '/messages') { // Skip JSON parsing for MCP messages endpoint next(); } else { express.json()(req, res, next); } }); class MediaCatMCPServer { server; sseManager; syncService; constructor() { this.sseManager = new SSEManager(); this.syncService = new SyncService(this.sseManager, SERVER_API_KEY); this.setupToolHandlers(); } setupToolHandlers() { this.server = new Server({ name: 'mediacat-mcp', version: VERSION, }, { capabilities: { tools: {}, }, }); this.server.setRequestHandler(ListToolsRequestSchema, async () => { return await this.handleListTools(); }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { return await this.handleCallTool(request); }); } /** * Handle listing available tools - can be called from both MCP handlers and HTTP endpoint */ async handleListTools() { const baseTools = [ { name: 'run_sync', description: `Run MediaCAT subtitle generation workflow (MediaCAT Sync). By default returns immediately with a requestId. To receive results asynchronously, provide webhook_url - the complete subtitle content will be POSTed to your URL when processing completes. Alternatively, set synchronous=true to wait for results (may timeout for long videos). For local servers, use filePath instead of fileData for better performance.`, inputSchema: { type: 'object', properties: { fileData: { type: 'string', description: 'Base64 encoded file data (use for remote servers). Mutually exclusive with filePath.', }, filePath: { type: 'string', description: 'Local file path (use for local servers - more efficient). Mutually exclusive with fileData.', }, filename: { type: 'string', description: 'Name of the video file', }, mimeType: { type: 'string', description: 'MIME type of the video file (e.g., video/mp4)', }, language: { type: 'string', description: 'Target language code (e.g., en-US, ko-KR, es-ES, fr-FR, de-DE)', }, format: { type: 'string', enum: ['srt', 'vtt', 'ttml', 'json'], description: 'Output subtitle format', }, apiKey: { type: 'string', description: SERVER_API_KEY ? 'XL8.ai API key (optional - server has default key configured)' : 'XL8.ai API key for authentication (required)', }, transcriptS3Key: { type: 'string', description: 'Optional S3 key of transcript file for reference', }, synchronous: { type: 'boolean', description: 'If true, wait for processing to complete and return the result directly with subtitle content. If false, return immediately with requestId for async tracking via SSE. Default: false', }, webhook_url: { type: 'string', description: (DEFAULT_WEBHOOK_URL ? '' : 'REQUIRED when synchronous is not true. ') + 'URL to receive results when processing completes. The full subtitle content and metadata will be POSTed as JSON to this URL.' + (DEFAULT_WEBHOOK_URL ? ` The default value is ${DEFAULT_WEBHOOK_URL}.` : '') }, }, required: SERVER_API_KEY ? ['filename', 'mimeType', 'language', 'format'] : ['filename', 'mimeType', 'language', 'format', 'apiKey'], }, }, // Future tools can be added here ]; return { tools: baseTools }; } /** * Handle tool calls - can be called from both MCP handlers and HTTP endpoint */ async handleCallTool(request) { const params = request.params; switch (params.name) { case 'run_sync': { if (!params.arguments) { throw new McpError(ErrorCode.InvalidParams, 'Arguments required for run_sync'); } // Use environment webhook_url if not provided in arguments const args = params.arguments; if (!args.webhook_url && DEFAULT_WEBHOOK_URL && !args.synchronous) { args.webhook_url = DEFAULT_WEBHOOK_URL; } const syncResult = await this.syncService.runSync(args); // Check if synchronous mode returned a result if (syncResult.result) { // Synchronous mode - return the result directly return { content: [ { type: 'text', text: JSON.stringify(syncResult.result) } ] }; } // Asynchronous mode - start the processing promise in the background if (syncResult.processingPromise) { syncResult.processingPromise.catch(error => { console.error(`Background processing failed for ${syncResult.requestId}:`, error); const errorData = { requestId: syncResult.requestId, error: error.message || 'Unknown error occurred during processing' }; // Send error to output channel for logging this.sseManager.sendToChannel("output", 'sync_failed', errorData); }); } return { content: [ { type: 'text', text: JSON.stringify({ success: true, requestId: syncResult.requestId, message: 'Subtitle generation started.', webhook_url: params.arguments.webhook_url, note: `Results will be POSTed to: ${params.arguments.webhook_url}` }) } ] }; } default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${params.name}`); } } setupMCPSSEEndpoints() { // Store active SSE transports const sseTransports = new Map(); // MCP SSE endpoint - follows the official MCP SSE protocol app.get('/sse', async (req, res) => { try { // Create SSE transport that will tell client where to POST messages const transport = new SSEServerTransport('/messages', res); const sessionId = transport.sessionId; // Store transport for later sseTransports.set(sessionId, transport); // Connect the server to the transport await this.server.connect(transport); // Clean up on disconnect req.on('close', () => { sseTransports.delete(sessionId); }); this.sseManager.addClient(sessionId, res, req.headers['last-event-id']); } catch (error) { console.error('Failed to establish MCP SSE connection:', error); res.status(500).json({ error: 'Failed to establish SSE connection' }); } }); // MCP message endpoint - handles POST messages from clients app.post('/messages', async (req, res) => { const sessionId = req.query.sessionId; if (!sessionId) { return res.status(400).json({ error: 'sessionId required' }); } const transport = sseTransports.get(sessionId); if (!transport) { return res.status(404).json({ error: 'Session not found' }); } try { // Let the transport handle the incoming message await transport.handlePostMessage(req, res); } catch (error) { console.error('Error handling MCP message:', error); res.status(500).json({ error: 'Failed to process message' }); } }); app.get('/sse/output', (req, res) => { // Set up SSE headers res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Cache-Control', }); this.sseManager.addClient("output", res, req.headers['last-event-id']); this.sseManager.sendToResponse(res, 'connected', {}); this.sseManager.sendToResponse(res, 'connected1', { 'key': 'value' }); this.sseManager.sendToResponse(res, 'connected2', { 'key1': 'value2' }); // Clean up on disconnect req.on('close', () => { // sseTransports.delete(sessionId); }); }); app.get('/health', (_req, res) => { return res.json({ status: 'healthy', connectedClients: this.sseManager.getClientCount(), serverApiKey: SERVER_API_KEY ? 'configured' : 'not configured', timestamp: new Date().toISOString(), ssePort: PORT, sseUrl: BASE_URL, }); }); } async start() { // Use stdio mode if stdout is piped (MCP client connected) if (isStdioMode) { if (fileLogger) { console.log(`Logging to: ${fileLogger.logFile}`); } const transport = new StdioServerTransport(); await this.server.connect(transport); console.log('MediaCat MCP Server running on stdio'); return; } this.setupMCPSSEEndpoints(); // Only start HTTP server if explicitly requested app.listen(PORT, () => { console.log(`Server running at ${BASE_URL}`); console.log(`MCP SSE endpoint at ${BASE_URL}/sse`); console.log(`Request-specific SSE at ${BASE_URL}/sse/:requestId`); console.log(`Health check at ${BASE_URL}/health`); if (SERVER_API_KEY) { console.log(`✅ Server API key configured - clients can omit API key`); } else { console.log(`⚠️ No server API key configured - clients must provide API key`); } }); console.log('MediaCat HTTP server running (MCP stdio disabled)'); } } const server = new MediaCatMCPServer(); // Graceful shutdown handling let isShuttingDown = false; const shutdown = async (signal) => { if (isShuttingDown) return; isShuttingDown = true; console.log(`\nReceived ${signal}. Shutting down gracefully...`); try { // Close any active connections, cleanup resources // The server will automatically close when the process exits process.exit(0); } catch (error) { console.error('Error during shutdown:', error); process.exit(1); } }; // Register signal handlers process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGQUIT', () => shutdown('SIGQUIT')); // Handle uncaught exceptions gracefully process.on('uncaughtException', (error) => { console.error('Uncaught Exception:', error); shutdown('uncaughtException'); }); process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection at:', promise, 'reason:', reason); shutdown('unhandledRejection'); }); server.start().catch(console.error); //# sourceMappingURL=index.js.map