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

354 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<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.**
```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<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:**
```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`).