mcp-ynab
Version:
Model Context Protocol server for YNAB integration
302 lines (272 loc) • 12.2 kB
JavaScript
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 };