BFS STAT-TAB PxWeb API for official Swiss statistics
The io.github.malkreide/swiss-statistics-mcp server provides a Model Context Protocol (MCP) interface to the BFS STAT-TAB PxWeb API for official Swiss statistics. It is part of the Swiss Public Data MCP Portfolio and is intended for accessing open data sources related to Switzerlandβs demographics and related domains.
π οΈ Key Features
MCP server for the BFS STAT-TAB PxWeb API
Targets official Swiss statistics via PxWeb
Topics include open data, Switzerland, and stat-tab
π Use Cases
Retrieve official Swiss demographic and education-related statistics
Use PxWeb-backed Swiss public data in MCP-based tools
Work with Swiss-open-data sources programmatically
β‘ Developer Benefits
Compatible with Model Context Protocol (MCP)
Python 3.11+ based project (per readme excerpt)
MIT licensed
β οΈ Limitations
Description provided does not specify authentication, tool coverage, or endpoint scope beyond the BFS STAT-TAB PxWeb API.
# Clone the repository
git clone https://github.com/malkreide/swiss-statistics-mcp.git
cd swiss-statistics-mcp
# Install
pip install -e .
# or with uv:
uv pip install -e .
Or with uvx (no permanent installation):
bash
uvx swiss-statistics-mcp
Quickstart
bash
# stdio (for Claude Desktop)
python -m swiss_statistics_mcp.server
# Streamable HTTP, loopback only (default: host=127.0.0.1, port=8000)
python -m swiss_statistics_mcp.server --http --port 8000
# Streamable HTTP, all interfaces (only behind a reverse proxy with access control)
MCP_HOST=0.0.0.0 python -m swiss_statistics_mcp.server --http --port 8000
# or
python -m swiss_statistics_mcp.server --http --host 0.0.0.0 --port 8000
Try it immediately in Claude Desktop:
"How many teachers worked in the canton of Zurich in 2023?""What is the population of canton Bern broken down by age?""Compare the social assistance rate across all cantons for 2022."
Configuration
Claude Desktop
Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
The configuration syntax is identical to Claude Desktop. The file name depends on the client:
Cursor:.cursor/mcp.json in the project folder, or ~/.cursor/mcp.json globally
Windsurf:~/.codeium/windsurf/mcp_config.json
VS Code + Continue:.continue/config.json
Cloud Deployment (SSE for browser access)
For use via claude.ai in the browser (e.g. on managed workstations without local software).
β οΈ Security note β this server has no authentication. A public URL
turns it into an open proxy to the BFS API on your deployment's IP. Any
client with the URL can drive the tools, consume your platform quota, and
attribute traffic to your IP. Two mitigations, in order of preference:
Put it behind access control β Render's Β«Private ServiceΒ», Cloudflare
Access, or a reverse proxy with Basic-Auth / IP allowlist in front of the
container.
Accept it as a public open-data proxy β only acceptable because all
data is BFS OGD (Public Open Data) and tools are read-only.
The server binds to 127.0.0.1 by default. To expose it on a container
port you must explicitly set MCP_HOST=0.0.0.0 (e.g. as a Render env var)
or pass --host 0.0.0.0. Do not do this without one of the mitigations above.
Render.com:
Push/fork the repository to GitHub
On render.com: New Web Service β connect GitHub repo
Set environment variable: MCP_HOST=0.0.0.0
Set start command: python -m swiss_statistics_mcp.server --http --port 8000
In claude.ai under Settings β MCP Servers, add: https://your-app.onrender.com/sse
π‘ "stdio for the developer laptop, SSE for the browser."
Output Schema
Since v0.2.0, every tool returns a typed Pydantic model rather than a JSON
string. FastMCP serializes these as structured content so MCP clients can
read fields directly.
python
# Old (pre-0.2.0)
result = await bfs_get_data(...) # str
data = json.loads(result) # dictprint(data["rows_total"])
# New (>= 0.2.0)
result = await bfs_get_data(...) # DataTableResultprint(result.rows_total) # 1000print(result.truncated) # True
Every result carries error: str | None and hint: str | None at the top
level β result.error is None means success. Data-returning tools
(bfs_get_data, bfs_education_stats, bfs_population,
bfs_compare_cantons) additionally expose truncated: bool,
rows_total: int, and rows_returned: int for machine-readable cap
detection.
Tool
Result type
bfs_browse_catalog
BrowseCatalogResult
bfs_search_tables
SearchTablesResult
bfs_get_table_metadata
TableMetadataResult
bfs_get_data
DataTableResult
bfs_education_stats
DataTableResult
bfs_population
DataTableResult
bfs_compare_cantons
DataTableResult
bfs_featured_datasets
FeaturedDatasetsResult
lookup_commune
LookupCommuneResult
resolve_historical_commune
ResolveHistoricalCommuneResult
list_communes
ListCommunesResult
search_historical_series
SearchHistoricalSeriesResult
bfs_construction_activity
ConstructionActivityResult
bfs_construction_investment
ConstructionInvestmentResult
bfs_price_index
PriceIndexResult
Reference-layer results additionally carry source (attribution) and provenance (live_api | cached); SearchHistoricalSeriesResult also carries licence_note with the mandatory HSSO NonCommercial notice. The construction and price-index results carry source + provenance on the same envelope pattern.
Available Tools
Tool
Description
bfs_featured_datasets
Curated list of highly relevant datasets (focus on education and demographics)
bfs_browse_catalog
Browse the catalogue: all 21 themes (no theme_code), or all tables in a theme (e.g. theme_code="15" = Education and Science)
bfs_search_tables
Full-text search across the entire data catalogue (682 datasets)
bfs_get_table_metadata
Variables, values and metadata for a specific table
bfs_get_data
Data retrieval with optional filters by dimensions and values
Resident population by canton, year, age structure or sex
bfs_compare_cantons
Cross-cantonal comparison for any table and any variable
lookup_commune
Resolve a commune by name or BFS number as of a given date (canton, validity, LINDAS URI)
resolve_historical_commune
Map a historical BFS number onto today's number(s) β re-key old statistics across fusions
list_communes
List all communes of a canton as of a given date
search_historical_series
Search long-run time series in Historical Statistics of Switzerland (HSSO)
bfs_construction_activity
New buildings & dwellings per commune (yearly), incl. dwelling room-size mix
bfs_construction_investment
Building investment & Arbeitsvorrat (leading indicator) by region/canton/commune
bfs_price_index
Construction price index (Baupreisindex, parsed series) / residential property price index (IMPI, source links)
Four of these tools form the reference layer of the portfolio (see Join Keys): they turn official BFS commune numbers into a reliable join key and let you re-key statistics that predate a municipal merger. The two bfs_construction_* tools cover STAT-TAB theme 09 (Bau- und Wohnungswesen) β see Construction sources. bfs_price_index covers price indices that are not in STAT-TAB β see Price-index sources.
Construction sources
Cube ID
Title
Coverage
Used by
px-x-0904030000_106
Neu erstellte GebΓ€ude mit Wohnungen nach Gemeinde, GebΓ€udetyp
2013β
bfs_construction_activity
px-x-0904030000_105
Neu erstellte Wohnungen nach Gemeinde, Anzahl Zimmer
2013β
bfs_construction_activity
px-x-0904010000_205
Bauinvestitionen und Arbeitsvorrat nach Grossregion/Kanton/Gemeinde
1994β
bfs_construction_investment
The pre-2013 Gemeinde-level building series lives in the discontinued cubes px-x-0904030000_101/_104 (1995β2012), which use a different geo coding and are not queried by these tools. Building/dwelling figures are the consolidated official yearly statistics β for up-to-date register states and the construction pipeline, cross-validate against swiss-housing-mcp (deliberate redundancy).
Price-index sources
bfs_price_index covers two indices that are not published via STAT-TAB. Their datasets live on opendata.swiss (CKAN); the data files themselves are BFS DAM assets.
Index
Source
Returns
baupreisindex
opendata.swiss dataset Schweizerischer Baupreisindex (Multibasen) β DAM XLSX asset
Parsed national semi-annual index series (Schweiz, Baugewerbe Total), with the base period
impi
opendata.swiss dataset Schweizerischer Wohnimmobilienpreisindex (IMPI) β DAM PDF/HTML assets
Official source links only β BFS does not publish a machine-readable IMPI series
Two quirks are handled for you: ckan.opendata.swiss returns HTTP 403 to default User-Agents, so every call sends a custom swiss-statistics-mcp/<version> User-Agent; and DAM assets mix formats, so the XLSX is selected by verifying the response content-type (PDFs are skipped). Results are cached for 24 h.
Example Use Cases
Query
Tool
"How many teachers worked in Zurich in 2023?"
bfs_education_stats
"How will upper secondary enrolment develop until 2031?"
bfs_education_stats
"What is the population of canton Zurich by age?"
bfs_population
"Compare the social assistance rate across all cantons"
bfs_compare_cantons
"Is there data on school buildings?"
bfs_search_tables
"Which Zurich communes have merged since 2000, and onto which of today's BFS numbers must I re-key old statistics?"
resolve_historical_commune
"List all communes of canton Glarus today"
list_communes
"Find long-run series on population in HSSO"
search_historical_series
"How many new dwellings were built in Winterthur since 2018, by room size?"
bfs_construction_activity
"What is the building investment and Arbeitsvorrat for canton Zurich?"
bfs_construction_investment
"How has the construction price index moved since 2015?"
AGVCH commune register β Architecture A (live-API-only). The official REST service (snapshot / correspondances / mutations / levels) is a clean, versioned, no-auth API β verified live on 2026-07-19 β so the commune tools query it directly with a 24 h in-memory cache and the shared retry policy. No dump fallback is needed. Finding: the live snapshot CSV header uses Inscription,Radiation,Rec_Type_fr (not the Einschreibung,Streichung names printed in the API PDF), and HistoricalCode is not globally unique across levels β the Parent link is disambiguated by tier when deriving a commune's canton.
HSSO β Architecture C (dump-only). HSSO offers no API, only static per-table XLSX at stable URLs (/get/{CHAPTER}.{NN}{suffix}.xlsx). search_historical_series builds a cached title index from the chapter pages and returns the stable download URL. HSSO is licensed CC BY-NC-SA 3.0 (NonCommercial) β different from this server's OGD baseline β so every HSSO response carries an explicit NonCommercial notice in licence_note.
Join Keys
The reference layer exists so that data from different servers in the Swiss Public Data MCP Portfolio can be joined reliably. Three identifiers are the portfolio-wide keys:
Key
What it identifies
Canonical form
Notes
BFS commune number (BfsCode)
A political commune
integer, e.g. 261 (ZΓΌrich)
The primary join key across statistics, geo, education and health data. Stable LINDAS/Linked-Data URI: https://ld.admin.ch/municipality/{BfsCode}. Not stable over time β a merger issues a new number, so historical data must be re-keyed via resolve_historical_commune.
EGID
A single building (Eidg. GebΓ€udeidentifikator)
9-digit integer
The join key for building/dwelling-level data (GWR, energy, addresses). A commune contains many EGIDs; BfsCode is the commune each EGID sits in.
Canton abbreviation
A canton
two letters, e.g. ZH
The coarsest geographic key. Derivable from any commune via its Parent chain (exposed as canton_abbr).
Why re-keying matters. BFS commune numbers change whenever communes merge, split, or move canton. Statistics published before a merger use the old number; joining them to today's data without re-keying silently drops or misattributes rows. resolve_historical_commune(bfs_number, from_date, to_date) returns the resolves_to set β the current number(s) old figures must be aggregated onto β plus the mutation_path (the fusions/renamings, with dates). Other portfolio servers are meant to mirror this contract conceptually so the same key resolves the same way everywhere.
Example (anchor query)."Which Zurich communes have merged since 2000?" β e.g. old 132 Hirzel and 133 Horgen both re-key onto today's 295 Horgen; 134/140/142 onto 293 WΓ€denswil.
Project Structure
code
swiss-statistics-mcp/
βββ src/swiss_statistics_mcp/
β βββ __init__.py # Package
β βββ server.py # 15 tools
βββ tests/
β βββ test_server.py # Unit + integration tests (mocked HTTP)
βββ .github/workflows/ci.yml # GitHub Actions (Python 3.11/3.12/3.13)
βββ pyproject.toml
βββ CHANGELOG.md
βββ CONTRIBUTING.md # English
βββ CONTRIBUTING.de.md # German version
βββ SECURITY.md # English
βββ SECURITY.de.md # German version
βββ LICENSE
βββ README.md # This file (English)
βββ README.de.md # German version
Observability
The server emits one JSON log line per tool call on stderr:
rid β 8-char correlation id linking tool_start and tool_end for the same call
params_keys β sorted list of input field names (no values, no PII)
duration_ms β per-call latency on the tool_end event
status β "ok" or "error"; error_type is added when a tool raises
Render and other cloud platforms can index these directly for per-tool latency
dashboards and error-rate alerts. Set MCP_LOG_LEVEL=DEBUG for verbose output
or WARNING to suppress per-call events.
βΉοΈ Logs go to stderr so they never collide with the MCP protocol on
stdio transport (which uses stdout).
Resilience
The server absorbs transient BFS-API hiccups before they reach the LLM:
Retries β 5xx, 429, and network errors are retried up to 3 times with
exponential backoff (0.5s β 4s). 4xx errors surface immediately so client
bugs aren't masked. Tunable via MCP_RETRY_MAX_ATTEMPTS,
MCP_RETRY_WAIT_INITIAL, MCP_RETRY_WAIT_MAX env vars.
Metadata cache β Table metadata (variables, value domains, last_updated)
is cached in-memory per (table_id, lang) for 1h. Cold list/detail flows
warm the cache; subsequent calls return instantly.
Concurrency cap β Fan-out metadata fetches in bfs_browse_catalog
(theme mode) run in parallel bounded by FANOUT_CONCURRENCY = 5. For limit=20 this
cuts wall-clock from ~20s sequential to ~4s, without overwhelming the
upstream API.
Known Limitations
PxWeb API: Rate limiting may apply for rapid successive queries; the server uses a 1-hour cache for the catalogue index and a 1-hour cache for table metadata
Language: Dataset titles and dimension values are in German by default; French, Italian and English coverage varies by table
JSON-STAT2: Some complex cross-tabulations may return large result sets; use dimension filters to narrow queries
Commune register (AGVCH): Live snapshot CSV headers use Inscription/Radiation/Rec_Type_fr (not the Einschreibung/Streichung names in the API PDF); HistoricalCode is not globally unique across levels, so the canton is derived by walking the Parent chain one tier at a time. Snapshots/mutations are cached for 24 h.
HSSO: Licensed CC BY-NC-SA 3.0 (NonCommercial) β attribution required, no commercial use; every response carries this in licence_note. HSSO exposes no per-table period filter, so search_historical_series's period argument is an informational hint only β verify the actual span in the XLSX. search_historical_series returns the stable XLSX download URL, not the parsed series values.
PxWeb commune codes are not consistent across cubes. In px-x-0904030000_106/_107 the value code IS the zero-padded BFS number (0261); in px-x-0904030000_105 it is an opaque sequential id (160) and the BFS number appears only in the label (......0261 ZΓΌrich). bfs_construction_activity resolves each cube against its own live dimension values by matching the label-embedded BFS number, never by guessing the code.
Construction coverage: the current Gemeinde-level building series starts in 2013; bfs_construction_activity therefore accepts since_year >= 2013. Values are the consolidated official yearly statistics. Building investment values (bfs_construction_investment) are in 1000 CHF; the Arbeitsvorrat is the following year's building volume (a monetary leading indicator).
Price indices (bfs_price_index): the IMPI (residential property price index) is published by BFS only as PDF/HTML β there is no machine-readable series β so index="impi" returns the official source links plus an explicit limitation, not values. The Baupreisindex XLSX is parsed to the national semi-annual series (Schweiz, Baugewerbe Total); regional/object-type breakdowns exist in the source XLSX but are not returned. The DAM asset ids are resolved live from CKAN metadata (never hard-coded), because they change on republish; if the upstream XLSX structure changes, the tool degrades to a clear error rather than returning wrong values.
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 and asserted
against the installed SDK, so a Dependabot bump of mcp cannot move either one
silently. The same file also reads the negotiated revision off a real
connection, and tests/test_modern_wire.py drives
the ASGI app from streamable_http_app() over HTTP: server/discover, the
serverInfo stamp, the resultType that 2026-07-28 makes mandatory, the
SEP-2549 freshness hint, and the named rejection of a half-built envelope.
Both forms stay. A measured connection says which revision was negotiated
today β if an SDK bump moves the ceiling, it silently measures the new one.
Only the constant pin catches that. They assert different things; neither
replaces the other.
Server identity
The modern era stamps serverInfo into the _meta of every result, not
once per connection, and the SDK substitutes nothing: "An unversioned server
reports an empty version." This server therefore declares title,
version, description and website_url at construction, so every response
carries the same identity that server.json publishes.
version, description and website_url are read from the package metadata
rather than written as literals: a literal here would be a third copy beside
pyproject.toml and server.json, and the only one nobody keeps current
because it is visible nowhere. title stays a literal β a display name is not
package metadata.
icons is deliberately unset β this repository hosts no icon assets, and
inventing a URL with nothing behind it is worse than omitting the field. For the
same reason an absent field beats an empty one: website_url is str | None
with no URL validation, and the stamp is dumped with exclude_none, so None
omits the field while "" ships "websiteUrl": "" to every caller.
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.
Testing
bash
# Unit tests (no API key required)
PYTHONPATH=src pytest tests/ -m "not live"# Integration tests (live API calls)
pytest tests/ -m "live"
Safety & Limits
Read-only: All tools perform HTTP GET requests only β no data is written, modified, or deleted.
No personal data: STAT-TAB returns aggregated statistical datasets. No personally identifiable information (PII) is processed or stored by this server.
Rate limits: The PxWeb API is a public endpoint without documented rate limits; avoid tight loops over the full 682-table catalogue. The server enforces a 30s timeout per request and caches the catalogue index for 1 hour.
Data freshness: BFS publishes updated figures periodically (not real-time). Figures reflect the state of the upstream database at query time.
Terms of service: Data is subject to the BFS Terms of Use (OGD). All STAT-TAB data is published as Open Government Data and may be freely used with attribution.
No guarantees: This server is a community project, not affiliated with the Swiss Federal Statistical Office. Availability depends on the upstream BFS API.
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):