UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

292 lines (261 loc) 8.92 kB
import fs from 'fs/promises'; import path from 'path'; import yaml from 'js-yaml'; import Ajv from 'ajv'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; import logger from './logger.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** * Prompt Loader - Handles loading and validating YAML prompt files */ export class PromptLoader { constructor() { this.ajv = new Ajv({ allErrors: true }); this.promptSchema = this.createPromptSchema(); } /** * JSON Schema for validating prompt files */ createPromptSchema() { return { type: 'object', required: ['version', 'name', 'prompts'], properties: { version: { type: 'string' }, name: { type: 'string' }, description: { type: 'string' }, prompts: { type: 'array', items: { type: 'object', required: ['name', 'description', 'steps', 'tools_used'], properties: { name: { type: 'string' }, description: { type: 'string' }, parameters: { type: 'array', items: { type: 'object', required: ['name', 'type', 'description'], properties: { name: { type: 'string' }, type: { type: 'string', enum: ['string', 'number', 'integer', 'boolean', 'array'] }, description: { type: 'string' }, required: { type: 'boolean' }, default: {}, enum: { type: 'array' }, minimum: { type: 'number' }, maximum: { type: 'number' } } } }, steps: { type: 'array', items: { type: 'string' } }, tools_used: { type: 'array', items: { type: 'string' } }, output_format: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } } } } } } }; } /** * Load and validate a YAML prompt file * @param {string} filePath - Path to the YAML file * @returns {Object} Parsed and validated prompt configuration */ async loadPromptFile(filePath) { try { // Read the file const fileContent = await fs.readFile(filePath, 'utf8'); // Parse YAML const promptConfig = yaml.load(fileContent); // Validate against schema const validate = this.ajv.compile(this.promptSchema); const isValid = validate(promptConfig); if (!isValid) { throw new Error(`Invalid prompt file format: ${this.formatValidationErrors(validate.errors)}`); } // Add metadata promptConfig._filePath = filePath; promptConfig._loadedAt = new Date(); return promptConfig; } catch (error) { if (error.code === 'ENOENT') { throw new Error(`Prompt file not found: ${filePath}`); } throw new Error(`Failed to load prompt file: ${error.message}`); } } /** * Load the default prompt file * @returns {Object} Default prompt configuration */ async loadDefaultPrompts() { const defaultPath = path.join(__dirname, '..', 'prompts', 'default-prompts.yaml'); return await this.loadPromptFile(defaultPath); } /** * Get all available prompt files in the prompts directory * @returns {Array} List of available prompt files */ async getAvailablePromptFiles() { const promptsDir = path.join(__dirname, '..', 'prompts'); const examplesDir = path.join(promptsDir, 'examples'); const promptFiles = []; try { // Check main prompts directory const mainFiles = await fs.readdir(promptsDir); for (const file of mainFiles) { if (file.endsWith('.yaml') || file.endsWith('.yml')) { const filePath = path.join(promptsDir, file); const stat = await fs.stat(filePath); if (stat.isFile()) { promptFiles.push({ name: file, path: filePath, type: 'main' }); } } } // Check examples directory const exampleFiles = await fs.readdir(examplesDir); for (const file of exampleFiles) { if (file.endsWith('.yaml') || file.endsWith('.yml')) { const filePath = path.join(examplesDir, file); const stat = await fs.stat(filePath); if (stat.isFile()) { promptFiles.push({ name: file, path: filePath, type: 'example' }); } } } } catch (error) { // Directory doesn't exist or other error await logger.warn('Could not scan prompts directory', { error: error.message }); } return promptFiles; } /** * Validate prompt parameters against their schema * @param {Object} prompt - The prompt definition * @param {Object} parameters - Parameters to validate * @returns {Object} Validation result */ validatePromptParameters(prompt, parameters = {}) { if (!prompt.parameters || prompt.parameters.length === 0) { return { valid: true, errors: [] }; } const errors = []; const processedParams = { ...parameters }; // Check each parameter definition for (const paramDef of prompt.parameters) { const paramName = paramDef.name; const paramValue = parameters[paramName]; // Check required parameters if (paramDef.required && (paramValue === undefined || paramValue === null)) { errors.push(`Required parameter '${paramName}' is missing`); continue; } // Apply defaults for missing optional parameters if (paramValue === undefined && paramDef.default !== undefined) { processedParams[paramName] = paramDef.default; continue; } // Skip validation if parameter is not provided and not required if (paramValue === undefined) { continue; } // Type validation if (!this.validateParameterType(paramValue, paramDef)) { errors.push(`Parameter '${paramName}' has invalid type. Expected ${paramDef.type}, got ${typeof paramValue}`); } // Enum validation if (paramDef.enum && !paramDef.enum.includes(paramValue)) { errors.push(`Parameter '${paramName}' must be one of: ${paramDef.enum.join(', ')}`); } // Range validation for numbers if (paramDef.type === 'number' || paramDef.type === 'integer') { if (paramDef.minimum !== undefined && paramValue < paramDef.minimum) { errors.push(`Parameter '${paramName}' must be >= ${paramDef.minimum}`); } if (paramDef.maximum !== undefined && paramValue > paramDef.maximum) { errors.push(`Parameter '${paramName}' must be <= ${paramDef.maximum}`); } } } return { valid: errors.length === 0, errors, processedParameters: processedParams }; } /** * Validate parameter type */ validateParameterType(value, paramDef) { switch (paramDef.type) { case 'string': return typeof value === 'string'; case 'number': return typeof value === 'number' && !isNaN(value); case 'integer': return typeof value === 'number' && Number.isInteger(value); case 'boolean': return typeof value === 'boolean'; case 'array': return Array.isArray(value); default: return true; // Unknown type, assume valid } } /** * Format validation errors for display */ formatValidationErrors(errors) { return errors.map(err => `${err.instancePath}: ${err.message}`).join('; '); } /** * Get a specific prompt by name from a prompt configuration * @param {Object} promptConfig - Loaded prompt configuration * @param {string} promptName - Name of the prompt to get * @returns {Object|null} The prompt definition or null if not found */ getPromptByName(promptConfig, promptName) { return promptConfig.prompts.find(p => p.name === promptName) || null; } /** * List all prompts in a configuration * @param {Object} promptConfig - Loaded prompt configuration * @returns {Array} List of prompt summaries */ listPrompts(promptConfig) { return promptConfig.prompts.map(prompt => ({ name: prompt.name, description: prompt.description, parameters: prompt.parameters?.map(p => ({ name: p.name, type: p.type, required: p.required || false, description: p.description })) || [], tags: prompt.tags || [] })); } } export default PromptLoader;