Compare commits

...

9 Commits

Author SHA1 Message Date
aj 7b2d6203df fix: update Dockerfile for multi-project solution structure
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 19:42:01 -04:00
aj cbc46314db feat(mcp): implement all MCP tools (transactions, budgets, categories, receipts, merchants, accounts, dashboard)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 18:27:09 -04:00
aj f54c5ed54d feat: add MoneyMap.Mcp project skeleton with stdio transport
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 18:21:47 -04:00
aj 62fa1d5c4c refactor: consolidate service registration into AddMoneyMapCore extension
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 18:20:23 -04:00
aj d63ded45e1 refactor: abstract IWebHostEnvironment to IReceiptStorageOptions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 18:19:31 -04:00
aj 3b01efd8a6 refactor: move services and AITools to MoneyMap.Core
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 18:18:20 -04:00
aj 3deca29f05 refactor: extract Models and Data into MoneyMap.Core shared library
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 18:16:33 -04:00
aj d831991ad0 Add implementation plan for MoneyMap MCP server
14 tasks covering: Core library extraction, service migration,
IWebHostEnvironment abstraction, shared DI registration, MCP project
skeleton, and all 20 MCP tools across 7 tool files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:25:25 -04:00
aj dcb57c5cf6 Add design spec for MoneyMap MCP server
Shared class library (MoneyMap.Core) extraction with MCP console app
for conversational financial analysis, category correction with receipt
image verification, and budget feasibility modeling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:17:54 -04:00
66 changed files with 3336 additions and 69 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 763 B

@@ -1,6 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MoneyMap.Models; using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Data namespace MoneyMap.Data
{ {
+5
View File
@@ -0,0 +1,5 @@
global using Microsoft.Extensions.Configuration;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Logging;
global using Microsoft.AspNetCore.Hosting;
global using Microsoft.AspNetCore.Http;
@@ -1,4 +1,3 @@
using MoneyMap.Services;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace MoneyMap.Models; namespace MoneyMap.Models;
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.8.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
<PackageReference Include="PdfPig" Version="0.1.11" />
</ItemGroup>
<ItemGroup>
<None Update="Prompts\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MoneyMap.Data;
using MoneyMap.Services;
using MoneyMap.Services.AITools;
namespace MoneyMap.Core;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMoneyMapCore(
this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<MoneyMapContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("MoneyMapDb")));
services.AddMemoryCache();
// Core transaction and import services
services.AddScoped<ITransactionImporter, TransactionImporter>();
services.AddScoped<ICardResolver, CardResolver>();
services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
services.AddScoped<ITransactionService, TransactionService>();
services.AddScoped<ITransactionStatisticsService, TransactionStatisticsService>();
// Entity management services
services.AddScoped<IAccountService, AccountService>();
services.AddScoped<ICardService, CardService>();
services.AddScoped<IMerchantService, MerchantService>();
services.AddScoped<IBudgetService, BudgetService>();
// Receipt services
services.AddScoped<IReceiptMatchingService, ReceiptMatchingService>();
services.AddScoped<IReceiptManager, ReceiptManager>();
services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
services.AddScoped<IPdfToImageConverter, PdfToImageConverter>();
// Reference data and dashboard
services.AddScoped<IReferenceDataService, ReferenceDataService>();
services.AddScoped<IDashboardService, DashboardService>();
services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>();
services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
services.AddScoped<ISpendTrendsProvider, SpendTrendsProvider>();
// AI services
services.AddScoped<IAIToolExecutor, AIToolExecutor>();
services.AddScoped<IFinancialAuditService, FinancialAuditService>();
return services;
}
}
@@ -0,0 +1,6 @@
namespace MoneyMap.Services;
public interface IReceiptStorageOptions
{
string ReceiptsBasePath { get; }
}
@@ -21,8 +21,7 @@ namespace MoneyMap.Services
public class ReceiptManager : IReceiptManager public class ReceiptManager : IReceiptManager
{ {
private readonly MoneyMapContext _db; private readonly MoneyMapContext _db;
private readonly IWebHostEnvironment _environment; private readonly IReceiptStorageOptions _receiptStorage;
private readonly IConfiguration _configuration;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly IReceiptParseQueue _parseQueue; private readonly IReceiptParseQueue _parseQueue;
private readonly ILogger<ReceiptManager> _logger; private readonly ILogger<ReceiptManager> _logger;
@@ -46,15 +45,13 @@ namespace MoneyMap.Services
public ReceiptManager( public ReceiptManager(
MoneyMapContext db, MoneyMapContext db,
IWebHostEnvironment environment, IReceiptStorageOptions receiptStorage,
IConfiguration configuration,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
IReceiptParseQueue parseQueue, IReceiptParseQueue parseQueue,
ILogger<ReceiptManager> logger) ILogger<ReceiptManager> logger)
{ {
_db = db; _db = db;
_environment = environment; _receiptStorage = receiptStorage;
_configuration = configuration;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_parseQueue = parseQueue; _parseQueue = parseQueue;
_logger = logger; _logger = logger;
@@ -62,9 +59,7 @@ namespace MoneyMap.Services
private string GetReceiptsBasePath() private string GetReceiptsBasePath()
{ {
// Get from config, default to "receipts" in wwwroot return _receiptStorage.ReceiptsBasePath;
var relativePath = _configuration["Receipts:StoragePath"] ?? "receipts";
return Path.Combine(_environment.WebRootPath, relativePath);
} }
public async Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file) public async Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file)
@@ -0,0 +1,15 @@
using Microsoft.Extensions.Configuration;
using MoneyMap.Services;
namespace MoneyMap.Mcp;
public class ConfigReceiptStorageOptions : IReceiptStorageOptions
{
public string ReceiptsBasePath { get; }
public ConfigReceiptStorageOptions(IConfiguration config)
{
ReceiptsBasePath = config["Receipts:StoragePath"]
?? throw new InvalidOperationException("Receipts:StoragePath not configured");
}
}
+27
View File
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+22
View File
@@ -0,0 +1,22 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MoneyMap.Core;
using MoneyMap.Mcp;
using MoneyMap.Services;
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.AddMoneyMapCore(builder.Configuration);
builder.Services.AddSingleton<IReceiptStorageOptions, ConfigReceiptStorageOptions>();
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly(typeof(Program).Assembly);
var app = builder.Build();
await app.RunAsync();
+67
View File
@@ -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);
}
}
+96
View File
@@ -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);
}
}
+80
View File
@@ -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);
}
}
+68
View File
@@ -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);
}
}
+95
View File
@@ -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);
}
}
+199
View File
@@ -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);
}
}
+266
View File
@@ -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);
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"ConnectionStrings": {
"MoneyMapDb": "Server=barge.lan;Database=MoneyMap;User Id=moneymap;Password=Cn87oXQPj7EEkx;TrustServerCertificate=True;"
},
"Receipts": {
"StoragePath": "\\\\TRUENAS\\receipts"
}
}
+1
View File
@@ -22,6 +22,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\MoneyMap\MoneyMap.csproj" /> <ProjectReference Include="..\MoneyMap\MoneyMap.csproj" />
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+48
View File
@@ -7,20 +7,68 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap", "MoneyMap\MoneyM
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Tests", "MoneyMap.Tests\MoneyMap.Tests.csproj", "{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Tests", "MoneyMap.Tests\MoneyMap.Tests.csproj", "{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Core", "MoneyMap.Core\MoneyMap.Core.csproj", "{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Mcp", "MoneyMap.Mcp\MoneyMap.Mcp.csproj", "{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|Any CPU.Build.0 = Debug|Any CPU {B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|x64.ActiveCfg = Debug|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|x64.Build.0 = Debug|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|x86.ActiveCfg = Debug|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|x86.Build.0 = Debug|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|Any CPU.ActiveCfg = Release|Any CPU {B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|Any CPU.Build.0 = Release|Any CPU {B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|Any CPU.Build.0 = Release|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|x64.ActiveCfg = Release|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|x64.Build.0 = Release|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|x86.ActiveCfg = Release|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|x86.Build.0 = Release|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|Any CPU.Build.0 = Debug|Any CPU {4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|x64.ActiveCfg = Debug|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|x64.Build.0 = Debug|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|x86.ActiveCfg = Debug|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|x86.Build.0 = Debug|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|Any CPU.ActiveCfg = Release|Any CPU {4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|Any CPU.Build.0 = Release|Any CPU {4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|Any CPU.Build.0 = Release|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|x64.ActiveCfg = Release|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|x64.Build.0 = Release|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|x86.ActiveCfg = Release|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|x86.Build.0 = Release|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|x64.ActiveCfg = Debug|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|x64.Build.0 = Debug|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|x86.ActiveCfg = Debug|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|x86.Build.0 = Debug|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|Any CPU.Build.0 = Release|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|x64.ActiveCfg = Release|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|x64.Build.0 = Release|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|x86.ActiveCfg = Release|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|x86.Build.0 = Release|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|x64.ActiveCfg = Debug|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|x64.Build.0 = Debug|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|x86.ActiveCfg = Debug|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|x86.Build.0 = Debug|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|Any CPU.Build.0 = Release|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|x64.ActiveCfg = Release|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|x64.Build.0 = Release|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|x86.ActiveCfg = Release|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
+6 -3
View File
@@ -2,9 +2,11 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src WORKDIR /src
# Copy csproj and restore dependencies # Copy solution and project files for restore
COPY MoneyMap.csproj . COPY MoneyMap.sln .
RUN dotnet restore COPY MoneyMap/MoneyMap.csproj MoneyMap/
COPY MoneyMap.Core/MoneyMap.Core.csproj MoneyMap.Core/
RUN dotnet restore MoneyMap/MoneyMap.csproj
# Install libman CLI for client-side library restore # Install libman CLI for client-side library restore
RUN dotnet tool install -g Microsoft.Web.LibraryManager.Cli RUN dotnet tool install -g Microsoft.Web.LibraryManager.Cli
@@ -12,6 +14,7 @@ ENV PATH="${PATH}:/root/.dotnet/tools"
# Copy everything else and build # Copy everything else and build
COPY . . COPY . .
WORKDIR /src/MoneyMap
RUN libman restore RUN libman restore
RUN dotnet publish -c Release -o /app/publish RUN dotnet publish -c Release -o /app/publish
+8 -10
View File
@@ -18,25 +18,23 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CsvHelper" Version="33.1.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.8.2" /> <PrivateAssets>all</PrivateAssets>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" /> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" /> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.9"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="PdfPig" Version="0.1.11" /> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Migrations\" /> <Folder Include="Migrations\" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Update="Prompts\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project> </Project>
+6 -45
View File
@@ -1,8 +1,7 @@
using System.Globalization; using System.Globalization;
using Microsoft.EntityFrameworkCore; using MoneyMap.Core;
using MoneyMap.Data;
using MoneyMap.Services; using MoneyMap.Services;
using MoneyMap.Services.AITools; using MoneyMap.WebApp.Services;
// Set default culture to en-US for currency formatting ($) // Set default culture to en-US for currency formatting ($)
var culture = new CultureInfo("en-US"); var culture = new CultureInfo("en-US");
@@ -11,11 +10,8 @@ CultureInfo.DefaultThreadCurrentUICulture = culture;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<MoneyMapContext>(options => builder.Services.AddMoneyMapCore(builder.Configuration);
options.UseSqlServer(builder.Configuration.GetConnectionString("MoneyMapDb"))); builder.Services.AddSingleton<IReceiptStorageOptions, WebReceiptStorageOptions>();
// Add memory cache for services like TransactionCategorizer
builder.Services.AddMemoryCache();
// Add session support // Add session support
builder.Services.AddDistributedMemoryCache(); builder.Services.AddDistributedMemoryCache();
@@ -24,44 +20,13 @@ builder.Services.AddSession(options =>
options.IdleTimeout = TimeSpan.FromMinutes(30); options.IdleTimeout = TimeSpan.FromMinutes(30);
options.Cookie.HttpOnly = true; options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true; options.Cookie.IsEssential = true;
options.IOTimeout = TimeSpan.FromMinutes(5); // Increase timeout for large data options.IOTimeout = TimeSpan.FromMinutes(5);
}); });
// Use session-based TempData provider to avoid cookie size limits // Use session-based TempData provider to avoid cookie size limits
builder.Services.AddRazorPages() builder.Services.AddRazorPages()
.AddSessionStateTempDataProvider(); .AddSessionStateTempDataProvider();
// Core transaction and import services
builder.Services.AddScoped<ITransactionImporter, TransactionImporter>();
builder.Services.AddScoped<ICardResolver, CardResolver>();
builder.Services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
builder.Services.AddScoped<ITransactionService, TransactionService>();
builder.Services.AddScoped<ITransactionStatisticsService, TransactionStatisticsService>();
// Entity management services
builder.Services.AddScoped<IAccountService, AccountService>();
builder.Services.AddScoped<ICardService, CardService>();
builder.Services.AddScoped<IMerchantService, MerchantService>();
builder.Services.AddScoped<IBudgetService, BudgetService>();
// Receipt services
builder.Services.AddScoped<IReceiptMatchingService, ReceiptMatchingService>();
// Reference data services
builder.Services.AddScoped<IReferenceDataService, ReferenceDataService>();
// Dashboard services
builder.Services.AddScoped<IDashboardService, DashboardService>();
builder.Services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>();
builder.Services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
builder.Services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
builder.Services.AddScoped<ISpendTrendsProvider, SpendTrendsProvider>();
// Receipt services
builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
builder.Services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
builder.Services.AddScoped<IPdfToImageConverter, PdfToImageConverter>();
// Receipt parse queue and background worker // Receipt parse queue and background worker
builder.Services.AddSingleton<IReceiptParseQueue, ReceiptParseQueue>(); builder.Services.AddSingleton<IReceiptParseQueue, ReceiptParseQueue>();
builder.Services.AddHostedService<ReceiptParseWorkerService>(); builder.Services.AddHostedService<ReceiptParseWorkerService>();
@@ -72,18 +37,14 @@ builder.Services.AddHttpClient<ClaudeVisionClient>();
builder.Services.AddHttpClient<OllamaVisionClient>(); builder.Services.AddHttpClient<OllamaVisionClient>();
builder.Services.AddHttpClient<LlamaCppVisionClient>(); builder.Services.AddHttpClient<LlamaCppVisionClient>();
builder.Services.AddScoped<IAIVisionClientResolver, AIVisionClientResolver>(); builder.Services.AddScoped<IAIVisionClientResolver, AIVisionClientResolver>();
builder.Services.AddScoped<IAIToolExecutor, AIToolExecutor>();
builder.Services.AddScoped<IReceiptParser, AIReceiptParser>(); builder.Services.AddScoped<IReceiptParser, AIReceiptParser>();
// AI categorization service // AI categorization service
builder.Services.AddHttpClient<ITransactionAICategorizer, TransactionAICategorizer>(); builder.Services.AddHttpClient<ITransactionAICategorizer, TransactionAICategorizer>();
// Model warmup service - preloads the configured AI model on startup // Model warmup service
builder.Services.AddHostedService<ModelWarmupService>(); builder.Services.AddHostedService<ModelWarmupService>();
// Financial audit API service
builder.Services.AddScoped<IFinancialAuditService, FinancialAuditService>();
var app = builder.Build(); var app = builder.Build();
// Seed default category mappings on startup // Seed default category mappings on startup
@@ -0,0 +1,17 @@
using MoneyMap.Services;
namespace MoneyMap.WebApp.Services;
public class WebReceiptStorageOptions : IReceiptStorageOptions
{
public string ReceiptsBasePath { get; }
public WebReceiptStorageOptions(IWebHostEnvironment env, IConfiguration config)
{
var relativePath = config["Receipts:StoragePath"] ?? "receipts";
if (Path.IsPathRooted(relativePath))
ReceiptsBasePath = relativePath;
else
ReceiptsBasePath = Path.Combine(env.WebRootPath, relativePath);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,376 @@
# MoneyMap MCP Server — Design Spec
## Overview
Create an MCP (Model Context Protocol) console application that exposes MoneyMap's personal finance data and operations to Claude Code. This enables conversational financial analysis, category correction with receipt image verification, and budget feasibility modeling.
## Motivation
The primary use case is financial decision-making: analyzing household income vs. spending, identifying miscategorized transactions (with receipt images for verification), and modeling scenarios like income changes. Full read/write access allows Claude to both analyze and correct data in a single conversation.
## Architecture
### Solution Structure
```
MoneyMap.sln
├── MoneyMap.Core/ (NEW - shared class library)
│ ├── Models/ (moved from MoneyMap)
│ ├── Data/
│ │ └── MoneyMapContext.cs (moved from MoneyMap)
│ ├── Services/ (moved from MoneyMap)
│ │ ├── TransactionService.cs
│ │ ├── BudgetService.cs
│ │ ├── DashboardService.cs
│ │ ├── TransactionCategorizer.cs
│ │ ├── MerchantService.cs
│ │ ├── AccountService.cs
│ │ ├── CardService.cs
│ │ ├── ReceiptManager.cs
│ │ ├── ReceiptMatchingService.cs
│ │ ├── ReferenceDataService.cs
│ │ ├── TransactionStatisticsService.cs
│ │ └── AITools/
│ ├── ServiceCollectionExtensions.cs (shared DI registration)
│ └── MoneyMap.Core.csproj (EF Core, CsvHelper, Magick.NET, etc.)
├── MoneyMap/ (existing web app - slimmed)
│ ├── Pages/ (stays - thin PageModel delegation)
│ ├── Migrations/ (stays - deployment-specific)
│ ├── wwwroot/ (stays - includes receipt files)
│ ├── Program.cs (calls AddMoneyMapCore)
│ └── MoneyMap.csproj (references MoneyMap.Core)
├── MoneyMap.Mcp/ (NEW - MCP console app)
│ ├── Tools/
│ │ ├── TransactionTools.cs
│ │ ├── BudgetTools.cs
│ │ ├── CategoryTools.cs
│ │ ├── ReceiptTools.cs
│ │ ├── MerchantTools.cs
│ │ ├── AccountTools.cs
│ │ └── DashboardTools.cs
│ ├── ConfigReceiptStorageOptions.cs
│ ├── Program.cs
│ ├── appsettings.json
│ └── MoneyMap.Mcp.csproj (references MoneyMap.Core + MCP SDK)
└── MoneyMap.Tests/ (existing - updates reference to MoneyMap.Core)
```
### Key Architectural Decisions
1. **Shared class library (MoneyMap.Core)**: All models, DbContext, and services extracted into a shared library. Both the web app and MCP app reference it. This ensures logic never drifts between the two consumers.
2. **Shared DI registration**: A `ServiceCollectionExtensions.AddMoneyMapCore(IConfiguration)` extension method registers DbContext and all services. Both apps call this method, preventing registration drift.
3. **Migrations stay in web project**: EF migrations are deployment-specific and remain in the web app project.
4. **Receipt files stay in wwwroot**: The MCP server reads receipt images from the web app's `wwwroot/receipts/` directory via a configured absolute path.
5. **TransactionImporter stays in web app**: CSV import is a UI-driven workflow. It remains in `Upload.cshtml.cs` and is not exposed via MCP.
6. **ReceiptParseWorkerService stays in web app**: The background hosted service for async receipt parsing stays tied to the web process.
7. **Logging to stderr only**: MCP uses stdio for the protocol. All logging in the MCP app must be directed to stderr (or file) to avoid corrupting the MCP stream. The `AddMoneyMapCore` extension must not force a stdout-based logging provider.
8. **No IWebHostEnvironment dependency in Core**: Services that currently rely on `IWebHostEnvironment` to resolve file paths (e.g., `ReceiptManager`) will be refactored to use an `IReceiptStorageOptions` interface backed by configuration. This allows both the web app and MCP app to provide the correct path without web-host coupling.
9. **MoneyMap.Core targets `Microsoft.NET.Sdk`**: The shared library must not depend on `Microsoft.AspNetCore.App`. It uses only `Microsoft.Extensions.*` packages to stay lightweight for the console app consumer.
10. **Path traversal protection**: Receipt file access validates that resolved paths remain within the configured receipts directory to prevent directory traversal attacks.
## MCP Tools
### Transaction Tools
| Tool | Description | Parameters | Mutates |
|------|-------------|------------|---------|
| `search_transactions` | Filter and list transactions | `query?` (full-text across name/memo/category), `startDate?`, `endDate?`, `category?`, `merchantName?`, `minAmount?`, `maxAmount?`, `accountId?`, `cardId?`, `type?` (debit/credit), `uncategorizedOnly?`, `limit?` (default 50) | No |
| `get_transaction` | Get single transaction with full details | `transactionId` | No |
| `get_spending_summary` | Spending totals grouped by category for a date range (excludes transfers) | `startDate`, `endDate`, `accountId?` | No |
| `get_income_summary` | Credit totals grouped by source/name for a date range | `startDate`, `endDate`, `accountId?` | No |
| `update_transaction_category` | Change category and optionally merchant on transactions | `transactionIds` (array), `category`, `merchantName?` | Yes |
| `bulk_recategorize` | Recategorize all transactions matching a name pattern | `namePattern`, `fromCategory?`, `toCategory`, `merchantName?`, `dryRun?` (default true — returns preview of affected transactions without applying changes) | Yes |
### Budget Tools
| Tool | Description | Parameters | Mutates |
|------|-------------|------------|---------|
| `get_budget_status` | All active budgets with current period spending vs. limit | `asOfDate?` | No |
| `create_budget` | Create a new category or total budget | `category?` (null = total), `amount`, `period` (Weekly/Monthly/Yearly), `startDate` | Yes |
| `update_budget` | Modify budget amount, period, or active status | `budgetId`, `amount?`, `period?`, `isActive?` | Yes |
### Category Tools
| Tool | Description | Parameters | Mutates |
|------|-------------|------------|---------|
| `list_categories` | All categories with transaction counts | (none) | No |
| `get_category_mappings` | Auto-categorization pattern rules | `category?` (filter) | No |
| `add_category_mapping` | Add a new auto-categorization rule | `pattern`, `category`, `merchantName?`, `priority?` | Yes |
### Receipt Tools
| Tool | Description | Parameters | Mutates |
|------|-------------|------------|---------|
| `get_receipt_image` | Returns receipt as base64 image (PDF → PNG conversion). Path-traversal safe. | `receiptId` | No |
| `get_receipt_text` | Returns already-parsed receipt data (merchant, date, amounts, line items) as structured text — avoids re-OCR when parsed data exists | `receiptId` | No |
| `list_receipts` | List receipts with parse status | `transactionId?`, `parseStatus?`, `limit?` | No |
| `get_receipt_details` | Full receipt metadata, parsed data, and line items | `receiptId` | No |
### Merchant Tools
| Tool | Description | Parameters | Mutates |
|------|-------------|------------|---------|
| `list_merchants` | All merchants with transaction counts and category mapping info | `query?` (filter by name) | No |
| `merge_merchants` | Merge duplicate merchants — reassigns all transactions and mappings from source to target, then deletes source | `sourceMerchantId`, `targetMerchantId` | Yes |
### Account & Card Tools
| Tool | Description | Parameters | Mutates |
|------|-------------|------------|---------|
| `list_accounts` | All accounts with transaction counts | (none) | No |
| `list_cards` | All cards with account info and stats | `accountId?` | No |
### Dashboard Tools
| Tool | Description | Parameters | Mutates |
|------|-------------|------------|---------|
| `get_dashboard` | Top spending categories, recent transactions, aggregate stats | `topCategoriesCount?`, `recentTransactionsCount?` | No |
| `get_monthly_trend` | Month-over-month spending totals | `months?` (default 6), `category?` | No |
## Internal Design
### Program.cs
```csharp
var builder = Host.CreateApplicationBuilder(args);
// MCP uses stdio — all logging must go to stderr
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Configuration.AddJsonFile("appsettings.json");
builder.Services.AddMoneyMapCore(builder.Configuration);
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly(typeof(Program).Assembly);
var app = builder.Build();
await app.RunAsync();
```
### ServiceCollectionExtensions (MoneyMap.Core)
```csharp
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMoneyMapCore(
this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<MoneyMapContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("MoneyMapDb")));
services.AddScoped<ITransactionService, TransactionService>();
services.AddScoped<ITransactionStatisticsService, TransactionStatisticsService>();
services.AddScoped<IBudgetService, BudgetService>();
services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
services.AddScoped<IMerchantService, MerchantService>();
services.AddScoped<IAccountService, AccountService>();
services.AddScoped<ICardService, CardService>();
services.AddScoped<IReceiptManager, ReceiptManager>();
services.AddScoped<IReceiptMatchingService, ReceiptMatchingService>();
services.AddScoped<IReferenceDataService, ReferenceDataService>();
services.AddScoped<IDashboardService, DashboardService>();
services.AddScoped<IAIToolExecutor, AIToolExecutor>();
services.AddScoped<IAIToolRegistry, AIToolRegistry>();
return services;
}
}
```
### Tool Implementation Pattern
All tools return `McpToolResult` for consistency and to support multi-part responses (text + images).
```csharp
[McpServerTool]
[Description("Get spending totals grouped by category for a date range. Excludes transfers.")]
public static async Task<McpToolResult> GetSpendingSummary(
[Description("Start date (inclusive), e.g. 2026-01-01")] string startDate,
[Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
[Description("Optional: filter to specific account ID")] int? accountId,
MoneyMapContext db)
{
var start = DateTime.Parse(startDate);
var end = DateTime.Parse(endDate);
var query = db.Transactions
.Where(t => t.Date >= start && t.Date <= end)
.Where(t => t.Amount < 0) // debits only
.Where(t => t.TransferToAccountId == null); // exclude transfers
if (accountId.HasValue)
query = query.Where(t => t.AccountId == accountId.Value);
var summary = await query
.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();
return McpToolResult.Text(JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true }));
}
```
### Receipt Image Tool
```csharp
[McpServerTool]
[Description("Get a receipt image for visual inspection. Returns the image as base64. Useful for verifying transaction categories.")]
public static async Task<McpToolResult> GetReceiptImage(
[Description("Receipt ID")] long receiptId,
MoneyMapContext db,
IConfiguration config)
{
var receipt = await db.Receipts.FindAsync(receiptId);
if (receipt == null)
return McpToolResult.Text("Receipt not found");
var basePath = Path.GetFullPath(config["Receipts:StoragePath"]!);
var fullPath = Path.GetFullPath(Path.Combine(basePath, receipt.StoragePath));
// Path traversal protection
if (!fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
return McpToolResult.Text("Invalid receipt path");
if (!File.Exists(fullPath))
return McpToolResult.Text("Receipt file not found on disk");
byte[] imageBytes;
string mimeType;
if (receipt.ContentType == "application/pdf")
{
using var image = new MagickImage(fullPath + "[0]");
image.Density = new Density(220);
image.Format = MagickFormat.Png;
imageBytes = image.ToByteArray();
mimeType = "image/png";
}
else
{
imageBytes = await File.ReadAllBytesAsync(fullPath);
mimeType = receipt.ContentType;
}
return McpToolResult.Image(Convert.ToBase64String(imageBytes), mimeType);
}
```
## Configuration
### MoneyMap.Mcp/appsettings.json
```json
{
"ConnectionStrings": {
"MoneyMapDb": "Server=(localdb)\\mssqllocaldb;Database=MoneyMap;Trusted_Connection=True;"
},
"Receipts": {
"StoragePath": "C:/Users/AJ/Desktop/Projects/MoneyMap/MoneyMap/wwwroot/receipts"
}
}
```
## Publishing & Installation
### Build
```bash
dotnet publish MoneyMap.Mcp -c Release -o "$USERPROFILE/.claude/mcp/MoneyMap.Mcp"
```
### Register
```bash
claude mcp add --transport stdio --scope user moneymap -- "C:/Users/AJ/.claude/mcp/MoneyMap.Mcp/MoneyMap.Mcp.exe"
```
## What Stays in the Web App
- EF Migrations (`MoneyMap/Migrations/`)
- Receipt physical files (`MoneyMap/wwwroot/receipts/`)
- PageModel code (`Pages/*.cshtml.cs`) — thin delegation to Core services
- `TransactionImporter` & `CardResolver` — import-workflow-specific
- `ReceiptParseWorkerService` — background worker tied to web process
- `AIReceiptParser` — tied to the parse worker and web upload flow
## What Moves to MoneyMap.Core
- All models (`Models/`)
- `MoneyMapContext` (`Data/MoneyMapContext.cs`)
- All service interfaces and implementations (`Services/`)
- AITools (`Services/AITools/`)
- All DTOs and result types
## NuGet Dependencies (MoneyMap.Core)
- Microsoft.EntityFrameworkCore.SqlServer
- CsvHelper
- Magick.NET-Q8-AnyCPU (for receipt PDF→PNG)
- UglyToad.PdfPig (for PDF processing)
## NuGet Dependencies (MoneyMap.Mcp)
- ModelContextProtocol (prerelease)
- Microsoft.Extensions.Hosting
## Refactoring Notes
### IWebHostEnvironment Removal from Core
Services like `ReceiptManager` currently use `IWebHostEnvironment.WebRootPath` to resolve receipt file paths. This must be abstracted:
```csharp
// MoneyMap.Core
public interface IReceiptStorageOptions
{
string ReceiptsBasePath { get; }
}
// MoneyMap (web app) implementation
public class WebReceiptStorageOptions : IReceiptStorageOptions
{
public WebReceiptStorageOptions(IWebHostEnvironment env)
=> ReceiptsBasePath = Path.Combine(env.WebRootPath, "receipts");
public string ReceiptsBasePath { get; }
}
// MoneyMap.Mcp implementation
public class ConfigReceiptStorageOptions : IReceiptStorageOptions
{
public ConfigReceiptStorageOptions(IConfiguration config)
=> ReceiptsBasePath = config["Receipts:StoragePath"]!;
public string ReceiptsBasePath { get; }
}
```
Each host registers its own implementation. `AddMoneyMapCore` does NOT register `IReceiptStorageOptions` — that's host-specific.
## Risks & Mitigations
| Risk | Mitigation |
|------|-----------|
| Large refactoring breaks web app | Run existing tests after extraction; web app behavior unchanged |
| Namespace changes break references | Use find-and-replace for `using` statements; Roslyn Bridge can verify |
| Receipt path differs per machine | Configurable via `IReceiptStorageOptions` + appsettings.json |
| MCP binary needs Magick.NET native libs | Included via NuGet package (AnyCPU variant) |
| Concurrent DB access (web + MCP) | EF Core handles this fine with scoped DbContext per request |
| Logging corrupts MCP stdio stream | All logging redirected to stderr in MCP host |
| Path traversal on receipt reads | Resolved paths validated against base directory |
| MoneyMap.Core pulls in ASP.NET deps | Target `Microsoft.NET.Sdk`, avoid `Microsoft.AspNetCore.App` framework ref |