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.9 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. Speaker IDs are always included in the generated subtitles. Quick sync is enabled for faster processing.`, 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', 'xl8.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