lunchmoney-mcp

by leafeye
Verified
  • src
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import fetch from "node-fetch"; const API_BASE = "https://dev.lunchmoney.app/v1"; interface Tag { id: number; name: string; } interface Transaction { id: number; date: string; amount: string; currency: string; payee: string; category_name: string; category_group_name: string; account_display_name?: string; status: string; notes?: string; tags?: Tag[]; } interface TransactionResponse { transactions: Transaction[]; } interface BudgetData { num_transactions?: number; spending_to_base?: number; budget_to_base?: number; budget_amount?: number; budget_currency?: string; is_automated?: boolean; } interface BudgetConfig { config_id: number; cadence: string; amount: number; currency: string; to_base: number; auto_suggest: string; } interface RecurringItem { payee: string; amount: string; currency: string; to_base: number; } interface Recurring { list: RecurringItem[]; } interface Budget { category_name: string; category_id: number; category_group_name: string | null; group_id: number | null; is_group: boolean | null; is_income: boolean; exclude_from_budget: boolean; exclude_from_totals: boolean; data: Record<string, BudgetData>; config: BudgetConfig | null; order: number; archived: boolean; recurring: Recurring | null; } interface BudgetParams { start_date: string; end_date: string; } //////////////////////////////////////////////////////////////// class LunchmoneyServer { private server: McpServer; private token: string; constructor(token: string) { this.token = token; this.server = new McpServer({ name: "lunchmoney", version: "1.0.0", }); // Register tools this.registerTools(); } private registerTools() { this.server.tool( "get-budget-summary", "Get budget summary for a specific time period", { start_date: z.string().describe("Start date (YYYY-MM-DD, should be start of month)"), end_date: z.string().describe("End date (YYYY-MM-DD, should be end of month)"), }, async ({ start_date, end_date }) => { try { const budgets = await this.fetchBudgets(start_date, end_date); if (!budgets.length) { return { content: [ { type: "text", text: "No budget data found for the specified period.", }, ], }; } return { content: [ { type: "text", text: this.formatBudgetSummary(budgets), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error fetching budget data: ${error}`, }, ], }; } } ); this.server.tool( "get-recent-transactions", "Get recent transactions", { days: z.number().default(30).describe("Number of days to look back"), limit: z.number().default(10).describe("Maximum number of transactions to return"), }, async ({ days, limit }) => { const endDate = new Date().toISOString().split('T')[0]; const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000) .toISOString() .split('T')[0]; const transactions = await this.fetchTransactions({ start_date: startDate, end_date: endDate, limit, }); return { content: [ { type: "text", text: this.formatTransactions(transactions), }, ], }; }, ); this.server.tool( "search-transactions", "Search transactions by keyword", { keyword: z.string().describe("Search term to look for"), days: z.number().default(90).describe("Number of days to look back"), limit: z.number().default(10).describe("Maximum number of transactions to return"), }, async ({ keyword, days, limit }) => { const endDate = new Date().toISOString().split('T')[0]; const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000) .toISOString() .split('T')[0]; const transactions = await this.fetchTransactions({ start_date: startDate, end_date: endDate, limit: 1000, }); const matchingTransactions = transactions.filter( (tx: Transaction) => tx.payee.toLowerCase().includes(keyword.toLowerCase()) || (tx.notes && tx.notes.toLowerCase().includes(keyword.toLowerCase())), ); return { content: [ { type: "text", text: this.formatTransactions(matchingTransactions.slice(0, limit)), }, ], }; }, ); this.server.tool( "get-category-spending", "Get spending in a category", { category: z.string().describe("Category name"), days: z.number().default(30).describe("Number of days to look back"), }, async ({ category, days }) => { const endDate = new Date().toISOString().split('T')[0]; const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000) .toISOString() .split('T')[0]; const transactions = await this.fetchTransactions({ start_date: startDate, end_date: endDate, limit: 1000, }); const matchingTransactions = transactions.filter( (tx: Transaction) => tx.category_name.toLowerCase() === category.toLowerCase(), ); const totals = matchingTransactions.reduce((acc: Record<string, number>, tx: Transaction) => { const currency = tx.currency.toUpperCase(); acc[currency] = (acc[currency] || 0) + Number(tx.amount); return acc; }, {}); let summary = `Spending in '${category}' over the past ${days} days:\n\n`; Object.entries(totals).forEach(([currency, total]) => { summary += `${currency}: ${total.toFixed(2)}\n`; }); if (matchingTransactions.length > 0) { summary += "\nRecent transactions in this category:\n"; summary += this.formatTransactions(matchingTransactions.slice(0, 5)); } return { content: [ { type: "text", text: summary, }, ], }; }, ); } private async fetchBudgets(start_date: string, end_date: string): Promise<Budget[]> { const response = await fetch(`${API_BASE}/budgets?start_date=${start_date}&end_date=${end_date}`, { headers: { Authorization: `Bearer ${this.token}`, Accept: "application/json", } }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return await response.json() as Budget[]; } private formatBudgetSummary(budgets: Budget[]): string { let summary: string[] = []; for (const budget of budgets) { const categoryHeader = `Category: ${budget.category_name}${budget.category_group_name ? ` (${budget.category_group_name})` : ''}`; let budgetInfo: string[] = [categoryHeader]; // Add monthly data Object.entries(budget.data).forEach(([date, data]) => { const monthData = [ `\nMonth: ${date}`, `Transactions: ${data.num_transactions || 0}`, `Spending: ${data.spending_to_base?.toFixed(2) || '0.00'} ${data.budget_currency?.toUpperCase() || 'USD'}`, ]; if (data.budget_amount) { monthData.push(`Budget: ${data.budget_amount.toFixed(2)} ${data.budget_currency?.toUpperCase() || 'USD'}`); const remainingBudget = (data.budget_amount - (data.spending_to_base || 0)).toFixed(2); monthData.push(`Remaining: ${remainingBudget} ${data.budget_currency?.toUpperCase() || 'USD'}`); } budgetInfo.push(monthData.join('\n')); }); // Add recurring items if any if (budget.recurring && budget.recurring.list && budget.recurring.list.length > 0) { budgetInfo.push('\nRecurring Items:'); budget.recurring.list.forEach(item => { budgetInfo.push(`- ${item.payee}: ${item.amount} ${item.currency.toUpperCase()}`); }); } summary.push(budgetInfo.join('\n')); } return summary.join('\n\n---\n\n'); } private async fetchTransactions(params: Record<string, any>): Promise<Transaction[]> { const queryParams = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { queryParams.append(key, value.toString()); } const response = await fetch(`${API_BASE}/transactions?${queryParams}`, { headers: { Authorization: `Bearer ${this.token}`, Accept: "application/json", } }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json() as TransactionResponse; return data.transactions || []; } private formatTransactions(transactions: Transaction[]): string { return transactions .map(tx => { let summary = [ `Date: ${tx.date}`, `Amount: ${tx.amount} ${tx.currency.toUpperCase()}`, `Payee: ${tx.payee}`, `Category: ${tx.category_name} (${tx.category_group_name})`, `Account: ${tx.account_display_name || "N/A"}`, `Status: ${tx.status}`, ]; if (tx.tags && tx.tags.length > 0) { summary.push(`Tags: ${tx.tags.map((t: Tag) => t.name).join(", ")}`); } if (tx.notes) { summary.push(`Notes: ${tx.notes}`); } return summary.join("\n"); }) .join("\n\n---\n\n"); } async start() { const transport = new StdioServerTransport(); await this.server.connect(transport); } } // Check if LUNCHMONEY_TOKEN environment variable is set const token = process.env.LUNCHMONEY_TOKEN; if (!token) { console.error("Error: LUNCHMONEY_TOKEN environment variable is required"); process.exit(1); } const server = new LunchmoneyServer(token); server.start().catch(error => { console.error("Fatal error:", error); process.exit(1); });