{
  "markdown": "# swiss-electricity-mcp\n\n> **MCP server for Swiss electricity data — three official sources, twelve tools, zero authentication.**\n\n[![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)\n[![PyPI](https://img.shields.io/pypi/v/swiss-electricity-mcp.svg)](https://pypi.org/project/swiss-electricity-mcp/)\n[![Python](https://img.shields.io/pypi/pyversions/swiss-electricity-mcp.svg)](https://pypi.org/project/swiss-electricity-mcp/)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)\n\n🌍 **Read this in your language:** [🇩🇪 Deutsch](README.de.md)\n\nPart 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.\n\n---\n\n## Anchor demo query\n\n> *\"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?\"*\n\nA 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.\n\n### Demo\n\n![Demo: Claude using tariff_get_by_municipality and tariff_get_median_swiss](docs/assets/demo.svg)\n\n---\n\n## What's inside\n\nThree official Swiss data sources combined into one MCP server, each with its own dedicated tool group:\n\n| Source | What it provides | Provenance |\n|---|---|---|\n| **Energiedashboard.ch** (Bundesamt für Energie) | National production mix, consumption forecast, storage-lake fill, consumer price index | `live_api` |\n| **ElCom electricity-price cubes** (via LINDAS SPARQL) | Tariffs per municipality, category, year, with full breakdown (energy + grid usage + KEV + Abgaben) | `sparql` |\n| **opendata.swiss + Stadt Zürich OGD** (CKAN) | Dataset discovery for raw time series (e.g. quarter-hour NE5/NE7 consumption) | `live_api` |\n\n**No authentication required.** All endpoints are public Swiss OGD.\n\n---\n\n## Tools (12)\n\n### `dashboard_*` — Energiedashboard.ch (BFE)\n\n- **`dashboard_get_production_mix`** — Production mix by year (TWh + %): Kernkraft, Wasserkraft, PV, Wind, thermal.\n- **`dashboard_get_consumption_forecast`** — Current consumption forecast + 5-day outlook + 5-year envelope.\n- **`dashboard_get_storage_lakes`** — Speichersee fill level (CH or per region: Wallis, Tessin, Graubünden, Zentral/Ost) — critical winter-supply indicator.\n- **`dashboard_get_consumer_price_index`** — Endverbraucher-Strompreis-Index (2020-01-01 = 100).\n\n### `tariff_*` — ElCom (via LINDAS SPARQL)\n\n- **`tariff_list_categories`** — H1–H8 (households) and C1–C7 (commercial). **C3 ≈ 150'000 kWh/a is the typical reference for school buildings.**\n- **`tariff_get_by_municipality`** — Tariffs for a BFS-Nr + category + year range, broken into energy / grid usage / KEV / Abgaben.\n- **`tariff_get_median_swiss`** — National median benchmark.\n- **`tariff_get_median_canton`** — Cantonal median (e.g. for Kanton Zürich).\n- **`tariff_compare_municipalities`** — Compare up to 20 municipalities side-by-side.\n\n### `consumption_*` — opendata.swiss + Stadt Zürich OGD\n\n- **`consumption_search_bfe_datasets`** — CKAN search across BFE-published datasets.\n- **`consumption_search_zurich`** — CKAN search across Stadt Zürich OGD (includes quarter-hour NE5/NE7 consumption).\n\n### Status\n\n- **`electricity_check_status`** — Liveness probe across all four upstreams (HTTP status + latency + overall-healthy flag).\n\n---\n\n## Installation\n\n### From PyPI\n\n```bash\npip install swiss-electricity-mcp\n```\n\n### From source\n\n```bash\ngit clone https://github.com/malkreide/swiss-electricity-mcp.git\ncd swiss-electricity-mcp\npip install -e \".[dev]\"\n```\n\n---\n\n## Use with Claude Desktop\n\nAdd to `claude_desktop_config.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"swiss-electricity\": {\n      \"command\": \"swiss-electricity-mcp\"\n    }\n  }\n}\n```\n\n---\n\n## Cloud deployment (Streamable HTTP)\n\n```bash\nSWISS_ELECTRICITY_TRANSPORT=streamable-http \\\nSWISS_ELECTRICITY_HOST=0.0.0.0 \\\nSWISS_ELECTRICITY_PORT=8000 \\\nswiss-electricity-mcp\n```\n\nWorks on Render.com, Railway, Fly.io.\n\n> **Host binding (security).** In HTTP mode the host defaults to `127.0.0.1`\n> (loopback only). Bind to all interfaces with `SWISS_ELECTRICITY_HOST=0.0.0.0`\n> **only inside a container**, where the network boundary is the container, not\n> the host. Setting `0.0.0.0` on a developer machine exposes the server to the\n> local network (NeighborJack).\n\n### Docker\n\nA multi-stage `Dockerfile` is provided. It runs as a non-root user (UID 10001)\nand sets `SWISS_ELECTRICITY_HOST=0.0.0.0` explicitly for the containerised case.\n\n```bash\ndocker build -t swiss-electricity-mcp .\ndocker run --rm -p 8000:8000 swiss-electricity-mcp\n```\n\n---\n\n## Observability & configuration\n\n| Env var | Default | Purpose |\n|---|---|---|\n| `SWISS_ELECTRICITY_TRANSPORT` | `stdio` | `stdio` or `streamable-http` |\n| `SWISS_ELECTRICITY_HOST` | `127.0.0.1` | HTTP bind host (`0.0.0.0` in containers only) |\n| `SWISS_ELECTRICITY_PORT` | `8000` | HTTP port |\n| `SWISS_ELECTRICITY_LOG_LEVEL` | `INFO` | Log level (DEBUG/INFO/WARNING/ERROR) |\n| `SWISS_ELECTRICITY_CORS_ORIGINS` | _(empty)_ | Comma-separated allowed CORS origins (browser clients); never `*` |\n| `OTEL_EXPORTER_OTLP_ENDPOINT` | _(unset)_ | Enables OpenTelemetry tracing when set |\n| `SWISS_ELECTRICITY_ENV` | `unknown` | `deployment.environment` resource attribute for traces |\n\n- **Logging** is structured JSON on **stderr** (stdout is reserved for the stdio\n  JSON-RPC channel). Upstream failures are logged in full server-side but masked\n  in client-facing responses.\n- **Tracing** is opt-in. Install the extra and point it at a collector:\n\n  ```bash\n  pip install \"swiss-electricity-mcp[otel]\"\n  OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 swiss-electricity-mcp\n  ```\n\n  You get one span per tool call (`mcp.tool.<name>`) plus automatic httpx child\n  spans for each upstream request. No argument values or PII are recorded.\n\n---\n\n## Architecture\n\n```\n          ┌────────────────────────── MCP client (Claude etc.) ──────────────────────────┐\n          │                          stdio  or  Streamable HTTP                           │\n          └───────────────────────────────────────┬──────────────────────────────────────┘\n                                                   │  12 read-only tools (annotated)\n                                          ┌────────▼─────────┐\n                                          │  FastMCP server   │  egress allow-list + HTTPS gate\n                                          │  + structlog/OTel │  per-source TTL cache + retry\n                                          └───┬────────┬───┬──┘\n                  dashboard_* │ tariff_*      │        │   │   consumption_*\n                              ▼               ▼        ▼   ▼\n                  ┌───────────────────┐ ┌───────────┐ ┌──────────────┐ ┌─────────────────────┐\n                  │ Energiedashboard  │ │  LINDAS   │ │ opendata.swiss│ │ data.stadt-zuerich.ch│\n                  │ .admin.ch (BFE)   │ │  SPARQL   │ │   CKAN        │ │   CKAN (OGD)         │\n                  └───────────────────┘ └───────────┘ └──────────────┘ └─────────────────────┘\n```\n\n**Hybrid (live API + SPARQL + CKAN discovery)**, no authentication. Three reasons this is the right shape:\n\n1. **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.\n2. **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.\n3. **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.\n\n### Provenance discipline\n\nEvery tool response is a Pydantic envelope carrying:\n\n- `source` — full attribution string (e.g. *\"Daten: Bundesamt für Energie (BFE)…\"*).\n- `provenance` — exactly one of `live_api` / `sparql` / `cached` / `weekly_dump` / `stale_cache_fallback`.\n- `retrieved_at` — ISO-8601 UTC timestamp.\n\nThis makes accidental misattribution structurally impossible.\n\n### Resilience\n\n- **Retry**: 3 attempts with exponential backoff (2 s / 4 s / 8 s).\n- **5xx + 429**: retried. **4xx (except 429)**: raised immediately (permanent client error).\n- **In-memory TTL cache**: per-source TTLs reduce upstream load and round-trip during multi-step agent workflows.\n\n### MCP primitives — why Tools only\n\nThis server intentionally exposes **only Tools**, not Resources or Prompts. The\ndata is parametric and query-driven (a municipality BFS number, a category, a\nyear), which maps naturally to tool calls; there is no stable, enumerable set of\ndocuments to expose as Resources, and no curated prompt templates to ship. If a\nfuture use case needs, say, a fixed \"national production mix\" document, the\nread-only `dashboard_*` tools are the obvious Resource-migration candidates.\n\n### Project phase\n\n**Phase 1 — read-only.** All 12 tools are read-only (`readOnlyHint=true`) with no\nwrite or destructive operations. Phase-transition criteria and the longer-term\nplan live in [`docs/roadmap.md`](docs/roadmap.md). Security posture (egress,\nsupply-chain, lethal-trifecta assessment) is documented in\n[`docs/security-posture.md`](docs/security-posture.md).\n\n---\n\n## MCP Protocol Version\n\nThis server speaks **two protocol eras** over the same endpoint. The client's\nfirst request on a connection decides which one applies; a later claim from the\nother era is refused.\n\n| Era | Revision | Who reaches it |\n|---|---|---|\n| `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. |\n| Per-request envelope | **`2026-07-28`** | A request carrying the `2026-07-28` `_meta` envelope opens a modern connection. |\n\nBoth revisions are pinned in\n[`tests/test_protocol_version.py`](tests/test_protocol_version.py) and asserted\nagainst the installed SDK, so a Dependabot bump of `mcp` cannot move either one\nsilently. The handshake ceiling is measured against a live `initialize` through\nthe assembled ASGI stack, not read off a constant name.\n\nNote that the SDK's `LATEST_PROTOCOL_VERSION` is an alias for the **modern**\nera, not for the handshake era — pinning against it alone would leave the era\nthat current clients actually negotiate free to drift.\n\n**Update policy.** When the gate fails, do not edit the constant blindly: read\nthe spec changelog between the two revisions, verify the server still behaves,\nthen move the constant, this section, `README.de.md` and\n[`CHANGELOG.md`](CHANGELOG.md) together.\n\n---\n\n## Testing\n\n```bash\n# Unit tests (mocked, fast, CI default) — tests/test_unit.py + tests/test_security.py\nPYTHONPATH=src pytest -m \"not live\" -v\n\n# Live tests (hits real upstreams) — tests/test_live.py\nPYTHONPATH=src pytest -m live -v\n```\n\nUnit tests cover the contract layers: **Happy** (response parsing), **Retry**\n(5xx, 429, 4xx), **Timeout** (network errors → clean `UpstreamUnreachableError`),\nenvelope/attribution invariants, plus **security** (egress allow-list, SPARQL\nescaping, tool-definition lock). CI runs ruff + `pytest -m \"not live\"` on\nPython 3.11–3.13.\n\n### Auditing the ruff pin across the portfolio\n\n`scripts/pin_audit.py` checks whether a server's own pin guards actually hold.\nIt is **not** a CI gate — it needs the sibling repositories on disk — but it is\nworth running whenever a pin convention changes or a new server joins:\n\n```bash\npython scripts/pin_audit.py ../*-mcp\n```\n\nIt measures black-box: prepend an ordinary second pre-commit hook with its own\n`rev:`, run the guard, read the exit code, restore the file. Two guards in the\nportfolio used to report that hook's version as the ruff pin, turning CI red\nwith a number nobody had written. A positive control (misconfigure the ruff\nhook's own `rev`) separates \"correctly scoped\" from \"never reads the file\" —\nwithout it, a guard that ignores the config looks like a clean bill of health.\n\n### Where the test data comes from\n\nThe fixtures under `tests/fixtures/` are **recorded from the live sources** and\ndated. Source, retrieval date, selection rule and SHA-256 for every file:\n[`tests/fixtures/PROVENANCE.md`](tests/fixtures/PROVENANCE.md).\n\n```bash\npython scripts/record_fixtures.py   # re-record\n```\n\n**The requests are built by the production code.** The script calls\n`ElComSparqlClient` and `EnergyDashboardClient` and captures the answer through\nan httpx transport, rather than retyping the SPARQL alongside. A fixture that\nanswers a slightly different question than the server asks proves the wrong\nanswer — quietly, because it looks plausible. At 40 lines of SPARQL, \"slightly\ndifferent\" is the normal case, not the exception.\n\nTwo selection rules are deliberately more than \"the first N\":\n\n- **The storage-lake series runs into the future.** After the last measured day\n  come rows with a `null` measurement — 94 of them on the recording day. They\n  are kept on purpose: without them, no test could show that the tool skips\n  them.\n- **What counts as a measurement is named per file, not guessed.** The first\n  version of this used \"any field other than `date` is non-null\", which is\n  wrong: those future rows do carry values — the five-year reference curves —\n  just no measurement.\n\nWhere a search is trimmed, `count` keeps its real value: it says how much is\n*not* in the file.\n\n\n---\n\n## MCP protocol version\n\nThis server is built on the official MCP Python SDK (`mcp[cli]`), pinned to\n`>=1.2.0,<2.0.0`. The MCP protocol version is negotiated by the SDK at the\n`initialize` handshake; the supported spec version tracks the pinned SDK\n(currently MCP spec `2025-11-25`).\n\n**Update policy:** SDK updates arrive as weekly Dependabot PRs. A protocol-spec\nbump is only adopted via an explicit SDK minor/major bump, recorded in\n[`CHANGELOG.md`](CHANGELOG.md), and verified against the tool-definition lock\n(`tool-definitions.lock.json`).\n\n---\n\n## Known limitations\n\n- **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`.\n- **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`.\n- **No FHIR or smart-meter data**: out of scope. Future work may add a `swiss-prosumer-mcp` or similar.\n- **Year coverage**: ElCom tariff data starts in 2009. Energiedashboard mix starts in 2014.\n\n---\n\n## Portfolio synergy\n\nThis server composes naturally with other portfolio servers:\n\n- **+ `swiss-energy-mcp`** — combine geo/asset data (power plants) with time-series and tariffs for full energy-infrastructure analysis.\n- **+ `meteoswiss-mcp`** — correlate consumption forecasts with weather (temperature drives heating/cooling load).\n- **+ `fedlex-mcp`** — pair tariff data with the Stromversorgungsgesetz (StromVG) for compliance/legal context.\n- **+ `zh-education-mcp`** — Schulamt-relevant queries combining tariffs, school counts, infrastructure budgets.\n\n---\n\n## Data sources & licensing\n\nAll upstream data is **Open Government Data Switzerland (OGD-CH)**:\n\n- **Energiedashboard.ch** © Bundesamt für Energie BFE — *Open data, free to use.*\n- **ElCom / LINDAS** © Eidgenössische Elektrizitätskommission ElCom — *CC BY 4.0.*\n- **opendata.swiss** © Various Swiss public bodies — *Mostly CC0 / CC BY 4.0.*\n- **Stadt Zürich OGD** © Stadt Zürich — *CC0.*\n\nThis MCP server is MIT-licensed (see [LICENSE](LICENSE)). Always cite the original data source — the response envelope includes the proper attribution string automatically.\n\n---\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Security\n\nSee [SECURITY.md](SECURITY.md) for the security policy and how to report a\nvulnerability.\n\n## License\n\nMIT License — see [LICENSE](LICENSE). The upstream data keeps the licences\nlisted under *Data sources & licensing* above.\n\n## Author\n\n**Hayal Oezkan** · [github.com/malkreide](https://github.com/malkreide)\n\n## Changelog\n\nSee [CHANGELOG.md](CHANGELOG.md).\n\n<!-- mcp-name: io.github.malkreide/swiss-electricity-mcp -->\n\n<!-- BEGIN GENERATED: install -->\n## Installation\n\nRun 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`):\n\n```json\n{\n  \"mcpServers\": {\n    \"swiss-electricity-mcp\": {\n      \"command\": \"uvx\",\n      \"args\": [\n        \"swiss-electricity-mcp\"\n      ]\n    }\n  }\n}\n```\n<!-- END GENERATED: install -->\n",
  "bytes": 17064,
  "sha": "a52505734e9a1d7437db22c4c9381f73bdce92807535b8ce26ed861e35ceaf90",
  "repo_slug": "malkreide/swiss-electricity-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_malkreide_swiss_electricity_mc_2633a6dd/readme"
}