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>
354 lines
15 KiB
Markdown
354 lines
15 KiB
Markdown
# 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`).
|