UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

470 lines (405 loc) 14.8 kB
#!/usr/bin/env node // Load environment variables FIRST before any other imports import dotenv from 'dotenv'; import { fileURLToPath } from 'url'; import { dirname, resolve } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const envPath = resolve(__dirname, '../.env'); dotenv.config({ path: envPath }); // Now import everything else after environment is loaded import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import { ErrorHandler } from './shared/error-handler.js'; import PromptLoader from './shared/prompt-loader.js'; import PromptExecutor from './shared/prompt-executor.js'; import logger from './shared/logger.js'; // Import all tools // Budget Overview Tools import * as getBudgetSummary from './tools/budget-overview/get-budget-summary.js'; import * as getBudgetMonths from './tools/budget-overview/get-budget-months.js'; import * as getCategories from './tools/budget-overview/get-categories.js'; import * as listBudgets from './tools/budget-overview/list-budgets.js'; // Account Tools import * as getAccounts from './tools/account-tools/get-accounts.js'; import * as getAccountDetails from './tools/account-tools/get-account-details.js'; // Transaction Analysis Tools import * as getTransactions from './tools/transaction-analysis/get-transactions.js'; import * as searchTransactions from './tools/transaction-analysis/search-transactions.js'; import * as getPayees from './tools/transaction-analysis/get-payees.js'; import * as getScheduledTransactions from './tools/transaction-analysis/get-scheduled-transactions.js'; // Analysis & Reporting Tools import * as getSpendingReport from './tools/analysis-reporting/get-spending-report.js'; import * as getCashFlowAnalysis from './tools/analysis-reporting/get-cash-flow-analysis.js'; import * as getGoalsStatus from './tools/analysis-reporting/get-goals-status.js'; import * as getNetWorthTrend from './tools/analysis-reporting/get-net-worth-trend.js'; import * as getOverspendingAnalysis from './tools/analysis-reporting/get-overspending-analysis.js'; import * as getUnderspendingAnalysis from './tools/analysis-reporting/get-underspending-analysis.js'; // Prompt Generator Tool import * as promptGenerator from './tools/prompt-generator.js'; // Cache Management Tool import * as showCacheStats from './tools/show-cache-stats.js'; // Parse command line arguments const argv = yargs(hideBin(process.argv)) .option('prompts', { alias: 'p', type: 'string', description: 'Path to custom YAML prompt file' }) .option('list-prompts', { type: 'boolean', description: 'List available prompts and exit' }) .help() .argv; class YNABMCPServer { constructor() { this.errorHandler = new ErrorHandler(); this.promptLoader = new PromptLoader(); this.promptExecutor = new PromptExecutor(); this.promptConfig = null; // Log environment loading logger.startup('Environment loaded', { envPath, hasApiKey: !!process.env.YNAB_API_KEY, apiKeyLength: process.env.YNAB_API_KEY?.length, nodeEnv: process.env.NODE_ENV, mcpDebug: process.env.MCP_DEBUG, loggerEnabled: logger.enabled }); // Validate environment on startup try { this.errorHandler.validateEnvironment(); } catch (error) { logger.error('Environment validation failed', { error: error.message }); process.exit(1); } this.server = new Server( { name: 'mcp-ynab', version: '1.2.0', }, { capabilities: { resources: {}, tools: {}, prompts: {}, }, } ); // Register all tools this.tools = new Map([ // Budget Overview Tools (4/4) ['get_budget_summary', getBudgetSummary], ['get_budget_months', getBudgetMonths], ['get_categories', getCategories], ['list_budgets', listBudgets], // Account Tools (2/2) ['get_accounts', getAccounts], ['get_account_details', getAccountDetails], // Transaction Analysis Tools (4/4) ['get_transactions', getTransactions], ['search_transactions', searchTransactions], ['get_payees', getPayees], ['get_scheduled_transactions', getScheduledTransactions], // Analysis & Reporting Tools (6/6) ['get_spending_report', getSpendingReport], ['get_cash_flow_analysis', getCashFlowAnalysis], ['get_goals_status', getGoalsStatus], ['get_net_worth_trend', getNetWorthTrend], ['get_overspending_analysis', getOverspendingAnalysis], ['get_underspending_analysis', getUnderspendingAnalysis], // Prompt Generator Tool ['generate_prompt', promptGenerator], // Cache Management Tool ['show_cache_stats', showCacheStats], ]); // Initialize prompts this.prompts = new Map(); // Note: prompts will be loaded asynchronously in run() this.setupHandlers(); } /** * Load prompts from YAML file (custom or default) * Note: Prompts must have simple string steps for server compatibility * Complex logic should be implemented as dedicated MCP tools */ async loadPrompts() { try { // Load prompt configuration if (argv.prompts) { logger.debug('Loading custom prompts', { file: argv.prompts }); this.promptConfig = await this.promptLoader.loadPromptFile(argv.prompts); } else { logger.debug('Loading default prompts'); this.promptConfig = await this.promptLoader.loadDefaultPrompts(); } // Register prompts as MCP tools for (const prompt of this.promptConfig.prompts) { const promptDefinition = { name: prompt.name, description: prompt.description, inputSchema: this.createPromptInputSchema(prompt) }; const promptHandler = async (params) => { // Validate parameters const validation = this.promptLoader.validatePromptParameters(prompt, params); if (!validation.valid) { throw new Error(`Parameter validation failed: ${validation.errors.join(', ')}`); } // Execute the prompt return await this.promptExecutor.executePrompt(prompt, validation.processedParameters); }; this.prompts.set(prompt.name, { promptDefinition, handler: promptHandler, config: prompt }); } logger.info('Prompts loaded successfully', { count: this.promptConfig.prompts.length, source: this.promptConfig.name }); } catch (error) { logger.error('Failed to load prompts', { error: error.message }); } } /** * Create JSON Schema for prompt parameters */ createPromptInputSchema(prompt) { const schema = { type: 'object', properties: {}, required: [] }; if (prompt.parameters) { for (const param of prompt.parameters) { schema.properties[param.name] = { type: param.type, description: param.description }; if (param.enum) { schema.properties[param.name].enum = param.enum; } if (param.minimum !== undefined) { schema.properties[param.name].minimum = param.minimum; } if (param.maximum !== undefined) { schema.properties[param.name].maximum = param.maximum; } if (param.default !== undefined) { schema.properties[param.name].default = param.default; } if (param.required) { schema.required.push(param.name); } } } return schema; } setupHandlers() { // List available tools (including prompts as tools) this.server.setRequestHandler(ListToolsRequestSchema, async () => { const tools = Array.from(this.tools.values()).map(tool => tool.toolDefinition); // Add prompts as tools const promptTools = Array.from(this.prompts.values()).map(prompt => prompt.promptDefinition); return { tools: [...tools, ...promptTools] }; }); // Handle tool calls (including prompts) this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; // Check if it's a regular tool if (this.tools.has(name)) { try { const tool = this.tools.get(name); const result = await tool.handler(args || {}); // Log tool execution logger.tool(name, args, result); // Check if result is an error if (result && result.error) { throw new Error(result.message || 'Tool execution failed'); } return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { logger.error('Tool execution failed', { tool: name, error: error.message }); const formattedError = this.errorHandler.formatForMCP(error); return { content: [ { type: 'text', text: JSON.stringify(formattedError, null, 2), }, ], isError: true, }; } } // Check if it's a prompt if (this.prompts.has(name)) { try { const prompt = this.prompts.get(name); const result = await prompt.handler(args || {}); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { const formattedError = this.errorHandler.formatForMCP(error); return { content: [ { type: 'text', text: JSON.stringify(formattedError, null, 2), }, ], isError: true, }; } } throw new Error(`Unknown tool or prompt: ${name}`); }); // List available prompts this.server.setRequestHandler(ListPromptsRequestSchema, async () => { const prompts = Array.from(this.prompts.values()).map(prompt => prompt.promptDefinition); return { prompts }; }); // Handle prompt requests this.server.setRequestHandler(GetPromptRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (!this.prompts.has(name)) { throw new Error(`Unknown prompt: ${name}`); } try { const prompt = this.prompts.get(name); const result = await prompt.handler(args || {}); // Format response according to MCP GetPrompt protocol return { messages: [ { role: "assistant", content: { type: "text", text: JSON.stringify(result, null, 2) } } ] }; } catch (error) { const formattedError = this.errorHandler.formatForMCP(error); return { messages: [ { role: "assistant", content: { type: "text", text: JSON.stringify(formattedError, null, 2) } } ] }; } }); // List available resources (none for now) this.server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: [] }; }); // Handle resource requests (none for now) this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params; throw new Error(`Unknown resource: ${uri}`); }); } async run() { // Handle list-prompts option if (argv['list-prompts']) { try { // Load prompts first await this.loadPrompts(); console.log('\nAvailable Prompts:'); console.log('================='); if (this.promptConfig && this.promptConfig.prompts.length > 0) { console.log(`From: ${this.promptConfig.name}`); console.log(`Description: ${this.promptConfig.description || 'No description'}`); console.log(); for (const prompt of this.promptConfig.prompts) { console.log(`• ${prompt.name}`); console.log(` Description: ${prompt.description}`); if (prompt.parameters && prompt.parameters.length > 0) { console.log(` Parameters: ${prompt.parameters.map(p => p.name).join(', ')}`); } if (prompt.tags && prompt.tags.length > 0) { console.log(` Tags: ${prompt.tags.join(', ')}`); } console.log(); } } else { console.log('No prompts loaded.'); } // List available prompt files const availableFiles = await this.promptLoader.getAvailablePromptFiles(); if (availableFiles.length > 0) { console.log('Available Prompt Files:'); console.log('====================='); for (const file of availableFiles) { console.log(`• ${file.name} (${file.type})`); console.log(` Path: ${file.path}`); } } process.exit(0); } catch (error) { console.error('Error listing prompts:', error.message); process.exit(1); } } // Load prompts on startup await this.loadPrompts(); const transport = new StdioServerTransport(); await this.server.connect(transport); // Force an immediate test log entry await logger.info('MCP-YNAB Server Starting', { version: '1.2.0', environment: { NODE_ENV: process.env.NODE_ENV, MCP_DEBUG: process.env.MCP_DEBUG, loggerEnabled: logger.enabled } }); // Log server startup logger.startup('MCP server started', { toolCount: this.tools.size, promptCount: this.prompts.size, tools: Array.from(this.tools.keys()), prompts: Array.from(this.prompts.keys()) }); } } // Start the server const server = new YNABMCPServer(); server.run().catch((error) => { // Server failed to start - exit silently to avoid MCP protocol interference process.exit(1); });