Skip to main content
Glama

1MCP Server

interactiveSelector.ts23.2 kB
import { McpConfigManager } from '@src/config/mcpConfigManager.js'; import { TagQueryEvaluator, TagSelection, TagState } from '@src/domains/preset/parsers/tagQueryEvaluator.js'; import { PresetConfig, PresetStrategy, TagQuery } from '@src/domains/preset/types/presetTypes.js'; import logger from '@src/logger/logger.js'; import boxen from 'boxen'; import chalk from 'chalk'; import prompts from 'prompts'; /** * Interactive server selection result */ export interface SelectionResult { strategy: PresetStrategy; tagQuery: TagQuery; cancelled: boolean; } /** * Interactive CLI utility for server selection with arrow key navigation */ export class InteractiveSelector { private mcpConfig: McpConfigManager; constructor() { this.mcpConfig = McpConfigManager.getInstance(); } /** * Interactive tag-based selection with strategy configuration and back navigation */ public async selectServers(existingConfig?: Partial<PresetConfig>, configPath?: string): Promise<SelectionResult> { // Display welcome message with boxen let welcomeContent = chalk.magenta.bold('🚀 MCP Preset Configuration\n\n') + chalk.yellow('Configure your preset selection strategy:'); if (configPath) { welcomeContent += '\n\n' + chalk.gray(`📁 Config: ${configPath}`); } const welcomeMessage = boxen(welcomeContent, { padding: 1, margin: 1, borderStyle: 'double', borderColor: 'cyan', title: 'Preset Builder', titleAlignment: 'center', }); console.log(welcomeMessage); try { // Get available servers and collect all tags const servers = this.mcpConfig.getTransportConfig(); if (Object.keys(servers).length === 0) { console.log( boxen(chalk.red.bold('⚠️ No MCP servers found in configuration'), { padding: 1, borderStyle: 'round', borderColor: 'red', }), ); return { strategy: 'or', tagQuery: {}, cancelled: true, }; } // Collect all available tags from all servers const allTags = new Set<string>(); for (const serverConfig of Object.values(servers)) { if (serverConfig.tags) { serverConfig.tags.forEach((tag: string) => allTags.add(tag)); } } const availableTags = Array.from(allTags).sort(); if (availableTags.length === 0) { console.log( boxen(chalk.red.bold('⚠️ No tags found in server configuration'), { padding: 1, borderStyle: 'round', borderColor: 'red', }), ); return { strategy: 'or', tagQuery: {}, cancelled: true, }; } // Main interaction loop with back navigation support let strategy: PresetStrategy | undefined; let tagQuery: TagQuery = {}; let completed = false; while (!completed) { // Step 1: Strategy selection const strategyChoices = [ { title: 'Match ANY selected tags (OR logic)', description: 'Servers that have ANY of the selected tags', value: 'or' as PresetStrategy, }, { title: 'Match ALL selected tags (AND logic)', description: 'Servers that have ALL of the selected tags', value: 'and' as PresetStrategy, }, { title: 'Custom JSON query', description: 'Advanced JSON-based query for complex filtering', value: 'advanced' as PresetStrategy, }, ]; const strategySelection = await prompts({ type: 'select', name: 'strategy', message: 'Select filtering strategy:', choices: strategyChoices, initial: existingConfig?.strategy === 'and' ? 1 : existingConfig?.strategy === 'advanced' ? 2 : 0, }); if (strategySelection.strategy === undefined) { return { strategy: 'or', tagQuery: {}, cancelled: true, }; } strategy = strategySelection.strategy; // Step 2: Create query based on strategy if (strategy === 'advanced') { // Custom JSON query input console.log( boxen(chalk.magenta.bold('📝 Custom Query Input'), { padding: 1, borderStyle: 'round', borderColor: 'magenta', }), ); const queryInput = await prompts({ type: 'text', name: 'query', message: 'Enter JSON query (e.g., {"tag": "web"}, {"$or": [{"tag": "web"}, {"tag": "api"}]}):', initial: existingConfig?.tagQuery ? JSON.stringify(existingConfig.tagQuery, null, 2) : '{"tag": ""}', validate: (value: string) => { if (!value.trim()) { return 'Query cannot be empty'; } try { const parsed = JSON.parse(value.trim()); const validation = TagQueryEvaluator.validateQuery(parsed); if (!validation.isValid) { return `Invalid query: ${validation.errors.join(', ')}`; } return true; } catch (error) { return `Invalid JSON: ${error instanceof Error ? error.message : 'Unknown error'}`; } }, }); if (queryInput.query === undefined) { return { strategy: 'or', tagQuery: {}, cancelled: true, }; } tagQuery = JSON.parse(queryInput.query.trim()); completed = true; } else { // Step 2: Three-state tag selection with arrow key navigation if (strategy) { const tagSelectionResult = await this.selectTagsInteractive( availableTags, servers, strategy, existingConfig?.tagQuery, ); if (tagSelectionResult.goBack) { // User wants to go back to strategy selection strategy = undefined; continue; } if (tagSelectionResult.cancelled) { return { strategy: 'or', tagQuery: {}, cancelled: true, }; } tagQuery = tagSelectionResult.tagQuery; completed = true; } } // Step 3: Preview and confirmation if (completed) { const queryString = TagQueryEvaluator.queryToString(tagQuery); // Show matching servers const matchingServers = Object.entries(servers) .filter(([, serverConfig]) => { const serverTags = serverConfig.tags || []; return TagQueryEvaluator.evaluate(tagQuery, serverTags); }) .map(([serverName]) => serverName); const serverList = matchingServers.slice(0, 3).join(', '); const moreText = matchingServers.length > 3 ? `... and ${matchingServers.length - 3} more` : ''; const previewContent = chalk.yellow.bold('Preview query: ') + chalk.green(queryString) + '\n\n' + chalk.yellow.bold(`Matching servers (${matchingServers.length}): `) + chalk.green(serverList) + (moreText ? '\n' + chalk.gray(moreText) : ''); console.log( boxen(previewContent, { padding: 1, borderStyle: 'round', borderColor: 'green', title: '✅ Query Preview', titleAlignment: 'center', }), ); } } return { strategy: strategy!, tagQuery, cancelled: false, }; } catch (error) { logger.error('Interactive selection failed', { error }); console.log( boxen(chalk.red.bold('❌ Selection failed - see logs for details'), { padding: 1, borderStyle: 'round', borderColor: 'red', }), ); return { strategy: 'or', tagQuery: {}, cancelled: true, }; } } /** * Confirm save operation with preset name */ public async confirmSave(presetName?: string): Promise<{ name: string; description?: string; save: boolean }> { if (presetName) { // Pre-specified name, just confirm const confirm = await prompts({ type: 'confirm', name: 'save', message: `Save preset as '${presetName}'?`, }); return { name: presetName, save: confirm.save || false, }; } // Get preset name and optional description const nameInput = await prompts({ type: 'text', name: 'name', message: 'Enter preset name:', validate: (value: string) => { if (!value.trim()) { return 'Preset name is required'; } if (value.trim().length > 50) { return 'Preset name must be 50 characters or less'; } if (!/^[a-zA-Z0-9_-]+$/.test(value.trim())) { return 'Preset name can only contain letters, numbers, hyphens, and underscores'; } return true; }, }); if (!nameInput.name) { return { name: '', save: false }; } const descriptionInput = await prompts({ type: 'text', name: 'description', message: 'Enter optional description:', }); return { name: nameInput.name.trim(), description: descriptionInput.description?.trim() || undefined, save: true, }; } /** * Display server configuration for validation */ public displayServerConfig(serverName: string): void { const servers = this.mcpConfig.getTransportConfig(); const config = servers[serverName]; if (!config) { console.log(`Server '${serverName}' not found`); return; } const tags = config.tags || []; console.log(`\n📋 Server: ${serverName}`); console.log(` Tags: ${tags.length > 0 ? tags.join(', ') : 'none'}`); } /** * Validate preset name format */ public validatePresetName(name: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(name.trim()); } /** * Simple confirmation prompt */ public async confirm(message: string): Promise<boolean> { const result = await prompts({ type: 'confirm', name: 'confirmed', message, }); return result.confirmed || false; } /** * Get a numeric choice from user within a range */ public async getChoice(message: string, min: number, max: number): Promise<number> { const result = await prompts({ type: 'number', name: 'choice', message, min, max, validate: (value: number) => { if (value < min || value > max) { return `Please enter a number between ${min} and ${max}`; } return true; }, }); return result.choice || min; } /** * Show error message */ public showError(message: string): void { console.error(`❌ ${message}`); } /** * Show URL result */ public showUrl(name: string, url: string): void { console.log(`\n🔗 Preset URL for '${name}':`); console.log(` ${url}\n`); } /** * Show save success message */ public showSaveSuccess(name: string, url: string): void { console.log(`\n✅ Preset '${name}' saved successfully!`); console.log(`🔗 URL: ${url}\n`); } /** * Test preset and show results */ public async testPreset(name: string, testResult: { servers: string[]; tags: string[] }): Promise<void> { console.log(`\n🔍 Testing preset '${name}':`); console.log(` Matching servers: ${testResult.servers.join(', ') || 'none'}`); console.log(` Available tags: ${testResult.tags.join(', ') || 'none'}\n`); } /** * Interactive three-state tag selection with boxen UI and custom keyboard controls */ private async selectTagsInteractive( availableTags: string[], servers: Record<string, any>, strategy: PresetStrategy, existingQuery?: TagQuery, ): Promise<{ tagQuery: TagQuery; goBack: boolean; cancelled: boolean; }> { // Build tag-to-servers mapping const tagServerMap = TagQueryEvaluator.buildTagServerMap(servers); // Initialize tag selections with server info and restore from existing query const tagSelections: TagSelection[] = availableTags.map((tag) => ({ tag, state: this.getInitialTagStateFromQuery(tag, existingQuery, strategy), servers: tagServerMap.get(tag) || [], })); let currentIndex = 0; while (true) { // Clear screen console.clear(); // Show main tag selection interface await this.showTagSelection(tagSelections, currentIndex, servers, strategy); // Get user input const action = await this.getKeyInput(); // Main tag selection view switch (action) { case 'up': currentIndex = Math.max(0, currentIndex - 1); break; case 'down': currentIndex = Math.min(tagSelections.length - 1, currentIndex + 1); break; case 'space': if (currentIndex < tagSelections.length) { tagSelections[currentIndex].state = TagQueryEvaluator.cycleTagState(tagSelections[currentIndex].state); } break; case 'right': { // Show server details for current tag if (currentIndex < tagSelections.length) { await this.showTagServerDetails(tagSelections[currentIndex], servers); } break; } case 'enter': { // Build final query const finalQuery = TagQueryEvaluator.buildQueryFromSelections(tagSelections, strategy); return { tagQuery: finalQuery, goBack: false, cancelled: false }; } case 'left': return { tagQuery: {}, goBack: true, cancelled: false }; case 'escape': return { tagQuery: {}, goBack: false, cancelled: true }; } } } /** * Show main tag selection interface with boxen styling */ private async showTagSelection( tagSelections: TagSelection[], currentIndex: number, servers: Record<string, any>, strategy: PresetStrategy, ): Promise<void> { // Header const header = boxen( chalk.cyan.bold('🎯 Three-State Tag Selection\n\n') + chalk.yellow(`Strategy: ${strategy === 'and' ? 'ALL' : 'ANY'} selected tags must match\n`) + chalk.gray('Controls: ↑↓ Navigate Space Cycle states → Server details Enter Confirm ← Back Esc Cancel'), { padding: 1, borderStyle: 'double', borderColor: 'cyan', title: 'Tag Selection', titleAlignment: 'center', }, ); console.log(header); // Tag list const tagListContent = tagSelections .map((selection, index) => { const symbol = TagQueryEvaluator.getTagStateSymbol(selection.state); const stateColor = this.getTagStateColor(selection.state); const isCurrentIndex = index === currentIndex; const cursor = isCurrentIndex ? chalk.yellow.bold('►') : ' '; const tagHighlight = isCurrentIndex ? chalk.bgGray.white.bold : chalk.white; // Count enabled and disabled servers for this tag const enabledServers = selection.servers.filter((serverName) => servers[serverName]?.disabled !== true); const disabledServers = selection.servers.filter((serverName) => servers[serverName]?.disabled === true); let serverInfo = chalk.gray(`(${chalk.blue(enabledServers.length)} enabled`); if (disabledServers.length > 0) { serverInfo += chalk.gray(`, ${chalk.red(disabledServers.length)} disabled`); } serverInfo += chalk.gray(')'); return `${cursor} ${stateColor(symbol)} ${tagHighlight(selection.tag)} ${serverInfo}`; }) .join('\n'); console.log( boxen(tagListContent, { padding: 1, borderStyle: 'round', borderColor: 'blue', }), ); // Live preview const matchingServers = TagQueryEvaluator.getMatchingServers(tagSelections, servers, strategy); // Check for disabled servers in the matching set const disabledServers = matchingServers.filter((serverName) => servers[serverName]?.disabled === true); const enabledServers = matchingServers.filter((serverName) => servers[serverName]?.disabled !== true); const matchColor = enabledServers.length === 0 ? chalk.red : enabledServers.length < 3 ? chalk.yellow : chalk.green; const matchIcon = enabledServers.length === 0 ? '❌' : enabledServers.length < 3 ? '⚠️' : '✅'; let previewContent = chalk.blue.bold('Live Preview:\n') + `${matchIcon} ${matchColor.bold(`${enabledServers.length} enabled servers`)} match your selection\n` + (enabledServers.length > 0 ? chalk.green(`Servers: ${TagQueryEvaluator.formatServerList(enabledServers, 3)}`) : chalk.gray('No enabled servers match')); // Add warning for disabled servers if any if (disabledServers.length > 0) { previewContent += '\n' + chalk.red.bold(`⚠️ ${disabledServers.length} disabled servers also match: `) + chalk.red(TagQueryEvaluator.formatServerList(disabledServers, 3)); } console.log( boxen(previewContent, { padding: 1, borderStyle: 'round', borderColor: disabledServers.length > 0 ? 'yellow' : 'green', title: '⚡ Live Preview', titleAlignment: 'center', }), ); // State legend const legend = chalk.gray('○ ') + chalk.dim('Empty (ignored)') + ' ' + chalk.green('✓ ') + chalk.green('Selected (include)') + ' ' + chalk.red('✗ ') + chalk.red('Not selected (exclude)'); console.log( boxen(legend, { padding: 1, borderStyle: 'single', borderColor: 'gray', }), ); } /** * Get single key input with proper handling for arrow keys */ private async getKeyInput(): Promise<string> { return new Promise((resolve) => { const stdin = process.stdin; stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf8'); const onKeypress = (key: string) => { stdin.setRawMode(false); stdin.pause(); stdin.removeListener('data', onKeypress); // Handle escape sequences for arrow keys if (key === '\u001b[A') resolve('up'); else if (key === '\u001b[B') resolve('down'); else if (key === '\u001b[D') resolve('left'); else if (key === '\u001b[C') resolve('right'); else if (key === ' ') resolve('space'); else if (key === '\r' || key === '\n') resolve('enter'); else if (key === '\u001b' || key === '\u0003') resolve('escape'); // ESC or Ctrl+C else resolve('unknown'); }; stdin.on('data', onKeypress); }); } /** * Get color for tag state */ private getTagStateColor(state: TagState): typeof chalk { switch (state) { case 'empty': return chalk.gray; case 'selected': return chalk.green; case 'not-selected': return chalk.red; default: return chalk.reset; } } /** * Show detailed information about servers for a specific tag */ private async showTagServerDetails(tagSelection: TagSelection, servers: Record<string, any>): Promise<void> { console.clear(); const enabledServers = tagSelection.servers.filter((serverName) => servers[serverName]?.disabled !== true); const disabledServers = tagSelection.servers.filter((serverName) => servers[serverName]?.disabled === true); let content = chalk.blue.bold(`📋 Tag: ${tagSelection.tag}\n\n`); if (enabledServers.length > 0) { content += chalk.green.bold(`✅ Enabled Servers (${enabledServers.length}):\n`); for (const serverName of enabledServers) { const serverConfig = servers[serverName]; const allTags = (serverConfig.tags || []).join(', '); content += chalk.green(` • ${serverName}`) + chalk.gray(` - tags: ${allTags || 'none'}\n`); } content += '\n'; } if (disabledServers.length > 0) { content += chalk.red.bold(`❌ Disabled Servers (${disabledServers.length}):\n`); for (const serverName of disabledServers) { const serverConfig = servers[serverName]; const allTags = (serverConfig.tags || []).join(', '); content += chalk.red(` • ${serverName}`) + chalk.gray(` - tags: ${allTags || 'none'}\n`); } content += '\n'; } if (tagSelection.servers.length === 0) { content += chalk.yellow('No servers have this tag.\n\n'); } content += chalk.gray('Press any key to return to tag selection...'); console.log( boxen(content, { padding: 1, borderStyle: 'round', borderColor: 'blue', title: `🔍 Server Details`, titleAlignment: 'center', }), ); // Wait for any key press await this.getKeyInput(); } /** * Determine initial tag state from existing query */ private getInitialTagStateFromQuery(tag: string, existingQuery?: TagQuery, _strategy?: PresetStrategy): TagState { if (!existingQuery || typeof existingQuery !== 'object') { return 'empty'; } // Helper function to recursively check if a query matches a tag const queryMatches = (query: any): boolean => { if (!query || typeof query !== 'object') { return false; } // Direct tag match if (query.tag === tag) { return true; } // Check nested $or if (query.$or && Array.isArray(query.$or)) { return query.$or.some((subQuery: any) => queryMatches(subQuery)); } // Check nested $and if (query.$and && Array.isArray(query.$and)) { return query.$and.some((subQuery: any) => queryMatches(subQuery)); } // Check $in operator if (query.$in && Array.isArray(query.$in)) { return query.$in.includes(tag); } return false; }; // Helper function to check for NOT conditions const queryMatchesNot = (query: any): boolean => { if (!query || typeof query !== 'object') { return false; } // Direct NOT match if (query.$not) { return queryMatches(query.$not); } // Check for NOT in nested structures if (query.$and && Array.isArray(query.$and)) { return query.$and.some((subQuery: any) => subQuery.$not && queryMatches(subQuery.$not)); } return false; }; // Check for NOT conditions first (they take precedence) if (queryMatchesNot(existingQuery)) { return 'not-selected'; } // Check for positive matches if (queryMatches(existingQuery)) { return 'selected'; } return 'empty'; } }

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/1mcp-app/agent'

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