fal-image-video-mcp
Version:
MCP server for FAL AI image and video generation with data URL support
1,025 lines • 54.3 kB
JavaScript
#!/usr/bin/env node
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
const sse_js_1 = require("@modelcontextprotocol/sdk/server/sse.js");
const http_1 = require("http");
const url_1 = require("url");
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
const client_1 = require("@fal-ai/client");
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const os = __importStar(require("os"));
const child_process_1 = require("child_process");
// Parse dot-notation query parameters into nested object
function parseDotNotation(params) {
const result = {};
for (const [key, value] of params.entries()) {
const keys = key.split('.');
let current = result;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (!(k in current)) {
current[k] = {};
}
current = current[k];
}
current[keys[keys.length - 1]] = value;
}
return result;
}
// Configure FAL client - will be lazily configured when needed
function configureFalClient(configOverride) {
const falKey = configOverride?.FAL_KEY ||
configOverride?.apiKey ||
process.env.FAL_KEY;
if (!falKey) {
throw new Error('FAL_KEY is required. Please configure your API key via environment variable or query parameter.');
}
client_1.fal.config({
credentials: falKey,
});
}
// Configure download path with container-safe fallback
function getDownloadPath() {
if (process.env.DOWNLOAD_PATH) {
return process.env.DOWNLOAD_PATH;
}
try {
const homeDir = os.homedir();
// Check if we're in a container with invalid home directory
if (homeDir === '/nonexistent' || homeDir === '/' || !homeDir) {
return '/tmp/fal-downloads';
}
return path.join(homeDir, 'Downloads');
}
catch (error) {
// Fallback for containers or restricted environments
return '/tmp/fal-downloads';
}
}
const DOWNLOAD_PATH = getDownloadPath();
// Configure data URL behavior
const ENABLE_DATA_URLS = process.env.ENABLE_DATA_URLS === 'true'; // Default: false (optimized for Claude Desktop)
const MAX_DATA_URL_SIZE = parseInt(process.env.MAX_DATA_URL_SIZE || '2097152'); // Default: 2MB
// Configure auto-open behavior (disabled in containers)
const AUTOOPEN = process.env.AUTOOPEN !== 'false' &&
!process.env.PORT && // Disable auto-open in HTTP mode (likely container)
process.platform !== 'linux'; // Disable auto-open on Linux containers
// Ensure download directory exists
try {
if (!fs.existsSync(DOWNLOAD_PATH)) {
fs.mkdirSync(DOWNLOAD_PATH, { recursive: true });
}
}
catch (error) {
console.warn(`Warning: Could not create download directory ${DOWNLOAD_PATH}:`, error);
console.warn('Downloads will be disabled for this session');
}
// Dynamic model registry - this could be updated via API call
const MODEL_REGISTRY = {
imageGeneration: [
{ id: 'imagen4', endpoint: 'fal-ai/imagen4/preview', name: 'Imagen 4', description: 'Google\'s latest text-to-image model' },
{ id: 'flux_kontext', endpoint: 'fal-ai/flux-pro/kontext/text-to-image', name: 'FLUX Kontext Pro', description: 'State-of-the-art prompt adherence and typography' },
{ id: 'ideogram_v3', endpoint: 'fal-ai/ideogram/v3', name: 'Ideogram V3', description: 'Advanced typography and realistic outputs' },
{ id: 'recraft_v3', endpoint: 'fal-ai/recraft/v3/text-to-image', name: 'Recraft V3', description: 'Professional design and illustration' },
{ id: 'stable_diffusion_35', endpoint: 'fal-ai/stable-diffusion-v35-large', name: 'Stable Diffusion 3.5 Large', description: 'Improved image quality and performance' },
{ id: 'flux_dev', endpoint: 'fal-ai/flux/dev', name: 'FLUX Dev', description: 'High-quality 12B parameter model' },
{ id: 'hidream', endpoint: 'fal-ai/hidream-i1-full', name: 'HiDream I1', description: 'High-resolution image generation' },
{ id: 'janus', endpoint: 'fal-ai/janus', name: 'Janus', description: 'Multimodal understanding and generation' }
],
textToVideo: [
{ id: 'veo3', endpoint: 'fal-ai/veo3', name: 'Veo 3', description: 'Google DeepMind\'s latest with speech and audio' },
{ id: 'kling_master_text', endpoint: 'fal-ai/kling-video/v2.1/master/text-to-video', name: 'Kling 2.1 Master', description: 'Premium text-to-video with motion fluidity' },
{ id: 'pixverse_text', endpoint: 'fal-ai/pixverse/v4.5/text-to-video', name: 'Pixverse V4.5', description: 'Advanced text-to-video generation' },
{ id: 'magi', endpoint: 'fal-ai/magi', name: 'Magi', description: 'Creative video generation' },
{ id: 'luma_ray2', endpoint: 'fal-ai/luma-dream-machine/ray-2', name: 'Luma Ray 2', description: 'Latest Luma Dream Machine' },
{ id: 'wan_pro_text', endpoint: 'fal-ai/wan-pro/text-to-video', name: 'Wan Pro', description: 'Professional video effects' },
{ id: 'vidu_text', endpoint: 'fal-ai/vidu/q1/text-to-video', name: 'Vidu Q1', description: 'High-quality text-to-video' }
],
imageToVideo: [
{ id: 'ltx_video', endpoint: 'fal-ai/ltx-video-13b-distilled/image-to-video', name: 'LTX Video', description: 'Fast and high-quality image-to-video conversion' },
{ id: 'kling_master_image', endpoint: 'fal-ai/kling-video/v2.1/master/image-to-video', name: 'Kling 2.1 Master I2V', description: 'Premium image-to-video conversion' },
{ id: 'pixverse_image', endpoint: 'fal-ai/pixverse/v4.5/image-to-video', name: 'Pixverse V4.5 I2V', description: 'Advanced image-to-video' },
{ id: 'wan_pro_image', endpoint: 'fal-ai/wan-pro/image-to-video', name: 'Wan Pro I2V', description: 'Professional image animation' },
{ id: 'hunyuan_image', endpoint: 'fal-ai/hunyuan-video-image-to-video', name: 'Hunyuan I2V', description: 'Open-source image-to-video' },
{ id: 'vidu_image', endpoint: 'fal-ai/vidu/image-to-video', name: 'Vidu I2V', description: 'High-quality image animation' },
{ id: 'luma_ray2_image', endpoint: 'fal-ai/luma-dream-machine/ray-2/image-to-video', name: 'Luma Ray 2 I2V', description: 'Latest Luma image-to-video' }
]
};
// Helper function to get all models
function getAllModels() {
return [
...MODEL_REGISTRY.imageGeneration,
...MODEL_REGISTRY.textToVideo,
...MODEL_REGISTRY.imageToVideo
];
}
// Helper function to get model by ID
function getModelById(id) {
const allModels = getAllModels();
return allModels.find(model => model.id === id);
}
async function urlToDataUrl(url) {
if (!ENABLE_DATA_URLS) {
return null;
}
try {
const response = await fetch(url);
const buffer = await response.arrayBuffer();
// Check size limit
if (buffer.byteLength > MAX_DATA_URL_SIZE) {
console.error(`File too large for data URL: ${buffer.byteLength} bytes (max: ${MAX_DATA_URL_SIZE})`);
return null;
}
const base64 = Buffer.from(buffer).toString('base64');
const contentType = response.headers.get('content-type') || 'image/jpeg';
return `data:${contentType};base64,${base64}`;
}
catch (error) {
console.error('Error converting URL to data URL:', error);
return null;
}
}
async function downloadFile(url, filename) {
try {
// Check if download directory is available
if (!fs.existsSync(DOWNLOAD_PATH)) {
console.warn('Download directory not available, skipping download');
return null;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const buffer = await response.arrayBuffer();
const fullPath = path.join(DOWNLOAD_PATH, filename);
fs.writeFileSync(fullPath, Buffer.from(buffer));
console.error(`Downloaded: ${fullPath}`);
return fullPath;
}
catch (error) {
console.warn('Error downloading file (continuing without download):', error);
return null;
}
}
function generateFilename(type, modelName, index) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const suffix = index !== undefined ? `_${index}` : '';
const extension = type === 'video' ? 'mp4' : 'jpg';
return `fal_${modelName}_${timestamp}${suffix}.${extension}`;
}
async function autoOpenFile(filePath) {
if (!AUTOOPEN) {
return;
}
try {
let command;
// Cross-platform file opening
switch (os.platform()) {
case 'darwin': // macOS
command = `open "${filePath}"`;
break;
case 'win32': // Windows
command = `start "" "${filePath}"`;
break;
default: // Linux and other Unix-like systems
command = `xdg-open "${filePath}"`;
break;
}
(0, child_process_1.exec)(command, (error, stdout, stderr) => {
if (error) {
console.error(`Failed to auto-open file: ${error.message}`);
return;
}
if (stderr) {
console.error(`Auto-open stderr: ${stderr}`);
return;
}
console.error(`Auto-opened: ${filePath}`);
});
}
catch (error) {
console.error('Error auto-opening file:', error);
}
}
async function downloadAndProcessImages(images, modelName) {
const processedImages = await Promise.all(images.map(async (image, index) => {
const filename = generateFilename('image', modelName, images.length > 1 ? index : undefined);
const localPath = await downloadFile(image.url, filename);
const dataUrl = await urlToDataUrl(image.url);
// Auto-open the downloaded image if available
if (localPath) {
await autoOpenFile(localPath);
}
const result = {
url: image.url,
width: image.width,
height: image.height,
};
// Only include localPath if download was successful
if (localPath) {
result.localPath = localPath;
}
// Only include dataUrl if it was successfully generated
if (dataUrl) {
result.dataUrl = dataUrl;
}
return result;
}));
return processedImages;
}
async function downloadAndProcessVideo(videoUrl, modelName) {
const filename = generateFilename('video', modelName);
const localPath = await downloadFile(videoUrl, filename);
const dataUrl = await urlToDataUrl(videoUrl);
// Auto-open the downloaded video if available
if (localPath) {
await autoOpenFile(localPath);
}
const result = {};
// Only include localPath if download was successful
if (localPath) {
result.localPath = localPath;
}
// Only include dataUrl if it was successfully generated
if (dataUrl) {
result.dataUrl = dataUrl;
}
return result;
}
class FalMcpServer {
constructor() {
this.currentQueryConfig = {};
this.server = new index_js_1.Server({
name: 'fal-image-video-mcp',
version: '1.0.7',
}, {
capabilities: {
tools: {},
resources: {},
prompts: {},
experimental: {
configSchema: true
}
},
});
this.setupToolHandlers();
this.setupConfigHandlers();
}
// Method to update query configuration for HTTP mode
setQueryConfig(config) {
this.currentQueryConfig = config;
}
generateToolSchema(model, category) {
const baseSchema = {
name: model.id,
description: `${model.name} - ${model.description}`,
inputSchema: {
type: 'object',
properties: {},
required: [],
},
};
if (category === 'imageGeneration') {
baseSchema.inputSchema.properties = {
prompt: { type: 'string', description: 'Text prompt for image generation' },
image_size: { type: 'string', enum: ['square_hd', 'square', 'portrait_4_3', 'portrait_16_9', 'landscape_4_3', 'landscape_16_9'], default: 'landscape_4_3' },
num_images: { type: 'number', default: 1, minimum: 1, maximum: 4 },
};
baseSchema.inputSchema.required = ['prompt'];
// Add model-specific parameters
if (model.id.includes('flux') || model.id.includes('stable_diffusion')) {
baseSchema.inputSchema.properties.num_inference_steps = { type: 'number', default: 25, minimum: 1, maximum: 50 };
baseSchema.inputSchema.properties.guidance_scale = { type: 'number', default: 3.5, minimum: 1, maximum: 20 };
}
if (model.id.includes('stable_diffusion') || model.id === 'ideogram_v3') {
baseSchema.inputSchema.properties.negative_prompt = { type: 'string', description: 'Negative prompt' };
}
}
else if (category === 'textToVideo') {
baseSchema.inputSchema.properties = {
prompt: { type: 'string', description: 'Text prompt for video generation' },
duration: { type: 'number', default: 5, minimum: 1, maximum: 30 },
aspect_ratio: { type: 'string', enum: ['16:9', '9:16', '1:1', '4:3', '3:4'], default: '16:9' },
};
baseSchema.inputSchema.required = ['prompt'];
}
else if (category === 'imageToVideo') {
baseSchema.inputSchema.properties = {
image_url: { type: 'string', description: 'URL of the input image' },
prompt: { type: 'string', description: 'Motion description prompt' },
duration: { type: 'string', enum: ['5', '10'], default: '5', description: 'Video duration in seconds' },
aspect_ratio: { type: 'string', enum: ['16:9', '9:16', '1:1'], default: '16:9' },
negative_prompt: { type: 'string', description: 'What to avoid in the video' },
cfg_scale: { type: 'number', default: 0.5, minimum: 0, maximum: 1, description: 'How closely to follow the prompt' }
};
baseSchema.inputSchema.required = ['image_url', 'prompt'];
}
return baseSchema;
}
setupToolHandlers() {
this.server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
const tools = [];
// Generate tools for each category
for (const model of MODEL_REGISTRY.imageGeneration) {
tools.push(this.generateToolSchema(model, 'imageGeneration'));
}
for (const model of MODEL_REGISTRY.textToVideo) {
tools.push(this.generateToolSchema(model, 'textToVideo'));
}
for (const model of MODEL_REGISTRY.imageToVideo) {
tools.push(this.generateToolSchema(model, 'imageToVideo'));
}
// Add generic tools for model discovery and custom execution
tools.push({
name: 'list_available_models',
description: 'List all available models in the current registry with their capabilities',
inputSchema: {
type: 'object',
properties: {
category: {
type: 'string',
enum: ['all', 'imageGeneration', 'textToVideo', 'imageToVideo'],
default: 'all',
description: 'Filter models by category'
}
},
required: []
}
});
tools.push({
name: 'execute_custom_model',
description: 'Execute any FAL model by specifying the endpoint directly',
inputSchema: {
type: 'object',
properties: {
endpoint: {
type: 'string',
description: 'FAL model endpoint (e.g., fal-ai/flux/schnell, fal-ai/custom-model)'
},
input_params: {
type: 'object',
description: 'Input parameters for the model (varies by model)'
},
category_hint: {
type: 'string',
enum: ['image', 'video', 'image_to_video', 'other'],
default: 'other',
description: 'Hint about the expected output type for proper handling'
}
},
required: ['endpoint', 'input_params']
}
});
return { tools };
});
this.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
// Handle special tools first
if (name === 'list_available_models') {
return await this.handleListModels(args);
}
else if (name === 'execute_custom_model') {
return await this.handleCustomModel(args);
}
const model = getModelById(name);
if (!model) {
throw new types_js_1.McpError(types_js_1.ErrorCode.MethodNotFound, `Unknown model: ${name}`);
}
// Determine category and handle accordingly
if (MODEL_REGISTRY.imageGeneration.find(m => m.id === name)) {
return await this.handleImageGeneration(args, model);
}
else if (MODEL_REGISTRY.textToVideo.find(m => m.id === name)) {
return await this.handleTextToVideo(args, model);
}
else if (MODEL_REGISTRY.imageToVideo.find(m => m.id === name)) {
return await this.handleImageToVideo(args, model);
}
throw new types_js_1.McpError(types_js_1.ErrorCode.MethodNotFound, `Unsupported model category for: ${name}`);
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, errorMessage);
}
});
}
async handleImageGeneration(args, model) {
const { prompt, image_size = 'landscape_4_3', num_inference_steps = 25, guidance_scale = 3.5, num_images = 1, negative_prompt, safety_tolerance, raw, } = args;
try {
// Configure FAL client lazily with query config override
configureFalClient(this.currentQueryConfig);
const inputParams = { prompt };
// Add common parameters
if (image_size)
inputParams.image_size = image_size;
if (num_images > 1)
inputParams.num_images = num_images;
// Add model-specific parameters based on model capabilities
if (model.id.includes('flux') || model.id.includes('stable_diffusion')) {
if (num_inference_steps)
inputParams.num_inference_steps = num_inference_steps;
if (guidance_scale)
inputParams.guidance_scale = guidance_scale;
}
if ((model.id.includes('stable_diffusion') || model.id === 'ideogram_v3') && negative_prompt) {
inputParams.negative_prompt = negative_prompt;
}
if (model.id.includes('flux_pro') && safety_tolerance) {
inputParams.safety_tolerance = safety_tolerance;
}
if (model.id === 'flux_pro_ultra' && raw !== undefined) {
inputParams.raw = raw;
}
const result = await client_1.fal.subscribe(model.endpoint, { input: inputParams });
const imageData = result.data;
const processedImages = await downloadAndProcessImages(imageData.images, model.id);
return {
content: [
{
type: 'text',
text: JSON.stringify({
model: model.name,
id: model.id,
endpoint: model.endpoint,
prompt,
images: processedImages,
metadata: inputParams,
download_path: DOWNLOAD_PATH,
data_url_settings: {
enabled: ENABLE_DATA_URLS,
max_size_mb: Math.round(MAX_DATA_URL_SIZE / 1024 / 1024),
},
autoopen_settings: {
enabled: AUTOOPEN,
note: AUTOOPEN ? "Files automatically opened with default application" : "Auto-open disabled"
},
}, null, 2),
},
],
};
}
catch (error) {
throw new Error(`${model.name} generation failed: ${error}`);
}
}
async handleImageToVideo(args, model) {
const { image_url, prompt, duration = '5', aspect_ratio = '16:9', negative_prompt, cfg_scale } = args;
try {
// Configure FAL client lazily with query config override
configureFalClient(this.currentQueryConfig);
const inputParams = { image_url, prompt };
// Add optional parameters
if (duration)
inputParams.duration = duration;
if (aspect_ratio)
inputParams.aspect_ratio = aspect_ratio;
if (negative_prompt)
inputParams.negative_prompt = negative_prompt;
if (cfg_scale !== undefined)
inputParams.cfg_scale = cfg_scale;
const result = await client_1.fal.subscribe(model.endpoint, { input: inputParams });
const videoData = result.data;
const videoProcessed = await downloadAndProcessVideo(videoData.video.url, model.id);
return {
content: [
{
type: 'text',
text: JSON.stringify({
model: model.name,
id: model.id,
endpoint: model.endpoint,
input_image: image_url,
prompt,
video: {
url: videoData.video.url,
localPath: videoProcessed.localPath,
...(videoProcessed.dataUrl && { dataUrl: videoProcessed.dataUrl }),
width: videoData.video.width,
height: videoData.video.height,
},
metadata: inputParams,
download_path: DOWNLOAD_PATH,
data_url_settings: {
enabled: ENABLE_DATA_URLS,
max_size_mb: Math.round(MAX_DATA_URL_SIZE / 1024 / 1024),
},
autoopen_settings: {
enabled: AUTOOPEN,
note: AUTOOPEN ? "Files automatically opened with default application" : "Auto-open disabled"
},
}, null, 2),
},
],
};
}
catch (error) {
throw new Error(`${model.name} generation failed: ${error}`);
}
}
async handleTextToVideo(args, model) {
const { prompt, duration = 5, aspect_ratio = '16:9' } = args;
try {
// Configure FAL client lazily with query config override
configureFalClient(this.currentQueryConfig);
const inputParams = { prompt };
if (duration)
inputParams.duration = duration;
if (aspect_ratio)
inputParams.aspect_ratio = aspect_ratio;
const result = await client_1.fal.subscribe(model.endpoint, { input: inputParams });
const videoData = result.data;
const videoProcessed = await downloadAndProcessVideo(videoData.video.url, model.id);
return {
content: [
{
type: 'text',
text: JSON.stringify({
model: model.name,
id: model.id,
endpoint: model.endpoint,
prompt,
video: {
url: videoData.video.url,
localPath: videoProcessed.localPath,
...(videoProcessed.dataUrl && { dataUrl: videoProcessed.dataUrl }),
width: videoData.video.width,
height: videoData.video.height,
},
metadata: inputParams,
download_path: DOWNLOAD_PATH,
data_url_settings: {
enabled: ENABLE_DATA_URLS,
max_size_mb: Math.round(MAX_DATA_URL_SIZE / 1024 / 1024),
},
autoopen_settings: {
enabled: AUTOOPEN,
note: AUTOOPEN ? "Files automatically opened with default application" : "Auto-open disabled"
},
}, null, 2),
},
],
};
}
catch (error) {
throw new Error(`${model.name} generation failed: ${error}`);
}
}
async handleListModels(args) {
const { category = 'all' } = args;
let modelsToList = [];
if (category === 'all') {
modelsToList = getAllModels();
}
else if (category === 'imageGeneration') {
modelsToList = MODEL_REGISTRY.imageGeneration;
}
else if (category === 'imageGeneration') {
modelsToList = MODEL_REGISTRY.imageGeneration;
}
else if (category === 'textToVideo') {
modelsToList = MODEL_REGISTRY.textToVideo;
}
else if (category === 'imageToVideo') {
modelsToList = MODEL_REGISTRY.imageToVideo;
}
const modelList = modelsToList.map(model => ({
id: model.id,
name: model.name,
description: model.description,
endpoint: model.endpoint,
category: MODEL_REGISTRY.imageGeneration.includes(model) ? 'imageGeneration' :
MODEL_REGISTRY.textToVideo.includes(model) ? 'textToVideo' :
MODEL_REGISTRY.imageToVideo.includes(model) ? 'imageToVideo' : 'unknown'
}));
return {
content: [
{
type: 'text',
text: JSON.stringify({
total_models: modelList.length,
category_filter: category,
models: modelList,
note: "Use 'execute_custom_model' to run any FAL endpoint not in this registry"
}, null, 2),
},
],
};
}
async handleCustomModel(args) {
const { endpoint, input_params, category_hint = 'other' } = args;
try {
// Configure FAL client lazily with query config override
configureFalClient(this.currentQueryConfig);
const result = await client_1.fal.subscribe(endpoint, { input: input_params });
// Handle different output types based on category hint
if (category_hint === 'image' || category_hint === 'other') {
// Assume image output
const data = result.data;
if (data.images && Array.isArray(data.images)) {
const processedImages = await downloadAndProcessImages(data.images, endpoint.replace(/[^a-zA-Z0-9]/g, '_'));
return {
content: [
{
type: 'text',
text: JSON.stringify({
endpoint,
category_hint,
images: processedImages,
raw_output: data,
input_params,
download_path: DOWNLOAD_PATH,
}, null, 2),
},
],
};
}
}
else if (category_hint === 'video' || category_hint === 'image_to_video') {
// Assume video output
const data = result.data;
if (data.video) {
const videoProcessed = await downloadAndProcessVideo(data.video.url, endpoint.replace(/[^a-zA-Z0-9]/g, '_'));
return {
content: [
{
type: 'text',
text: JSON.stringify({
endpoint,
category_hint,
video: {
url: data.video.url,
dataUrl: videoProcessed.dataUrl,
localPath: videoProcessed.localPath,
width: data.video.width,
height: data.video.height,
},
raw_output: data,
input_params,
download_path: DOWNLOAD_PATH,
}, null, 2),
},
],
};
}
}
// Fallback: return raw output
return {
content: [
{
type: 'text',
text: JSON.stringify({
endpoint,
category_hint,
raw_output: result.data,
input_params,
note: "Raw output - model type not recognized for enhanced processing"
}, null, 2),
},
],
};
}
catch (error) {
throw new Error(`Custom model execution failed for ${endpoint}: ${error}`);
}
}
setupConfigHandlers() {
// Add initialization handler for Smithery
this.server.setRequestHandler(types_js_1.InitializeRequestSchema, async (request) => {
return {
protocolVersion: '2024-11-05',
capabilities: {
tools: {},
resources: {},
prompts: {},
experimental: {
configSchema: {
type: 'object',
properties: {
FAL_KEY: {
type: 'string',
description: 'Your FAL AI API key for image and video generation'
}
},
required: ['FAL_KEY']
}
}
},
serverInfo: {
name: 'fal-image-video-mcp',
version: '1.0.8'
}
};
});
// Add MCP configuration schema handlers for Smithery
const configMethods = [
'config/schema',
'configuration/schema',
'server/config',
'mcp/config'
];
configMethods.forEach(method => {
try {
this.server.setRequestHandler({ method }, async () => {
return {
schema: {
type: 'object',
properties: {
FAL_KEY: {
type: 'string',
description: 'Your FAL AI API key for image and video generation'
}
},
required: ['FAL_KEY']
}
};
});
}
catch (error) {
// Method might not be supported, continue
}
});
}
async findAvailablePort(startPort) {
return new Promise((resolve) => {
const testServer = (0, http_1.createServer)();
testServer.listen(startPort, () => {
const port = testServer.address()?.port || startPort;
testServer.close(() => resolve(port));
});
testServer.on('error', () => {
resolve(this.findAvailablePort(startPort + 1));
});
});
}
async run() {
// Auto-detect HTTP mode: Smithery sets PORT, or explicit flags
const useHttp = !!process.env.PORT ||
process.env.MCP_TRANSPORT === 'http' ||
process.argv.includes('--http');
if (useHttp) {
const basePort = Number(process.env.PORT) || 3000;
// Use exact port if specified (Smithery), otherwise find available
const targetPort = process.env.PORT ?
basePort :
await this.findAvailablePort(basePort);
// HTTP transport for Smithery and testing
const httpServer = (0, http_1.createServer)(async (req, res) => {
// Log all incoming requests for debugging
console.error(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
console.error(`Headers:`, JSON.stringify(req.headers, null, 2));
// Parse URL and extract configuration from query parameters
const fullUrl = `http://localhost:${targetPort}${req.url}`;
const parsedUrl = new url_1.URL(fullUrl);
const queryConfig = parseDotNotation(parsedUrl.searchParams);
if (Object.keys(queryConfig).length > 0) {
console.error('Parsed query config:', JSON.stringify(queryConfig, null, 2));
}
// Store query config in a request context for tool handlers
req.queryConfig = queryConfig;
if (req.method === 'OPTIONS') {
res.writeHead(200, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
});
res.end();
return;
}
if (parsedUrl.pathname === '/mcp' && req.method === 'GET') {
// SSE endpoint for MCP communication - let SSE transport handle headers
const transport = new sse_js_1.SSEServerTransport('/mcp', res);
await this.server.connect(transport);
// Keep connection alive
req.on('close', () => {
transport.close?.();
});
}
else if (parsedUrl.pathname === '/mcp' && req.method === 'POST') {
// Handle MCP JSON-RPC messages directly
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', async () => {
try {
const message = JSON.parse(body);
console.error('Received MCP message:', JSON.stringify(message, null, 2));
// Set query config for tool execution
this.setQueryConfig(queryConfig);
// Handle MCP protocol messages directly
let response;
if (message.method === 'initialize') {
response = {
jsonrpc: '2.0',
id: message.id,
result: {
protocolVersion: '2024-11-05',
capabilities: {
tools: {},
resources: {},
prompts: {},
experimental: {
configSchema: {
type: 'object',
properties: {
FAL_KEY: {
type: 'string',
description: 'Your FAL AI API key for image and video generation'
},
apiKey: {
type: 'string',
description: 'Alternative name for FAL API key (alias for FAL_KEY)'
}
},
required: ['FAL_KEY']
}
}
},
serverInfo: {
name: 'fal-image-video-mcp',
version: '1.0.8'
},
...(Object.keys(queryConfig).length > 0 && {
configuration: {
received: queryConfig,
status: queryConfig.FAL_KEY || queryConfig.apiKey ? 'configured' : 'missing_api_key'
}
})
}
};
}
else if (message.method === 'tools/list') {
// Generate tools list directly
const tools = [];
// Add image generation tools
for (const model of MODEL_REGISTRY.imageGeneration) {
tools.push(this.generateToolSchema(model, 'imageGeneration'));
}
// Add video generation tools
for (const model of MODEL_REGISTRY.textToVideo) {
tools.push(this.generateToolSchema(model, 'textToVideo'));
}
// Add image-to-video tools
for (const model of MODEL_REGISTRY.imageToVideo) {
tools.push(this.generateToolSchema(model, 'imageToVideo'));
}
// Add utility tools
tools.push({
name: 'list_available_models',
description: 'List all available models in the current registry with their capabilities',
inputSchema: {
type: 'object',
properties: {
category: {
type: 'string',
enum: ['all', 'imageGeneration', 'textToVideo', 'imageToVideo'],
default: 'all',
description: 'Filter models by category'
}
},
required: []
}
});
tools.push({
name: 'execute_custom_model',
description: 'Execute any FAL model by specifying the endpoint directly',
inputSchema: {
type: 'object',
properties: {
endpoint: {
type: 'string',
description: 'FAL model endpoint (e.g., fal-ai/flux/schnell, fal-ai/custom-model)'
},
input_params: {
type: 'object',
description: 'Input parameters for the model (varies by model)'
},
category_hint: {
type: 'string',
enum: ['image', 'video', 'image_to_video', 'other'],
default: 'other',
description: 'Hint about the expected output type for proper handling'
}
},
required: ['endpoint', 'input_params']
}
});
response = {
jsonrpc: '2.0',
id: message.id,
result: { tools }
};
}
else if (message.method === 'tools/call') {
// Handle tool execution directly
try {
const { name, arguments: args } = message.params;
console.error('Executing tool:', name, 'with args:', args);
let toolResult;
// Handle special tools first
if (name === 'list_available_models') {
toolResult = await this.handleListModels(args);
}
else if (name === 'execute_custom_model') {
toolResult = await this.handleCustomModel(args);
}
else {
const model = getModelById(name);
if (!model) {
throw new Error(`Unknown model: ${name}`);
}
// Determine category and handle accordingly
if (MODEL_REGISTRY.imageGeneration.find(m => m.id === name)) {
toolResult = await this.handleImageGeneration(args, model);
}
else if (MODEL_REGISTRY.textToVideo.find(m => m.id === name)) {
toolResult = await this.handleTextToVideo(args, model);
}
else if (MODEL_REGISTRY.imageToVideo.find(m => m.id === name)) {
toolResult = await this.handleImageToVideo(args, model);
}
else {
throw new Error(`Unsupported model category for: ${name}`);
}
}
response = {
jsonrpc: '2.0',
id: message.id,
result: toolResult
};
}
catch (error) {
console.error('Tool execution error:', error);
response = {
jsonrpc: '2.0',
id: message.id,
error: {
code: -32603,
message: 'Internal error',
data: { error: String(error) }
}
};
}
}
else {
// Unknown method
response = {
jsonrpc: '2.0',
id: message.id,
error: {
code: -32601,
message: 'Method not found',
data: { method: message.method }
}
};
}
console.error('Sending MCP response:', JSON.stringify(response, null, 2));
res.writeHead(200, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
});
res.end(JSON.stringify(response));
}
catch (error) {
console.error('Error processing MCP message:', error);
res.writeHead(400, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
});
res.end(JSON.stringify({
jso