🇨🇭 Part of the Swiss Public Data MCP Portfolio
🗺️ swisstopo-mcp
MCP server for Swiss federal geodata -- maps, elevation, geocoding, cadastral extracts, and downloadable datasets via Swisstopo APIs
Overview
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 →
Demo
Features
- 🗺️ 24 tools (REST, Geocoding, Height, STAC, WMTS, OEREB, geodienste.ch, OpenStreetMap/Overpass, OpenPLZ)
- 🏛️ Resolve the administrative address level (PLZ → commune/BFS number → district → canton) via OpenPLZ
- 🔍 Geocode Swiss addresses and reverse-geocode coordinates
- 🏔️ Query elevation and compute elevation profiles
- 📦 Discover and download geodatasets (orthophotos, 3D buildings, historical maps)
- 🏗️ Identify map features at coordinates across 500+ Swisstopo layers
- 🔗 Generate shareable map.geo.admin.ch links
- 📋 Look up cadastral property IDs (EGRID) and retrieve OEREB extracts
- 🔓 No API key required for all tools (OEREB extract needs a supported canton)
- ☁️ Dual transport -- stdio (Claude Desktop) + Streamable HTTP (cloud)
Prerequisites
- Python 3.11+
- uv (recommended) or pip
Installation
# 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
Quickstart
# 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)?"
Configuration
Claude Desktop
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:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Cloud Deployment (SSE for browser access)
For use via claude.ai in the browser (e.g. on managed workstations without local software):
Render.com (recommended):
- Push/fork the repository to GitHub
- On render.com: New Web Service -> connect GitHub repo
- Set start command:
python -m swisstopo_mcp.server --http --port 8000 - In claude.ai under Settings -> MCP Servers, add:
https://your-app.onrender.com/sse
Available Tools
REST API (Layer & Feature Queries)
| 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 |
Geocoding
| Tool | Description |
|---|---|
swisstopo_geocode |
Convert Swiss addresses, place names, or postal codes to coordinates |
swisstopo_reverse_geocode |
Find the nearest address for given coordinates |
Height Service
| 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 |
STAC Catalog (Geodata Downloads)
| Tool | Description |
|---|---|
swisstopo_search_geodata |
Search the STAC catalog for downloadable geodatasets |
swisstopo_get_collection |
Get details and download links for a STAC collection |
WMTS (Map URLs)
| Tool | Description |
|---|---|
swisstopo_map_url |
Generate a map.geo.admin.ch URL for browser display |
OEREB Cadastre
| 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 |
Consolidated Geodata Façade
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) |
Administrative Address Level (OpenPLZ)
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 |
Example Use Cases
| 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) |
Architecture
┌─────────────────┐ ┌──────────────────────────────┐ ┌──────────────────────────┐
│ 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)│
└──────────────────────────────┘ └──────────────────────────┘
Project Structure
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
Security & Compliance
The full security policy and posture is documented in SECURITY.md.
Phase
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.
Tool budget and aggregation
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.
Data sources and licences
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.
Project structure
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.
Lethal Trifecta assessment
| 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.
Egress
Outbound requests are restricted to an explicit code-layer allow-list and redirects are disabled — see docs/network-egress.md.
Container deployment
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.
MCP Protocol Version
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
- SDK updates are tested on a feature branch before merge.
- A change to the negotiated protocol version is recorded in
CHANGELOG.md under
### Changed, naming the old and the new version. - A protocol change that breaks existing clients triggers a major release.
Sessions & Authentication
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).
Error handling
- Execution errors (upstream failure, invalid value) are returned as a
ToolResponsewithis_error: trueand a user-friendlysummary; raw exception text is never leaked to the client (it is logged to stderr instead). - Protocol errors (unknown tool, malformed/invalid arguments) are emitted by
the MCP SDK as JSON-RPC errors with standard codes (e.g.
-32602invalid params). Input validation happens at the Pydantic boundary (SEC-018).
MCP Primitives
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.
Tool workflows
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):
- Feature query:
swisstopo_search_layers(find layer IDs) →swisstopo_identify_features/swisstopo_find_features→swisstopo_get_feature(full detail). - Cadastre:
swisstopo_geocode→swisstopo_get_egrid→swisstopo_get_oereb_extract. - Downloads:
swisstopo_search_geodata→swisstopo_get_collection.
Response Format
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 |
Known Limitations
- OEREB tools require a canton parameter; not all cantons expose the same API format
- STAC catalog uses Swisstopo's v0.9 endpoint; some collections may lack complete metadata
- Geocoding covers Swiss addresses only (no Liechtenstein)
- Rate limits are enforced by Swisstopo; high-frequency usage may be throttled
Known findings — OpenPLZ live probe (2026-07-20)
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.
Testing
# Unit tests (no network required)
pytest tests/ -m "not live"
# Integration tests (live API calls)
pytest tests/ -m "live"
Changelog
See CHANGELOG.md
Contributing
See CONTRIBUTING.md
Security
Read-only, no authentication, public geodata only. See SECURITY.md (Deutsch) for the security posture and how to report a vulnerability.
License
MIT License -- see LICENSE
Data provided by swisstopo under Open Government Data terms.
Author
Hayal Oezkan · malkreide
Credits & Related Projects
- Swisstopo: www.swisstopo.admin.ch -- Swiss Federal Office of Topography
- Swisstopo APIs: api3.geo.admin.ch / data.geo.admin.ch
- Protocol: Model Context Protocol -- Anthropic / Linux Foundation
- Related: zurich-opendata-mcp -- Zurich city open data
- Related: swiss-transport-mcp -- Swiss public transport
- Related: swiss-cultural-heritage-mcp -- Swiss cultural heritage
- Portfolio: Swiss Public Data MCP Portfolio
Installation
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"
]
}
}
}
No comments yet
Be the first to share your take.