Connects Claude to Switzerland's official geodata infrastructure through 13 tools spanning Swisstopo's REST API, geocoding service, height queries, STAC catalog, WMTS map builder, and OEREB cadastre. You can geocode Swiss addresses, query elevations and profiles, identify map features across 500+ layers, search for downloadable datasets like orthophotos and 3D building models, generate shareable map links, and retrieve cadastral extracts with public law restrictions. Eleven of thirteen tools need no authentication. Built for queries like "What land use restrictions apply to this Zurich address?" or "Show me elevation along this route." Supports both stdio for Claude Desktop and SSE transport for browser deployment on Render or similar platforms.
🇨🇭 Part of the Swiss Public Data MCP Portfolio
MCP server for Swiss federal geodata -- maps, elevation, geocoding, cadastral extracts, and downloadable datasets via Swisstopo APIs
swisstopo-mcp gives AI assistants access to Switzerland's official geodata infrastructure through 24 tools, all without authentication:
| Source | Data | API |
|---|---|---|
| Swisstopo REST API | 500+ geodata layers (buildings, boundaries, land use) | REST/JSON |
| Geocoding | Official addresses, place names, postal codes | REST/JSON |
| Height Service | Elevation above sea level, elevation profiles | REST/JSON |
| STAC Catalog | Orthophotos, elevation models, 3D buildings | STAC 0.9 |
| WMTS | National maps, aerial images, zoning maps | URL builder |
| OEREB Cadastre | Public-law restrictions, parcels | REST/JSON (cantonal) |
| geodienste.ch | Interkantonale Basisgeodaten (cadastral survey, contaminated sites, hazard maps, …) | OGC API Features / WMS / WFS |
| OpenStreetMap | Points of interest (schools, playgrounds, pharmacies, …) | Overpass API (ODbL) |
| OpenPLZ API | Administrative address level: postal codes → commune (BFS number) → district → canton | REST/JSON (BFS + swisstopo OGD) |
Anchor demo query: "Which communes are in the Uster district, and what are their BFS numbers for joining with BFS statistics data?"
(The BFS commune number is the official join key to swiss-statistics-mcp and zurich-opendata-mcp — this is what turns a geodata wrapper into a semantic connector at the commune level.)
→ More use cases by audience →
# Clone the repository
git clone https://github.com/malkreide/swisstopo-mcp.git
cd swisstopo-mcp
# Install
pip install -e .
# or with uv:
uv pip install -e .
Or with uvx (no permanent installation):
uvx swisstopo-mcp
# stdio (for Claude Desktop)
python -m swisstopo_mcp.server
# Streamable HTTP (port 8000)
python -m swisstopo_mcp.server --http --port 8000
Try it immediately in Claude Desktop:
"Where is Bahnhofstrasse 1, Zurich? Give me the coordinates." "What is the elevation at the Uetliberg summit?" "What buildings are at coordinates 2683500, 1247500 (LV95)?"
Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"swisstopo": {
"command": "python",
"args": ["-m", "swisstopo_mcp.server"]
}
}
}
Or with uvx:
{
"mcpServers": {
"swisstopo": {
"command": "uvx",
"args": ["swisstopo-mcp"]
}
}
}
Config file locations:
~/Library/Application Support/Claude/claude_desktop_config.json%APPDATA%\Claude\claude_desktop_config.jsonFor use via claude.ai in the browser (e.g. on managed workstations without local software):
Render.com (recommended):
python -m swisstopo_mcp.server --http --port 8000https://your-app.onrender.com/sse| Tool | Description |
|---|---|
swisstopo_search_layers | Search the Swisstopo layer catalog (500+ layers) by keyword |
swisstopo_identify_features | Find map features at a specific coordinate (spatial query) |
swisstopo_find_features | Search features by attribute value within a layer (e.g. buildings by EGID) |
swisstopo_get_feature | Retrieve full attributes and geometry for a feature by ID |
swisstopo_layer_info | List a layer's queryable fields and its legend (which search_field values are valid) |
swisstopo_zoning_at | Harmonised building zone at a coordinate — one call, no layer lookup (not legally binding) |
swisstopo_municipality_at | Municipality, canton and official BFS number at a coordinate |
| Tool | Description |
|---|---|
swisstopo_geocode | Convert Swiss addresses, place names, or postal codes to coordinates |
swisstopo_reverse_geocode | Find the nearest address for given coordinates |
| Tool | Description |
|---|---|
swisstopo_get_height | Get elevation above sea level (m a.s.l.) at a coordinate |
swisstopo_elevation_profile | Compute an elevation profile along a line |
swisstopo_convert_coordinates | Official WGS84 ↔ LV95 conversion via the swisstopo REFRAME service |
| Tool | Description |
|---|---|
swisstopo_search_geodata | Search the STAC catalog for downloadable geodatasets |
swisstopo_get_collection | Get details and download links for a STAC collection |
| Tool | Description |
|---|---|
swisstopo_map_url | Generate a map.geo.admin.ch URL for browser display |
| Tool | Description |
|---|---|
swisstopo_get_egrid | Resolve a cadastral property ID (EGRID) from coordinates |
swisstopo_get_oereb_extract | Retrieve public-law land-use restrictions (OEREB) for a parcel |
One façade over several map/layer sources, kept under the 25-tool budget (see
docs/geodaten-erweiterung-phase1.md):
| Tool | Description |
|---|---|
swisstopo_list_available_layers | Discover layer keys for swisstopo_query_geodata (strassenverzeichnis, oereb-verfuegbarkeit, geodienste:<topic>:<canton>); filters to contract-free geodienste datasets |
swisstopo_query_geodata | Query a chosen layer by point / bbox / commune — amtliches Strassenverzeichnis, interkantonale geodienste.ch data (OGC API Features), or ÖREB availability |
swisstopo_query_osm_features | OpenStreetMap POIs (schools, playgrounds, pharmacies, …) around a point via Overpass — separate source, ODbL (© OpenStreetMap contributors) |
The amtliche address hierarchy PLZ → commune → district → canton, served by
the OpenPLZ API (data: BFS municipal directory +
swisstopo street directory, Swiss OGD — a separate source and licence from
the swisstopo geodata above). Every commune-bearing response exposes
bfs_commune_number as a named top-level field: the official join key to
BFS statistics (swiss-statistics-mcp) and zurich-opendata-mcp.
| Tool | Description |
|---|---|
swisstopo_lookup_postal_code | Resolve a Swiss postal code → locality, commune (+BFS number), district, canton |
swisstopo_find_commune | Resolve a commune both directions (name ↔ bfs_number) or list all communes of a canton / district. Accepts canton abbreviation (ZH) or key (1); resolution happens server-side |
swisstopo_search_address | Full-text search over Swiss streets and localities, returning commune + BFS number per hit |
| Query | Tool |
|---|---|
| "Where is Bahnhofstrasse 1, Zurich?" | swisstopo_geocode |
| "What is the elevation at the Uetliberg summit?" | swisstopo_get_height |
| "What buildings are at coordinates 2683500, 1247500?" | swisstopo_identify_features |
| "Find orthophoto datasets for download" | swisstopo_search_geodata |
| "Show me a map of Bern at zoom level 10" | swisstopo_map_url |
| "What restrictions apply to parcel at Musterstrasse 5?" | swisstopo_get_egrid + swisstopo_get_oereb_extract |
| "Which schools are within 500 m of Bederstrasse 109, 8002 Zürich, and which streets lead there?" | swisstopo_query_osm_features + swisstopo_query_geodata (strassenverzeichnis) |
| "Which contaminated-sites data is free for canton ZH?" | swisstopo_list_available_layers + swisstopo_query_geodata (geodienste:kataster_belasteter_standorte:ZH) |
| "Which communes are in the Uster district and what are their BFS numbers?" | swisstopo_find_commune (district=109) |
| "Which commune and canton does postal code 8001 belong to?" | swisstopo_lookup_postal_code |
| "What is the BFS number of Winterthur (to join with BFS statistics)?" | swisstopo_find_commune (name=Winterthur) |
┌─────────────────┐ ┌──────────────────────────────┐ ┌──────────────────────────┐
│ Claude / AI │────▶│ swisstopo-mcp │────▶│ Swisstopo REST API │
│ (MCP Host) │◀────│ (MCP Server) │◀────│ api3.geo.admin.ch │
└─────────────────┘ │ │ ├──────────────────────────┤
│ 24 Tools │────▶│ Geocoding │
│ Stdio | Streamable HTTP │◀────│ api3.geo.admin.ch │
│ │ ├──────────────────────────┤
│ No authentication required │────▶│ STAC Catalog │
│ (all tools; OEREB canton opt) │◀────│ data.geo.admin.ch │
│ │ ├──────────────────────────┤
│ │────▶│ OEREB Cadastre │
│ │◀────│ (cantonal endpoints) │
│ │ ├──────────────────────────┤
│ │────▶│ geodienste.ch (OGC API) │
│ │◀────│ overpass.osm.ch (ODbL) │
│ │ ├──────────────────────────┤
│ BFS-Nr = join key to │────▶│ OpenPLZ API │
│ swiss-statistics-mcp │◀────│ openplzapi.org (BFS/OGD)│
└──────────────────────────────┘ └──────────────────────────┘
swisstopo-mcp/
├── src/swisstopo_mcp/
│ ├── __init__.py # Package version
│ ├── server.py # MCP server wiring (tool registrations)
│ ├── api_client.py # Shared HTTP client (httpx + error handling)
│ ├── geocoding.py # swisstopo_geocode, swisstopo_reverse_geocode
│ ├── rest_api.py # swisstopo_search_layers, identify, find, get_feature
│ ├── height.py # swisstopo_get_height, swisstopo_elevation_profile
│ ├── stac.py # swisstopo_search_geodata, swisstopo_get_collection
│ ├── wmts.py # swisstopo_map_url
│ ├── oereb.py # swisstopo_get_egrid, swisstopo_get_oereb_extract
│ ├── geodata.py # swisstopo_query_geodata + swisstopo_list_available_layers (façade)
│ ├── overpass.py # swisstopo_query_osm_features (OpenStreetMap / Overpass)
│ └── openplz.py # swisstopo_lookup_postal_code, swisstopo_find_commune, swisstopo_search_address (OpenPLZ)
├── tests/
│ ├── test_api_client.py
│ ├── test_geocoding.py
│ ├── test_height.py
│ ├── test_oereb.py
│ ├── test_rest_api.py
│ ├── test_stac.py
│ ├── test_wmts.py
│ ├── test_geodata.py
│ ├── test_overpass.py
│ ├── test_openplz.py
│ └── test_retry.py
├── .github/workflows/ci.yml # GitHub Actions (Python 3.11/3.12/3.13)
├── pyproject.toml
├── CHANGELOG.md
├── CONTRIBUTING.md # Contribution guide (English)
├── CONTRIBUTING.de.md # Contribution guide (German)
├── SECURITY.md # Security policy (English)
├── SECURITY.de.md # Security policy (German)
├── LICENSE
├── README.md # This file (English)
└── README.de.md # German version
The full security policy and posture is documented in SECURITY.md.
This server is in Phase 2.5 — Consolidation of swiss-geodata-mcp
(see docs/roadmap.md, the single authority for phase state).
| Property | Status |
|---|---|
| Read tools | 23, all readOnlyHint: true / destructiveHint: false |
| Write tools | none — Phase 3, not planned |
| Transport | stdio (default) and Streamable-HTTP |
| Last audit | audits/2026-07-27T125314-Z-swisstopo-mcp/ |
A phase advance requires: the phase's roadmap items checked off, a re-run audit
with no open critical findings, and a CHANGELOG entry naming the new phase.
Phase 3 (write tools) additionally requires re-running the Lethal-Trifecta
assessment and a security review before any implementation starts.
24 tools against a self-imposed budget of 25. The check's ideal is ≤12, so the count needs an argument, not just a number. Per cluster:
The five api3 tools (search_layers, layer_info, identify_features,
find_features, get_feature) are kept separate because their argument shapes
are genuinely disjoint: a geometry, an attribute name plus value, and an opaque
feature ID. Merging them behind one tool with a discriminated union would turn a
tool choice into a variant choice — the same decision, relocated, plus a schema
the caller must navigate. The audit notes the real risk here: a wrong pick
returns empty rather than erroring. That is mitigated instead by the note
hints added for ARCH-003, which name the likely mistake and the tool to use.
This remains a merge candidate for a future major release, not a settled
question.
Search → detail pairs. search_geodata → get_collection is a genuine
pair: STAC collection metadata is large and callers usually want one of many
search hits. get_egrid → get_oereb_extract was the same shape and has been
collapsed: swisstopo_oereb_at answers the actual question in one call and
resolves the EGRID internally, because the EGRID is an upstream identifier
rather than something a caller asked for. get_egrid remains for callers who
want the parcel ID itself.
Genuine aggregation already in place. query_geodata fronts three sources
behind one tool; zoning_at and municipality_at each collapse a discovery
chain that previously took two calls.
When the next source is added, the choice is a raise or a consolidation. The consolidation on the table is the api3 five; it is a breaking change and should ride a major release together with any other renames.
Every response carries source and license. ARE is a different federal
office from swisstopo, so its licence is asserted rather than inherited.
| Source | Served by | Licence |
|---|---|---|
| swisstopo / geo.admin.ch | most tools | Swiss OGD (opendata.swiss) |
| swisstopo REFRAME (geodesy.geo.admin.ch) | swisstopo_convert_coordinates | Swiss OGD (opendata.swiss) |
| swissBOUNDARIES3D (swisstopo) | swisstopo_municipality_at | Swiss OGD (opendata.swiss) |
ch.are.bauzonen (ARE) | swisstopo_zoning_at | Swiss OGD — Bundesamt für Raumentwicklung ARE |
| Cantonal ÖREB cadastre | swisstopo_get_egrid, swisstopo_get_oereb_extract | Cantonal ÖREB terms |
| geodienste.ch (cantons) | swisstopo_query_geodata | Free use — attribution required |
| OpenStreetMap (Overpass) | swisstopo_query_osm_features | ODbL — © OpenStreetMap contributors |
| OpenPLZ (BFS + swisstopo) | swisstopo_lookup_postal_code, swisstopo_find_commune, swisstopo_search_address | Free use — attribution required |
ch.are.bauzonen is a federal synthesis for cross-cantonal comparability and
is not legally binding — only the cantonal or communal Nutzungsplanung is.
That caveat is carried on every swisstopo_zoning_at result record.
The tool modules sit flat under src/swisstopo_mcp/ rather than in a tools/
sub-package. Each module maps to exactly one upstream API family —
rest_api.py → api3 MapServer, stac.py → STAC, oereb.py → cantonal ÖREB,
openplz.py → OpenPLZ, overpass.py → OSM, coords.py → REFRAME — which is
the axis along which this server's code actually varies. A tools/ level would
add a directory without adding a distinction.
server.py contains tool registrations only; every tool body lives in its
domain module. Splitting it further is a readability question, not a structural
one.
| Capability | Status | Rationale |
|---|---|---|
| Access to private data | ❌ No | Public Open Data only (federal/cantonal geodata) |
| Exposure to untrusted content | ⚠️ Limited | Reads only from a fixed allow-list of trusted geo.admin / OEREB hosts |
| External communication (write/send) | ❌ No | Read-only; no mail/webhook/write tools |
Trifecta score: at most 1 of 3 — safe by design.
Outbound requests are restricted to an explicit code-layer allow-list and redirects are disabled — see docs/network-egress.md.
For containerised HTTP deployments, a hardened Dockerfile and Kubernetes
manifests (non-root, read-only root filesystem, dropped capabilities, egress
NetworkPolicy) are provided — see docs/deployment.md.
The MCP protocol version is negotiated during initialize; the Python SDK does
not expose an author-settable pin. As of mcp 1.28.1 the negotiated version is
2025-11-25 (mcp.types.LATEST_PROTOCOL_VERSION). The SDK is pinned to the
1.x major in pyproject.toml so an update cannot silently move it, and
tests/test_protocol_version.py fails if it does — a Dependabot bump cannot
change the protocol version unnoticed.
Update policy
### Changed, naming the old and the new
version.The server is unauthenticated by design — it serves only public open data. Over HTTP, session IDs are managed entirely by the FastMCP framework; there is no per-user state, so there is nothing user-specific to bind a session to. If an authenticated deployment is ever introduced, session IDs must be bound to the validated user identity (audit finding SEC-009).
ToolResponse with is_error: true and a user-friendly summary; raw
exception text is never leaked to the client (it is logged to stderr instead).-32602 invalid
params). Input validation happens at the Pydantic boundary (SEC-018).This server intentionally exposes Tools only (no Resources or Prompts): it is a Phase-1 read-only wrapper, and every result is a live, parameterised API query rather than a static addressable document. Resources/Prompts may be added in a later phase if stable URI schemes emerge.
Most tools return a thought-complete result in a single call. Two domains use a short, documented discovery chain (each tool's description states the next step):
swisstopo_search_layers (find layer IDs) →
swisstopo_identify_features / swisstopo_find_features →
swisstopo_get_feature (full detail).swisstopo_geocode → swisstopo_get_egrid →
swisstopo_get_oereb_extract.swisstopo_search_geodata → swisstopo_get_collection.Every tool returns a structured ToolResponse (FastMCP emits it as structured
content with an output schema, plus a JSON text block):
| Field | Meaning |
|---|---|
summary | Human-readable Markdown summary |
results | Machine-readable structured records |
count | Number of results |
match_type | exact / fuzzy / none (search-style tools) |
source / license | Data attribution (OGD-CH, CC/OGD terms) |
provenance / retrieved_at | How and when the data was obtained |
is_error | true for handled errors |
The OpenPLZ endpoints were probed live before implementation. Findings baked into the tools:
| Endpoint / behaviour | Result | Handling |
|---|---|---|
/Cantons | 200, 26 records, key = BFS canton number (ZH = 1) | canton abbreviation resolved from this list |
/Cantons/{key}/Districts|Communes | 200 | path param is the numeric key |
/Cantons/ZH/Districts (abbreviation) | 200 + [] — not an error | ZH→1 resolved server-side; empty answer gets an explanatory note |
/Localities?postalCode=8001 | 200, commune.key = 261 (BFS Zürich) | bfs_commune_number surfaced top-level |
/Localities?postalCode=9999 (unknown) | 200 + [] | reported as a note — empty ≠ absent |
| list endpoints pagination | default pageSize=10, hard max 50 (100 → HTTP 400) | tools iterate pages via x-total-count |
raw umlaut in query (?name=Zürich) | HTTP 400 | httpx URL-encodes params automatically |
historicalCode field | ≠ key for communes (historized-directory id) | not used; the join key is the current key |
| bulk dump | none from OpenPLZ (only /swagger) | Architecture A (live-API-only) — adequate for a lookup connector |
The abbreviation-vs-key trap in one line: an empty OpenPLZ list is almost never proof that something does not exist — it usually means a wrong path parameter (an abbreviation where a numeric key was expected). The tools resolve abbreviations server-side and annotate every empty result.
# Unit tests (no network required)
pytest tests/ -m "not live"
# Integration tests (live API calls)
pytest tests/ -m "live"
See CHANGELOG.md
See CONTRIBUTING.md
Read-only, no authentication, public geodata only. See SECURITY.md (Deutsch) for the security posture and how to report a vulnerability.
MIT License -- see LICENSE
Data provided by swisstopo under Open Government Data terms.
Hayal Oezkan · malkreide
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": {
"swisstopo-mcp": {
"command": "uvx",
"args": [
"swisstopo-mcp"
]
}
}
}