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

62 KiB

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/*.csMoneyMap.Core/Models/

  • Move: MoneyMap/Models/Api/*.csMoneyMap.Core/Models/Api/

  • Move: MoneyMap/Models/Dashboard/*.csMoneyMap.Core/Models/Dashboard/

  • Move: MoneyMap/Models/Import/*.csMoneyMap.Core/Models/Import/

  • Move: MoneyMap/Data/MoneyMapContext.csMoneyMap.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

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
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:

<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
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
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
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet sln MoneyMap.sln add MoneyMap.Core/MoneyMap.Core.csproj

Add ProjectReference in MoneyMap/MoneyMap.csproj:

<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:

<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
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:

<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
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet test MoneyMap.Tests/MoneyMap.Tests.csproj

Expected: All existing tests pass.

  • Step 10: Commit
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/*.csMoneyMap.Core/Services/

  • Move: MoneyMap/Services/AITools/*.csMoneyMap.Core/Services/AITools/

  • Keep in web: ReceiptParseWorkerService.cs, ModelWarmupService.cs (hosted services)

  • Step 1: Move core services to MoneyMap.Core

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:

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.

mv MoneyMap/Prompts MoneyMap.Core/Prompts

Add to MoneyMap.Core/MoneyMap.Core.csproj:

<ItemGroup>
  <None Update="Prompts\**\*">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </None>
</ItemGroup>

Remove from MoneyMap/MoneyMap.csproj the Prompts section:

<!-- Remove this -->
<ItemGroup>
  <None Update="Prompts\**\*">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </None>
</ItemGroup>
  • Step 4: Build and fix issues
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:
<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
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet test MoneyMap.Tests/MoneyMap.Tests.csproj

Expected: All tests pass.

  • Step 6: Commit
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:

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:

private readonly IWebHostEnvironment _environment;

with:

private readonly IReceiptStorageOptions _receiptStorage;

Replace the constructor assignment:

_environment = environment;

with:

_receiptStorage = receiptStorage;

Replace GetReceiptsBasePath():

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:

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:

using MoneyMap.WebApp.Services;

// After other service registrations:
builder.Services.AddSingleton<IReceiptStorageOptions, WebReceiptStorageOptions>();
  • Step 5: Build and run tests
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
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:

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:

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
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
cd C:/Users/AJ/Desktop/Projects/MoneyMap/MoneyMap
dotnet run

Verify it starts without errors, then stop it (Ctrl+C).

  • Step 5: Commit
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

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:

<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
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:

{
  "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:

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:

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
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.Mcp/MoneyMap.Mcp.csproj
  • Step 8: Commit
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:

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
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.Mcp/MoneyMap.Mcp.csproj
  • Step 3: Commit
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:

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
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.Mcp/MoneyMap.Mcp.csproj
  • Step 3: Commit
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:

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
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.Mcp/MoneyMap.Mcp.csproj
  • Step 3: Commit
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:

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
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
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:

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
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.Mcp/MoneyMap.Mcp.csproj
  • Step 3: Commit
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:

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:

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
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet build MoneyMap.Mcp/MoneyMap.Mcp.csproj
  • Step 4: Commit
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

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
cd C:/Users/AJ/Desktop/Projects/MoneyMap
dotnet test MoneyMap.Tests/MoneyMap.Tests.csproj
  • Step 4: Quick smoke test the MCP server
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:

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
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

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
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
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