🇨🇭 Part of the Swiss Public Data MCP Portfolio

🗺️ swisstopo-mcp

Version License: MIT Python 3.11+ MCP No Auth Required CI

MCP server for Swiss federal geodata -- maps, elevation, geocoding, cadastral extracts, and downloadable datasets via Swisstopo APIs

🇩🇪 Deutsche Version


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

Demo: Claude using swisstopo_find_commune and swisstopo_lookup_postal_code


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):

  1. Push/fork the repository to GitHub
  2. On render.com: New Web Service -> connect GitHub repo
  3. Set start command: python -m swisstopo_mcp.server --http --port 8000
  4. 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 (namebfs_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_geodataget_collection is a genuine pair: STAC collection metadata is large and callers usually want one of many search hits. get_egridget_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 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).
  • Protocol errors (unknown tool, malformed/invalid arguments) are emitted by the MCP SDK as JSON-RPC errors with standard codes (e.g. -32602 invalid 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_featuresswisstopo_get_feature (full detail).
  • Cadastre: swisstopo_geocodeswisstopo_get_egridswisstopo_get_oereb_extract.
  • Downloads: swisstopo_search_geodataswisstopo_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 ZH1 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

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"
      ]
    }
  }
}