swiss-transport-mcp
> π¨π **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide/swiss-public-data-mcp)**
# π swiss-transport-mcp

[](https://opensource.org/licenses/MIT)
[](https://www.python.org/downloads/)
[](https://modelcontextprotocol.io/)
[](https://opentransportdata.swiss/)

> 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

---
## 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)
<!-- mcp-name: io.github.malkreide/swiss-transport-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": {
"swiss-transport-mcp": {
"command": "uvx",
"args": [
"swiss-transport-mcp"
]
}
}
}
```
<!-- END GENERATED: install -->
TDQS
Scored across 11 tools
Each tool has a clearly distinct purpose. Even closely related tools like transport_search_stop and transport_nearby_stops are differentiated by search method (by name vs. by location). No overlapping or ambiguous tools.
Naming is inconsistent: six tools use the 'transport_' prefix, four use 'get_', and one uses 'check_'. The verb-noun pattern is not consistently applied, mixing prefixes and different verb styles.
11 tools is well-scoped for a Swiss transport server. Each tool serves a clear need without being redundant or excessive, covering stops, departures, trips, disruptions, occupancy, prices, composition, and data catalog access.
The tool surface covers all major use cases for Swiss public transport: stop search, nearby stops, departures, trip planning, disruptions, occupancy forecasts, ticket pricing, train composition, and even access to the open data catalog. No obvious gaps for an assistant helping with transport queries.