UNPKG

@agentdesk/workflows-mcp

Version:

MCP workflow orchestration tool with presets for thinking, coding and more

772 lines (688 loc) 23.2 kB
import * as fs from "fs"; import * as path from "path"; import * as yaml from "js-yaml"; import { fileURLToPath } from "url"; import { dirname } from "path"; import { z } from "zod"; // In ES modules, __dirname is not available directly const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** * Represents a parameter for a tool * * @interface ParameterConfig */ export interface ParameterConfig { /** Type of the parameter */ type: "string" | "number" | "boolean" | "array" | "object" | "enum"; /** Description of what the parameter does */ description?: string; /** Whether the parameter is required */ required?: boolean; /** Default value for the parameter */ default?: any; /** Possible values for enum type parameters */ enum?: (string | number)[]; /** For array types, defines the type of items in the array */ items?: ParameterConfig; /** For object types, defines the properties of the object */ properties?: Record<string, ParameterConfig>; } /** * Represents a tool that can be used in a prompt * * @interface ToolConfig */ export interface ToolConfig { /** Name of the tool */ name: string; /** Description of what the tool does */ description?: string; /** Parameters that the tool accepts */ parameters?: Record<string, ParameterConfig>; } /** * Configuration for a specific prompt * * @interface PromptConfig */ export interface PromptConfig { /** If provided, completely replaces the default prompt */ prompt?: string; /** Additional context to append to the prompt (either default or custom) */ context?: string; /** Available tools for this prompt */ tools?: ToolConfig[]; /** Whether tools should be executed sequentially or situationally */ toolMode?: "sequential" | "situational"; /** Description for the tool (used as second parameter in server.tool) */ description?: string; /** Whether this tool is disabled */ disabled?: boolean; /** Optional name override for the registered tool (default is the config key) */ name?: string; /** Parameters that the tool accepts */ parameters?: Record<string, ParameterConfig>; } /** * Main configuration interface for all developer tools * All tool configurations are dynamically loaded from YAML files in the presets directory * * @interface DevToolsConfig */ export interface DevToolsConfig { /** * Dynamic mapping of tool names to their configurations * Tool names are determined by the keys in the YAML preset files */ [key: string]: PromptConfig | undefined; } // Default empty configuration const defaultConfig: DevToolsConfig = {}; /** * Merges two config objects, with the second one having precedence * * @param {DevToolsConfig} target - The target config to merge into * @param {DevToolsConfig} source - The source config to merge from (has precedence over target) * @returns {DevToolsConfig} The merged configuration object */ export function mergeConfigs( target: DevToolsConfig, source: DevToolsConfig ): DevToolsConfig { Object.entries(source).forEach(([key, value]) => { if (target[key]) { // If the property already exists, merge with the existing one target[key] = { ...target[key], ...value, // Special handling for tools array - concatenate rather than replace tools: mergeTools(target[key]?.tools, value?.tools), }; } else { // Otherwise, just set it target[key] = value; } }); return target; } /** * Helper function to merge tools arrays from two configs * * @param {ToolConfig[] | undefined} targetTools - The target tools array * @param {ToolConfig[] | undefined} sourceTools - The source tools array to merge * @returns {ToolConfig[] | undefined} The merged tools array or undefined if both inputs are undefined */ function mergeTools( targetTools?: ToolConfig[], sourceTools?: ToolConfig[] ): ToolConfig[] | undefined { if (!targetTools && !sourceTools) { return undefined; } if (!targetTools) { return sourceTools; } if (!sourceTools) { return targetTools; } return [...targetTools, ...sourceTools]; } /** * Loads preset configuration from a YAML file * * @param {string} filePath - Path to the preset YAML file * @returns {DevToolsConfig} The loaded configuration or empty object on error */ function loadPresetConfig(filePath: string): DevToolsConfig { try { const content = fs.readFileSync(filePath, "utf-8"); const config = yaml.load(content) as DevToolsConfig; if (typeof config !== "object") { console.error( `Preset config in ${filePath} must be an object, returning empty config` ); return {}; } return config; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); console.error( `Error loading preset config from ${filePath}: ${errorMessage}` ); return {}; } } /** * Lists all available preset names by scanning the presets directory * * @returns {string[]} Array of available preset names (without file extensions) */ export function listAvailablePresets(): string[] { try { const presetsDir = path.join(__dirname, "presets"); if (!fs.existsSync(presetsDir)) { console.error(`Presets directory not found at ${presetsDir}`); return []; } return fs .readdirSync(presetsDir) .filter((file) => file.endsWith(".yaml") || file.endsWith(".yml")) .map((file) => path.basename(file, path.extname(file))); } catch (error) { console.error("Error listing available presets:", error); return []; } } /** * Loads configuration from preset names * * @param {string[]} presets - Array of preset names to load * @returns {DevToolsConfig} The merged preset configuration */ export function loadPresetConfigs(presets: string[]): DevToolsConfig { const mergedConfig: DevToolsConfig = {}; const availablePresets = listAvailablePresets(); for (const preset of presets) { try { // If preset is empty, skip if (!preset) { console.error("Empty preset name provided, skipping"); continue; } // Check if preset exists if (!availablePresets.includes(preset)) { console.error(`Preset "${preset}" not found, skipping`); continue; } // Load preset from the internal presets directory const presetPath = path.join(__dirname, "presets", `${preset}.yaml`); const presetConfig = loadPresetConfig(presetPath); mergeConfigs(mergedConfig, presetConfig); } catch (error) { console.error(`Error loading preset "${preset}": ${error}`); } } return mergedConfig; } /** * Loads configuration from all YAML files in the specified directory * or returns default config if directory not found or empty * * @param {string} [directoryPath] - Path to the directory containing configuration YAML files * @returns {Promise<DevToolsConfig>} Promise resolving to the loaded and merged configuration */ export async function loadConfig( directoryPath?: string ): Promise<DevToolsConfig> { if (!directoryPath) { console.error( "No config directory path provided, using default configuration" ); return defaultConfig; } try { // Resolve absolute path const absolutePath = path.resolve(directoryPath); // Check if directory exists and is a directory if ( !fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isDirectory() ) { console.error( `Config directory not found or is not a directory at ${absolutePath}, using default configuration` ); return defaultConfig; } // Check if directory name is either .workflows or .mcp-workflows const validDirNames = [".workflows", ".mcp-workflows"]; const dirName = path.basename(absolutePath); if (!validDirNames.includes(dirName)) { console.error( `Config directory must be named either .workflows or .mcp-workflows, found ${dirName}, using default configuration` ); return defaultConfig; } // Read all YAML files in the directory const files = fs .readdirSync(absolutePath) .filter( (file) => file.toLowerCase().endsWith(".yaml") || file.toLowerCase().endsWith(".yml") ); if (files.length === 0) { console.error( `No YAML files found in ${absolutePath}, using default configuration` ); return defaultConfig; } // Merge all configurations const mergedConfig: DevToolsConfig = {}; for (const file of files) { const filePath = path.join(absolutePath, file); console.error(`Loading config from: ${filePath}`); try { const content = fs.readFileSync(filePath, "utf-8"); const fileConfig = yaml.load(content) as DevToolsConfig; if (typeof fileConfig !== "object") { console.error(`Config in ${filePath} must be an object, skipping`); continue; } // Merge this file's config into the overall config mergeConfigs(mergedConfig, fileConfig); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); console.error( `Error loading config from ${filePath}: ${errorMessage}, skipping` ); } } return mergedConfig; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Error loading configs from directory: ${errorMessage}`); return defaultConfig; } } /** * Synchronous version of loadConfig for easier testing * * @param {string} [directoryPath] - Path to the directory containing configuration YAML files * @returns {DevToolsConfig} The loaded and merged configuration */ export function loadConfigSync(directoryPath?: string): DevToolsConfig { if (!directoryPath) { console.error( "No config directory path provided, using default configuration" ); return defaultConfig; } try { // Resolve absolute path const absolutePath = path.resolve(directoryPath); // Check if directory exists and is a directory if ( !fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isDirectory() ) { console.error( `Config directory not found or is not a directory at ${absolutePath}, using default configuration` ); return defaultConfig; } // Check if directory name is either .workflows or .mcp-workflows const validDirNames = [".workflows", ".mcp-workflows"]; const dirName = path.basename(absolutePath); if (!validDirNames.includes(dirName)) { console.error( `Config directory must be named either .workflows or .mcp-workflows, found ${dirName}, using default configuration` ); return defaultConfig; } // Read all YAML files in the directory const files = fs .readdirSync(absolutePath) .filter( (file) => file.toLowerCase().endsWith(".yaml") || file.toLowerCase().endsWith(".yml") ); if (files.length === 0) { console.error( `No YAML files found in ${absolutePath}, using default configuration` ); return defaultConfig; } // Merge all configurations const mergedConfig: DevToolsConfig = {}; for (const file of files) { const filePath = path.join(absolutePath, file); console.error(`Loading config from: ${filePath}`); try { const content = fs.readFileSync(filePath, "utf-8"); const fileConfig = yaml.load(content) as DevToolsConfig; if (typeof fileConfig !== "object") { console.error(`Config in ${filePath} must be an object, skipping`); continue; } // Merge this file's config into the overall config mergeConfigs(mergedConfig, fileConfig); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); console.error( `Error loading config from ${filePath}: ${errorMessage}, skipping` ); } } return mergedConfig; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Error loading configs from directory: ${errorMessage}`); return defaultConfig; } } /** * Validates the configuration of a specific tool * Note: This function validates the tool definition itself, not the runtime parameters * (which are validated by the schema validation mechanism) * * @param {DevToolsConfig} config - The complete tool configuration * @param {string} toolName - The name of the tool to validate * @returns {string|null} Error message if invalid, null if valid */ export function validateToolConfig( config: DevToolsConfig, toolName: string ): string | null { try { // Get the tool configuration const toolConfig = config[toolName]; if (!toolConfig) { return `Tool "${toolName}" not found in configuration`; } // If the tool has parameters, validate them if (toolConfig.parameters) { for (const [paramName, param] of Object.entries(toolConfig.parameters)) { // Check parameter type if (!param.type) { return `Parameter "${paramName}" is missing type property`; } const validTypes = [ "string", "number", "boolean", "array", "object", "enum", ]; if (!validTypes.includes(param.type)) { return `Parameter "${paramName}" has invalid type "${param.type}"`; } // For enum types, check that enum values are provided if (param.type === "enum") { if ( !param.enum || !Array.isArray(param.enum) || param.enum.length === 0 ) { return `Parameter "${paramName}" of type "enum" must have a non-empty enum array`; } } // Recursively validate nested parameters if (param.type === "object" && param.properties) { for (const [nestedName, nestedParam] of Object.entries( param.properties )) { if (!nestedParam.type) { return `Nested parameter "${nestedName}" in "${paramName}" is missing type property`; } if (!validTypes.includes(nestedParam.type)) { return `Nested parameter "${nestedName}" in "${paramName}" has invalid type "${nestedParam.type}"`; } // Recursively validate deeper nested structures const nestedValidation = validateNestedParameter( nestedParam, `${paramName}.${nestedName}` ); if (nestedValidation) { return nestedValidation; } } } // Validate array item types if (param.type === "array" && param.items) { if (!param.items.type) { return `Items in array parameter "${paramName}" must specify a type`; } if (!validTypes.includes(param.items.type)) { return `Items in array parameter "${paramName}" have invalid type "${param.items.type}"`; } // Recursively validate array item if it's a complex type const itemValidation = validateNestedParameter( param.items, `${paramName} items` ); if (itemValidation) { return itemValidation; } } } } return null; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return `Error validating tool configuration: ${errorMessage}`; } } /** * Helper function to recursively validate a nested parameter * @param {ParameterConfig} param - Parameter configuration to validate * @param {string} path - Path to the parameter (for error reporting) * @returns {string|null} Error message if invalid, null if valid */ function validateNestedParameter( param: ParameterConfig, path: string ): string | null { const validTypes = ["string", "number", "boolean", "array", "object", "enum"]; // For enum types, check that enum values are provided if (param.type === "enum") { if (!param.enum || !Array.isArray(param.enum) || param.enum.length === 0) { return `Parameter "${path}" of type "enum" must have a non-empty enum array`; } } // Recursively validate nested objects if (param.type === "object" && param.properties) { for (const [nestedName, nestedParam] of Object.entries(param.properties)) { if (!nestedParam.type) { return `Nested parameter "${nestedName}" in "${path}" is missing type property`; } if (!validTypes.includes(nestedParam.type)) { return `Nested parameter "${nestedName}" in "${path}" has invalid type "${nestedParam.type}"`; } const nestedValidation = validateNestedParameter( nestedParam, `${path}.${nestedName}` ); if (nestedValidation) { return nestedValidation; } } } // Validate array item types if (param.type === "array" && param.items) { if (!param.items.type) { return `Items in array parameter "${path}" must specify a type`; } if (!validTypes.includes(param.items.type)) { return `Items in array parameter "${path}" have invalid type "${param.items.type}"`; } const itemValidation = validateNestedParameter( param.items, `${path} items` ); if (itemValidation) { return itemValidation; } } return null; } /** * Converts parameter configuration to JSON Schema format * @param {Record<string, ParameterConfig>} parameters - Parameter configurations * @returns {object} JSON Schema object */ export function convertParametersToJsonSchema( parameters: Record<string, ParameterConfig> ): any { const properties: Record<string, any> = {}; const required: string[] = []; for (const [name, param] of Object.entries(parameters)) { if (param.required) { required.push(name); } properties[name] = convertParameterToJsonSchema(param); } // Fix the schema format to be compatible with MCP SDK const schema = { type: "object", properties, ...(required.length > 0 ? { required } : {}), }; return schema; } /** * Converts a single parameter configuration to JSON Schema * @param {ParameterConfig} param - Parameter configuration * @returns {object} JSON Schema for the parameter */ export function convertParameterToJsonSchema(param: ParameterConfig): any { const schema: any = {}; switch (param.type) { case "string": schema.type = "string"; break; case "number": schema.type = "number"; break; case "boolean": schema.type = "boolean"; break; case "array": schema.type = "array"; if (param.items) { schema.items = convertParameterToJsonSchema(param.items); } else { schema.items = { type: "string" }; } break; case "object": schema.type = "object"; if (param.properties) { const nestedSchema = convertParametersToJsonSchema(param.properties); schema.properties = nestedSchema.properties; if (nestedSchema.required && nestedSchema.required.length > 0) { schema.required = nestedSchema.required; } } else { schema.additionalProperties = true; } break; case "enum": if (param.enum && param.enum.length > 0) { const firstValue = param.enum[0]; if (typeof firstValue === "number") { schema.type = "number"; } else { schema.type = "string"; } schema.enum = param.enum; } else { schema.type = "string"; schema.enum = []; } break; } if (param.description) { schema.description = param.description; } if (param.default !== undefined) { schema.default = param.default; } return schema; } /** * Converts parameter configuration to Zod schema * @param {Record<string, ParameterConfig>} parameters - Parameter configurations * @returns {object} Zod schema object for use with MCP SDK */ export function convertParametersToZodSchema( parameters: Record<string, ParameterConfig> ): Record<string, z.ZodTypeAny> { const schemaObj: Record<string, z.ZodTypeAny> = {}; for (const [name, param] of Object.entries(parameters)) { let schema = convertParameterToZodSchema(param); // If parameter is required, don't add .optional() if (!param.required) { schema = schema.optional(); } schemaObj[name] = schema; } return schemaObj; } /** * Converts a single parameter configuration to a Zod schema * @param {ParameterConfig} param - Parameter configuration * @returns {z.ZodTypeAny} Zod schema for the parameter */ export function convertParameterToZodSchema( param: ParameterConfig ): z.ZodTypeAny { let schema: z.ZodTypeAny; switch (param.type) { case "string": schema = z.string(); break; case "number": schema = z.number(); break; case "boolean": schema = z.boolean(); break; case "array": if (param.items) { // Create array with the specific item type schema = z.array(convertParameterToZodSchema(param.items)); } else { // Default to array of strings if item type not specified schema = z.array(z.string()); } break; case "object": if (param.properties) { // Create object with specific properties const propertySchemas = convertParametersToZodSchema(param.properties); schema = z.object(propertySchemas); } else { // Default to record of unknown if properties not specified schema = z.record(z.unknown()); } break; case "enum": if (param.enum && param.enum.length > 0) { const firstValue = param.enum[0]; if (typeof firstValue === "number") { // For numeric enums, we need to handle them differently // Since z.nativeEnum expects an actual TypeScript enum, // we'll use z.union of z.literal values schema = z.union( param.enum.map((value) => z.literal(value)) as [ z.ZodLiteral<any>, z.ZodLiteral<any>, ...z.ZodLiteral<any>[] ] ); } else { // For string enums, use regular enum schema = z.enum(param.enum as [string, ...string[]]); } } else { // Default to empty string enum if enum values not provided schema = z.enum([""] as [string, ...string[]]); } break; default: // Default to string for unknown types schema = z.string(); } // Add description if available if (param.description) { schema = schema.describe(param.description); } // Add default value if specified if (param.default !== undefined) { schema = schema.default(param.default); } return schema; }