diff --git a/docs/superpowers/plans/2026-04-20-moneymap-mcp.md b/docs/superpowers/plans/2026-04-20-moneymap-mcp.md
new file mode 100644
index 0000000..31294bc
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-20-moneymap-mcp.md
@@ -0,0 +1,1842 @@
+# 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
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **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
+
+
+
+```
+
+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
+
+```
+
+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
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+```
+
+- [ ] **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
+
+
+ PreserveNewest
+
+
+```
+
+Remove from `MoneyMap/MoneyMap.csproj` the Prompts section:
+
+```xml
+
+
+
+ PreserveNewest
+
+
+```
+
+- [ ] **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
+
+
+
+```
+
+**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 `` 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();
+```
+
+- [ ] **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(options =>
+ options.UseSqlServer(configuration.GetConnectionString("MoneyMapDb")));
+
+ services.AddMemoryCache();
+
+ // Core transaction and import services
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ // Entity management services
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ // Receipt services
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ // Reference data and dashboard
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ // AI services
+ services.AddScoped();
+ services.AddScoped();
+
+ 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();
+
+// Web-specific services that stay here:
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
+builder.Services.AddHttpClient();
+builder.Services.AddHttpClient();
+builder.Services.AddHttpClient();
+builder.Services.AddHttpClient();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddHttpClient();
+builder.Services.AddHostedService();
+```
+
+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
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+```
+
+- [ ] **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();
+
+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 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 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 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 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 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 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 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 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(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 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(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 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 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 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> 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 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 ListReceipts(
+ [Description("Filter by transaction ID")] long? transactionId = null,
+ [Description("Filter by parse status: NotRequested, Queued, Parsing, Completed, Failed")] string? parseStatus = null,
+ [Description("Max results (default 50)")] int? limit = null,
+ MoneyMapContext db = default!)
+ {
+ var q = db.Receipts
+ .Include(r => r.Transaction)
+ .AsQueryable();
+
+ if (transactionId.HasValue)
+ q = q.Where(r => r.TransactionId == transactionId.Value);
+
+ if (!string.IsNullOrWhiteSpace(parseStatus) && Enum.TryParse(parseStatus, true, out var status))
+ q = q.Where(r => r.ParseStatus == status);
+
+ var results = await q
+ .OrderByDescending(r => r.UploadedAtUtc)
+ .Take(limit ?? 50)
+ .Select(r => new
+ {
+ r.Id,
+ r.FileName,
+ 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 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` 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 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 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 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 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 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 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