{
  "markdown": "> 🇨🇭 **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide/swiss-public-data-mcp)**\n\n# 🚆 swiss-transport-mcp\n\n![Version](https://img.shields.io/badge/version-0.4.0-blue)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)\n[![MCP](https://img.shields.io/badge/MCP-Model%20Context%20Protocol-purple)](https://modelcontextprotocol.io/)\n[![Data Source](https://img.shields.io/badge/Data-opentransportdata.swiss-red)](https://opentransportdata.swiss/)\n![CI](https://github.com/malkreide/swiss-transport-mcp/actions/workflows/ci.yml/badge.svg)\n\n> 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/).\n\n[🇩🇪 Deutsche Version](README.de.md)\n\n### Demo\n\n![Demo: Claude queries disruptions and departures](docs/assets/demo.svg)\n\n---\n\n## Overview\n\n**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.\n\nThe 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.\n\n**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.\"*\n→ [More use cases by audience](EXAMPLES.md) →\n\n---\n\n## Features\n\n- 🗺️ **Journey planning** (A → B with transfers, duration, transport mode) via OJP 2.0\n- 🕐 **Real-time departures** with delays and platform information\n- 🔍 **Stop search** by name or coordinates\n- 🚨 **Live disruption alerts** (cancellations, closures) via SIRI-SX\n- 📊 **Occupancy forecasts** for trains (SBB, BLS, Thurbo, SOB)\n- 💰 **Ticket prices** including class selection\n- 🚃 **Train formation** – coaches, classes, amenities, accessibility\n- 📦 **Open data catalogue** – ~90 transport datasets via CKAN\n- 🔑 **Graceful degradation** – server starts with core tools even without optional API keys\n- ☁️ **Dual transport** – stdio for Claude Desktop, Streamable HTTP/SSE for cloud deployment\n\n---\n\n## Prerequisites\n\n- Python 3.11+\n- A free API key from [api-manager.opentransportdata.swiss](https://api-manager.opentransportdata.swiss/) (subscribe to **OJP 2.0** as minimum)\n- Optional: additional keys for SIRI-SX, Occupancy, Formation, OJP Fare\n\n---\n\n## Installation\n\n```bash\n# Clone the repository\ngit clone https://github.com/malkreide/swiss-transport-mcp.git\ncd swiss-transport-mcp\n\n# Install\npip install -e .\n```\n\nOr with `uvx` (no permanent installation):\n\n```bash\nuvx swiss-transport-mcp\n```\n\n---\n\n## Quickstart\n\n```bash\n# Set the minimum required key (OJP core tools)\nexport TRANSPORT_API_KEY=your_key_here\n\n# Start the server (stdio mode for Claude Desktop)\nswiss-transport-mcp\n```\n\nTry it immediately in Claude Desktop:\n\n> *\"What are the next departures from Zurich Stadelhofen?\"*\n> *\"How do I get from Wädenswil to Bern by train?\"*\n\n---\n\n## Configuration\n\n### Environment Variables\n\n| Variable | API | Required |\n|---|---|---|\n| `TRANSPORT_API_KEY` | Unified key for OJP + CKAN | ✅ (or individual keys) |\n| `TRANSPORT_OJP_API_KEY` | OJP 2.0 Journey Planner | Optional (override) |\n| `TRANSPORT_CKAN_API_KEY` | CKAN data catalogue | Optional (separate subscription) |\n| `SIRI_SX_API_KEY` | Disruption alerts (SIRI-SX) | Optional |\n| `OCCUPANCY_API_KEY` | Occupancy forecast | Optional |\n| `FORMATION_API_KEY` | Train formation | Optional |\n| `OJP_FARE_API_KEY` | Ticket prices (OJP Fare) | Optional |\n\n> APIs without a key are silently disabled – the server starts fine with just the 6 core tools.\n\n**Operational / security variables:**\n\n| Variable | Effect | Default |\n|---|---|---|\n| `MCP_ENV` / `ENV` | Process environment. Must be `dev`/`development`/`local`/`test` to allow disabling TLS verification. | _(unset → production)_ |\n| `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` |\n| `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` |\n| `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` |\n| `LOG_FORMAT` | `json` for structured logs (RFC 5424 severity); anything else for human-readable text. Always written to stderr. | `text` |\n| `OTEL_TRACES_ENABLED` | `1` to enable OpenTelemetry tracing (requires the `otel` extra: `pip install 'swiss-transport-mcp[otel]'`). No-op otherwise. | _(off)_ |\n| `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)_ |\n| `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)_ |\n\n> 🔒 **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).\n\n### Claude Desktop Configuration\n\n**Minimal (core tools only):**\n\n```json\n{\n  \"mcpServers\": {\n    \"swiss-transport\": {\n      \"command\": \"swiss-transport-mcp\",\n      \"env\": {\n        \"TRANSPORT_API_KEY\": \"your_key_here\"\n      }\n    }\n  }\n}\n```\n\n**Full (all 11 tools):**\n\n```json\n{\n  \"mcpServers\": {\n    \"swiss-transport\": {\n      \"command\": \"swiss-transport-mcp\",\n      \"env\": {\n        \"TRANSPORT_API_KEY\": \"your_ojp_key_here\",\n        \"SIRI_SX_API_KEY\": \"your_siri_key_here\",\n        \"OCCUPANCY_API_KEY\": \"your_occupancy_key_here\",\n        \"FORMATION_API_KEY\": \"your_formation_key_here\",\n        \"OJP_FARE_API_KEY\": \"your_fare_key_here\"\n      }\n    }\n  }\n}\n```\n\n**Config file locations:**\n- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`\n- Windows: `%APPDATA%\\Claude\\claude_desktop_config.json`\n\n### Cloud Deployment (Streamable HTTP)\n\nFor 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**.\n\n| `MCP_TRANSPORT` | Use | Endpoint |\n|---|---|---|\n| `stdio` (default) | Local Claude Desktop subprocess | – |\n| `streamable-http` (or `http`) | Cloud / container (recommended) | `/mcp` |\n| `sse` | Legacy browser transport (deprecated) | `/sse` |\n\n**Docker (recommended):**\n\n```bash\n# Build + run with explicit resource limits (see docker-compose.yml)\nTRANSPORT_API_KEY=xxx docker compose up --build\n# → http://127.0.0.1:8000/mcp\n```\n\nThe 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.\n\n**Render.com:**\n1. Push/fork the repository to GitHub\n2. On [render.com](https://render.com): New Web Service → connect GitHub repo (Docker runtime)\n3. Set env `MCP_TRANSPORT=streamable-http` **and `MCP_HOST=0.0.0.0`**\n4. In claude.ai under Settings → MCP Servers, add: `https://your-app.onrender.com/mcp`\n\n> 💡 *\"stdio for the developer laptop, Streamable HTTP for the cloud.\"*\n\n**Scaling horizontally:** run with `MCP_STATELESS=1`. In stateless mode the\nserver keeps no per-session state, so any instance can serve any request and a\nplain round-robin load balancer suffices — **no sticky sessions / `Mcp-Session-Id`\naffinity required**. If you need stateful streaming instead, route by\n`Mcp-Session-Id` at the edge LB (e.g. HAProxy stick-tables) so each session\nstays pinned to one instance.\n\n> ⚠️ **Binding:** In a network transport the server binds to `127.0.0.1` by\n> default so a locally started server is **not** exposed to your whole network\n> (e.g. public Wi-Fi). Set `MCP_HOST=0.0.0.0` **only** in a container/cloud\n> environment where binding to all interfaces is intended (the Docker image\n> does this for you).\n\n---\n\n## Available Tools\n\n### Core Tools (OJP 2.0 / CKAN)\n\n| Tool | Description | Data Source |\n|---|---|---|\n| `transport_search_stop` | Search stops/stations by name | OJP 2.0 |\n| `transport_nearby_stops` | Find nearby stops by coordinates | OJP 2.0 |\n| `transport_departures` | Real-time departure board with delays & platforms | OJP 2.0 |\n| `transport_trip_plan` | Plan journey A → B with transfers, duration, mode | OJP 2.0 |\n| `transport_search_datasets` | Search open data catalogue (~90 datasets) | CKAN¹ |\n| `transport_get_dataset` | Get full details of a specific dataset | CKAN¹ |\n\n¹ *CKAN tools require a separate subscription in the [API Manager](https://api-manager.opentransportdata.swiss/).*\n\n### Extension Tools (optional API keys)\n\n| Tool | Description | Data Source |\n|---|---|---|\n| `get_transport_disruptions` | 🚨 Live disruptions, cancellations, line closures | SIRI-SX |\n| `get_train_occupancy` | 📊 Occupancy forecast for specific trains | Occupancy JSON |\n| `get_ticket_price` | 💰 Ticket prices for connections | OJP Fare |\n| `get_train_composition` | 🚃 Train formation, classes, accessibility | Formation REST |\n| `check_transport_api_status` | 🔍 Health check for all configured APIs | All |\n\n### Example Use Cases\n\n| Query | Tool |\n|---|---|\n| *\"Next trains from Zurich Stadelhofen?\"* | `transport_departures` |\n| *\"Plan a trip for 25 students from Zurich to Winterthur Technorama\"* | `transport_trip_plan` |\n| *\"Any disruptions between Zurich and Bern?\"* | `get_transport_disruptions` |\n| *\"How full is IC 1009 today?\"* | `get_train_occupancy` |\n| *\"What does a ticket from Wädenswil to Bern cost?\"* | `get_ticket_price` |\n| *\"Does IC 708 have a dining car?\"* | `get_train_composition` |\n| *\"Which stops are near Langstrasse 100?\"* | `transport_nearby_stops` |\n\n---\n\n## Architecture\n\n```\n┌─────────────────┐     ┌───────────────────────────┐     ┌──────────────────────────┐\n│   Claude / AI   │────▶│   Swiss Transport MCP     │────▶│  opentransportdata.swiss  │\n│   (MCP Host)    │◀────│   (MCP Server)            │◀────│                          │\n└─────────────────┘     │                           │     │  OJP 2.0  (XML/SOAP)     │\n                        │  11 Tools · 2 Resources   │     │  SIRI-SX  (XML)          │\n                        │  Stdio | SSE              │     │  CKAN     (REST/JSON)    │\n                        │                           │     │  Occupancy(REST/JSON)    │\n                        │  Core:                    │     │  Formation(REST/JSON)    │\n                        │   api_client + ojp_client │     │  OJP Fare (XML/SOAP)     │\n                        │  Extensions:              │     └──────────────────────────┘\n                        │   siri_sx, occupancy,     │\n                        │   ojp_fare, formation     │\n                        └───────────────────────────┘\n```\n\n### Infrastructure Components\n\n| Component | Metaphor | Function |\n|---|---|---|\n| RateLimiter | Bouncer | Limits API calls per time window |\n| SimpleCache | Whiteboard | Caches responses for repeated queries |\n| APIClient | Switchboard | Handles auth, redirects, errors centrally |\n| APIConfig | Business card | Key, URL, limits per API |\n\n### Caching Strategy\n\n| API | Cache TTL | Rationale |\n|---|---|---|\n| SIRI-SX | 120s | Disruptions don't change every second |\n| Occupancy | 300s | Forecasts are day-based |\n| Formation | 600s | Train composition is stable for the day |\n| OJP Fare | 1800s | Prices rarely change intraday |\n\n---\n\n## Project Structure\n\n```\nswiss-transport-mcp/\n├── src/swiss_transport_mcp/        # Main package\n│   ├── server.py                   # FastMCP server, tool definitions\n│   ├── api_client.py               # Core OJP + CKAN client\n│   ├── ojp_client.py               # OJP 2.0 XML/SOAP parser\n│   ├── api_infrastructure.py       # RateLimiter, SimpleCache, APIClient\n│   ├── siri_sx.py                  # Disruption alerts\n│   ├── occupancy.py                # Occupancy forecasts\n│   ├── ojp_fare.py                 # Ticket prices\n│   └── formation.py                # Train formation\n├── tests/\n│   └── test_server.py              # Unit + integration tests\n├── .github/workflows/ci.yml        # GitHub Actions (Python 3.11/3.12/3.13)\n├── claude_desktop_config.json       # Example Claude Desktop config\n├── pyproject.toml\n├── CHANGELOG.md\n├── CONTRIBUTING.md\n├── LICENSE\n├── README.md                        # This file (English)\n└── README.de.md                     # German version\n```\n\n---\n\n## Safety & Limits\n\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n\n### Before you install (consent)\n\nAdding this server to your MCP client lets the connected AI model issue Swiss\npublic-transport queries on your behalf, using **your** opentransportdata.swiss\nAPI key, and make outbound HTTPS requests to `opentransportdata.swiss`. Nothing\nis written upstream and no PII is stored, but you should review the tool list\nabove and confirm you are comfortable granting that access before configuring\nthe server.\n\n### Running the HTTP transport safely (no built-in auth)\n\nThe server has **no authentication of its own**. When you run the Streamable\nHTTP transport (`MCP_TRANSPORT=streamable-http`), the MCP SDK issues a\ncryptographically random `Mcp-Session-Id` per session, but there is no user\nidentity bound to it. Therefore:\n\n- **Do not expose a no-auth instance directly to the public internet.** Put it\n  behind an authenticating reverse proxy (OAuth2 proxy, mTLS, or your platform's\n  access control), or restrict it to a trusted network.\n- Keep the default `MCP_HOST=127.0.0.1` for local use; only bind `0.0.0.0`\n  inside a controlled container/cloud environment (see Deployment).\n- Scope `MCP_CORS_ORIGINS` to the origins you actually trust.\n- Set `MCP_ALLOWED_HOSTS` whenever you bind beyond loopback. It guards against\n  **DNS rebinding**: a page on your network resolves its own hostname to this\n  server's address and then talks to it from the browser. CORS does not stop\n  that — from the browser's point of view the request is same-origin — and\n  neither would a token, since the attacking page runs in a context that holds\n  one. Only the `Host` check does. Left unset the check stays off, which is the\n  right default only when something in front of the server validates `Host`.\n\nSee [`SECURITY.md`](SECURITY.md) for the full security posture and the\naccepted-risk decisions (gateway-level controls).\n\n---\n\n## Known Limitations\n\n- **OJP Fare:** Discounts (Halbtax, GA, regional passes) are not always reflected\n- **Formation:** Stop-based data is only available for TODAY (real-time dependency)\n- **Occupancy:** SBB, BLS, Thurbo and SOB only – no private railways\n- **SIRI-SX:** Returns ALL Swiss disruptions → use the `filter_text` parameter\n- **CKAN:** Requires a separate subscription in the API Manager\n\n---\n\n## MCP Protocol Version\n\nThis server speaks **two protocol eras** over the same endpoint. The client's\nfirst request on a connection decides which one applies; a later claim from the\nother era is refused.\n\n| Era | Revision | Who reaches it |\n|---|---|---|\n| `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. |\n| Per-request envelope | **`2026-07-28`** | A request carrying the `2026-07-28` `_meta` envelope opens a modern connection. |\n\nBoth revisions are pinned in\n[`tests/test_protocol_version.py`](tests/test_protocol_version.py) and asserted\nagainst the installed SDK, so a Dependabot bump of `mcp` cannot move either one\nsilently. This server builds no ASGI app to send an `initialize` through, so\nthe gate asserts the SDK constants rather than a measured response — the\nweaker form, named rather than left unsaid.\n\nNote that the SDK's `LATEST_PROTOCOL_VERSION` is an alias for the **modern**\nera, not for the handshake era — pinning against it alone would leave the era\nthat current clients actually negotiate free to drift.\n\n**Update policy.** When the gate fails, do not edit the constant blindly: read\nthe spec changelog between the two revisions, verify the server still behaves,\nthen move the constant, this section, `README.de.md` and\n[`CHANGELOG.md`](CHANGELOG.md) together.\n\n---\n\n## Testing\n\n```bash\n# Unit tests (no API key required)\nPYTHONPATH=src pytest tests/ -m \"not live\"\n\n# Integration tests (API key required)\nTRANSPORT_API_KEY=xxx pytest tests/ -m \"live\"\n```\n\n### Where the test data comes from\n\nAll four upstream APIs need a Bearer token from the opentransportdata.swiss\nAPI-Manager, so CI cannot record a real response — measured and kept in\n`tests/fixtures/upstream_auth_probe.json`. The XML payloads in the test modules\nare therefore **hand-written, not recorded**, and cannot refute the production\ncode: both come from the same reading of the docs, and where both are wrong\nthey are wrong together.\n\nWhat *can* be recorded is the contract. OJP 2.0 is a CEN standard\n(CEN/TS 17118) with a public XML schema, and `tests/fixtures/ojp_2_0_contract.json`\nis a dated index derived from it — element names, the structures this server\nbuilds on, the enumerations it sends as values, plus the SHA-256 of every\nschema file read. `tests/test_ojp_contract.py` holds the requests and parsers\nagainst it. The schema itself is deliberately **not** vendored: the source\nrepository carries no licence file.\n\n```bash\npython scripts/record_fixtures.py          # re-record\npython scripts/record_fixtures.py --check  # recompute against the pinned tag\n```\n\nSource, date, selection rule and hashes: [`tests/fixtures/PROVENANCE.md`](tests/fixtures/PROVENANCE.md).\n\n---\n\n## Changelog\n\nSee [CHANGELOG.md](CHANGELOG.md)\n\n---\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md)\n\n---\n\n## Security\n\nSee [SECURITY.md](SECURITY.md) ([Deutsch](SECURITY.de.md)) for the security\nposture and how to report a vulnerability.\n\n---\n\n## License\n\nMIT License — see [LICENSE](LICENSE)\n\n---\n\n## Author\n\nHayal Oezkan · [github.com/malkreide](https://github.com/malkreide)\n\n---\n\n## Credits & Related Projects\n\n- **Data:** [opentransportdata.swiss](https://opentransportdata.swiss/) – Federal Office of Transport (FOT/BAV)\n- **Protocol:** [Model Context Protocol](https://modelcontextprotocol.io/) – Anthropic / Linux Foundation\n- **Related:** [zurich-opendata-mcp](https://github.com/malkreide/zurich-opendata-mcp) – MCP server for Zurich city open data\n- **Portfolio:** [Swiss Public Data MCP Portfolio](https://github.com/malkreide)\n\n<!-- mcp-name: io.github.malkreide/swiss-transport-mcp -->\n\n<!-- BEGIN GENERATED: install -->\n## Installation\n\nRun 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`):\n\n```json\n{\n  \"mcpServers\": {\n    \"swiss-transport-mcp\": {\n      \"command\": \"uvx\",\n      \"args\": [\n        \"swiss-transport-mcp\"\n      ]\n    }\n  }\n}\n```\n<!-- END GENERATED: install -->\n",
  "bytes": 21438,
  "sha": "6849d7f802c67ca6ddf15af3c9fd380319960f6fe956018e11040dd2d4a5169e",
  "repo_slug": "malkreide/swiss-transport-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_malkreide_swiss_transport_mcp_f57fadb1/readme"
}