UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

495 lines (430 loc) 14.2 kB
import { ElectronMCPClient } from '../../ai/mcp/electron-client'; export interface MCPToolSchema { name: string; description?: string; inputSchema?: { type: string; properties?: Record<string, any>; required?: string[]; }; } export interface UserContext { userMessage?: string; conversationHistory?: Array<{ role: string; content: string }>; kubernetesContext?: { selectedClusters?: string[]; namespace?: string; currentResource?: any; }; lastToolResults?: Record<string, any>; timeContext?: Date; } export interface ProcessedArguments { original: Record<string, any>; processed: Record<string, any>; schema: MCPToolSchema | null; suggestions: Record<string, any>; errors: string[]; intelligentFills: Record<string, { value: any; reason: string; confidence: number }>; } export class MCPArgumentProcessor { private static toolSchemas: Map<string, MCPToolSchema> = new Map(); private static schemasLoaded = false; /** * Load MCP tool schemas */ static async loadSchemas(): Promise<void> { if (this.schemasLoaded) return; try { const mcpClient = new ElectronMCPClient(); const toolsConfigResponse = await mcpClient.getToolsConfig(); if (toolsConfigResponse && toolsConfigResponse.config) { // Parse the new structure: { "serverName": { "toolName": { enabled, inputSchema, ... } } } Object.entries(toolsConfigResponse.config).forEach( ([serverName, serverTools]: [string, any]) => { Object.entries(serverTools).forEach(([toolName, toolConfig]: [string, any]) => { const fullToolName = `${serverName}__${toolName}`; this.toolSchemas.set(fullToolName, { name: fullToolName, description: toolConfig.description, inputSchema: toolConfig.inputSchema, }); }); } ); this.schemasLoaded = true; } } catch (error) { console.error('Failed to load MCP tool schemas:', error); } } /** * Validate and process arguments for MCP tools (simplified version) * Main argument generation is now handled by AI in LangChainManager */ static async processArguments( toolName: string, aiProcessedArgs: Record<string, any> = {}, userContext?: UserContext ): Promise<ProcessedArguments> { await this.loadSchemas(); const schema = this.toolSchemas.get(toolName); const errors: string[] = []; const processed = { ...aiProcessedArgs }; const intelligentFills: Record<string, { value: any; reason: string; confidence: number }> = {}; // Check if arguments were enhanced by LLM const llmEnhanced = aiProcessedArgs._llmEnhanced; if (llmEnhanced) { // Remove metadata before processing delete processed._llmEnhanced; // Mark LLM-enhanced fields in intelligentFills for (const fieldName of llmEnhanced.enhancedFields || []) { if (fieldName in processed) { intelligentFills[fieldName] = { value: processed[fieldName], reason: `AI-enhanced based on user request analysis`, confidence: 0.9, // High confidence for LLM-enhanced fields }; } } } if (!schema) { errors.push(`No schema found for tool: ${toolName}`); return { original: aiProcessedArgs, processed, schema: null, suggestions: {}, errors, intelligentFills, }; } // Ensure required fields have appropriate values (even if empty objects/arrays) if (schema.inputSchema?.required) { for (const requiredField of schema.inputSchema.required) { if (!(requiredField in processed) || processed[requiredField] === undefined) { const fieldSchema = schema.inputSchema.properties?.[requiredField]; if (fieldSchema) { // Provide appropriate empty value based on type processed[requiredField] = this.getEmptyValueForRequiredField(fieldSchema); // Only mark as intelligent fill if not already enhanced by LLM if (!intelligentFills[requiredField]) { intelligentFills[requiredField] = { value: processed[requiredField], reason: `Required field provided with empty ${fieldSchema.type}`, confidence: 0.8, }; } } } } } // Generate suggestions for UI display const suggestions = this.generateIntelligentSuggestions(schema, userContext); // Validate processed arguments const validationErrors = this.validateArgumentsWithEmptyObjectSupport(processed, schema); errors.push(...validationErrors); return { original: aiProcessedArgs, processed, schema, suggestions, errors, intelligentFills, }; } /** * Get appropriate empty value for required field */ private static getEmptyValueForRequiredField(fieldSchema: any): any { const type = fieldSchema.type; switch (type) { case 'object': return {}; case 'array': return []; case 'string': return fieldSchema.default || ''; case 'number': case 'integer': return fieldSchema.default || fieldSchema.minimum || 0; case 'boolean': return fieldSchema.default !== undefined ? fieldSchema.default : false; default: return null; } } /** * Generate intelligent suggestions based on tool schema and context */ private static generateIntelligentSuggestions( schema: MCPToolSchema, userContext?: UserContext ): Record<string, any> { const suggestions: Record<string, any> = {}; if (!schema.inputSchema?.properties) return suggestions; const properties = schema.inputSchema.properties; // Generate suggestions based on property types and names for (const [key, propertySchema] of Object.entries(properties)) { const suggestion = this.generatePropertySuggestion(key, propertySchema, userContext); if (suggestion !== undefined) { suggestions[key] = suggestion; } } return suggestions; } /** * Generate suggestion for a specific property */ private static generatePropertySuggestion( propertyName: string, propertySchema: any, userContext?: UserContext ): any { const type = propertySchema.type; const description = propertySchema.description?.toLowerCase() || ''; // Check context data for matching values if (userContext) { // Check kubernetes context if (userContext.kubernetesContext) { const k8sContext = userContext.kubernetesContext; if (propertyName.toLowerCase().includes('namespace') && k8sContext.namespace) { return k8sContext.namespace; } if (propertyName.toLowerCase().includes('cluster') && k8sContext.selectedClusters?.length) { return k8sContext.selectedClusters[0]; } } // Check last tool results if (userContext.lastToolResults) { const lowerPropName = propertyName.toLowerCase(); for (const [contextKey, contextValue] of Object.entries(userContext.lastToolResults)) { if ( contextKey.toLowerCase().includes(lowerPropName) || lowerPropName.includes(contextKey.toLowerCase()) ) { return contextValue; } } } } // Generate suggestions based on property name and type switch (type) { case 'string': return this.suggestStringValue(propertyName, description, propertySchema); case 'number': case 'integer': return this.suggestNumberValue(propertyName, description, propertySchema); case 'boolean': return this.suggestBooleanValue(propertyName); case 'array': return this.suggestArrayValue(); case 'object': return this.suggestObjectValue(); default: return undefined; } } /** * Suggest string values based on property name and context */ private static suggestStringValue( propertyName: string, description: string, schema: any ): string | undefined { const lowerName = propertyName.toLowerCase(); const lowerDesc = description.toLowerCase(); // Check for enum values if (schema.enum && Array.isArray(schema.enum)) { return schema.enum[0]; // Default to first enum value } // Path-related suggestions if ( lowerName.includes('path') || lowerName.includes('directory') || lowerName.includes('dir') ) { if (lowerDesc.includes('current') || lowerDesc.includes('working')) { return '.'; } if (lowerDesc.includes('home')) { return '~'; } return undefined; } // File-related suggestions if (lowerName.includes('file') || lowerName.includes('filename')) { return ''; } // Name suggestions if (lowerName.includes('name') && !lowerName.includes('filename')) { return ''; } // Command suggestions if (lowerName.includes('command') || lowerName.includes('cmd')) { return ''; } // Query suggestions if (lowerName.includes('query') || lowerName.includes('search')) { return ''; } return undefined; } /** * Suggest number values */ private static suggestNumberValue( propertyName: string, description: string, schema: any ): number | undefined { const lowerName = propertyName.toLowerCase(); // Check for default in schema if (schema.default !== undefined) { return schema.default; } // Check for minimum value if (schema.minimum !== undefined) { return schema.minimum; } // Common number patterns if (lowerName.includes('port')) { return 8080; } if (lowerName.includes('timeout')) { return 30; } if (lowerName.includes('limit') || lowerName.includes('max')) { return 100; } if (lowerName.includes('count')) { return 10; } return undefined; } /** * Suggest boolean values */ private static suggestBooleanValue(propertyName: string): boolean | undefined { const lowerName = propertyName.toLowerCase(); // Note: description analysis could be added here for more intelligent suggestions // Common boolean patterns if (lowerName.includes('enable') || lowerName.includes('enabled')) { return false; // Conservative default } if (lowerName.includes('disable') || lowerName.includes('disabled')) { return false; } if (lowerName.includes('recursive') || lowerName.includes('recurse')) { return false; } if (lowerName.includes('force')) { return false; } if (lowerName.includes('verbose')) { return false; } return undefined; } /** * Suggest array values */ private static suggestArrayValue(): any[] | undefined { // Return empty array for optional arrays return []; } /** * Suggest object values */ private static suggestObjectValue(): Record<string, any> | undefined { // Return empty object for optional objects return {}; } /** * Clean up arguments by removing empty non-required fields */ static cleanupArguments(args: Record<string, any>, schema: MCPToolSchema): Record<string, any> { if (!schema.inputSchema) return args; const cleaned: Record<string, any> = {}; const required = schema.inputSchema.required || []; const properties = schema.inputSchema.properties || {}; for (const [key, value] of Object.entries(args)) { // Skip LLM metadata if (key === '_llmEnhanced') continue; const isRequired = required.includes(key); const propertySchema = properties[key]; const hasDefault = propertySchema?.default !== undefined; // Include if: // 1. Required field // 2. Has a non-empty value // 3. Has a default value defined in schema if (isRequired || this.hasActualValue(value) || hasDefault) { cleaned[key] = value; } } return cleaned; } /** * Check if a value is meaningful (not empty/null/undefined) */ private static hasActualValue(value: any): boolean { if (value === null || value === undefined || value === '') { return false; } if (Array.isArray(value)) { return value.length > 0; } if (typeof value === 'object') { return Object.keys(value).length > 0; } return true; } /** * Validate arguments against schema with support for empty objects/arrays */ private static validateArgumentsWithEmptyObjectSupport( args: Record<string, any>, schema: MCPToolSchema ): string[] { const errors: string[] = []; if (!schema.inputSchema) return errors; const required = schema.inputSchema.required || []; const properties = schema.inputSchema.properties || {}; // Check required fields (allow empty objects/arrays for required fields) for (const requiredField of required) { if ( !(requiredField in args) || args[requiredField] === undefined || args[requiredField] === null ) { errors.push(`Required field '${requiredField}' is missing`); } } // Check type validation for (const [key, value] of Object.entries(args)) { if (properties[key] && value !== undefined && value !== null) { const expectedType = properties[key].type; const actualType = Array.isArray(value) ? 'array' : typeof value; if (expectedType && actualType !== expectedType) { errors.push(`Field '${key}' should be ${expectedType}, got ${actualType}`); } } } return errors; } /** * Get tool schema */ static async getToolSchema(toolName: string): Promise<MCPToolSchema | null> { await this.loadSchemas(); return this.toolSchemas.get(toolName) || null; } /** * Get all available tool names */ static async getAvailableTools(): Promise<string[]> { await this.loadSchemas(); return Array.from(this.toolSchemas.keys()); } }