mcp-ynab
Version:
Model Context Protocol server for YNAB integration
292 lines (261 loc) • 8.92 kB
JavaScript
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;