Skip to main content
Glama
by IBM
validate-config.ts19.5 kB
#!/usr/bin/env node /** * @fileoverview Standalone YAML Configuration Validation CLI Script * * This script validates YAML tool configurations against the JSON schema without * importing any server source code. It uses Ajv for JSON Schema validation. * * Usage: * npm run validate -- --tools file.yaml * npm run validate -- --tools-dir tools/ * * @module scripts/validate-config */ import { readFileSync, readdirSync, statSync } from "fs"; import { resolve, extname, relative, dirname } from "path"; import { fileURLToPath } from "url"; import { parseArgs } from "util"; import * as yaml from "js-yaml"; import Ajv from "ajv"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Path to the JSON schema const SCHEMA_PATH = resolve( __dirname, "../server/src/ibmi-mcp-server/schemas/json/sql-tools-config.json" ); interface ValidationResult { success: boolean; errors: string[]; config?: unknown; } interface FileValidationResult { filePath: string; relativePath: string; isValid: boolean; errors: string[]; processingTime: number; stats?: ConfigStats; config?: any; // Keep the parsed config for cross-reference validation } interface CrossReferenceValidationResult { isValid: boolean; errors: string[]; } interface ConfigStats { sourceCount: number; toolCount: number; toolsetCount: number; parameterCount: number; } interface ValidationReport { totalFiles: number; validFiles: number; invalidFiles: number; files: FileValidationResult[]; summary: ValidationSummary; crossReferenceErrors?: string[]; // Cross-file validation errors } interface ValidationSummary { totalSources: number; totalTools: number; totalToolsets: number; totalParameters: number; commonErrors: string[]; } /** * CLI argument parser configuration */ const ARGS_CONFIG = { tools: { type: "string" as const, short: "t", description: "Path to a single YAML file to validate", }, "tools-dir": { type: "string" as const, short: "d", description: "Path to a directory containing YAML files to validate", }, verbose: { type: "boolean" as const, short: "v", default: false, description: "Enable verbose output with detailed validation results", }, help: { type: "boolean" as const, short: "h", default: false, description: "Show this help message", }, } as const; /** * Display usage information and help text */ function showHelp(): void { console.log(` YAML Configuration Validation Tool Usage: npm run validate -- --tools <file.yaml> Validate a single YAML file npm run validate -- --tools-dir <directory> Validate all YAML files in a directory Options: -t, --tools <file> Path to a single YAML file to validate -d, --tools-dir <dir> Path to a directory containing YAML files -v, --verbose Enable verbose output with detailed validation results -h, --help Show this help message Examples: npm run validate -- --tools tools/performance.yaml npm run validate -- --tools-dir tools/ npm run validate -- --tools-dir ../tools/ --verbose Note: The "--" is required to separate npm arguments from script arguments. The script validates YAML configurations against the JSON schema located at: ${SCHEMA_PATH} `); } /** * Parse command line arguments using Node.js built-in parseArgs */ function parseCliArgs(): { tools?: string; toolsDir?: string; verbose: boolean; help: boolean; } { try { const { values } = parseArgs({ args: process.argv.slice(2), options: ARGS_CONFIG, allowPositionals: false, }); return { tools: values.tools, toolsDir: values["tools-dir"], verbose: values.verbose || false, help: values.help || false, }; } catch (error) { console.error( `❌ Invalid arguments: ${error instanceof Error ? error.message : "Unknown error"}` ); showHelp(); process.exit(1); } } /** * Load and parse the JSON schema */ function loadJsonSchema(): any { try { const schemaContent = readFileSync(SCHEMA_PATH, "utf-8"); return JSON.parse(schemaContent); } catch (error) { console.error(`❌ Failed to load JSON schema from ${SCHEMA_PATH}`); console.error( ` Error: ${error instanceof Error ? error.message : "Unknown error"}` ); process.exit(1); } } /** * Validate a parsed YAML config against the JSON schema */ function validateAgainstSchema( config: unknown, ajvInstance: any ): ValidationResult { let valid: boolean; let errors: any[] = []; try { // Validate using the schema - Ajv will follow the $ref valid = ajvInstance.validate( "https://github.com/IBM/ibmi-mcp-server.git/src/ibmi-mcp-server/schemas/json/sql-tools-config.json", config ); errors = ajvInstance.errors || []; } catch (error) { return { success: false, errors: [error instanceof Error ? error.message : "Validation error"], config, }; } if (!valid && errors.length > 0) { const formattedErrors = errors.map((err: any) => { const path = err.instancePath || "root"; const message = err.message || "validation error"; const params = err.params ? ` (${JSON.stringify(err.params)})` : ""; return `${path}: ${message}${params}`; }); return { success: false, errors: formattedErrors, config, }; } return { success: true, errors: [], config, }; } /** * Calculate statistics from a valid config */ function calculateStats(config: unknown): ConfigStats { const stats: ConfigStats = { sourceCount: 0, toolCount: 0, toolsetCount: 0, parameterCount: 0, }; if (typeof config !== "object" || config === null) { return stats; } const configObj = config as Record<string, unknown>; // Count sources if (configObj.sources && typeof configObj.sources === "object") { stats.sourceCount = Object.keys(configObj.sources).length; } // Count tools and parameters if (configObj.tools && typeof configObj.tools === "object") { const tools = configObj.tools as Record<string, unknown>; stats.toolCount = Object.keys(tools).length; // Count parameters for (const tool of Object.values(tools)) { if ( typeof tool === "object" && tool !== null && "parameters" in tool && Array.isArray(tool.parameters) ) { stats.parameterCount += tool.parameters.length; } } } // Count toolsets if (configObj.toolsets && typeof configObj.toolsets === "object") { stats.toolsetCount = Object.keys(configObj.toolsets).length; } return stats; } /** * Get all YAML files in a directory recursively */ function getYamlFilesInDirectory(dirPath: string): string[] { const yamlFiles: string[] = []; const resolvedDir = resolve(dirPath); function scanDirectory(currentDir: string): void { try { const entries = readdirSync(currentDir); for (const entry of entries) { const fullPath = resolve(currentDir, entry); const stat = statSync(fullPath); if (stat.isDirectory()) { scanDirectory(fullPath); } else if ( stat.isFile() && [".yaml", ".yml"].includes(extname(entry).toLowerCase()) ) { yamlFiles.push(fullPath); } } } catch (error) { console.warn( `⚠️ Warning: Could not scan directory ${currentDir}: ${error instanceof Error ? error.message : "Unknown error"}` ); } } scanDirectory(resolvedDir); return yamlFiles.sort(); } /** * Validate a single YAML file and return detailed results */ function validateSingleFile( filePath: string, ajv: any ): FileValidationResult { const startTime = process.hrtime.bigint(); const absolutePath = resolve(filePath); const relativePath = relative(process.cwd(), absolutePath); try { // Read and parse YAML const yamlContent = readFileSync(absolutePath, "utf-8"); const config = yaml.load(yamlContent); // Validate against schema const validationResult = validateAgainstSchema(config, ajv); const endTime = process.hrtime.bigint(); const processingTime = Number(endTime - startTime) / 1_000_000; // Convert to milliseconds const stats = validationResult.success ? calculateStats(config) : undefined; return { filePath: absolutePath, relativePath, isValid: validationResult.success, errors: validationResult.errors, processingTime, stats, config: validationResult.success ? config : undefined, }; } catch (error) { const endTime = process.hrtime.bigint(); const processingTime = Number(endTime - startTime) / 1_000_000; return { filePath: absolutePath, relativePath, isValid: false, errors: [ error instanceof Error ? error.message : "Unknown validation error", ], processingTime, }; } } /** * Generate a comprehensive validation summary */ function generateValidationSummary( results: FileValidationResult[] ): ValidationSummary { let totalSources = 0; let totalTools = 0; let totalToolsets = 0; let totalParameters = 0; const errorMap: Record<string, number> = {}; for (const result of results) { if (result.stats) { totalSources += result.stats.sourceCount; totalTools += result.stats.toolCount; totalToolsets += result.stats.toolsetCount; totalParameters += result.stats.parameterCount; } if (!result.isValid) { for (const error of result.errors) { const normalizedError = error.split(":")[0].trim(); // Get error type errorMap[normalizedError] = (errorMap[normalizedError] || 0) + 1; } } } // Get most common errors (top 5) const commonErrors = Object.entries(errorMap) .sort(([, a], [, b]) => b - a) .slice(0, 5) .map(([error, count]) => `${error} (${count} occurrences)`); return { totalSources, totalTools, totalToolsets, totalParameters, commonErrors, }; } /** * Perform cross-reference validation across all valid configurations * Validates that: * 1. All tools reference sources that exist (across all files) * 2. All toolsets reference tools that exist (across all files) */ function validateCrossReferences( validResults: FileValidationResult[] ): CrossReferenceValidationResult { const errors: string[] = []; // Aggregate all sources, tools, and toolsets from valid configs const allSources = new Set<string>(); const allTools = new Set<string>(); const allToolsets: Record<string, { tools: string[]; file: string }> = {}; // First pass: collect all sources and tools for (const result of validResults) { const config = result.config; if (!config) continue; // Collect sources if (config.sources && typeof config.sources === "object") { Object.keys(config.sources).forEach((sourceName) => allSources.add(sourceName) ); } // Collect tools if (config.tools && typeof config.tools === "object") { Object.keys(config.tools).forEach((toolName) => allTools.add(toolName)); } // Collect toolsets if (config.toolsets && typeof config.toolsets === "object") { Object.entries(config.toolsets).forEach(([toolsetName, toolset]) => { if ( typeof toolset === "object" && toolset !== null && "tools" in toolset && Array.isArray(toolset.tools) ) { allToolsets[toolsetName] = { tools: toolset.tools, file: result.relativePath, }; } }); } } // Second pass: validate tool source references for (const result of validResults) { const config = result.config; if (!config || !config.tools) continue; Object.entries(config.tools).forEach(([toolName, tool]) => { if ( typeof tool === "object" && tool !== null && "source" in tool && typeof tool.source === "string" ) { if (!allSources.has(tool.source)) { errors.push( `[${result.relativePath}] Tool '${toolName}' references unknown source '${tool.source}'. ` + `Available sources: ${allSources.size > 0 ? Array.from(allSources).join(", ") : "none"}` ); } } }); } // Third pass: validate toolset tool references Object.entries(allToolsets).forEach(([toolsetName, toolsetInfo]) => { toolsetInfo.tools.forEach((toolName) => { if (!allTools.has(toolName)) { errors.push( `[${toolsetInfo.file}] Toolset '${toolsetName}' references unknown tool '${toolName}'. ` + `Available tools: ${allTools.size > 0 ? Array.from(allTools).join(", ") : "none"}` ); } }); }); return { isValid: errors.length === 0, errors, }; } /** * Format and display validation results */ function displayResults(report: ValidationReport, verbose: boolean): void { const { totalFiles, validFiles, invalidFiles, files, summary } = report; // Header console.log("\n📋 YAML Configuration Validation Report"); console.log("═".repeat(50)); // Overall statistics const successRate = totalFiles > 0 ? ((validFiles / totalFiles) * 100).toFixed(1) : "0.0"; console.log(`\n📊 Overall Results:`); console.log(` Total files processed: ${totalFiles}`); console.log(` ✅ Valid configurations: ${validFiles}`); console.log(` ❌ Invalid configurations: ${invalidFiles}`); console.log(` 📈 Success rate: ${successRate}%`); // Configuration summary for valid files if (validFiles > 0) { console.log(`\n🔧 Configuration Summary:`); console.log(` Total sources defined: ${summary.totalSources}`); console.log(` Total tools defined: ${summary.totalTools}`); console.log(` Total toolsets defined: ${summary.totalToolsets}`); console.log(` Total parameters defined: ${summary.totalParameters}`); } // Common errors if (summary.commonErrors.length > 0) { console.log(`\n🚨 Most Common Errors:`); summary.commonErrors.forEach((error, index) => { console.log(` ${index + 1}. ${error}`); }); } // Cross-reference validation errors if (report.crossReferenceErrors && report.crossReferenceErrors.length > 0) { console.log(`\n🔗 Cross-Reference Validation Errors:`); report.crossReferenceErrors.forEach((error) => { console.log(` ❌ ${error}`); }); } // Detailed file results console.log(`\n📄 File Validation Results:`); files.forEach((fileResult) => { const status = fileResult.isValid ? "✅" : "❌"; const timeStr = fileResult.processingTime.toFixed(2); console.log(` ${status} ${fileResult.relativePath} (${timeStr}ms)`); // Show errors for invalid files if (!fileResult.isValid) { fileResult.errors.forEach((error) => { console.log(` ❌ ${error}`); }); } // Show stats for valid files if (fileResult.isValid && fileResult.stats) { const stats = fileResult.stats; console.log( ` 📊 Sources: ${stats.sourceCount}, Tools: ${stats.toolCount}, Toolsets: ${stats.toolsetCount}, Parameters: ${stats.parameterCount}` ); } // Verbose mode can be extended here for more details if (verbose && fileResult.isValid) { console.log(` ℹ️ Validation completed successfully`); } }); // Footer with recommendations console.log("\n💡 Recommendations:"); if (invalidFiles > 0) { console.log( " • Fix validation errors in invalid files before deployment" ); console.log(" • Review the JSON schema for expected structure"); } if (report.crossReferenceErrors && report.crossReferenceErrors.length > 0) { console.log( " • Fix cross-reference errors (tools referencing non-existent sources or toolsets referencing non-existent tools)" ); console.log( " • Ensure all referenced sources are defined in at least one configuration file" ); console.log( " • Ensure all tools referenced by toolsets exist in at least one configuration file" ); } if ( validFiles > 0 && (!report.crossReferenceErrors || report.crossReferenceErrors.length === 0) ) { console.log(" • Valid configurations are ready for use"); } console.log( " • Run this script regularly during development to catch issues early" ); console.log("═".repeat(50)); } /** * Main execution function */ async function main(): Promise<void> { const args = parseCliArgs(); if (args.help) { showHelp(); return; } if (!args.tools && !args.toolsDir) { console.error("❌ Error: You must specify either --tools or --tools-dir"); showHelp(); process.exit(1); } if (args.tools && args.toolsDir) { console.error( "❌ Error: Cannot specify both --tools and --tools-dir at the same time" ); process.exit(1); } console.log("🔍 Starting YAML configuration validation...\n"); try { // Load JSON schema const schema = loadJsonSchema(); const ajvConstructor: any = Ajv; const ajv = new ajvConstructor({ strict: false, allErrors: true }); ajv.addSchema(schema); let filesToValidate: string[] = []; if (args.tools) { const resolvedPath = resolve(args.tools); filesToValidate = [resolvedPath]; console.log( `📁 Validating single file: ${relative(process.cwd(), resolvedPath)}` ); } else if (args.toolsDir) { const resolvedDir = resolve(args.toolsDir); filesToValidate = getYamlFilesInDirectory(resolvedDir); console.log( `📁 Validating directory: ${relative(process.cwd(), resolvedDir)}` ); console.log(`📄 Found ${filesToValidate.length} YAML file(s)`); } if (filesToValidate.length === 0) { console.log("ℹ️ No YAML files found to validate"); return; } // Validate all files const results = filesToValidate.map((file) => validateSingleFile(file, ajv) ); // Perform cross-reference validation on valid files const validResults = results.filter((r) => r.isValid); const crossRefValidation = validateCrossReferences(validResults); // Generate report const report: ValidationReport = { totalFiles: results.length, validFiles: results.filter((r) => r.isValid).length, invalidFiles: results.filter((r) => !r.isValid).length, files: results, summary: generateValidationSummary(results), crossReferenceErrors: crossRefValidation.errors, }; // Display results displayResults(report, args.verbose); // Set appropriate exit code // Exit with error if there are invalid files OR cross-reference errors const exitCode = report.invalidFiles > 0 || !crossRefValidation.isValid ? 1 : 0; process.exit(exitCode); } catch (error) { console.error( `❌ Validation failed: ${error instanceof Error ? error.message : "Unknown error"}` ); process.exit(1); } } // Execute main function if (import.meta.url === `file://${process.argv[1]}`) { main().catch((error) => { console.error( `❌ Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}` ); process.exit(1); }); }

Latest Blog Posts

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/IBM/ibmi-mcp'

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