UNPKG

@pictory/pictory-mcp-server

Version:

MCP server for Pictory AI video creation platform - now available as a Desktop Extension

279 lines (278 loc) 12.1 kB
#!/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 { PictoryAPI } from "./pictory-api.js"; // Enhanced error logging and debugging const DEBUG = process.env.DEBUG_PICTORY_MCP === 'true'; function debugLog(message, data) { if (DEBUG) { console.error(`[DEBUG] ${message}`, data ? JSON.stringify(data, null, 2) : ''); } } function errorLog(message, error) { console.error(`[ERROR] ${message}`, error?.message || error || ''); } // Progress tracking for jobs with timeout management const progressData = {}; const JOB_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes timeout // Enhanced environment validation function validateEnvironment() { const required = ['PICTORY_CLIENT_ID', 'PICTORY_CLIENT_SECRET', 'PICTORY_USER_ID']; const missing = required.filter(key => !process.env[key]?.trim()); if (missing.length > 0) { throw new Error(`Missing required environment variables: ${missing.join(', ')}`); } debugLog('Environment validation passed'); } // Create Pictory API instance with enhanced error handling let pictoryApi; try { validateEnvironment(); pictoryApi = new PictoryAPI(); debugLog('Pictory API initialized successfully'); } catch (error) { errorLog("Failed to initialize Pictory API", error); console.error("Please ensure all required environment variables are set:"); console.error("- PICTORY_CLIENT_ID"); console.error("- PICTORY_CLIENT_SECRET"); console.error("- PICTORY_USER_ID"); console.error("- PICTORY_API_BASE_URL (optional)"); process.exit(1); } // Create MCP server instance const server = new McpServer({ name: "pictory-dxt", version: "1.0.4", }); // Enhanced progress callback with timeout detection const progressCallback = (jobId, status) => { const now = Date.now(); if (!progressData[jobId]) { progressData[jobId] = { nCalls: 1, total: 60, startTime: now }; } const elapsed = now - progressData[jobId].startTime; if (elapsed > JOB_TIMEOUT_MS) { errorLog(`Job ${jobId} timed out after ${elapsed / 1000} seconds`); throw new Error(`Job timeout: ${jobId} exceeded maximum processing time`); } const progress = progressData[jobId].nCalls / progressData[jobId].total; debugLog(`Job ${jobId} progress: ${progress.toFixed(2)} (${progress * 100}%)`, { status, elapsed: elapsed / 1000 }); progressData[jobId].nCalls += 1; }; // Enhanced input validation schemas const createStoryboardSchema = z.object({ script: z.array(z.string().min(1).max(1000)).min(1).max(50).describe("The script - list of voice over text for each scene (1-50 scenes, each 1-1000 chars)"), title: z.string().max(200).optional().describe("The title of the video (max 200 characters)"), voice: z.string().max(50).optional().default("Amanda").describe("The voice to use for the video"), width: z.number().int().min(480).max(4096).optional().default(1920).describe("Video width in pixels (480-4096)"), height: z.number().int().min(360).max(4096).optional().default(1080).describe("Video height in pixels (360-4096)"), }); const jobIdSchema = z.object({ jobId: z.string().min(1).max(100).describe("The job ID"), }); const templateIdSchema = z.object({ templateId: z.string().min(1).max(100).describe("Template ID of the video template"), }); const templateVariablesSchema = z.object({ templateId: z.string().min(1).max(100).describe("Template ID of the video template"), templateVariables: z.record(z.string().max(1000)).describe("Template variables to be replaced (each value max 1000 chars)"), }); // Utility function for consistent error responses function createErrorResponse(operation, error) { const errorMessage = error instanceof Error ? error.message : String(error); errorLog(`${operation} failed`, error); return { content: [ { type: "text", text: `❌ ${operation} failed: ${errorMessage}`, }, ], }; } // Utility function for success responses function createSuccessResponse(message) { return { content: [ { type: "text", text: `✅ ${message}`, }, ], }; } // Register Pictory tools with enhanced validation and error handling server.tool("create-storyboard", "Create a storyboard using the Pictory API with comprehensive error handling and input validation", createStoryboardSchema.shape, async (params) => { try { debugLog('Creating storyboard', params); const { script, title = "", voice = "Amanda", width = 1920, height = 1080 } = params; const storyboardJobId = await pictoryApi.createStoryboard(script, title, voice, [width, height]); debugLog('Storyboard creation started', { jobId: storyboardJobId }); return createSuccessResponse(`Storyboard creation started. Job ID: ${storyboardJobId}`); } catch (error) { return createErrorResponse("Storyboard creation", error); } }); server.tool("poll-storyboard-job-status", "Poll the status of a storyboard job until completion with timeout protection", jobIdSchema.shape, async ({ jobId }) => { try { debugLog('Polling storyboard job status', { jobId }); const result = await pictoryApi.pollJobStatus(jobId, progressCallback); debugLog('Storyboard job completed', { jobId, status: result.status, hasPreview: !!result.preview }); return createSuccessResponse(`Storyboard job completed. Status: ${result.status}. Preview available: ${result.preview ? 'Yes' : 'No'}`); } catch (error) { return createErrorResponse("Storyboard job status polling", error); } }); server.tool("get-storyboard-preview", "Get the preview URL from a storyboard job with validation", jobIdSchema.shape, async ({ jobId }) => { try { debugLog('Getting storyboard preview', { jobId }); const result = await pictoryApi.pollJobStatus(jobId, progressCallback); if (!result.preview) { return { content: [ { type: "text", text: "⏳ Preview not yet available for this storyboard job. Please wait for completion.", }, ], }; } debugLog('Storyboard preview retrieved', { jobId, previewUrl: result.preview }); return createSuccessResponse(`Storyboard preview URL: ${result.preview}`); } catch (error) { return createErrorResponse("Getting storyboard preview", error); } }); server.tool("render-video", "Render a video from a storyboard with comprehensive validation", jobIdSchema.shape, async ({ jobId: storyboardJobId }) => { try { debugLog('Starting video render', { storyboardJobId }); const videoJobId = await pictoryApi.renderVideo(storyboardJobId); debugLog('Video render started', { storyboardJobId, videoJobId }); return createSuccessResponse(`Video rendering started. Job ID: ${videoJobId}`); } catch (error) { return createErrorResponse("Video rendering", error); } }); server.tool("poll-video-job-status", "Poll the status of a video rendering job until completion with timeout management", jobIdSchema.shape, async ({ jobId }) => { try { debugLog('Polling video job status', { jobId }); const result = await pictoryApi.pollJobStatus(jobId, progressCallback); debugLog('Video job completed', { jobId, status: result.status, hasOutputUrl: !!result.output_url }); return createSuccessResponse(`Video job completed. Status: ${result.status}. Output URL available: ${result.output_url ? 'Yes' : 'No'}`); } catch (error) { return createErrorResponse("Video job status polling", error); } }); server.tool("get-rendered-video-url", "Get the video URL from a video rendering job with validation", jobIdSchema.shape, async ({ jobId }) => { try { debugLog('Getting rendered video URL', { jobId }); const result = await pictoryApi.pollJobStatus(jobId, progressCallback); if (!result.output_url) { return { content: [ { type: "text", text: "⏳ Video URL not yet available for this rendering job. Please wait for completion.", }, ], }; } debugLog('Rendered video URL retrieved', { jobId, outputUrl: result.output_url }); return createSuccessResponse(`Rendered video URL: ${result.output_url}`); } catch (error) { return createErrorResponse("Getting rendered video URL", error); } }); server.tool("get-all-video-templates", "Get all video templates from Pictory API with error handling", {}, async () => { try { debugLog('Fetching all video templates'); const templates = await pictoryApi.getAllVideoTemplates(); debugLog('Video templates retrieved', { count: templates.length }); return { content: [ { type: "text", text: `📋 Found ${templates.length} video templates:\n\n${JSON.stringify(templates, null, 2)}`, }, ], }; } catch (error) { return createErrorResponse("Getting video templates", error); } }); server.tool("get-video-template-detail", "Get details of a specific video template from Pictory API with validation", templateIdSchema.shape, async ({ templateId }) => { try { debugLog('Getting video template detail', { templateId }); const templateDetail = await pictoryApi.getVideoTemplateById(templateId); debugLog('Video template detail retrieved', { templateId }); return { content: [ { type: "text", text: `📄 Template details:\n\n${JSON.stringify(templateDetail, null, 2)}`, }, ], }; } catch (error) { return createErrorResponse("Getting template details", error); } }); server.tool("create-storyboard-from-template", "Create a video storyboard from a template with comprehensive validation", templateVariablesSchema.shape, async ({ templateId, templateVariables }) => { try { debugLog('Creating storyboard from template', { templateId, variables: Object.keys(templateVariables) }); const storyboardJobId = await pictoryApi.createStoryboardFromTemplate(templateId, templateVariables); debugLog('Storyboard creation from template started', { templateId, jobId: storyboardJobId }); return createSuccessResponse(`Storyboard creation from template started. Job ID: ${storyboardJobId}`); } catch (error) { return createErrorResponse("Creating storyboard from template", error); } }); // Enhanced server startup with better error handling async function main() { try { const transport = new StdioServerTransport(); await server.connect(transport); debugLog("Pictory MCP Server connected successfully"); console.error("Pictory MCP Desktop Extension running on stdio"); } catch (error) { errorLog("Failed to start MCP server", error); process.exit(1); } } // Graceful shutdown handling process.on('SIGINT', () => { debugLog('Received SIGINT, shutting down gracefully'); process.exit(0); }); process.on('SIGTERM', () => { debugLog('Received SIGTERM, shutting down gracefully'); process.exit(0); }); process.on('uncaughtException', (error) => { errorLog('Uncaught exception', error); process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { errorLog('Unhandled rejection', { reason, promise }); process.exit(1); }); main().catch((error) => { errorLog("Fatal error in main", error); process.exit(1); });