Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b2d6203df | |||
| cbc46314db | |||
| f54c5ed54d | |||
| 62fa1d5c4c | |||
| d63ded45e1 | |||
| 3b01efd8a6 | |||
| 3deca29f05 | |||
| d831991ad0 | |||
| dcb57c5cf6 |
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
|
||||||
{
|
{
|
||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"MoneyMapDb": "Server=barge.lan;Database=MoneyMap;User Id=moneymap;Password=Cn87oXQPj7EEkx;TrustServerCertificate=True;"
|
||||||
|
},
|
||||||
|
"Receipts": {
|
||||||
|
"StoragePath": "\\\\TRUENAS\\receipts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 |
|
||||||
Reference in New Issue
Block a user