feat(mcp): implement all MCP tools (transactions, budgets, categories, receipts, merchants, accounts, dashboard)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user