Skip to main content
Glama

Elasticsearch Knowledge Graph for MCP

by j3k0
admin-cli.ts33 kB
#!/usr/bin/env node import { promises as fs } from 'fs'; import path from 'path'; import { KnowledgeGraphClient } from './kg-client.js'; import { ESEntity, ESRelation, KG_RELATIONS_INDEX } from './es-types.js'; import { importFromJsonFile, exportToJsonFile } from './json-to-es.js'; import readline from 'readline'; import GroqAI from './ai-service.js'; // Environment configuration for Elasticsearch const ES_NODE = process.env.ES_NODE || 'http://localhost:9200'; const ES_USERNAME = process.env.ES_USERNAME; const ES_PASSWORD = process.env.ES_PASSWORD; const DEFAULT_ZONE = process.env.KG_DEFAULT_ZONE || 'default'; // Configure ES client with authentication if provided const esOptions: { node: string; auth?: { username: string; password: string }; defaultZone?: string; } = { node: ES_NODE, defaultZone: DEFAULT_ZONE }; if (ES_USERNAME && ES_PASSWORD) { esOptions.auth = { username: ES_USERNAME, password: ES_PASSWORD }; } // Create KG client const kgClient = new KnowledgeGraphClient(esOptions); /** * Display help information */ function showHelp() { console.log('Knowledge Graph Admin CLI'); console.log('========================'); console.log(''); console.log('Commands:'); console.log(' init Initialize the Elasticsearch index'); console.log(' import <file> [zone] Import data from a JSON file (optionally to a specific zone)'); console.log(' export <file> [zone] Export data to a JSON file (optionally from a specific zone)'); console.log(' backup <file> Backup all zones and relations to a file'); console.log(' restore <file> [--yes] Restore all zones and relations from a backup file'); console.log(' stats [zone] Display statistics about the knowledge graph'); console.log(' search <query> [zone] Search the knowledge graph'); console.log(' reset [zone] [--yes] Reset the knowledge graph (delete all data)'); console.log(' entity <n> [zone] Display information about a specific entity'); console.log(' zones list List all memory zones'); console.log(' zones add <name> [desc] Add a new memory zone'); console.log(' zones delete <name> [--yes] Delete a memory zone and all its data'); console.log(' zones stats <name> Show statistics for a specific zone'); console.log(' zones update_descriptions <name> [limit] [prompt]'); console.log(' Generate AI descriptions based on zone content'); console.log(' (limit: optional entity limit, prompt: optional description of zone purpose)'); console.log(' relations <entity> [zone] Show relations for a specific entity'); console.log(' help Show this help information'); console.log(''); console.log('Options:'); console.log(' --yes, -y Automatically confirm all prompts (for scripts)'); console.log(''); console.log('Environment variables:'); console.log(' ES_NODE Elasticsearch node URL (default: http://localhost:9200)'); console.log(' ES_USERNAME Elasticsearch username (if authentication is required)'); console.log(' ES_PASSWORD Elasticsearch password (if authentication is required)'); console.log(' KG_DEFAULT_ZONE Default zone to use (default: "default")'); } /** * Initialize the Elasticsearch index */ async function initializeIndex() { try { await kgClient.initialize(); console.log('Elasticsearch indices initialized successfully'); } catch (error) { console.error('Error initializing index:', (error as Error).message); process.exit(1); } } /** * Display statistics about the knowledge graph or a specific zone */ async function showStats(zone?: string) { try { // Initialize client await kgClient.initialize(zone); if (zone) { // Get zone-specific stats const stats = await kgClient.getMemoryZoneStats(zone); console.log(`Knowledge Graph Statistics for Zone: ${zone}`); console.log('============================================='); console.log(`Total entities: ${stats.entityCount}`); console.log(`Total relations: ${stats.relationCount}`); console.log(''); console.log('Entity types:'); Object.entries(stats.entityTypes).forEach(([type, count]) => { console.log(` ${type}: ${count}`); }); console.log(''); console.log('Relation types:'); Object.entries(stats.relationTypes).forEach(([type, count]) => { console.log(` ${type}: ${count}`); }); } else { // Get all zone metadata const zones = await kgClient.listMemoryZones(); console.log('Knowledge Graph Multi-zone Statistics'); console.log('===================================='); console.log(`Total zones: ${zones.length}`); console.log(''); console.log('Zones:'); for (const zone of zones) { console.log(`Zone: ${zone.name}`); console.log(` Description: ${zone.description || 'N/A'}`); console.log(` Created: ${zone.createdAt}`); console.log(` Last modified: ${zone.lastModified}`); // Get zone stats const stats = await kgClient.getMemoryZoneStats(zone.name); console.log(` Entities: ${stats.entityCount}`); console.log(` Relations: ${stats.relationCount}`); console.log(''); } // Get relation stats const data = await kgClient.exportData(); const relations = data.filter(item => item.type === 'relation'); console.log(`Total relations in all zones: ${relations.length}`); // Count relation types const relationTypes = new Map<string, number>(); relations.forEach(relation => { const type = (relation as any).relationType; relationTypes.set(type, (relationTypes.get(type) || 0) + 1); }); console.log('Relation types:'); relationTypes.forEach((count, type) => { console.log(` ${type}: ${count}`); }); } } catch (error) { console.error('Error getting statistics:', (error as Error).message); process.exit(1); } } /** * Search the knowledge graph * @param query The search query * @param zone Optional zone to search in */ async function searchGraph(query: string, zone?: string) { try { // Initialize client await kgClient.initialize(zone); // Search for entities const results = await kgClient.search({ query, limit: 10, sortBy: 'relevance', zone }); // Display results console.log(`Search Results for "${query}"${zone ? ` in zone "${zone}"` : ''}`); console.log('===================================='); console.log(`Found ${results.hits.total.value} matches`); console.log(''); // Extract all entities from search results const entities = results.hits.hits .filter(hit => hit._source.type === 'entity') .map(hit => hit._source as ESEntity); // Get entity names for relation lookup const entityNames = entities.map(entity => entity.name); // Create a set of entity names for faster lookup const entityNameSet = new Set(entityNames); // Collect all relations const allRelations: ESRelation[] = []; const relatedEntities = new Map<string, ESEntity>(); // For each found entity, get all its relations for (const entityName of entityNames) { const { relations } = await kgClient.getRelatedEntities(entityName, 1, zone); for (const relation of relations) { // Add relation if not already added if (!allRelations.some(r => r.from === relation.from && r.fromZone === relation.fromZone && r.to === relation.to && r.toZone === relation.toZone && r.relationType === relation.relationType )) { allRelations.push(relation); // Track related entities that weren't in the search results // If 'from' entity is not in our set and not already tracked if (!entityNameSet.has(relation.from) && !relatedEntities.has(relation.from)) { const entity = await kgClient.getEntityWithoutUpdatingLastRead(relation.from, relation.fromZone); if (entity) relatedEntities.set(relation.from, entity); } // If 'to' entity is not in our set and not already tracked if (!entityNameSet.has(relation.to) && !relatedEntities.has(relation.to)) { const entity = await kgClient.getEntityWithoutUpdatingLastRead(relation.to, relation.toZone); if (entity) relatedEntities.set(relation.to, entity); } } } } // Display each entity from search results entities.forEach((entity, index) => { const hit = results.hits.hits.find(h => h._source.type === 'entity' && (h._source as ESEntity).name === entity.name ); const score = hit && hit._score !== null && hit._score !== undefined ? hit._score.toFixed(2) : 'N/A'; console.log(`${index + 1}. ${entity.name} (${entity.entityType}) [Score: ${score}]`); console.log(` Zone: ${entity.zone || 'default'}`); console.log(` Observations: ${entity.observations.length}`); // Show highlights if available if (hit && hit.highlight) { console.log(' Matches:'); Object.entries(hit.highlight).forEach(([field, highlights]) => { highlights.forEach(highlight => { console.log(` - ${field}: ${highlight}`); }); }); } console.log(''); }); // Display relations if any if (allRelations.length > 0) { console.log('Relations for these entities:'); console.log('===================================='); allRelations.forEach(relation => { // Lookup entity types for more context const fromType = entityNameSet.has(relation.from) ? entities.find(e => e.name === relation.from)?.entityType : relatedEntities.get(relation.from)?.entityType || '?'; const toType = entityNameSet.has(relation.to) ? entities.find(e => e.name === relation.to)?.entityType : relatedEntities.get(relation.to)?.entityType || '?'; console.log(`${relation.from} [${relation.fromZone}] (${fromType}) → ${relation.relationType} → ${relation.to} [${relation.toZone}] (${toType})`); }); console.log(''); } } catch (error) { console.error('Error searching knowledge graph:', (error as Error).message); process.exit(1); } } /** * Reset the knowledge graph (delete all data) * @param zone Optional zone to reset, if not provided resets all zones */ async function resetIndex(zone?: string, args: string[] = []) { try { const confirmMessage = zone ? `Are you sure you want to delete all data in zone "${zone}"? This cannot be undone. (y/N) ` : 'Are you sure you want to delete ALL DATA IN ALL ZONES? This cannot be undone. (y/N) '; const confirmed = await confirmAction(confirmMessage, args); if (confirmed) { if (zone) { // Delete specific zone if (zone === 'default') { // For default zone, just delete all entities but keep the index await kgClient.initialize(zone); const allEntities = await kgClient.exportData(zone); for (const item of allEntities) { if (item.type === 'entity') { await kgClient.deleteEntity(item.name, zone); } } // Delete relations involving this zone const client = kgClient['client']; // Access the private client property await client.deleteByQuery({ index: KG_RELATIONS_INDEX, body: { query: { bool: { should: [ { term: { fromZone: zone } }, { term: { toZone: zone } } ], minimum_should_match: 1 } } }, refresh: true }); console.log(`Zone "${zone}" has been reset (entities and relations deleted)`); } else { // For non-default zones, delete the zone completely const success = await kgClient.deleteMemoryZone(zone); if (success) { console.log(`Zone "${zone}" has been completely deleted`); } else { console.error(`Failed to delete zone "${zone}"`); process.exit(1); } } } else { // Delete all zones const zones = await kgClient.listMemoryZones(); // Delete all indices const client = kgClient['client']; // Access the private client property for (const zone of zones) { if (zone.name === 'default') { // Clear default zone but don't delete it const indexName = `knowledge-graph@default`; try { await client.indices.delete({ index: indexName }); console.log(`Deleted index: ${indexName}`); } catch (error) { console.error(`Error deleting index ${indexName}:`, error); } } else { // Delete non-default zones await kgClient.deleteMemoryZone(zone.name); console.log(`Deleted zone: ${zone.name}`); } } // Delete relations index try { await client.indices.delete({ index: KG_RELATIONS_INDEX }); console.log('Deleted relations index'); } catch (error) { console.error('Error deleting relations index:', error); } // Re-initialize everything await kgClient.initialize(); console.log('Knowledge graph has been completely reset'); } } else { console.log('Operation cancelled'); } } catch (error) { console.error('Error resetting index:', (error as Error).message); process.exit(1); } } /** * Display information about a specific entity * @param name Entity name * @param zone Optional zone name */ async function showEntity(name: string, zone?: string) { try { // Initialize client await kgClient.initialize(zone); // Get entity const entity = await kgClient.getEntityWithoutUpdatingLastRead(name, zone); if (!entity) { console.error(`Entity "${name}" not found${zone ? ` in zone "${zone}"` : ''}`); process.exit(1); } // Get related entities const related = await kgClient.getRelatedEntities(name, 1, zone); // Display entity information console.log(`Entity: ${entity.name}`); console.log(`Type: ${entity.entityType}`); console.log(`Zone: ${entity.zone || 'default'}`); console.log(`Last read: ${entity.lastRead}`); console.log(`Last write: ${entity.lastWrite}`); console.log(`Read count: ${entity.readCount}`); console.log(`Relevance score: ${typeof entity.relevanceScore === 'number' ? entity.relevanceScore.toFixed(2) : '1.00'} (higher = more important)`); console.log(''); console.log('Observations:'); entity.observations.forEach((obs: string, i: number) => { console.log(` ${i+1}. ${obs}`); }); console.log(''); console.log('Relations:'); for (const relation of related.relations) { if (relation.from === name && relation.fromZone === (entity.zone || 'default')) { console.log(` → ${relation.relationType} → ${relation.to} [${relation.toZone}]`); } else { console.log(` ← ${relation.relationType} ← ${relation.from} [${relation.fromZone}]`); } } } catch (error) { console.error('Error getting entity:', (error as Error).message); process.exit(1); } } /** * List all memory zones */ async function listZones() { try { await kgClient.initialize(); const zones = await kgClient.listMemoryZones(); console.log('Memory Zones:'); console.log('============='); for (const zone of zones) { console.log(`${zone.name}`); console.log(` Description: ${zone.description || 'N/A'}`); console.log(` Created: ${zone.createdAt}`); console.log(` Last modified: ${zone.lastModified}`); console.log(''); } console.log(`Total: ${zones.length} zones`); } catch (error) { console.error('Error listing zones:', (error as Error).message); process.exit(1); } } /** * Add a new memory zone */ async function addZone(name: string, description?: string) { try { await kgClient.initialize(); await kgClient.addMemoryZone(name, description); console.log(`Zone "${name}" created successfully`); } catch (error) { console.error('Error adding zone:', (error as Error).message); process.exit(1); } } /** * Delete a memory zone */ async function deleteZone(name: string, args: string[] = []) { try { const confirmMessage = `Are you sure you want to delete zone "${name}" and all its data? This cannot be undone. (y/N) `; const confirmed = await confirmAction(confirmMessage, args); if (confirmed) { await kgClient.initialize(); const success = await kgClient.deleteMemoryZone(name); if (success) { console.log(`Zone "${name}" deleted successfully`); } else { console.error(`Failed to delete zone "${name}"`); process.exit(1); } } else { console.log('Operation cancelled'); } } catch (error) { console.error('Error deleting zone:', (error as Error).message); process.exit(1); } } /** * Show relations for a specific entity */ async function showRelations(name: string, zone?: string) { try { await kgClient.initialize(zone); // Check if entity exists const entity = await kgClient.getEntityWithoutUpdatingLastRead(name, zone); if (!entity) { console.error(`Entity "${name}" not found${zone ? ` in zone "${zone}"` : ''}`); process.exit(1); } const actualZone = zone || 'default'; // Get all relations for this entity const { relations } = await kgClient.getRelationsForEntities([name], actualZone); console.log(`Relations for entity "${name}" in zone "${actualZone}":"`); console.log('===================================='); if (relations.length === 0) { console.log('No relations found.'); return; } // Group by relation type const relationsByType = new Map<string, ESRelation[]>(); for (const relation of relations) { if (!relationsByType.has(relation.relationType)) { relationsByType.set(relation.relationType, []); } relationsByType.get(relation.relationType)!.push(relation); } // Display grouped relations for (const [type, rels] of relationsByType.entries()) { console.log(`\n${type} (${rels.length}):`); console.log('----------------'); for (const rel of rels) { if (rel.from === name && rel.fromZone === actualZone) { // This entity is the source console.log(`→ ${rel.to} [${rel.toZone}]`); } else { // This entity is the target console.log(`← ${rel.from} [${rel.fromZone}]`); } } } } catch (error) { console.error('Error showing relations:', (error as Error).message); process.exit(1); } } /** * Backup all zones to a file */ async function backupAll(filePath: string) { try { await kgClient.initialize(); // Export all data from all zones console.log('Exporting all zones and relations...'); const data = await kgClient.exportAllData(); console.log(`Found ${data.entities.length} entities, ${data.relations.length} relations, and ${data.zones.length} zones`); // Write to file const jsonData = JSON.stringify(data, null, 2); await fs.writeFile(filePath, jsonData); console.log(`Backup saved to ${filePath}`); console.log(`Entities: ${data.entities.length}`); console.log(`Relations: ${data.relations.length}`); console.log(`Zones: ${data.zones.length}`); } catch (error) { console.error('Error creating backup:', (error as Error).message); process.exit(1); } } /** * Restore all zones from a backup file */ async function restoreAll(filePath: string, args: string[] = []) { try { // Read the backup file const jsonData = await fs.readFile(filePath, 'utf8'); const data = JSON.parse(jsonData); if (!data.entities || !data.relations || !data.zones) { console.error('Invalid backup file format'); process.exit(1); } console.log(`Found ${data.entities.length} entities, ${data.relations.length} relations, and ${data.zones.length} zones in backup`); // Confirm with user const confirmMessage = 'This will merge the backup with existing data. Continue? (y/N) '; const confirmed = await confirmAction(confirmMessage, args); if (!confirmed) { console.log('Operation cancelled'); return; } // Import the data await kgClient.initialize(); const result = await kgClient.importAllData(data); console.log('Restore completed:'); console.log(`Zones added: ${result.zonesAdded}`); console.log(`Entities added: ${result.entitiesAdded}`); console.log(`Relations added: ${result.relationsAdded}`); } catch (error) { console.error('Error restoring backup:', (error as Error).message); process.exit(1); } } /** * Helper function to check if --yes flag is present */ function hasYesFlag(args: string[]): boolean { return args.includes('--yes') || args.includes('-y'); } /** * Remove --yes or -y flags from arguments if present */ function cleanArgs(args: string[]): string[] { return args.filter(arg => arg !== '--yes' && arg !== '-y'); } /** * Confirm an action with the user * @param message The confirmation message to display * @param args Command line arguments to check for --yes flag */ async function confirmAction(message: string, args: string[]): Promise<boolean> { // Skip confirmation if --yes flag is present if (hasYesFlag(args)) { return true; } // Otherwise, ask for confirmation const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const answer = await new Promise<string>((resolve) => { rl.question(message, (ans: string) => { resolve(ans); rl.close(); }); }); return answer.toLowerCase() === 'y'; } /** * Update zone descriptions based on content * @param zone Zone name to update * @param limit Optional maximum number of entities to analyze * @param userPrompt Optional user-provided description of the zone's purpose */ async function updateZoneDescriptions(zone: string, limit: number = 20, userPrompt?: string) { try { console.log(`Updating descriptions for zone "${zone}" based on content...`); // Initialize client await kgClient.initialize(zone); // Get current zone metadata const zoneMetadata = await kgClient.getZoneMetadata(zone); if (!zoneMetadata) { console.error(`Zone "${zone}" not found`); process.exit(1); } console.log(`Finding the most representative entities to answer "What is ${zone}?"...`); // Try multiple search strategies to get the most representative entities const relevantEntities = []; // If user provided a prompt, search for it specifically if (userPrompt) { console.log(`Using user-provided description: "${userPrompt}"`); const { entities: promptEntities } = await kgClient.userSearch({ query: userPrompt, limit: limit, sortBy: 'importance', includeObservations: true, informationNeeded: zone !== 'default' ? `What is ${zone}?` : undefined, reason: `Trying to figure out what ${zone} is about, in order to update the zone description.`, zone: zone }); relevantEntities.push(...promptEntities); } // Strategy 1: First get most important entities if (relevantEntities.length < limit) { const { entities: importantEntities } = await kgClient.userSearch({ query: "*", // Get all entities limit: Math.floor(limit / 2), sortBy: 'importance', includeObservations: true, informationNeeded: zone !== 'default' ? `What is ${zone}?` : undefined, reason: `Trying to figure out what ${zone} is about, in order to update the zone description.`, zone: zone }); // Add only new entities for (const entity of importantEntities) { if (!relevantEntities.some(e => e.entityType && // Make sure we're comparing entities entity.entityType && e.name === entity.name )) { relevantEntities.push(entity); } } } // Strategy 2: Use zone name as search query to find semantically related entities if (relevantEntities.length < limit) { const { entities: nameEntities } = await kgClient.userSearch({ query: zone, // Use zone name as search query limit: Math.ceil(limit / 4), sortBy: 'relevance', includeObservations: true, informationNeeded: zone !== 'default' ? `What is ${zone}?` : undefined, reason: `Trying to figure out what ${zone} is about, in order to update the zone description.`, zone: zone }); // Add only new entities not already in the list for (const entity of nameEntities) { if (!relevantEntities.some(e => e.entityType && // Make sure we're comparing entities entity.entityType && e.name === entity.name )) { relevantEntities.push(entity); } } } // Strategy 3: Get most frequently accessed entities if (relevantEntities.length < limit) { const { entities: recentEntities } = await kgClient.userSearch({ query: "*", // Get all entities limit: Math.ceil(limit / 4), sortBy: 'recent', includeObservations: true, informationNeeded: zone !== 'default' ? `What is ${zone}?` : undefined, reason: `Trying to figure out what ${zone} is about, in order to update the zone description.`, zone: zone }); // Add only new entities not already in the list for (const entity of recentEntities) { if (!relevantEntities.some(e => e.entityType && // Make sure we're comparing entities entity.entityType && e.name === entity.name )) { relevantEntities.push(entity); } } } if (relevantEntities.length === 0) { console.log(`No entities found in zone "${zone}" to analyze.`); return; } // Trim to limit const finalEntities = relevantEntities.slice(0, limit); console.log(`Found ${finalEntities.length} representative entities to analyze for zone description.`); // Generate descriptions using AI console.log("\nGenerating descriptions..."); try { const descriptions = await GroqAI.generateZoneDescriptions( zone, zoneMetadata.description || '', finalEntities, userPrompt ); // Update the zone with new descriptions await kgClient.updateZoneDescriptions( zone, descriptions.description, descriptions.shortDescription ); console.log(`\nUpdated descriptions for zone "${zone}":`); console.log(`\nShort Description: ${descriptions.shortDescription}`); console.log(`\nFull Description: ${descriptions.description}`); } catch (error) { console.error(`\nError generating descriptions: ${error.message}`); console.log('\nFalling back to existing description. Please try again or provide a more specific prompt.'); } } catch (error) { console.error('Error updating zone descriptions:', (error as Error).message); process.exit(1); } } /** * Main function to parse and execute commands */ async function main() { const args = process.argv.slice(2); const cleanedArgs = cleanArgs(args); const command = cleanedArgs[0]; if (!command || command === 'help') { showHelp(); return; } switch (command) { case 'init': await initializeIndex(); break; case 'import': if (!cleanedArgs[1]) { console.error('Error: File path is required for import'); process.exit(1); } await importFromJsonFile(cleanedArgs[1], { ...esOptions, defaultZone: cleanedArgs[2] || DEFAULT_ZONE }); break; case 'export': if (!cleanedArgs[1]) { console.error('Error: File path is required for export'); process.exit(1); } await exportToJsonFile(cleanedArgs[1], { ...esOptions, defaultZone: cleanedArgs[2] || DEFAULT_ZONE }); break; case 'backup': if (!cleanedArgs[1]) { console.error('Error: File path is required for backup'); process.exit(1); } await backupAll(cleanedArgs[1]); break; case 'restore': if (!cleanedArgs[1]) { console.error('Error: File path is required for restore'); process.exit(1); } await restoreAll(cleanedArgs[1], args); break; case 'stats': await showStats(cleanedArgs[1]); break; case 'search': if (!cleanedArgs[1]) { console.error('Error: Search query is required'); process.exit(1); } await searchGraph(cleanedArgs[1], cleanedArgs[2]); break; case 'reset': await resetIndex(cleanedArgs[1], args); break; case 'entity': if (!cleanedArgs[1]) { console.error('Error: Entity name is required'); process.exit(1); } await showEntity(cleanedArgs[1], cleanedArgs[2]); break; case 'zones': const zonesCommand = cleanedArgs[1]; if (!zonesCommand) { await listZones(); break; } switch (zonesCommand) { case 'list': await listZones(); break; case 'add': if (!cleanedArgs[2]) { console.error('Error: Zone name is required'); process.exit(1); } await addZone(cleanedArgs[2], cleanedArgs[3]); break; case 'delete': if (!cleanedArgs[2]) { console.error('Error: Zone name is required'); process.exit(1); } await deleteZone(cleanedArgs[2], args); break; case 'stats': if (!cleanedArgs[2]) { console.error('Error: Zone name is required'); process.exit(1); } await showStats(cleanedArgs[2]); break; case 'update_descriptions': if (!cleanedArgs[2]) { console.error('Error: Zone name is required'); process.exit(1); } let limit = 20; let userPrompt = undefined; // Check if the third argument is a number (limit) or a string (userPrompt) if (cleanedArgs[3]) { const parsedLimit = parseInt(cleanedArgs[3], 10); if (!isNaN(parsedLimit) && parsedLimit.toString() === cleanedArgs[3]) { // Only interpret as a limit if it's a pure number with no text limit = parsedLimit; // If there's a fourth argument, it's the user prompt if (cleanedArgs[4]) { userPrompt = cleanedArgs.slice(4).join(' '); } } else { // If third argument isn't a pure number, it's the start of the user prompt userPrompt = cleanedArgs.slice(3).join(' '); } } await updateZoneDescriptions(cleanedArgs[2], limit, userPrompt); break; default: console.error(`Unknown zones command: ${zonesCommand}`); showHelp(); process.exit(1); } break; case 'relations': if (!cleanedArgs[1]) { console.error('Error: Entity name is required'); process.exit(1); } await showRelations(cleanedArgs[1], cleanedArgs[2]); break; default: console.error(`Unknown command: ${command}`); showHelp(); process.exit(1); } } // Run the CLI main().catch(error => { console.error('Error:', (error as Error).message); process.exit(1); });

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/j3k0/mcp-brain-tools'

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