estv-mcp
# estv-mcp
[](https://github.com/noaahh/estv-mcp/actions/workflows/ci.yml)
MCP server for the official Swiss federal tax administration calculator at
[swisstaxcalculator.estv.admin.ch](https://swisstaxcalculator.estv.admin.ch/).
It wraps the undocumented JSON API behind that SPA (no key, no auth) and exposes
income, wealth, lump-sum, inheritance and corporate tax figures for every Swiss
municipality and tax years 2010-2026.
## Tools
Household income and wealth:
| Tool | What it does |
| --- | --- |
| `find_location` | Postal code or place name to `tax_location_id` |
| `calculate_tax` | Federal, cantonal, municipal and church tax from gross income, wealth and family situation |
| `list_deductions` | The ESTV budget sheet with its default values, and which lines you may override |
| `calculate_tax_from_taxable_amounts` | Applies the tax scales to taxable income read off an assessment |
| `explain_tax_brackets` | The statutory rate ladder and where an income sits in it |
Comparing places:
| Tool | What it does |
| --- | --- |
| `compare_locations` | Ranks municipalities by total burden for the same household |
| `find_cheapest_nearby` | Same ranking, restricted to a radius around a point |
Planning:
| Tool | What it does |
| --- | --- |
| `plan_capital_withdrawals` | Cheapest way to split a pension lump sum across tax years |
| `deduction_value` | What a deduction is worth, franc by franc, at this income |
Other taxes:
| Tool | What it does |
| --- | --- |
| `calculate_capital_payment_tax` | Tax on a pillar 2 / pillar 3a lump-sum withdrawal, single place or ranked |
| `calculate_inheritance_tax` | Inheritance and gift tax by beneficiary relationship |
| `calculate_company_tax` | Profit and capital tax for a GmbH or AG |
| `get_tax_years` | Covered year range per calculator and the ESTV data version |
## Install
```sh
uv sync
```
## Register with Claude Code
From inside the repository:
```sh
claude mcp add estv-tax -- uv run --directory "$PWD" estv-mcp
```
Add `-s user` to make it available in every project rather than just this one.
Or in any MCP client config, with `/path/to/estv-mcp` replaced by wherever you
cloned it:
```json
{
"mcpServers": {
"estv-tax": {
"command": "uv",
"args": ["run", "--directory", "/path/to/estv-mcp", "estv-mcp"]
}
}
}
```
## Development
```sh
uv sync # install, including dev group
uv run pytest -q # tool-level tests plus golden values
uv run python scripts/stdio_smoke.py # real MCP stdio handshake
uv run ruff check . && uv run ruff format --check .
```
Tests hit the live ESTV API, so they need network access but no credentials.
CI runs lint, the suite on Python 3.11/3.12/3.13, and the stdio smoke check on
every push and pull request, plus weekly on a schedule.
### Golden values
`tests/golden.json` pins recorded figures for a closed tax year, where the
scales should never move again. `tests/test_golden.py` re-checks them against
the live API with caching disabled, so the weekly run reports upstream drift as
a concrete diff rather than just confirming the endpoint still answers.
When a golden test fails, read the diff first. It usually means ESTV changed
something, not that this repo is broken. Once the change is understood:
```sh
uv run python scripts/update_golden.py # prints every value that moved
```
### Caching
Responses are cached under `~/.cache/estv-mcp` for a week, which matters because
the planning tools fan out into dozens of sub-queries. `ESTV_MCP_NO_CACHE=1`
disables it, `ESTV_MCP_CACHE_DIR` and `ESTV_MCP_CACHE_TTL` override the
location and lifetime.
## Notes
- Amounts are CHF per year. `income_type='employed'` means gross salary; the
model derives AHV/IV/EO, ALV, NBU and BVG contributions itself.
- Deduction overrides go through `deductions={id: value}` on `calculate_tax`,
using the ids from `list_deductions` (`PRAEMIEN3A` pillar 3a, `SCHULDZINSEN`
mortgage interest, `KKPRAEMIEN` health insurance premiums, `MIETAUSGABEN`
rent, `KINDBETREUUNGSKOSTEN` childcare, and so on).
- `compare_locations(scope='switzerland')` covers ~2100 municipalities; only
the extremes plus summary statistics come back so results stay readable.
- Church tax is only charged for `confession` values other than `none`.
- `plan_capital_withdrawals` models the rule that payouts received in the same
calendar year are aggregated before the rate applies, so splitting within a
year correctly shows no saving. It does not model the federal aggregation of
a spouse's payouts, and years beyond ESTV's published range are priced with
the latest available scales. Both are stated in the result's `caveats`.
- `explain_tax_brackets` always takes the amount from ESTV. The ladder is
rebuilt from the published scale and reproduces ESTV exactly for the
ZUERICH and BUND table layouts; splitting cantons apply extra rounding
(flagged via `ladder_note`), and BL, FR, VD and VS publish formulas rather
than tables, where the ladder is omitted rather than guessed at.
- Figures are the official ESTV model, not a binding assessment.
## API notes
Base URL: `https://swisstaxcalculator.estv.admin.ch/delegate/ost-integration/v1/lg-proxy/operation/c3b67379_ESTV`.
Every operation is a `POST` with a JSON body answering `{"response": ...}`. Bad
input returns an HTML 500 page rather than a structured error, which
`api.EstvClient` translates into an `EstvError`.
Enum values (from the SPA bundle):
- Relationship: single 1, married 2, concubinage 3, registered partnership 4
- Confession: reformed 1, roman catholic 2, christ catholic 3, none 4, other 5
- Income type: employed 1, self-employed 2, pensioner 3, other 4
- Language: de 1, fr 2, it 3, en 4
- `TaxGroupID`: cantons alphabetically 1-26 (AG=1 … ZH=26), capitals 88, all of Switzerland 99
Two gotchas worth recording:
- `API_exportManyTaxScales` reuses the same field names for two different
layouts. In `ZUERICH` tables `Amount` is the width of a band; in `BUND`
tables it is the lower threshold and `Taxes` carries the tax already due at
it. Reading a `BUND` table with the `ZUERICH` rule gives a plausible but
wrong number, so `TableType` has to be honoured.
- The geo search returns at most 200 results, silently clipping a large
radius. `find_cheapest_nearby` detects the cap and says so.
TDQS
Scored across 13 tools
Each tool targets a distinct tax calculation or query scenario. While `calculate_tax` and `calculate_tax_from_taxable_amounts` both compute tax, they differ in input types (gross vs taxable amounts). Similarly, `compare_locations` and `find_cheapest_nearby` both rank municipalities but one is global with optional filtering and the other is radius-based. Overall, descriptions clearly differentiate purposes, minimizing confusion.
All tool names follow a consistent verb_noun pattern using lowercase with underscores (e.g., `find_location`, `calculate_tax`, `explain_tax_brackets`). The verbs are action-oriented (find, list, calculate, compare, get, plan, explain) and the nouns describe the domain entity. No mixing of conventions, making the pattern predictable.
With 13 tools, the server covers the core Swiss tax calculation domain comprehensively without being bloated. Each tool serves a distinct purpose: location resolution, deduction listing, multiple tax computations (income, capital, inheritance, company), comparison utilities, planning, and bracket explanation. The count feels well-scoped for the complexity of the domain.
The tool set covers the full lifecycle of typical Swiss tax queries: resolving locations, understanding deductions, computing taxes from various inputs, comparing municipalities, optimizing capital withdrawals, and explaining brackets. Missing aspects like tax form filing or historical data are outside the server's stated purpose. The surface is complete for calculation and planning tasks.