Content
# 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.