UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

302 lines (272 loc) 12.2 kB
import { YnabClient } from '../../shared/ynab-client.js'; import { CacheManager } from '../../shared/cache-manager.js'; import { ErrorHandler } from '../../shared/error-handler.js'; const cache = new CacheManager(); const errorHandler = new ErrorHandler(); const toolDefinition = { name: 'search_transactions', description: 'Advanced transaction search with multiple criteria including payee name, memo text, amount ranges, and date periods', inputSchema: { type: 'object', properties: { budget_id: { type: 'string', description: 'Specific budget ID (optional, defaults to default budget)' }, payee_search: { type: 'string', description: 'Search transactions where payee name contains this text (case-insensitive)' }, memo_search: { type: 'string', description: 'Search transactions where memo contains this text (case-insensitive)' }, amount_min: { type: 'number', description: 'Minimum transaction amount (negative for expenses, positive for income)' }, amount_max: { type: 'number', description: 'Maximum transaction amount (negative for expenses, positive for income)' }, date_range: { type: 'string', enum: ['last_7_days', 'last_30_days', 'last_3_months', 'last_6_months', 'last_year', 'year_to_date'], description: 'Predefined date range for search' }, since_date: { type: 'string', description: 'Custom start date filter in YYYY-MM-DD format (overrides date_range)', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, until_date: { type: 'string', description: 'Custom end date filter in YYYY-MM-DD format (overrides date_range)', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, cleared_only: { type: 'boolean', description: 'Only return cleared transactions (default: false)' }, approved_only: { type: 'boolean', description: 'Only return approved transactions (default: false)' }, account_id: { type: 'string', description: 'Filter by specific account ID (optional)' }, category_id: { type: 'string', description: 'Filter by specific category ID (optional)' }, exclude_transfers: { type: 'boolean', description: 'Exclude transfer transactions (default: false)' }, flag_color: { type: 'string', enum: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], description: 'Filter by flag color' }, limit: { type: 'integer', description: 'Maximum number of results to return (optional, default 100, max 1000)', minimum: 1, maximum: 1000, default: 100 } }, additionalProperties: false } }; const handler = async (params) => { try { const ynab = new YnabClient(); const { budget_id, payee_search, memo_search, amount_min, amount_max, date_range, since_date, until_date, cleared_only = false, approved_only = false, account_id, category_id, exclude_transfers = false, flag_color, limit = 100 } = params; // Generate cache key for search results const cacheKey = cache.generateKey('search_transactions', params); return await cache.getOrSet(cacheKey, async () => { const budgetId = budget_id || await ynab.getDefaultBudgetId(); // Calculate date range if using predefined ranges let calculatedSinceDate = since_date; let calculatedUntilDate = until_date; if (date_range && !since_date && !until_date) { const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); switch (date_range) { case 'last_7_days': calculatedSinceDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; break; case 'last_30_days': calculatedSinceDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; break; case 'last_3_months': calculatedSinceDate = new Date(today.getFullYear(), today.getMonth() - 3, today.getDate()).toISOString().split('T')[0]; break; case 'last_6_months': calculatedSinceDate = new Date(today.getFullYear(), today.getMonth() - 6, today.getDate()).toISOString().split('T')[0]; break; case 'last_year': calculatedSinceDate = new Date(today.getFullYear() - 1, today.getMonth(), today.getDate()).toISOString().split('T')[0]; break; case 'year_to_date': calculatedSinceDate = new Date(today.getFullYear(), 0, 1).toISOString().split('T')[0]; break; } } // Get transactions with basic filters const transactions = await ynab.getTransactions(budgetId, { accountId: account_id, categoryId: category_id, sinceDate: calculatedSinceDate, untilDate: calculatedUntilDate, limit: 1000 // Get more initially for search filtering }); // Get additional data for enriching transaction details const [accounts, categories, payees] = await Promise.all([ ynab.getAccounts(budgetId), ynab.getCategories(budgetId), ynab.getPayees(budgetId) ]); // Create lookup maps for enrichment const accountMap = new Map(accounts.map(acc => [acc.id, acc])); const payeeMap = new Map(payees.map(payee => [payee.id, payee])); // Flatten categories from category groups for lookup const categoryMap = new Map(); categories.forEach(group => { if (group.categories) { group.categories.forEach(cat => { categoryMap.set(cat.id, { ...cat, group_name: group.name }); }); } }); // Apply advanced search filters let filteredTransactions = transactions.filter(transaction => { // Amount range filtering if (amount_min !== undefined || amount_max !== undefined) { const amount = ynab.milliunitsToAmount(transaction.amount); if (amount_min !== undefined && amount < amount_min) return false; if (amount_max !== undefined && amount > amount_max) return false; } // Payee search if (payee_search) { const payee = payeeMap.get(transaction.payee_id); const payeeName = payee ? payee.name : 'Transfer'; if (!payeeName.toLowerCase().includes(payee_search.toLowerCase())) return false; } // Memo search if (memo_search) { const memo = transaction.memo || ''; if (!memo.toLowerCase().includes(memo_search.toLowerCase())) return false; } // Cleared status filter if (cleared_only && transaction.cleared !== 'cleared') return false; // Approved status filter if (approved_only && !transaction.approved) return false; // Transfer exclusion if (exclude_transfers && transaction.transfer_account_id) return false; // Flag color filter if (flag_color && transaction.flag_color !== flag_color) return false; return true; }); // Transform and enrich transaction data const transactionsData = filteredTransactions.map(transaction => { const account = accountMap.get(transaction.account_id); const category = categoryMap.get(transaction.category_id); const payee = payeeMap.get(transaction.payee_id); return { id: transaction.id, date: transaction.date, amount: ynab.milliunitsToAmount(transaction.amount), cleared: transaction.cleared, approved: transaction.approved, payee_id: transaction.payee_id, payee_name: payee ? payee.name : 'Transfer', category_id: transaction.category_id, category_name: category ? category.name : null, category_group_name: category ? category.group_name : null, account_id: transaction.account_id, account_name: account ? account.name : null, memo: transaction.memo || null, flag_color: transaction.flag_color || null, flag_name: transaction.flag_name || null, transfer_account_id: transaction.transfer_account_id || null, transfer_transaction_id: transaction.transfer_transaction_id || null, matched_transaction_id: transaction.matched_transaction_id || null, import_id: transaction.import_id || null, debt_transaction_type: transaction.debt_transaction_type || null }; }); // Sort by date descending (most recent first) transactionsData.sort((a, b) => new Date(b.date) - new Date(a.date)); // Apply limit after sorting const limitedTransactions = transactionsData.slice(0, limit); // Calculate summary statistics const totalAmount = limitedTransactions.reduce((sum, t) => sum + t.amount, 0); const totalIncome = limitedTransactions.filter(t => t.amount > 0).reduce((sum, t) => sum + t.amount, 0); const totalExpenses = limitedTransactions.filter(t => t.amount < 0).reduce((sum, t) => sum + Math.abs(t.amount), 0); const clearedCount = limitedTransactions.filter(t => t.cleared === 'cleared').length; const unclearedCount = limitedTransactions.filter(t => t.cleared === 'uncleared').length; const transferCount = limitedTransactions.filter(t => t.transfer_account_id).length; // Search criteria summary const searchCriteria = []; if (payee_search) searchCriteria.push(`Payee contains "${payee_search}"`); if (memo_search) searchCriteria.push(`Memo contains "${memo_search}"`); if (amount_min !== undefined) searchCriteria.push(`Amount >= ${amount_min}`); if (amount_max !== undefined) searchCriteria.push(`Amount <= ${amount_max}`); if (date_range) searchCriteria.push(`Date range: ${date_range}`); if (cleared_only) searchCriteria.push('Cleared transactions only'); if (approved_only) searchCriteria.push('Approved transactions only'); if (exclude_transfers) searchCriteria.push('Excluding transfers'); if (flag_color) searchCriteria.push(`Flag color: ${flag_color}`); return { transactions: limitedTransactions, total_count: limitedTransactions.length, total_found: filteredTransactions.length, limited_results: filteredTransactions.length > limit, search_criteria: searchCriteria, summary: { total_amount: totalAmount, total_income: totalIncome, total_expenses: totalExpenses, cleared_count: clearedCount, uncleared_count: unclearedCount, transfer_count: transferCount, date_range: { earliest: limitedTransactions.length > 0 ? limitedTransactions[limitedTransactions.length - 1].date : null, latest: limitedTransactions.length > 0 ? limitedTransactions[0].date : null } }, filters_applied: { budget_id: budgetId, account_id: account_id || null, category_id: category_id || null, date_range: date_range || null, since_date: calculatedSinceDate || null, until_date: calculatedUntilDate || null, limit: limit } }; }, 2); // Cache for 2 minutes (transactions change frequently) } catch (error) { return errorHandler.formatForMCP(error); } }; export { toolDefinition, handler };