Files
MoneyMap/docs/superpowers/plans/2026-04-20-moneymap-mcp.md
T
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

1843 lines
62 KiB
Markdown

# MoneyMap MCP Server Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extract shared services into MoneyMap.Core class library and create MoneyMap.Mcp console app exposing financial data/operations via MCP protocol.
**Architecture:** Three-project solution — MoneyMap.Core (shared library with models, DbContext, services), MoneyMap (web app referencing Core), and MoneyMap.Mcp (MCP stdio console app referencing Core). ReceiptManager's IWebHostEnvironment dependency abstracted to IReceiptStorageOptions interface.
**Tech Stack:** .NET 8.0, EF Core 9.0, ModelContextProtocol SDK (prerelease), SQL Server, Magick.NET, stdio transport.
**Spec:** `docs/superpowers/specs/2026-04-20-moneymap-mcp-design.md`
---
## Task 1: Create MoneyMap.Core Project and Move Models
**Files:**
- Create: `MoneyMap.Core/MoneyMap.Core.csproj`
- Move: `MoneyMap/Models/*.cs``MoneyMap.Core/Models/`
- Move: `MoneyMap/Models/Api/*.cs``MoneyMap.Core/Models/Api/`
- Move: `MoneyMap/Models/Dashboard/*.cs``MoneyMap.Core/Models/Dashboard/`
- Move: `MoneyMap/Models/Import/*.cs``MoneyMap.Core/Models/Import/`
- Move: `MoneyMap/Data/MoneyMapContext.cs``MoneyMap.Core/Data/`
- Modify: `MoneyMap.sln` (add new project)
- Modify: `MoneyMap/MoneyMap.csproj` (add ProjectReference to Core, remove moved packages)
- [ ] **Step 1: Create MoneyMap.Core class library project**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet new classlib -n MoneyMap.Core --framework net8.0
rm MoneyMap.Core/Class1.cs
```
- [ ] **Step 2: Add NuGet packages to MoneyMap.Core**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap/MoneyMap.Core
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 9.0.9
dotnet add package CsvHelper --version 33.1.0
dotnet add package Magick.NET-Q16-AnyCPU --version 14.8.2
dotnet add package PdfPig --version 0.1.11
dotnet add package Microsoft.Extensions.Caching.Memory
dotnet add package Microsoft.Extensions.Http
```
- [ ] **Step 3: Enable nullable and implicit usings in MoneyMap.Core.csproj**
Edit `MoneyMap.Core/MoneyMap.Core.csproj` to contain:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<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="Microsoft.Extensions.Caching.Memory" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
<PackageReference Include="PdfPig" Version="0.1.11" />
</ItemGroup>
</Project>
```
- [ ] **Step 4: Move Models to MoneyMap.Core**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
mkdir -p MoneyMap.Core/Models/Api MoneyMap.Core/Models/Dashboard MoneyMap.Core/Models/Import
mv MoneyMap/Models/Account.cs MoneyMap.Core/Models/
mv MoneyMap/Models/Budget.cs MoneyMap.Core/Models/
mv MoneyMap/Models/Card.cs MoneyMap.Core/Models/
mv MoneyMap/Models/CategoryMapping.cs MoneyMap.Core/Models/
mv MoneyMap/Models/Merchant.cs MoneyMap.Core/Models/
mv MoneyMap/Models/Receipt.cs MoneyMap.Core/Models/
mv MoneyMap/Models/ReceiptLineItem.cs MoneyMap.Core/Models/
mv MoneyMap/Models/ReceiptParseLog.cs MoneyMap.Core/Models/
mv MoneyMap/Models/Transaction.cs MoneyMap.Core/Models/
mv MoneyMap/Models/Transfer.cs MoneyMap.Core/Models/
mv MoneyMap/Models/Api/FinancialAuditModels.cs MoneyMap.Core/Models/Api/
mv MoneyMap/Models/Dashboard/DashboardModels.cs MoneyMap.Core/Models/Dashboard/
mv MoneyMap/Models/Import/ImportContext.cs MoneyMap.Core/Models/Import/
mv MoneyMap/Models/Import/ImportResults.cs MoneyMap.Core/Models/Import/
mv MoneyMap/Models/Import/PaymentResolutionResult.cs MoneyMap.Core/Models/Import/
mv MoneyMap/Models/Import/TransactionCsvRow.cs MoneyMap.Core/Models/Import/
mv MoneyMap/Models/Import/TransactionCsvRowMap.cs MoneyMap.Core/Models/Import/
```
- [ ] **Step 5: Move Data folder to MoneyMap.Core**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
mkdir -p MoneyMap.Core/Data
mv MoneyMap/Data/MoneyMapContext.cs MoneyMap.Core/Data/
```
- [ ] **Step 6: Add MoneyMap.Core to the solution and add ProjectReference from MoneyMap**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet sln MoneyMap.sln add MoneyMap.Core/MoneyMap.Core.csproj
```
Add ProjectReference in `MoneyMap/MoneyMap.csproj`:
```xml
<ItemGroup>
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
</ItemGroup>
```
Remove from `MoneyMap/MoneyMap.csproj` the packages that moved to Core:
- `CsvHelper`
- `Magick.NET-Q16-AnyCPU`
- `Microsoft.EntityFrameworkCore` (keep `Microsoft.EntityFrameworkCore.Tools` for migrations)
- `Microsoft.EntityFrameworkCore.SqlServer`
- `PdfPig`
Keep `Microsoft.EntityFrameworkCore.Tools` (needed for migrations).
- [ ] **Step 7: Update MoneyMap.Tests to reference MoneyMap.Core**
In `MoneyMap.Tests/MoneyMap.Tests.csproj`, add:
```xml
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
```
Also add `Microsoft.EntityFrameworkCore.InMemory` remains (it's test-specific).
- [ ] **Step 8: Build and fix any namespace/reference issues**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.sln
```
Fix any issues — namespaces remain unchanged (`MoneyMap.Models`, `MoneyMap.Data`, etc.) so references should resolve through the ProjectReference. The web project may need the `Migrations` folder to reference the design-time DbContext from Core.
Add to `MoneyMap/MoneyMap.csproj` if migrations break:
```xml
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
```
- [ ] **Step 9: Run tests to verify nothing broke**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet test MoneyMap.Tests/MoneyMap.Tests.csproj
```
Expected: All existing tests pass.
- [ ] **Step 10: Commit**
```bash
git add -A
git commit -m "refactor: extract Models and Data into MoneyMap.Core shared library"
```
---
## Task 2: Move Services to MoneyMap.Core
**Files:**
- Move: `MoneyMap/Services/*.cs``MoneyMap.Core/Services/`
- Move: `MoneyMap/Services/AITools/*.cs``MoneyMap.Core/Services/AITools/`
- Keep in web: `ReceiptParseWorkerService.cs`, `ModelWarmupService.cs` (hosted services)
- [ ] **Step 1: Move core services to MoneyMap.Core**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
mkdir -p MoneyMap.Core/Services/AITools
# Core services (no web dependencies)
mv MoneyMap/Services/AccountService.cs MoneyMap.Core/Services/
mv MoneyMap/Services/BudgetService.cs MoneyMap.Core/Services/
mv MoneyMap/Services/CardResolver.cs MoneyMap.Core/Services/
mv MoneyMap/Services/CardService.cs MoneyMap.Core/Services/
mv MoneyMap/Services/DashboardService.cs MoneyMap.Core/Services/
mv MoneyMap/Services/FinancialAuditService.cs MoneyMap.Core/Services/
mv MoneyMap/Services/MerchantService.cs MoneyMap.Core/Services/
mv MoneyMap/Services/ReceiptAutoMapper.cs MoneyMap.Core/Services/
mv MoneyMap/Services/ReceiptMatchingService.cs MoneyMap.Core/Services/
mv MoneyMap/Services/ReferenceDataService.cs MoneyMap.Core/Services/
mv MoneyMap/Services/TransactionCategorizer.cs MoneyMap.Core/Services/
mv MoneyMap/Services/TransactionFilters.cs MoneyMap.Core/Services/
mv MoneyMap/Services/TransactionImporter.cs MoneyMap.Core/Services/
mv MoneyMap/Services/TransactionService.cs MoneyMap.Core/Services/
mv MoneyMap/Services/TransactionStatisticsService.cs MoneyMap.Core/Services/
mv MoneyMap/Services/TransactionAICategorizer.cs MoneyMap.Core/Services/
mv MoneyMap/Services/PdfToImageConverter.cs MoneyMap.Core/Services/
mv MoneyMap/Services/AIReceiptParser.cs MoneyMap.Core/Services/
mv MoneyMap/Services/AIVisionClient.cs MoneyMap.Core/Services/
mv MoneyMap/Services/ReceiptManager.cs MoneyMap.Core/Services/
mv MoneyMap/Services/ReceiptParseQueue.cs MoneyMap.Core/Services/
# AI Tools
mv MoneyMap/Services/AITools/AIToolDefinitions.cs MoneyMap.Core/Services/AITools/
mv MoneyMap/Services/AITools/AIToolExecutor.cs MoneyMap.Core/Services/AITools/
```
- [ ] **Step 2: Keep hosted services in web project**
These stay in `MoneyMap/Services/`:
- `ReceiptParseWorkerService.cs` — BackgroundService tied to web lifecycle
- `ModelWarmupService.cs` — startup hosted service
Verify they're still there:
```bash
ls MoneyMap/Services/
```
Expected: Only `ReceiptParseWorkerService.cs` and `ModelWarmupService.cs` remain.
- [ ] **Step 3: Move the Prompts folder to MoneyMap.Core**
The `AIReceiptParser` reads `Prompts/ReceiptParserPrompt.txt` from `AppContext.BaseDirectory`. This needs to be embedded or copied with MoneyMap.Core.
```bash
mv MoneyMap/Prompts MoneyMap.Core/Prompts
```
Add to `MoneyMap.Core/MoneyMap.Core.csproj`:
```xml
<ItemGroup>
<None Update="Prompts\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
```
Remove from `MoneyMap/MoneyMap.csproj` the Prompts section:
```xml
<!-- Remove this -->
<ItemGroup>
<None Update="Prompts\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
```
- [ ] **Step 4: Build and fix issues**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.sln
```
Likely issues:
- `ReceiptManager` uses `IFormFile` (from `Microsoft.AspNetCore.Http`) — need to add this package to Core or abstract it. Since `IFormFile` is in `Microsoft.AspNetCore.Http.Features`, add to `MoneyMap.Core.csproj`:
```xml
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
```
**Wait** — this contradicts the spec's decision #9 (no ASP.NET deps in Core). The spec says to abstract `IWebHostEnvironment` via `IReceiptStorageOptions`, but `IFormFile` is also ASP.NET-specific. Since `ReceiptManager`'s upload methods (`UploadReceiptAsync`, `UploadUnmappedReceiptAsync`, `UploadManyUnmappedReceiptsAsync`) use `IFormFile` extensively and are only called from the web app, the cleanest approach is:
**Split ReceiptManager into two parts:**
1. `MoneyMap.Core/Services/ReceiptManager.cs` — read-only operations (GetReceiptPhysicalPath, GetReceiptAsync, DeleteReceiptAsync, MapReceiptToTransactionAsync, UnmapReceiptAsync, duplicate checks)
2. `MoneyMap/Services/ReceiptUploadService.cs` — upload operations using IFormFile (stays in web project)
But this is complex. Simpler: add the `FrameworkReference` to Core. It's a reference, not a deployment dependency — the MCP console app won't actually call upload methods. The alternative (splitting) adds complexity for no runtime benefit.
**Decision: Add `<FrameworkReference Include="Microsoft.AspNetCore.App" />` to MoneyMap.Core.** The MCP console app will need to declare it doesn't serve web traffic, but the types compile fine. For a local-only MCP tool this is acceptable.
Actually — on .NET 8 console apps, you CAN reference `Microsoft.AspNetCore.App` shared framework. It's available regardless. This is fine.
- [ ] **Step 5: Run tests**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet test MoneyMap.Tests/MoneyMap.Tests.csproj
```
Expected: All tests pass.
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "refactor: move services and AITools to MoneyMap.Core"
```
---
## Task 3: Abstract IWebHostEnvironment from ReceiptManager
**Files:**
- Create: `MoneyMap.Core/Services/IReceiptStorageOptions.cs`
- Modify: `MoneyMap.Core/Services/ReceiptManager.cs` (replace IWebHostEnvironment with IReceiptStorageOptions)
- Create: `MoneyMap/Services/WebReceiptStorageOptions.cs`
- Modify: `MoneyMap/Program.cs` (register WebReceiptStorageOptions)
- [ ] **Step 1: Create IReceiptStorageOptions interface in Core**
Create `MoneyMap.Core/Services/IReceiptStorageOptions.cs`:
```csharp
namespace MoneyMap.Services;
public interface IReceiptStorageOptions
{
string ReceiptsBasePath { get; }
}
```
- [ ] **Step 2: Update ReceiptManager to use IReceiptStorageOptions**
In `MoneyMap.Core/Services/ReceiptManager.cs`:
Replace the constructor parameter `IWebHostEnvironment environment` with `IReceiptStorageOptions receiptStorage`.
Replace the field:
```csharp
private readonly IWebHostEnvironment _environment;
```
with:
```csharp
private readonly IReceiptStorageOptions _receiptStorage;
```
Replace the constructor assignment:
```csharp
_environment = environment;
```
with:
```csharp
_receiptStorage = receiptStorage;
```
Replace `GetReceiptsBasePath()`:
```csharp
private string GetReceiptsBasePath()
{
return _receiptStorage.ReceiptsBasePath;
}
```
Remove the `IConfiguration _configuration` field and constructor parameter (if only used for receipts path). Check if `_configuration` is used elsewhere in ReceiptManager first — if yes, keep it.
- [ ] **Step 3: Create WebReceiptStorageOptions in web project**
Create `MoneyMap/Services/WebReceiptStorageOptions.cs`:
```csharp
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 the path is absolute (UNC or rooted), use directly; otherwise combine with wwwroot
if (Path.IsPathRooted(relativePath))
ReceiptsBasePath = relativePath;
else
ReceiptsBasePath = Path.Combine(env.WebRootPath, relativePath);
}
}
```
- [ ] **Step 4: Register in web app's Program.cs**
Add to `MoneyMap/Program.cs`:
```csharp
using MoneyMap.WebApp.Services;
// After other service registrations:
builder.Services.AddSingleton<IReceiptStorageOptions, WebReceiptStorageOptions>();
```
- [ ] **Step 5: Build and run tests**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.sln
dotnet test MoneyMap.Tests/MoneyMap.Tests.csproj
```
Expected: All tests pass.
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "refactor: abstract IWebHostEnvironment to IReceiptStorageOptions"
```
---
## Task 4: Create ServiceCollectionExtensions in MoneyMap.Core
**Files:**
- Create: `MoneyMap.Core/ServiceCollectionExtensions.cs`
- Modify: `MoneyMap/Program.cs` (replace individual registrations with AddMoneyMapCore call)
- [ ] **Step 1: Create the extension method**
Create `MoneyMap.Core/ServiceCollectionExtensions.cs`:
```csharp
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;
}
}
```
- [ ] **Step 2: Update web app Program.cs to use AddMoneyMapCore**
Replace the individual service registrations in `MoneyMap/Program.cs` with:
```csharp
using MoneyMap.Core;
using MoneyMap.WebApp.Services;
// ... after builder creation ...
builder.Services.AddMoneyMapCore(builder.Configuration);
builder.Services.AddSingleton<IReceiptStorageOptions, WebReceiptStorageOptions>();
// Web-specific services that stay here:
builder.Services.AddSingleton<IReceiptParseQueue, ReceiptParseQueue>();
builder.Services.AddHostedService<ReceiptParseWorkerService>();
builder.Services.AddHttpClient<OpenAIVisionClient>();
builder.Services.AddHttpClient<ClaudeVisionClient>();
builder.Services.AddHttpClient<OllamaVisionClient>();
builder.Services.AddHttpClient<LlamaCppVisionClient>();
builder.Services.AddScoped<IAIVisionClientResolver, AIVisionClientResolver>();
builder.Services.AddScoped<IReceiptParser, AIReceiptParser>();
builder.Services.AddHttpClient<ITransactionAICategorizer, TransactionAICategorizer>();
builder.Services.AddHostedService<ModelWarmupService>();
```
Remove all the individual core service registrations that are now in `AddMoneyMapCore`.
- [ ] **Step 3: Build and run tests**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.sln
dotnet test MoneyMap.Tests/MoneyMap.Tests.csproj
```
- [ ] **Step 4: Quick smoke test the web app**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap/MoneyMap
dotnet run
```
Verify it starts without errors, then stop it (Ctrl+C).
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "refactor: consolidate service registration into AddMoneyMapCore extension"
```
---
## Task 5: Create MoneyMap.Mcp Project Skeleton
**Files:**
- Create: `MoneyMap.Mcp/MoneyMap.Mcp.csproj`
- Create: `MoneyMap.Mcp/Program.cs`
- Create: `MoneyMap.Mcp/ConfigReceiptStorageOptions.cs`
- Create: `MoneyMap.Mcp/appsettings.json`
- Modify: `MoneyMap.sln` (add project)
- [ ] **Step 1: Create the console project**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet new console -n MoneyMap.Mcp --framework net8.0
```
- [ ] **Step 2: Set up MoneyMap.Mcp.csproj**
Replace `MoneyMap.Mcp/MoneyMap.Mcp.csproj` with:
```xml
<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="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>
```
- [ ] **Step 3: Add to solution**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet sln MoneyMap.sln add MoneyMap.Mcp/MoneyMap.Mcp.csproj
```
- [ ] **Step 4: Create appsettings.json**
Create `MoneyMap.Mcp/appsettings.json`:
```json
{
"ConnectionStrings": {
"MoneyMapDb": "Server=barge.lan;Database=MoneyMap;User Id=moneymap;Password=Cn87oXQPj7EEkx;TrustServerCertificate=True;"
},
"Receipts": {
"StoragePath": "\\\\TRUENAS\\receipts"
}
}
```
- [ ] **Step 5: Create ConfigReceiptStorageOptions**
Create `MoneyMap.Mcp/ConfigReceiptStorageOptions.cs`:
```csharp
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");
}
}
```
- [ ] **Step 6: Create Program.cs**
Replace `MoneyMap.Mcp/Program.cs` with:
```csharp
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
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();
```
- [ ] **Step 7: Build to verify skeleton compiles**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.Mcp/MoneyMap.Mcp.csproj
```
- [ ] **Step 8: Commit**
```bash
git add -A
git commit -m "feat: add MoneyMap.Mcp project skeleton with stdio transport"
```
---
## Task 6: Implement Transaction Tools
**Files:**
- Create: `MoneyMap.Mcp/Tools/TransactionTools.cs`
- [ ] **Step 1: Create TransactionTools.cs**
Create `MoneyMap.Mcp/Tools/TransactionTools.cs`:
```csharp
using System.ComponentModel;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Mcp.Tools;
public static class TransactionTools
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
[McpServerTool, 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.Issuer + " " + 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, 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.Issuer + " " + 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, 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, 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, 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, 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);
}
}
```
- [ ] **Step 2: Build to verify**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.Mcp/MoneyMap.Mcp.csproj
```
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "feat(mcp): implement transaction tools (search, spending, income, categorize)"
```
---
## Task 7: Implement Budget Tools
**Files:**
- Create: `MoneyMap.Mcp/Tools/BudgetTools.cs`
- [ ] **Step 1: Create BudgetTools.cs**
Create `MoneyMap.Mcp/Tools/BudgetTools.cs`:
```csharp
using System.ComponentModel;
using System.Text.Json;
using ModelContextProtocol.Server;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Mcp.Tools;
public static class BudgetTools
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
[McpServerTool, 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, 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, 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);
}
}
```
- [ ] **Step 2: Build to verify**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.Mcp/MoneyMap.Mcp.csproj
```
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "feat(mcp): implement budget tools (status, create, update)"
```
---
## Task 8: Implement Category Tools
**Files:**
- Create: `MoneyMap.Mcp/Tools/CategoryTools.cs`
- [ ] **Step 1: Create CategoryTools.cs**
Create `MoneyMap.Mcp/Tools/CategoryTools.cs`:
```csharp
using System.ComponentModel;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Mcp.Tools;
public static class CategoryTools
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
[McpServerTool, 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, 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, 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);
}
}
```
- [ ] **Step 2: Build to verify**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.Mcp/MoneyMap.Mcp.csproj
```
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "feat(mcp): implement category tools (list, mappings, add mapping)"
```
---
## Task 9: Implement Receipt Tools
**Files:**
- Create: `MoneyMap.Mcp/Tools/ReceiptTools.cs`
- [ ] **Step 1: Create ReceiptTools.cs**
Create `MoneyMap.Mcp/Tools/ReceiptTools.cs`:
```csharp
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;
public static class ReceiptTools
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
[McpServerTool, Description("Get a receipt image for visual inspection. Returns the image as base64. Useful for verifying transaction categories.")]
public static async Task<IEnumerable<Content>> GetReceiptImage(
[Description("Receipt ID")] long receiptId,
MoneyMapContext db = default!,
IReceiptStorageOptions storageOptions = default!)
{
var receipt = await db.Receipts.FindAsync(receiptId);
if (receipt == null)
return [new Content { Type = "text", Text = "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 [new Content { Type = "text", Text = "Invalid receipt path" }];
if (!File.Exists(fullPath))
return [new Content { Type = "text", Text = "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;
}
return [new Content
{
Type = "image",
MimeType = mimeType,
Data = Convert.ToBase64String(imageBytes)
}];
}
[McpServerTool, 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", receipt.ParseStatus }, 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, 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,
r.ParseStatus,
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, 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,
receipt.ParseStatus,
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);
}
}
```
- [ ] **Step 2: Build to verify**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.Mcp/MoneyMap.Mcp.csproj
```
Note: The `Content` type and return type for `GetReceiptImage` may need adjustment based on the exact MCP SDK API. If it doesn't compile, check the SDK's approach for returning image content — it may use `[McpServerTool]` with a return type of `Task<McpToolResponse>` or similar. Adapt accordingly.
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "feat(mcp): implement receipt tools (image, text, list, details)"
```
---
## Task 10: Implement Merchant Tools
**Files:**
- Create: `MoneyMap.Mcp/Tools/MerchantTools.cs`
- [ ] **Step 1: Create MerchantTools.cs**
Create `MoneyMap.Mcp/Tools/MerchantTools.cs`:
```csharp
using System.ComponentModel;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Mcp.Tools;
public static class MerchantTools
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
[McpServerTool, 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, 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";
// Reassign transactions
var transactions = await db.Transactions
.Where(t => t.MerchantId == sourceMerchantId)
.ToListAsync();
foreach (var t in transactions)
t.MerchantId = targetMerchantId;
// Reassign category mappings (delete duplicates)
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;
}
// Delete source merchant
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);
}
}
```
- [ ] **Step 2: Build to verify**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.Mcp/MoneyMap.Mcp.csproj
```
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "feat(mcp): implement merchant tools (list, merge)"
```
---
## Task 11: Implement Account and Dashboard Tools
**Files:**
- Create: `MoneyMap.Mcp/Tools/AccountTools.cs`
- Create: `MoneyMap.Mcp/Tools/DashboardTools.cs`
- [ ] **Step 1: Create AccountTools.cs**
Create `MoneyMap.Mcp/Tools/AccountTools.cs`:
```csharp
using System.ComponentModel;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
using MoneyMap.Data;
namespace MoneyMap.Mcp.Tools;
public static class AccountTools
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
[McpServerTool, 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.Issuer).ThenBy(a => a.Last4)
.Select(a => new
{
a.Id,
a.Issuer,
a.Last4,
a.Owner,
Label = a.DisplayLabel,
TransactionCount = a.Transactions.Count,
CardCount = a.Cards.Count
})
.ToListAsync();
return JsonSerializer.Serialize(accounts, JsonOptions);
}
[McpServerTool, 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.Issuer + " " + c.Account.Last4,
AccountId = c.AccountId,
TransactionCount = c.Transactions.Count
})
.ToListAsync();
return JsonSerializer.Serialize(cards, JsonOptions);
}
}
```
- [ ] **Step 2: Create DashboardTools.cs**
Create `MoneyMap.Mcp/Tools/DashboardTools.cs`:
```csharp
using System.ComponentModel;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Mcp.Tools;
public static class DashboardTools
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
[McpServerTool, 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, 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);
}
}
```
- [ ] **Step 3: Build to verify**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.Mcp/MoneyMap.Mcp.csproj
```
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "feat(mcp): implement account, card, and dashboard tools"
```
---
## Task 12: Build, Test, and Fix Compilation Issues
**Files:**
- Possibly modify multiple files to fix compilation errors
- [ ] **Step 1: Full solution build**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.sln
```
- [ ] **Step 2: Fix any compilation errors**
Common issues to watch for:
- Missing `using` statements for `MoneyMap.Core` namespace in Program.cs
- The `Content` type in ReceiptTools may need to match the MCP SDK's actual API (check `ModelContextProtocol.Protocol` namespace)
- `ExcludeTransfers()` extension method may need the `MoneyMap.Services` using
- `CategoryMappings` DbSet access — verify property name matches DbContext
Fix each error and rebuild.
- [ ] **Step 3: Run all tests**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet test MoneyMap.Tests/MoneyMap.Tests.csproj
```
- [ ] **Step 4: Quick smoke test the MCP server**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet run --project MoneyMap.Mcp -- --help 2>/dev/null
```
Or test stdio by piping an MCP initialize request:
```bash
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | dotnet run --project MoneyMap.Mcp
```
- [ ] **Step 5: Commit any fixes**
```bash
git add -A
git commit -m "fix: resolve compilation issues from MCP integration"
```
---
## Task 13: Publish and Register MCP Server
**Files:**
- Modify: `~/.claude/CLAUDE.md` (update MCP server table)
- [ ] **Step 1: Publish the MCP server**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet publish MoneyMap.Mcp -c Release -o "$USERPROFILE/.claude/mcp/MoneyMap.Mcp"
```
- [ ] **Step 2: Register with Claude Code**
```bash
claude mcp add --transport stdio --scope user moneymap -- "C:/Users/AJ/.claude/mcp/MoneyMap.Mcp/MoneyMap.Mcp.exe"
```
- [ ] **Step 3: Update CLAUDE.md MCP table**
Add to the MCP Server Publishing table in `~/.claude/CLAUDE.md`:
```
| MoneyMapMcp | `MoneyMap.Mcp` | `~/.claude/mcp/MoneyMap.Mcp/` | Done |
```
- [ ] **Step 4: Verify MCP server loads in Claude Code**
Restart Claude Code and verify the moneymap tools appear. Run a simple tool like `list_accounts` to confirm end-to-end connectivity.
- [ ] **Step 5: Final commit**
```bash
cd C:/Users/AJ/Desktop/Projects/MoneyMap
git add -A
git commit -m "feat: publish MoneyMap MCP server and register with Claude Code"
```
---
## Task 14: End-to-End Validation
- [ ] **Step 1: Test read-only tools**
In a new Claude Code session, verify:
- `list_accounts` returns accounts
- `search_transactions` with date range returns results
- `get_spending_summary` for last month works
- `get_budget_status` returns budget info
- `list_categories` shows categories with counts
- `list_merchants` shows merchants
- [ ] **Step 2: Test receipt image tool**
- `list_receipts` with `parseStatus=Completed` to find a parsed receipt
- `get_receipt_text` on that receipt to see parsed data
- `get_receipt_image` on a receipt to verify base64 image is returned
- [ ] **Step 3: Test mutation tools**
- `bulk_recategorize` with `dryRun=true` to preview
- `update_transaction_category` on a single transaction
- Verify the change via `get_transaction`
- [ ] **Step 4: Run the real use case**
Execute the income vs. spending analysis workflow:
1. `get_income_summary` for last 3 months
2. `get_spending_summary` for last 3 months
3. `search_transactions` with `uncategorizedOnly=true`
4. For ambiguous ones, use `get_receipt_image` to verify
5. `update_transaction_category` to fix miscategorized items
6. Re-run `get_spending_summary` to see corrected picture