UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

508 lines (446 loc) 15.9 kB
import fs from 'fs/promises'; import path from 'path'; import yaml from 'js-yaml'; import PromptLoader from '../shared/prompt-loader.js'; /** * Built-in Prompt Generator Tool * Helps users create custom YAML prompt files through interactive conversation */ const toolDefinition = { name: 'generate_prompt', description: 'Generate custom YNAB prompt files or templates through interactive conversation', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['template', 'interactive', 'validate', 'save'], description: 'Action to perform: template (generate basic template), interactive (create prompt through conversation), validate (check existing prompt), save (save prompt to file)' }, prompt_name: { type: 'string', description: 'Name for the new prompt (required for interactive mode)' }, prompt_description: { type: 'string', description: 'Description of what the prompt should do (required for interactive mode)' }, requirements: { type: 'string', description: 'Detailed requirements or conversation about what the prompt should accomplish' }, tools_needed: { type: 'array', items: { type: 'string' }, description: 'List of YNAB tools this prompt should use (optional, will be auto-suggested if not provided)' }, parameters: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, type: { type: 'string' }, description: { type: 'string' }, required: { type: 'boolean' }, default: {} } }, description: 'Parameters the prompt should accept' }, output_file: { type: 'string', description: 'Filename to save the generated prompt (for save action)' }, prompt_yaml: { type: 'string', description: 'YAML content to validate or save' } }, required: ['action'] } }; const handler = async (params) => { const { action, prompt_name, prompt_description, requirements, tools_needed, parameters, output_file, prompt_yaml } = params; try { switch (action) { case 'template': return await generateTemplate(); case 'interactive': if (!prompt_name || !prompt_description) { throw new Error('prompt_name and prompt_description are required for interactive mode'); } return await generateInteractivePrompt({ name: prompt_name, description: prompt_description, requirements, tools_needed, parameters }); case 'validate': if (!prompt_yaml) { throw new Error('prompt_yaml is required for validation'); } return await validatePrompt(prompt_yaml); case 'save': if (!prompt_yaml || !output_file) { throw new Error('prompt_yaml and output_file are required for save action'); } return await savePrompt(prompt_yaml, output_file); default: throw new Error(`Unknown action: ${action}`); } } catch (error) { return { success: false, error: error.message, action }; } }; /** * Generate a basic template YAML file */ async function generateTemplate() { const template = { version: '1.0', name: 'My Custom YNAB Prompts', description: 'Custom prompt set for personalized YNAB analysis', prompts: [ { name: 'my_custom_prompt', description: 'Replace with your prompt description', parameters: [ { name: 'budget_id', type: 'string', description: 'Specific budget ID (optional)', required: false } ], steps: [ 'Get budget summary and health metrics', 'Analyze spending patterns and trends', 'Generate insights and recommendations' ], step_format_note: 'Steps must be simple strings - the server uses keyword matching to execute tools. For complex logic, create a dedicated MCP tool instead.', tools_used: [ 'get_budget_summary', 'get_categories' ], tags: ['custom', 'template'] } ] }; const yamlContent = yaml.dump(template, { indent: 2, lineWidth: 100, noRefs: true }); return { success: true, action: 'template', yaml_content: yamlContent, instructions: [ '1. Copy the YAML content above', '2. Replace "my_custom_prompt" with your desired prompt name', '3. Update the description to match your needs', '4. Modify the steps to define your analysis workflow', '5. Adjust tools_used to match the tools needed for your analysis', '6. Add or modify parameters as needed', '7. Save to a .yaml file in your prompts directory' ] }; } /** * Generate a prompt based on interactive conversation */ async function generateInteractivePrompt(config) { const { name, description, requirements, tools_needed, parameters } = config; // Available YNAB tools with their descriptions const availableTools = { 'get_budget_summary': 'Get overall budget health and summary', 'get_categories': 'Get category balances and goals', 'get_accounts': 'Get account balances and details', 'get_overspending_analysis': 'Find categories with overspending patterns', 'get_goals_status': 'Check savings goals progress', 'get_cash_flow_analysis': 'Analyze income vs expenses over time', 'get_spending_report': 'Generate comprehensive spending analysis', 'get_payees': 'Get payee information and spending history', 'get_scheduled_transactions': 'Get upcoming scheduled transactions', 'get_net_worth_trend': 'Analyze net worth changes over time', 'get_underspending_analysis': 'Find categories with unused budget', 'get_transactions': 'Get transaction history', 'search_transactions': 'Search transactions with advanced criteria', 'get_account_details': 'Get detailed information about specific accounts', 'get_budget_months': 'Get available budget months', 'list_budgets': 'List all available budgets' }; // Auto-suggest tools based on requirements let suggestedTools = tools_needed || []; if (!tools_needed && requirements) { suggestedTools = suggestToolsFromRequirements(requirements, availableTools); } // Generate steps based on requirements const steps = generateStepsFromRequirements(requirements, description, suggestedTools); // Generate default parameters if not provided let promptParameters = parameters || [ { name: 'budget_id', type: 'string', description: 'Specific budget ID (optional)', required: false } ]; // Add analysis period parameter for time-based analysis if (requirements && (requirements.includes('month') || requirements.includes('time') || requirements.includes('period'))) { promptParameters.push({ name: 'analysis_months', type: 'integer', description: 'Number of months to analyze', default: 6, minimum: 1, maximum: 12 }); } const prompt = { version: '1.0', name: `Custom YNAB Analysis - ${name}`, description: `Generated prompt: ${description}`, prompts: [ { name: name.toLowerCase().replace(/\s+/g, '_'), description, parameters: promptParameters, steps, tools_used: suggestedTools, tags: ['custom', 'generated'] } ] }; const yamlContent = yaml.dump(prompt, { indent: 2, lineWidth: 100, noRefs: true }); return { success: true, action: 'interactive', prompt_name: name, yaml_content: yamlContent, suggested_tools: suggestedTools, generated_steps: steps, next_steps: [ '1. Review the generated YAML above', '2. Modify steps or tools if needed', '3. Use the "validate" action to check the prompt', '4. Use the "save" action to save to a file' ], tool_descriptions: Object.fromEntries( suggestedTools.map(tool => [tool, availableTools[tool] || 'Tool description not available']) ), important_limitations: [ 'PROMPT LIMITATIONS:', '• Steps must be simple string descriptions, not complex objects', '• The server uses basic keyword matching to select tools', '• Complex logic with decision trees should be implemented as dedicated MCP tools', '• Prompts are best for coordinating multiple tools in sequence', '• For advanced analysis like transaction matching or receipt processing, create a dedicated tool' ] }; } /** * Suggest tools based on requirements text */ function suggestToolsFromRequirements(requirements, availableTools) { const req = requirements.toLowerCase(); const suggestedTools = []; // Start with budget summary for most analyses if (req.includes('budget') || req.includes('overview') || req.includes('summary')) { suggestedTools.push('get_budget_summary'); } // Category-related analysis if (req.includes('category') || req.includes('categories') || req.includes('balance')) { suggestedTools.push('get_categories'); } // Spending analysis if (req.includes('spend') || req.includes('expense') || req.includes('cost')) { suggestedTools.push('get_spending_report'); } // Overspending detection if (req.includes('overspend') || req.includes('over budget') || req.includes('excess')) { suggestedTools.push('get_overspending_analysis'); } // Goals and savings if (req.includes('goal') || req.includes('saving') || req.includes('target')) { suggestedTools.push('get_goals_status'); } // Cash flow and income if (req.includes('cash flow') || req.includes('income') || req.includes('money flow')) { suggestedTools.push('get_cash_flow_analysis'); } // Account analysis if (req.includes('account') || req.includes('balance')) { suggestedTools.push('get_accounts'); } // Net worth tracking if (req.includes('net worth') || req.includes('wealth') || req.includes('asset')) { suggestedTools.push('get_net_worth_trend'); } // Default to budget summary if no specific matches if (suggestedTools.length === 0) { suggestedTools.push('get_budget_summary', 'get_categories'); } return [...new Set(suggestedTools)]; // Remove duplicates } /** * Generate analysis steps from requirements * IMPORTANT: Steps must be simple strings that the MCP server can execute * Complex logic should be implemented as dedicated MCP tools, not prompts */ function generateStepsFromRequirements(requirements, description, tools) { const steps = []; // Always start with getting overview steps.push('Get budget overview and current status'); // Add specific steps based on tools if (tools.includes('get_categories')) { steps.push('Analyze category balances and spending patterns'); } if (tools.includes('get_overspending_analysis')) { steps.push('Identify categories with overspending issues'); } if (tools.includes('get_goals_status')) { steps.push('Review savings goals progress and completion estimates'); } if (tools.includes('get_spending_report')) { steps.push('Generate detailed spending analysis and trends'); } if (tools.includes('get_cash_flow_analysis')) { steps.push('Analyze cash flow patterns and forecasts'); } if (tools.includes('search_transactions')) { steps.push('Search and filter transactions based on criteria'); } if (tools.includes('get_payees')) { steps.push('Analyze payee spending patterns and history'); } if (tools.includes('get_net_worth_trend')) { steps.push('Review net worth changes and trends over time'); } // Always end with generating insights steps.push('Generate insights and actionable recommendations'); return steps; } /** * Validate a YAML prompt */ async function validatePrompt(yamlContent) { try { const promptLoader = new PromptLoader(); // Parse YAML const promptConfig = yaml.load(yamlContent); // Validate structure const validate = promptLoader.ajv.compile(promptLoader.promptSchema); const isValid = validate(promptConfig); if (!isValid) { return { success: false, action: 'validate', valid: false, errors: validate.errors.map(err => `${err.instancePath}: ${err.message}`), yaml_content: yamlContent }; } // Additional validation checks const warnings = []; const availableTools = [ 'get_budget_summary', 'get_categories', 'get_accounts', 'get_overspending_analysis', 'get_goals_status', 'get_cash_flow_analysis', 'get_spending_report', 'get_payees', 'get_scheduled_transactions', 'get_net_worth_trend', 'get_underspending_analysis', 'get_transactions', 'search_transactions', 'get_account_details', 'get_budget_months', 'list_budgets' ]; for (const prompt of promptConfig.prompts) { // Check if tools exist for (const tool of prompt.tools_used) { if (!availableTools.includes(tool)) { warnings.push(`Unknown tool '${tool}' in prompt '${prompt.name}'`); } } // Check if prompt has reasonable number of steps if (prompt.steps.length === 0) { warnings.push(`Prompt '${prompt.name}' has no steps defined`); } if (prompt.steps.length > 10) { warnings.push(`Prompt '${prompt.name}' has many steps (${prompt.steps.length}) - consider breaking into smaller prompts`); } } return { success: true, action: 'validate', valid: true, prompt_count: promptConfig.prompts.length, warnings: warnings.length > 0 ? warnings : undefined, summary: { name: promptConfig.name, version: promptConfig.version, prompts: promptConfig.prompts.map(p => ({ name: p.name, description: p.description, steps: p.steps.length, tools: p.tools_used.length, parameters: p.parameters ? p.parameters.length : 0 })) } }; } catch (error) { return { success: false, action: 'validate', valid: false, errors: [`YAML parsing error: ${error.message}`], yaml_content: yamlContent }; } } /** * Save a prompt to file */ async function savePrompt(yamlContent, filename) { try { // Ensure the filename has .yaml extension const outputFile = filename.endsWith('.yaml') || filename.endsWith('.yml') ? filename : `${filename}.yaml`; // Determine the full path const promptsDir = path.join(process.cwd(), 'src', 'prompts'); const fullPath = path.join(promptsDir, outputFile); // Validate the YAML first const validation = await validatePrompt(yamlContent); if (!validation.valid) { return { success: false, action: 'save', error: 'Cannot save invalid YAML', validation_errors: validation.errors }; } // Ensure directory exists await fs.mkdir(promptsDir, { recursive: true }); // Write the file await fs.writeFile(fullPath, yamlContent, 'utf8'); return { success: true, action: 'save', filename: outputFile, full_path: fullPath, file_size: yamlContent.length, message: `Prompt file saved successfully to ${outputFile}` }; } catch (error) { return { success: false, action: 'save', error: `Failed to save file: ${error.message}` }; } } export { toolDefinition, handler };