BFE energy dashboard, ElCom tariffs, public consumption data
Runs on your machine from its package; the steps are below.
At most: only reads or looks up information.
Odel charges nothing per call. The publisher may charge for its own service.
Runs locally over stdio
This server isn't hosted — your MCP client launches it from a package registry. Use one of the commands below, or drop the config into your client (e.g. Claude Desktop).
Run
uvx swiss-electricity-mcp
MCP client config
{
"mcpServers": {
"swiss-electricity": {
"command": "uvx",
"args": [
"swiss-electricity-mcp"
]
}
}
}MCP server for Swiss electricity data — three official sources, twelve tools, zero authentication.
🌍 Read this in your language: 🇩🇪 Deutsch
Part of the Swiss Public Data MCP Portfolio — a coordinated set of MCP servers for Swiss public administration.
"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.
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.
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 OGDconsumption_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).electricity_check_status — Liveness probe across all four upstreams (HTTP status + latency + overall-healthy flag).pip install swiss-electricity-mcp
git clone https://github.com/malkreide/swiss-electricity-mcp.git
cd swiss-electricity-mcp
pip install -e ".[dev]"
Add to claude_desktop_config.json:
{
"mcpServers": {
"swiss-electricity": {
"command": "swiss-electricity-mcp"
}
}
}
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 withSWISS_ELECTRICITY_HOST=0.0.0.0only inside a container, where the network boundary is the container, not the host. Setting0.0.0.0on a developer machine exposes the server to the local network (NeighborJack).
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.
docker build -t swiss-electricity-mcp .
docker run --rm -p 8000:8000 swiss-electricity-mcp
| 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:
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.
┌────────────────────────── 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:
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.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.
Retry-After sent with 429 or 503 takes precedence (plus 0–25 %, also capped at 20 s).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.
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. Security posture (egress,
supply-chain, lethal-trifecta assessment) is documented in
docs/security-posture.md.
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 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 together. A spec bump is adopted only through an
explicit mcp minor/major bump, recorded in CHANGELOG.md and
verified against the tool-definition lock (tool-definitions.lock.json).
2026-07-28The 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
checks each identity field separately on each era, and
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.
# 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.
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:
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.
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.
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":
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.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.
UpstreamUnreachableError.consumption_search_bfe_datasets.swiss-prosumer-mcp or similar.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.All upstream data is Open Government Data Switzerland (OGD-CH):
This MCP server is MIT-licensed (see LICENSE). Always cite the original data source — the response envelope includes the proper attribution string automatically.
See CONTRIBUTING.md.
See SECURITY.md for the security policy and how to report a vulnerability.
MIT License — see LICENSE. The upstream data keeps the licences listed under Data sources & licensing above.
Hayal Oezkan · github.com/malkreide
See CHANGELOG.md.
Run via 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):
{
"mcpServers": {
"swiss-electricity-mcp": {
"command": "uvx",
"args": [
"swiss-electricity-mcp"
]
}
}
}