UNPKG

@toolprint/hypertool-mcp

Version:

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

430 lines • 17.6 kB
/** * PersonaMcpIntegration Implementation * * This module implements integration with the MCP configuration system to apply * persona-specific server configurations. When a persona is activated, its MCP * configuration is merged with the existing MCP configuration, and when deactivated, * the original configuration is restored. * * @fileoverview MCP configuration integration for persona content pack system */ import { promises as fs } from "fs"; import { createChildLogger } from "../utils/logging.js"; import { MCPConfigParser } from "../config/mcpConfigParser.js"; import { PersonaError, createFileSystemError, } from "./errors.js"; import { PersonaErrorCode } from "./types.js"; const logger = createChildLogger({ module: "persona/mcp-integration" }); /** * PersonaMcpIntegration class for handling MCP config operations * * Provides functionality to merge persona MCP configurations with existing * configurations, handle conflicts, backup and restore configurations, and * manage server connections during persona activation/deactivation. */ export class PersonaMcpIntegration { getCurrentConfig; setCurrentConfig; restartConnections; currentBackup = null; parser; defaultMergeOptions; constructor(getCurrentConfig, setCurrentConfig, restartConnections, mergeOptions) { this.getCurrentConfig = getCurrentConfig; this.setCurrentConfig = setCurrentConfig; this.restartConnections = restartConnections; this.parser = new MCPConfigParser({ validatePaths: true, allowRelativePaths: true, strict: false, }); this.defaultMergeOptions = { conflictResolution: "persona-wins", preserveBaseEnv: true, mergeEnvironment: true, ...mergeOptions, }; } /** * Apply persona MCP configuration * * Loads the persona's MCP configuration, merges it with the current configuration, * and applies the result. Creates a backup for later restoration. * * @param mcpConfigFile - Path to persona's mcp.json file * @param options - Merge options * @returns Promise resolving to merge result */ async applyPersonaConfig(mcpConfigFile, options) { try { logger.info(`Loading persona MCP configuration from: ${mcpConfigFile}`); // Load persona MCP configuration const personaConfig = await this.loadPersonaMcpConfig(mcpConfigFile); // Log the servers found in the persona config const serverNames = Object.keys(personaConfig.mcpServers); if (serverNames.length > 0) { logger.info(`Found ${serverNames.length} MCP server${serverNames.length !== 1 ? "s" : ""} in persona config: ${serverNames.join(", ")}`); } else { logger.info("No MCP servers found in persona configuration"); } // Get current MCP configuration const currentConfig = await this.getCurrentConfig(); if (!currentConfig) { logger.info("No current MCP config found, using persona config directly"); // No base config to merge, just apply the persona config await this.setCurrentConfig(personaConfig); const warnings = []; // Note: setCurrentConfig already handles connecting to the servers, // so no need to call restartConnections() here return { success: true, mergedConfig: personaConfig, conflicts: [], warnings, errors: [], stats: { baseServersCount: 0, personaServersCount: Object.keys(personaConfig.mcpServers).length, mergedServersCount: Object.keys(personaConfig.mcpServers).length, conflictsResolved: 0, }, }; } // Create backup of current configuration await this.createConfigBackup(currentConfig); // Merge configurations const mergeResult = await this.mergeConfigurations(currentConfig, personaConfig, { ...this.defaultMergeOptions, ...options }); if (!mergeResult.success || !mergeResult.mergedConfig) { // Restore backup on merge failure if (this.currentBackup) { await this.restoreOriginalConfig(); } return mergeResult; } // Apply merged configuration await this.setCurrentConfig(mergeResult.mergedConfig); // Restart connections if handler provided if (this.restartConnections) { try { await this.restartConnections(); } catch (error) { logger.warn("Failed to restart connections after applying persona config:", error); mergeResult.warnings.push(`Connection restart failed: ${error instanceof Error ? error.message : String(error)}`); } } logger.info("Successfully applied persona MCP configuration"); return mergeResult; } catch (error) { const errorMessage = `Failed to apply persona MCP config: ${error instanceof Error ? error.message : String(error)}`; logger.error(errorMessage, error); return { success: false, conflicts: [], warnings: [], errors: [errorMessage], stats: { baseServersCount: 0, personaServersCount: 0, mergedServersCount: 0, conflictsResolved: 0, }, }; } } /** * Restore original MCP configuration * * Restores the configuration that was backed up before persona activation. * * @returns Promise resolving when restoration is complete */ async restoreOriginalConfig() { if (!this.currentBackup) { logger.warn("No backup available for restoration"); return; } try { logger.debug("Restoring original MCP configuration"); // Restore the backed-up configuration await this.setCurrentConfig(this.currentBackup.originalConfig); // Restart connections if handler provided if (this.restartConnections) { try { await this.restartConnections(); } catch (error) { logger.warn("Failed to restart connections after restoring config:", error); // Don't throw here as the restore was successful } } // Clear the backup this.currentBackup = null; logger.info("Successfully restored original MCP configuration"); } catch (error) { const errorMessage = `Failed to restore original MCP config: ${error instanceof Error ? error.message : String(error)}`; logger.error(errorMessage, error); throw new PersonaError(PersonaErrorCode.MCP_CONFIG_CONFLICT, errorMessage, { recoverable: false, suggestions: [ "Check if the MCP configuration system is accessible", "Try manually restarting the MCP server", "Check system logs for additional error details", ], }); } } /** * Check if there's a backup available for restoration */ hasBackup() { return this.currentBackup !== null; } /** * Get backup information */ getBackupInfo() { return this.currentBackup; } /** * Load persona MCP configuration from file */ async loadPersonaMcpConfig(filePath) { try { const parseResult = await this.parser.parseFile(filePath); if (!parseResult.success || !parseResult.config) { const errorMessage = parseResult.error || `Parse failed: ${parseResult.validationErrors?.join(", ") || "Unknown error"}`; throw createFileSystemError("parsing persona MCP config", filePath, new Error(errorMessage)); } if (parseResult.validationErrors && parseResult.validationErrors.length > 0) { logger.warn(`Persona MCP config has warnings: ${parseResult.validationErrors.join(", ")}`); } return parseResult.config; } catch (error) { if (error instanceof PersonaError) { throw error; } throw createFileSystemError("loading persona MCP config", filePath, error); } } /** * Create backup of current configuration */ async createConfigBackup(config) { this.currentBackup = { originalConfig: JSON.parse(JSON.stringify(config)), // Deep copy backupTimestamp: new Date(), source: "persona-integration", metadata: { configHash: this.generateConfigHash(config), }, }; logger.debug("Created MCP configuration backup"); } /** * Merge base and persona MCP configurations */ async mergeConfigurations(baseConfig, personaConfig, options) { const conflicts = []; const warnings = []; const errors = []; const mergedServers = {}; let conflictsResolved = 0; try { // Start with base config servers const baseServers = { ...baseConfig.mcpServers }; const personaServers = { ...personaConfig.mcpServers }; // Copy all base servers first for (const [serverName, serverConfig] of Object.entries(baseServers)) { mergedServers[serverName] = { ...serverConfig }; } // Process persona servers for (const [serverName, personaServer] of Object.entries(personaServers)) { const baseServer = baseServers[serverName]; if (!baseServer) { // No conflict, add persona server mergedServers[serverName] = { ...personaServer }; continue; } // Conflict detected conflicts.push(`Server "${serverName}" exists in both base and persona configurations`); let resolvedServer = null; // Apply conflict resolution strategy // Custom resolver takes precedence if provided if (options.customResolver) { resolvedServer = options.customResolver(serverName, baseServer, personaServer); } else { switch (options.conflictResolution) { case "persona-wins": resolvedServer = this.mergeServerConfigs(baseServer, personaServer, options); break; case "base-wins": resolvedServer = this.mergeServerConfigs(personaServer, baseServer, options); warnings.push(`Using base config for server "${serverName}"`); break; case "user-choice": // In a real implementation, this would prompt the user // For now, default to persona-wins resolvedServer = this.mergeServerConfigs(baseServer, personaServer, options); warnings.push(`Auto-resolved conflict for server "${serverName}" (persona wins)`); break; case "error": errors.push(`Configuration conflict for server "${serverName}" - resolution required`); continue; default: resolvedServer = this.mergeServerConfigs(baseServer, personaServer, options); } } if (resolvedServer) { mergedServers[serverName] = resolvedServer; conflictsResolved++; } } const success = errors.length === 0; const mergedConfig = { mcpServers: mergedServers, }; return { success, mergedConfig: success ? mergedConfig : undefined, conflicts, warnings, errors, stats: { baseServersCount: Object.keys(baseServers).length, personaServersCount: Object.keys(personaServers).length, mergedServersCount: Object.keys(mergedServers).length, conflictsResolved, }, }; } catch (error) { const errorMessage = `Merge operation failed: ${error instanceof Error ? error.message : String(error)}`; errors.push(errorMessage); return { success: false, conflicts, warnings, errors, stats: { baseServersCount: Object.keys(baseConfig.mcpServers).length, personaServersCount: Object.keys(personaConfig.mcpServers).length, mergedServersCount: 0, conflictsResolved: 0, }, }; } } /** * Merge two server configurations */ mergeServerConfigs(baseServer, personaServer, options) { // Create a deep copy of the persona server as the base const merged = JSON.parse(JSON.stringify(personaServer)); // Handle environment variable merging if both have env if (options.preserveBaseEnv && options.mergeEnvironment && "env" in baseServer && baseServer.env && "env" in personaServer && personaServer.env) { merged.env = { ...baseServer.env, ...personaServer.env, // Persona env takes precedence }; } else if (options.preserveBaseEnv && "env" in baseServer && baseServer.env && !("env" in merged)) { // Preserve base env if persona doesn't have env merged.env = { ...baseServer.env }; } return merged; } /** * Generate a simple hash of the configuration for tracking changes */ generateConfigHash(config) { const configString = JSON.stringify(config, Object.keys(config).sort()); // Simple hash function (not cryptographically secure, but sufficient for tracking) let hash = 0; for (let i = 0; i < configString.length; i++) { const char = configString.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32-bit integer } return hash.toString(16); } /** * Validate MCP configuration format */ static validateMcpConfig(config) { if (!config || typeof config !== "object") { return false; } const mcpConfig = config; if (!mcpConfig.mcpServers || typeof mcpConfig.mcpServers !== "object") { return false; } // Additional validation could be added here return true; } /** * Create a PersonaMcpIntegration instance for personas that don't have MCP configs */ static createNullIntegration() { return new PersonaMcpIntegration(async () => null, async () => { }, undefined); } /** * Dispose of the integration and clean up resources */ dispose() { this.currentBackup = null; } } /** * Create a PersonaMcpIntegration instance with default configuration handlers */ export function createPersonaMcpIntegration(getCurrentConfig, setCurrentConfig, restartConnections, mergeOptions) { return new PersonaMcpIntegration(getCurrentConfig, setCurrentConfig, restartConnections, mergeOptions); } /** * Helper function to check if persona assets include MCP configuration */ export function personaHasMcpConfig(assets) { return Boolean(assets.mcpConfigFile); } /** * Helper function to validate persona MCP config file exists and is readable */ export async function validatePersonaMcpConfigFile(filePath) { try { await fs.access(filePath); // Try to parse it to ensure it's valid const parser = new MCPConfigParser(); const result = await parser.parseFile(filePath); if (!result.success) { return { isValid: false, error: result.error || result.validationErrors?.join(", ") || "Unknown parsing error", }; } return { isValid: true }; } catch (error) { return { isValid: false, error: `File access error: ${error instanceof Error ? error.message : String(error)}`, }; } } //# sourceMappingURL=mcp-integration.js.map