Skip to main content
Glama
main.ts23.6 kB
import { MODULE_ID } from './constants.js'; import { SocketBridge } from './socket-bridge.js'; import { QueryHandlers } from './queries.js'; import { ModuleSettings } from './settings.js'; import { CampaignHooks } from './campaign-hooks.js'; import { ComfyUIManager } from './comfyui-manager.js'; // Connection control now handled through settings menu /** * Main Foundry MCP Bridge Module Class */ class FoundryMCPBridge { private settings: ModuleSettings; private queryHandlers: QueryHandlers; private campaignHooks: CampaignHooks; public comfyuiManager: ComfyUIManager; private socketBridge: SocketBridge | null = null; private isInitialized = false; private heartbeatInterval: number | null = null; private lastActivity: Date = new Date(); private isConnecting = false; constructor() { this.settings = new ModuleSettings(); this.queryHandlers = new QueryHandlers(); this.campaignHooks = new CampaignHooks(this); this.comfyuiManager = new ComfyUIManager(); } /** * Check if current user is a GM (silent check for security) */ private isGMUser(): boolean { return game.user?.isGM || false; } /** * Initialize the module during Foundry's init hook */ async initialize(): Promise<void> { try { console.log(`[${MODULE_ID}] Initializing Foundry MCP Bridge...`); // Register module settings this.settings.registerSettings(); // Register query handlers this.queryHandlers.registerHandlers(); // Register campaign hooks for interactive dashboards this.campaignHooks.register(); // Expose data access globally for settings UI (window as any).foundryMCPBridge.dataAccess = this.queryHandlers.dataAccess; this.isInitialized = true; console.log(`[${MODULE_ID}] Module initialized successfully`); } catch (error) { console.error(`[${MODULE_ID}] Failed to initialize:`, error); ui.notifications.error('Failed to initialize Foundry MCP Bridge'); throw error; } } /** * Start the module after Foundry is ready */ async onReady(): Promise<void> { try { // SECURITY: Silent GM-only check - non-GM users get no access and no messages if (!this.isGMUser()) { console.log(`[${MODULE_ID}] Module ready (user access restricted)`); return; } console.log(`[${MODULE_ID}] Foundry ready, checking bridge status...`); // Connection control now handled through settings menu // Validate settings const validation = this.settings.validateSettings(); if (!validation.valid) { console.warn(`[${MODULE_ID}] Invalid settings:`, validation.errors); ui.notifications.warn(`MCP Bridge settings validation failed: ${validation.errors.join(', ')}`); } // Auto-connect when enabled (always automatic) const enabled = this.settings.getSetting('enabled'); if (enabled) { await this.start(); } // Auto-build enhanced creature index if enabled and not exists await this.checkAndBuildEnhancedIndex(); // Start ComfyUI startup monitoring if module is enabled if (enabled) { await this.startComfyUIMonitoring(); } console.log(`[${MODULE_ID}] Module ready`); } catch (error) { console.error(`[${MODULE_ID}] Failed during ready:`, error); } } /** * Check if enhanced creature index exists and build if needed (better UX) */ private async checkAndBuildEnhancedIndex(): Promise<void> { try { // Only for GM users if (!this.isGMUser()) return; // Check if enhanced index is enabled const enhancedIndexEnabled = this.settings.getSetting('enableEnhancedCreatureIndex'); if (!enhancedIndexEnabled) return; // Check if index file exists const indexFilename = 'enhanced-creature-index.json'; try { const browseResult = await (foundry as any).applications.apps.FilePicker.implementation.browse('data', `worlds/${game.world.id}`); const indexExists = browseResult.files.some((f: any) => f.endsWith(indexFilename)); if (!indexExists) { console.log(`[${MODULE_ID}] Enhanced creature index not found, building automatically for better UX...`); ui.notifications?.info('Building enhanced creature index for faster searches...'); // Trigger index build through data access if (this.queryHandlers?.dataAccess?.rebuildEnhancedCreatureIndex) { await this.queryHandlers.dataAccess.rebuildEnhancedCreatureIndex(); } } else { console.log(`[${MODULE_ID}] Enhanced creature index exists, ready for instant searches`); } } catch (error) { // World directory might not exist yet, that's okay console.log(`[${MODULE_ID}] Could not check for enhanced index file (world directory may not exist yet)`); } } catch (error) { console.warn(`[${MODULE_ID}] Failed to auto-build enhanced index:`, error); } } /** * Start the MCP bridge connection */ async start(): Promise<void> { if (!this.isInitialized) { throw new Error('Module not initialized'); } // SECURITY: Double-check GM access (safety measure) if (!this.isGMUser()) { console.warn(`[${MODULE_ID}] Attempted to start bridge without GM access`); return; } if (this.socketBridge?.isConnected() || this.isConnecting) { console.log(`[${MODULE_ID}] Bridge already running or connecting`); return; } this.isConnecting = true; try { console.log(`[${MODULE_ID}] Starting MCP bridge...`); const config = this.settings.getBridgeConfig(); // Validate configuration const validation = this.settings.validateSettings(); if (!validation.valid) { throw new Error(`Invalid configuration: ${validation.errors.join(', ')}`); } // Create and connect socket bridge this.socketBridge = new SocketBridge(config); await this.socketBridge.connect(); // Log connection details for debugging const connectionInfo = this.socketBridge.getConnectionInfo(); console.log(`[${MODULE_ID}] Bridge started successfully - Type: ${connectionInfo.type}, State: ${connectionInfo.state}`); await this.settings.setSetting('lastConnectionState', 'connected'); await this.settings.setSetting('lastActivity', new Date().toISOString()); this.updateLastActivity(); // Update settings display with connection status this.settings.updateConnectionStatusDisplay(true, 17); // 17 MCP tools // Start heartbeat monitoring if enabled this.startHeartbeat(); // Show connection notification based on user preference if (this.settings.getSetting('enableNotifications')) { ui.notifications.info('🔗 MCP Bridge connected successfully'); } console.log(`[${MODULE_ID}] GM connection established - Bridge active for user: ${game.user?.name}`); } catch (error) { // Log as warning instead of error for initial connection failures console.warn(`[${MODULE_ID}] Failed to start bridge:`, error); // Show helpful message for GM users when MCP server isn't available if (this.isGMUser()) { const errorMessage = error instanceof Error ? error.message : String(error); // Check if it's a connection refusal (MCP server not running) if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('connect ECONNREFUSED')) { // Only show this notification if it's been more than 30 seconds since last shown const lastShown = this.settings.getSetting('lastMCPServerNotification') as string; const now = new Date().getTime(); const thirtySecondsAgo = now - (30 * 1000); if (!lastShown || new Date(lastShown).getTime() < thirtySecondsAgo) { ui.notifications?.warn( 'MCP Server not found. Install it from https://github.com/adambdooley/foundry-vtt-mcp' ); // Remember when we showed this notification this.settings.setSetting('lastMCPServerNotification', new Date().toISOString()).catch(() => { // Ignore settings save errors during startup }); } } } await this.settings.setSetting('lastConnectionState', 'error'); throw error; } finally { this.isConnecting = false; } } /** * Stop the MCP bridge connection */ async stop(): Promise<void> { if (!this.socketBridge) { console.log(`[${MODULE_ID}] Bridge not running`); return; } try { console.log(`[${MODULE_ID}] Stopping MCP bridge...`); // Stop heartbeat monitoring this.stopHeartbeat(); this.socketBridge.disconnect(); this.socketBridge = null; await this.settings.setSetting('lastConnectionState', 'disconnected'); // Update settings display with disconnected status this.settings.updateConnectionStatusDisplay(false, 0); console.log(`[${MODULE_ID}] Bridge stopped`); // Show disconnection notification based on user preference if (this.settings.getSetting('enableNotifications')) { ui.notifications.info('MCP Bridge disconnected'); } } catch (error) { console.error(`[${MODULE_ID}] Error stopping bridge:`, error); } } /** * Restart the bridge with current settings */ async restart(): Promise<void> { console.log(`[${MODULE_ID}] Restarting bridge...`); await this.stop(); // Small delay to ensure clean disconnect await new Promise(resolve => setTimeout(resolve, 1000)); if (this.settings.getSetting('enabled')) { await this.start(); } } /** * Get current bridge status */ getStatus(): any { return { initialized: this.isInitialized, enabled: this.settings.getSetting('enabled'), connected: this.socketBridge?.isConnected() ?? false, connectionState: this.socketBridge?.getConnectionState() ?? 'disconnected', connectionInfo: this.socketBridge?.getConnectionInfo(), settings: this.settings.getAllSettings(), registeredMethods: this.queryHandlers.getRegisteredMethods(), lastConnectionState: this.settings.getSetting('lastConnectionState'), lastActivity: this.lastActivity.toISOString(), heartbeatActive: this.heartbeatInterval !== null, }; } /** * Start heartbeat monitoring */ private startHeartbeat(): void { this.stopHeartbeat(); // Ensure no duplicate intervals const interval = this.settings.getSetting('heartbeatInterval') * 1000; // Convert to milliseconds this.heartbeatInterval = window.setInterval(async () => { await this.performHeartbeat(); }, interval); console.log(`[${MODULE_ID}] Heartbeat monitoring started (${interval}ms interval)`); } /** * Stop heartbeat monitoring */ private stopHeartbeat(): void { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; console.log(`[${MODULE_ID}] Heartbeat monitoring stopped`); } } /** * Perform heartbeat check */ private async performHeartbeat(): Promise<void> { try { // Lightweight connection check - just verify socket state if (!this.socketBridge || !this.socketBridge.isConnected()) { // Only log once per disconnection to avoid spam if (this.lastActivity && new Date().getTime() - this.lastActivity.getTime() > 60000) { console.warn(`[${MODULE_ID}] Heartbeat: Connection lost`); // Attempt auto-reconnection if enabled (with backoff) if (this.settings.getSetting('autoReconnectEnabled')) { console.log(`[${MODULE_ID}] Attempting auto-reconnection...`); await this.restart(); } } return; } // Just update activity timestamp - no actual network ping needed // The socket bridge already handles connection state monitoring this.updateLastActivity(); } catch (error) { // Only attempt reconnect once per failure cycle if (this.settings.getSetting('autoReconnectEnabled')) { console.log(`[${MODULE_ID}] Heartbeat failure - attempting single reconnection...`); try { await this.restart(); } catch (reconnectError) { console.error(`[${MODULE_ID}] Auto-reconnection failed:`, reconnectError); // Disable further attempts until manual intervention await this.settings.setSetting('autoReconnectEnabled', false); if (this.settings.getSetting('enableNotifications')) { ui.notifications.warn('⚠️ Lost connection to AI model - Auto-reconnect disabled'); } } } } } /** * Update last activity timestamp */ updateLastActivity(): void { this.lastActivity = new Date(); this.settings.setSetting('lastActivity', this.lastActivity.toISOString()); } /** * Get query handlers for campaign hooks */ getQueryHandlers(): QueryHandlers { return this.queryHandlers; } /** * Monitor ComfyUI startup and show status banners */ async startComfyUIMonitoring(): Promise<void> { try { // Check if ComfyUI monitoring is needed const autoStart = this.settings.getSetting('mapGenAutoStart') || false; if (!autoStart) { console.log(`[${MODULE_ID}] ComfyUI auto-start disabled, skipping monitoring`); return; } console.log(`[${MODULE_ID}] Starting ComfyUI monitoring...`); // Show initial loading banner ui.notifications?.info(`🔗 Starting AI Map Generation service... (Models loading, please wait)`); let attempts = 0; const maxAttempts = 24; // 2 minutes with 5-second intervals const checkInterval = 5000; // 5 seconds const checkStatus = async (): Promise<void> => { try { attempts++; const status = await this.comfyuiManager.checkStatus(); console.log(`[${MODULE_ID}] ComfyUI status check #${attempts}:`, status); if (status.status === 'running') { // Success! ComfyUI is ready ui.notifications?.info(`✅ AI Map Generation service ready! Models loaded successfully.`); console.log(`[${MODULE_ID}] ComfyUI ready after ${attempts} attempts (${attempts * 5}s)`); return; } if (attempts >= maxAttempts) { // Timeout - show failure banner ui.notifications?.warn(`⚠️ AI Map Generation service failed to start (timeout after 2 minutes). Check ComfyUI installation.`); console.warn(`[${MODULE_ID}] ComfyUI startup timeout after ${maxAttempts} attempts`); return; } // Continue checking setTimeout(checkStatus, checkInterval); } catch (error) { console.error(`[${MODULE_ID}] ComfyUI status check failed:`, error); if (attempts >= maxAttempts) { ui.notifications?.error(`❌ AI Map Generation service failed to start. Error: ${error instanceof Error ? error.message : 'Unknown error'}`); return; } // Continue checking despite errors (ComfyUI might still be starting) setTimeout(checkStatus, checkInterval); } }; // Start monitoring setTimeout(checkStatus, 2000); // Initial 2-second delay to let backend start } catch (error) { console.error(`[${MODULE_ID}] Failed to start ComfyUI monitoring:`, error); ui.notifications?.warn(`⚠️ Failed to monitor AI Map Generation startup: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Connection control is now handled through the settings menu */ /** * Cleanup when module is disabled or world is closed */ async cleanup(): Promise<void> { console.log(`[${MODULE_ID}] Cleaning up...`); await this.stop(); this.queryHandlers.unregisterHandlers(); this.campaignHooks.unregister(); console.log(`[${MODULE_ID}] Cleanup complete`); } } // Create global instance const foundryMCPBridge = new FoundryMCPBridge(); // Make it available globally for settings callbacks (window as any).foundryMCPBridge = foundryMCPBridge; // Foundry VTT Hooks Hooks.once('init', async () => { try { await foundryMCPBridge.initialize(); } catch (error) { console.error(`[${MODULE_ID}] Initialization failed:`, error); } }); Hooks.once('ready', async () => { try { await foundryMCPBridge.onReady(); // Register socket listener for roll state management (after game.user is available) game.socket?.on('module.foundry-mcp-bridge', async (data) => { try { // Handle ChatMessage update requests (GM only) if (data.type === 'requestMessageUpdate' && data.buttonId && data.messageId) { // Only GM can update ChatMessages for other users if (game.user?.isGM) { try { // Get the data access instance to update the message const queryHandlers = foundryMCPBridge['queryHandlers'] as any; if (queryHandlers && queryHandlers.dataAccess) { await queryHandlers.dataAccess.updateRollButtonMessage(data.buttonId, data.userId, data.rollLabel); } } catch (error) { console.error(`[${MODULE_ID}] GM failed to update message:`, error); // Notify GM about the failure if (game.user?.isGM) { ui.notifications?.error(`Failed to update player roll message: ${error instanceof Error ? error.message : 'Unknown error'}`); } } } else { } return; } // Handle roll state save requests (GM only) - LEGACY if (data.type === 'requestRollStateSave' && data.buttonId && data.rollState) { // Only GM can save to world settings if (game.user?.isGM) { try { // Get the data access instance to save the roll state const queryHandlers = foundryMCPBridge['queryHandlers'] as any; if (queryHandlers && queryHandlers.dataAccess) { await queryHandlers.dataAccess.saveRollState(data.buttonId, data.rollState.rolledBy); } } catch (error) { console.error(`[${MODULE_ID}] GM failed to save LEGACY roll state:`, error); // Notify GM about the failure so they can take action if (game.user?.isGM) { ui.notifications?.error(`Failed to save player roll state: ${error instanceof Error ? error.message : 'Unknown error'}`); } } } else { } return; } // Handle real-time roll state updates - LEGACY (now handled by ChatMessage.update()) if (data.type === 'rollStateUpdate' && data.buttonId && data.rollState) { // No longer needed - ChatMessage.update() automatically syncs across all clients } // Note: rollStateSaved confirmations removed - not needed since rollStateUpdate handles UI sync } catch (error) { console.error(`[${MODULE_ID}] Error handling socket message:`, error); } }); } catch (error) { console.error(`[${MODULE_ID}] Ready failed:`, error); } }); // Handle settings menu close to check for changes Hooks.on('closeSettingsConfig', () => { try { const enabled = foundryMCPBridge.getStatus().enabled; const connected = foundryMCPBridge.getStatus().connected; if (enabled && !connected) { // Setting was enabled but not connected, try to start foundryMCPBridge.start().catch(error => { console.error(`[${MODULE_ID}] Failed to start after settings change:`, error); }); } else if (!enabled && connected) { // Setting was disabled but still connected, stop foundryMCPBridge.stop().catch(error => { console.error(`[${MODULE_ID}] Failed to stop after settings change:`, error); }); } } catch (error) { console.error(`[${MODULE_ID}] Error handling settings change:`, error); } }); // Global hook to handle MCP roll button rendering and state management // Using renderChatMessageHTML for Foundry v13 compatibility (renderChatMessage is deprecated) Hooks.on('renderChatMessageHTML', (message: any, html: HTMLElement) => { try { // Convert HTMLElement to jQuery for compatibility with existing handler code const $html = $(html); // Check if this message has MCP roll button flags const rollButtons = message.getFlag?.(MODULE_ID, 'rollButtons'); if (rollButtons) { // Get the data access instance const queryHandlers = foundryMCPBridge['queryHandlers'] as any; if (queryHandlers && queryHandlers.dataAccess) { // Check if any buttons in this message are already rolled for (const [_buttonId, buttonData] of Object.entries(rollButtons as any)) { if (buttonData && typeof buttonData === 'object' && (buttonData as any).rolled) { break; } } // If message has rolled buttons, the content should already be updated // Just attach any necessary handlers for active buttons if ($html.find('.mcp-roll-button').length > 0) { // Only attach handlers to active (non-rolled) buttons queryHandlers.dataAccess.attachRollButtonHandlers($html); } } } else if ($html.find('.mcp-roll-button').length > 0) { // Legacy message without flags - fall back to old behavior const queryHandlers = foundryMCPBridge['queryHandlers'] as any; if (queryHandlers && queryHandlers.dataAccess) { queryHandlers.dataAccess.attachRollButtonHandlers($html); // Check for legacy roll states setTimeout(() => { queryHandlers.dataAccess.ensureButtonStatesForMessage($html); }, 100); } } } catch (error) { console.warn(`[${MODULE_ID}] Error processing roll buttons in chat message:`, error); } }); // Socket listener will be registered in the 'ready' hook when game.user is available // Handle world close/reload Hooks.on('canvasReady', () => { // Canvas ready indicates the world is fully loaded // Good time to ensure bridge is in correct state try { const status = foundryMCPBridge.getStatus(); if (status.enabled && !status.connected) { foundryMCPBridge.start().catch(error => { console.warn(`[${MODULE_ID}] Failed to reconnect on canvas ready:`, error); }); } } catch (error) { console.error(`[${MODULE_ID}] Error on canvas ready:`, error); } }); // Cleanup on unload window.addEventListener('beforeunload', () => { foundryMCPBridge.cleanup().catch(error => { console.error(`[${MODULE_ID}] Cleanup failed:`, error); }); }); // Development helpers (only in debug mode) if (typeof window !== 'undefined') { (window as any).foundryMCPDebug = { bridge: foundryMCPBridge, getStatus: () => foundryMCPBridge.getStatus(), start: () => foundryMCPBridge.start(), stop: () => foundryMCPBridge.stop(), restart: () => foundryMCPBridge.restart(), }; } export { foundryMCPBridge };

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