Game Asset Generator

by MubarakHAlketbi
Verified
#!/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 { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourceTemplatesRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { Client } from "@gradio/client"; import { InferenceClient } from "@huggingface/inference"; import { promises as fs } from "fs"; import path from "path"; import express from "express"; import crypto from "crypto"; import dotenv from "dotenv"; import { z } from "zod"; import https from "https"; // Load environment variables from .env file console.error("========== ENVIRONMENT VARIABLE DEBUGGING =========="); console.error(`Current working directory: ${process.cwd()}`); // Check if .env file exists try { const envPath = path.join(process.cwd(), '.env'); const envExamplePath = path.join(process.cwd(), '.env.example'); console.error(`Checking if .env file exists at: ${envPath}`); const envExists = fs.existsSync(envPath); console.error(`.env file exists: ${envExists}`); if (envExists) { console.error(`Reading contents of .env file:`); const envContents = fs.readFileSync(envPath, 'utf8'); console.error(`----- .env file contents -----`); console.error(envContents); console.error(`-----------------------------`); } console.error(`Checking if .env.example file exists at: ${envExamplePath}`); const envExampleExists = fs.existsSync(envExamplePath); console.error(`.env.example file exists: ${envExampleExists}`); } catch (error) { console.error(`Error checking .env files: ${error.message}`); } // Force load from specific path and override existing environment variables console.error(`Loading .env file explicitly from path with override...`); const dotenvResult = dotenv.config({ path: path.join(process.cwd(), '.env'), override: true // This makes .env variables override system environment variables }); if (dotenvResult.error) { console.error(`Error loading .env file: ${dotenvResult.error.message}`); } else { console.error(`.env file loaded successfully from: ${dotenvResult.parsed ? Object.keys(dotenvResult.parsed).length + " variables" : "unknown"}`); if (dotenvResult.parsed) { console.error(`Parsed variables: ${JSON.stringify(dotenvResult.parsed)}`); } } // Force reload the MODEL_SPACE variable from .env to ensure it overrides system variables if (dotenvResult.parsed && dotenvResult.parsed.MODEL_SPACE) { console.error(`Forcing MODEL_SPACE to value from .env: "${dotenvResult.parsed.MODEL_SPACE}"`); process.env.MODEL_SPACE = dotenvResult.parsed.MODEL_SPACE; } // Debug log for environment variables console.error(`ENV: MODEL_SPACE = "${process.env.MODEL_SPACE}"`); console.error(`ENV: HF_TOKEN = "${process.env.HF_TOKEN ? "***" + process.env.HF_TOKEN.substring(process.env.HF_TOKEN.length - 4) : "not set"}"`); console.error(`ENV: MODEL_3D_STEPS = "${process.env.MODEL_3D_STEPS || "not set"}"`); console.error(`ENV: MODEL_3D_GUIDANCE_SCALE = "${process.env.MODEL_3D_GUIDANCE_SCALE || "not set"}"`); console.error(`ENV: MODEL_3D_OCTREE_RESOLUTION = "${process.env.MODEL_3D_OCTREE_RESOLUTION || "not set"}"`); console.error(`ENV: MODEL_3D_SEED = "${process.env.MODEL_3D_SEED || "not set"}"`); console.error(`ENV: MODEL_3D_REMOVE_BACKGROUND = "${process.env.MODEL_3D_REMOVE_BACKGROUND || "not set"}"`); console.error(`ENV: MODEL_3D_TURBO_MODE = "${process.env.MODEL_3D_TURBO_MODE || "not set"}"`); // Verify MODEL_SPACE is set correctly console.error(`VERIFICATION: MODEL_SPACE should be set to the value from .env file`); console.error(`VERIFICATION: Expected value from .env: "mubarak-alketbi/Hunyuan3D-2mini-Turbo"`); console.error(`VERIFICATION: Actual value in process.env: "${process.env.MODEL_SPACE}"`); console.error(`VERIFICATION: Is correct? ${process.env.MODEL_SPACE === "mubarak-alketbi/Hunyuan3D-2mini-Turbo"}`); console.error("===================================================="); // Allow working directory to be specified via command-line argument const workDir = process.argv[2] || process.cwd(); // Logging function with file output async function log(level = 'INFO', message) { // If only one parameter is provided, assume it's the message if (!message) { message = level; level = 'INFO'; } const timestamp = new Date().toISOString(); const logMessage = `[${level.toUpperCase()}] ${timestamp} - ${message}\n`; // Log to console console.error(logMessage.trim()); // Log to file try { const logDir = path.join(workDir, 'logs'); await fs.mkdir(logDir, { recursive: true }); const logFile = path.join(logDir, 'server.log'); await fs.appendFile(logFile, logMessage); } catch (err) { console.error(`Failed to write to log file: ${err}`); } } // Enhanced logging with operation ID for tracking long-running operations let operationCounter = 0; // Global object to store operation updates that can be accessed by clients global.operationUpdates = {}; // Function to notify clients that the resource list has changed async function notifyResourceListChanged() { await log('DEBUG', "Notifying clients of resource list change"); await server.notification({ method: "notifications/resources/list_changed" }); // For SSE transport, notify all connected clients if (global.transports && global.transports.size > 0) { for (const [clientId, transport] of global.transports) { try { await transport.sendNotification({ method: "notifications/resources/list_changed" }); await log('DEBUG', `Sent resource list change notification to client ${clientId}`); } catch (error) { await log('ERROR', `Failed to send notification to client ${clientId}: ${error.message}`); } } } } async function logOperation(toolName, operationId, status, details = {}) { const level = status === 'ERROR' ? 'ERROR' : 'INFO'; const detailsStr = Object.entries(details) .map(([key, value]) => `${key}: ${value}`) .join(', '); const logMessage = `Operation ${operationId} [${toolName}] - ${status}${detailsStr ? ' - ' + detailsStr : ''}`; await log(level, logMessage); // Store the update in the global object for potential client access if (!global.operationUpdates[operationId]) { global.operationUpdates[operationId] = []; } global.operationUpdates[operationId].push({ status: status, details: details, timestamp: new Date().toISOString(), message: logMessage }); // Limit the size of the updates array to prevent memory issues if (global.operationUpdates[operationId].length > 100) { global.operationUpdates[operationId].shift(); // Remove the oldest update } } // Retry function with exponential backoff async function retryWithBackoff(operation, operationId = null, maxRetries = 3, initialDelay = 5000) { let retries = 0; let delay = initialDelay; while (true) { try { return await operation(); } catch (error) { retries++; // Check if we've exceeded the maximum number of retries if (retries > maxRetries) { throw error; } // Check if the error is due to GPU quota with improved regex to handle multiple time formats const gpuQuotaMatch = error.message?.match(/exceeded your GPU quota.*(?:retry|wait)\s*(?:in|after)?\s*(?:(\d+):(\d+):(\d+)|(\d+)\s*(?:seconds|s)|(\d+)\s*(?:minutes|m)|(\d+)\s*(?:hours|h))/i); if (gpuQuotaMatch) { let waitTime; let waitTimeSource = "unknown format"; if (gpuQuotaMatch[1]) { // HH:MM:SS format const hours = parseInt(gpuQuotaMatch[1]) || 0; const minutes = parseInt(gpuQuotaMatch[2]) || 0; const seconds = parseInt(gpuQuotaMatch[3]) || 0; waitTime = (hours * 3600 + minutes * 60 + seconds) * 1000; waitTimeSource = `${hours}h:${minutes}m:${seconds}s format`; } else if (gpuQuotaMatch[4]) { // Seconds format waitTime = parseInt(gpuQuotaMatch[4]) * 1000; waitTimeSource = `${gpuQuotaMatch[4]} seconds format`; } else if (gpuQuotaMatch[5]) { // Minutes format waitTime = parseInt(gpuQuotaMatch[5]) * 60 * 1000; waitTimeSource = `${gpuQuotaMatch[5]} minutes format`; } else if (gpuQuotaMatch[6]) { // Hours format waitTime = parseInt(gpuQuotaMatch[6]) * 3600 * 1000; waitTimeSource = `${gpuQuotaMatch[6]} hours format`; } // If no valid time was parsed, use a safe default if (!waitTime || waitTime <= 0) { waitTime = 60 * 1000; // Default to 60 seconds waitTimeSource = "default fallback"; } const waitTimeSeconds = Math.ceil(waitTime/1000); const waitMessage = `GPU quota exceeded. Waiting for ${waitTimeSeconds} seconds before retry ${retries}/${maxRetries} (detected from ${waitTimeSource})`; await log('WARN', waitMessage); // If this is part of a 3D asset generation operation, update the client // Only if operationId is provided and the updates object exists if (operationId && global.operationUpdates && global.operationUpdates[operationId]) { global.operationUpdates[operationId].push({ status: "WAITING", message: waitMessage, retryCount: retries, maxRetries: maxRetries, waitTime: waitTimeSeconds, timestamp: new Date().toISOString() }); } await new Promise(resolve => setTimeout(resolve, waitTime + 1000)); // Add 1 second buffer } else if (error.message?.includes("GPU quota") || error.message?.includes("quota exceeded")) { // GPU quota error detected but format not recognized const defaultDelay = 60 * 1000; // 60 seconds as a safe default await log('WARN', `GPU quota error with unrecognized format: "${error.message}". Using default wait time of ${defaultDelay/1000} seconds before retry ${retries}/${maxRetries}`); // Update operation status if available if (operationId && global.operationUpdates && global.operationUpdates[operationId]) { global.operationUpdates[operationId].push({ status: "WAITING", message: `GPU quota exceeded. Using default wait time of ${defaultDelay/1000} seconds before retry ${retries}/${maxRetries}`, retryCount: retries, maxRetries: maxRetries, waitTime: defaultDelay/1000, timestamp: new Date().toISOString() }); } await new Promise(resolve => setTimeout(resolve, defaultDelay)); } else { // For other errors, use exponential backoff await log('WARN', `Operation failed: ${error.message}. Retrying in ${delay/1000} seconds (${retries}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); delay *= 2; // Exponential backoff } } } } // Define MCP Error Codes const MCP_ERROR_CODES = { InvalidRequest: -32600, MethodNotFound: -32601, InvalidParams: -32602, InternalError: -32603, ParseError: -32700 }; // Initialize MCP server const server = new Server( { name: "game-asset-generator", version: "0.3.0" }, // Updated to version 0.3.0 with Hunyuan3D-2mini-Turbo support { capabilities: { tools: { list: true, call: true }, resources: { list: true, read: true, listChanged: true }, // Added listChanged capability prompts: { list: true, get: true } } } ); // Create working directory if it doesn't exist await fs.mkdir(workDir, { recursive: true }); // Create a dedicated assets directory const assetsDir = path.join(workDir, "assets"); await fs.mkdir(assetsDir, { recursive: true }); // Simple rate limiting const rateLimits = new Map(); function checkRateLimit(clientId, limit = 10, windowMs = 60000) { const now = Date.now(); const clientKey = clientId || 'default'; if (!rateLimits.has(clientKey)) { rateLimits.set(clientKey, { count: 1, resetAt: now + windowMs }); return true; } const clientLimit = rateLimits.get(clientKey); if (now > clientLimit.resetAt) { clientLimit.count = 1; clientLimit.resetAt = now + windowMs; return true; } if (clientLimit.count >= limit) { return false; } clientLimit.count++; return true; } await fs.mkdir(workDir, { recursive: true }); // Define Zod schemas for input validation const schema2D = z.object({ prompt: z.string().min(1).max(500).transform(val => sanitizePrompt(val)) }); const schema3D = z.object({ prompt: z.string().min(1).max(500).transform(val => sanitizePrompt(val)) }); // Tool definitions const TOOLS = { GENERATE_2D_ASSET: { name: "generate_2d_asset", description: "Generate a 2D game asset (e.g., pixel art sprite) from a text prompt.", inputSchema: { type: "object", properties: { prompt: { type: "string", description: "Text description of the 2D asset (e.g., 'pixel art sword')" } }, required: ["prompt"] } }, GENERATE_3D_ASSET: { name: "generate_3d_asset", description: "Generate a 3D game asset (e.g., OBJ model) from a text prompt.", inputSchema: { type: "object", properties: { prompt: { type: "string", description: "Text description of the 3D asset (e.g., 'isometric 3D castle')" } }, required: ["prompt"] } } }; // Get environment variables const hfToken = process.env.HF_TOKEN; // Get MODEL_SPACE from environment variable with fallback const modelSpaceFromEnv = process.env.MODEL_SPACE; const modelSpace = modelSpaceFromEnv || "mubarak-alketbi/InstantMesh"; // Debug log for modelSpace console.error("========== MODEL SPACE DEBUGGING =========="); console.error(`MODEL_SPACE from environment: "${modelSpaceFromEnv}"`); console.error(`MODEL_SPACE after fallback: "${modelSpace}"`); console.error(`Is MODEL_SPACE using default fallback? ${!modelSpaceFromEnv}`); console.error(`Does MODEL_SPACE include "hunyuan"? ${modelSpace.toLowerCase().includes("hunyuan")}`); console.error(`Does MODEL_SPACE include "hunyuan3d-2mini"? ${modelSpace.toLowerCase().includes("hunyuan3d-2mini")}`); console.error(`Does MODEL_SPACE include "instantmesh"? ${modelSpace.toLowerCase().includes("instantmesh")}`); console.error("==========================================="); // Get and validate 3D model configuration parameters from environment variables with defaults // Function to validate numeric value within a range function validateNumericRange(value, min, max, defaultValue, paramName) { if (value === null || value === undefined) { return defaultValue; } const numValue = Number(value); if (isNaN(numValue)) { console.error(`Invalid ${paramName} value: "${value}" is not a number. Using default: ${defaultValue}`); return defaultValue; } if (numValue < min) { console.error(`${paramName} value ${numValue} is below minimum (${min}). Using minimum value.`); return min; } if (numValue > max) { console.error(`${paramName} value ${numValue} exceeds maximum (${max}). Using maximum value.`); return max; } return numValue; } // Function to validate enum values function validateEnum(value, allowedValues, defaultValue, paramName) { if (value === null || value === undefined) { return defaultValue; } if (!allowedValues.includes(value)) { console.error(`Invalid ${paramName} value: "${value}". Allowed values: [${allowedValues.join(', ')}]. Using default: ${defaultValue}`); return defaultValue; } return value; } // Parse and validate steps (will be validated per model type later) const model3dSteps = process.env.MODEL_3D_STEPS ? parseInt(process.env.MODEL_3D_STEPS) : null; // Parse and validate guidance scale (0.0-100.0) const model3dGuidanceScale = process.env.MODEL_3D_GUIDANCE_SCALE ? validateNumericRange(parseFloat(process.env.MODEL_3D_GUIDANCE_SCALE), 0.0, 100.0, null, "MODEL_3D_GUIDANCE_SCALE") : null; // Parse octree resolution (will be validated per model type later) const model3dOctreeResolution = process.env.MODEL_3D_OCTREE_RESOLUTION || null; // Parse and validate seed (0-10000000) const model3dSeed = process.env.MODEL_3D_SEED ? validateNumericRange(parseInt(process.env.MODEL_3D_SEED), 0, 10000000, null, "MODEL_3D_SEED") : null; // Parse and validate remove background (boolean) const model3dRemoveBackground = process.env.MODEL_3D_REMOVE_BACKGROUND ? process.env.MODEL_3D_REMOVE_BACKGROUND.toLowerCase() === 'true' : true; // Default to true if not specified // Parse and validate turbo mode (enum: "Turbo", "Fast", "Standard") const validTurboModes = ["Turbo", "Fast", "Standard"]; const model3dTurboMode = validateEnum( process.env.MODEL_3D_TURBO_MODE, validTurboModes, "Turbo", "MODEL_3D_TURBO_MODE" ); // Space types const SPACE_TYPE = { INSTANTMESH: "instantmesh", HUNYUAN3D: "hunyuan3d", HUNYUAN3D_MINI_TURBO: "hunyuan3d_mini_turbo", UNKNOWN: "unknown" }; // Space detection state let detectedSpaceType = SPACE_TYPE.UNKNOWN; // Validate space format function validateSpaceFormat(space) { // Check if the space follows the format "username/space-name" if (!space) { return false; } // Check basic format with regex const spaceRegex = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/; if (!spaceRegex.test(space)) { return false; } // Additional validation const parts = space.split('/'); if (parts.length !== 2) { return false; } const [username, spaceName] = parts; // Username and space name should be at least 2 characters if (username.length < 2 || spaceName.length < 2) { return false; } return true; } // Detect which space was duplicated by checking available endpoints using view_api() async function detectSpaceType(client) { try { await log('INFO', "Detecting space type using view_api()..."); await log('DEBUG', "========== SPACE DETECTION DEBUGGING =========="); await log('DEBUG', `Current modelSpace: "${modelSpace}"`); await log('DEBUG', `modelSpace lowercase: "${modelSpace.toLowerCase()}"`); await log('DEBUG', `Contains "instantmesh": ${modelSpace.toLowerCase().includes("instantmesh")}`); await log('DEBUG', `Contains "hunyuan3d-2mini-turbo": ${modelSpace.toLowerCase().includes("hunyuan3d-2mini-turbo")}`); await log('DEBUG', `Contains "hunyuan3d-2mini": ${modelSpace.toLowerCase().includes("hunyuan3d-2mini")}`); await log('DEBUG', `Contains "hunyuan3dmini": ${modelSpace.toLowerCase().includes("hunyuan3dmini")}`); await log('DEBUG', `Contains "hunyuan": ${modelSpace.toLowerCase().includes("hunyuan")}`); await log('DEBUG', "=============================================="); // First, check if the space name contains a hint about the type // Check for Hunyuan3D-2mini-Turbo first (most specific match) if (modelSpace.toLowerCase().includes("hunyuan3d-2mini-turbo") || modelSpace.toLowerCase().includes("hunyuan3d-2mini") || modelSpace.toLowerCase().includes("hunyuan3dmini")) { detectedSpaceType = SPACE_TYPE.HUNYUAN3D_MINI_TURBO; await log('INFO', `Detected space type: Hunyuan3D-2mini-Turbo (based on space name)`); await log('DEBUG', `Space detection result: HUNYUAN3D_MINI_TURBO (${SPACE_TYPE.HUNYUAN3D_MINI_TURBO})`); return SPACE_TYPE.HUNYUAN3D_MINI_TURBO; } // Then check for regular Hunyuan3D-2 else if (modelSpace.toLowerCase().includes("hunyuan")) { detectedSpaceType = SPACE_TYPE.HUNYUAN3D; await log('INFO', `Detected space type: Hunyuan3D-2 (based on space name)`); await log('DEBUG', `Space detection result: HUNYUAN3D (${SPACE_TYPE.HUNYUAN3D})`); return SPACE_TYPE.HUNYUAN3D; } // Finally check for InstantMesh else if (modelSpace.toLowerCase().includes("instantmesh")) { detectedSpaceType = SPACE_TYPE.INSTANTMESH; await log('INFO', `Detected space type: InstantMesh (based on space name)`); await log('DEBUG', `Space detection result: INSTANTMESH (${SPACE_TYPE.INSTANTMESH})`); return SPACE_TYPE.INSTANTMESH; } await log('DEBUG', "No space type detected from name, continuing with API endpoint detection..."); // Try a direct predict call to test if the client is working try { await log('DEBUG', "Testing client with a simple predict call..."); // Try a simple predict call with an empty API name const result = await client.predict("", []); await log('DEBUG', `Predict call result: ${JSON.stringify(result)}`); } catch (predictError) { await log('DEBUG', `Simple predict call error: ${predictError.message}`); // This is expected to fail, but it helps test if the client is working } // Add a timeout to the view_api call await log('DEBUG', "Creating view_api promise..."); const apiInfoPromise = client.view_api(true); // true to show all endpoints // Create a timeout promise const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error("view_api call timed out after 30 seconds")); }, 30000); // 30 second timeout (increased from 20 seconds) }); // Race the API info promise against the timeout await log('DEBUG', "Starting view_api call with 30 second timeout..."); const apiInfo = await Promise.race([apiInfoPromise, timeoutPromise]); // Log the full API info for debugging await log('DEBUG', `API info retrieved: ${JSON.stringify(apiInfo, null, 2)}`); // Check for InstantMesh-specific endpoints in named_endpoints if (apiInfo && apiInfo.named_endpoints) { const endpoints = Object.keys(apiInfo.named_endpoints); await log('DEBUG', `Available endpoints: ${endpoints.join(', ')}`); // Check for InstantMesh-specific endpoints if (endpoints.includes("/check_input_image") || endpoints.includes("/make3d") || endpoints.includes("/generate_mvs") || endpoints.includes("/preprocess")) { detectedSpaceType = SPACE_TYPE.INSTANTMESH; await log('INFO', `Detected space type: InstantMesh (based on API endpoints)`); return SPACE_TYPE.INSTANTMESH; } // Check for Hunyuan3D-2mini-Turbo-specific endpoints if (endpoints.includes("/on_gen_mode_change") || endpoints.includes("/on_decode_mode_change") || endpoints.includes("/on_export_click")) { detectedSpaceType = SPACE_TYPE.HUNYUAN3D_MINI_TURBO; await log('INFO', `Detected space type: Hunyuan3D-2mini-Turbo (based on API endpoints)`); return SPACE_TYPE.HUNYUAN3D_MINI_TURBO; } // Check for Hunyuan3D-specific endpoints if (endpoints.includes("/shape_generation") || endpoints.includes("/generation_all")) { detectedSpaceType = SPACE_TYPE.HUNYUAN3D; await log('INFO', `Detected space type: Hunyuan3D-2 (based on API endpoints)`); return SPACE_TYPE.HUNYUAN3D; } } // If we get here, we couldn't determine the space type from named_endpoints // Check unnamed_endpoints as well if (apiInfo && apiInfo.unnamed_endpoints) { const unnamedEndpoints = Object.keys(apiInfo.unnamed_endpoints); await log('DEBUG', `Available unnamed endpoints: ${unnamedEndpoints.join(', ')}`); // Check for InstantMesh-specific endpoints in unnamed_endpoints if (unnamedEndpoints.some(endpoint => endpoint.includes("check_input_image") || endpoint.includes("make3d") || endpoint.includes("generate_mvs") || endpoint.includes("preprocess"))) { detectedSpaceType = SPACE_TYPE.INSTANTMESH; await log('INFO', `Detected space type: InstantMesh (based on unnamed API endpoints)`); return SPACE_TYPE.INSTANTMESH; } // Check for Hunyuan3D-2mini-Turbo-specific endpoints in unnamed_endpoints if (unnamedEndpoints.some(endpoint => endpoint.includes("on_gen_mode_change") || endpoint.includes("on_decode_mode_change") || endpoint.includes("on_export_click"))) { detectedSpaceType = SPACE_TYPE.HUNYUAN3D_MINI_TURBO; await log('INFO', `Detected space type: Hunyuan3D-2mini-Turbo (based on unnamed API endpoints)`); return SPACE_TYPE.HUNYUAN3D_MINI_TURBO; } // Check for Hunyuan3D-specific endpoints in unnamed_endpoints if (unnamedEndpoints.some(endpoint => endpoint.includes("shape_generation") || endpoint.includes("generation_all"))) { detectedSpaceType = SPACE_TYPE.HUNYUAN3D; await log('INFO', `Detected space type: Hunyuan3D-2 (based on unnamed API endpoints)`); return SPACE_TYPE.HUNYUAN3D; } } // If we still can't determine the space type, check the space name as a hint await log('DEBUG', "Fallback space detection from name..."); // Check for Hunyuan3D-2mini-Turbo first (most specific match) if (modelSpace.toLowerCase().includes("hunyuan3d-2mini-turbo") || modelSpace.toLowerCase().includes("hunyuan3d-2mini") || modelSpace.toLowerCase().includes("hunyuan3dmini")) { detectedSpaceType = SPACE_TYPE.HUNYUAN3D_MINI_TURBO; await log('INFO', `Detected space type: Hunyuan3D-2mini-Turbo (based on space name fallback)`); await log('DEBUG', `Fallback space detection result: HUNYUAN3D_MINI_TURBO (${SPACE_TYPE.HUNYUAN3D_MINI_TURBO})`); return SPACE_TYPE.HUNYUAN3D_MINI_TURBO; } // Then check for regular Hunyuan3D-2 else if (modelSpace.toLowerCase().includes("hunyuan")) { detectedSpaceType = SPACE_TYPE.HUNYUAN3D; await log('INFO', `Detected space type: Hunyuan3D-2 (based on space name fallback)`); await log('DEBUG', `Fallback space detection result: HUNYUAN3D (${SPACE_TYPE.HUNYUAN3D})`); return SPACE_TYPE.HUNYUAN3D; } // Finally check for InstantMesh else if (modelSpace.toLowerCase().includes("instantmesh")) { detectedSpaceType = SPACE_TYPE.INSTANTMESH; await log('INFO', `Detected space type: InstantMesh (based on space name fallback)`); await log('DEBUG', `Fallback space detection result: INSTANTMESH (${SPACE_TYPE.INSTANTMESH})`); return SPACE_TYPE.INSTANTMESH; } // If we get here, we couldn't determine the space type // This is a critical error - we should not proceed without knowing the space type const errorMessage = `Could not determine space type after API analysis. Please ensure your MODEL_SPACE environment variable in .env file is set correctly according to .env.example. You must use one of the following options: 1. A Hunyuan3D-2 space (containing "hunyuan" in the name) 2. A Hunyuan3D-2mini-Turbo space (containing "hunyuan3d-2mini" in the name) 3. An InstantMesh space (containing "instantmesh" in the name)`; await log('ERROR', errorMessage); throw new Error(errorMessage); } catch (error) { await log('ERROR', `Error detecting space type: ${error.message}`); // Rethrow the error instead of defaulting to InstantMesh throw new Error(`Failed to detect space type: ${error.message}. Please check your MODEL_SPACE environment variable in .env file and ensure it follows the format specified in .env.example.`); } } // Authentication options for Gradio using HF token const authOptions = { hf_token: hfToken }; // Connect to Hugging Face Spaces and Inference API let modelClient; let inferenceClient; // Initialize Hugging Face Inference Client for 2D and 3D asset generation if (!hfToken) { await log('ERROR', "HF_TOKEN is required in the .env file for 2D and 3D asset generation"); throw new Error("HF_TOKEN is required in the .env file for 2D and 3D asset generation"); } try { await log('INFO', "Initializing Hugging Face Inference Client..."); await log('DEBUG', `HF_TOKEN length: ${hfToken ? hfToken.length : 0}`); await log('DEBUG', `HF_TOKEN first 4 chars: ${hfToken ? hfToken.substring(0, 4) : 'none'}`); inferenceClient = new InferenceClient(hfToken); await log('INFO', "Successfully initialized Hugging Face Inference Client"); await log('DEBUG', "InferenceClient initialized successfully"); } catch (error) { await log('ERROR', `Error initializing Hugging Face Inference Client: ${error.message}`); await log('DEBUG', `Error stack: ${error.stack}`); throw new Error("Failed to initialize Hugging Face Inference Client. Check your HF_TOKEN."); } // Connect to Model Space API using Gradio client try { // Validate model space format if (!validateSpaceFormat(modelSpace)) { await log('ERROR', `Invalid model space format: "${modelSpace}". Format must be "username/space-name"`); throw new Error(`Invalid model space format: "${modelSpace}". Format must be "username/space-name" (e.g., "your-username/InstantMesh" or "your-username/Hunyuan3D-2"). Please check your MODEL_SPACE environment variable in the .env file. You need to: 1. Duplicate either space from: - https://huggingface.co/spaces/mubarak-alketbi/InstantMesh - https://huggingface.co/spaces/mubarak-alketbi/Hunyuan3D-2 2. Set MODEL_SPACE to your username and space name (e.g., "your-username/InstantMesh") 3. Make sure your HF_TOKEN has access to this space`); } await log('INFO', `Connecting to model space: ${modelSpace}...`); await log('INFO', "Using HF token authentication"); // Additional logging for debugging await log('DEBUG', `MODEL_SPACE environment variable: "${modelSpaceFromEnv}"`); await log('DEBUG', `MODEL_SPACE after default fallback: "${modelSpace}"`); await log('DEBUG', `Is MODEL_SPACE using default? ${!modelSpaceFromEnv}`); // Check if the space exists before trying to connect to it await log('DEBUG', "Checking if space exists..."); let spaceExists = false; let alternativeSpace = null; try { // Try to fetch the space URL to see if it exists const spaceUrl = `https://huggingface.co/spaces/${modelSpace}`; await log('DEBUG', `Checking space URL: ${spaceUrl}`); const response = await fetch(spaceUrl, { method: 'HEAD', headers: { Authorization: `Bearer ${hfToken}` } }); spaceExists = response.ok; await log('DEBUG', `Space exists check result: ${spaceExists} (status: ${response.status})`); if (!spaceExists) { // If the space doesn't exist, try alternative casings if (modelSpace.toLowerCase().includes("hunyuan3d-2mini") || modelSpace.toLowerCase().includes("hunyuan3dmini")) { // Try different casings for Hunyuan3D-2mini-Turbo const alternatives = [ `${modelSpace.split('/')[0]}/Hunyuan3D-2mini-Turbo`, `${modelSpace.split('/')[0]}/hunyuan3d-2mini-turbo`, `${modelSpace.split('/')[0]}/Hunyuan3D-2mini`, `${modelSpace.split('/')[0]}/hunyuan3d-2mini` ]; for (const alt of alternatives) { const altUrl = `https://huggingface.co/spaces/${alt}`; await log('DEBUG', `Checking alternative space URL: ${altUrl}`); const altResponse = await fetch(altUrl, { method: 'HEAD', headers: { Authorization: `Bearer ${hfToken}` } }); if (altResponse.ok) { alternativeSpace = alt; await log('INFO', `Found alternative space: ${alternativeSpace}`); break; } } } else if (modelSpace.toLowerCase().includes("hunyuan")) { // Try different casings for Hunyuan3D-2 const alternatives = [ `${modelSpace.split('/')[0]}/Hunyuan3D-2`, `${modelSpace.split('/')[0]}/hunyuan3d-2`, `${modelSpace.split('/')[0]}/HunyuanD-2` ]; for (const alt of alternatives) { const altUrl = `https://huggingface.co/spaces/${alt}`; await log('DEBUG', `Checking alternative space URL: ${altUrl}`); const altResponse = await fetch(altUrl, { method: 'HEAD', headers: { Authorization: `Bearer ${hfToken}` } }); if (altResponse.ok) { alternativeSpace = alt; await log('INFO', `Found alternative space: ${alternativeSpace}`); break; } } } else if (modelSpace.toLowerCase().includes("instantmesh")) { // Try different casings for InstantMesh const alternatives = [ `${modelSpace.split('/')[0]}/InstantMesh`, `${modelSpace.split('/')[0]}/instantmesh`, `${modelSpace.split('/')[0]}/Instantmesh` ]; for (const alt of alternatives) { const altUrl = `https://huggingface.co/spaces/${alt}`; await log('DEBUG', `Checking alternative space URL: ${altUrl}`); const altResponse = await fetch(altUrl, { method: 'HEAD', headers: { Authorization: `Bearer ${hfToken}` } }); if (altResponse.ok) { alternativeSpace = alt; await log('INFO', `Found alternative space: ${alternativeSpace}`); break; } } } } } catch (error) { await log('WARN', `Error checking if space exists: ${error.message}`); // Continue anyway, as the space might still be accessible } // Use the alternative space if found if (alternativeSpace) { await log('INFO', `Using alternative space: ${alternativeSpace} instead of ${modelSpace}`); await log('DEBUG', `Changing MODEL_SPACE from "${modelSpace}" to "${alternativeSpace}"`); // Store the original value for debugging const originalModelSpace = modelSpace; modelSpace = alternativeSpace; await log('DEBUG', `MODEL_SPACE changed from "${originalModelSpace}" to "${modelSpace}"`); } // Add a timeout to the connection attempt await log('DEBUG', `Creating connection promise for ${modelSpace} with token length ${hfToken.length}`); const connectionPromise = Client.connect(modelSpace, authOptions); // Create a timeout promise const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error(`Connection to ${modelSpace} timed out after 60 seconds`)); }, 60000); // 60 second timeout (increased from 30 seconds) }); try { // Race the connection promise against the timeout await log('DEBUG', "Starting connection attempt with 60 second timeout..."); modelClient = await Promise.race([connectionPromise, timeoutPromise]); await log('INFO', `Successfully connected to model space: ${modelSpace}`); // Add more diagnostic logs await log('DEBUG', "Connection successful, checking client object..."); await log('DEBUG', `Client object type: ${typeof modelClient}`); await log('DEBUG', `Client object methods: ${Object.getOwnPropertyNames(Object.getPrototypeOf(modelClient)).join(', ')}`); // Detect which space was duplicated await log('DEBUG', `Starting space type detection for "${modelSpace}"...`); // Log the modelSpace value right before detection await log('DEBUG', `About to detect space type for: "${modelSpace}"`); await log('DEBUG', `modelSpace lowercase: "${modelSpace.toLowerCase()}"`); await log('DEBUG', `Contains "hunyuan3d-2mini-turbo": ${modelSpace.toLowerCase().includes("hunyuan3d-2mini-turbo")}`); await log('DEBUG', `Contains "hunyuan": ${modelSpace.toLowerCase().includes("hunyuan")}`); await log('DEBUG', `Contains "instantmesh": ${modelSpace.toLowerCase().includes("instantmesh")}`); const spaceType = await detectSpaceType(modelClient); // We successfully connected to the space, so it's valid // Even if we couldn't determine the exact type, we'll use the detected type await log('INFO', `Using space type: ${spaceType}`); await log('DEBUG', `Final detected space type: ${spaceType}`); } catch (error) { await log('ERROR', `Error connecting to model space: ${error.message}`); await log('DEBUG', `Error stack: ${error.stack}`); if (error.message.includes("timed out")) { await log('ERROR', `Connection to model space "${modelSpace}" timed out. This could be due to network issues or the space being unavailable.`); throw new Error(`Connection to model space "${modelSpace}" timed out. Please check your internet connection and try again later. If the problem persists, the space might be unavailable or overloaded.`); } else if (error.message.includes("not found") || error.message.includes("404")) { await log('ERROR', `Model space "${modelSpace}" not found. Please make sure you've duplicated either InstantMesh or Hunyuan3D-2 space and set the correct space name in your .env file.`); throw new Error(`Model space "${modelSpace}" not found. This could be because: 1. The space doesn't exist - verify you've duplicated it correctly, 2. You've entered the wrong format - it should be "username/space-name" not the full URL, 3. The space is private and your token doesn't have access to it. Please duplicate either space and set the correct name in your .env file.`); } else if (error.message.includes("unauthorized") || error.message.includes("401")) { await log('ERROR', `Unauthorized access to model space "${modelSpace}". Please check your HF_TOKEN and make sure it has access to this space.`); throw new Error(`Unauthorized access to model space "${modelSpace}". This means your HF_TOKEN doesn't have permission to access this space. Please: 1. Make sure your HF_TOKEN is correct and not expired, 2. Ensure the space is either public or you have granted access to your account, 3. Try generating a new token with appropriate permissions at https://huggingface.co/settings/tokens`); } else { throw error; } } } catch (error) { await log('ERROR', `Error connecting to model space: ${error.message}`); throw new Error(`Failed to connect to model space: ${error.message}`); } // Register tool list handler server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [TOOLS.GENERATE_2D_ASSET, TOOLS.GENERATE_3D_ASSET] }; }); // Tool call handler server.setRequestHandler(CallToolRequestSchema, async (request) => { const toolName = request.params.name; const args = request.params.arguments; await log('INFO', `Calling tool: ${toolName}`); try { if (toolName === TOOLS.GENERATE_2D_ASSET.name) { const { prompt } = schema2D.parse(args); if (!prompt) { throw new Error("Invalid or empty prompt"); } await log('INFO', `Generating 2D asset with prompt: "${prompt}"`); // Use the Hugging Face Inference API to generate the image await log('DEBUG', "Calling Hugging Face Inference API for 2D asset generation..."); // Enhance the prompt to specify high detail, complete object, and white background const enhancedPrompt = `${prompt}, high detailed, complete object, not cut off, white solid background`; await log('DEBUG', `Enhanced 2D prompt: "${enhancedPrompt}"`); const image = await inferenceClient.textToImage({ model: "gokaygokay/Flux-2D-Game-Assets-LoRA", inputs: enhancedPrompt, parameters: { num_inference_steps: 50 }, provider: "hf-inference", }); if (!image) { throw new Error("No image returned from 2D asset generation API"); } // Save the image (which is a Blob) and notify clients of resource change // Detect the actual image format (JPEG or PNG) const imageBuffer = await image.arrayBuffer(); const format = detectImageFormat(Buffer.from(imageBuffer)); const extension = format === "JPEG" ? "jpg" : "png"; await log('DEBUG', `Detected 2D image format: ${format}, using extension: ${extension}`); const saveResult = await saveFileFromData(image, "2d_asset", extension, toolName); await log('INFO', `2D asset saved at: ${saveResult.filePath}`); // Notify clients that a new resource is available await notifyResourceListChanged(); return { content: [{ type: "text", text: `2D asset available at ${saveResult.resourceUri}` }], isError: false }; } if (toolName === TOOLS.GENERATE_3D_ASSET.name) { const operationId = `3D-${++operationCounter}`; await logOperation(toolName, operationId, 'STARTED'); try { const { prompt } = schema3D.parse(args); if (!prompt) { throw new Error("Invalid or empty prompt"); } await log('INFO', `Generating 3D asset with prompt: "${prompt}"`); await logOperation(toolName, operationId, 'PROCESSING', { step: 'Parsing prompt', prompt }); // Initial response to prevent timeout with more detailed information const initialResponse = { content: [ { type: "text", text: `Starting 3D asset generation (Operation ID: ${operationId})...\n\n` + `This process involves several steps:\n` + `1. Generating initial 3D image from prompt\n` + `2. Validating image for 3D conversion\n` + `3. Preprocessing image (removing background)\n` + `4. Generating multi-view images\n` + `5. Creating 3D models (OBJ and GLB)\n\n` + `This may take several minutes. The process will continue in the background.\n` + `You'll see status updates here for any significant events (like GPU quota limits).\n` + `The final 3D models will be available when the process completes.` } ], isError: false, metadata: { operationId: operationId, status: "STARTED", startTime: new Date().toISOString(), prompt: prompt } }; // Start the 3D asset generation process in the background (async () => { try { // Step 1: Generate the initial image using the Inference API await logOperation(toolName, operationId, 'PROCESSING', { step: 'Generating initial image' }); await log('DEBUG', "Calling Hugging Face Inference API for 3D asset generation..."); // Use retry mechanism for the image generation // Enhance the prompt to specify high detail, complete object, and white background const enhancedPrompt = `${prompt}, high detailed, complete object, not cut off, white solid background`; await log('DEBUG', `Enhanced 3D prompt: "${enhancedPrompt}"`); const image = await retryWithBackoff(async () => { return await inferenceClient.textToImage({ model: "gokaygokay/Flux-Game-Assets-LoRA-v2", inputs: enhancedPrompt, parameters: { num_inference_steps: 50 }, provider: "hf-inference", }); }, operationId); if (!image) { throw new Error("No image returned from 3D image generation API"); } // Save the image (which is a Blob) // Detect the actual image format (JPEG or PNG) const imageBuffer = await image.arrayBuffer(); const format = detectImageFormat(Buffer.from(imageBuffer)); const extension = format === "JPEG" ? "jpg" : "png"; await log('DEBUG', `Detected 3D image format: ${format}, using extension: ${extension}`); const saveResult = await saveFileFromData(image, "3d_image", extension, toolName); const imagePath = saveResult.filePath; await log('INFO', `3D image generated at: ${imagePath}`); await logOperation(toolName, operationId, 'PROCESSING', { step: 'Initial image generated', path: imagePath }); // Step 2: Process the image with InstantMesh using the correct multi-step process // 2.1: Check if the image is valid await log('DEBUG', "Validating image for 3D conversion..."); await logOperation(toolName, operationId, 'PROCESSING', { step: 'Validating image' }); const imageFile = await fs.readFile(imagePath); const checkResult = await retryWithBackoff(async () => { // Use different endpoints based on the detected space type if (detectedSpaceType === SPACE_TYPE.INSTANTMESH) { return await modelClient.predict("/check_input_image", [ new File([imageFile], path.basename(imagePath), { type: "image/png" }) ]); } else if (detectedSpaceType === SPACE_TYPE.HUNYUAN3D || detectedSpaceType === SPACE_TYPE.HUNYUAN3D_MINI_TURBO) { // Hunyuan3D and Hunyuan3D-2mini-Turbo don't have a check_input_image endpoint, // so we'll just return a success await log('INFO', `Using ${detectedSpaceType} space - skipping image validation step`); return true; } else { throw new Error("Unknown space type detected. Cannot proceed with 3D asset generation."); } }, operationId); await logOperation(toolName, operationId, 'PROCESSING', { step: 'Image validation complete' }); // 2.2: Preprocess the image (with background removal) await log('DEBUG', "Preprocessing image..."); await logOperation(toolName, operationId, 'PROCESSING', { step: 'Preprocessing image' }); const preprocessResult = await retryWithBackoff(async () => { // Use different endpoints based on the detected space type if (detectedSpaceType === SPACE_TYPE.INSTANTMESH) { return await modelClient.predict("/preprocess", [ new File([imageFile], path.basename(imagePath), { type: "image/png" }), model3dRemoveBackground // Use configured value ]); } else if (detectedSpaceType === SPACE_TYPE.HUNYUAN3D || detectedSpaceType === SPACE_TYPE.HUNYUAN3D_MINI_TURBO) { // Neither Hunyuan3D nor Hunyuan3D-2mini-Turbo have a preprocess endpoint, // but they have built-in background removal await log('INFO', `Using ${detectedSpaceType} space - using built-in background removal`); // Return the original image as we'll handle it in the next step return { data: imageFile }; } else { throw new Error("Unknown space type detected. Cannot proceed with 3D asset generation."); } }, operationId); if (!preprocessResult || !preprocessResult.data) { throw new Error("Image preprocessing failed"); } await log('DEBUG', "Successfully preprocessed image with InstantMesh"); await log('DEBUG', "Preprocessed data type: " + typeof preprocessResult.data); // Save the preprocessed image and notify clients of resource change // Save the preprocessed image const processedResult = await saveFileFromData( preprocessResult.data, "3d_processed", "png", toolName ); const processedImagePath = processedResult.filePath; await log('INFO', `Preprocessed image saved at: ${processedImagePath}`); await logOperation(toolName, operationId, 'PROCESSING', { step: 'Preprocessing complete', path: processedImagePath }); // Notify clients that a new resource is available await notifyResourceListChanged(); // 2.3: Generate multi-views await log('DEBUG', "Generating multi-views..."); await logOperation(toolName, operationId, 'PROCESSING', { step: 'Generating multi-views' }); const processedImageFile = await fs.readFile(processedImagePath); const mvsResult = await retryWithBackoff(async () => { // Use different endpoints based on the detected space type if (detectedSpaceType === SPACE_TYPE.INSTANTMESH) { // Use configured values or defaults for InstantMesh with validation // InstantMesh steps range: 30-75 let steps = model3dSteps !== null ? model3dSteps : 75; // Default: 75 steps = validateNumericRange(steps, 30, 75, 75, "InstantMesh steps"); // Any integer is valid for seed, but use default if not provided const seed = model3dSeed !== null ? model3dSeed : 42; // Default: 42 await log('INFO', `InstantMesh parameters - steps: ${steps}, seed: ${seed}`); return await modelClient.predict("/generate_mvs", [ new File([processedImageFile], path.basename(processedImagePath), { type: "image/png" }), steps, seed ]); } else if (detectedSpaceType === SPACE_TYPE.HUNYUAN3D) { // Use configured values or defaults for Hunyuan3D-2 with validation // Hunyuan3D-2 steps range: 20-50 let steps = model3dSteps !== null ? model3dSteps : 20; // Default: 20 steps = validateNumericRange(steps, 20, 50, 20, "Hunyuan3D-2 steps"); // Guidance scale already validated (0.0-100.0) const guidanceScale = model3dGuidanceScale !== null ? model3dGuidanceScale : 5.5; // Default: 5.5 // Seed already validated (0-10000000) const seed = model3dSeed !== null ? model3dSeed : 1234; // Default: 1234 // Validate octree resolution (valid options: "256", "384", "512") const validOctreeResolutions = ["256", "384", "512"]; const octreeResolution = validateEnum( model3dOctreeResolution, validOctreeResolutions, "256", "Hunyuan3D-2 octree_resolution" ); await log('INFO', `Hunyuan3D-2 parameters - steps: ${steps}, guidance_scale: ${guidanceScale}, seed: ${seed}, octree_resolution: ${octreeResolution}, remove_background: ${model3dRemoveBackground}`); // Hunyuan3D uses generation_all instead of generate_mvs await log('INFO', "Using Hunyuan3D space - using generation_all endpoint"); return await modelClient.predict("/generation_all", [ prompt, new File([processedImageFile], path.basename(processedImagePath), { type: "image/png" }), steps, guidanceScale, seed, octreeResolution, model3dRemoveBackground ]); } else if (detectedSpaceType === SPACE_TYPE.HUNYUAN3D_MINI_TURBO) { // Use configured values or defaults for Hunyuan3D-2mini-Turbo with validation // Determine default steps based on the selected mode let defaultSteps; if (model3dTurboMode === "Turbo") { defaultSteps = 5; // Default for Turbo mode } else if (model3dTurboMode === "Fast") { defaultSteps = 10; // Default for Fast mode } else { // Standard mode defaultSteps = 20; // Default for Standard mode } // Hunyuan3D-2mini-Turbo steps range: 1-100 let steps = model3dSteps !== null ? model3dSteps : defaultSteps; steps = validateNumericRange(steps, 1, 100, defaultSteps, "Hunyuan3D-2mini-Turbo steps"); // Guidance scale already validated (0.0-100.0) const guidanceScale = model3dGuidanceScale !== null ? model3dGuidanceScale : 5.0; // Default: 5.0 // Seed already validated (0-10000000) const seed = model3dSeed !== null ? model3dSeed : 1234; // Default: 1234 // Validate octree resolution (range: 16-512) let octreeResolution = model3dOctreeResolution !== null ? parseInt(model3dOctreeResolution) : 256; // Default: 256 octreeResolution = validateNumericRange(octreeResolution, 16, 512, 256, "Hunyuan3D-2mini-Turbo octree_resolution"); // Validate num_chunks (range: 1000-5000000) const numChunks = validateNumericRange(8000, 1000, 5000000, 8000, "Hunyuan3D-2mini-Turbo num_chunks"); await log('INFO', `Hunyuan3D-2mini-Turbo parameters - mode: ${model3dTurboMode}, steps: ${steps}, guidance_scale: ${guidanceScale}, seed: ${seed}, octree_resolution: ${octreeResolution}, remove_background: ${model3dRemoveBackground}, num_chunks: ${numChunks}`); // First, set the generation mode if specified if (model3dTurboMode) { try { await modelClient.predict("/on_gen_mode_change", [model3dTurboMode]); await log('INFO', `Set generation mode to ${model3dTurboMode}`); } catch (error) { await log('WARN', `Failed to set generation mode: ${error.message}`); // Continue with the generation even if setting the mode fails } } // Use generation_all endpoint await log('INFO', "Using Hunyuan3D-2mini-Turbo space - using generation_all endpoint"); // Hunyuan3D-2mini-Turbo has different parameters than Hunyuan3D-2 return await modelClient.predict("/generation_all", [ prompt, // caption new File([processedImageFile], path.basename(processedImagePath), { type: "image/png" }), null, null, null, null, // Multi-view images (front, back, left, right) steps, guidanceScale, seed, octreeResolution, model3dRemoveBackground, numChunks, // num_chunks with validation true // randomize_seed ]); } else { throw new Error("Unknown space type detected. Cannot proceed with 3D asset generation."); } }, operationId); if (!mvsResult || !mvsResult.data) { throw new Error("Multi-view generation failed"); } await log('DEBUG', "Successfully generated multi-view image"); await log('DEBUG', "Multi-view data type: " + typeof mvsResult.data); // Save the multi-view image and notify clients of resource change // Save the multi-view image const mvsResult2 = await saveFileFromData( mvsResult.data, "3d_multiview", "png", toolName ); const mvsImagePath = mvsResult2.filePath; await log('INFO', `Multi-view image saved at: ${mvsImagePath}`); await logOperation(toolName, operationId, 'PROCESSING', { step: 'Multi-view generation complete', path: mvsImagePath }); // Notify clients that a new resource is available await notifyResourceListChanged(); // 2.4: Generate 3D models (OBJ and GLB) await log('DEBUG', "Generating 3D models..."); await logOperation(toolName, operationId, 'PROCESSING', { step: 'Generating 3D models' }); // This step is particularly prone to GPU quota errors, so use retry with backoff const modelResult = await retryWithBackoff(async () => { // Use different endpoints based on the detected space type if (detectedSpaceType === SPACE_TYPE.INSTANTMESH) { return await modelClient.predict("/make3d", []); } else if (detectedSpaceType === SPACE_TYPE.HUNYUAN3D) { // Hunyuan3D-2 doesn't need a separate make3d step // as generation_all already returns the 3D model await log('INFO', `Using ${detectedSpaceType} space - 3D model already generated in previous step`); // Return the result from the previous step // For Hunyuan3D-2, the textured mesh URL is at result.data[1].url await log('DEBUG', `Hunyuan3D-2: Extracting textured mesh from result.data[1].url`); return mvsResult; } else if (detectedSpaceType === SPACE_TYPE.HUNYUAN3D_MINI_TURBO) { // Hunyuan3D-2mini-Turbo doesn't need a separate make3d step // as generation_all already returns the 3D model await log('INFO', `Using ${detectedSpaceType} space - 3D model already generated in previous step`); // Return the result from the previous step // For Hunyuan3D-2mini-Turbo, the textured mesh URL is at result.data[1].value.url await log('DEBUG', `Hunyuan3D-2mini-Turbo: Extracting textured mesh from result.data[1].value.url`); return mvsResult; } else { throw new Error("Unknown space type detected. Cannot proceed with 3D asset generation."); } }, operationId, 5); // Pass operationId and more retries for this critical step if (!modelResult || !modelResult.data || !modelResult.data.length) { throw new Error("3D model generation failed"); } await log('DEBUG', "Successfully generated 3D models"); await log('DEBUG', "Model data type: " + typeof modelResult.data); // Save debug information for troubleshooting const modelDebugFilename = generateUniqueFilename("model_data", "json"); const modelDebugPath = path.join(assetsDir, modelDebugFilename); await fs.writeFile(modelDebugPath, JSON.stringify(modelResult, null, 2)); await log('DEBUG', `Model data saved as JSON at: ${modelDebugPath}`); // Extract the model data based on the space type let objModelData, glbModelData; if (detectedSpaceType === SPACE_TYPE.INSTANTMESH) { // InstantMesh returns both OBJ and GLB formats objModelData = modelResult.data[0]; glbModelData = modelResult.data[1]; await log('DEBUG', `InstantMesh: Using modelResult.data[0] for OBJ and modelResult.data[1] for GLB`); } else if (detectedSpaceType === SPACE_TYPE.HUNYUAN3D) { // For Hunyuan3D-2, we want the textured mesh which is at index 1 // We'll use it for both OBJ and GLB since we primarily want the textured version objModelData = modelResult.data[1]; // Textured mesh glbModelData = modelResult.data[1]; // Textured mesh await log('DEBUG', `Hunyuan3D-2: Using textured mesh from modelResult.data[1] for both OBJ and GLB`); } else if (detectedSpaceType === SPACE_TYPE.HUNYUAN3D_MINI_TURBO) { // For Hunyuan3D-2mini-Turbo, the textured mesh is at index 1 but nested in value // We need to ensure we're accessing it correctly if (modelResult.data[1] && modelResult.data[1].value) { objModelData = modelResult.data[1].value; // Textured mesh glbModelData = modelResult.data[1].value; // Textured mesh await log('DEBUG', `Hunyuan3D-2mini-Turbo: Using textured mesh from modelResult.data[1].value for both OBJ and GLB`); } else { // Fallback to white mesh if textured mesh is not available objModelData = modelResult.data[0]; glbModelData = modelResult.data[0]; await log('WARN', `Hunyuan3D-2mini-Turbo: Textured mesh not found, falling back to white mesh`); } } else { throw new Error(`Unknown space type: ${detectedSpaceType}`); } // Save both model formats // Save both model formats // Save both model formats and notify clients of resource changes const objResult = await saveFileFromData(objModelData, "3d_model", "obj", toolName); await log('INFO', `OBJ model saved at: ${objResult.filePath}`); // Notify clients that a new resource is available await notifyResourceListChanged(); const glbResult = await saveFileFromData(glbModelData, "3d_model", "glb", toolName); await log('INFO', `GLB model saved at: ${glbResult.filePath}`); // Notify clients that a new resource is available await notifyResourceListChanged(); // Create a completion message with detailed information const completionMessage = `3D asset generation complete (Operation ID: ${operationId}).\n\n` + `Process completed in ${Math.round((Date.now() - new Date(global.operationUpdates[operationId][0].timestamp).getTime()) / 1000)} seconds.\n\n` + `3D models available at:\n` + `- OBJ: ${objResult.resourceUri}\n` + `- GLB: ${glbResult.resourceUri}\n\n` + `You can view these models in any 3D viewer that supports OBJ or GLB formats.`; await logOperation(toolName, operationId, 'COMPLETED', { objPath: objResult.filePath, glbPath: glbResult.filePath, objUri: objResult.resourceUri, glbUri: glbResult.resourceUri, processingTime: `${Math.round((Date.now() - new Date(global.operationUpdates[operationId][0].timestamp).getTime()) / 1000)} seconds` }); // Here you would typically send the final response to the client // Since we're already returning the initial response, we'll log the completion await log('INFO', `Operation ${operationId} completed successfully. Final response ready.`); // In a real-world scenario, you would send this completion message to the client // For example, through a WebSocket connection or by updating a status endpoint // For now, we'll just log it await log('INFO', `Completion message for client:\n${completionMessage}`); } catch (error) { const errorMessage = `Error in 3D asset generation (Operation ID: ${operationId}):\n${error.message}\n\nThe operation has been terminated. Please try again later or with a different prompt.`; await log('ERROR', `Error in background processing for operation ${operationId}: ${error.message}`); await logOperation(toolName, operationId, 'ERROR', { error: error.message, stack: error.stack, phase: global.operationUpdates[operationId] ? global.operationUpdates[operationId][global.operationUpdates[operationId].length - 1].status : 'UNKNOWN' }); // Here you would typically send an error response to the client // Since we're already returning the initial response, we'll log the error await log('ERROR', `Operation ${operationId} failed: ${error.message}`); // In a real-world scenario, you would send this error message to the client // For example, through a WebSocket connection or by updating a status endpoint // For now, we'll just log it await log('INFO', `Error message for client:\n${errorMessage}`); } })(); // Return the initial response immediately to prevent timeout return initialResponse; } catch (error) { await log('ERROR', `Error starting operation ${operationId}: ${error.message}`); await logOperation(toolName, operationId, 'ERROR', { error: error.message }); return { content: [{ type: "text", text: `Error starting 3D asset generation: ${error.message}` }], isError: true }; } } throw { code: MCP_ERROR_CODES.MethodNotFound, message: `Unknown tool: ${toolName}` }; } catch (error) { // Handle different types of errors with appropriate MCP error codes let errorCode = MCP_ERROR_CODES.InternalError; let errorMessage = error.message || "Unknown error"; if (error.code) { // If the error already has a code, use it errorCode = error.code; errorMessage = error.message; } else if (error instanceof z.ZodError) { // Validation errors errorCode = MCP_ERROR_CODES.InvalidParams; errorMessage = `Invalid parameters: ${error.errors.map(e => e.message).join(", ")}`; } await log('ERROR', `Error in ${toolName}: ${errorMessage} (Code: ${errorCode})`); return { content: [{ type: "text", text: `Error: ${errorMessage}` }], isError: true, errorCode: errorCode }; } }); // Helper function to sanitize prompts function sanitizePrompt(prompt) { if (!prompt || typeof prompt !== 'string') { return ''; } // Enhanced sanitization: // 1. Trim whitespace // 2. Remove potentially harmful characters (keeping alphanumeric, spaces, and basic punctuation) // 3. Limit length to 500 characters return prompt.trim() .replace(/[^\w\s.,!?-]/g, '') .slice(0, 500); } // Generate a unique filename to prevent conflicts function generateUniqueFilename(prefix, ext, toolName) { const timestamp = Date.now(); const uniqueId = crypto.randomBytes(4).toString('hex'); return `${prefix}_${toolName}_${timestamp}_${uniqueId}.${ext}`; } // Parse resource URI templates function parseResourceUri(uri) { // Support for templated URIs like asset://{type}/{id} const match = uri.match(/^asset:\/\/(?:([^\/]+)\/)?(.+)$/); if (!match) return null; const [, type, id] = match; return { type, id }; } // Helper to get MIME type from filename function getMimeType(filename) { if (filename.endsWith(".png")) return "image/png"; if (filename.endsWith(".jpg") || filename.endsWith(".jpeg")) return "image/jpeg"; if (filename.endsWith(".obj")) return "model/obj"; if (filename.endsWith(".glb")) return "model/gltf-binary"; return "application/octet-stream"; // Default } // Helper to detect image format from buffer function detectImageFormat(buffer) { if (!buffer || buffer.length < 4) { return "Unknown"; } // Check for JPEG if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) { return "JPEG"; } // Check for PNG if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) { return "PNG"; } // Default to PNG if unknown return "PNG"; } // Helper to save files from URL async function saveFileFromUrl(url, prefix, ext, toolName) { if (!url || typeof url !== 'string' || !url.startsWith("http")) { throw new Error("Invalid URL provided"); } const filename = generateUniqueFilename(prefix, ext, toolName); const filePath = path.join(assetsDir, filename); // Security check: ensure file path is within assetsDir if (!filePath.startsWith(assetsDir)) { throw new Error("Invalid file path - security violation"); } try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } const buffer = await response.arrayBuffer(); await fs.writeFile(filePath, Buffer.from(buffer)); // Return both the file path and the resource URI return { filePath, resourceUri: `asset://${filename}` }; } catch (error) { await log('ERROR', `Error saving file from URL: ${error.message}`); throw new Error("Failed to save file from URL"); } } // Helper to save files from data (blob, base64, etc.) async function saveFileFromData(data, prefix, ext, toolName) { if (!data) { throw new Error("No data provided to save"); } const filename = generateUniqueFilename(prefix, ext, toolName); const filePath = path.join(assetsDir, filename); // Security check: ensure file path is within assetsDir if (!filePath.startsWith(assetsDir)) { throw new Error("Invalid file path - security violation"); } try { // Handle different data types if (data instanceof Blob || data instanceof File) { await log('DEBUG', "Saving data as Blob/File"); const arrayBuffer = await data.arrayBuffer(); await fs.writeFile(filePath, Buffer.from(arrayBuffer)); } else if (typeof data === 'string') { // Check if it's base64 encoded if (data.match(/^data:[^;]+;base64,/)) { await log('DEBUG', "Saving data as base64 string"); const base64Data = data.split(',')[1]; await fs.writeFile(filePath, Buffer.from(base64Data, 'base64')); } else { await log('DEBUG', "Saving data as regular string"); await fs.writeFile(filePath, data); } } else if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { await log('DEBUG', "Saving data as ArrayBuffer"); await fs.writeFile(filePath, Buffer.from(data)); } else if (Array.isArray(data) && data.length > 0) { // Handle array of file data (common in InstantMesh API responses) await log('DEBUG', "Data is an array with " + data.length + " items"); const fileData = data[0]; if (fileData.url) { await log('DEBUG', "Found URL in data: " + fileData.url); // Fetch the file from the URL with authentication const headers = { Authorization: `Bearer ${hfToken}` }; await log('DEBUG', "Adding HF token authentication to URL fetch request"); // Verify the URL domain matches the expected domain for the configured space try { const urlDomain = new URL(fileData.url).hostname; const expectedDomain = modelSpace.split('/')[0].toLowerCase() + '-' + modelSpace.split('/')[1].toLowerCase() + '.hf.space'; if (urlDomain !== expectedDomain) { await log('WARN', `URL domain mismatch: Expected "${expectedDomain}" but got "${urlDomain}". This suggests your MODEL_SPACE setting doesn't match the space that generated this URL.`); } } catch (urlError) { await log('WARN', `Error parsing URL: ${urlError.message}`); } const response = await fetch(fileData.url, { headers }); if (!response.ok) { throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`); } const buffer = await response.arrayBuffer(); await fs.writeFile(filePath, Buffer.from(buffer)); await log('DEBUG', "Successfully saved file from URL"); } else { await log('DEBUG', "No URL found in array data, saving as JSON"); await fs.writeFile(filePath, JSON.stringify(data)); } } else if (typeof data === 'object' && data.url) { // Handle object with URL property await log('DEBUG', "Data is an object with URL: " + data.url); // Fetch the file from the URL with authentication const headers = { Authorization: `Bearer ${hfToken}` }; await log('DEBUG', "Adding HF token authentication to URL fetch request"); // Verify the URL domain matches the expected domain for the configured space try { const urlDomain = new URL(data.url).hostname; const expectedDomain = modelSpace.split('/')[0].toLowerCase() + '-' + modelSpace.split('/')[1].toLowerCase() + '.hf.space'; if (urlDomain !== expectedDomain) { await log('WARN', `URL domain mismatch: Expected "${expectedDomain}" but got "${urlDomain}". This suggests your MODEL_SPACE setting doesn't match the space that generated this URL.`); } } catch (urlError) { await log('WARN', `Error parsing URL: ${urlError.message}`); } const response = await fetch(data.url, { headers }); if (!response.ok) { throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`); } const buffer = await response.arrayBuffer(); await fs.writeFile(filePath, Buffer.from(buffer)); await log('DEBUG', "Successfully saved file from URL"); } else if (typeof data === 'object') { // JSON or other object await log('DEBUG', "Saving data as JSON object"); await fs.writeFile(filePath, JSON.stringify(data)); } else { // Fallback await log('DEBUG', "Saving data using fallback method"); await fs.writeFile(filePath, Buffer.from(String(data))); } // Return both the file path and the resource URI return { filePath, resourceUri: `asset://${filename}` }; } catch (error) { // Provide more detailed error messages for common issues if (error.message.includes("404") || error.message.includes("Not Found")) { await log('ERROR', `Error saving file from data: ${error.message}. This may be due to an incorrect model space configuration. Please check your MODEL_SPACE environment variable.`); // Check if the URL contains a domain that doesn't match the configured space if (typeof data === 'object' && (data.url || (Array.isArray(data) && data[0]?.url))) { const url = data.url || (Array.isArray(data) ? data[0].url : null); if (url) { try { const urlDomain = new URL(url).hostname; const expectedDomain = modelSpace.split('/')[0].toLowerCase() + '-' + modelSpace.split('/')[1].toLowerCase() + '.hf.space'; if (urlDomain !== expectedDomain) { await log('ERROR', `URL domain mismatch: Expected "${expectedDomain}" but got "${urlDomain}". This suggests your MODEL_SPACE setting doesn't match the space that generated this URL.`); await log('INFO', `To fix this issue, please update your MODEL_SPACE in .env to match the space name in the URL, or duplicate the space correctly and update your configuration.`); } } catch (urlError) { await log('ERROR', `Error parsing URL: ${urlError.message}`); } } } } else if (error.message.includes("401") || error.message.includes("unauthorized")) { await log('ERROR', `Error saving file from data: ${error.message}. This may be due to authentication issues. Please check your HF_TOKEN environment variable.`); } else { await log('ERROR', `Error saving file from data: ${error.message}`); } // Save debug information for troubleshooting try { const debugFilename = generateUniqueFilename("debug_data", "json"); const debugPath = path.join(assetsDir, debugFilename); let debugData; if (typeof data === 'object') { debugData = JSON.stringify(data, null, 2); } else { debugData = String(data); } await fs.writeFile(debugPath, debugData); await log('INFO', `Debug data saved at: ${debugPath}`); } catch (debugError) { await log('ERROR', `Failed to save debug data: ${debugError.message}`); } throw new Error("Failed to save file from data"); } } // Resource listing (for file management) server.setRequestHandler(ListResourcesRequestSchema, async (request) => { await log('INFO', "Listing resources"); try { // Check if there's a filter in the request const uriTemplate = request.params?.uriTemplate; let typeFilter = null; if (uriTemplate) { const templateMatch = uriTemplate.match(/^asset:\/\/([^\/]+)\/.*$/); if (templateMatch) { typeFilter = templateMatch[1]; await log('INFO', `Filtering resources by type: ${typeFilter}`); } } const files = await fs.readdir(assetsDir, { withFileTypes: true }); const resources = await Promise.all( files .filter(f => f.isFile()) .map(async (file) => { const filePath = path.join(assetsDir, file.name); const stats = await fs.stat(filePath); const filenameParts = file.name.split('_'); const assetType = filenameParts[0] || 'unknown'; const toolOrigin = filenameParts[1] || 'unknown'; // Create a structured URI that includes the type const uri = `asset://${assetType}/${file.name}`; return { uri, name: file.name, mimetype: getMimeType(file.name), created: stats.ctime.toISOString(), size: stats.size, toolOrigin, assetType }; }) ); // Apply type filter if specified const filteredResources = typeFilter ? resources.filter(r => r.assetType === typeFilter) : resources; return { resources: filteredResources }; } catch (error) { await log('ERROR', `Error listing resources: ${error.message}`); return { resources: [] }; } }); // Resource read handler server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; await log('INFO', `Reading resource: ${uri}`); if (uri.startsWith("asset://")) { // Parse the URI to handle templated URIs const parsedUri = parseResourceUri(uri); if (!parsedUri) { throw new Error("Invalid resource URI format"); } // For templated URIs like asset://{type}/{id}, the filename is in the id part // For traditional URIs like asset://filename, the id is the filename const filename = parsedUri.type && parsedUri.id.includes('/') ? parsedUri.id : (parsedUri.type ? `${parsedUri.type}/${parsedUri.id}` : parsedUri.id); // Remove any type prefix if it exists const actualFilename = filename.includes('/') ? filename.split('/').pop() : filename; const filePath = path.join(assetsDir, actualFilename); // Security check: ensure file path is within assetsDir if (!filePath.startsWith(assetsDir)) { throw new Error("Invalid resource path - security violation"); } try { const stats = await fs.stat(filePath); if (!stats.isFile()) { throw new Error("Not a file"); } const data = await fs.readFile(filePath); const mimetype = getMimeType(actualFilename); return { contents: [{ uri: uri, mimeType: mimetype, blob: data.toString("base64") // Binary data as base64 }] }; } catch (error) { await log('ERROR', `Error reading resource: ${error.message}`); return { content: [{ type: "text", text: "Error reading resource" }], isError: true }; } } throw new Error("Unsupported URI scheme"); }); // Resource templates handler server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { return { templates: [ { uriTemplate: "asset://{type}/{id}", name: "Generated Asset", description: "Filter assets by type and ID" } ] }; }); // Prompt handlers server.setRequestHandler(ListPromptsRequestSchema, async () => { return { prompts: [ { name: "generate_2d_sprite", description: "Generate a 2D sprite from a description", arguments: [{ name: "prompt", description: "Sprite description", required: true }] }, { name: "generate_3d_model", description: "Generate a 3D model from a description", arguments: [{ name: "prompt", description: "Model description", required: true }] } ] }; }); // Define Zod schemas for prompt argument validation const promptSchema2D = z.object({ prompt: z.string().min(1).max(500).transform(val => sanitizePrompt(val)) }); const promptSchema3D = z.object({ prompt: z.string().min(1).max(500).transform(val => sanitizePrompt(val)) }); server.setRequestHandler(GetPromptRequestSchema, async (request) => { const promptName = request.params.name; const args = request.params.arguments; try { if (promptName === "generate_2d_sprite") { // Validate arguments using Zod schema const { prompt } = promptSchema2D.parse(args); return { description: "Generate a 2D sprite", messages: [ { role: "user", content: { type: "text", text: `Generate a 2D sprite: ${prompt}, high detailed, complete object, not cut off, white solid background` } } ] }; } if (promptName === "generate_3d_model") { // Validate arguments using Zod schema const { prompt } = promptSchema3D.parse(args); return { description: "Generate a 3D model", messages: [ { role: "user", content: { type: "text", text: `Generate a 3D model: ${prompt}, high detailed, complete object, not cut off, white solid background` } } ] }; } // If prompt not found, throw an error with MCP error code throw { code: MCP_ERROR_CODES.MethodNotFound, message: `Prompt not found: ${promptName}` }; } catch (error) { // Handle different types of errors if (error.code) { // If the error already has a code, rethrow it throw error; } else if (error instanceof z.ZodError) { // Validation errors throw { code: MCP_ERROR_CODES.InvalidParams, message: `Invalid arguments: ${error.errors.map(e => e.message).join(", ")}` }; } else { // Other errors throw { code: MCP_ERROR_CODES.InternalError, message: `Internal error: ${error.message}` }; } } }); // Start the server async function main() { // Check if we should use SSE transport (for remote access) const useSSE = process.argv.includes("--sse"); const useHttps = process.argv.includes("--https"); if (useSSE) { // Setup Express server for SSE transport const app = express(); const port = process.env.PORT || 3000; // Store transports by client ID for multi-connection support global.transports = new Map(); // Add health check endpoint app.get("/health", (req, res) => { res.status(200).json({ status: "ok", timestamp: new Date().toISOString(), version: "0.3.0", // Updated to version 0.3.0 uptime: process.uptime() }); }); app.get("/sse", async (req, res) => { const clientId = req.query.clientId || crypto.randomUUID(); await log('INFO', `SSE connection established for client: ${clientId}`); // Set headers for SSE res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); // Create a new transport for this client const transport = new SSEServerTransport("/messages", res); global.transports.set(clientId, transport); // Handle client disconnect req.on('close', () => { global.transports.delete(clientId); log('INFO', `Client ${clientId} disconnected`); }); await server.connect(transport); // Send initial connection confirmation res.write(`data: ${JSON.stringify({ connected: true, clientId })}\n\n`); }); app.post("/messages", express.json(), async (req, res) => { const clientId = req.headers['x-client-id'] || 'anonymous'; // Apply rate limiting if (!checkRateLimit(clientId)) { res.status(429).json({ error: "Too many requests" }); return; } // Get the transport for this client const transport = global.transports.get(clientId); if (!transport) { res.status(404).json({ error: "Client not connected" }); return; } await transport.handlePostMessage(req, res); }); // Use HTTPS if requested if (useHttps) { try { // Check for SSL certificate files const sslDir = path.join(process.cwd(), 'ssl'); const keyPath = path.join(sslDir, 'key.pem'); const certPath = path.join(sslDir, 'cert.pem'); // Create ssl directory if it doesn't exist await fs.mkdir(sslDir, { recursive: true }); // Check if SSL files exist, if not, generate self-signed certificate let key, cert; try { key = await fs.readFile(keyPath); cert = await fs.readFile(certPath); await log('INFO', "Using existing SSL certificates"); } catch (error) { await log('WARN', "SSL certificates not found, please create them manually"); await log('INFO', "You can generate self-signed certificates with:"); await log('INFO', "openssl req -x509 -newkey rsa:4096 -keyout ssl/key.pem -out ssl/cert.pem -days 365 -nodes"); throw new Error("SSL certificates required for HTTPS"); } const httpsServer = https.createServer({ key, cert }, app); httpsServer.listen(port, () => { log('INFO', `MCP Game Asset Generator running with HTTPS SSE transport on port ${port}`); }); } catch (error) { await log('ERROR', `HTTPS setup failed: ${error.message}`); await log('WARN', "Falling back to HTTP"); app.listen(port, () => { log('INFO', `MCP Game Asset Generator running with HTTP SSE transport on port ${port}`); }); } } else { // Standard HTTP server app.listen(port, () => { log('INFO', `MCP Game Asset Generator running with HTTP SSE transport on port ${port}`); }); } } else { // Use stdio transport for local access (e.g., Claude Desktop) const transport = new StdioServerTransport(); await server.connect(transport); await log('INFO', "MCP Game Asset Generator running with stdio transport"); // Add health check handler for stdio transport server.setRequestHandler(z.object({ method: z.literal("health/check") }), async () => { return { status: "ok", timestamp: new Date().toISOString(), version: "0.3.0", // Updated to version 0.3.0 uptime: process.uptime() }; }); } } main().catch((err) => { console.error("Server error:", err); process.exit(1); });