> πŸ‡¨πŸ‡­ **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide/swiss-public-data-mcp)** # πŸš† swiss-transport-mcp ![Version](https://img.shields.io/badge/version-0.4.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/) [![Data Source](https://img.shields.io/badge/Data-opentransportdata.swiss-red)](https://opentransportdata.swiss/) ![CI](https://github.com/malkreide/swiss-transport-mcp/actions/workflows/ci.yml/badge.svg) > MCP server connecting AI models to the Swiss public transport system – journey planning, real-time departures, disruptions, occupancy, ticket prices, train formations and open data from [opentransportdata.swiss](https://opentransportdata.swiss/). [πŸ‡©πŸ‡ͺ Deutsche Version](README.de.md) ### Demo ![Demo: Claude queries disruptions and departures](docs/assets/demo.svg) --- ## Overview **swiss-transport-mcp** gives AI assistants like Claude a complete Swiss travel information system – not just timetables, but also real-time disruption alerts, occupancy forecasts, ticket prices, and a full train formation view. All accessible through a single, standardised MCP interface. The various APIs at opentransportdata.swiss speak different protocols – OJP 2.0 (XML/SOAP), SIRI-SX (XML), REST/JSON. This server translates everything into clean JSON for the AI model, acting as a multilingual protocol interpreter. **Anchor demo query:** *"Plan a school trip for 25 students from Zurich to the Technorama in Winterthur – check for disruptions and find the best departure."* β†’ [More use cases by audience](EXAMPLES.md) β†’ --- ## Features - πŸ—ΊοΈ **Journey planning** (A β†’ B with transfers, duration, transport mode) via OJP 2.0 - πŸ• **Real-time departures** with delays and platform information - πŸ” **Stop search** by name or coordinates - 🚨 **Live disruption alerts** (cancellations, closures) via SIRI-SX - πŸ“Š **Occupancy forecasts** for trains (SBB, BLS, Thurbo, SOB) - πŸ’° **Ticket prices** including class selection - πŸšƒ **Train formation** – coaches, classes, amenities, accessibility - πŸ“¦ **Open data catalogue** – ~90 transport datasets via CKAN - πŸ”‘ **Graceful degradation** – server starts with core tools even without optional API keys - ☁️ **Dual transport** – stdio for Claude Desktop, Streamable HTTP/SSE for cloud deployment --- ## Prerequisites - Python 3.11+ - A free API key from [api-manager.opentransportdata.swiss](https://api-manager.opentransportdata.swiss/) (subscribe to **OJP 2.0** as minimum) - Optional: additional keys for SIRI-SX, Occupancy, Formation, OJP Fare --- ## Installation ```bash # Clone the repository git clone https://github.com/malkreide/swiss-transport-mcp.git cd swiss-transport-mcp # Install pip install -e . ``` Or with `uvx` (no permanent installation): ```bash uvx swiss-transport-mcp ``` --- ## Quickstart ```bash # Set the minimum required key (OJP core tools) export TRANSPORT_API_KEY=your_key_here # Start the server (stdio mode for Claude Desktop) swiss-transport-mcp ``` Try it immediately in Claude Desktop: > *"What are the next departures from Zurich Stadelhofen?"* > *"How do I get from WΓ€denswil to Bern by train?"* --- ## Configuration ### Environment Variables | Variable | API | Required | |---|---|---| | `TRANSPORT_API_KEY` | Unified key for OJP + CKAN | βœ… (or individual keys) | | `TRANSPORT_OJP_API_KEY` | OJP 2.0 Journey Planner | Optional (override) | | `TRANSPORT_CKAN_API_KEY` | CKAN data catalogue | Optional (separate subscription) | | `SIRI_SX_API_KEY` | Disruption alerts (SIRI-SX) | Optional | | `OCCUPANCY_API_KEY` | Occupancy forecast | Optional | | `FORMATION_API_KEY` | Train formation | Optional | | `OJP_FARE_API_KEY` | Ticket prices (OJP Fare) | Optional | > APIs without a key are silently disabled – the server starts fine with just the 6 core tools. **Operational / security variables:** | Variable | Effect | Default | |---|---|---| | `MCP_ENV` / `ENV` | Process environment. Must be `dev`/`development`/`local`/`test` to allow disabling TLS verification. | _(unset β†’ production)_ | | `TRANSPORT_SSL_VERIFY` | Set to `false` to disable TLS certificate verification. **Honoured only when `MCP_ENV` marks a dev environment** – otherwise the request is ignored and verification stays on. | `true` | | `TRANSPORT_CKAN_URL` | Override the CKAN base URL. Must stay on the egress allow-list (`*.opentransportdata.swiss`); off-site overrides are refused. | `https://api.opentransportdata.swiss/ckan-api` | | `MCP_CORS_ORIGINS` | Comma-separated list of browser origins allowed to call the HTTP transport. Use `*` to allow any origin (not recommended). The `Mcp-Session-Id` header is exposed to these origins. | `https://claude.ai` | | `LOG_FORMAT` | `json` for structured logs (RFC 5424 severity); anything else for human-readable text. Always written to stderr. | `text` | | `OTEL_TRACES_ENABLED` | `1` to enable OpenTelemetry tracing (requires the `otel` extra: `pip install 'swiss-transport-mcp[otel]'`). No-op otherwise. | _(off)_ | | `MCP_STATELESS` | `1` to run the Streamable HTTP transport statelessly β€” no server-side session state, so instances need **no sticky load balancing**. Recommended for horizontal scale-out. | _(off β†’ stateful)_ | | `MCP_ALLOWED_HOSTS` | Comma-separated list of the names this server is reachable under, port included where it matters (e.g. `fahrplan.example.ch:8080`). Requests arriving under any other `Host` are rejected with **421**; loopback stays allowed so container health checks keep working. Unset on a non-loopback bind, the check is off and a warning is logged. | _(unset β†’ off)_ | > πŸ”’ **Egress allow-list:** all outbound requests are restricted to `https://` on `opentransportdata.swiss` hosts. Any other host is refused before a request is sent (SSRF / egress hardening). ### Claude Desktop Configuration **Minimal (core tools only):** ```json { "mcpServers": { "swiss-transport": { "command": "swiss-transport-mcp", "env": { "TRANSPORT_API_KEY": "your_key_here" } } } } ``` **Full (all 11 tools):** ```json { "mcpServers": { "swiss-transport": { "command": "swiss-transport-mcp", "env": { "TRANSPORT_API_KEY": "your_ojp_key_here", "SIRI_SX_API_KEY": "your_siri_key_here", "OCCUPANCY_API_KEY": "your_occupancy_key_here", "FORMATION_API_KEY": "your_formation_key_here", "OJP_FARE_API_KEY": "your_fare_key_here" } } } } ``` **Config file locations:** - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` - Windows: `%APPDATA%\Claude\claude_desktop_config.json` ### Cloud Deployment (Streamable HTTP) For use via **claude.ai in the browser** (e.g. on managed workstations without local software). The cloud transport is **Streamable HTTP** (`MCP_TRANSPORT=streamable-http`, endpoint `/mcp`). SSE (`/sse`) is still supported but **deprecated**. | `MCP_TRANSPORT` | Use | Endpoint | |---|---|---| | `stdio` (default) | Local Claude Desktop subprocess | – | | `streamable-http` (or `http`) | Cloud / container (recommended) | `/mcp` | | `sse` | Legacy browser transport (deprecated) | `/sse` | **Docker (recommended):** ```bash # Build + run with explicit resource limits (see docker-compose.yml) TRANSPORT_API_KEY=xxx docker compose up --build # β†’ http://127.0.0.1:8000/mcp ``` The image is a multi-stage build running as a **non-root** user; `docker-compose.yml` adds `read_only`, `no-new-privileges` and memory/CPU/PID limits. **Render.com:** 1. Push/fork the repository to GitHub 2. On [render.com](https://render.com): New Web Service β†’ connect GitHub repo (Docker runtime) 3. Set env `MCP_TRANSPORT=streamable-http` **and `MCP_HOST=0.0.0.0`** 4. In claude.ai under Settings β†’ MCP Servers, add: `https://your-app.onrender.com/mcp` > πŸ’‘ *"stdio for the developer laptop, Streamable HTTP for the cloud."* **Scaling horizontally:** run with `MCP_STATELESS=1`. In stateless mode the server keeps no per-session state, so any instance can serve any request and a plain round-robin load balancer suffices β€” **no sticky sessions / `Mcp-Session-Id` affinity required**. If you need stateful streaming instead, route by `Mcp-Session-Id` at the edge LB (e.g. HAProxy stick-tables) so each session stays pinned to one instance. > ⚠️ **Binding:** In a network transport the server binds to `127.0.0.1` by > default so a locally started server is **not** exposed to your whole network > (e.g. public Wi-Fi). Set `MCP_HOST=0.0.0.0` **only** in a container/cloud > environment where binding to all interfaces is intended (the Docker image > does this for you). --- ## Available Tools ### Core Tools (OJP 2.0 / CKAN) | Tool | Description | Data Source | |---|---|---| | `transport_search_stop` | Search stops/stations by name | OJP 2.0 | | `transport_nearby_stops` | Find nearby stops by coordinates | OJP 2.0 | | `transport_departures` | Real-time departure board with delays & platforms | OJP 2.0 | | `transport_trip_plan` | Plan journey A β†’ B with transfers, duration, mode | OJP 2.0 | | `transport_search_datasets` | Search open data catalogue (~90 datasets) | CKANΒΉ | | `transport_get_dataset` | Get full details of a specific dataset | CKANΒΉ | ΒΉ *CKAN tools require a separate subscription in the [API Manager](https://api-manager.opentransportdata.swiss/).* ### Extension Tools (optional API keys) | Tool | Description | Data Source | |---|---|---| | `get_transport_disruptions` | 🚨 Live disruptions, cancellations, line closures | SIRI-SX | | `get_train_occupancy` | πŸ“Š Occupancy forecast for specific trains | Occupancy JSON | | `get_ticket_price` | πŸ’° Ticket prices for connections | OJP Fare | | `get_train_composition` | πŸšƒ Train formation, classes, accessibility | Formation REST | | `check_transport_api_status` | πŸ” Health check for all configured APIs | All | ### Example Use Cases | Query | Tool | |---|---| | *"Next trains from Zurich Stadelhofen?"* | `transport_departures` | | *"Plan a trip for 25 students from Zurich to Winterthur Technorama"* | `transport_trip_plan` | | *"Any disruptions between Zurich and Bern?"* | `get_transport_disruptions` | | *"How full is IC 1009 today?"* | `get_train_occupancy` | | *"What does a ticket from WΓ€denswil to Bern cost?"* | `get_ticket_price` | | *"Does IC 708 have a dining car?"* | `get_train_composition` | | *"Which stops are near Langstrasse 100?"* | `transport_nearby_stops` | --- ## Architecture ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Claude / AI │────▢│ Swiss Transport MCP │────▢│ opentransportdata.swiss β”‚ β”‚ (MCP Host) │◀────│ (MCP Server) │◀────│ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ OJP 2.0 (XML/SOAP) β”‚ β”‚ 11 Tools Β· 2 Resources β”‚ β”‚ SIRI-SX (XML) β”‚ β”‚ Stdio | SSE β”‚ β”‚ CKAN (REST/JSON) β”‚ β”‚ β”‚ β”‚ Occupancy(REST/JSON) β”‚ β”‚ Core: β”‚ β”‚ Formation(REST/JSON) β”‚ β”‚ api_client + ojp_client β”‚ β”‚ OJP Fare (XML/SOAP) β”‚ β”‚ Extensions: β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ siri_sx, occupancy, β”‚ β”‚ ojp_fare, formation β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ### Infrastructure Components | Component | Metaphor | Function | |---|---|---| | RateLimiter | Bouncer | Limits API calls per time window | | SimpleCache | Whiteboard | Caches responses for repeated queries | | APIClient | Switchboard | Handles auth, redirects, errors centrally | | APIConfig | Business card | Key, URL, limits per API | ### Caching Strategy | API | Cache TTL | Rationale | |---|---|---| | SIRI-SX | 120s | Disruptions don't change every second | | Occupancy | 300s | Forecasts are day-based | | Formation | 600s | Train composition is stable for the day | | OJP Fare | 1800s | Prices rarely change intraday | --- ## Project Structure ``` swiss-transport-mcp/ β”œβ”€β”€ src/swiss_transport_mcp/ # Main package β”‚ β”œβ”€β”€ server.py # FastMCP server, tool definitions β”‚ β”œβ”€β”€ api_client.py # Core OJP + CKAN client β”‚ β”œβ”€β”€ ojp_client.py # OJP 2.0 XML/SOAP parser β”‚ β”œβ”€β”€ api_infrastructure.py # RateLimiter, SimpleCache, APIClient β”‚ β”œβ”€β”€ siri_sx.py # Disruption alerts β”‚ β”œβ”€β”€ occupancy.py # Occupancy forecasts β”‚ β”œβ”€β”€ ojp_fare.py # Ticket prices β”‚ └── formation.py # Train formation β”œβ”€β”€ tests/ β”‚ └── test_server.py # Unit + integration tests β”œβ”€β”€ .github/workflows/ci.yml # GitHub Actions (Python 3.11/3.12/3.13) β”œβ”€β”€ claude_desktop_config.json # Example Claude Desktop config β”œβ”€β”€ pyproject.toml β”œβ”€β”€ CHANGELOG.md β”œβ”€β”€ CONTRIBUTING.md β”œβ”€β”€ LICENSE β”œβ”€β”€ README.md # This file (English) └── README.de.md # German version ``` --- ## Safety & Limits - **Read-only:** All tools perform read-only requests (HTTP GET / OJP XML POST for queries only) β€” no data is written, modified, or deleted on any upstream system. - **No personal data:** Journey queries are transient and not stored by this server. The APIs return scheduled timetable and real-time operational data. No personally identifiable information (PII) is processed or retained. - **Rate limits:** opentransportdata.swiss enforces per-key rate limits (documented in the API Manager). The server's built-in `RateLimiter` (SIRI-SX: 2 req/min, Formation/OJP Fare: 5 req/min) stays within these bounds automatically. Use the `limit` parameters conservatively for bulk queries. - **API key required:** A free key from [api-manager.opentransportdata.swiss](https://api-manager.opentransportdata.swiss/) is mandatory. Keys are bound to your account's subscription β€” only subscribe to APIs you intend to use. - **Data freshness:** Real-time tools (departures, disruptions, occupancy) reflect the upstream source at query time. The server caches responses for short TTLs (120s–1800s) to reduce API load β€” see the Caching Strategy table above. - **Terms of service:** Data is subject to the ToS of [opentransportdata.swiss](https://opentransportdata.swiss/de/nutzungsbedingungen/). OJP, SIRI-SX, and the CKAN catalogue are published under open licences (ODbL / CC BY 4.0) for non-commercial and research use. - **No guarantees:** This server is a community project, not affiliated with the Federal Office of Transport (BAV/OFT) or SBB. Availability depends on upstream APIs. ### Before you install (consent) Adding this server to your MCP client lets the connected AI model issue Swiss public-transport queries on your behalf, using **your** opentransportdata.swiss API key, and make outbound HTTPS requests to `opentransportdata.swiss`. Nothing is written upstream and no PII is stored, but you should review the tool list above and confirm you are comfortable granting that access before configuring the server. ### Running the HTTP transport safely (no built-in auth) The server has **no authentication of its own**. When you run the Streamable HTTP transport (`MCP_TRANSPORT=streamable-http`), the MCP SDK issues a cryptographically random `Mcp-Session-Id` per session, but there is no user identity bound to it. Therefore: - **Do not expose a no-auth instance directly to the public internet.** Put it behind an authenticating reverse proxy (OAuth2 proxy, mTLS, or your platform's access control), or restrict it to a trusted network. - Keep the default `MCP_HOST=127.0.0.1` for local use; only bind `0.0.0.0` inside a controlled container/cloud environment (see Deployment). - Scope `MCP_CORS_ORIGINS` to the origins you actually trust. - Set `MCP_ALLOWED_HOSTS` whenever you bind beyond loopback. It guards against **DNS rebinding**: a page on your network resolves its own hostname to this server's address and then talks to it from the browser. CORS does not stop that β€” from the browser's point of view the request is same-origin β€” and neither would a token, since the attacking page runs in a context that holds one. Only the `Host` check does. Left unset the check stays off, which is the right default only when something in front of the server validates `Host`. See [`SECURITY.md`](SECURITY.md) for the full security posture and the accepted-risk decisions (gateway-level controls). --- ## Known Limitations - **OJP Fare:** Discounts (Halbtax, GA, regional passes) are not always reflected - **Formation:** Stop-based data is only available for TODAY (real-time dependency) - **Occupancy:** SBB, BLS, Thurbo and SOB only – no private railways - **SIRI-SX:** Returns ALL Swiss disruptions β†’ use the `filter_text` parameter - **CKAN:** Requires a separate subscription in the API Manager --- ## MCP Protocol Version This server speaks **two protocol eras** over the same endpoint. The client's first request on a connection decides which one applies; a later claim from the other era is refused. | Era | Revision | Who reaches it | |---|---|---| | `initialize` handshake | `2024-11-05` … **`2025-11-25`** | What today's clients speak. The server answers with the revision asked for, or with the `2025-11-25` ceiling when the request asks for something newer. | | Per-request envelope | **`2026-07-28`** | A request carrying the `2026-07-28` `_meta` envelope opens a modern connection. | Both revisions are pinned in [`tests/test_protocol_version.py`](tests/test_protocol_version.py) and asserted against the installed SDK, so a Dependabot bump of `mcp` cannot move either one silently. That pin says which revisions the SDK *offers*. That the server actually serves them is **measured** in [`tests/test_modern_wire.py`](tests/test_modern_wire.py), against the very app `main()` hands to uvicorn: real `2026-07-28` single-exchange POSTs β€” no `initialize`, no `Mcp-Session-Id` β€” for `server/discover`, `tools/list` and a `tools/call`, the three rejection rungs (missing envelope, routing header disagreeing with the body, unserved revision), and a legacy `initialize` on the same endpoint to show both eras coexist. An earlier version of this section claimed the repo built no ASGI app to send a request through, and offered that as the reason the gate could only assert constants. It did build one. **Server identity.** `2026-07-28` has no `initialize`, and so no single place where a client reads `serverInfo`. The revision puts the `Implementation` (name, title, version, website) into `server/discover` *and* into the `_meta` of every result instead. This server fills those from its own distribution metadata, so the version on the wire is the version that was installed β€” it cannot drift from `pyproject.toml`. Note that the SDK's `LATEST_PROTOCOL_VERSION` is an alias for the **modern** era, not for the handshake era β€” pinning against it alone would leave the era that current clients actually negotiate free to drift. **Update policy.** When the gate fails, do not edit the constant blindly: read the spec changelog between the two revisions, verify the server still behaves, then move the constant, this section, `README.de.md` and [`CHANGELOG.md`](CHANGELOG.md) together. --- ## Testing ```bash # Unit tests (no API key required) PYTHONPATH=src pytest tests/ -m "not live" # Integration tests (API key required) TRANSPORT_API_KEY=xxx pytest tests/ -m "live" ``` ### Where the test data comes from All four upstream APIs need a Bearer token from the opentransportdata.swiss API-Manager, so CI cannot record a real response β€” measured and kept in `tests/fixtures/upstream_auth_probe.json`. The XML payloads in the test modules are therefore **hand-written, not recorded**, and cannot refute the production code: both come from the same reading of the docs, and where both are wrong they are wrong together. What *can* be recorded is the contract. OJP 2.0 is a CEN standard (CEN/TS 17118) with a public XML schema, and `tests/fixtures/ojp_2_0_contract.json` is a dated index derived from it β€” element names, the structures this server builds on, the enumerations it sends as values, plus the SHA-256 of every schema file read. `tests/test_ojp_contract.py` holds the requests and parsers against it. The schema itself is deliberately **not** vendored: the source repository carries no licence file. ```bash python scripts/record_fixtures.py # re-record python scripts/record_fixtures.py --check # recompute against the pinned tag ``` Source, date, selection rule and hashes: [`tests/fixtures/PROVENANCE.md`](tests/fixtures/PROVENANCE.md). --- ## Changelog See [CHANGELOG.md](CHANGELOG.md) --- ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) --- ## Security See [SECURITY.md](SECURITY.md) ([Deutsch](SECURITY.de.md)) for the security posture and how to report a vulnerability. --- ## License MIT License β€” see [LICENSE](LICENSE) --- ## Author Hayal Oezkan Β· [github.com/malkreide](https://github.com/malkreide) --- ## Credits & Related Projects - **Data:** [opentransportdata.swiss](https://opentransportdata.swiss/) – Federal Office of Transport (FOT/BAV) - **Protocol:** [Model Context Protocol](https://modelcontextprotocol.io/) – Anthropic / Linux Foundation - **Related:** [zurich-opendata-mcp](https://github.com/malkreide/zurich-opendata-mcp) – MCP server for Zurich city open data - **Portfolio:** [Swiss Public Data MCP Portfolio](https://github.com/malkreide) ## 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": { "swiss-transport-mcp": { "command": "uvx", "args": [ "swiss-transport-mcp" ] } } } ```