
Connects Claude to three official Swiss electricity data sources: the BFE Energiedashboard for live production mix and storage lake levels, ElCom tariffs via LINDAS SPARQL for municipal pricing breakdowns by consumption category, and CKAN endpoints on opendata.swiss for dataset discovery. You get 12 tools covering everything from national consumption forecasts to side-by-side municipal tariff comparisons. The anchor use case is tracking how a specific building's electricity costs have evolved over time compared to cantonal or national medians. No authentication required, all public OGD. Includes structured logging, optional OpenTelemetry tracing, and per-source caching tuned to update cadence. Ships with stdio for Claude Desktop and a streamable HTTP mode for cloud deployment.
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.
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"
]
}
}
}