@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
JavaScript
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);
});