Skip to main content
Glama
composition-lifecycle.ts.backup26.8 kB
/** * EuConquisto Composer MCP Server - Composition Lifecycle Tools * * @version 0.1.4 * @created 2025-06-09 * @updated 2025-06-09 * @author EuConquisto Development Team * * @description Implements the complete composition lifecycle management: * 1. Create new composition via "Nova composição" button * 2. Edit composition metadata via hamburger menu → "Configurações" * 3. Save composition via "Salvar" button with URL-based persistence * * This implementation uses browser automation to interact with the Composer interface * and provides URL encoding/decoding for composition persistence. */ import { z } from "zod"; import { chromium, Browser, Page, BrowserContext } from "playwright"; /** * Browser automation client for Composer interface interactions */ export class ComposerClient { private browser: Browser | BrowserContext | null = null; private page: Page | null = null; private readonly baseURL = "https://composer.euconquisto.com/#/embed"; private readonly orgId = "36c92686-c494-ec11-a22a-dc984041c95d"; private readonly jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkbWluLmRlc2Vudm9sdmltZW50b0BldWNvbnF1aXN0by5jb20iLCJuYW1lIjoiQWRtaW4gRGV2Iiwib2lkIjoiNWZiM2RlYzYtYzQ5NC1lYzExLWEyMmEtZGM5ODQwNDFjOTVkIiwiZGlyZWN0b3J5IjoiYjBmZWY4NjAtYzQ5NC1lYzExLWEyMmEtZGM5ODQwNDFjOTVkIiwiYXBpbSI6IkVFQzUzQTI0LUVDMEUtNDFCOS05NDA1LTg2QTE3NTAwREIzNCIsImRpcm4iOiJEZXNlbnZvbHZpbWVudG8iLCJyb2xlIjpbIlJldmlld0NvbnRyaWJ1dG9yIiwiQ29udHJpYnV0b3JHbG9iYWwiLCJBZG1pbmlzdHJhdG9yR2xvYmFsIl0sImRjbiI6WyJINHNJQUFBQUFBQUFBMkptWUdBd1pRQ0JaQXVqRkdNREM0dFYyMXRTRS1vZHA5NElpMnVVYTNqMXNxRXl6YWhrN3ZxWlN5MG5sLV94c3cyb0xEN05jT2ppRThWelhyZDBEamF3XzQyVW1fZmxrT1loZzlVN0ZKOGx5N1dWQVFBQUFQX18iLCJINHNJQUFBQUFBQUFBMkptWUdBd1pRQ0JaQXVqRkdNREM0c2xCWS0zTjB5WkU1S1NNSFhKTzhjcm0wNS1remgtYVBzTXhtMnZvOTBxYzhXTjktX08wRzRMbmo5dDRqb2R4bnZNdVkycHJEd1NrYlkyamJOMnpuNXJvemJmR3dBQUFQX18iLCJINHNJQUFBQUFBQUFBMkptWUdBd1pRQ0JaQXVqRkdNREM0dnpkMWU1SDFGTzVhenRyR1RuZXZ6emx0U0djM3VFT1IteHExWXZNVHF5NEstaThXR0gwd292cC1ka3FjNm82d2xSZkM0d3k5MVcxSHFqOFU4aEMtRWZMa0pSQUFBQUFQX18iLCJINHNJQUFBQUFBQUFBMkptWUdBd1pRQ0JaQXVqRkdNREM0dU5XMnUyS3A5dXZhUGhkOFNpOE9KNV8yTmRmZnNTSC0temZHTXVmX084Wm5GbXFXRE5qeE1ubnBWelRkVTBPVF9mUldtNVVhTkxlV2oyTjY5Y3JRS0x1SmJQQUFBQUFQX18iLCJINHNJQUFBQUFBQUFBMkptWUdBd1pRQ0JaQXVqRkdNREM0czY2NE5odVhwck52S3FiV1RuUEtGNTU2UzRycXdXNDJ1SnkzbjNGVkxFVjZ4VlljdU9kTnVrZXliMjdKbjRlOS1GaGNNdW1iOTZ1cUx0aEptSDJrX0h0U29MQUFBQUFQX18iLCJINHNJQUFBQUFBQUFBMkptWUdBd1pRQ0JaQXVqRkdNREN3dWpCWjhyQW81RUxEaHRvQ2ZUdmVQcTVET2hpd1dtM3A2eFktTDdIYkpTb2RFU2I2SUVWQ3BQcXpETTJ2WlR6VzlsMXBrWlBFb3Zkbl8tV3R2LWY0bkt3dmEtU0FBQUFBRF9fdyJdLCJzY3AiOlsiSDRzSUFBQUFBQUFBQTJKbVlHRHdZQUFCMDZRMDA5VFU1RlNKTmZzV1NKNDRWWGZBeHZYeDVEc3lzaFhPNmllVGk0cWVYbTZaVVN2QzZ2djNST0hNRDBkVHBDVHUzekFfVUMtWVVIOHIyMzNlS29mQ0hZR3UwWG83aWswWGROWVVxdmotV0hfclhiU3VVYnJZM1VZQUFBQUFfXzgiLCJINHNJQUFBQUFBQUFBd0RTQWkzOUF3QUFBck1BQUFBQUFHRTVaalF6TkRsa2E0TWozbjdQejY0dmpPSWl5LVlNb3dhRDdlMWpqcDZJakE2SVFZMi14TFNOTWFSZWNrTTdzWEJLamR2SkpoeUF5TnZJVHQ5aUFFWHcybVVzM3lIbGc2UHNtSld1Mm1EU2UwbXlrYnQ0OGl1NTlkYTIxMVBNdkxyRzdxOV9teDlhYWI0SjdZbWlNODg2OTVUclk3WFY1Nmlsb3UwdnQ4VmdBMWptbkdYd0paa0ZDTXhYaUlhcGl5S01TVGlfa3BnREdQdlRlYUNNdE5GSXlIU0gwUTZ3bTFwRkJBYzRMQ1dqRThMeWtnc1ZtbEJxRG9aek1oZTVnY21sZHl0VW1FVFltakIyakVaUHdvd0M2NFRZb1JudDJOWkpmRDdDZXg1T3FqY1ptVU5tNjMtVlRYOFh0UEtza1BpSnpIb3kzcHF5dXVycnVrTndZenducHpwak51OEdKZXQ4V2RoMl85ckNoVnpPUnR4N1N5UXEyb2prUmJfWEphenFtTnEwMEVTcTNSZUVSX0pQZmhGOGg4bGlxcGVWeE1FSG5RUGFmMXp2N3FVQ3JzTWtrVjd3NU5sOURWYU9DMnNLV2Q1TDdBNDYxUlFRSjVUNU5MMGQ2UGZqVWV4dkZTR0RQdW5jcHAxeDA0TlBOM2ttTmdMSlF3eDZLZjRxLVBZbDVXWmFQUTlXU0NLNHBmeHY3ZTJRZEw0X1IwQ1c3NUdQRHh1bEZQZG1tcmRrc0k2RVhqUTFkQTdkZlNFUnJoZHdvOUhCN0xGMzRYSENnMWg3Z2I4UWVKdmhMTXRjUmtlUzVpQkctOGNfNkdCQU9YUDdQR2ptQ1NUZzV2WGVyX0Rud3FkektCOFdtY1p0OXFjMXZpU0ROV1JHZVN0bHc3d250UzFSSXlBN205cS01YU9FLUx0ODB3bE1HTkxOTi1TVFN3OWVDN2dxVmQtSUZZWlhYQ2NZcWNfcWc1dDhodFdSLVJ2cUJsX3Fjbkt2N2xhaHk5elRTaHJfX1BTRWJqdTg0SkhDV3I5SXhGd2lzYVd1eVZiMUp0ZUQzcDk0WnpWZDhnTEJQRGFRVUJMZHc1SVEzWkJIc3daTXExRjJqVGdEcF92RDJLQzZzVnd1dVhaa3RzYWlrc3pMZW5NcTFUVjRUMmF5QUdyVHB3UlUzMFMwc3hfQ3pCRVlLREV0b2s4eDI5TGlnUnlHMnVfZDVpOHRyVEJ1WTNjVFdzSXVIdzRibXNVV1lkTXNwblRMMXpwNE1LWUFBQURfX3ciXSwibmJmIjoxNzQ4ODc2OTU1LCJleHAiOjE3NTE0Njg5NTUsImlhdCI6MTc0ODg3Njk1NSwiaXNzIjoiaHR0cHM6Ly9hcGkuZGlnaXRhbHBhZ2VzLmNvbS5iciIsImF1ZCI6IkV1Q29ucXVpc3RvIn0.iTUfl6-mwLwFaxYYPf6PufRSYbSJlw3tKejmbc5G42g"; // TASK-001 FIX: Corrected JWT token with G→E and K→L fixes /** * Initialize browser automation */ async initialize(): Promise<void> { try { // TASK-001 SOLUTION: Brave-like configuration that hides automation detection this.browser = await chromium.launch({ headless: false, args: [ '--no-sandbox', '--disable-dev-shm-usage', '--enable-features=VizDisplayCompositor', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-features=TranslateUI', '--disable-ipc-flooding-protection', '--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ], timeout: 60000 }); const context = await this.browser.newContext({ viewport: { width: 1280, height: 720 }, permissions: ['notifications'], colorScheme: 'light', timezoneId: 'America/New_York' }); this.page = await context.newPage(); // TASK-001 Enhanced: Longer timeouts for SPA loading this.page.setDefaultTimeout(60000); this.page.setDefaultNavigationTimeout(120000); // TASK-001 Enhanced: Add error handling and logging this.page.on('console', msg => console.log(`PAGE: ${msg.text()}`)); this.page.on('pageerror', error => console.error(`PAGE ERROR: ${error.message}`)); this.page.on('requestfailed', request => console.error(`REQUEST FAILED: ${request.url()}`)); console.log('✅ Enhanced browser automation initialized with manual-matching context'); } catch (error) { console.error('Failed to initialize browser automation:', error); throw error; } } /** * Navigate to Composer with authentication */ async navigateToComposer(path: string = 'home'): Promise<void> { if (!this.page) { throw new Error('Browser not initialized. Call initialize() first.'); } const embedURL = `${this.baseURL}/auth-with-token/pt_br/${path}/${this.orgId}/${this.jwtToken}`; try { console.log('🌐 TASK-001: Navigating to Composer with enhanced SPA handling...'); console.log(`🔗 URL: ${embedURL.substring(0, 80)}...`); // TASK-001 Enhanced: Navigate with better wait conditions const response = await this.page.goto(embedURL, { waitUntil: 'domcontentloaded', // Don't wait for all resources timeout: 120000 }); if (!response || !response.ok()) { throw new Error(`Navigation failed with status: ${response?.status()}`); } console.log('⏳ Waiting for JavaScript application to initialize...'); // TASK-001 Enhanced: Progressive waiting for SPA initialization let attempts = 0; const maxAttempts = 12; // 60 seconds total (5s * 12) while (attempts < maxAttempts) { attempts++; console.log(` 🔄 Attempt ${attempts}/${maxAttempts}: Checking for app initialization...`); // Wait for JavaScript to execute await this.page.waitForTimeout(5000); // Check if app has initialized by looking for interactive elements const hasInteractiveElements = await this.page.evaluate(() => { const buttons = (globalThis as any).document.querySelectorAll('button, a, [role="button"], input[type="button"]').length; const hasContent = (globalThis as any).document.body.textContent && (globalThis as any).document.body.textContent.length > 100 && !(globalThis as any).document.body.textContent.includes('You need to enable JavaScript'); return buttons > 0 || hasContent; }); if (hasInteractiveElements) { console.log('✅ JavaScript application initialized successfully'); break; } if (attempts === maxAttempts) { console.warn('⚠️ App may not have fully initialized, but continuing...'); } } // Take debug screenshot await this.page.screenshot({ path: `debug-navigation-${Date.now()}.png`, fullPage: true }); console.log('✅ Navigation completed with enhanced SPA handling'); } catch (error) { console.error('❌ Enhanced navigation failed:', error); // Take error screenshot if (this.page) { await this.page.screenshot({ path: `error-navigation-${Date.now()}.png`, fullPage: true }); } throw error; } } /** * Create a new composition by clicking "Nova composição" button */ async createNewComposition(): Promise<{ success: boolean; message: string; compositionId?: string }> { if (!this.page) { throw new Error('Browser not initialized'); } try { console.log('Creating new composition...'); // TASK-001 Enhanced: Wait for page and JavaScript app to be ready console.log('⏳ Ensuring application is fully loaded...'); // Wait for network to settle try { await this.page.waitForLoadState('networkidle', { timeout: 30000 }); } catch (error) { console.log('⚠️ Network still busy, but continuing...'); } // Additional wait for JavaScript frameworks to initialize await this.page.waitForTimeout(3000); // TASK-001 SOLUTION: Target exact "NOVA COMPOSIÇÃO" button from working screenshot const newCompositionSelectors = [ // Exact text match from screenshot 'text=NOVA COMPOSIÇÃO', 'button:has-text("NOVA COMPOSIÇÃO")', '[role="button"]:has-text("NOVA COMPOSIÇÃO")', // Common variations 'text="Nova Composição"', 'button:has-text("Nova Composição")', 'text="Nova composição"', 'button:has-text("Nova composição")', // CSS selectors for button styling seen in screenshot 'button[class*="btn"]', '.btn-primary', 'button[style*="background"]', // Fallback patterns 'button:text("NOVA")', 'button:text("Nova")', 'a:text("NOVA COMPOSIÇÃO")', // Generic button search 'button', '[role="button"]' ]; let clicked = false; for (const selector of newCompositionSelectors) { try { await this.page.waitForSelector(selector, { timeout: 5000 }); await this.page.click(selector); clicked = true; console.log(`Clicked new composition button with selector: ${selector}`); break; } catch (error) { // Try next selector continue; } } if (!clicked) { // Fallback: search for any button containing "Nova" or "composição" const fallbackSelector = 'button'; const buttons = await this.page.locator(fallbackSelector).all(); for (const button of buttons) { const text = await button.textContent(); if (text && (text.includes('Nova') || text.includes('composição'))) { await button.click(); clicked = true; console.log(`Clicked new composition button via text search: "${text}"`); break; } } } if (!clicked) { throw new Error('Could not find "Nova composição" button'); } // Wait for navigation or modal to appear await this.page.waitForTimeout(2000); // Check if we're now in composition editor or a modal appeared const currentURL = this.page.url(); const compositionId = this.extractCompositionIdFromURL(currentURL); return { success: true, message: 'New composition created successfully', compositionId: compositionId || 'new' }; } catch (error) { console.error('Failed to create new composition:', error); return { success: false, message: `Failed to create new composition: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } /** * Edit composition metadata via hamburger menu → "Configurações" */ async editCompositionMetadata(metadata: { title?: string; description?: string; author?: string; tags?: string[]; }): Promise<{ success: boolean; message: string }> { if (!this.page) { throw new Error('Browser not initialized'); } try { console.log('Opening composition metadata editor...'); // Look for hamburger menu button const hamburgerSelectors = [ '[data-testid="hamburger-menu"]', '.hamburger-menu', '[aria-label*="menu"]', 'button[class*="hamburger"]', 'button[class*="menu"]', '[data-cy="hamburger-menu"]' ]; let menuOpened = false; for (const selector of hamburgerSelectors) { try { await this.page.waitForSelector(selector, { timeout: 5000 }); await this.page.click(selector); menuOpened = true; console.log(`Opened hamburger menu with selector: ${selector}`); break; } catch (error) { continue; } } if (!menuOpened) { // Fallback: look for three dots or lines pattern const menuIcons = await this.page.locator('button').all(); for (const icon of menuIcons) { const innerHTML = await icon.innerHTML(); if (innerHTML.includes('⋮') || innerHTML.includes('≡') || innerHTML.includes('menu') || innerHTML.includes('bars')) { await icon.click(); menuOpened = true; console.log('Opened hamburger menu via icon search'); break; } } } if (!menuOpened) { throw new Error('Could not find hamburger menu button'); } // Wait for menu to appear await this.page.waitForTimeout(1000); // Look for "Configurações" option const configSelectors = [ 'a:has-text("Configurações")', 'button:has-text("Configurações")', '[data-testid="settings"]', '[data-cy="settings"]', 'li:has-text("Configurações")', '.menu-item:has-text("Configurações")' ]; let configOpened = false; for (const selector of configSelectors) { try { await this.page.waitForSelector(selector, { timeout: 3000 }); await this.page.click(selector); configOpened = true; console.log(`Opened configurations with selector: ${selector}`); break; } catch (error) { continue; } } if (!configOpened) { // Fallback: search menu items for text containing "Configurações" const menuItems = await this.page.locator('a, button, li').all(); for (const item of menuItems) { const text = await item.textContent(); if (text && text.includes('Configurações')) { await item.click(); configOpened = true; console.log(`Opened configurations via text search: "${text}"`); break; } } } if (!configOpened) { throw new Error('Could not find "Configurações" option in menu'); } // Wait for configuration modal/page to load await this.page.waitForTimeout(2000); // Fill metadata fields if (metadata.title) { await this.fillField(['[name="title"]', '#title', '[data-testid="title"]'], metadata.title); } if (metadata.description) { await this.fillField(['[name="description"]', '#description', '[data-testid="description"]', 'textarea'], metadata.description); } if (metadata.author) { await this.fillField(['[name="author"]', '#author', '[data-testid="author"]'], metadata.author); } if (metadata.tags && metadata.tags.length > 0) { await this.fillField(['[name="tags"]', '#tags', '[data-testid="tags"]'], metadata.tags.join(', ')); } // Save changes const saveSelectors = [ 'button:has-text("Salvar")', 'button:has-text("Save")', '[data-testid="save"]', '.save-btn', 'button[type="submit"]' ]; for (const selector of saveSelectors) { try { await this.page.click(selector); console.log(`Saved metadata with selector: ${selector}`); break; } catch (error) { continue; } } return { success: true, message: 'Composition metadata updated successfully' }; } catch (error) { console.error('Failed to edit composition metadata:', error); return { success: false, message: `Failed to edit metadata: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } /** * Save composition via "Salvar" button */ async saveComposition(): Promise<{ success: boolean; message: string; compositionURL?: string }> { if (!this.page) { throw new Error('Browser not initialized'); } try { console.log('Saving composition...'); // Look for "Salvar" button const saveSelectors = [ 'button:has-text("Salvar")', '[data-testid="save-composition"]', '.save-composition-btn', '[data-cy="save"]', 'button[aria-label*="salvar"]', 'button[title*="Salvar"]' ]; let saved = false; for (const selector of saveSelectors) { try { await this.page.waitForSelector(selector, { timeout: 5000 }); await this.page.click(selector); saved = true; console.log(`Saved composition with selector: ${selector}`); break; } catch (error) { continue; } } if (!saved) { // Fallback: search for any button containing "Salvar" const buttons = await this.page.locator('button').all(); for (const button of buttons) { const text = await button.textContent(); if (text && text.includes('Salvar')) { await button.click(); saved = true; console.log(`Saved composition via text search: "${text}"`); break; } } } if (!saved) { throw new Error('Could not find "Salvar" button'); } // Wait for save operation to complete await this.page.waitForTimeout(3000); // Get the updated URL (should contain encoded composition data) const currentURL = this.page.url(); return { success: true, message: 'Composition saved successfully', compositionURL: currentURL }; } catch (error) { console.error('Failed to save composition:', error); return { success: false, message: `Failed to save composition: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } /** * Helper method to fill form fields with multiple selector fallbacks */ private async fillField(selectors: string[], value: string): Promise<void> { for (const selector of selectors) { try { await this.page!.waitForSelector(selector, { timeout: 3000 }); await this.page!.fill(selector, value); console.log(`Filled field with selector: ${selector}`); return; } catch (error) { continue; } } console.warn(`Could not find field for selectors: ${selectors.join(', ')}`); } /** * Extract composition ID from URL */ private extractCompositionIdFromURL(url: string): string | null { try { // Look for encoded composition data in URL const match = url.match(/\/composer\/([A-Za-z0-9+/=]+)/); return match ? match[1] : null; } catch (error) { return null; } } /** * Close browser automation */ async close(): Promise<void> { try { if (this.browser) { await this.browser.close(); this.browser = null; this.page = null; console.log('Browser automation closed'); } } catch (error) { console.error('Error closing browser:', error); } } } /** * URL codec for composition persistence */ export class CompositionURLCodec { /** * Encode composition data to URL format */ static encode(compositionData: any): string { try { const jsonString = JSON.stringify(compositionData); const base64 = Buffer.from(jsonString).toString('base64'); return base64; } catch (error) { console.error('Failed to encode composition data:', error); throw new Error('Composition encoding failed'); } } /** * Decode composition data from URL format */ static decode(encodedData: string): any { try { const jsonString = Buffer.from(encodedData, 'base64').toString('utf-8'); const compositionData = JSON.parse(jsonString); return compositionData; } catch (error) { console.error('Failed to decode composition data:', error); throw new Error('Composition decoding failed'); } } /** * Extract composition data from full URL */ static extractFromURL(url: string): any | null { try { const match = url.match(/\/composer\/([A-Za-z0-9+/=]+)/); if (match) { return this.decode(match[1]); } return null; } catch (error) { console.error('Failed to extract composition from URL:', error); return null; } } } /** * MCP Tool schemas for composition lifecycle */ export const CompositionSchemas = { createComposition: z.object({ navigate: z.boolean().optional().describe("Navigate to Composer first (default: true)") }), editMetadata: z.object({ title: z.string().optional().describe("Composition title"), description: z.string().optional().describe("Composition description"), author: z.string().optional().describe("Composition author"), tags: z.array(z.string()).optional().describe("Composition tags") }), saveComposition: z.object({ returnURL: z.boolean().optional().describe("Return the composition URL (default: true)") }) }; /** * Composition lifecycle tool implementations for MCP server */ export class CompositionLifecycleTools { private static client: ComposerClient | null = null; /** * Get or create composer client instance */ private static async getClient(): Promise<ComposerClient> { if (!this.client) { this.client = new ComposerClient(); await this.client.initialize(); } return this.client; } /** * Create new composition tool */ static async createNewComposition(params: z.infer<typeof CompositionSchemas.createComposition>) { try { const client = await this.getClient(); if (params.navigate !== false) { await client.navigateToComposer(); } const result = await client.createNewComposition(); return { content: [{ type: "text", text: JSON.stringify({ success: result.success, message: result.message, compositionId: result.compositionId, nextStep: "Use edit-composition-metadata to set title, description, etc.", timestamp: new Date().toISOString() }, null, 2) }] }; } catch (error) { return { content: [{ type: "text", text: JSON.stringify({ success: false, message: `Failed to create composition: ${error instanceof Error ? error.message : 'Unknown error'}`, timestamp: new Date().toISOString() }, null, 2) }] }; } } /** * Edit composition metadata tool */ static async editCompositionMetadata(params: z.infer<typeof CompositionSchemas.editMetadata>) { try { const client = await this.getClient(); const result = await client.editCompositionMetadata(params); return { content: [{ type: "text" as const, text: JSON.stringify({ success: result.success, message: result.message, metadata: params, nextStep: "Use save-composition to persist the changes", timestamp: new Date().toISOString() }, null, 2) }] }; } catch (error) { return { content: [{ type: "text" as const, text: JSON.stringify({ success: false, message: `Failed to edit metadata: ${error instanceof Error ? error.message : 'Unknown error'}`, timestamp: new Date().toISOString() }, null, 2) }] }; } } /** * Save composition tool */ static async saveComposition(params: z.infer<typeof CompositionSchemas.saveComposition>) { try { const client = await this.getClient(); const result = await client.saveComposition(); const responseData: any = { success: result.success, message: result.message, timestamp: new Date().toISOString() }; if (params.returnURL !== false && result.compositionURL) { responseData.compositionURL = result.compositionURL; // Try to extract composition data from URL const compositionData = CompositionURLCodec.extractFromURL(result.compositionURL); if (compositionData) { responseData.compositionData = compositionData; } } return { content: [{ type: "text" as const, text: JSON.stringify(responseData, null, 2) }] }; } catch (error) { return { content: [{ type: "text" as const, text: JSON.stringify({ success: false, message: `Failed to save composition: ${error instanceof Error ? error.message : 'Unknown error'}`, timestamp: new Date().toISOString() }, null, 2) }] }; } } /** * Cleanup resources */ static async cleanup(): Promise<void> { if (this.client) { await this.client.close(); this.client = null; } } }

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/rkm097git/euconquisto-composer-mcp-poc'

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