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); } }