Skip to main content
Glama
backend.ts48.9 kB
import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as net from 'net'; import { spawn, ChildProcess } from 'child_process'; import { config } from './config.js'; import { Logger } from './logger.js'; import { FoundryClient } from './foundry-client.js'; import { CharacterTools } from './tools/character.js'; import { CompendiumTools } from './tools/compendium.js'; import { SceneTools } from './tools/scene.js'; import { ActorCreationTools } from './tools/actor-creation.js'; import { QuestCreationTools } from './tools/quest-creation.js'; import { DiceRollTools } from './tools/dice-roll.js'; import { CampaignManagementTools } from './tools/campaign-management.js'; import { OwnershipTools } from './tools/ownership.js'; import { MapGenerationTools } from './tools/map-generation.js'; import { TokenManipulationTools } from './tools/token-manipulation.js'; import { DSA5CharacterCreator } from './systems/dsa5/character-creator.js'; const CONTROL_HOST = '127.0.0.1'; const CONTROL_PORT = 31414; const LOCK_FILE = path.join(os.tmpdir(), 'foundry-mcp-backend.lock'); function getBundledPythonPath(): string { // Detect installation directory based on current executable location let installDir = path.join(os.homedir(), 'AppData', 'Local', 'FoundryMCPServer'); // Try to detect install directory from current process location const currentDir = process.cwd(); const execDir = path.dirname(process.execPath); // Check if we're running from an installed location if (currentDir.includes('FoundryMCPServer') || execDir.includes('FoundryMCPServer')) { // Extract the installation directory const foundryMcpIndex = currentDir.indexOf('FoundryMCPServer'); if (foundryMcpIndex !== -1) { installDir = currentDir.substring(0, foundryMcpIndex + 'FoundryMCPServer'.length); } else { const foundryMcpExecIndex = execDir.indexOf('FoundryMCPServer'); if (foundryMcpExecIndex !== -1) { installDir = execDir.substring(0, foundryMcpExecIndex + 'FoundryMCPServer'.length); } } } // Check for nested ComfyUI installation (current actual structure) const nestedComfyUIPythonPath = path.join(installDir, 'ComfyUI', 'ComfyUI', 'python_embeded', 'python.exe'); if (fs.existsSync(nestedComfyUIPythonPath)) { return nestedComfyUIPythonPath; } // Check for flat ComfyUI portable installation (fallback) const portablePythonPath = path.join(installDir, 'ComfyUI', 'python_embeded', 'python.exe'); if (fs.existsSync(portablePythonPath)) { return portablePythonPath; } // Path to bundled Python virtual environment (legacy) const bundledPythonPath = path.join(installDir, 'ComfyUI-env', 'Scripts', 'python.exe'); // Check if bundled Python exists if (fs.existsSync(bundledPythonPath)) { return bundledPythonPath; } // Fallback: try alternative installation paths const fallbackPaths = [ path.join(os.homedir(), 'AppData', 'Local', 'FoundryMCPServer', 'ComfyUI', 'ComfyUI', 'python_embeded', 'python.exe'), path.join(os.homedir(), 'AppData', 'Local', 'FoundryMCPServer', 'ComfyUI-headless', 'ComfyUI', 'python_embeded', 'python.exe'), path.join(os.homedir(), 'AppData', 'Local', 'FoundryMCPServer', 'ComfyUI', 'python_embeded', 'python.exe'), path.join(os.homedir(), 'AppData', 'Local', 'FoundryMCPServer', 'ComfyUI-headless', 'python_embeded', 'python.exe'), path.join(os.homedir(), 'AppData', 'Local', 'FoundryMCPServer', 'ComfyUI-env', 'Scripts', 'python.exe'), path.join(process.cwd(), '..', '..', 'ComfyUI-env', 'Scripts', 'python.exe'), path.join(__dirname, '..', '..', '..', 'ComfyUI-env', 'Scripts', 'python.exe'), path.join(os.homedir(), 'AppData', 'Local', 'FoundryMCPServer', 'Python', 'python.exe') ]; for (const fallbackPath of fallbackPaths) { if (fs.existsSync(fallbackPath)) { return fallbackPath; } } // Final fallback to system Python (should not happen with bundled installer) console.error('Bundled Python not found, falling back to system Python'); return 'python'; } // ComfyUI Service Management let comfyuiProcess: ChildProcess | null = null; let comfyuiStatus: 'stopped' | 'starting' | 'running' | 'error' = 'stopped'; let lockFd: number | null = null; function acquireLock(): boolean { try { try { lockFd = fs.openSync(LOCK_FILE, 'wx'); } catch (err: any) { if (err && err.code === 'EEXIST') { try { const lockData = fs.readFileSync(LOCK_FILE, 'utf8'); const lockPid = parseInt(lockData.trim(), 10); try { process.kill(lockPid, 0); // Backend already running - return false to exit gracefully return false; } catch { console.error(`Removing stale backend lock for PID ${lockPid}`); try { fs.unlinkSync(LOCK_FILE); } catch {} lockFd = fs.openSync(LOCK_FILE, 'wx'); } } catch (readErr) { console.error('Corrupt backend lock file, removing:', readErr); try { fs.unlinkSync(LOCK_FILE); } catch {} lockFd = fs.openSync(LOCK_FILE, 'wx'); } } else { console.error('Failed to open backend lock file:', err); return false; } } if (lockFd === null) return false; fs.writeFileSync(lockFd, String(process.pid)); try { fs.fsyncSync(lockFd); } catch {} console.error(`Acquired backend lock with PID ${process.pid}`); return true; } catch (error) { console.error('Failed to acquire backend lock:', error); return false; } } function releaseLock(): void { try { if (lockFd !== null) { try { fs.closeSync(lockFd); } catch {} lockFd = null; } if (fs.existsSync(LOCK_FILE)) { try { fs.unlinkSync(LOCK_FILE); } catch {} } } catch (error) { console.error('Failed to release backend lock:', error); } } // ComfyUI Service Management Functions async function findComfyUIPath(): Promise<string> { // Check for nested ComfyUI installation (current actual structure) const nestedComfyUIPath = path.join(os.homedir(), 'AppData', 'Local', 'FoundryMCPServer', 'ComfyUI', 'ComfyUI'); if (fs.existsSync(path.join(nestedComfyUIPath, 'main.py'))) { return nestedComfyUIPath; } // Check for legacy nested ComfyUI-headless installation (fallback) const nestedHeadlessPath = path.join(os.homedir(), 'AppData', 'Local', 'FoundryMCPServer', 'ComfyUI-headless', 'ComfyUI'); if (fs.existsSync(path.join(nestedHeadlessPath, 'main.py'))) { return nestedHeadlessPath; } // Check for flat ComfyUI installation (unlikely but possible) const flatPath = path.join(os.homedir(), 'AppData', 'Local', 'FoundryMCPServer', 'ComfyUI'); if (fs.existsSync(path.join(flatPath, 'main.py'))) { return flatPath; } // Check for legacy flat ComfyUI-headless installation (fallback) const legacyFlatPath = path.join(os.homedir(), 'AppData', 'Local', 'FoundryMCPServer', 'ComfyUI-headless'); if (fs.existsSync(path.join(legacyFlatPath, 'main.py'))) { return legacyFlatPath; } throw new Error('ComfyUI installation not found'); } async function waitForComfyUIReady(timeoutMs: number = 60000): Promise<void> { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { try { const response = await fetch('http://127.0.0.1:31411/system_stats', { signal: AbortSignal.timeout(5000) }); if (response.ok) { return; // ComfyUI is ready } } catch (error) { // Still starting up, continue polling } await new Promise(resolve => setTimeout(resolve, 2000)); } throw new Error('ComfyUI failed to start within timeout'); } async function startComfyUIService(logger: Logger): Promise<any> { if (comfyuiStatus === 'running') { return { status: 'already_running', message: 'ComfyUI service is already running' }; } if (comfyuiStatus === 'starting') { return { status: 'starting', message: 'ComfyUI service start already in progress' }; } try { comfyuiStatus = 'starting'; logger.info('Starting ComfyUI service...'); // Find ComfyUI installation const comfyUIPath = await findComfyUIPath(); logger.info('ComfyUI found', { path: comfyUIPath }); // Spawn ComfyUI process logger.info('Starting ComfyUI process', { path: path.join(comfyUIPath, 'main.py') }); // Use bundled Python virtual environment const pythonExe = getBundledPythonPath(); logger.info('Using bundled Python', { pythonPath: pythonExe }); comfyuiProcess = spawn(pythonExe, [ 'main.py', '--port', '31411', '--listen', '127.0.0.1', '--disable-auto-launch', '--dont-print-server' ], { cwd: comfyUIPath, stdio: ['ignore', 'pipe', 'pipe'], detached: false, windowsHide: true // Prevent Python console window on Windows }); // Handle process events comfyuiProcess.on('spawn', () => { logger.info('ComfyUI process spawned successfully'); }); comfyuiProcess.on('error', (error) => { logger.error('ComfyUI process error', { error: error.message }); comfyuiStatus = 'error'; }); comfyuiProcess.on('exit', (code, signal) => { logger.info('ComfyUI process exited', { code, signal }); comfyuiStatus = 'stopped'; comfyuiProcess = null; }); // Capture stdout/stderr for debugging comfyuiProcess.stdout?.on('data', (data) => { logger.debug('ComfyUI stdout', { data: data.toString().trim() }); }); comfyuiProcess.stderr?.on('data', (data) => { logger.debug('ComfyUI stderr', { data: data.toString().trim() }); }); // Wait for ComfyUI API to be ready await waitForComfyUIReady(); comfyuiStatus = 'running'; logger.info('ComfyUI service started successfully', { pid: comfyuiProcess.pid, status: comfyuiStatus }); return { status: 'running', message: 'ComfyUI service started successfully', pid: comfyuiProcess.pid }; } catch (error: any) { logger.error('ComfyUI service start failed', { error: error.message }); comfyuiStatus = 'error'; if (comfyuiProcess) { comfyuiProcess.kill(); comfyuiProcess = null; } return { status: 'error', message: `Failed to start ComfyUI service: ${error.message}` }; } } async function stopComfyUIService(logger: Logger): Promise<any> { if (comfyuiStatus === 'stopped') { return { status: 'already_stopped', message: 'ComfyUI service is already stopped' }; } try { logger.info('Stopping ComfyUI service...'); if (comfyuiProcess) { comfyuiProcess.kill('SIGTERM'); // Wait for graceful shutdown, then force kill if needed await new Promise(resolve => setTimeout(resolve, 5000)); if (comfyuiProcess && !comfyuiProcess.killed) { comfyuiProcess.kill('SIGKILL'); } } comfyuiStatus = 'stopped'; comfyuiProcess = null; logger.info('ComfyUI service stopped successfully'); return { status: 'stopped', message: 'ComfyUI service stopped successfully' }; } catch (error: any) { logger.error('ComfyUI service stop failed', { error: error.message }); return { status: 'error', message: `Failed to stop ComfyUI service: ${error.message}` }; } } async function checkComfyUIStatus(): Promise<any> { // Always check if ComfyUI is actually responsive on port 31411 // This handles both spawned processes and externally-started instances try { const response = await fetch('http://127.0.0.1:31411/system_stats', { signal: AbortSignal.timeout(5000) }); if (response.ok) { comfyuiStatus = 'running'; } else { comfyuiStatus = 'error'; } } catch (error) { // ComfyUI is not responsive on port 31411 comfyuiStatus = 'stopped'; } return { status: comfyuiStatus, message: getStatusMessage(comfyuiStatus), pid: comfyuiProcess?.pid || null }; } function getStatusMessage(status: string): string { const statusMessages = { 'stopped': 'ComfyUI service is not running', 'starting': 'ComfyUI service is starting...', 'running': 'ComfyUI service is running', 'error': 'ComfyUI service encountered an error' }; return statusMessages[status as keyof typeof statusMessages] || 'Unknown status'; } // Map generation WebSocket handlers (matching existing tool pattern) async function handleGenerateMapRequest(message: any, jobQueue: any, comfyuiClient: any, logger: Logger, foundryClient: any): Promise<any> { try { logger.info('Map generation request received via WebSocket', { message }); if (!jobQueue || !comfyuiClient) { throw new Error('Map generation components not initialized'); } // Extract data from message - could be in message.data or message directly const data = message.data || message; // Validate input if (!data.prompt || typeof data.prompt !== 'string') { throw new Error('Prompt is required and must be a string'); } if (!data.scene_name || typeof data.scene_name !== 'string') { throw new Error('Scene name is required and must be a string'); } const params = { prompt: data.prompt.trim(), scene_name: data.scene_name.trim(), size: data.size || 'medium', grid_size: data.grid_size || 70, quality: data.quality || 'low' }; // Create job using mapgen's JobQueue const job = await jobQueue.createJob({ params }); const jobId = job.id; // Start background processing (mapgen style) processMapGenerationInBackend(jobId, jobQueue, comfyuiClient, logger, foundryClient).catch((error) => { logger.error('Background map generation failed', { jobId, error }); }); return { status: 'success', jobId: jobId, message: 'Map generation started', estimatedTime: 'varies by hardware and quality setting' }; } catch (error: any) { logger.error('Map generation request failed', { error: error.message }); return { status: 'error', message: error.message }; } } async function handleCheckMapStatusRequest(data: any, jobQueue: any, logger: Logger): Promise<any> { try { if (!data) { throw new Error('Request data is required'); } const jobId = data.job_id; if (!jobId) { throw new Error('Job ID is required'); } const job = await jobQueue.getJob(jobId); if (!job) { return { status: 'error', message: `Job ${jobId} not found` }; } return { status: 'success', job: { id: job.id, status: job.status, progress_percent: job.progress_percent, current_stage: job.current_stage, result: job.result, error: job.error } }; } catch (error: any) { logger.error('Map status check failed', { error: error.message }); return { status: 'error', message: error.message }; } } async function handleCancelMapJobRequest(data: any, jobQueue: any, comfyuiClient: any, logger: Logger): Promise<any> { try { if (!data) { throw new Error('Request data is required'); } const jobId = data.job_id; if (!jobId) { throw new Error('Job ID is required'); } // Get the job to find ComfyUI prompt_id const job = await jobQueue.getJob(jobId); if (!job) { return { status: 'error', message: 'Job not found' }; } // Cancel in ComfyUI if we have a prompt_id if (job.comfyui_job_id) { logger.info('Cancelling ComfyUI job', { jobId, promptId: job.comfyui_job_id }); const comfyuiCancelled = await comfyuiClient.cancelJob(job.comfyui_job_id); if (comfyuiCancelled) { logger.info('ComfyUI job interrupted successfully', { jobId, promptId: job.comfyui_job_id }); } else { logger.warn('Failed to interrupt ComfyUI job', { jobId, promptId: job.comfyui_job_id }); } } // Mark job as cancelled in our queue const cancelled = await jobQueue.cancelJob(jobId); return { status: cancelled ? 'success' : 'error', message: cancelled ? 'Job cancelled successfully' : 'Failed to cancel job' }; } catch (error: any) { logger.error('Map job cancellation failed', { error: error.message }); return { status: 'error', message: error.message }; } } // Background processing using mapgen's proven approach async function processMapGenerationInBackend(jobId: string, jobQueue: any, comfyuiClient: any, logger: Logger, foundryClient: any): Promise<void> { // CRITICAL: Log entry to file IMMEDIATELY const fs2 = await import('fs').then(m => m.promises); const path2 = await import('path'); const os2 = await import('os'); const processDebugLog = path2.join(os2.tmpdir(), 'process-mapgen-debug.log'); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] processMapGenerationInBackend ENTERED - jobId: ${jobId}\n`); try { await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Getting job from queue...\n`); const job = await jobQueue.getJob(jobId); if (!job) { await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] ERROR: Job not found!\n`); throw new Error(`Job ${jobId} not found`); } await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Job retrieved: ${JSON.stringify(job.params)}\n`); logger.info('Starting background map generation processing', { jobId, params: job.params }); // Mark job as started (mapgen style) await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Marking job as started...\n`); await jobQueue.markJobStarted(jobId); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Job marked as started\n`); // Emit progress to Foundry module await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Sending initial progress...\n`); foundryClient.sendMessage({ type: 'map-generation-progress', jobId: jobId, progress: 10, stage: 'Starting processing...' }); // Ensure ComfyUI is running await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Checking ComfyUI health...\n`); const healthInfo = await comfyuiClient.checkHealth(); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Health check: ${JSON.stringify(healthInfo)}\n`); if (!healthInfo.available) { await comfyuiClient.startService(); } await jobQueue.updateJobProgress(jobId, 25, 'Submitting to ComfyUI...'); foundryClient.sendMessage({ type: 'map-generation-progress', jobId: jobId, progress: 25, stage: 'Submitting to ComfyUI...' }); // Submit to ComfyUI (using mapgen's client) await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Submitting job to ComfyUI...\n`); const sizePixels = comfyuiClient.getSizePixels(job.params.size as any); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Size pixels: ${sizePixels}\n`); let comfyuiJob; try { comfyuiJob = await comfyuiClient.submitJob({ prompt: job.params.prompt, width: sizePixels, height: sizePixels, quality: job.params.quality }); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] ComfyUI job submitted: ${comfyuiJob.prompt_id}\n`); // Store ComfyUI prompt_id in job for cancellation support const currentJob = await jobQueue.getJob(jobId); if (currentJob) { currentJob.comfyui_job_id = comfyuiJob.prompt_id; } } catch (submitError: any) { await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] ERROR submitting to ComfyUI: ${submitError.message}\n`); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Error stack: ${submitError.stack}\n`); throw submitError; } // Wait for completion (mapgen style) await jobQueue.updateJobProgress(jobId, 50, 'Generating battlemap...'); foundryClient.sendMessage({ type: 'map-generation-progress', jobId: jobId, progress: 50, stage: 'Generating battlemap...' }); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Starting status polling with WebSocket progress...\n`); // Register WebSocket callback for real-time progress updates comfyuiClient.registerProgressCallback(comfyuiJob.prompt_id, (progress: { currentStep: number; totalSteps: number }) => { const { currentStep, totalSteps } = progress; const progressPercent = Math.floor((currentStep / totalSteps) * 100); logger.info('Real-time progress update from ComfyUI', { jobId, promptId: comfyuiJob.prompt_id, currentStep, totalSteps, progressPercent }); // Send progress update to Foundry foundryClient.sendMessage({ type: 'map-generation-progress', data: { jobId: jobId, progress: 50 + (progressPercent / 2), // Map 0-100% to 50-100% (since we're at 50% when generation starts) status: 'AI generating battlemap...', queueInfo: { currentStep, totalSteps, estimatedTimeRemaining: undefined // WebSocket doesn't provide time estimates } } }); }); let status = await comfyuiClient.getJobStatus(comfyuiJob.prompt_id); logger.info('Initial job status', { jobId, promptId: comfyuiJob.prompt_id, status }); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Initial status: ${status}\n`); let pollCount = 0; while (status === 'queued' || status === 'running') { pollCount++; logger.info('Polling job status', { jobId, promptId: comfyuiJob.prompt_id, pollCount, currentStatus: status }); await new Promise(resolve => setTimeout(resolve, 5000)); // Check status every 5 seconds (WebSocket handles progress) status = await comfyuiClient.getJobStatus(comfyuiJob.prompt_id); logger.info('Job status after poll', { jobId, promptId: comfyuiJob.prompt_id, pollCount, newStatus: status }); } // Unregister callback when done comfyuiClient.unregisterProgressCallback(comfyuiJob.prompt_id); logger.info('Job polling completed', { jobId, promptId: comfyuiJob.prompt_id, finalStatus: status, totalPolls: pollCount }); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Polling complete, status: ${status}\n`); if (status === 'failed') { throw new Error('ComfyUI generation failed'); } // Download and save the generated image (like mapgen does) await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Getting job images...\n`); await jobQueue.updateJobProgress(jobId, 85, 'Downloading image...'); // Get the generated image filenames from ComfyUI history const imageFilenames = await comfyuiClient.getJobImages(comfyuiJob.prompt_id); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Images: ${JSON.stringify(imageFilenames)}\n`); if (!imageFilenames || imageFilenames.length === 0) { throw new Error('No images found in ComfyUI job output'); } // Download the first generated image await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Downloading image: ${imageFilenames[0]}\n`); const firstImageFilename = imageFilenames[0]; const imageBuffer = await comfyuiClient.downloadImage(firstImageFilename); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Downloaded, buffer size: ${imageBuffer?.length || 0}\n`); if (!imageBuffer) { throw new Error(`Failed to download generated image: ${firstImageFilename}`); } await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Updating progress to 90%...\n`); await jobQueue.updateJobProgress(jobId, 90, 'Saving image...'); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Progress updated\n`); // Save image to Foundry-accessible location await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] About to import fs/path/os for upload...\n`); const fs = await import('fs').then(m => m.promises); const path = await import('path'); const os = await import('os'); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Imports complete\n`); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Creating filename and checking connection type...\n`); const timestamp = Date.now(); const filename = `map_${jobId}_${timestamp}.png`; let webPath: string; // ALWAYS upload images via Foundry query instead of direct filesystem write // Reason: MCP server and Foundry may be on different machines or have different paths // The Foundry module's upload handler knows the correct local path await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] foundryClient exists: ${!!foundryClient}, type: ${typeof foundryClient}\n`); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] About to call getConnectionType()...\n`); let connectionType: 'websocket' | 'webrtc' | null = null; try { connectionType = foundryClient.getConnectionType(); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] getConnectionType() returned: ${connectionType}\n`); } catch (err) { await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] getConnectionType() threw error: ${err}\n`); connectionType = 'webrtc'; // Assume WebRTC since we're here } await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Using upload method for all connections\n`); // ALWAYS write debug log to trace execution const debugLog = async (msg: string) => { const logPath = path.join(os.tmpdir(), 'foundry-mcp-upload-debug.log'); await fs.appendFile(logPath, `[${new Date().toISOString()}] ${msg}\n`); }; await debugLog(`=== MAP GENERATION DEBUG START ===`); await debugLog(`JobId: ${jobId}, Filename: ${filename}`); await debugLog(`Connection type: ${connectionType}`); await debugLog(`Image size: ${imageBuffer.length} bytes`); await debugLog(`Using upload method (always) - imageSize: ${imageBuffer.length} bytes`); // Convert image buffer to base64 for transmission const base64Image = imageBuffer.toString('base64'); await debugLog(`Base64 conversion complete - size: ${base64Image.length} bytes (${(base64Image.length / 1024 / 1024).toFixed(2)} MB)`); // Upload to Foundry via WebRTC/WebSocket query // The Foundry module's upload handler knows the correct local path await debugLog('Sending upload query to Foundry...'); let uploadResult: any; try { uploadResult = await foundryClient.query('foundry-mcp-bridge.upload-generated-map', { filename: filename, imageData: base64Image }); await debugLog(`Upload query completed - success: ${uploadResult.success}`); await debugLog(`Full uploadResult: ${JSON.stringify(uploadResult)}`); if (!uploadResult.success) { await debugLog(`Upload failed - error: ${uploadResult.error}`); throw new Error(`Failed to upload image to Foundry: ${uploadResult.error}`); } } catch (error) { await debugLog(`Upload exception: ${error instanceof Error ? error.message : String(error)}`); throw error; } await debugLog(`Extracting path from uploadResult...`); webPath = uploadResult.path; await debugLog(`webPath extracted: ${webPath}`); logger.info('Image uploaded successfully to Foundry', { path: webPath }); await jobQueue.updateJobProgress(jobId, 95, 'Creating scene data...'); // Create scene data payload (simplified version of mapgen's FoundryIntegrator) const sceneSize = comfyuiClient.getSizePixels(job.params.size as any); // Debug: Log what we received logger.info('Job params received', { scene_name: job.params.scene_name, prompt: job.params.prompt, all_params: job.params }); if (!job.params.scene_name) { throw new Error(`Scene name missing from job params. Received params: ${JSON.stringify(job.params)}`); } const sceneName = job.params.scene_name.trim(); logger.info('Using scene name', { scene_name: sceneName }); const sceneData = { name: sceneName, img: webPath, background: { src: webPath }, // Foundry v13 compatibility width: sceneSize, height: sceneSize, padding: 0.25, initial: { x: sceneSize / 2, y: sceneSize / 2, scale: 1 }, backgroundColor: "#999999", grid: { type: 1, // CONST.GRID_TYPES.SQUARE size: job.params.grid_size || 100, color: "#000000", alpha: 0.2, distance: 5, units: "ft" }, tokenVision: true, fogExploration: true, fogReset: Date.now(), globalLight: false, darkness: 0, navigation: true, active: false, permission: { default: 2 // CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER }, walls: [] // Could add wall detection here later }; // Mark job as complete with full result data await jobQueue.updateJobProgress(jobId, 100, 'Complete'); await jobQueue.markJobComplete(jobId, { generation_time_ms: Date.now() - (job.started_at || job.created_at), image_url: webPath, foundry_scene_payload: sceneData }); // Broadcast completion with scene data (like mapgen does) foundryClient.broadcastMessage({ type: 'job-completed', // Use mapgen's message type jobId: jobId, data: { status: 'completed', result: sceneData, // Complete scene payload image_path: webPath, prompt: job.params.prompt } }); logger.info('Map generation completed successfully', { jobId }); } catch (error: any) { // Log to debug file first await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] ERROR in processMapGenerationInBackend: ${error.message}\n`); await fs2.appendFile(processDebugLog, `[${new Date().toISOString()}] Error stack: ${error.stack}\n`); logger.error('Background map generation processing failed', { jobId, error }); await jobQueue.markJobFailed(jobId, error.message); // Emit failure to Foundry module foundryClient.sendMessage({ type: 'map-generation-failed', jobId: jobId, error: error.message }); } } async function startBackend(): Promise<void> { // Logger: file output allowed; avoid stdout noise const logger = new Logger({ level: config.logLevel, format: config.logFormat, enableConsole: false, enableFile: true, filePath: path.join(os.tmpdir(), 'foundry-mcp-server', 'mcp-server.log'), }); logger.info('Starting Foundry MCP Backend', { version: config.server.version, foundryHost: config.foundry.host, foundryPort: config.foundry.port, }); // Initialize Foundry client and tools const foundryClient = new FoundryClient(config.foundry, logger); // Initialize system registry and register adapters const { getSystemRegistry } = await import('./systems/index.js'); const { DnD5eAdapter } = await import('./systems/dnd5e/adapter.js'); const { PF2eAdapter } = await import('./systems/pf2e/adapter.js'); const { DSA5Adapter } = await import('./systems/dsa5/adapter.js'); const systemRegistry = getSystemRegistry(logger); systemRegistry.register(new DnD5eAdapter()); systemRegistry.register(new PF2eAdapter()); systemRegistry.register(new DSA5Adapter()); logger.info('System registry initialized', { supportedSystems: systemRegistry.getSupportedSystems() }); const characterTools = new CharacterTools({ foundryClient, logger, systemRegistry }); const compendiumTools = new CompendiumTools({ foundryClient, logger, systemRegistry }); const sceneTools = new SceneTools({ foundryClient, logger }); const actorCreationTools = new ActorCreationTools({ foundryClient, logger }); const dsa5CharacterCreator = new DSA5CharacterCreator({ foundryClient, logger }); const questCreationTools = new QuestCreationTools({ foundryClient, logger }); const diceRollTools = new DiceRollTools({ foundryClient, logger }); const campaignManagementTools = new CampaignManagementTools(foundryClient, logger); const ownershipTools = new OwnershipTools({ foundryClient, logger }); const tokenManipulationTools = new TokenManipulationTools({ foundryClient, logger }); // Initialize mapgen-style backend components for map generation let mapGenerationJobQueue: any = null; let mapGenerationComfyUIClient: any = null; try { // Import and initialize job queue and ComfyUI client const { JobQueue } = await import('./job-queue.js'); const { ComfyUIClient } = await import('./comfyui-client.js'); mapGenerationJobQueue = new JobQueue({ logger }); // Initialize ComfyUI client - always runs locally on same machine as MCP server mapGenerationComfyUIClient = new ComfyUIClient({ logger, config: { port: config.comfyui?.port || 31411 } }); logger.info('Map generation backend components initialized (ComfyUI on localhost:31411)'); // Auto-start ComfyUI if installed and autoStart is enabled if (mapGenerationComfyUIClient && (mapGenerationComfyUIClient as any).config?.autoStart) { const isInstalled = await (mapGenerationComfyUIClient as any).checkInstallation(); if (isInstalled) { logger.info('Auto-starting ComfyUI service...'); try { await (mapGenerationComfyUIClient as any).startService(); logger.info('ComfyUI service auto-started successfully'); } catch (error) { logger.warn('Failed to auto-start ComfyUI service', { error }); } } else { logger.info('ComfyUI not installed, skipping auto-start'); } } } catch (error) { logger.warn('Failed to initialize map generation components', { error }); } // Set up global ComfyUI message handlers for WebSocket messages from Foundry BEFORE creating map tools (globalThis as any).backendComfyUIHandlers = { handleMessage: async (message: any) => { // CRITICAL DEBUG: Write to file IMMEDIATELY when this function is called const fs = await import('fs').then(m => m.promises); const path = await import('path'); const os = await import('os'); const debugLog = path.join(os.tmpdir(), 'backend-handler-debug.log'); await fs.appendFile(debugLog, `[${new Date().toISOString()}] handleMessage called - type: ${message?.type}, requestId: ${message?.requestId}\n`); logger.info('Handling ComfyUI message', { requestId: message.requestId, type: message.type, hasData: !!message.data }); try { // Debug: Log before switch const fs = await import('fs').then(m => m.promises); const path = await import('path'); const os = await import('os'); const debugLog = path.join(os.tmpdir(), 'backend-handler-debug.log'); await fs.appendFile(debugLog, `[${new Date().toISOString()}] About to switch on message.type: "${message.type}"\n`); let result: any; switch (message.type) { case 'start-comfyui-service': result = await startComfyUIService(logger); break; case 'stop-comfyui-service': result = await stopComfyUIService(logger); break; case 'check-comfyui-status': result = await checkComfyUIStatus(); break; // Map generation handlers (following existing tool pattern) case 'generate-map-request': await fs.appendFile(debugLog, `[${new Date().toISOString()}] Matched generate-map-request case, calling handler...\n`); result = await handleGenerateMapRequest(message, mapGenerationJobQueue, mapGenerationComfyUIClient, logger, foundryClient); await fs.appendFile(debugLog, `[${new Date().toISOString()}] Handler returned: ${JSON.stringify(result)}\n`); break; case 'check-map-status-request': result = await handleCheckMapStatusRequest(message.data, mapGenerationJobQueue, logger); break; case 'cancel-map-job-request': result = await handleCancelMapJobRequest(message.data, mapGenerationJobQueue, mapGenerationComfyUIClient, logger); break; default: logger.warn('Unknown ComfyUI message type', { type: message.type }); result = { status: 'error', message: `Unknown message type: ${message.type}` }; } // Send response back through foundryClient if requestId is provided if (message.requestId && foundryClient) { const response = { type: `${message.type}-response`, requestId: message.requestId, ...result }; // Send response to Foundry via WebSocket try { foundryClient.sendMessage(response); } catch (error) { logger.error('Failed to send ComfyUI response to Foundry', { error, response }); } } return result; } catch (error: any) { logger.error('ComfyUI message handling failed', { requestId: message.requestId, type: message.type, error: error.message }); const errorResult = { status: 'error', message: error.message }; // Send error response if requestId provided if (message.requestId && foundryClient) { try { foundryClient.sendMessage({ type: `${message.type}-response`, requestId: message.requestId, ...errorResult }); } catch (sendError) { logger.error('Failed to send ComfyUI error response', { sendError }); } } return errorResult; } } }; // Now create MapGenerationTools with the handlers available const mapGenerationTools = new MapGenerationTools({ foundryClient, logger, backendComfyUIHandlers: (globalThis as any).backendComfyUIHandlers }); const allTools = [ ...characterTools.getToolDefinitions(), ...compendiumTools.getToolDefinitions(), ...sceneTools.getToolDefinitions(), ...actorCreationTools.getToolDefinitions(), ...dsa5CharacterCreator.getToolDefinitions(), ...questCreationTools.getToolDefinitions(), ...diceRollTools.getToolDefinitions(), ...campaignManagementTools.getToolDefinitions(), ...ownershipTools.getToolDefinitions(), ...tokenManipulationTools.getToolDefinitions(), ...mapGenerationTools.getToolDefinitions(), ]; // Start Foundry connector (owns app port 31415) foundryClient.connect().catch((e) => { logger.error('Foundry connector failed to start', e); }); const autoStartComfyUI = async () => { try { logger.info('Auto-starting ComfyUI service...'); const result = await startComfyUIService(logger); logger.info('ComfyUI auto-start result', result); } catch (error: any) { logger.warn('ComfyUI auto-start failed', { error: error instanceof Error ? error.message : String(error) }); // Don't throw - backend should continue even if ComfyUI fails to start } }; // Control channel (TCP JSON-lines) const server = net.createServer((socket) => { socket.setEncoding('utf8'); let buffer = ''; socket.on('data', async (chunk: string) => { buffer += chunk; let idx: number; while ((idx = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, idx).trim(); buffer = buffer.slice(idx + 1); if (!line) continue; try { const msg = JSON.parse(line) as { id: string; method: string; params?: any }; if (msg.method === 'ping') { socket.write(JSON.stringify({ id: msg.id, result: { ok: true } }) + '\n'); continue; } if (msg.method === 'list_tools') { socket.write(JSON.stringify({ id: msg.id, result: { tools: allTools } }) + '\n'); continue; } if (msg.method === 'call_tool') { const { name, args } = (msg.params || {}) as { name: string; args?: any }; try { let result: any; switch (name) { // Character tools case 'get-character': result = await characterTools.handleGetCharacter(args); break; case 'list-characters': result = await characterTools.handleListCharacters(args); break; case 'get-character-entity': result = await characterTools.handleGetCharacterEntity(args); break; // Compendium tools case 'search-compendium': result = await compendiumTools.handleSearchCompendium(args); break; case 'get-compendium-item': result = await compendiumTools.handleGetCompendiumItem(args); break; case 'list-creatures-by-criteria': result = await compendiumTools.handleListCreaturesByCriteria(args); break; case 'list-compendium-packs': result = await compendiumTools.handleListCompendiumPacks(args); break; // Scene tools case 'get-current-scene': result = await sceneTools.handleGetCurrentScene(args); break; case 'get-world-info': result = await sceneTools.handleGetWorldInfo(args); break; // Actor creation tools case 'create-actor-from-compendium': result = await actorCreationTools.handleCreateActorFromCompendium(args); break; case 'get-compendium-entry-full': result = await actorCreationTools.handleGetCompendiumEntryFull(args); break; // DSA5 character creation tools case 'create-dsa5-character-from-archetype': result = await dsa5CharacterCreator.handleCreateCharacterFromArchetype(args); break; case 'list-dsa5-archetypes': result = await dsa5CharacterCreator.handleListArchetypes(args); break; // Quest creation tools case 'create-quest-journal': result = await questCreationTools.handleCreateQuestJournal(args); break; case 'link-quest-to-npc': result = await questCreationTools.handleLinkQuestToNPC(args); break; case 'update-quest-journal': result = await questCreationTools.handleUpdateQuestJournal(args); break; case 'list-journals': result = await questCreationTools.handleListJournals(args); break; case 'search-journals': result = await questCreationTools.handleSearchJournals(args); break; // Dice roll tools case 'request-player-rolls': result = await diceRollTools.handleRequestPlayerRolls(args); break; // Campaign management tools case 'create-campaign-dashboard': result = await campaignManagementTools.handleCreateCampaignDashboard(args); break; // Ownership tools case 'assign-actor-ownership': result = await ownershipTools.handleToolCall('assign-actor-ownership', args); break; case 'remove-actor-ownership': result = await ownershipTools.handleToolCall('remove-actor-ownership', args); break; case 'list-actor-ownership': result = await ownershipTools.handleToolCall('list-actor-ownership', args); break; // Token manipulation tools case 'move-token': result = await tokenManipulationTools.handleMoveToken(args); break; case 'update-token': result = await tokenManipulationTools.handleUpdateToken(args); break; case 'delete-tokens': result = await tokenManipulationTools.handleDeleteTokens(args); break; case 'get-token-details': result = await tokenManipulationTools.handleGetTokenDetails(args); break; case 'toggle-token-condition': result = await tokenManipulationTools.handleToggleTokenCondition(args); break; case 'get-available-conditions': result = await tokenManipulationTools.handleGetAvailableConditions(args); break; // Map generation tools case 'generate-map': result = await mapGenerationTools.generateMap(args); break; case 'check-map-status': result = await mapGenerationTools.checkMapStatus(args); break; case 'cancel-map-job': result = await mapGenerationTools.cancelMapJob(args); break; case 'list-scenes': result = await mapGenerationTools.listScenes(args); break; case 'switch-scene': result = await mapGenerationTools.switchScene(args); break; default: throw new Error(`Unknown tool: ${name}`); } const payload = { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }], }; socket.write(JSON.stringify({ id: msg.id, result: payload }) + '\n'); } catch (e: any) { const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred'; socket.write( JSON.stringify({ id: msg.id, result: { content: [{ type: 'text', text: `Error: ${errorMessage}` }], isError: true } }) + '\n' ); } continue; } // Unknown method socket.write(JSON.stringify({ id: msg.id, error: { message: 'Unknown method' } }) + '\n'); } catch (e: any) { try { socket.write(JSON.stringify({ error: { message: e?.message || 'Bad request' } }) + '\n'); } catch {} } } }); }); await new Promise<void>((resolve, reject) => { server.listen(CONTROL_PORT, CONTROL_HOST, () => { logger.info(`Backend control channel listening on ${CONTROL_HOST}:${CONTROL_PORT}`); resolve(); }); server.on('error', reject); }); void autoStartComfyUI(); // Shutdown hooks process.on('SIGINT', () => { foundryClient.disconnect(); releaseLock(); process.exit(0); }); process.on('SIGTERM', () => { foundryClient.disconnect(); releaseLock(); process.exit(0); }); } // Check lock BEFORE any async operations // If another instance is running, wait forever silently (don't exit) // This prevents Claude Desktop from seeing a "server closed" error const hasLock = acquireLock(); (async function main() { if (!hasLock) { // Another backend is running - wait forever without doing anything // This keeps the process alive so Claude doesn't see an error await new Promise(() => {}); // Never resolves return; } process.on('exit', releaseLock); try { await startBackend(); } catch (e: any) { console.error('Failed to start backend:', e?.message || e); releaseLock(); process.exit(1); } })();

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/adambdooley/foundry-vtt-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server