swiss-electricity-mcp
# swiss-electricity-mcp
> **MCP server for Swiss electricity data β three official sources, twelve tools, zero authentication.**
[](https://github.com/malkreide/swiss-electricity-mcp/actions/workflows/test.yml)
[](https://pypi.org/project/swiss-electricity-mcp/)
[](https://pypi.org/project/swiss-electricity-mcp/)
[](LICENSE)
π **Read this in your language:** [π©πͺ Deutsch](README.de.md)
Part of the **[Swiss Public Data MCP Portfolio](https://github.com/malkreide/swiss-public-data-mcp)** β a coordinated set of MCP servers for Swiss public administration.
---
## Anchor demo query
> *"How have ewz electricity tariffs for a typical school building (consumption category C3, β150'000 kWh/a) developed since 2019, and how do they compare to the Swiss median?"*
A single conversation calls `tariff_get_by_municipality` (bfs_nr=261, category="C3") + `tariff_get_median_swiss` and returns a year-by-year comparison with full provenance β ready for a GeschΓ€ftsleitung slide.
### Demo

---
## What's inside
Three official Swiss data sources combined into one MCP server, each with its own dedicated tool group:
| Source | What it provides | Provenance |
|---|---|---|
| **Energiedashboard.ch** (Bundesamt fΓΌr Energie) | National production mix, consumption forecast, storage-lake fill, consumer price index | `live_api` |
| **ElCom electricity-price cubes** (via LINDAS SPARQL) | Tariffs per municipality, category, year, with full breakdown (energy + grid usage + KEV + Abgaben) | `sparql` |
| **opendata.swiss + Stadt ZΓΌrich OGD** (CKAN) | Dataset discovery for raw time series (e.g. quarter-hour NE5/NE7 consumption) | `live_api` |
**No authentication required.** All endpoints are public Swiss OGD.
---
## Tools (12)
### `dashboard_*` β Energiedashboard.ch (BFE)
- **`dashboard_get_production_mix`** β Production mix by year (TWh + %): Kernkraft, Wasserkraft, PV, Wind, thermal.
- **`dashboard_get_consumption_forecast`** β Current consumption forecast + 5-day outlook + 5-year envelope.
- **`dashboard_get_storage_lakes`** β Speichersee fill level (CH or per region: Wallis, Tessin, GraubΓΌnden, Zentral/Ost) β critical winter-supply indicator.
- **`dashboard_get_consumer_price_index`** β Endverbraucher-Strompreis-Index (2020-01-01 = 100).
### `tariff_*` β ElCom (via LINDAS SPARQL)
- **`tariff_list_categories`** β H1βH8 (households) and C1βC7 (commercial). **C3 β 150'000 kWh/a is the typical reference for school buildings.**
- **`tariff_get_by_municipality`** β Tariffs for a BFS-Nr + category + year range, broken into energy / grid usage / KEV / Abgaben.
- **`tariff_get_median_swiss`** β National median benchmark.
- **`tariff_get_median_canton`** β Cantonal median (e.g. for Kanton ZΓΌrich).
- **`tariff_compare_municipalities`** β Compare up to 20 municipalities side-by-side.
### `consumption_*` β opendata.swiss + Stadt ZΓΌrich OGD
- **`consumption_search_bfe_datasets`** β CKAN search across BFE-published datasets.
- **`consumption_search_zurich`** β CKAN search across Stadt ZΓΌrich OGD (includes quarter-hour NE5/NE7 consumption).
### Status
- **`electricity_check_status`** β Liveness probe across all four upstreams (HTTP status + latency + overall-healthy flag).
---
## Installation
### From PyPI
```bash
pip install swiss-electricity-mcp
```
### From source
```bash
git clone https://github.com/malkreide/swiss-electricity-mcp.git
cd swiss-electricity-mcp
pip install -e ".[dev]"
```
---
## Use with Claude Desktop
Add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"swiss-electricity": {
"command": "swiss-electricity-mcp"
}
}
}
```
---
## Cloud deployment (Streamable HTTP)
```bash
SWISS_ELECTRICITY_TRANSPORT=streamable-http \
SWISS_ELECTRICITY_HOST=0.0.0.0 \
SWISS_ELECTRICITY_PORT=8000 \
swiss-electricity-mcp
```
Works on Render.com, Railway, Fly.io.
> **Host binding (security).** In HTTP mode the host defaults to `127.0.0.1`
> (loopback only). Bind to all interfaces with `SWISS_ELECTRICITY_HOST=0.0.0.0`
> **only inside a container**, where the network boundary is the container, not
> the host. Setting `0.0.0.0` on a developer machine exposes the server to the
> local network (NeighborJack).
### Docker
A multi-stage `Dockerfile` is provided. It runs as a non-root user (UID 10001)
and sets `SWISS_ELECTRICITY_HOST=0.0.0.0` explicitly for the containerised case.
```bash
docker build -t swiss-electricity-mcp .
docker run --rm -p 8000:8000 swiss-electricity-mcp
```
---
## Observability & configuration
| Env var | Default | Purpose |
|---|---|---|
| `SWISS_ELECTRICITY_TRANSPORT` | `stdio` | `stdio` or `streamable-http` |
| `SWISS_ELECTRICITY_HOST` | `127.0.0.1` | HTTP bind host (`0.0.0.0` in containers only) |
| `SWISS_ELECTRICITY_PORT` | `8000` | HTTP port |
| `SWISS_ELECTRICITY_LOG_LEVEL` | `INFO` | Log level (DEBUG/INFO/WARNING/ERROR) |
| `SWISS_ELECTRICITY_CORS_ORIGINS` | _(empty)_ | Comma-separated allowed CORS origins (browser clients); never `*` |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | _(unset)_ | Enables OpenTelemetry tracing when set |
| `SWISS_ELECTRICITY_ENV` | `unknown` | `deployment.environment` resource attribute for traces |
- **Logging** is structured JSON on **stderr** (stdout is reserved for the stdio
JSON-RPC channel). Upstream failures are logged in full server-side but masked
in client-facing responses.
- **Tracing** is opt-in. Install the extra and point it at a collector:
```bash
pip install "swiss-electricity-mcp[otel]"
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 swiss-electricity-mcp
```
You get one span per tool call (`mcp.tool.<name>`) plus automatic httpx child
spans for each upstream request. No argument values or PII are recorded.
---
## Architecture
```
βββββββββββββββββββββββββββ MCP client (Claude etc.) βββββββββββββββββββββββββββ
β stdio or Streamable HTTP β
βββββββββββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββ
β 12 read-only tools (annotated)
ββββββββββΌβββββββββββ
β MCPServer (mcp) β egress allow-list + HTTPS gate
β + structlog/OTel β per-source TTL cache + retry
βββββ¬βββββββββ¬ββββ¬βββ
dashboard_* β tariff_* β β β consumption_*
βΌ βΌ βΌ βΌ
βββββββββββββββββββββ βββββββββββββ ββββββββββββββββ βββββββββββββββββββββββ
β Energiedashboard β β LINDAS β β opendata.swissβ β data.stadt-zuerich.chβ
β .admin.ch (BFE) β β SPARQL β β CKAN β β CKAN (OGD) β
βββββββββββββββββββββ βββββββββββββ ββββββββββββββββ βββββββββββββββββββββββ
```
**Hybrid (live API + SPARQL + CKAN discovery)**, no authentication. Three reasons this is the right shape:
1. **Different latency profiles per source**: Energiedashboard responds in ~200 ms (great live); LINDAS SPARQL is slower and occasionally returns 504 (longer timeout + 3 retries); CKAN is metadata-only and inherently safe.
2. **Different update cadences**: Dashboard updates intraday; ElCom tariffs update once per year; OGD datasets are stable for months. Per-source TTL caching (600 s / 3600 s) reflects this.
3. **Domain separation from `swiss-energy-mcp`**: that server covers geo and infrastructure data (power plants, grid lines). `swiss-electricity-mcp` covers time-series and tariffs. Both compose cleanly.
### Provenance discipline
Every tool response is a Pydantic envelope carrying:
- `source` β full attribution string (e.g. *"Daten: Bundesamt fΓΌr Energie (BFE)β¦"*).
- `provenance` β exactly one of `live_api` / `sparql` / `cached` / `weekly_dump` / `stale_cache_fallback`.
- `retrieved_at` β ISO-8601 UTC timestamp.
This makes accidental misattribution structurally impossible.
### Resilience
- **Retry**: up to 3 retries, i.e. at most 4 attempts. Waits grow exponentially (base 2 s / 4 s / 8 s), each jittered to 0.5β1.5Γ so that clients do not return in lockstep after an outage. No single wait exceeds 20 s. A `Retry-After` sent with 429 or 503 takes precedence (plus 0β25 %, also capped at 20 s).
- **Time budget**: 25 s for the whole call, all attempts and waits together. A wait that would overrun the budget ends the call instead, so there may be fewer than 4 attempts.
- **5xx + 429**: retried. **4xx (except 429)**: raised immediately (permanent client error).
- **In-memory TTL cache**: per-source TTLs reduce upstream load and round-trip during multi-step agent workflows.
### MCP primitives β why Tools only
This server intentionally exposes **only Tools**, not Resources or Prompts. The
data is parametric and query-driven (a municipality BFS number, a category, a
year), which maps naturally to tool calls; there is no stable, enumerable set of
documents to expose as Resources, and no curated prompt templates to ship. If a
future use case needs, say, a fixed "national production mix" document, the
read-only `dashboard_*` tools are the obvious Resource-migration candidates.
### Project phase
**Phase 1 β read-only.** All 12 tools are read-only (`readOnlyHint=true`) with no
write or destructive operations. Phase-transition criteria and the longer-term
plan live in [`docs/roadmap.md`](docs/roadmap.md). Security posture (egress,
supply-chain, lethal-trifecta assessment) is documented in
[`docs/security-posture.md`](docs/security-posture.md).
---
## MCP Protocol Version
This server speaks **two protocol eras** over the same endpoint. The client's
first request on a connection decides which one applies; a later claim from the
other era is refused.
| Era | Revision | Who reaches it |
|---|---|---|
| `initialize` handshake | `2024-11-05` β¦ **`2025-11-25`** | What today's clients speak. The server answers with the revision asked for, or with the `2025-11-25` ceiling when the request asks for something newer. |
| Per-request envelope | **`2026-07-28`** | A request carrying the `2026-07-28` `_meta` envelope opens a modern connection. |
Both revisions are pinned in
[`tests/test_protocol_version.py`](tests/test_protocol_version.py) and asserted
against the installed SDK, so a Dependabot bump of `mcp` cannot move either one
silently. The handshake ceiling is measured against a live `initialize` through
the assembled ASGI stack, not read off a constant name.
Note that the SDK's `LATEST_PROTOCOL_VERSION` is an alias for the **modern**
era, not for the handshake era β pinning against it alone would leave the era
that current clients actually negotiate free to drift.
**Update policy.** When the gate fails, do not edit the constant blindly: read
the spec changelog between the two revisions, verify the server still behaves,
then move the constant, this section, `README.de.md` and
[`CHANGELOG.md`](CHANGELOG.md) together. A spec bump is adopted only through an
explicit `mcp` minor/major bump, recorded in [`CHANGELOG.md`](CHANGELOG.md) and
verified against the tool-definition lock (`tool-definitions.lock.json`).
### What the server sets natively for `2026-07-28`
The SDK reaching a revision is not the same as a server speaking it. Two
surfaces the SDK leaves to the server, and what happens when it is left alone:
| Surface | What this server sets | What the SDK does without it |
|---|---|---|
| `serverInfo` β stamped into the `_meta` of **every** result, not just the `initialize` reply | `name`, `title`, `version`, `websiteUrl` | Substitutes nothing. An unversioned server reports `"version": ""` to every caller, on both eras and both transports. |
| `ttlMs` / `cacheScope` on the cacheable methods (SEP-2549) | `300000` ms, scope `public`, on `tools/list`, `server/discover`, `prompts/list`, `resources/list`, `resources/templates/list` | `CacheHint()` defaults to `ttl_ms=0`, `scope="private"` β the wire form of "already stale, never share". Every client then re-lists on every connection. |
`2026-07-28` moved `serverInfo` from a once-per-connection handshake footnote to
a stamp on the running traffic, which is what makes the empty version worth a
gate rather than a shrug.
The three **empty** directories carry a hint on purpose. `MCPServer` registers
their handlers unconditionally and `server/discover` lists `prompts` and
`resources` among its capabilities, so the surface exists on the wire β it is
just empty. This server has no way to register a prompt or a resource at
runtime, so it stays empty for the life of the process, which makes it the
safest thing here to cache.
Both are measured off a real response rather than read back off the
constructor: [`tests/test_server_identity.py`](tests/test_server_identity.py)
checks each identity field separately on each era, and
[`tests/test_cache_hints.py`](tests/test_cache_hints.py) reads the hints out of
a live client session. Each has a negative control against a bare
`MCPServer("kontrolle")` β without it, an assertion that the SDK one day starts
satisfying by itself would keep reading as proof that *this server* sets it.
---
## Testing
```bash
# Unit tests (mocked, fast, CI default) β tests/test_unit.py + tests/test_security.py
PYTHONPATH=src pytest -m "not live" -v
# Live tests (hits real upstreams) β tests/test_live.py
PYTHONPATH=src pytest -m live -v
```
Unit tests cover the contract layers: **Happy** (response parsing), **Retry**
(5xx, 429, 4xx), **Timeout** (network errors β clean `UpstreamUnreachableError`),
envelope/attribution invariants, plus **security** (egress allow-list, SPARQL
escaping, tool-definition lock). CI runs ruff + `pytest -m "not live"` on
Python 3.11β3.13.
### Auditing the ruff pin across the portfolio
`scripts/pin_audit.py` checks whether a server's own pin guards actually hold.
It is **not** a CI gate β it needs the sibling repositories on disk β but it is
worth running whenever a pin convention changes or a new server joins:
```bash
python scripts/pin_audit.py ../*-mcp
```
It measures black-box: prepend an ordinary second pre-commit hook with its own
`rev:`, run the guard, read the exit code, restore the file. Two guards in the
portfolio used to report that hook's version as the ruff pin, turning CI red
with a number nobody had written. A positive control (misconfigure the ruff
hook's own `rev`) separates "correctly scoped" from "never reads the file" β
without it, a guard that ignores the config looks like a clean bill of health.
### Where the test data comes from
The fixtures under `tests/fixtures/` are **recorded from the live sources** and
dated. Source, retrieval date, selection rule and SHA-256 for every file:
[`tests/fixtures/PROVENANCE.md`](tests/fixtures/PROVENANCE.md).
```bash
python scripts/record_fixtures.py # re-record
```
**The requests are built by the production code.** The script calls
`ElComSparqlClient` and `EnergyDashboardClient` and captures the answer through
an httpx transport, rather than retyping the SPARQL alongside. A fixture that
answers a slightly different question than the server asks proves the wrong
answer β quietly, because it looks plausible. At 40 lines of SPARQL, "slightly
different" is the normal case, not the exception.
Two selection rules are deliberately more than "the first N":
- **The storage-lake series runs into the future.** After the last measured day
come rows with a `null` measurement β 94 of them on the recording day. They
are kept on purpose: without them, no test could show that the tool skips
them.
- **What counts as a measurement is named per file, not guessed.** The first
version of this used "any field other than `date` is non-null", which is
wrong: those future rows do carry values β the five-year reference curves β
just no measurement.
Where a search is trimmed, `count` keeps its real value: it says how much is
*not* in the file.
---
## Known limitations
- **LINDAS SPARQL 504 timeouts**: the LINDAS public endpoint occasionally returns 504 under load. The 3-retry policy handles transient cases; persistent unavailability surfaces as `UpstreamUnreachableError`.
- **No historical PV/wind detail**: Energiedashboard exposes only aggregated production mix at year level. For sub-yearly PV or wind, use `consumption_search_bfe_datasets`.
- **No FHIR or smart-meter data**: out of scope. Future work may add a `swiss-prosumer-mcp` or similar.
- **Year coverage**: ElCom tariff data starts in 2009. Energiedashboard mix starts in 2014.
---
## Portfolio synergy
This server composes naturally with other portfolio servers:
- **+ `swiss-energy-mcp`** β combine geo/asset data (power plants) with time-series and tariffs for full energy-infrastructure analysis.
- **+ `meteoswiss-mcp`** β correlate consumption forecasts with weather (temperature drives heating/cooling load).
- **+ `fedlex-mcp`** β pair tariff data with the Stromversorgungsgesetz (StromVG) for compliance/legal context.
- **+ `zh-education-mcp`** β Schulamt-relevant queries combining tariffs, school counts, infrastructure budgets.
---
## Data sources & licensing
All upstream data is **Open Government Data Switzerland (OGD-CH)**:
- **Energiedashboard.ch** Β© Bundesamt fΓΌr Energie BFE β *Open data, free to use.*
- **ElCom / LINDAS** Β© EidgenΓΆssische ElektrizitΓ€tskommission ElCom β *CC BY 4.0.*
- **opendata.swiss** Β© Various Swiss public bodies β *Mostly CC0 / CC BY 4.0.*
- **Stadt ZΓΌrich OGD** Β© Stadt ZΓΌrich β *CC0.*
This MCP server is MIT-licensed (see [LICENSE](LICENSE)). Always cite the original data source β the response envelope includes the proper attribution string automatically.
---
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
## Security
See [SECURITY.md](SECURITY.md) for the security policy and how to report a
vulnerability.
## License
MIT License β see [LICENSE](LICENSE). The upstream data keeps the licences
listed under *Data sources & licensing* above.
## Author
**Hayal Oezkan** Β· [github.com/malkreide](https://github.com/malkreide)
## Changelog
See [CHANGELOG.md](CHANGELOG.md).
<!-- mcp-name: io.github.malkreide/swiss-electricity-mcp -->
<!-- BEGIN GENERATED: install -->
## Installation
Run via [`uv`](https://docs.astral.sh/uv/)'s `uvx` β no clone or manual install needed. Add to your MCP client config (`mcpServers` for Claude Desktop, Cursor and Windsurf; use a top-level `servers` key for VS Code in `.vscode/mcp.json`):
```json
{
"mcpServers": {
"swiss-electricity-mcp": {
"command": "uvx",
"args": [
"swiss-electricity-mcp"
]
}
}
}
```
<!-- END GENERATED: install -->
TDQS
Scored across 12 tools
Each tool has a distinct purpose: dashboard tools cover different aspects of energy data, tariff tools handle various tariff queries, consumption search tools target specific catalogs, and health check is unique. No overlapping responsibilities.
All tools follow a consistent verb_noun pattern with snake_case, using prefixes like dashboard_, tariff_, consumption_search_, and electricity_. No mixed conventions.
12 tools is well-scoped for the Swiss electricity domain, covering production, consumption, storage, pricing, tariffs, dataset search, and system health. Each tool has a clear role.
The set covers key areas well (production mix, consumption forecast, storage, tariffs, dataset search). Minor gaps like historical consumption time series are mitigated by dataset search tools that can find additional data.