refactor(mcp): rewrite all tools to use MoneyMapApiClient instead of direct DB access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 20:40:23 -04:00
parent 4bee73ba26
commit 274569bd79
7 changed files with 44 additions and 650 deletions
+12 -201
View File
@@ -1,17 +1,11 @@
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<string> SearchTransactions(
[Description("Full-text search across name, memo, and category")] string? query = null,
@@ -26,108 +20,17 @@ public static class TransactionTools
[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!)
MoneyMapApiClient api = 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);
return await api.SearchTransactionsAsync(query, startDate, endDate, category, merchantName, minAmount, maxAmount, accountId, cardId, type, uncategorizedOnly, limit);
}
[McpServerTool(Name = "get_transaction"), Description("Get a single transaction with all details including receipts.")]
public static async Task<string> GetTransaction(
[Description("Transaction ID")] long transactionId,
MoneyMapContext db = default!)
MoneyMapApiClient api = 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);
return await api.GetTransactionAsync(transactionId);
}
[McpServerTool(Name = "get_spending_summary"), Description("Get spending totals grouped by category for a date range. Excludes transfers.")]
@@ -135,29 +38,9 @@ public static class TransactionTools
[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!)
MoneyMapApiClient api = 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);
return await api.GetSpendingSummaryAsync(startDate, endDate, accountId);
}
[McpServerTool(Name = "get_income_summary"), Description("Get income (credits) grouped by source/name for a date range.")]
@@ -165,29 +48,9 @@ public static class TransactionTools
[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!)
MoneyMapApiClient api = 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);
return await api.GetIncomeSummaryAsync(startDate, endDate, accountId);
}
[McpServerTool(Name = "update_transaction_category"), Description("Update the category (and optionally merchant) on one or more transactions.")]
@@ -195,30 +58,9 @@ public static class TransactionTools
[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!)
MoneyMapApiClient api = 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);
return await api.UpdateTransactionCategoryAsync(transactionIds, category, merchantName);
}
[McpServerTool(Name = "bulk_recategorize"), Description("Recategorize all transactions matching a name pattern. Use dryRun=true (default) to preview changes first.")]
@@ -228,39 +70,8 @@ public static class TransactionTools
[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!)
MoneyMapApiClient api = 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);
return await api.BulkRecategorizeAsync(namePattern, toCategory, fromCategory, merchantName, dryRun);
}
}