# swiss-electricity-mcp > **MCP server for Swiss electricity data β€” three official sources, twelve tools, zero authentication.** [![CI](https://github.com/malkreide/swiss-electricity-mcp/actions/workflows/test.yml/badge.svg)](https://github.com/malkreide/swiss-electricity-mcp/actions/workflows/test.yml) [![PyPI](https://img.shields.io/pypi/v/swiss-electricity-mcp.svg)](https://pypi.org/project/swiss-electricity-mcp/) [![Python](https://img.shields.io/pypi/pyversions/swiss-electricity-mcp.svg)](https://pypi.org/project/swiss-electricity-mcp/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 ![Demo: Claude using tariff_get_by_municipality and tariff_get_median_swiss](docs/assets/demo.svg) --- ## 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.`) 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**: 3 attempts with exponential backoff (2 s / 4 s / 8 s). - **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). ## 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" ] } } } ```