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:
@@ -11,7 +11,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ModelContextProtocol" Version="0.*-*" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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<string> 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<string> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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<string> 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<BudgetPeriod>(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<string> 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<BudgetPeriod>(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);
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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<string> 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<string> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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<string> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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<string> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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<string> 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<string> 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<ReceiptParseStatus>(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<string> 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);
|
||||
}
|
||||
}
|
||||
@@ -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