Skip to main content
Glama
map-generation.ts11.6 kB
import { Tool } from '@modelcontextprotocol/sdk/types.js'; import { FoundryClient } from '../foundry-client.js'; import { Logger } from '../logger.js'; export interface MapGenerationToolsOptions { foundryClient: FoundryClient; logger: Logger; backendComfyUIHandlers?: any; // Access to backend ComfyUI service } interface JobData { id: string; status: 'queued' | 'generating' | 'processing' | 'complete' | 'failed' | 'expired'; created_at: number; started_at?: number; completed_at?: number; progress_percent: number; current_stage: string; result?: any; error?: string; attempts: number; max_attempts: number; params: { prompt: string; scene_name: string; size: string; grid_size: number; }; } export class MapGenerationTools { private foundryClient: FoundryClient; private logger: Logger; private backendComfyUIHandlers: any; private jobs = new Map<string, JobData>(); // Simple in-memory job storage private jobStartTimes = new Map<string, number>(); private lastStatusCheck = new Map<string, number>(); private jobIdCounter = 0; constructor(options: MapGenerationToolsOptions) { this.foundryClient = options.foundryClient; this.logger = options.logger.child({ component: 'MapGenerationTools' }); this.backendComfyUIHandlers = options.backendComfyUIHandlers; } getToolDefinitions(): Tool[] { return [ { name: 'generate-map', description: 'Start AI map generation using D&D Battlemaps SDXL (async)', inputSchema: { type: 'object', properties: { prompt: { type: 'string', description: 'Map description (will be enhanced with "2d DnD battlemap" trigger and perspective)' }, scene_name: { type: 'string', description: 'Short, creative name for the Foundry scene (e.g., "Harbor District", "Moonlit Tavern", "Crystal Caverns"). Be creative and evocative!' }, size: { type: 'string', enum: ['small', 'medium', 'large'], default: 'medium', description: 'Map size (small=1024px, medium=1536px, large=2048px)' }, grid_size: { type: 'number', default: 70, description: 'Pixels per 5ft square for Foundry scene setup' } }, required: ['prompt', 'scene_name'] } }, { name: 'check-map-status', description: 'Check status of map generation job. Progress updates appear automatically in Foundry VTT. DO NOT check frequently - this wastes tokens. Only check if user explicitly asks for status.', inputSchema: { type: 'object', properties: { job_id: { type: 'string', description: 'Job ID to check status for' } }, required: ['job_id'] } }, { name: 'cancel-map-job', description: 'Cancel a running map generation job', inputSchema: { type: 'object', properties: { job_id: { type: 'string', description: 'Job ID to cancel' } }, required: ['job_id'] } }, { name: 'list-scenes', description: 'List all available Foundry VTT scenes with their details', inputSchema: { type: 'object', properties: { filter: { type: 'string', description: 'Optional filter to search scene names (case-insensitive)', default: '' }, include_active_only: { type: 'boolean', description: 'Only return the currently active scene', default: false } } } }, { name: 'switch-scene', description: 'Switch to a different Foundry VTT scene by name or ID', inputSchema: { type: 'object', properties: { scene_identifier: { type: 'string', description: 'Scene name or ID to switch to' }, optimize_view: { type: 'boolean', description: 'Automatically optimize the view for the scene', default: true } }, required: ['scene_identifier'] } } ]; } async listScenes(input: any): Promise<any> { const safeInput = input ?? {}; try { const params = { filter: typeof safeInput.filter === 'string' ? safeInput.filter : undefined, include_active_only: Boolean(safeInput.include_active_only), }; return await this.foundryClient.query('foundry-mcp-bridge.list-scenes', params); } catch (error: any) { this.logger.error('List scenes failed', { error, input: safeInput }); return { success: false, error: error?.message ?? 'Unknown error' }; } } async switchScene(input: any): Promise<any> { const safeInput = input ?? {}; try { const sceneIdentifier = typeof safeInput.scene_identifier === 'string' ? safeInput.scene_identifier : safeInput.sceneId; if (!sceneIdentifier || typeof sceneIdentifier !== 'string' || !sceneIdentifier.trim()) { return { success: false, error: 'scene_identifier is required' }; } const params = { scene_identifier: sceneIdentifier, optimize_view: safeInput.optimize_view !== false, }; return await this.foundryClient.query('foundry-mcp-bridge.switch-scene', params); } catch (error: any) { this.logger.error('Switch scene failed', { error, input: safeInput }); return { success: false, error: error?.message ?? 'Unknown error' }; } } async generateMap(input: any): Promise<any> { const safeInput = input ?? {}; try { this.logger.info('Map generation requested via MCP', { input: safeInput }); const prompt = typeof safeInput.prompt === 'string' ? safeInput.prompt.trim() : ''; if (!prompt) { return 'Error: Prompt is required and must be a string.'; } const sceneName = typeof safeInput.scene_name === 'string' ? safeInput.scene_name.trim() : ''; if (!sceneName) { return 'Error: Scene name is required and must be a string.'; } const size = typeof safeInput.size === 'string' ? safeInput.size : 'medium'; const gridSizeRaw = typeof safeInput.grid_size === 'number' ? safeInput.grid_size : Number(safeInput.grid_size); const gridSize = Number.isFinite(gridSizeRaw) ? gridSizeRaw : 70; const params = { prompt, scene_name: sceneName, size, grid_size: gridSize, } as const; const response = await this.foundryClient.query('foundry-mcp-bridge.generate-map', params); if (response?.error) { throw new Error(response.error); } const jobId = response?.jobId ?? 'unknown'; const estimatedTime = response?.estimatedTime ?? 'varies by hardware and quality setting'; const lines = [ `Map generation started. Job ID: ${jobId}`, '', `Prompt: ${params.prompt}`, `Size: ${params.size} (${this.getSizePixels(params.size)})`, `Grid size: ${params.grid_size}px`, '', `Generation time: ${estimatedTime}`, '', 'Progress updates will appear automatically in Foundry VTT.', 'Once complete, the map will be imported as a new scene.', 'Do NOT check status frequently - this wastes tokens.', ]; return lines.join('\n'); } catch (error: any) { this.logger.error('Map generation failed', { error, input: safeInput }); return `Error: ${error?.message ?? 'Unknown error'}`; } } async checkMapStatus(input: any): Promise<any> { const safeInput = input ?? {}; try { const jobIdCandidate = typeof safeInput.job_id === 'string' ? safeInput.job_id : safeInput.jobId; const jobId = typeof jobIdCandidate === 'string' ? jobIdCandidate.trim() : ''; if (!jobId) { return 'Error: job_id is required.'; } this.logger.info('Map status check requested via MCP', { jobId, input: safeInput }); const response = await this.foundryClient.query('foundry-mcp-bridge.check-map-status', { job_id: jobId }); if (response?.error) { const message = response?.message ?? response?.error ?? 'Failed to check job status'; return `Error: ${message}`; } const job = response?.job; if (!job) { return `Job ${jobId} not found. It may have expired or been cleaned up.`; } switch (job.status) { case 'queued': return `Job ${jobId} is queued. Status: ${job.current_stage ?? 'Pending'}.`; case 'generating': case 'processing': return `Job ${jobId} in progress. Stage: ${job.current_stage ?? 'Processing'}. Progress: ${job.progress_percent ?? 0}%`; case 'complete': { const duration = job.result?.generation_time_ms; const durationText = typeof duration === 'number' ? ` Generation time: ${Math.round(duration / 1000)}s.` : ''; return `Job ${jobId} completed successfully.${durationText}`; } case 'failed': return `Job ${jobId} failed. Reason: ${job.error ?? 'Unknown error'}.`; case 'expired': return `Job ${jobId} has expired.`; default: return `Job ${jobId} returned status "${job.status}".`; } } catch (error: any) { this.logger.error('Status check failed', { error, input: safeInput }); return `Error checking status: ${error?.message ?? 'Unknown error'}`; } } async cancelMapJob(input: any): Promise<any> { const safeInput = input ?? {}; try { const jobIdCandidate = typeof safeInput.job_id === 'string' ? safeInput.job_id : safeInput.jobId; const jobId = typeof jobIdCandidate === 'string' ? jobIdCandidate.trim() : ''; if (!jobId) { return 'Error: job_id is required.'; } this.logger.info('Map job cancellation requested via MCP', { jobId, input: safeInput }); const response = await this.foundryClient.query('foundry-mcp-bridge.cancel-map-job', { job_id: jobId }); if (response?.error) { const message = response?.message ?? response?.error ?? 'Failed to cancel map job'; return `Error: ${message}`; } const status = typeof response?.status === 'string' ? response.status : (response?.success ? 'success' : 'unknown'); const message = response?.message ?? 'Map generation job cancelled.'; return `${message} (status: ${status})`; } catch (error: any) { this.logger.error('Map job cancellation failed', { error, input: safeInput }); return `Error cancelling job: ${error?.message ?? 'Unknown error'}`; } } private getSizePixels(size: string): string { switch (size) { case 'small': return '1024x1024'; case 'large': return '2048x2048'; case 'medium': default: return '1536x1536'; } } private generateJobId(): string { this.jobIdCounter++; const timestamp = Date.now().toString(36); const counter = this.jobIdCounter.toString(36); const random = Math.random().toString(36).substring(2, 8); return `job_${timestamp}_${counter}_${random}`; } async shutdown(): Promise<void> { this.logger.info('MapGenerationTools shutdown complete'); } }

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