@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
JavaScript
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