UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

253 lines (217 loc) 7.88 kB
import * as ynab from 'ynab'; import logger from './logger.js'; class YnabClient { constructor() { this.apiKey = process.env.YNAB_API_KEY; this.budgetId = process.env.YNAB_BUDGET_ID; if (!this.apiKey) { throw new Error('YNAB_API_KEY environment variable is required'); } this.ynabAPI = new ynab.API(this.apiKey); this._defaultBudgetId = null; } async getDefaultBudgetId() { if (this.budgetId) { return this.budgetId; } if (this._defaultBudgetId) { return this._defaultBudgetId; } try { const budgetsResponse = await this.ynabAPI.budgets.getBudgets(); const budgets = budgetsResponse.data.budgets; if (budgets.length === 0) { throw new Error('No budgets found in YNAB account'); } // Use the first non-demo budget or the first budget if all are demo const nonDemoBudget = budgets.find(b => !b.name.toLowerCase().includes('demo')); this._defaultBudgetId = nonDemoBudget ? nonDemoBudget.id : budgets[0].id; return this._defaultBudgetId; } catch (error) { throw new Error(`Failed to get default budget: ${error.message}`); } } async getBudgets() { try { await logger.debug('Making YNAB API call to get budgets...'); const response = await this.ynabAPI.budgets.getBudgets(); await logger.debug(`Successfully retrieved ${response.data.budgets.length} budgets`); return response.data.budgets; } catch (error) { await logger.error('Failed to get budgets', { error: error.message }); throw new Error(`Failed to get budgets: ${error.message}`); } } async getBudgetById(budgetId) { try { const response = await this.ynabAPI.budgets.getBudgetById(budgetId); return response.data.budget; } catch (error) { throw new Error(`Failed to get budget: ${error.message}`); } } async getCategories(budgetId, month = null) { try { budgetId = budgetId || await this.getDefaultBudgetId(); let response; if (month) { // When month is specified, get month data which includes categories with their month-specific balances response = await this.ynabAPI.months.getBudgetMonth(budgetId, month); if (response.data.month.categories) { // Month API returns flat categories array, need to group them // We'll get the category groups from the regular API and merge the month data const categoriesResponse = await this.ynabAPI.categories.getCategories(budgetId); const categoryGroups = categoriesResponse.data.category_groups; const monthCategories = response.data.month.categories; // Create a map of category ID to month data for quick lookup const monthCategoryMap = {}; monthCategories.forEach(cat => { monthCategoryMap[cat.id] = cat; }); // Merge month-specific data into category groups structure categoryGroups.forEach(group => { if (group.categories) { group.categories.forEach(category => { const monthData = monthCategoryMap[category.id]; if (monthData) { // Merge month-specific fields into category Object.assign(category, monthData); } }); } }); return categoryGroups; } else { // Fallback to regular categories call await logger.debug('Categories not found in month response, falling back to categories API'); response = await this.ynabAPI.categories.getCategories(budgetId); return response.data.category_groups; } } else { // When no month specified, get current categories response = await this.ynabAPI.categories.getCategories(budgetId); return response.data.category_groups; } } catch (error) { throw new Error(`Failed to get categories: ${error.message}`); } } async getAccounts(budgetId) { try { budgetId = budgetId || await this.getDefaultBudgetId(); const response = await this.ynabAPI.accounts.getAccounts(budgetId); return response.data.accounts; } catch (error) { throw new Error(`Failed to get accounts: ${error.message}`); } } async getAccountById(budgetId, accountId) { try { budgetId = budgetId || await this.getDefaultBudgetId(); const response = await this.ynabAPI.accounts.getAccountById(budgetId, accountId); return response.data.account; } catch (error) { throw new Error(`Failed to get account: ${error.message}`); } } async getTransactions(budgetId, params = {}) { try { budgetId = budgetId || await this.getDefaultBudgetId(); const { accountId, categoryId, sinceDate, untilDate, limit = 100 } = params; let response; if (accountId) { response = await this.ynabAPI.transactions.getTransactionsByAccount( budgetId, accountId, sinceDate ); } else if (categoryId) { response = await this.ynabAPI.transactions.getTransactionsByCategory( budgetId, categoryId, sinceDate ); } else { response = await this.ynabAPI.transactions.getTransactions( budgetId, sinceDate ); } let transactions = response.data.transactions; // Apply date filtering if needed if (untilDate) { transactions = transactions.filter(t => t.date <= untilDate); } // Apply limit if (limit) { transactions = transactions.slice(0, limit); } return transactions; } catch (error) { throw new Error(`Failed to get transactions: ${error.message}`); } } async getPayees(budgetId) { try { budgetId = budgetId || await this.getDefaultBudgetId(); const response = await this.ynabAPI.payees.getPayees(budgetId); return response.data.payees; } catch (error) { throw new Error(`Failed to get payees: ${error.message}`); } } async getScheduledTransactions(budgetId) { try { budgetId = budgetId || await this.getDefaultBudgetId(); const response = await this.ynabAPI.scheduledTransactions.getScheduledTransactions(budgetId); return response.data.scheduled_transactions; } catch (error) { throw new Error(`Failed to get scheduled transactions: ${error.message}`); } } async getMonths(budgetId) { try { budgetId = budgetId || await this.getDefaultBudgetId(); const response = await this.ynabAPI.months.getBudgetMonths(budgetId); return response.data.months; } catch (error) { throw new Error(`Failed to get months: ${error.message}`); } } async getMonth(budgetId, month) { try { budgetId = budgetId || await this.getDefaultBudgetId(); const response = await this.ynabAPI.months.getBudgetMonth(budgetId, month); return response.data.month; } catch (error) { throw new Error(`Failed to get month: ${error.message}`); } } // Utility methods getCurrentMonthInISOFormat() { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); return `${year}-${month}-01`; } milliunitsToAmount(milliunits) { return milliunits / 1000; } amountToMilliunits(amount) { return Math.round(amount * 1000); } formatCurrency(milliunits) { const amount = this.milliunitsToAmount(milliunits); return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount); } } export { YnabClient };