Skip to main content
Glama

hypertool-mcp

manager.tsโ€ข24.2 kB
/** * Backup and restore manager for MCP configurations */ import { promises as fs } from "fs"; import { vol } from "memfs"; import { isTestMode } from "../../config/environment.js"; import { join, basename } from "path"; import { homedir, platform, arch } from "os"; import * as tar from "tar"; import * as yaml from "yaml"; import { BackupMetadata, ApplicationDefinition, BackupResult, RestoreResult, DeleteResult, BackupListItem, } from "../types/index.js"; import { AppRegistry } from "../apps/registry.js"; import { TransformerRegistry } from "../transformers/base.js"; import { getCompositeDatabaseService } from "../../db/compositeDatabaseService.js"; import { ServerConfigRecord, ServerConfigGroup, IConfigSource, } from "../../db/interfaces.js"; import { createChildLogger } from "../../utils/logging.js"; const logger = createChildLogger({ module: "BackupManager" }); export class BackupManager { private basePath: string; private backupDir: string; private registry: AppRegistry; private fs: typeof fs; constructor(basePath: string = join(homedir(), ".toolprint/hypertool-mcp")) { this.basePath = basePath; this.backupDir = join(basePath, "backups"); this.registry = new AppRegistry(basePath); // Use memfs in test mode, real fs in production this.fs = isTestMode() ? (vol.promises as any as typeof fs) : fs; } /** * Get current platform (for testing support) */ private getCurrentPlatform(): NodeJS.Platform { // In test mode, check for simulated platform if (isTestMode()) { const testPlatform = (global as any).__TEST_PLATFORM__; if (testPlatform) { return testPlatform; } } return platform() as NodeJS.Platform; } /** * Copy directory recursively (for test mode) */ private async copyDirectory(src: string, dest: string): Promise<void> { await this.fs.mkdir(dest, { recursive: true }); const entries = await this.fs.readdir(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = join(src, entry.name); const destPath = join(dest, entry.name); if (entry.isDirectory()) { await this.copyDirectory(srcPath, destPath); } else { const content = await this.fs.readFile(srcPath); await this.fs.writeFile(destPath, content); } } } /** * Get current working directory (for testing support) */ private getCurrentWorkingDirectory(): string { // In test mode, use test base path as working directory to avoid contamination if (isTestMode()) { return this.basePath; } return process.cwd(); } /** * Create a comprehensive backup of all MCP configurations */ async createBackup(): Promise<BackupResult> { try { // Ensure backup directory exists await this.fs.mkdir(this.backupDir, { recursive: true }); // Generate backup filename with ISO timestamp const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const backupName = `app_backup_${timestamp}`; const backupPath = join(this.backupDir, `${backupName}.tgz`); // Create temporary directory for backup contents const tempDir = join(this.backupDir, "temp", backupName); await this.fs.mkdir(join(tempDir, "config"), { recursive: true }); await this.fs.mkdir(join(tempDir, "database"), { recursive: true }); // Get all enabled applications const apps = await this.registry.getEnabledApplications(); const metadata: BackupMetadata = { version: "1.0.0", timestamp: new Date().toISOString(), hypertool_version: await this.getHypertoolVersion(), applications: {}, total_servers: 0, system_info: { platform: this.getCurrentPlatform(), arch: arch(), node_version: process.version, }, }; // Backup each application's configuration for (const [appId, app] of Object.entries(apps)) { try { const backupResult = await this.backupApplication( appId, app, tempDir ); if (backupResult) { metadata.applications[appId] = backupResult; metadata.total_servers += backupResult.servers_count; } } catch (error) { logger.warn(`Failed to backup ${appId}`, { appId, error }); } } // Export database contents if NeDB is enabled if (false) { const dbExportResult = await this.exportDatabase(tempDir); if (dbExportResult) { metadata.database = dbExportResult; } } // Write metadata const metadataPath = join(tempDir, "metadata.yaml"); await this.fs.writeFile(metadataPath, yaml.stringify(metadata), "utf-8"); // Create tar.gz archive if (isTestMode()) { // In test mode, create a directory at the backup path // We keep the .tgz extension for consistency await this.fs.mkdir(backupPath, { recursive: true }); // Copy the entire temp directory structure to the backup location await this.copyDirectory(tempDir, backupPath); } else { await tar.create( { gzip: true, file: backupPath, cwd: join(this.backupDir, "temp"), }, [backupName] ); } // Clean up temp directory await this.fs.rm(tempDir, { recursive: true, force: true }); await this.fs .rm(join(this.backupDir, "temp"), { recursive: true, force: true }) .catch(() => {}); // Also write metadata file alongside the tar.gz for easier listing const metadataFilePath = backupPath.replace(".tgz", ".yaml"); await this.fs.writeFile( metadataFilePath, yaml.stringify(metadata), "utf-8" ); return { success: true, backupId: backupName, backupPath, metadata, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Unknown error occurred during backup", }; } } /** * Backup a single application's configuration */ private async backupApplication( appId: string, app: ApplicationDefinition, tempDir: string ): Promise<{ source_path: string; format: string; servers_count: number; } | null> { // Get platform-specific config const platformConfig = this.registry.getPlatformConfig(app); if (!platformConfig) { return null; } // Resolve the configuration path const configPath = this.registry.resolvePath(platformConfig.configPath); // Check if it's a project-local app (skip if not in a project) if (app.detection.type === "project-local") { const projectConfigPath = join( this.getCurrentWorkingDirectory(), basename(configPath) ); try { await this.fs.access(projectConfigPath); // Use the project-local path instead const content = await this.fs.readFile(projectConfigPath, "utf-8"); const appDir = join(tempDir, "config", appId); await this.fs.mkdir(appDir, { recursive: true }); await this.fs.writeFile( join(appDir, basename(projectConfigPath)), content, "utf-8" ); // Validate JSON and count servers let serverCount = 0; try { const config = JSON.parse(content); const transformer = TransformerRegistry.getTransformer( platformConfig.format ); const standardConfig = transformer.toStandard(config); serverCount = Object.keys(standardConfig.mcpServers || {}).length; } catch { // Skip corrupted project-local configurations return null; } return { source_path: projectConfigPath, format: platformConfig.format, servers_count: serverCount, }; } catch { // Skip if not in a project with config return null; } } // Check if configuration file exists try { await this.fs.access(configPath); } catch { return null; } // Read and backup the configuration const content = await this.fs.readFile(configPath, "utf-8"); // Validate JSON before proceeding let serverCount = 0; try { const config = JSON.parse(content); const transformer = TransformerRegistry.getTransformer( platformConfig.format ); const standardConfig = transformer.toStandard(config); serverCount = Object.keys(standardConfig.mcpServers || {}).length; } catch { // Skip corrupted configurations return null; } const appDir = join(tempDir, "config", appId); await this.fs.mkdir(appDir, { recursive: true }); // Save with original filename const filename = basename(configPath); await this.fs.writeFile(join(appDir, filename), content, "utf-8"); return { source_path: configPath, format: platformConfig.format, servers_count: serverCount, }; } /** * List available backups */ async listBackups(): Promise<BackupListItem[]> { try { const files = await this.fs.readdir(this.backupDir); const backups: BackupListItem[] = []; for (const file of files) { // In test mode, look for directories with .tgz extension // In production mode, look for .tgz files if (!file.endsWith(".tgz")) continue; const backupPath = join(this.backupDir, file); try { // Check if backup exists (file or directory) await this.fs.access(backupPath); let metadata: BackupMetadata; if (isTestMode()) { // In test mode, read metadata from directory const metadataPath = join(backupPath, "metadata.yaml"); try { const yamlContent = await this.fs.readFile(metadataPath, "utf-8"); metadata = yaml.parse(yamlContent); } catch { // Skip if metadata not found continue; } } else { // In production mode, try .yaml file first, then extract from tar const metadataPath = backupPath.replace(".tgz", ".yaml"); try { const yamlContent = await this.fs.readFile(metadataPath, "utf-8"); metadata = yaml.parse(yamlContent); } catch { // Fall back to extracting from tar file metadata = await this.extractMetadata(backupPath); } } const backupId = file.replace(".tgz", ""); backups.push({ id: backupId, timestamp: metadata.timestamp, metadata, path: backupPath, }); } catch { // Skip invalid backups } } // Sort by timestamp (newest first) backups.sort( (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() ); return backups; } catch { return []; } } /** * Get a specific backup by ID */ async getBackup(backupId: string): Promise<BackupListItem | null> { try { const backupPath = join(this.backupDir, `${backupId}.tgz`); // Check if backup exists (file or directory) try { await this.fs.access(backupPath); } catch { return null; } let metadata: BackupMetadata; if (isTestMode()) { // In test mode, read metadata from directory const metadataPath = join(backupPath, "metadata.yaml"); try { const yamlContent = await this.fs.readFile(metadataPath, "utf-8"); metadata = yaml.parse(yamlContent); } catch { // No metadata found return null; } } else { // In production mode, try .yaml file first, then extract from tar const metadataPath = backupPath.replace(".tgz", ".yaml"); try { const yamlContent = await this.fs.readFile(metadataPath, "utf-8"); metadata = yaml.parse(yamlContent); } catch { // Fall back to extracting from tar file metadata = await this.extractMetadata(backupPath); } } return { id: backupId, timestamp: metadata.timestamp, metadata, path: backupPath, }; } catch { return null; } } /** * Extract metadata from a backup without full extraction */ private async extractMetadata(backupPath: string): Promise<BackupMetadata> { return new Promise((resolve, reject) => { let metadata: BackupMetadata | null = null; tar .list({ file: backupPath, onentry: (entry) => { if (entry.path.endsWith("metadata.yaml")) { let content = ""; entry.on("data", (chunk) => { content += chunk.toString(); }); entry.on("end", () => { try { metadata = yaml.parse(content); } catch (error) { reject(error); } }); } else { entry.resume(); // Skip other files } }, }) .then(() => { if (metadata) { resolve(metadata); } else { reject(new Error("No metadata found in backup")); } }) .catch(reject); }); } /** * Restore configurations from a backup */ async restoreBackup( backupId: string, options?: { applications?: string[] } ): Promise<RestoreResult> { try { const backupPath = join(this.backupDir, `${backupId}.tgz`); // Check if backup exists try { await this.fs.access(backupPath); } catch { return { success: false, restored: [], failed: [], error: `Backup not found: ${backupId}`, }; } // Create temp directory for extraction const tempDir = join(this.backupDir, "temp", "restore"); await this.fs.mkdir(tempDir, { recursive: true }); const restored: string[] = []; const failed: string[] = []; try { let extractedDir: string; let metadataPath: string; let configDir: string; if (isTestMode()) { // In test mode, the backup is already a directory extractedDir = backupPath; metadataPath = join(extractedDir, "metadata.yaml"); configDir = join(extractedDir, "config"); } else { // Extract backup await tar.extract({ file: backupPath, cwd: tempDir, }); // Find the extracted directory const dirs = await this.fs.readdir(tempDir); if (dirs.length !== 1) { throw new Error("Invalid backup structure"); } extractedDir = join(tempDir, dirs[0]); metadataPath = join(extractedDir, "metadata.yaml"); configDir = join(extractedDir, "config"); } // Read metadata const metadataContent = await this.fs.readFile(metadataPath, "utf-8"); const metadata: BackupMetadata = yaml.parse(metadataContent); // Filter applications if specified const appsToRestore = options?.applications ? Object.entries(metadata.applications).filter(([appId]) => options.applications!.includes(appId) ) : Object.entries(metadata.applications); // Restore each application for (const [appId, appBackup] of appsToRestore) { try { await this.restoreApplication(appId, appBackup, configDir); restored.push(appId); } catch (error) { logger.warn(`Failed to restore ${appId}`, { appId, error }); failed.push(appId); } } // Restore database if present and NeDB is enabled if (false) { const dbDir = join(extractedDir, "database"); try { await this.fs.access(dbDir); await this.restoreDatabase(dbDir); } catch (error) { // Database directory might not exist in older backups logger.warn("No database backup found or restore failed", { error, }); } } return { success: true, restored, failed, }; } finally { // Clean up temp directory (not needed in test mode) if (!isTestMode()) { await this.fs.rm(tempDir, { recursive: true, force: true }); } } } catch (error) { return { success: false, restored: [], failed: [], error: error instanceof Error ? error.message : "Unknown error occurred during restore", }; } } /** * Delete a backup by ID */ async deleteBackup(backupId: string): Promise<DeleteResult> { try { const backupPath = join(this.backupDir, `${backupId}.tgz`); // Check if backup exists try { await this.fs.access(backupPath); } catch { return { success: false, error: `Backup not found: ${backupId}`, }; } if (isTestMode()) { // In test mode, delete directory await this.fs.rm(backupPath, { recursive: true, force: true }); } else { // In production mode, delete tar file await this.fs.unlink(backupPath); // Delete metadata file if it exists const metadataPath = backupPath.replace(".tgz", ".yaml"); try { await this.fs.unlink(metadataPath); } catch { // Ignore if metadata file doesn't exist } } return { success: true, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Unknown error occurred during deletion", }; } } /** * Restore a single application's configuration */ private async restoreApplication( appId: string, appBackup: BackupMetadata["applications"][string], configDir: string ): Promise<void> { const app = await this.registry.getApplication(appId); if (!app) { throw new Error(`Application ${appId} not found in registry`); } // Check if the application is actually installed before restoring const isInstalled = await this.registry.isApplicationInstalled(app); if (!isInstalled) { throw new Error(`Application ${appId} is not installed`); } const appConfigDir = join(configDir, appId); const files = await this.fs.readdir(appConfigDir); if (files.length === 0) { return; } // Read the backed up configuration const backupFile = join(appConfigDir, files[0]); const content = await this.fs.readFile(backupFile, "utf-8"); // Write to the original location const targetPath = appBackup.source_path; // Ensure directory exists const targetDir = join(targetPath, ".."); await this.fs.mkdir(targetDir, { recursive: true }); // Write the configuration await this.fs.writeFile(targetPath, content, "utf-8"); } /** * Get HyperTool version from package.json */ private async getHypertoolVersion(): Promise<string> { try { // Try to find package.json const possiblePaths = [ join(this.basePath, "../../package.json"), join(this.getCurrentWorkingDirectory(), "package.json"), join(__dirname, "../../../package.json"), ]; for (const path of possiblePaths) { try { const content = await this.fs.readFile(path, "utf-8"); const pkg = JSON.parse(content); if (pkg.name === "@toolprint/hypertool-mcp") { return pkg.version || "0.0.0"; } } catch { // Try next path } } } catch { // Ignore errors } return "0.0.0"; } /** * Export database contents for backup */ private async exportDatabase(tempDir: string): Promise<{ servers_count: number; groups_count: number; sources_count: number; export_files: string[]; }> { try { const dbService = getCompositeDatabaseService(); await dbService.init(); const dbDir = join(tempDir, "database"); const exportFiles: string[] = []; // Export servers const servers = await dbService.servers.findAll(); const serversPath = join(dbDir, "servers.json"); await this.fs.writeFile( serversPath, JSON.stringify(servers, null, 2), "utf-8" ); exportFiles.push("servers.json"); // Export groups const groups = await dbService.groups.findAll(); const groupsPath = join(dbDir, "groups.json"); await this.fs.writeFile( groupsPath, JSON.stringify(groups, null, 2), "utf-8" ); exportFiles.push("groups.json"); // Export config sources const sources = await dbService.configSources.findAll(); const sourcesPath = join(dbDir, "sources.json"); await this.fs.writeFile( sourcesPath, JSON.stringify(sources, null, 2), "utf-8" ); exportFiles.push("sources.json"); return { servers_count: servers.length, groups_count: groups.length, sources_count: sources.length, export_files: exportFiles, }; } catch (error) { logger.warn("Failed to export database", { error }); return { servers_count: 0, groups_count: 0, sources_count: 0, export_files: [], }; } } /** * Restore database from backup */ private async restoreDatabase(dbDir: string): Promise<void> { const dbService = getCompositeDatabaseService(); await dbService.init(); // Restore servers const serversPath = join(dbDir, "servers.json"); try { const serversContent = await this.fs.readFile(serversPath, "utf-8"); const servers: ServerConfigRecord[] = JSON.parse(serversContent); // Clear existing servers and add from backup const existingServers = await dbService.servers.findAll(); for (const server of existingServers) { await dbService.servers.delete(server.id); } for (const server of servers) { const { id, ...serverData } = server; await dbService.servers.add(serverData); } } catch (error) { logger.warn("Failed to restore servers", { error }); } // Restore groups const groupsPath = join(dbDir, "groups.json"); try { const groupsContent = await this.fs.readFile(groupsPath, "utf-8"); const groups: ServerConfigGroup[] = JSON.parse(groupsContent); // Clear existing groups and add from backup const existingGroups = await dbService.groups.findAll(); for (const group of existingGroups) { await dbService.groups.delete(group.id); } for (const group of groups) { const { id, ...groupData } = group; await dbService.groups.add(groupData); } } catch (error) { logger.warn("Failed to restore groups", { error }); } // Restore config sources const sourcesPath = join(dbDir, "sources.json"); try { const sourcesContent = await this.fs.readFile(sourcesPath, "utf-8"); const sources: IConfigSource[] = JSON.parse(sourcesContent); // Clear existing sources and add from backup const existingSources = await dbService.configSources.findAll(); for (const source of existingSources) { await dbService.configSources.delete(source.id); } for (const source of sources) { const { id, ...sourceData } = source; await dbService.configSources.add(sourceData); } } catch (error) { logger.warn("Failed to restore config sources", { error }); } } }

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/toolprint/hypertool-mcp'

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