Metadata-Version: 2.4
Name: swisstopo-mcp
Version: 0.2.0
Summary: MCP server for Swiss federal geodata (Swisstopo APIs)
Project-URL: Homepage, https://github.com/malkreide/swisstopo-mcp
Project-URL: Repository, https://github.com/malkreide/swisstopo-mcp
Project-URL: Issues, https://github.com/malkreide/swisstopo-mcp/issues
License: MIT License
        
        Copyright (c) 2026 Hayal Oezkan
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: geocoding,geodata,gis,mcp,model-context-protocol,swiss-open-data,swisstopo
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27.0
Requires-Dist: mcp[cli]<2.0.0,>=1.0.0
Requires-Dist: pydantic-settings>=2.0.0
Requires-Dist: pydantic>=2.0.0
Requires-Dist: structlog>=24.1.0
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: respx>=0.21.0; extra == 'dev'
Requires-Dist: ruff>=0.4.0; extra == 'dev'
Description-Content-Type: text/markdown

> 🇨🇭 **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide)**

# 🗺️ swisstopo-mcp

![Version](https://img.shields.io/badge/version-0.2.0-blue)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![MCP](https://img.shields.io/badge/MCP-Model%20Context%20Protocol-purple)](https://modelcontextprotocol.io/)
[![No Auth Required](https://img.shields.io/badge/auth-none%20required-brightgreen)](https://github.com/malkreide/swisstopo-mcp)
![CI](https://github.com/malkreide/swisstopo-mcp/actions/workflows/ci.yml/badge.svg)

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

[🇩🇪 Deutsche Version](README.de.md)

---

## Overview

`swisstopo-mcp` gives AI assistants access to Switzerland's official geodata infrastructure through 19 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`](https://github.com/malkreide) and [`zurich-opendata-mcp`](https://github.com/malkreide/zurich-opendata-mcp) — this is what turns a geodata wrapper into a semantic connector at the commune level.)
[→ More use cases by audience →](EXAMPLES.md)

---

## Features

- 🗺️ **19 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](https://github.com/astral-sh/uv) (recommended) or pip

---

## Installation

```bash
# 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):

```bash
uvx swisstopo-mcp
```

---

## Quickstart

```bash
# 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):

```json
{
  "mcpServers": {
    "swisstopo": {
      "command": "python",
      "args": ["-m", "swisstopo_mcp.server"]
    }
  }
}
```

Or with `uvx`:

```json
{
  "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](https://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 |

### 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 |

### 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 18-tool budget (see
[`docs/geodaten-erweiterung-phase1.md`](docs/geodaten-erweiterung-phase1.md)):

| Tool | Description |
|------|-------------|
| `list_available_layers` | Discover layer keys for `query_geodata` (`strassenverzeichnis`, `oereb-verfuegbarkeit`, `geodienste:<topic>:<canton>`); filters to contract-free geodienste datasets |
| `query_geodata` | Query a chosen layer by `point` / `bbox` / `commune` — amtliches Strassenverzeichnis, interkantonale geodienste.ch data (OGC API Features), or ÖREB availability |
| `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](https://openplzapi.org) (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 |
|------|-------------|
| `lookup_postal_code` | Resolve a Swiss postal code → locality, commune (+**BFS number**), district, canton |
| `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 |
| `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?"* | `query_osm_features` + `query_geodata` (`strassenverzeichnis`) |
| *"Which contaminated-sites data is free for canton ZH?"* | `list_available_layers` + `query_geodata` (`geodienste:kataster_belasteter_standorte:ZH`) |
| *"Which communes are in the Uster district and what are their BFS numbers?"* | `find_commune` (`district=109`) |
| *"Which commune and canton does postal code 8001 belong to?"* | `lookup_postal_code` |
| *"What is the BFS number of Winterthur (to join with BFS statistics)?"* | `find_commune` (`name=Winterthur`) |

---

## Architecture

```
┌─────────────────┐     ┌──────────────────────────────┐     ┌──────────────────────────┐
│   Claude / AI   │────▶│  swisstopo-mcp               │────▶│  Swisstopo REST API      │
│   (MCP Host)    │◀────│  (MCP Server)                │◀────│  api3.geo.admin.ch       │
└─────────────────┘     │                              │     ├──────────────────────────┤
                        │  19 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               # query_geodata + list_available_layers (façade)
│   ├── overpass.py              # query_osm_features (OpenStreetMap / Overpass)
│   └── openplz.py               # lookup_postal_code, find_commune, 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](SECURITY.md).

### Phase

This server is in **Phase 1 — Read-only wrapper**. All 19 tools are
`readOnlyHint: true` / `destructiveHint: false`; there are no write or send
capabilities. See [docs/roadmap.md](docs/roadmap.md) for later phases.

### 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](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](docs/deployment.md).

### MCP Protocol Version

The MCP protocol version is negotiated by the `mcp` SDK, which is pinned to the
`1.x` major in `pyproject.toml` so an update cannot silently change the
negotiated version. SDK bumps are proposed monthly via Dependabot and tracked
in [CHANGELOG.md](CHANGELOG.md).

### 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_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

```bash
# Unit tests (no network required)
pytest tests/ -m "not live"

# Integration tests (live API calls)
pytest tests/ -m "live"
```

---

## Changelog

See [CHANGELOG.md](CHANGELOG.md)

---

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md)

---

## License

MIT License -- see [LICENSE](LICENSE)

Data provided by [swisstopo](https://www.swisstopo.admin.ch/) under [Open Government Data](https://opendata.swiss/) terms.

---

## Author

Hayal Oezkan · [malkreide](https://github.com/malkreide)

---

## Credits & Related Projects

- **Swisstopo:** [www.swisstopo.admin.ch](https://www.swisstopo.admin.ch/) -- Swiss Federal Office of Topography
- **Swisstopo APIs:** [api3.geo.admin.ch](https://api3.geo.admin.ch/) / [data.geo.admin.ch](https://data.geo.admin.ch/)
- **Protocol:** [Model Context Protocol](https://modelcontextprotocol.io/) -- Anthropic / Linux Foundation
- **Related:** [zurich-opendata-mcp](https://github.com/malkreide/zurich-opendata-mcp) -- Zurich city open data
- **Related:** [swiss-transport-mcp](https://github.com/malkreide/swiss-transport-mcp) -- Swiss public transport
- **Related:** [swiss-cultural-heritage-mcp](https://github.com/malkreide/swiss-cultural-heritage-mcp) -- Swiss cultural heritage
- **Portfolio:** [Swiss Public Data MCP Portfolio](https://github.com/malkreide)

<!-- mcp-name: io.github.malkreide/swisstopo-mcp -->

<!-- BEGIN GENERATED: install -->
## Installation

Run via [`uv`](https://docs.astral.sh/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`):

```json
{
  "mcpServers": {
    "swisstopo-mcp": {
      "command": "uvx",
      "args": [
        "swisstopo-mcp"
      ]
    }
  }
}
```
<!-- END GENERATED: install -->
