Swiss federal geodata: geocoding, height, STAC, WMTS, OEREB and more
This MCP server provides Swiss federal geodata services, including geocoding, height, STAC, WMTS, and OEREB. Packaged as part of the Swiss Public Data MCP portfolio, it is MIT-licensed and runs with Python 3.11+.
π οΈ Key Features
Geocoding
Height data
STAC access
WMTS access
OEREB support
π Use Cases
Perform geocoding for Swiss locations
Retrieve elevation/height information
Consume geospatial datasets via STAC
Use map tiles through WMTS
Access OEREB-related geodata
β‘ Developer Benefits
Model Context Protocol (MCP) integration
MIT license
Requires Python 3.11+
β οΈ Limitations
No auth is required (per the available badge info), so access control details are not described.
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 β
"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):
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_map_query
The national map catalogue (api3.geo.admin.ch). One operation per call β see the five below
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
swisstopo_map_query operations
operation
Answers
Required arguments
search_layers
Which layers exist for a keyword? (500+ catalogue)
query
layer_info
What fields can I query on this layer, and what is its legend?
layer
features_at_point
What is at this coordinate?
layers + a point (lat/lonoreasting/northing)
features_by_attribute
Which features carry this value? (e.g. buildings by EGID)
layer, search_field, search_text
feature_by_id
Give me this one feature in full, with geometry
layer, feature_id
Arguments belonging to a different operation are rejected, not ignored β the
error names the ones the chosen operation accepts. Silently dropping a
misplaced search_field would return a plausible answer to a question nobody
asked, which is the failure mode the whole envelope design is against.
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
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
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?"
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
20 tools against a self-imposed budget of 25. The check's ideal is β€12, so the
count still needs an argument, not just a number. Per cluster:
The five api3 tools are merged (0.4.1, breaking). search_layers,
layer_info, identify_features, find_features and get_feature are now
operation values on swisstopo_map_query. They were the textbook
one-tool-per-REST-endpoint mapping the check names, and they are gone as such.
Earlier releases argued the opposite here, and the argument is worth keeping
visible because it was not wrong so much as outweighed: merging relocates the
decision from tool selection into schema navigation, where a model has less
help, because tool descriptions are what it actually reads. Three things address
that directly rather than hoping it does not matter:
The operations are named for questions, not endpoints.identify and
find are ESRI vocabulary β they say which MapServer route is called, not
what is being asked, and nobody without ArcGIS experience can tell them apart.
features_at_point and features_by_attribute can be picked correctly from
the operation list alone.
A misplaced argument is an error, not a silent drop. With five tools the
schema made a wrong pairing impossible. With one tool it does not, so
validation does: each operation declares the fields it accepts, and anything
else is refused with a message naming the alternatives.
The note hints from ARCH-003 still name the next step, now as
operations rather than tool names β an empty attribute search points at
operation='layer_info' for the valid field names, and so on.
Observability was the other cost, and it is not paid: each operation keeps its
own log and trace label (swisstopo_map_query:features_at_point), so per-operation
timing and error rates survive the merge.
The two pairs that stay separate, both named by the audit:
geocode + reverse_geocode do hit the same SearchServer endpoint, so on the
API axis this is a 1:1 mapping twice over. They stay separate on the axis that
matters for tool selection: "address β coordinates" and "coordinates β
address" are different questions with different input types, and collapsing
them into one tool with a mode would make the model choose a variant instead
of a tool. Sharing an endpoint is an implementation detail of the upstream.
search_geodata β get_collection is a genuine search β detail pair over
STAC; see below.
The naming ambiguity is resolved, which the audit did not raise but the
previous version of this section recorded for "the next breaking release" β this
is it. swisstopo_search_layers and swisstopo_list_available_layers both said
"layers" while fronting different catalogues. The first is now
swisstopo_map_query with operation='search_layers', which puts the national
catalogue in the tool name and leaves list_available_layers unambiguously the
consolidated faΓ§ade.
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.
With the api3 five merged there is no obvious consolidation left holding
headroom, so the next surface growth is a real conversation about the ceiling
rather than a deferred cleanup. tests/test_tool_namespace.py::TestToolBudget
is where that conversation is forced: raising the budget means editing the
number there and in both READMEs.
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.
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 server speaks 2026-07-28 natively and keeps every older revision
reachable. Which one you get is decided by how you connect, not by
configuration β one process serves both eras at once.
Modern era
Handshake era
Revisions
2026-07-28
2024-11-05 β¦ 2025-11-25
Opening exchange
none β every request stands alone
initialize + notifications/initialized
Protocol version travels in
params._metaand the MCP-Protocol-Version header
the initialize params
Session
none
Mcp-Session-Id, closed with DELETE /mcp
Directory
server/discover
the initialize result
Server identity
the io.modelcontextprotocol/serverInfo stamp in every response's _meta
The MCP-Protocol-Version header is what routes the request to the modern
path; without it the same body lands on the handshake transport. Mcp-Method
and Mcp-Name must agree with the body, or the request is rejected with
-32020. All three are in the CORS allow-list so a browser client's preflight
passes.
What this means in practice:
Existing clients are unaffected. A client asking for 2025-06-18 still
gets 2025-06-18; one asking over initialize for 2026-07-28 gets the
handshake ceiling 2025-11-25 back. Measured, not assumed β
tests/test_protocol_version.py.
Both eras expose the same 20 tools. Nothing is gated on the revision
(tests/test_modern_protocol.py).
Sticky sessions matter only for the handshake era. A modern request
carries no session, so it can be served by any replica β see
docs/deployment.md.
Neither revision is author-settable: the SDK negotiates the handshake era and
routes the modern one, and pyproject.toml pins mcp to the 2.x major so an
update cannot move either silently. tests/test_protocol_version.py pins both
ceilings and fails if a Dependabot bump changes one.
Update policy
SDK updates are tested on a feature branch before merge.
A change to either protocol ceiling 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.
Sessions exist in the handshake era only. There, session IDs are managed
entirely by the MCP SDK; there is no per-user state, so there is nothing
user-specific to bind a session to. Idle sessions are reaped after
SWISSTOPO_SESSION_IDLE_TIMEOUT seconds (default 1800) so a client that
disconnects without sending DELETE /mcp does not leak one for the lifetime of
the process. A 2026-07-28 request opens no session at all, so neither the
timeout nor the reaper applies to it.
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; unexpected
exception text is masked and logged to stderr instead. No upstream body or
internal configuration is forwarded: Overpass error pages are classified
against a fixed signature table, and an egress refusal returns a fixed message
rather than the allow-list (OBS-002).
Protocol errors (unknown tool, malformed/invalid arguments) come back from
the SDK as tool results with the protocol isError flag set β not as
JSON-RPC error objects. Verified against mcp 1.28.1 by runtime probe; an
earlier version of this section claimed -32602 and was wrong. Input
validation happens at the Pydantic boundary (SEC-018).
Both flags agree. A handled execution error sets the protocol
isError flag and the payload field is_error, so a client can branch on
either. The envelope β including source and license β survives on the
error path (OBS-001).
MCP Primitives
Tools are the surface, and almost all of it: every result is a live,
parameterised API query rather than a static addressable document.
One Resource β swisstopo://catalogue/layers β serves the faΓ§ade layer
catalogue. It is the one thing here that behaves like a document: deterministic,
idempotent, and already served with provenance: "cached". swisstopo_list_available_layers
remains for filtered queries; the resource is for a client that wants the
catalogue itself, addressably.
Two Prompts encode the workflows below, including the precedence rule for
point questions. That rule lives in the tool descriptions and in the server
instructions too, but a prompt is the one place a model reads it as guidance
rather than as one of 24 descriptions (audit ARCH-007/ARCH-008):
Prompt
Arguments
swisstopo_feature_lookup
ort, was
swisstopo_geodata_download
thema
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: all four steps are swisstopo_map_query with a different
operation: search_layers (find layer IDs) β layer_info (see the queryable
fields) β features_at_point / features_by_attribute β feature_by_id
(full detail).
Cadastre:swisstopo_geocode β swisstopo_oereb_at (one call: coordinates
β EGRID β extract). Use swisstopo_get_egrid β swisstopo_get_oereb_extract
only when the parcel ID itself is wanted.
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
bash
# Unit tests (no network required)
pytest tests/ -m "not live"# Integration tests (live API calls)
pytest tests/ -m "live"
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):