Skip to main content
Glama
IBM
by IBM
toolConfigBuilder.ts30.4 kB
/** * @fileoverview Tool Configuration Builder - Standardized tool configuration creation * Unifies all tool configuration creation logic into a single, consistent class * Eliminates duplicate logic between cached and regular tool creation paths * * @module src/ibmi-mcp-server/utils/yaml/toolConfigBuilder */ import { z } from "zod"; import { resolve, join } from "path"; import { existsSync } from "fs"; import { glob } from "glob"; import { ProcessedSQLTool, CachedToolConfig, ConfigSource, ConfigBuildResult, } from "./types.js"; import { SqlToolConfig, SqlToolParameter, SqlToolsConfig, StandardSqlToolOutput, ToolAnnotations, } from "@/ibmi-mcp-server/schemas/index.js"; import { ConfigParser } from "./configParser.js"; import { ErrorHandler, logger } from "@/utils/internal/index.js"; import { requestContextService, RequestContext, } from "@/utils/internal/requestContext.js"; import { JsonRpcErrorCode, McpError } from "@/types-global/errors.js"; import { SQLToolFactory } from "./toolFactory.js"; import { sqlResponseFormatter, defaultResponseFormatter, standardSqlToolOutputSchema, } from "./toolDefinitions.js"; import { config } from "@/config/index.js"; /** * Configuration merging options */ export interface ConfigMergeOptions { /** Whether to merge arrays (true) or replace them (false) */ mergeArrays?: boolean; /** Whether to allow duplicate tool names */ allowDuplicateTools?: boolean; /** Whether to allow duplicate source names */ allowDuplicateSources?: boolean; /** Whether to validate merged config */ validateMerged?: boolean; } /** * Tool Configuration Builder * Standardized tool configuration creation with consistent error handling, * schema generation, and handler creation logic */ export class ToolConfigBuilder { private static instance: ToolConfigBuilder; /** * Get the singleton instance */ static getInstance(): ToolConfigBuilder { if (!ToolConfigBuilder.instance) { ToolConfigBuilder.instance = new ToolConfigBuilder(); } return ToolConfigBuilder.instance; } /** * Generate a Zod schema from SQL parameter definitions * @param parameters - SQL parameter definitions (using standardized types) * @param toolName - Tool name for error reporting * @returns Generated Zod schema */ generateZodSchema( parameters: SqlToolParameter[], toolName: string, ): z.ZodObject<Record<string, z.ZodTypeAny>> { const schemaShape: Record<string, z.ZodTypeAny> = {}; // Process parameters for (const param of parameters) { let zodType: z.ZodTypeAny; // Generate Zod type based on parameter type switch (param.type) { case "string": { let stringType = z.string(); // Apply string-specific constraints using native Zod methods if (param.minLength !== undefined) { stringType = stringType.min( param.minLength, `Length must be >= ${param.minLength}`, ); } if (param.maxLength !== undefined) { stringType = stringType.max( param.maxLength, `Length must be <= ${param.maxLength}`, ); } if (param.pattern) { stringType = stringType.regex( new RegExp(param.pattern), `Value does not match pattern: ${param.pattern}`, ); } zodType = stringType; break; } case "integer": { let intType = z.number().int("Value must be an integer"); // Apply numeric constraints using native Zod methods if (param.min !== undefined) { intType = intType.min(param.min, `Value must be >= ${param.min}`); } if (param.max !== undefined) { intType = intType.max(param.max, `Value must be <= ${param.max}`); } zodType = intType; break; } case "float": { let floatType = z.number(); // Apply numeric constraints using native Zod methods if (param.min !== undefined) { floatType = floatType.min( param.min, `Value must be >= ${param.min}`, ); } if (param.max !== undefined) { floatType = floatType.max( param.max, `Value must be <= ${param.max}`, ); } zodType = floatType; break; } case "boolean": zodType = z.boolean(); break; case "array": { // For array parameters, create array of the specified item type let itemType: z.ZodTypeAny; if (param.itemType === "string") { itemType = z.string(); } else if (param.itemType === "integer") { itemType = z.number().int("Array items must be integers"); } else if (param.itemType === "float") { itemType = z.number(); } else if (param.itemType === "boolean") { itemType = z.boolean(); } else { itemType = z.unknown(); } let arrayType = z.array(itemType); // Apply array length constraints using native Zod methods if (param.minLength !== undefined) { arrayType = arrayType.min( param.minLength, `Array length must be >= ${param.minLength}`, ); } if (param.maxLength !== undefined) { arrayType = arrayType.max( param.maxLength, `Array length must be <= ${param.maxLength}`, ); } zodType = arrayType; break; } default: throw new McpError( JsonRpcErrorCode.InvalidParams, `Unsupported parameter type '${param.type}' for parameter '${param.name}' in tool '${toolName}'`, { toolName, parameterName: param.name, parameterType: param.type }, ); } // Handle enum constraints for all types (except boolean which is already constrained) if ( param.enum && Array.isArray(param.enum) && param.enum.length > 0 && param.type !== "boolean" ) { // For enums, we need to replace the base type with a union of literals // This properly translates to JSON Schema with "enum" keyword const enumValues = param.enum as Array<string | number>; if (enumValues.length === 1) { // Single value enum becomes a literal zodType = z.literal(enumValues[0] as string | number | boolean); } else if (enumValues.every((v) => typeof v === "string")) { // All strings: use z.enum for optimal JSON Schema generation zodType = z.enum(enumValues as [string, ...string[]]); } else { // Mixed types or numbers: use union of literals // Construct the tuple directly to satisfy TypeScript's type requirements const [first, second, ...rest] = enumValues; zodType = z.union([ z.literal(first as string | number | boolean), z.literal(second as string | number | boolean), ...rest.map((val) => z.literal(val as string | number | boolean)), ] as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]); } } // Build enhanced description with enum values for LLM clarity let finalDescription = param.description || ""; // Append enum constraint to description for LLM understanding if (param.enum && Array.isArray(param.enum) && param.enum.length > 0) { const formattedValues = param.enum .map((v) => (typeof v === "string" ? `'${v}'` : String(v))) .join(", "); const enumClause = `Must be one of: ${formattedValues}`; if (finalDescription) { // Append to existing description with proper punctuation finalDescription = finalDescription.trim(); if ( !finalDescription.endsWith(".") && !finalDescription.endsWith("?") && !finalDescription.endsWith("!") ) { finalDescription += "."; } finalDescription += ` ${enumClause}`; } else { // Use as the sole description finalDescription = enumClause; } } // Add description (now includes enum info if applicable) if (finalDescription) { zodType = zodType.describe(finalDescription); } // Add default value if provided (must be done last) if (param.default !== undefined) { zodType = zodType.default(param.default); } // Mark as optional if not required and no default provided if (param.required === false && param.default === undefined) { zodType = zodType.optional(); } schemaShape[param.name] = zodType; } return z.object(schemaShape).strict(); } /** * Filter processed tools by allowed toolsets * @param processedTools - Array of processed tools to filter * @param allowedToolsets - List of toolset names to include * @returns Filtered array containing only tools that belong to allowed toolsets */ filterToolsByToolsets( processedTools: ProcessedSQLTool[], allowedToolsets: string[], ): ProcessedSQLTool[] { if (!allowedToolsets || allowedToolsets.length === 0) { return processedTools; } return processedTools.filter( (tool) => tool.toolsets && tool.toolsets.some((toolset: string) => allowedToolsets.includes(toolset), ), ); } /** * Build a complete tool configuration * This is the single, unified method for creating all tool configurations * @param toolName - Name of the tool * @param config - Tool configuration from YAML * @param toolsets - Toolsets this tool belongs to * @param context - Request context * @returns Complete cached tool configuration */ async buildToolConfig( toolName: string, config: SqlToolConfig, toolsets: string[], context: RequestContext, ): Promise<CachedToolConfig> { const buildContext = requestContextService.createRequestContext({ parentRequestId: context.requestId, operation: "ToolConfigBuilder.buildToolConfig", toolName, }); return ErrorHandler.tryCatch( async () => { logger.debug( { ...buildContext, description: config.description, toolsets, domain: config.domain, category: config.category, }, `Building tool configuration: ${toolName}`, ); // Generate Zod schema for parameters const inputSchema = this.generateZodSchema( config.parameters || [], toolName, ); const toolLogic = this.createToolLogic( toolName, config, inputSchema, buildContext, ); const annotations = this.buildAnnotations(toolName, config, toolsets); const responseFormatter = this.getResponseFormatter(config); const toolConfig: CachedToolConfig = { name: toolName, title: this.formatToolTitle(toolName), description: config.description, inputSchema, outputSchema: standardSqlToolOutputSchema, annotations, logic: toolLogic, responseFormatter, }; logger.debug( buildContext, `Tool configuration built successfully: ${toolName}`, ); return toolConfig; }, { operation: "ToolConfigBuilder.buildToolConfig", context: buildContext, errorCode: JsonRpcErrorCode.InternalError, }, ); } /** * Create a unified tool handler function * This replaces the duplicate handler logic in yamlToolFactory * @param toolName - Name of the tool * @param config - Tool configuration * @param context - Request context * @returns Tool handler function * @private */ private createToolLogic( toolName: string, config: SqlToolConfig, inputSchema: z.ZodObject<Record<string, z.ZodTypeAny>>, context: RequestContext, ) { return async ( params: z.infer<typeof inputSchema>, requestContext: RequestContext, ): Promise<StandardSqlToolOutput> => { const executionContext = requestContextService.createRequestContext({ parentRequestId: context.requestId, operation: `ExecuteYamlTool_${toolName}`, toolName, input: params, requestContext, }); return ErrorHandler.tryCatch( async () => { if (!config.statement) { throw new McpError( JsonRpcErrorCode.InvalidParams, `Tool ${toolName} has no SQL statement defined`, { toolName }, ); } const result = await SQLToolFactory.executeStatementWithParameters( toolName, config.source, config.statement, params, config.parameters || [], executionContext, config.security, ); const simplifiedColumns = (result.columns ?? []).map( (column: unknown, index: number) => { const record = column as { name?: string; type?: string; label?: string; }; const name = record.name ?? record.label ?? `column_${index}`; return { name, type: record.type, label: record.label ?? record.name, }; }, ); return { success: true, // eslint-disable-next-line @typescript-eslint/no-explicit-any data: result.data as Record<string, any>[], metadata: { executionTime: result.executionTime, rowCount: result.rowCount, affectedRows: result.affectedRows, columns: simplifiedColumns.length > 0 ? simplifiedColumns : undefined, parameterMode: result.parameterMetadata?.mode, parameterCount: result.parameterMetadata?.parameterCount, processedParameters: result.parameterMetadata?.processedParameters, toolName, sqlStatement: config.statement, parameters: params, }, } satisfies StandardSqlToolOutput; }, { operation: `ExecuteYamlTool_${toolName}`, context: executionContext, input: params, errorCode: JsonRpcErrorCode.InternalError, }, ); }; } /** * Determines the appropriate response formatter based on tool configuration. * For SQL formatters, extracts and passes formatting configuration (tableFormat, maxDisplayRows). */ private getResponseFormatter(config: SqlToolConfig) { if (config.responseFormat === "markdown") { // Extract formatting configuration from tool config const formatterConfig = { tableFormat: config.tableFormat, maxDisplayRows: config.maxDisplayRows, }; // Return a wrapper that passes the config to sqlResponseFormatter return (result: StandardSqlToolOutput) => sqlResponseFormatter(result, formatterConfig); } return defaultResponseFormatter; } private buildAnnotations( toolName: string, config: SqlToolConfig, toolsets: string[], ): ToolAnnotations { const annotationInput: ToolAnnotations = { ...(config.annotations ?? {}), }; const legacyReadOnly = config.readOnlyHint; const legacyOpenWorld = config.openWorldHint; const legacyIdempotent = config.idempotentHint; const legacyDestructive = config.destructiveHint; if ( Array.isArray(annotationInput.toolsets) && annotationInput.toolsets.length > 0 ) { logger.warning( { toolName, providedToolsets: annotationInput.toolsets, resolvedToolsets: toolsets, }, "Tool annotations specified 'toolsets', but toolset membership is derived from YAML toolset mappings. Ignoring provided values.", ); } // Remove any externally provided toolsets to prevent divergence from configured mappings delete annotationInput.toolsets; const mergedCustomMetadata = this.mergeCustomMetadata( annotationInput.customMetadata, config.metadata, ); const resolvedAnnotations: ToolAnnotations = { ...annotationInput, title: annotationInput.title ?? this.formatToolTitle(toolName), domain: annotationInput.domain ?? config.domain, category: annotationInput.category ?? config.category, readOnlyHint: annotationInput.readOnlyHint ?? legacyReadOnly ?? config.security?.readOnly ?? true, openWorldHint: annotationInput.openWorldHint ?? legacyOpenWorld, idempotentHint: annotationInput.idempotentHint ?? legacyIdempotent, destructiveHint: annotationInput.destructiveHint ?? legacyDestructive, toolsets, }; if (mergedCustomMetadata) { resolvedAnnotations.customMetadata = mergedCustomMetadata; } return resolvedAnnotations; } private mergeCustomMetadata( annotationsMetadata: unknown, toolMetadata?: Record<string, unknown>, ): Record<string, unknown> | undefined { const candidates = [annotationsMetadata, toolMetadata].filter( (value): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value), ); if (candidates.length === 0) { return undefined; } return Object.assign({}, ...candidates); } /** * Format tool name into a human-readable title * @param toolName - Tool name * @returns Formatted title * @private */ private formatToolTitle(toolName: string): string { return toolName .split(/[_-]/) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); } // ============================================================================ // Configuration Parsing Methods (moved from YamlConfigBuilder) // ============================================================================ /** * Build configuration from multiple sources * @param sources - Array of configuration sources * @param options - Merge options * @param context - Request context * @returns Built configuration */ async buildFromSources( sources: ConfigSource[], options?: ConfigMergeOptions, context?: RequestContext, ): Promise<ConfigBuildResult> { const operationContext = context || requestContextService.createRequestContext({ operation: "ToolConfigBuilder.buildFromSources", }); return ErrorHandler.tryCatch( async () => { logger.info( operationContext, `Building YAML configuration from sources: ${sources.length} sources`, ); const filePaths = await this.resolveAllFilePaths( sources, operationContext, ); const configs = await this.loadAllConfigurations( filePaths, operationContext, ); const mergedConfig = await this.mergeConfigurations( configs, options, operationContext, ); const stats = { sourcesLoaded: filePaths.length, sourcesMerged: configs.length, toolsTotal: Object.keys(mergedConfig.tools || {}).length, toolsetsTotal: Object.keys(mergedConfig.toolsets || {}).length, sourcesTotal: Object.keys(mergedConfig.sources || {}).length, }; logger.info( { ...operationContext, ...stats, }, "YAML configuration built successfully", ); return { success: true, config: mergedConfig, stats, resolvedFilePaths: filePaths, }; }, { operation: "ToolConfigBuilder.buildFromSources", context: operationContext, errorCode: JsonRpcErrorCode.ConfigurationError, }, ); } /** * Resolve all file paths from sources * @private */ private async resolveAllFilePaths( sources: ConfigSource[], context: RequestContext, ): Promise<string[]> { const allPaths: string[] = []; for (const source of sources) { try { const paths = await this.resolveSourcePaths(source); allPaths.push(...paths); } catch (error) { if (source.required) { throw error; } logger.warning( context, `Optional source not found: ${source.path} (${source.type})`, ); } } // Remove duplicates return [...new Set(allPaths)]; } /** * Resolve paths for a single source * @private */ private async resolveSourcePaths(source: ConfigSource): Promise<string[]> { switch (source.type) { case "file": return this.resolveFilePath(source.path); case "directory": return this.resolveDirectoryPaths(source.path); case "glob": return this.resolveGlobPaths(source.path, source.baseDir); default: throw new McpError( JsonRpcErrorCode.ConfigurationError, `Unknown source type: ${(source as unknown as { type: string }).type}`, ); } } /** * Resolve a single file path * @private */ private resolveFilePath(filePath: string): string[] { const resolvedPath = resolve(filePath); if (!existsSync(resolvedPath)) { throw new McpError( JsonRpcErrorCode.ConfigurationError, `Configuration file not found: ${resolvedPath}`, ); } return [resolvedPath]; } /** * Resolve directory paths * @private */ private resolveDirectoryPaths(directoryPath: string): string[] { const resolvedDir = resolve(directoryPath); if (!existsSync(resolvedDir)) { throw new McpError( JsonRpcErrorCode.ConfigurationError, `Configuration directory not found: ${resolvedDir}`, ); } const pattern = join(resolvedDir, "**/*.{yaml,yml}"); return glob.sync(pattern, { absolute: true }); } /** * Resolve glob paths * @private */ private resolveGlobPaths(pattern: string, baseDir?: string): string[] { const searchPattern = baseDir ? join(baseDir, pattern) : pattern; const paths = glob.sync(searchPattern, { absolute: true }); if (paths.length === 0) { throw new McpError( JsonRpcErrorCode.ConfigurationError, `No files found matching pattern: ${searchPattern}`, ); } return paths; } /** * Load all configurations from file paths * @private */ private async loadAllConfigurations( filePaths: string[], context: RequestContext, ): Promise<SqlToolsConfig[]> { const configs: SqlToolsConfig[] = []; for (const filePath of filePaths) { try { logger.debug(context, `Loading configuration from: ${filePath}`); const result = await ConfigParser.parseYamlFile(filePath, context); if (result.success && result.config) { configs.push(result.config); } else { logger.error( context, `Failed to load configuration from: ${filePath}`, ); } } catch (error) { logger.error( context, `Error loading configuration from: ${filePath}: ${error instanceof Error ? error.message : String(error)}`, ); throw error; } } return configs; } /** * Merge multiple configurations * Uses environment-configured merge options from config.yamlMergeOptions as defaults * @private */ private async mergeConfigurations( configs: SqlToolsConfig[], options?: ConfigMergeOptions, context?: RequestContext, ): Promise<SqlToolsConfig> { if (configs.length === 0) { throw new McpError( JsonRpcErrorCode.ConfigurationError, "No valid configurations to merge", ); } if (configs.length === 1) { return configs[0]!; } // Use environment-configured merge options as defaults, allow explicit overrides const mergeOptions: ConfigMergeOptions = { ...config.yamlMergeOptions, ...options, }; logger.debug( context || {}, `Merging ${configs.length} configurations with options: ${JSON.stringify(mergeOptions)}`, ); const mergedConfig: SqlToolsConfig = { sources: {}, tools: {}, toolsets: {}, metadata: {}, }; // Merge each configuration for (const configToMerge of configs) { await this.mergeIntoTarget( mergedConfig, configToMerge, mergeOptions, context, ); } // Validate merged configuration if requested if (mergeOptions.validateMerged) { await this.validateMergedConfiguration(mergedConfig, context); } return mergedConfig; } /** * Merge a configuration into the target * @private */ private async mergeIntoTarget( target: SqlToolsConfig, source: SqlToolsConfig, options: ConfigMergeOptions, context?: RequestContext, ): Promise<void> { // Merge sources if (source.sources) { if (!target.sources) { target.sources = {}; } for (const [sourceName, sourceConfig] of Object.entries(source.sources)) { if (target.sources[sourceName]) { if (!options.allowDuplicateSources) { throw new McpError( JsonRpcErrorCode.ConfigurationError, `Duplicate source name: ${sourceName}. To allow duplicate source names, set YAML_ALLOW_DUPLICATE_SOURCES=true in server .env`, ); } logger.warning( context || {}, `Overriding existing source: ${sourceName}`, ); } target.sources[sourceName] = sourceConfig; } } // Merge tools if (source.tools) { if (!target.tools) { target.tools = {}; } for (const [toolName, toolConfig] of Object.entries(source.tools)) { if (target.tools[toolName]) { if (!options.allowDuplicateTools) { throw new McpError( JsonRpcErrorCode.ConfigurationError, `Duplicate tool name: ${toolName}`, ); } logger.warning( context || {}, `Overriding existing tool: ${toolName}`, ); } target.tools[toolName] = toolConfig; } } // Merge toolsets if (source.toolsets) { if (!target.toolsets) { target.toolsets = {}; } for (const [toolsetName, toolsetConfig] of Object.entries( source.toolsets, )) { if (target.toolsets[toolsetName]) { if (options.mergeArrays) { // Merge tool arrays target.toolsets[toolsetName].tools = [ ...target.toolsets[toolsetName].tools, ...toolsetConfig.tools, ]; } else { // Replace with new toolset target.toolsets[toolsetName] = toolsetConfig; } } else { target.toolsets[toolsetName] = toolsetConfig; } } } // Merge metadata if (source.metadata) { if (!target.metadata) { target.metadata = {}; } target.metadata = { ...target.metadata, ...source.metadata }; } } /** * Validate the merged configuration * @private */ private async validateMergedConfiguration( config: SqlToolsConfig, context?: RequestContext, ): Promise<void> { // Validate that all tool sources exist (only if both sections exist) if (config.tools && config.sources) { for (const [toolName, toolConfig] of Object.entries(config.tools)) { if (!config.sources[toolConfig.source]) { throw new McpError( JsonRpcErrorCode.ConfigurationError, `Tool '${toolName}' references non-existent source '${toolConfig.source}'`, ); } } } // Validate that all toolset tools exist (only if both sections exist) if (config.toolsets && config.tools) { for (const [toolsetName, toolsetConfig] of Object.entries( config.toolsets, )) { for (const toolName of toolsetConfig.tools) { if (!config.tools[toolName]) { throw new McpError( JsonRpcErrorCode.ConfigurationError, `Toolset '${toolsetName}' references non-existent tool '${toolName}'`, ); } } } } logger.debug(context || {}, "Merged configuration validated successfully"); } // Static factory methods for convenience static async fromFile( filePath: string, context?: RequestContext, ): Promise<ConfigBuildResult> { const builder = ToolConfigBuilder.getInstance(); return builder.buildFromSources( [{ type: "file", path: filePath, required: true }], undefined, context, ); } static async fromFiles( filePaths: string[], context?: RequestContext, ): Promise<ConfigBuildResult> { const builder = ToolConfigBuilder.getInstance(); const sources = filePaths.map((path) => ({ type: "file" as const, path, required: true, })); return builder.buildFromSources(sources, undefined, context); } static async fromDirectory( directoryPath: string, context?: RequestContext, ): Promise<ConfigBuildResult> { const builder = ToolConfigBuilder.getInstance(); return builder.buildFromSources( [{ type: "directory", path: directoryPath, required: true }], undefined, context, ); } static async fromGlob( pattern: string, baseDir?: string, context?: RequestContext, ): Promise<ConfigBuildResult> { const builder = ToolConfigBuilder.getInstance(); return builder.buildFromSources( [{ type: "glob", path: pattern, baseDir, required: true }], undefined, context, ); } }

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