diff --git a/MoneyMap.Mcp/MoneyMap.Mcp.csproj b/MoneyMap.Mcp/MoneyMap.Mcp.csproj
index b5a3596..752b275 100644
--- a/MoneyMap.Mcp/MoneyMap.Mcp.csproj
+++ b/MoneyMap.Mcp/MoneyMap.Mcp.csproj
@@ -11,7 +11,7 @@
-
+
diff --git a/MoneyMap.Mcp/Tools/AccountTools.cs b/MoneyMap.Mcp/Tools/AccountTools.cs
new file mode 100644
index 0000000..0c387b5
--- /dev/null
+++ b/MoneyMap.Mcp/Tools/AccountTools.cs
@@ -0,0 +1,67 @@
+using System.ComponentModel;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using ModelContextProtocol.Server;
+using MoneyMap.Data;
+
+namespace MoneyMap.Mcp.Tools;
+
+[McpServerToolType]
+public static class AccountTools
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ [McpServerTool(Name = "list_accounts"), Description("List all accounts with transaction counts.")]
+ public static async Task ListAccounts(
+ MoneyMapContext db = default!)
+ {
+ var accounts = await db.Accounts
+ .Include(a => a.Cards)
+ .Include(a => a.Transactions)
+ .OrderBy(a => a.Institution).ThenBy(a => a.Last4)
+ .Select(a => new
+ {
+ a.Id,
+ a.Institution,
+ a.Last4,
+ a.Owner,
+ Label = a.DisplayLabel,
+ TransactionCount = a.Transactions.Count,
+ CardCount = a.Cards.Count
+ })
+ .ToListAsync();
+
+ return JsonSerializer.Serialize(accounts, JsonOptions);
+ }
+
+ [McpServerTool(Name = "list_cards"), Description("List all cards with account info and transaction counts.")]
+ public static async Task ListCards(
+ [Description("Filter cards by account ID")] int? accountId = null,
+ MoneyMapContext db = default!)
+ {
+ var q = db.Cards
+ .Include(c => c.Account)
+ .Include(c => c.Transactions)
+ .AsQueryable();
+
+ if (accountId.HasValue)
+ q = q.Where(c => c.AccountId == accountId.Value);
+
+ var cards = await q
+ .OrderBy(c => c.Owner).ThenBy(c => c.Last4)
+ .Select(c => new
+ {
+ c.Id,
+ c.Issuer,
+ c.Last4,
+ c.Owner,
+ Label = c.DisplayLabel,
+ Account = c.Account != null ? c.Account.Institution + " " + c.Account.Last4 : null,
+ AccountId = c.AccountId,
+ TransactionCount = c.Transactions.Count
+ })
+ .ToListAsync();
+
+ return JsonSerializer.Serialize(cards, JsonOptions);
+ }
+}
diff --git a/MoneyMap.Mcp/Tools/BudgetTools.cs b/MoneyMap.Mcp/Tools/BudgetTools.cs
new file mode 100644
index 0000000..f80dd07
--- /dev/null
+++ b/MoneyMap.Mcp/Tools/BudgetTools.cs
@@ -0,0 +1,96 @@
+using System.ComponentModel;
+using System.Text.Json;
+using ModelContextProtocol.Server;
+using MoneyMap.Models;
+using MoneyMap.Services;
+
+namespace MoneyMap.Mcp.Tools;
+
+[McpServerToolType]
+public static class BudgetTools
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ [McpServerTool(Name = "get_budget_status"), Description("Get all active budgets with current period spending vs. limit.")]
+ public static async Task GetBudgetStatus(
+ [Description("Date to calculate status for (defaults to today)")] string? asOfDate = null,
+ IBudgetService budgetService = default!)
+ {
+ DateTime? date = null;
+ if (!string.IsNullOrWhiteSpace(asOfDate) && DateTime.TryParse(asOfDate, out var parsed))
+ date = parsed;
+
+ var statuses = await budgetService.GetAllBudgetStatusesAsync(date);
+
+ var result = statuses.Select(s => new
+ {
+ s.Budget.Id,
+ Category = s.Budget.DisplayName,
+ s.Budget.Amount,
+ Period = s.Budget.Period.ToString(),
+ s.PeriodStart,
+ s.PeriodEnd,
+ s.Spent,
+ s.Remaining,
+ PercentUsed = Math.Round(s.PercentUsed, 1),
+ s.IsOverBudget
+ }).ToList();
+
+ return JsonSerializer.Serialize(result, JsonOptions);
+ }
+
+ [McpServerTool(Name = "create_budget"), Description("Create a new budget for a category or total spending.")]
+ public static async Task CreateBudget(
+ [Description("Budget amount limit")] decimal amount,
+ [Description("Period: Weekly, Monthly, or Yearly")] string period,
+ [Description("Start date for period calculation, e.g. 2026-01-01")] string startDate,
+ [Description("Category name (omit for total spending budget)")] string? category = null,
+ IBudgetService budgetService = default!)
+ {
+ if (!Enum.TryParse(period, true, out var budgetPeriod))
+ return $"Invalid period '{period}'. Must be Weekly, Monthly, or Yearly.";
+
+ var budget = new Budget
+ {
+ Category = category,
+ Amount = amount,
+ Period = budgetPeriod,
+ StartDate = DateTime.Parse(startDate),
+ IsActive = true
+ };
+
+ var result = await budgetService.CreateBudgetAsync(budget);
+
+ return JsonSerializer.Serialize(new { result.Success, result.Message, BudgetId = budget.Id }, JsonOptions);
+ }
+
+ [McpServerTool(Name = "update_budget"), Description("Update an existing budget's amount, period, or active status.")]
+ public static async Task UpdateBudget(
+ [Description("Budget ID to update")] int budgetId,
+ [Description("New budget amount")] decimal? amount = null,
+ [Description("New period: Weekly, Monthly, or Yearly")] string? period = null,
+ [Description("Set active/inactive")] bool? isActive = null,
+ IBudgetService budgetService = default!)
+ {
+ var budget = await budgetService.GetBudgetByIdAsync(budgetId);
+ if (budget == null)
+ return "Budget not found";
+
+ if (amount.HasValue)
+ budget.Amount = amount.Value;
+
+ if (!string.IsNullOrWhiteSpace(period))
+ {
+ if (!Enum.TryParse(period, true, out var budgetPeriod))
+ return $"Invalid period '{period}'. Must be Weekly, Monthly, or Yearly.";
+ budget.Period = budgetPeriod;
+ }
+
+ if (isActive.HasValue)
+ budget.IsActive = isActive.Value;
+
+ var result = await budgetService.UpdateBudgetAsync(budget);
+
+ return JsonSerializer.Serialize(new { result.Success, result.Message }, JsonOptions);
+ }
+}
diff --git a/MoneyMap.Mcp/Tools/CategoryTools.cs b/MoneyMap.Mcp/Tools/CategoryTools.cs
new file mode 100644
index 0000000..bc93b8a
--- /dev/null
+++ b/MoneyMap.Mcp/Tools/CategoryTools.cs
@@ -0,0 +1,80 @@
+using System.ComponentModel;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using ModelContextProtocol.Server;
+using MoneyMap.Data;
+using MoneyMap.Services;
+
+namespace MoneyMap.Mcp.Tools;
+
+[McpServerToolType]
+public static class CategoryTools
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ [McpServerTool(Name = "list_categories"), Description("List all categories with transaction counts.")]
+ public static async Task ListCategories(
+ MoneyMapContext db = default!)
+ {
+ var categories = await db.Transactions
+ .Where(t => t.Category != null && t.Category != "")
+ .GroupBy(t => t.Category!)
+ .Select(g => new { Category = g.Key, Count = g.Count(), TotalSpent = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)) })
+ .OrderByDescending(x => x.Count)
+ .ToListAsync();
+
+ var uncategorized = await db.Transactions
+ .CountAsync(t => t.Category == null || t.Category == "");
+
+ return JsonSerializer.Serialize(new { Categories = categories, UncategorizedCount = uncategorized }, JsonOptions);
+ }
+
+ [McpServerTool(Name = "get_category_mappings"), Description("Get auto-categorization pattern rules (CategoryMappings).")]
+ public static async Task GetCategoryMappings(
+ [Description("Filter mappings to a specific category")] string? category = null,
+ ITransactionCategorizer categorizer = default!)
+ {
+ var mappings = await categorizer.GetAllMappingsAsync();
+
+ if (!string.IsNullOrWhiteSpace(category))
+ mappings = mappings.Where(m => m.Category.Equals(category, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ var result = mappings.Select(m => new
+ {
+ m.Id,
+ m.Pattern,
+ m.Category,
+ m.MerchantId,
+ m.Priority
+ }).OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList();
+
+ return JsonSerializer.Serialize(result, JsonOptions);
+ }
+
+ [McpServerTool(Name = "add_category_mapping"), Description("Add a new auto-categorization rule that maps transaction name patterns to categories.")]
+ public static async Task AddCategoryMapping(
+ [Description("Pattern to match in transaction name (case-insensitive)")] string pattern,
+ [Description("Category to assign when pattern matches")] string category,
+ [Description("Merchant name to assign (creates if new)")] string? merchantName = null,
+ [Description("Priority (higher = checked first, default 0)")] int priority = 0,
+ MoneyMapContext db = default!,
+ IMerchantService merchantService = default!)
+ {
+ int? merchantId = null;
+ if (!string.IsNullOrWhiteSpace(merchantName))
+ merchantId = await merchantService.GetOrCreateIdAsync(merchantName);
+
+ var mapping = new MoneyMap.Models.CategoryMapping
+ {
+ Pattern = pattern,
+ Category = category,
+ MerchantId = merchantId,
+ Priority = priority
+ };
+
+ db.CategoryMappings.Add(mapping);
+ await db.SaveChangesAsync();
+
+ return JsonSerializer.Serialize(new { Created = true, mapping.Id, mapping.Pattern, mapping.Category, Merchant = merchantName, mapping.Priority }, JsonOptions);
+ }
+}
diff --git a/MoneyMap.Mcp/Tools/DashboardTools.cs b/MoneyMap.Mcp/Tools/DashboardTools.cs
new file mode 100644
index 0000000..98d80c2
--- /dev/null
+++ b/MoneyMap.Mcp/Tools/DashboardTools.cs
@@ -0,0 +1,68 @@
+using System.ComponentModel;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using ModelContextProtocol.Server;
+using MoneyMap.Data;
+using MoneyMap.Services;
+
+namespace MoneyMap.Mcp.Tools;
+
+[McpServerToolType]
+public static class DashboardTools
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ [McpServerTool(Name = "get_dashboard"), Description("Get dashboard overview: top spending categories, recent transactions, and aggregate stats.")]
+ public static async Task GetDashboard(
+ [Description("Number of top categories to show (default 8)")] int? topCategoriesCount = null,
+ [Description("Number of recent transactions to show (default 20)")] int? recentTransactionsCount = null,
+ IDashboardService dashboardService = default!)
+ {
+ var data = await dashboardService.GetDashboardDataAsync(
+ topCategoriesCount ?? 8,
+ recentTransactionsCount ?? 20);
+
+ return JsonSerializer.Serialize(data, JsonOptions);
+ }
+
+ [McpServerTool(Name = "get_monthly_trend"), Description("Get month-over-month spending totals for trend analysis.")]
+ public static async Task GetMonthlyTrend(
+ [Description("Number of months to include (default 6)")] int? months = null,
+ [Description("Filter to a specific category")] string? category = null,
+ MoneyMapContext db = default!)
+ {
+ var monthCount = months ?? 6;
+ var endDate = DateTime.Today;
+ var startDate = new DateTime(endDate.Year, endDate.Month, 1).AddMonths(-(monthCount - 1));
+
+ var q = db.Transactions
+ .Where(t => t.Date >= startDate && t.Date <= endDate)
+ .Where(t => t.Amount < 0)
+ .Where(t => t.TransferToAccountId == null)
+ .ExcludeTransfers();
+
+ if (!string.IsNullOrWhiteSpace(category))
+ q = q.Where(t => t.Category == category);
+
+ var monthly = await q
+ .GroupBy(t => new { t.Date.Year, t.Date.Month })
+ .Select(g => new
+ {
+ Year = g.Key.Year,
+ Month = g.Key.Month,
+ Total = g.Sum(t => Math.Abs(t.Amount)),
+ Count = g.Count()
+ })
+ .OrderBy(x => x.Year).ThenBy(x => x.Month)
+ .ToListAsync();
+
+ var result = monthly.Select(m => new
+ {
+ Period = $"{m.Year}-{m.Month:D2}",
+ m.Total,
+ m.Count
+ }).ToList();
+
+ return JsonSerializer.Serialize(new { Category = category ?? "All Spending", Months = result }, JsonOptions);
+ }
+}
diff --git a/MoneyMap.Mcp/Tools/MerchantTools.cs b/MoneyMap.Mcp/Tools/MerchantTools.cs
new file mode 100644
index 0000000..2a7b3fb
--- /dev/null
+++ b/MoneyMap.Mcp/Tools/MerchantTools.cs
@@ -0,0 +1,95 @@
+using System.ComponentModel;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using ModelContextProtocol.Server;
+using MoneyMap.Data;
+
+namespace MoneyMap.Mcp.Tools;
+
+[McpServerToolType]
+public static class MerchantTools
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ [McpServerTool(Name = "list_merchants"), Description("List all merchants with transaction counts and category mapping info.")]
+ public static async Task ListMerchants(
+ [Description("Filter merchants by name (contains)")] string? query = null,
+ MoneyMapContext db = default!)
+ {
+ var q = db.Merchants
+ .Include(m => m.Transactions)
+ .Include(m => m.CategoryMappings)
+ .AsQueryable();
+
+ if (!string.IsNullOrWhiteSpace(query))
+ q = q.Where(m => m.Name.Contains(query));
+
+ var merchants = await q
+ .OrderBy(m => m.Name)
+ .Select(m => new
+ {
+ m.Id,
+ m.Name,
+ TransactionCount = m.Transactions.Count,
+ MappingCount = m.CategoryMappings.Count,
+ Categories = m.CategoryMappings.Select(cm => cm.Category).Distinct().ToList()
+ })
+ .ToListAsync();
+
+ return JsonSerializer.Serialize(new { Count = merchants.Count, Merchants = merchants }, JsonOptions);
+ }
+
+ [McpServerTool(Name = "merge_merchants"), Description("Merge duplicate merchants. Reassigns all transactions and category mappings from source to target, then deletes source.")]
+ public static async Task MergeMerchants(
+ [Description("Merchant ID to merge FROM (will be deleted)")] int sourceMerchantId,
+ [Description("Merchant ID to merge INTO (will be kept)")] int targetMerchantId,
+ MoneyMapContext db = default!)
+ {
+ if (sourceMerchantId == targetMerchantId)
+ return "Source and target merchant cannot be the same";
+
+ var source = await db.Merchants.FindAsync(sourceMerchantId);
+ var target = await db.Merchants.FindAsync(targetMerchantId);
+
+ if (source == null)
+ return $"Source merchant {sourceMerchantId} not found";
+ if (target == null)
+ return $"Target merchant {targetMerchantId} not found";
+
+ var transactions = await db.Transactions
+ .Where(t => t.MerchantId == sourceMerchantId)
+ .ToListAsync();
+
+ foreach (var t in transactions)
+ t.MerchantId = targetMerchantId;
+
+ var sourceMappings = await db.CategoryMappings
+ .Where(cm => cm.MerchantId == sourceMerchantId)
+ .ToListAsync();
+
+ var targetMappingPatterns = await db.CategoryMappings
+ .Where(cm => cm.MerchantId == targetMerchantId)
+ .Select(cm => cm.Pattern)
+ .ToListAsync();
+
+ foreach (var mapping in sourceMappings)
+ {
+ if (targetMappingPatterns.Contains(mapping.Pattern))
+ db.CategoryMappings.Remove(mapping);
+ else
+ mapping.MerchantId = targetMerchantId;
+ }
+
+ db.Merchants.Remove(source);
+ await db.SaveChangesAsync();
+
+ return JsonSerializer.Serialize(new
+ {
+ Merged = true,
+ Source = new { source.Id, source.Name },
+ Target = new { target.Id, target.Name },
+ TransactionsReassigned = transactions.Count,
+ MappingsReassigned = sourceMappings.Count
+ }, JsonOptions);
+ }
+}
diff --git a/MoneyMap.Mcp/Tools/ReceiptTools.cs b/MoneyMap.Mcp/Tools/ReceiptTools.cs
new file mode 100644
index 0000000..6b721d2
--- /dev/null
+++ b/MoneyMap.Mcp/Tools/ReceiptTools.cs
@@ -0,0 +1,199 @@
+using System.ComponentModel;
+using System.Text.Json;
+using ImageMagick;
+using Microsoft.EntityFrameworkCore;
+using ModelContextProtocol.Server;
+using MoneyMap.Data;
+using MoneyMap.Models;
+using MoneyMap.Services;
+
+namespace MoneyMap.Mcp.Tools;
+
+[McpServerToolType]
+public static class ReceiptTools
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ [McpServerTool(Name = "get_receipt_image"), Description("Get a receipt image for visual inspection. Returns the image as base64-encoded data. Useful for verifying transaction categories.")]
+ public static async Task GetReceiptImage(
+ [Description("Receipt ID")] long receiptId,
+ MoneyMapContext db = default!,
+ IReceiptStorageOptions storageOptions = default!)
+ {
+ var receipt = await db.Receipts.FindAsync(receiptId);
+ if (receipt == null)
+ return "Receipt not found";
+
+ var basePath = Path.GetFullPath(storageOptions.ReceiptsBasePath);
+ var fullPath = Path.GetFullPath(Path.Combine(basePath, receipt.StoragePath));
+
+ if (!fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
+ return "Invalid receipt path";
+
+ if (!File.Exists(fullPath))
+ return "Receipt file not found on disk";
+
+ byte[] imageBytes;
+ string mimeType;
+
+ if (receipt.ContentType == "application/pdf")
+ {
+ var settings = new MagickReadSettings { Density = new Density(220) };
+ using var image = new MagickImage(fullPath + "[0]", settings);
+ image.Format = MagickFormat.Png;
+ image.BackgroundColor = MagickColors.White;
+ image.Alpha(AlphaOption.Remove);
+ imageBytes = image.ToByteArray();
+ mimeType = "image/png";
+ }
+ else
+ {
+ imageBytes = await File.ReadAllBytesAsync(fullPath);
+ mimeType = receipt.ContentType;
+ }
+
+ var base64 = Convert.ToBase64String(imageBytes);
+ return JsonSerializer.Serialize(new { MimeType = mimeType, Data = base64, SizeBytes = imageBytes.Length }, JsonOptions);
+ }
+
+ [McpServerTool(Name = "get_receipt_text"), Description("Get already-parsed receipt data as structured text. Avoids re-analyzing the image when parse data exists.")]
+ public static async Task GetReceiptText(
+ [Description("Receipt ID")] long receiptId,
+ MoneyMapContext db = default!)
+ {
+ var receipt = await db.Receipts
+ .Include(r => r.LineItems)
+ .Include(r => r.Transaction)
+ .FirstOrDefaultAsync(r => r.Id == receiptId);
+
+ if (receipt == null)
+ return "Receipt not found";
+
+ if (receipt.ParseStatus != ReceiptParseStatus.Completed)
+ return JsonSerializer.Serialize(new { Message = "Receipt has not been parsed yet", ParseStatus = receipt.ParseStatus.ToString() }, JsonOptions);
+
+ var result = new
+ {
+ receipt.Id,
+ receipt.Merchant,
+ receipt.ReceiptDate,
+ receipt.DueDate,
+ receipt.Subtotal,
+ receipt.Tax,
+ receipt.Total,
+ receipt.Currency,
+ LinkedTransaction = receipt.Transaction != null ? new { receipt.Transaction.Id, receipt.Transaction.Name, receipt.Transaction.Category, receipt.Transaction.Amount } : null,
+ LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new
+ {
+ li.LineNumber,
+ li.Description,
+ li.Quantity,
+ li.UnitPrice,
+ li.LineTotal,
+ li.Category
+ }).ToList()
+ };
+
+ return JsonSerializer.Serialize(result, JsonOptions);
+ }
+
+ [McpServerTool(Name = "list_receipts"), Description("List receipts with their parse status and basic info.")]
+ public static async Task ListReceipts(
+ [Description("Filter by transaction ID")] long? transactionId = null,
+ [Description("Filter by parse status: NotRequested, Queued, Parsing, Completed, Failed")] string? parseStatus = null,
+ [Description("Max results (default 50)")] int? limit = null,
+ MoneyMapContext db = default!)
+ {
+ var q = db.Receipts
+ .Include(r => r.Transaction)
+ .AsQueryable();
+
+ if (transactionId.HasValue)
+ q = q.Where(r => r.TransactionId == transactionId.Value);
+
+ if (!string.IsNullOrWhiteSpace(parseStatus) && Enum.TryParse(parseStatus, true, out var status))
+ q = q.Where(r => r.ParseStatus == status);
+
+ var results = await q
+ .OrderByDescending(r => r.UploadedAtUtc)
+ .Take(limit ?? 50)
+ .Select(r => new
+ {
+ r.Id,
+ r.FileName,
+ ParseStatus = r.ParseStatus.ToString(),
+ r.Merchant,
+ r.Total,
+ r.ReceiptDate,
+ r.UploadedAtUtc,
+ TransactionId = r.TransactionId,
+ TransactionName = r.Transaction != null ? r.Transaction.Name : null
+ })
+ .ToListAsync();
+
+ return JsonSerializer.Serialize(new { Count = results.Count, Receipts = results }, JsonOptions);
+ }
+
+ [McpServerTool(Name = "get_receipt_details"), Description("Get full receipt details including parsed data and all line items.")]
+ public static async Task GetReceiptDetails(
+ [Description("Receipt ID")] long receiptId,
+ MoneyMapContext db = default!)
+ {
+ var receipt = await db.Receipts
+ .Include(r => r.LineItems)
+ .Include(r => r.Transaction)
+ .Include(r => r.ParseLogs)
+ .FirstOrDefaultAsync(r => r.Id == receiptId);
+
+ if (receipt == null)
+ return "Receipt not found";
+
+ var result = new
+ {
+ receipt.Id,
+ receipt.FileName,
+ receipt.ContentType,
+ receipt.FileSizeBytes,
+ receipt.UploadedAtUtc,
+ ParseStatus = receipt.ParseStatus.ToString(),
+ ParsedData = new
+ {
+ receipt.Merchant,
+ receipt.ReceiptDate,
+ receipt.DueDate,
+ receipt.Subtotal,
+ receipt.Tax,
+ receipt.Total,
+ receipt.Currency
+ },
+ LinkedTransaction = receipt.Transaction != null ? new
+ {
+ receipt.Transaction.Id,
+ receipt.Transaction.Name,
+ receipt.Transaction.Date,
+ receipt.Transaction.Amount,
+ receipt.Transaction.Category
+ } : null,
+ LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new
+ {
+ li.LineNumber,
+ li.Description,
+ li.Quantity,
+ li.UnitPrice,
+ li.LineTotal,
+ li.Category
+ }).ToList(),
+ ParseHistory = receipt.ParseLogs.OrderByDescending(pl => pl.StartedAtUtc).Select(pl => new
+ {
+ pl.Provider,
+ pl.Model,
+ pl.Success,
+ pl.Confidence,
+ pl.Error,
+ pl.StartedAtUtc
+ }).ToList()
+ };
+
+ return JsonSerializer.Serialize(result, JsonOptions);
+ }
+}
diff --git a/MoneyMap.Mcp/Tools/TransactionTools.cs b/MoneyMap.Mcp/Tools/TransactionTools.cs
new file mode 100644
index 0000000..c3412b5
--- /dev/null
+++ b/MoneyMap.Mcp/Tools/TransactionTools.cs
@@ -0,0 +1,266 @@
+using System.ComponentModel;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using ModelContextProtocol.Server;
+using MoneyMap.Data;
+using MoneyMap.Services;
+
+namespace MoneyMap.Mcp.Tools;
+
+[McpServerToolType]
+public static class TransactionTools
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ [McpServerTool(Name = "search_transactions"), Description("Search and filter transactions. Returns matching transactions with details.")]
+ public static async Task SearchTransactions(
+ [Description("Full-text search across name, memo, and category")] string? query = null,
+ [Description("Start date (inclusive), e.g. 2026-01-01")] string? startDate = null,
+ [Description("End date (inclusive), e.g. 2026-01-31")] string? endDate = null,
+ [Description("Filter by category name (exact match)")] string? category = null,
+ [Description("Filter by merchant name (contains)")] string? merchantName = null,
+ [Description("Minimum amount (absolute value)")] decimal? minAmount = null,
+ [Description("Maximum amount (absolute value)")] decimal? maxAmount = null,
+ [Description("Filter by account ID")] int? accountId = null,
+ [Description("Filter by card ID")] int? cardId = null,
+ [Description("Filter by type: 'debit' or 'credit'")] string? type = null,
+ [Description("Only show uncategorized transactions")] bool? uncategorizedOnly = null,
+ [Description("Max results to return (default 50)")] int? limit = null,
+ MoneyMapContext db = default!)
+ {
+ var q = db.Transactions
+ .Include(t => t.Merchant)
+ .Include(t => t.Card)
+ .Include(t => t.Account)
+ .Include(t => t.Receipts)
+ .AsQueryable();
+
+ if (!string.IsNullOrWhiteSpace(query))
+ q = q.Where(t => t.Name.Contains(query) || (t.Memo != null && t.Memo.Contains(query)) || (t.Category != null && t.Category.Contains(query)));
+
+ if (!string.IsNullOrWhiteSpace(startDate) && DateTime.TryParse(startDate, out var start))
+ q = q.Where(t => t.Date >= start);
+
+ if (!string.IsNullOrWhiteSpace(endDate) && DateTime.TryParse(endDate, out var end))
+ q = q.Where(t => t.Date <= end);
+
+ if (!string.IsNullOrWhiteSpace(category))
+ q = q.Where(t => t.Category == category);
+
+ if (!string.IsNullOrWhiteSpace(merchantName))
+ q = q.Where(t => t.Merchant != null && t.Merchant.Name.Contains(merchantName));
+
+ if (minAmount.HasValue)
+ q = q.Where(t => Math.Abs(t.Amount) >= minAmount.Value);
+
+ if (maxAmount.HasValue)
+ q = q.Where(t => Math.Abs(t.Amount) <= maxAmount.Value);
+
+ if (accountId.HasValue)
+ q = q.Where(t => t.AccountId == accountId.Value);
+
+ if (cardId.HasValue)
+ q = q.Where(t => t.CardId == cardId.Value);
+
+ if (type?.ToLower() == "debit")
+ q = q.Where(t => t.Amount < 0);
+ else if (type?.ToLower() == "credit")
+ q = q.Where(t => t.Amount > 0);
+
+ if (uncategorizedOnly == true)
+ q = q.Where(t => t.Category == null || t.Category == "");
+
+ var results = await q
+ .OrderByDescending(t => t.Date).ThenByDescending(t => t.Id)
+ .Take(limit ?? 50)
+ .Select(t => new
+ {
+ t.Id,
+ t.Date,
+ t.Name,
+ t.Memo,
+ t.Amount,
+ t.Category,
+ Merchant = t.Merchant != null ? t.Merchant.Name : null,
+ Account = t.Account!.Institution + " " + t.Account.Last4,
+ Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null,
+ ReceiptCount = t.Receipts.Count,
+ t.TransferToAccountId
+ })
+ .ToListAsync();
+
+ return JsonSerializer.Serialize(new { Count = results.Count, Transactions = results }, JsonOptions);
+ }
+
+ [McpServerTool(Name = "get_transaction"), Description("Get a single transaction with all details including receipts.")]
+ public static async Task GetTransaction(
+ [Description("Transaction ID")] long transactionId,
+ MoneyMapContext db = default!)
+ {
+ var t = await db.Transactions
+ .Include(t => t.Merchant)
+ .Include(t => t.Card)
+ .Include(t => t.Account)
+ .Include(t => t.Receipts)
+ .Where(t => t.Id == transactionId)
+ .Select(t => new
+ {
+ t.Id,
+ t.Date,
+ t.Name,
+ t.Memo,
+ t.Amount,
+ t.TransactionType,
+ t.Category,
+ Merchant = t.Merchant != null ? t.Merchant.Name : null,
+ MerchantId = t.MerchantId,
+ Account = t.Account!.Institution + " " + t.Account.Last4,
+ AccountId = t.AccountId,
+ Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null,
+ CardId = t.CardId,
+ t.Notes,
+ t.TransferToAccountId,
+ Receipts = t.Receipts.Select(r => new { r.Id, r.FileName, r.ParseStatus, r.Merchant, r.Total }).ToList()
+ })
+ .FirstOrDefaultAsync();
+
+ if (t == null)
+ return "Transaction not found";
+
+ return JsonSerializer.Serialize(t, JsonOptions);
+ }
+
+ [McpServerTool(Name = "get_spending_summary"), Description("Get spending totals grouped by category for a date range. Excludes transfers.")]
+ public static async Task GetSpendingSummary(
+ [Description("Start date (inclusive), e.g. 2026-01-01")] string startDate,
+ [Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
+ [Description("Filter to specific account ID")] int? accountId = null,
+ MoneyMapContext db = default!)
+ {
+ var start = DateTime.Parse(startDate);
+ var end = DateTime.Parse(endDate);
+
+ var q = db.Transactions
+ .Where(t => t.Date >= start && t.Date <= end)
+ .Where(t => t.Amount < 0)
+ .Where(t => t.TransferToAccountId == null)
+ .ExcludeTransfers();
+
+ if (accountId.HasValue)
+ q = q.Where(t => t.AccountId == accountId.Value);
+
+ var summary = await q
+ .GroupBy(t => t.Category ?? "Uncategorized")
+ .Select(g => new { Category = g.Key, Total = g.Sum(t => Math.Abs(t.Amount)), Count = g.Count() })
+ .OrderByDescending(x => x.Total)
+ .ToListAsync();
+
+ var grandTotal = summary.Sum(x => x.Total);
+
+ return JsonSerializer.Serialize(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Categories = summary }, JsonOptions);
+ }
+
+ [McpServerTool(Name = "get_income_summary"), Description("Get income (credits) grouped by source/name for a date range.")]
+ public static async Task GetIncomeSummary(
+ [Description("Start date (inclusive), e.g. 2026-01-01")] string startDate,
+ [Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
+ [Description("Filter to specific account ID")] int? accountId = null,
+ MoneyMapContext db = default!)
+ {
+ var start = DateTime.Parse(startDate);
+ var end = DateTime.Parse(endDate);
+
+ var q = db.Transactions
+ .Where(t => t.Date >= start && t.Date <= end)
+ .Where(t => t.Amount > 0)
+ .Where(t => t.TransferToAccountId == null)
+ .ExcludeTransfers();
+
+ if (accountId.HasValue)
+ q = q.Where(t => t.AccountId == accountId.Value);
+
+ var summary = await q
+ .GroupBy(t => t.Name)
+ .Select(g => new { Source = g.Key, Total = g.Sum(t => t.Amount), Count = g.Count() })
+ .OrderByDescending(x => x.Total)
+ .ToListAsync();
+
+ var grandTotal = summary.Sum(x => x.Total);
+
+ return JsonSerializer.Serialize(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Sources = summary }, JsonOptions);
+ }
+
+ [McpServerTool(Name = "update_transaction_category"), Description("Update the category (and optionally merchant) on one or more transactions.")]
+ public static async Task UpdateTransactionCategory(
+ [Description("Array of transaction IDs to update")] long[] transactionIds,
+ [Description("New category to assign")] string category,
+ [Description("Merchant name to assign (creates if new)")] string? merchantName = null,
+ MoneyMapContext db = default!,
+ IMerchantService merchantService = default!)
+ {
+ var transactions = await db.Transactions
+ .Where(t => transactionIds.Contains(t.Id))
+ .ToListAsync();
+
+ if (!transactions.Any())
+ return "No transactions found with the provided IDs";
+
+ int? merchantId = null;
+ if (!string.IsNullOrWhiteSpace(merchantName))
+ merchantId = await merchantService.GetOrCreateIdAsync(merchantName);
+
+ foreach (var t in transactions)
+ {
+ t.Category = category;
+ if (merchantId.HasValue)
+ t.MerchantId = merchantId;
+ }
+
+ await db.SaveChangesAsync();
+
+ return JsonSerializer.Serialize(new { Updated = transactions.Count, Category = category, Merchant = merchantName }, JsonOptions);
+ }
+
+ [McpServerTool(Name = "bulk_recategorize"), Description("Recategorize all transactions matching a name pattern. Use dryRun=true (default) to preview changes first.")]
+ public static async Task BulkRecategorize(
+ [Description("Pattern to match in transaction name (case-insensitive contains)")] string namePattern,
+ [Description("New category to assign")] string toCategory,
+ [Description("Only recategorize transactions currently in this category")] string? fromCategory = null,
+ [Description("Merchant name to assign (creates if new)")] string? merchantName = null,
+ [Description("If true (default), only shows what would change without applying")] bool dryRun = true,
+ MoneyMapContext db = default!,
+ IMerchantService merchantService = default!)
+ {
+ var q = db.Transactions
+ .Where(t => t.Name.Contains(namePattern));
+
+ if (!string.IsNullOrWhiteSpace(fromCategory))
+ q = q.Where(t => t.Category == fromCategory);
+
+ var transactions = await q.ToListAsync();
+
+ if (!transactions.Any())
+ return JsonSerializer.Serialize(new { Message = "No transactions match the pattern", Pattern = namePattern, FromCategory = fromCategory }, JsonOptions);
+
+ if (dryRun)
+ {
+ var preview = transactions.Take(20).Select(t => new { t.Id, t.Date, t.Name, t.Amount, CurrentCategory = t.Category }).ToList();
+ return JsonSerializer.Serialize(new { DryRun = true, TotalMatches = transactions.Count, Preview = preview, ToCategory = toCategory }, JsonOptions);
+ }
+
+ int? merchantId = null;
+ if (!string.IsNullOrWhiteSpace(merchantName))
+ merchantId = await merchantService.GetOrCreateIdAsync(merchantName);
+
+ foreach (var t in transactions)
+ {
+ t.Category = toCategory;
+ if (merchantId.HasValue)
+ t.MerchantId = merchantId;
+ }
+
+ await db.SaveChangesAsync();
+
+ return JsonSerializer.Serialize(new { Applied = true, Updated = transactions.Count, ToCategory = toCategory, Merchant = merchantName }, JsonOptions);
+ }
+}