Skip to main content
Glama
malkreide

discover-swiss-mcp

by malkreide

πŸ‡¨πŸ‡­ Part of the Swiss Public Data MCP Portfolio

discover-swiss-mcp

Version License Python MCP Key required

MCP server for discover.swiss Infocenter Open β€” search Swiss tourism data with per-object attribution

πŸ‡©πŸ‡ͺ Deutsche Version


Status

pre-release β€” search entitlement confirmation from discover.swiss: pending.

Item

State

Search entitlement (written confirmation by discover.swiss)

pending β€” release gate

Tools

all eight registered (P3), live canaries in place (P4)

Phase

P5 β€” remediation of the audit of 2026-09-26 (see Phases and gates)

Release

none; version 0.1.0 is not published

Release P5 (PyPI, MCP registry, public reference instance)

on hold since 2026-09-26 β€” waits for the written confirmation

The live probe of 2026-09-17 found that /search works for the Open subscription β€” full text, distance ranking, date filters, 31 facets β€” although the official documentation states the opposite ("You can't use the search functionality"). The whole server is built on that endpoint, which makes the contradiction its main risk: an entitlement that contradicts the docs can be withdrawn without notice.

  • Written confirmation from discover.swiss is a release gate. No release before that conversation has happened. Once it has, set DISCOVER_SWISS_ENTITLEMENT_CONFIRMED=YYYY-MM-DD and this section changes from pending to confirmed with that date.

  • source_status reports the same state at runtime, so a host can see it without reading this file.

  • If the entitlement is withdrawn, the server keeps answering through a narrower list fallback (see Architecture decision).

Release on hold (2026-09-26). The release step was started on 2026-09-26 and stopped at its precondition: no written confirmation from discover.swiss is on record. Held until it arrives: the PyPI and MCP-registry release of 0.1.0, the public reference instance (it would run on the operator's key, which the confirmation has to cover), the portfolio status change and gate G1 over a remote endpoint. Until then the server runs locally with your own key β€” see docs/DEMO.md.


Related MCP server: meteoswiss-mcp

Phases and gates

The server is read-only in every phase β€” no tool writes, and none will before a phase that says so explicitly. Each phase ends at a gate: what was built, what is open, and a decision to continue or to discard.

Phase

Content

Gate

P1

Live probe, scaffold, client

probe report, architecture decision

P2

Core tools search, get_details, find_accommodation, find_tours

anchor queries live

P3

find_events, webcams_near, explore_area, source_status, list fallback

stop-gate run live

P4

Live canaries, audit, documentation

canaries green, audit published

P5

Remediation of the audit findings marked fix before release

targeted re-audit, canaries green

Release

0.1.0 on PyPI and in the MCP registry

written search confirmation by discover.swiss, no open release blocker


Anchor demo queries

Three questions this server is built to answer, chosen to fit the coverage it actually has. The tool chain for each β€” which tool, in which order, with which parameters β€” is in docs/DEMO.md, reproducible by anyone with a key.

  1. City (Zurich pilot) β€” "I have a rainy day in Zurich β€” which museums are within walking distance of the main station, and where do I eat vegetarian afterwards?" search β†’ get_details β†’ search; ZΓΌrich Tourismus as the source (CC BY-SA), attribution visible.

  2. Outdoor (Glarnerland) β€” "I'm in Braunwald: which hikes with little ascent are there, what does the webcam show right now, and is anything closed?" find_tours β†’ webcams_near, together with swiss-tourism-mcp (closures, cable cars) and meteoswiss-mcp (weather). Shows two servers working together.

  3. Lodging (nationwide) β€” "Family-friendly hotel near Interlaken, three stars, accessible β€” and how do I get there from Zurich airport?" find_accommodation with distance ranking β†’ get_details, then swiss-transport-mcp for the journey.


Scope map

What the open index holds, counted live on 2026-09-17 (probes/PROBE_REPORT_discover-swiss-mcp.md). 20,817 objects in total.

In scope

Count

Note

Lodging businesses, nationwide

5,275

Zermatt to Geneva; HotellerieSuisse, Schweiz Tourismus, TOMAS, contentdesk

Hotel rooms and meeting rooms

10,112 rooms

excluded from search unless types asks for them

Points of interest β€” Zurich (ZΓΌrich Tourismus)

1,311

museums, restaurants, shops, nightlife

Points of interest β€” Eastern Switzerland

6,211

Glarnerland 1,752 Β· Thurgau 1,381 Β· St. Gallen-Bodensee 1,338 Β· Heidiland 711 Β· Appenzellerland 608 Β· Toggenburg 421

Liechtenstein Β· Engadin Scuol

553 Β· 429

Tours

223

Eastern Switzerland, Zurich region; 16 from SchweizMobil

Webcams

73

all in Eastern Switzerland

Ski resorts Β· cable cars and lifts

21 Β· 36

Not in scope

Why it matters

Points of interest, tours, webcams for the Bernese Oberland, Central Switzerland, Valais, Ticino, Romandie

Hotels there are covered; sights, restaurants and hikes are not. An empty answer there means no data, not nothing there.

Events

21 entries in the whole index, a test record among them β€” practically empty. find_events says so in its description.

Nightly prices

Only a provider-declared price band (Niedrig / Mittel / Hoch).

Availability

Not in the Infocenter product.

Booking

Marketplace product, not this server.

explore_area and source_status report this coverage at runtime, so a model can check it before it searches.


Features

  • 8 read-only tools over search, detail, accommodation, tours, events, webcams, area exploration and source status

  • Per-object attribution β€” provider, licence and copyright notice travel in the response, not in this README

  • Licence whitelist on the root license field; everything else is counted into excluded_by_license and never served

  • Empty results carry a reason β€” a hint naming what to change, never an unexplained empty list

  • Degraded states are named β€” quota_exhausted, upstream_unreachable, search_unavailable

  • Dual transport β€” stdio (Claude Desktop) and Streamable HTTP (cloud)


Prerequisites

  • Python 3.11, 3.12 or 3.13

  • A discover.swiss Infocenter Open subscription key (self-service at portal.discover.swiss) β€” bring your own key

  • Rate limits of that subscription: 60 calls/minute, 50,000 calls/month


Installation

git clone https://github.com/malkreide/discover-swiss-mcp.git
cd discover-swiss-mcp
pip install -e ".[dev]"

Usage / Quickstart

export DISCOVER_SWISS_KEY="your-subscription-key"

# stdio (Claude Desktop and other local clients)
python -m discover_swiss_mcp

# Streamable HTTP β€” binds to 127.0.0.1:8000 by default (localhost only)
DISCOVER_SWISS_MCP_TRANSPORT=streamable-http python -m discover_swiss_mcp

The key is read from the environment only. It is held as a secret value, is never written to a log line, and belongs in no file that gets committed. .env.example lists every variable with placeholders.

The HTTP transport answers only under its exact host:port. Binding to any other address than loopback is refused at start-up unless inbound OAuth and DISCOVER_SWISS_MCP_ALLOWED_HOSTS are configured β€” see SECURITY.md.

Container. A hardened image (non-root, read-only root filesystem, no capabilities) and Kubernetes manifests with an egress NetworkPolicy are in Dockerfile and deploy/k8s/; commands and a Claude Desktop configuration are in docs/network-egress.md.


Available Tools

All eight tools are read-only (readOnlyHint: true, openWorldHint: true). Every hit carries its own attribution; every response counts what it withheld (excluded_by_license, excluded_test_objects, excluded_by_default_types) and explains an empty result in hint.

Tool

Source

Purpose

search

POST /search

Full text (match: all|name), type, locality and distance (near, radius_km) across the whole index; rooms and meeting rooms excluded unless requested

get_details

/vertices/{id}

Description, fees, accessibility, opening hours, amenities β€” trimmed to ~8 KB, HTML resolved to text; no_derivatives for CC BY-ND

find_accommodation

Search type=LodgingBusiness

Stars, garni, price band, amenities, accessibility (Pro Infirmis / OK:GO), distance; no availability, no nightly prices

find_tours

Search type=Tour

Kind, difficulty, length, ascent, season month, region, distance

find_events

Search type=Event, OData schedule filter

Date range (default 30 days); thin coverage stated in the description; test objects and all-rights-reserved events withheld and counted; the 2099 sentinel is reported as date_open, never as a date

webcams_near

Search type=Webcam + geo.distance

Radius (default 25 km) or region; live_url for the live image, snapshot_url labelled as a stored still

explore_area

Search with facets

Counts by type, data owner, season, price band for a region, locality or radius; short facet names mapped to OData, dropped names reported in missing_facets

source_status

/status, counters, unfiltered facet count

Reachability, search availability, calls per minute, quota state, index size, coverage, entitlement state; works without a key

Tool definitions are pinned in docs/tool-hashes.json; after an intended change, run python scripts/gen_tool_hashes.py --write in the same PR.


Attribution

Every response names its providers, and the host has to show them.

Each hit carries an attribution object β€” provider, licence and the provider's own copyright notice (for example Β«ZΓΌrich Tourismus www.zuerich.comΒ», CC BY-SA). One result set can mix three licences: ZΓΌrich Tourismus (CC BY-SA), SchweizMobil (CC BY), TOMAS (CC BY-ND). A single licence line in a footer covers none of them correctly.

  • Hosts and assistants: display provider and licence with the content taken from a hit. CC BY-SA content passed on stays CC BY-SA.

  • CC BY-ND (no_derivatives: true, mostly TOMAS rooms): quote or state facts, never rewrite the description.

  • Withheld objects (all rights reserved, no licence) are not served at all; excluded_by_license says how many there were.

The rules and the whitelist are in docs/LICENSES.md.


Architecture decision

ARCH A β€” live API only, search-centred, with a list fallback. (probes/PROBE_REPORT_discover-swiss-mcp.md, section 6)

  • /search covers full text, type, place, distance, dates and facets in one endpoint. Seven of eight tools are views on it; /vertices/{id} serves the detail.

  • There is no dump to mirror, and at 60 calls/minute and 50,000 a month a cache is enough: 15 minutes for search, 24 hours for detail. A monthly quota exhausted (403 with Β«quotaΒ») is a state β€” degraded: quota_exhausted β€” and is never retried.

  • The risk is the entitlement. Search contradicts the documentation and can be withdrawn. On 401/403 from /search, search and find_accommodation fall back to the typed list endpoints with client-side locality and distance filtering, answer provenance: list_fallback, degraded: search_unavailable, and say in hint what they ignored. The fallback is deliberately narrow: no full text, no star/price/amenity filters, at most eight list calls per tool call.

  • One project only: dsod-content. dsod-hs is a subset.


Known limitations

The twelve findings of the live probe that shape how this server behaves (probe report, section 10; details in CHANGELOG.md):

  1. The documentation denies search; the API grants it. Release gate: written confirmation.

  2. The documented example project demo-web answers 400. Projects come from /projects.

  3. Paging is nextPageToken, not continuation. Field names from live answers, not from the docs.

  4. top=1000 does not mean 1000. Cosmos DB cuts at ~4 MB; the token is followed.

  5. An open product is not an open licence. All-rights-reserved and unlicensed objects are withheld and counted.

  6. ds-containedInPlaceFilter is not a filter. It trims the response; the hit filter is the query parameter.

  7. Schweiz Tourismus already supplies data (2,769 hotels) β€” the open channel carries their content.

  8. Misspelt facet names are dropped silently β€” an invented one fails the whole request with 400. Only the eight verified names are sent; anything else is reported in missing_facets.

  9. One hotel weighs 78 KB. get_details trims to ~8 KB.

  10. There is test data in the production index. Β«Demo EventΒ» is withheld and counted in excluded_test_objects.

  11. Descriptions are HTML with entities. Resolved to plain text server-side.

  12. Half the index is rooms and meeting rooms. search excludes them unless asked.


Configuration

Variable

Required

Default

Purpose

DISCOVER_SWISS_KEY

yes

β€”

Subscription key, sent as Ocp-Apim-Subscription-Key

DISCOVER_SWISS_PROJECT

no

dsod-content

Project to query; dsod-hs is a subset

DISCOVER_SWISS_MCP_TRANSPORT

no

stdio

stdio or streamable-http

DISCOVER_SWISS_MCP_HOST

no

127.0.0.1

Bind address for HTTP transport

DISCOVER_SWISS_MCP_PORT

no

8000

TCP port for HTTP transport

DISCOVER_SWISS_MCP_LOG_LEVEL

no

INFO

structlog level; JSON goes to stderr

DISCOVER_SWISS_ENTITLEMENT_CONFIRMED

no

pending

Date (YYYY-MM-DD) of discover.swiss's written search confirmation; reported by source_status

DISCOVER_SWISS_MCP_ALLOWED_HOSTS

for a non-loopback bind

loopback host:port

Exact Host values the HTTP transport answers to; no wildcards

DISCOVER_SWISS_MCP_ALLOWED_ORIGINS

no

loopback origins

Browser origins the HTTP transport accepts

DISCOVER_SWISS_MCP_AUTH_* (five)

for a non-loopback bind

β€”

Inbound OAuth: issuer, resource URL, introspection URL, client id and secret β€” see SECURITY.md


Project Structure

discover-swiss-mcp/
β”œβ”€β”€ src/discover_swiss_mcp/
β”‚   β”œβ”€β”€ __main__.py        # python -m entry point, transport chosen by env
β”‚   β”œβ”€β”€ server.py          # MCP server, lifespan, tool wrappers
β”‚   β”œβ”€β”€ tools.py           # the *_impl functions, input and output models
β”‚   β”œβ”€β”€ config.py          # settings from env; the key is a SecretStr
β”‚   β”œβ”€β”€ models.py          # the response envelope
β”‚   β”œβ”€β”€ licenses.py        # licence whitelist, attribution, test-object filter
β”‚   β”œβ”€β”€ transform.py       # HTML to text, detail trimming
β”‚   β”œβ”€β”€ net.py             # SSRF guard, DNS pinning, egress allow-list
β”‚   β”œβ”€β”€ client.py          # API client: rate limit, retries, budget, cache, list fallback
β”‚   β”œβ”€β”€ http_app.py        # HTTP transport: bind policy, Host/Origin lists
β”‚   β”œβ”€β”€ auth.py            # inbound OAuth: scopes, token introspection, 401/403
β”‚   β”œβ”€β”€ _version.py        # the package version, a leaf module
β”‚   └── logging_config.py  # structlog, JSON to stderr
β”œβ”€β”€ tests/                 # unit tests; test_live.py is the `live` marker, not run by CI
β”œβ”€β”€ probes/                # live probe: scripts, raw responses, report
β”œβ”€β”€ audits/                # audit reports (mcp-audit)
β”œβ”€β”€ deploy/k8s/            # Deployment, NetworkPolicy, CiliumNetworkPolicy
β”œβ”€β”€ docs/                  # LICENSES, DEMO, DEFAULTS, network-egress, tool-hashes.json
β”œβ”€β”€ scripts/               # repo validation, release gate, tool hashes, default matrix
└── Dockerfile             # hardened image, base pinned by digest

Testing

# What CI runs β€” no network
pytest -m "not live"
ruff check .
ruff format --check .
python scripts/validate_repo.py .
python scripts/default_matrix.py --check     # docs/DEFAULTS.md matches the spec
# secrets over the whole history (what the CI job runs)
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:v8.30.1 git /repo --config /repo/.gitleaks.toml --redact

# Live canaries β€” real API, key from the environment, never in CI
export DISCOVER_SWISS_KEY="your-subscription-key"
pytest -m live -rA

The live canaries (tests/test_live.py, about twenty calls) hold each tool to roughly half of what the index held on 2026-09-17 β€” Β«LandesmuseumΒ» β‰₯ 20 hits, hotels β‰₯ 2,000, webcams within 100 km of St. Gallen β‰₯ 30, and so on β€” and check that scope parameters take effect: match="name" narrows, containedInPlace/id comes back as a facet, the Landesmuseum description arrives without HTML entities, the nearest hotel to Interlaken is in Interlaken. A floor that fails means a tool stopped finding what is there. Without a key the module skips; a skip is not a pass.


Changelog

See CHANGELOG.md


Contributing

Contributions are welcome β€” see CONTRIBUTING.md.


Security

Please report vulnerabilities as described in SECURITY.md.


License

MIT License β€” see LICENSE

The software is MIT. The data is not: it stays under the licence of the provider named in each hit. See docs/LICENSES.md.


Author

Hayal Oezkan Β· malkreide


Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    Enables AI models to search and retrieve bibliographic and digitized records from Swiss academic libraries (swisscovery, e-rara, e-periodica, e-manuscripta) via open protocols without requiring API keys.
    16
    148 PyPI
    1
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    Enables AI models to access Swiss weather and climate data from MeteoSwiss, including current observations, forecasts, and warnings.
    6
    48 PyPI
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    MCP server for Switzerland's national metadata catalogue, enabling AI agents to discover datasets, APIs, public services, and publishers through free-text search and structured queries.
    13
    MIT