AccountingNext/MCP_DESIGN.md
Claudio Schaad d64b3cd876 Add MCP server design doc
Design for a read-only MCP server that exposes the accounting data
to Claude (or another MCP client) via stdio: project layout, DI
wiring, year/mandator scope handling, tool catalog with input/output
schemas, packaging + Claude Desktop config, and three privacy modes
(Full / Aggregate / Local) covering what actually leaves the machine
when a hosted-model client relays tool results to its model provider.

Not implemented; PR sequencing sketched at the end of the doc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 18:46:23 +02:00

15 KiB
Raw Permalink Blame History

MCP server design

Design produced 2026-07-03 in conversation with Claude. Read alongside IMPROVEMENT_PLAN.md.

Goals

  • Let Claude answer questions like "how much did I spend on groceries in Q1?", "what's my current balance in CHF?", "which recurring debits changed vs. last year?".
  • Read-only. No Save*, Delete*, Backup, or Import* tools ever exposed.
  • Reuse the async ViewService / repository stack already in the codebase. No parallel data layer.
  • Local stdio transport. No HTTP, no auth surface. The MCP server process runs on your machine, but any field a tool returns is forwarded by the MCP client to whichever model it's paired with. See Privacy modes below for what that actually means in practice.

Architecture

Transport

  • Stdio JSON-RPC via the official ModelContextProtocol .NET SDK (Microsoft org's MCP package). Console app, Microsoft.Extensions.Hosting-based, [McpServerToolType] + [McpServerTool] attributes.
  • No custom framing/serialization — the SDK handles it.

Project layout

Schaad.Accounting.Mcp/
├── Schaad.Accounting.Mcp.csproj        - net9.0 console
├── Program.cs                          - host + DI + McpServer.CreateStdioServer
├── Context/
│   └── AccountingScope.cs              - year/mandator switching (see below)
├── Dtos/
│   ├── AccountDto.cs                   - flat records for LLM consumption
│   ├── TransactionDto.cs
│   └── ...
└── Tools/
    ├── ContextTools.cs                 - list_mandators, list_years
    ├── AccountTools.cs                 - list_accounts, get_account
    ├── BalanceTools.cs                 - get_balance_summary, get_income_statement
    ├── TransactionTools.cs             - get_transactions, search_transactions
    └── AnalysisTools.cs                - get_expenses_by_category, compare_years

DI wiring

The composition root AddAccounting() currently lives in Schaad.Accounting.UI/Extensions.cs. Two options:

  1. Extract to a new tiny Schaad.Accounting.Composition project that references Db + Services. UI and Mcp both reference it.
  2. Duplicate the ~15-line registration block in Mcp/Program.cs.

Recommend (1) — with two composition roots, every subsequent DI change would otherwise need to touch both.

Mcp/Program.cs is then:

var builder = Host.CreateApplicationBuilder(args);
builder.Services.Configure<SettingsDataset>(builder.Configuration.GetSection("Settings"));
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<SettingsDataset>>().Value);
builder.Services.AddAccounting();

builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly();

await builder.Build().RunAsync();

Handling year/mandator

SettingsService is Singleton and holds year + mandator. The UI mutates them via MyHeader + forceLoad. There is no equivalent in stdio MCP.

Approach: every tool takes optional year and mandator; wrap execution in a scope that stashes & restores.

public sealed class AccountingScope : IDisposable
{
    private readonly ISettingsService settings;
    private readonly int oldYear;
    private readonly string oldMandator;

    public AccountingScope(ISettingsService settings, int? year, string? mandator)
    {
        this.settings = settings;
        oldYear = settings.GetYear();
        oldMandator = settings.GetMandator();
        if (year.HasValue) settings.SetYear(year.Value);
        if (mandator is not null) settings.SetMandator(mandator);
    }

    public void Dispose()
    {
        settings.SetYear(oldYear);
        settings.SetMandator(oldMandator);
    }
}

Every tool wraps its body:

[McpServerTool, Description("...")]
public async Task<...> ListAccountsAsync(int? year = null, string? mandator = null, ...)
{
    using var _ = new AccountingScope(settingsService, year, mandator);
    // RepositoryCache is Scoped; resolve a fresh IServiceScope so scoped services
    // don't hold references to entries from an earlier (year, mandator).
}

Concurrency: stdio MCP calls are serial (one JSON-RPC message at a time), so the Singleton mutation window is safe. If we ever move to HTTP transport with concurrent calls, this design breaks — flag that in code.

Cache freshness: RepositoryCache is Scoped and keyed by absolute file path (which includes dbPath → year+mandator). When the scope changes year/mandator, the cache keys naturally change too, so stale data is not returned. But we should still resolve a fresh IServiceScope per tool call:

public class ToolExecutor
{
    private readonly IServiceScopeFactory scopeFactory;
    private readonly ISettingsService settings;

    public async Task<T> Run<T>(int? year, string? mandator, Func<IServiceProvider, Task<T>> body)
    {
        using var _ = new AccountingScope(settings, year, mandator);
        using var scope = scopeFactory.CreateScope();
        return await body(scope.ServiceProvider);
    }
}

Each tool class takes ToolExecutor and wraps its body.

Tool catalog

Naming: snake_case. All optional args default sensibly. All amounts are decimal, CHF-normalized where a currency isn't specified. All dates are ISO 8601 (yyyy-MM-dd).

list_mandators

List the mandators (client accounting sets) that have data.

  • Input: { year?: int }
  • Output: { mandators: string[] }

list_years

List the years for which the given mandator has data.

  • Input: { mandator?: string }
  • Output: { years: int[] }

list_accounts

List all accounts with their current-year balance in both account currency and CHF. Use before get_transactions to look up account IDs.

  • Input: { year?, mandator?, class?: "activa"|"passiva"|"income"|"expenses" }
  • Output:
    {
      "accounts": [
        { "id": "...", "number": 1010, "name": "Checking", "currency": "CHF",
          "class": "activa", "subclassName": "Umlaufvermögen",
          "balance": 12345.67, "balanceChf": 12345.67 }
      ]
    }
    

get_balance_summary

Total activa vs. total passiva, plus equity (activa − passiva). CHF.

  • Input: { year?, mandator? }
  • Output: { totalActivaChf, totalPassivaChf, equityChf }

get_income_statement

Income statement (Erfolgsrechnung) for the year: profit, loss, net, and the account list per side.

  • Input: { year?, mandator? }
  • Output: { profitChf, lossChf, netChf, incomeAccounts: [...], expenseAccounts: [...] }

get_transactions

Fetch transactions with filters. Prefer narrow filters — this is the main workhorse. Truncated at limit (default 200, max 1000).

  • Input:
    {
      "year": 2026,
      "mandator": null,
      "accountId": null,
      "fromDate": "2026-01-01",
      "toDate": "2026-03-31",
      "textSearch": "coop",
      "minAmount": 100,
      "maxAmount": null,
      "limit": 200
    }
    
  • Output:
    {
      "transactions": [
        { "id": "...", "date": "2026-02-14", "bookingDate": "2026-02-14",
          "text": "Coop City", "value": 87.30, "currency": "CHF",
          "originAccountId": "chk", "originAccountName": "Checking",
          "targetAccountId": "groc", "targetAccountName": "Groceries",
          "relatedParty": "Coop Genossenschaft" }
      ],
      "total": 47,
      "truncated": false
    }
    

Backed by ITransactionRepository.GetTransactionListAsync() + in-memory filter — datasets are small enough that we don't need repo-level query pushdown.

get_expenses_by_category

Expenses grouped by subclass or account. Sorted descending by CHF total.

  • Input: { year?, mandator?, groupBy?: "subclass" | "account" }
  • Output: { categories: [{ name, totalChf, transactionCount }] }

compare_years

Side-by-side comparison for equivalent categories across years. Useful for "how did my spending change YoY".

  • Input: { years: int[], mandator?, side: "income" | "expenses" | "both" }
  • Output:
    {
      "comparison": [
        { "year": 2025, "totalIncomeChf": ..., "totalExpensesChf": ...,
          "byCategory": { "Groceries": 4800.50, "Rent": 24000, ... } },
        { "year": 2026, ... }
      ]
    }
    

Output shape conventions

  • Flat records: no nested navigation properties. Account name inlined next to id so the LLM doesn't need a follow-up call.
  • CHF-normalized: any total is always in CHF. Per-transaction values keep the account currency plus the CHF equivalent when accounts differ.
  • No booleans for "successful" — either the tool returns data or it errors. MCP has native error surfacing.
  • Cap collection sizes: get_transactions truncates at limit; the LLM decides whether to narrow the filter.

Read-only enforcement

  • Repository / service interfaces still expose write methods (they're used from the UI). What's exposed is controlled by simply not authoring a [McpServerTool] for those verbs.
  • To make this explicit, the Tools/ folder contains only read operations, and typed errors (or similar) are the only way the LLM sees "no such account".
  • Do NOT expose SetYear / SetMandator as tools. Year/mandator switching happens implicitly per-call via AccountingScope. Otherwise the LLM could clobber the UI's session state.

Packaging & Claude Desktop

Build once:

dotnet publish Schaad.Accounting.Mcp/ -c Release -o D:/AccountingMcp

Claude Desktop claude_desktop_config.json:

{
  "mcpServers": {
    "schaad-accounting": {
      "command": "D:/AccountingMcp/Schaad.Accounting.Mcp.exe",
      "env": {
        "Settings__DataPath": "D:/Developer/AccountingData/",
        "Settings__DefaultMandator": "Claudio Schaad"
      }
    }
  }
}

FixerIoApiKey doesn't need to be set — MCP tools only ever need CHF conversions of stored CHF values, and DummyFxService is the current implementation.

Security

  • Local stdio only. No listening port. Anything running as your user can already read the XML files; MCP doesn't widen the local-attack blast radius.
  • DataPath only from environment / config. Never accept set_data_path or similar. The LLM must not be able to point the server at arbitrary directories.
  • No write tools — repeated deliberately because it's the single biggest risk mitigation.
  • No secrets in tool outputs. The FixerIoApiKey is not returned by any tool.
  • Data exposure to the model provider is a separate axis from local security. See Privacy modes below.

Privacy modes

The data-flow reality. When Claude Desktop calls an MCP tool, the invocation and result are relayed through the Anthropic API so the model can reason over them. "Local stdio" only means the server process runs on your PC — the JSON payload it emits still crosses the network on its way to the model. Other MCP clients (Cursor, Zed, any hosted-model client) work the same way.

To make the trade-off explicit, the server supports three privacy modes via a Mcp:Privacy config key.

Full (default)

Every tool from the catalog is registered. Tool results include per-transaction detail: text, related party, amount, dates, source/target account names. Most useful for open-ended questions, most exposing — a single get_transactions call can send hundreds of rows of counterparty/amount data to the model provider.

Use when: you're comfortable with the model provider's data-use policy on your account, and you want maximum answer quality.

Aggregate

Only aggregate tools are registered:

  • list_accounts — trimmed: name, currency, class, balanceChf. No account IDs, no bank account numbers, no start balance.
  • get_balance_summary
  • get_income_statement — trimmed: totals per class, no per-account rows.
  • get_expenses_by_category — grouped by subclass name.
  • compare_years — same as Full.

Withheld: get_transactions (entirely), per-account balance rows, related-party names, individual dates and texts.

Use when: you want spending/income insight without exposing individual counterparties or transaction narratives. Trades power for privacy — Claude can answer "how did my grocery spending change YoY?" but not "what was that odd 850 CHF withdrawal in March?".

Local

Same tool catalog as Full, but intended to be paired with a local MCP client — e.g. an Ollama- or LM-Studio-backed client that runs the model on your own machine. The MCP server itself doesn't and can't enforce this; the choice happens in which client you point at the server binary. Nothing crosses the network. Answer quality is lower than Claude, sometimes considerably.

Use when: you don't want any financial data leaving your PC at all.

Configuring the mode

{
  "mcpServers": {
    "schaad-accounting": {
      "command": "D:/AccountingMcp/Schaad.Accounting.Mcp.exe",
      "env": {
        "Settings__DataPath": "D:/Developer/AccountingData/",
        "Settings__DefaultMandator": "Claudio Schaad",
        "Mcp__Privacy": "Aggregate"
      }
    }
  }
}

Program.cs reads Mcp:Privacy at startup and decides which [McpServerToolType] classes to register with the SDK. Modes are enforced by registration, not by runtime filtering — a tool that's not registered simply doesn't exist from the model's point of view, so the model can't accidentally call it.

For belt-and-braces defence-in-depth, the sensitive-output DTOs (TransactionDto, per-account rows) can also live in a separate namespace whose types are only ever constructed by tools that are registered under Full; that way an accidentally shipped aggregate build won't compile against them.

Out of scope for v1

  • Resources: expose the chart of accounts as an MCP Resource so the LLM reads structure once instead of via list_accounts on every conversation.
  • Prompts: prebuilt Claude prompts ("Monatlicher Kassenbericht", "Erklär mir das Q3-Wachstum").
  • Bank-transaction matching preview: expose the MatchOpenBankTransactions output as a read-only tool so Claude can review the auto-matcher's guesses.
  • Budgets / alerts: not modelled in the app yet, so no data to expose.

Rough implementation size

  • New project + csproj + Program.cs: ~40 LOC
  • AccountingScope + ToolExecutor: ~40 LOC
  • 8 tools with DTOs: ~350 LOC (mostly declarative attribute schemas + Select projections)
  • Extract AddAccounting() to Composition project: ~30 LOC (mostly moving)
  • Total: ~450 LOC, plus config/docs.

Suggested PR sequencing

  • PR Q — Extract AddAccounting() from Schaad.Accounting.UI/Extensions.cs to a new Schaad.Accounting.Composition project referenced by UI and Mcp. Mechanical, low risk.
  • PR R — Add the Schaad.Accounting.Mcp project with Program.cs, AccountingScope, ToolExecutor, and the first two tools (list_mandators, list_accounts). Enough to wire Claude Desktop and iterate on the actual conversation feel before adding analytics tools.
  • PR S — Balance + income statement tools (get_balance_summary, get_income_statement).
  • PR T — get_transactions (the workhorse) with filter validation and truncation.
  • PR U — Analytics (get_expenses_by_category, compare_years).