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>
This commit is contained in:
Claudio Schaad 2026-07-03 18:46:23 +02:00
parent f9084dc074
commit d64b3cd876

354
MCP_DESIGN.md Normal file
View file

@ -0,0 +1,354 @@
# 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`).