UNPKG

@toolprint/hypertool-mcp

Version:

HyperTool MCP proxy server for routing requests between clients and multiple underlying MCP servers

474 lines • 18.5 kB
/** * Persona Loader Implementation * * This module implements persona loading functionality that parses YAML configs, * loads MCP configurations, and catalogs all assets in persona folders. The loader * orchestrates the parsing, validation, and asset discovery process to create * complete LoadedPersona objects ready for activation. * * @fileoverview Comprehensive persona loading with asset cataloging and validation */ import { promises as fs } from "fs"; import { basename, dirname, join } from "path"; import { parsePersonaYAMLFile, isPersonaConfigFile, getSupportedPersonaFiles, } from "./parser.js"; import { PersonaValidator, } from "./validator.js"; import { createFileSystemError, createPersonaNotFoundError, isPersonaError, } from "./errors.js"; import { PersonaDiscovery } from "./discovery.js"; /** * PersonaLoader class for comprehensive persona loading * * Orchestrates the loading process including YAML parsing, MCP configuration loading, * validation, and asset cataloging. Provides both individual and batch loading * capabilities with comprehensive error handling and performance monitoring. */ export class PersonaLoader { validator; discovery; toolDiscoveryEngine; constructor(toolDiscoveryEngine, discovery) { this.toolDiscoveryEngine = toolDiscoveryEngine; this.validator = new PersonaValidator(toolDiscoveryEngine); this.discovery = discovery; } /** * Load a persona from a file path * * @param filePath - Path to persona.yaml/yml file or persona directory * @param options - Loading options * @returns Promise resolving to load result */ async loadPersona(filePath, options = {}) { const startTime = Date.now(); const result = { success: false, errors: [], warnings: [], loadTime: 0, }; try { // Step 1: Determine if path is file or directory let configFilePath; let personaDirectory; const stats = await fs.stat(filePath); if (stats.isDirectory()) { // Find persona config file in directory personaDirectory = filePath; configFilePath = await this.findPersonaConfigFile(personaDirectory); if (!configFilePath) { result.errors.push(`No persona configuration file found in directory "${filePath}". Expected: ${getSupportedPersonaFiles().join(" or ")}`); return result; } } else if (stats.isFile()) { // Validate file is a supported persona config if (!isPersonaConfigFile(filePath)) { result.errors.push(`File "${filePath}" is not a supported persona configuration file. Expected: ${getSupportedPersonaFiles().join(" or ")}`); return result; } configFilePath = filePath; personaDirectory = dirname(filePath); } else { result.errors.push(`Path "${filePath}" is not a valid file or directory`); return result; } // Step 2: Parse YAML configuration const parseResult = await parsePersonaYAMLFile(configFilePath, { validateSchema: true, includeWarnings: options.includeWarnings ?? true, }); if (!parseResult.success || !parseResult.data) { result.errors.push(...parseResult.errors.map((e) => e.message)); if (options.includeWarnings && parseResult.warnings.length > 0) { result.warnings.push(...parseResult.warnings.map((w) => w.message)); } return result; } const personaConfig = parseResult.data; // Step 3: Catalog assets in persona folder const assets = await this.catalogPersonaAssets(personaDirectory, configFilePath, options.catalogAssets ?? true); // Step 4: Load MCP configuration if present and requested let mcpConfig; if (options.loadMcpConfig !== false && assets.mcpConfigFile) { try { mcpConfig = await this.loadMcpConfigFile(assets.mcpConfigFile); } catch (error) { const errorMessage = `Failed to load MCP configuration: ${error instanceof Error ? error.message : String(error)}`; if (options.stopOnValidationError) { result.errors.push(errorMessage); return result; } else { result.warnings.push(errorMessage); } } } // Step 5: Perform validation if requested let validationResult = { isValid: true, errors: [], warnings: [], }; if (options.validateOnLoad !== false) { const context = { personaPath: personaDirectory, expectedPersonaName: basename(personaDirectory), checkToolAvailability: options.validationOptions?.checkToolAvailability, validateMcpConfig: options.validationOptions?.validateMcpConfig, toolDiscoveryEngine: options.toolDiscoveryEngine || this.toolDiscoveryEngine, assets, }; validationResult = await this.validator.validatePersonaConfig(personaConfig, context, { ...options.validationOptions, includeWarnings: options.includeWarnings ?? true, }); // Add validation errors and warnings to result if (!validationResult.isValid) { const validationErrors = validationResult.errors.map((e) => e.message); if (options.stopOnValidationError) { result.errors.push(...validationErrors); return result; } else { result.warnings.push(...validationErrors); } } if (options.includeWarnings && validationResult.warnings.length > 0) { result.warnings.push(...validationResult.warnings.map((w) => w.message)); } } // Step 6: Create LoadedPersona object const loadedPersona = { config: personaConfig, assets, mcpConfig, validation: validationResult, loadedAt: new Date(), sourcePath: personaDirectory, }; result.success = true; result.persona = loadedPersona; return result; } catch (error) { const errorMessage = isPersonaError(error) ? error.message : `Failed to load persona: ${error instanceof Error ? error.message : String(error)}`; result.errors.push(errorMessage); return result; } finally { result.loadTime = Date.now() - startTime; } } /** * Load a persona from a PersonaReference * * @param reference - Persona reference from discovery * @param options - Loading options * @returns Promise resolving to load result */ async loadPersonaFromReference(reference, options = {}) { if (reference.isArchive) { // TODO: Add support for archive loading in future versions return { success: false, errors: ["Archive persona loading not yet supported"], warnings: [], loadTime: 0, }; } return this.loadPersona(reference.path, options); } /** * Load multiple personas from paths * * @param paths - Array of file or directory paths * @param options - Loading options * @returns Promise resolving to batch load result */ async loadMultiplePersonas(paths, options = {}) { const startTime = Date.now(); const result = { loaded: [], failed: [], totalLoadTime: 0, stats: { attempted: paths.length, succeeded: 0, failed: 0, averageLoadTime: 0, }, }; // Load all personas concurrently const loadPromises = paths.map(async (path) => { const loadResult = await this.loadPersona(path, options); return { path, loadResult }; }); const loadResults = await Promise.allSettled(loadPromises); // Process results const loadTimes = []; loadResults.forEach((promiseResult, index) => { const path = paths[index]; if (promiseResult.status === "fulfilled") { const { loadResult } = promiseResult.value; loadTimes.push(loadResult.loadTime); if (loadResult.success && loadResult.persona) { result.loaded.push(loadResult.persona); result.stats.succeeded++; } else { result.failed.push({ path, errors: loadResult.errors, warnings: loadResult.warnings, }); result.stats.failed++; } } else { // Promise rejection result.failed.push({ path, errors: [`Load operation failed: ${promiseResult.reason}`], warnings: [], }); result.stats.failed++; } }); // Calculate statistics result.totalLoadTime = Date.now() - startTime; result.stats.averageLoadTime = loadTimes.length > 0 ? Math.round(loadTimes.reduce((a, b) => a + b, 0) / loadTimes.length) : 0; return result; } /** * Load personas from discovery results * * @param discoveredPersonas - Array of persona references * @param options - Loading options * @returns Promise resolving to batch load result */ async loadPersonasFromDiscovery(discoveredPersonas, options = {}) { const paths = discoveredPersonas.map((ref) => ref.path); return this.loadMultiplePersonas(paths, options); } /** * Discover and load all available personas * * @param discoveryConfig - Discovery configuration * @param loadOptions - Loading options * @returns Promise resolving to batch load result with discovery info */ async discoverAndLoadPersonas(discoveryConfig = {}, loadOptions = {}) { // Use provided discovery instance or create a default one const discovery = this.discovery || new PersonaDiscovery(); try { // Discover personas const discoveryResult = await discovery.discoverPersonas(discoveryConfig); // Load discovered personas const batchResult = await this.loadPersonasFromDiscovery(discoveryResult.personas, loadOptions); return { ...batchResult, discoveryErrors: discoveryResult.errors, discoveryWarnings: discoveryResult.warnings, }; } catch (error) { const errorMessage = `Discovery failed: ${error instanceof Error ? error.message : String(error)}`; return { loaded: [], failed: [], totalLoadTime: 0, stats: { attempted: 0, succeeded: 0, failed: 0, averageLoadTime: 0 }, discoveryErrors: [errorMessage], discoveryWarnings: [], }; } } /** * Validate loaded persona without reloading * * @param persona - Loaded persona to validate * @param options - Validation options * @returns Promise resolving to validation result */ async validateLoadedPersona(persona, options = {}) { const context = { personaPath: persona.sourcePath, expectedPersonaName: basename(persona.sourcePath), checkToolAvailability: options.checkToolAvailability, validateMcpConfig: options.validateMcpConfig, toolDiscoveryEngine: this.toolDiscoveryEngine, assets: persona.assets, }; return this.validator.validatePersonaConfig(persona.config, context, options); } /** * Find persona configuration file in directory */ async findPersonaConfigFile(directory) { const supportedFiles = getSupportedPersonaFiles(); for (const filename of supportedFiles) { const filePath = join(directory, filename); try { await fs.access(filePath); return filePath; } catch { // File doesn't exist, try next continue; } } throw createPersonaNotFoundError(basename(directory), [ `No persona configuration file found in "${directory}"`, ]); } /** * Load MCP configuration from file */ async loadMcpConfigFile(filePath) { try { const content = await fs.readFile(filePath, "utf-8"); const mcpConfig = JSON.parse(content); // Basic validation if (!mcpConfig.mcpServers || typeof mcpConfig.mcpServers !== "object") { throw new Error("Invalid MCP config: missing or invalid 'mcpServers' object"); } return mcpConfig; } catch (error) { throw createFileSystemError("loading MCP config", filePath, error); } } /** * Catalog all assets in persona folder */ async catalogPersonaAssets(personaDirectory, configFilePath, enableCataloging = true) { const assets = { configFile: configFilePath, }; // Check for MCP configuration file const mcpConfigPath = join(personaDirectory, "mcp.json"); try { await fs.access(mcpConfigPath); assets.mcpConfigFile = mcpConfigPath; } catch { // MCP config is optional } if (!enableCataloging) { return assets; } // Catalog additional assets try { const assetFiles = []; const entries = await fs.readdir(personaDirectory, { withFileTypes: true, }); for (const entry of entries) { if (entry.isFile()) { const filePath = join(personaDirectory, entry.name); // Skip the main config files we've already cataloged if (filePath === configFilePath || filePath === assets.mcpConfigFile) { continue; } // Skip common non-asset files const skipPatterns = [ /^\..*/, // Hidden files /readme\.md$/i, // README files /license$/i, // License files /\.log$/i, // Log files ]; const shouldSkip = skipPatterns.some((pattern) => pattern.test(entry.name)); if (!shouldSkip) { assetFiles.push(filePath); } } } if (assetFiles.length > 0) { assets.assetFiles = assetFiles; } } catch (error) { // Asset cataloging is best-effort, don't fail the load // Could emit a warning here if needed } return assets; } /** * Set tool discovery engine for validation */ setToolDiscoveryEngine(engine) { this.toolDiscoveryEngine = engine; this.validator.setToolDiscoveryEngine(engine); } /** * Set persona discovery engine */ setDiscoveryEngine(discovery) { this.discovery = discovery; } /** * Get loader statistics */ getLoaderStats() { return { hasToolDiscoveryEngine: !!this.toolDiscoveryEngine, hasDiscoveryEngine: !!this.discovery, validatorStats: this.validator.getValidationStats(), }; } } /** * Create a default persona loader instance * * @param toolDiscoveryEngine - Optional tool discovery engine * @param discovery - Optional persona discovery engine * @returns PersonaLoader instance */ export function createPersonaLoader(toolDiscoveryEngine, discovery) { return new PersonaLoader(toolDiscoveryEngine, discovery); } /** * Quick load function for simple use cases * * @param personaPath - Path to persona file or directory * @param options - Loading options * @returns Promise resolving to load result */ export async function loadPersona(personaPath, options = {}) { const loader = new PersonaLoader(options.toolDiscoveryEngine); return loader.loadPersona(personaPath, options); } /** * Batch load function for multiple personas * * @param paths - Array of persona paths * @param options - Loading options * @returns Promise resolving to batch load result */ export async function loadMultiplePersonas(paths, options = {}) { const loader = new PersonaLoader(options.toolDiscoveryEngine); return loader.loadMultiplePersonas(paths, options); } /** * Discover and load all available personas * * @param discoveryConfig - Discovery configuration * @param loadOptions - Loading options * @returns Promise resolving to batch load result with discovery info */ export async function discoverAndLoadAllPersonas(discoveryConfig = {}, loadOptions = {}) { const loader = new PersonaLoader(loadOptions.toolDiscoveryEngine); return loader.discoverAndLoadPersonas(discoveryConfig, loadOptions); } /** * Default persona loader instance */ export const defaultPersonaLoader = new PersonaLoader(); //# sourceMappingURL=loader.js.map