# 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](#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: ```csharp var builder = Host.CreateApplicationBuilder(args); builder.Services.Configure(builder.Configuration.GetSection("Settings")); builder.Services.AddSingleton(sp => sp.GetRequiredService>().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.** ```csharp 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: ```csharp [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: ```csharp public class ToolExecutor { private readonly IServiceScopeFactory scopeFactory; private readonly ISettingsService settings; public async Task Run(int? year, string? mandator, Func> 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:** ```json { "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:** ```json { "year": 2026, "mandator": null, "accountId": null, "fromDate": "2026-01-01", "toDate": "2026-03-31", "textSearch": "coop", "minAmount": 100, "maxAmount": null, "limit": 200 } ``` - **Output:** ```json { "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:** ```json { "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: ```bash dotnet publish Schaad.Accounting.Mcp/ -c Release -o D:/AccountingMcp ``` Claude Desktop `claude_desktop_config.json`: ```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](#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 ```json { "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`).