Skip to main content
Glama

perron

An MCP server for Dutch Railways (NS) travel information. It gives Claude, or any other MCP client, the journey planner, live departure and arrival boards, disruptions and engineering works, fares, train composition and crowding, station facilities, and the last mile: shared scooters nearby, where you may park them, and GVB metro and ferry departures.

Not an official NS project. perron uses the public NS API portal with your own API key. NS is not involved and does not support it.

Tools

Tool

What it answers

search_stations

Find a station by name, synonym, NS code or UIC code, typo-tolerant

plan_trip

Journey options with live delays, track changes, transfers, crowding and the fare

trip_details

Refresh one plan_trip option by its tripId: live status plus every intermediate stop

trip_price

Domestic fare: single or return, 1st or 2nd class, adults and children, joint-journey discount

departures

Live departure board: delays, destinations, tracks and track changes, cancellations, notices

arrivals

Live arrival board

train_journey

All stops of one train with times, tracks, delays and crowding

disruptions

Disruptions, engineering works and calamities, nationwide or per station, with details by id

train_composition

Rolling stock, units, coaches, length, facilities, first-class seats, shortened or not, crowding

station_facilities

OV-fiets availability, lift status, and facilities and shops with today's opening hours

scooters_nearby

Available shared scooters (default: Check) near an address, station or place, nearest first

nearest_zone

Whether a scooter may be parked there, or how far to the nearest parking zone and on which street

ov_departures

GVB metro 52 and IJ ferry (F2, F3, F4) departures, with live delays for the metro

Every tool accepts station names ("Utrecht", "Den Bosch"), NS codes (UT) and UIC codes (8400621). When a name fits several stations, the tool returns an error listing them so the assistant can pick one.

Responses are compact JSON plus a one-line-per-item text summary. Times are in Europe/Amsterdam, HH:mm, with the date only when it is not today. Delays are whole minutes, prices are euros, and track changes, cancellations and disruptions are separate fields rather than buried in text.

Last mile

scooters_nearby and nearest_zone take a locatie (address, station or place) or lat/lon, and every answer echoes the coordinates, address and source it used, so a wrong geocode is visible. A name with "station" in it, or an exact station name, uses the NS station's own coordinates. Anything else goes to the PDOK Locatieserver; when PDOK's hit does not contain the words asked for, perron tries Nominatim (OpenStreetMap, at most one request a second). Geocodes are kept for good in SQLite under DATA_DIR.

  • Vehicles come from the open Available Vehicles API of Dashboard Deelmobiliteit (CROW): every unrented shared vehicle in the country, several megabytes, fetched only when a tool needs it and at most once per VEHICLES_CACHE_SECONDS, kept in memory as compact typed columns. Reserved and disabled vehicles are left out. vehicle_id changes after every ride, so it is not returned.

  • Parking zones are the operators' service areas from the MDS service_area API, per municipality (taken from PDOK) and operator, cached 24 hours. Outside a zone, perron returns the distance to the nearest zone edge as the crow flies and a point a few metres inside it, with the street from PDOK.

  • Metro and ferry times come from the OVapi GTFS feed: once a day perron downloads the national zip (~230 MB), keeps only the OV_AGENCY metro and ferry lines in SQLite under DATA_DIR, and deletes the zip. Until the first import is done (a minute or two after start), ov_departures asks to try again later. Delays and cancellations come from GTFS-Realtime tripUpdates.pb, cached 30 seconds; the ferries have none there, so their departures say realtime: false.

Related MCP server: ns-bridge

No authentication: put it behind something

perron has no auth of its own. Anyone who can reach /mcp can spend your NS API key's rate limit. Run it on localhost, or behind a reverse proxy or gateway that authenticates (an OAuth gateway, Cloudflare Access, Tailscale, a VPN, …). Do not expose it to the internet as is.

Getting an NS API key

  1. Create an account at apiportal.ns.nl.

  2. Under Products, subscribe to Ns-App. That one product covers every API perron uses.

  3. Your profile then shows a primary and a secondary key. Use either one as NS_API_KEY; keep the other for rotating without downtime.

The key allows 300 requests per 5 minutes. perron stays under that with a token bucket, respects Retry-After on a 429, and caches responses (see below).

Configuration

Variable

Default

Purpose

NS_API_KEY

—

Required. Subscription key for the Ns-App product.

PORT

3000

HTTP port.

HOST

0.0.0.0

Interface to listen on. Use 127.0.0.1 to keep it local.

BASE_PATH

(empty)

Prefix when a proxy forwards a sub-path unchanged, e.g. /ns.

REDIS_URL

(empty)

Redis for the cache, e.g. redis://redis:6379. Without it: in memory.

ICON_URL

(empty)

Icon shown by clients, as an https URL or data: URI. Empty: the built-in train icon.

VEHICLES_CACHE_SECONDS

60

How long one nationwide shared-vehicle download is reused (15–600).

DATA_DIR

./data

GVB timetable database and geocode cache. /app/data in the image; mount a volume there.

OV_AGENCY

GVB

GTFS agency for ov_departures.

OV_LINES

(empty)

Comma-separated lines to keep, e.g. 52,F2,F3,F4. Empty: every metro and ferry line.

GTFS_URL

OVapi

Static GTFS zip, downloaded once a day.

GTFS_RT_URL

OVapi

GTFS-Realtime trip updates.

LOG_LEVEL

info

fatal, error, warn, info, debug, trace or silent.

Endpoints: POST /mcp (Streamable HTTP, stateless) and GET /healthz. With a BASE_PATH both are also served under that prefix.

Running

Docker

docker run --rm -p 127.0.0.1:3000:3000 -e NS_API_KEY=your-key \
  -v perron-data:/app/data ghcr.io/ultimatelemon/perron:latest

The image runs as a non-root user and has a health check on /healthz. The volume keeps the timetable and geocodes across restarts; without it the timetable is downloaded again on every start. The daily import needs about 300 MB of free disk while it runs.

npm run check:last-mile -- <url> checks the last-mile tools against a running server with live data, using places measured by hand.

From source

Needs Node 24.

git clone https://github.com/ultimatelemon/perron.git
cd perron
npm ci
cp .env.example .env   # and fill in NS_API_KEY
npm run dev            # http://localhost:3000/mcp

npm run build && npm start runs the compiled version.

Connecting a client

Claude Code

claude mcp add --transport http perron http://localhost:3000/mcp

Claude Desktop and claude.ai

Add a custom connector with the public URL of your deployment. These clients reach the server from the internet, so this only works behind an authenticating gateway that speaks MCP's OAuth flow.

MCP Inspector

npx @modelcontextprotocol/inspector

Choose transport "Streamable HTTP" and URL http://localhost:3000/mcp.

Caching

Data

Kept for

Station list

24 hours

Station facilities, fares

1 hour

OV-fiets availability, lift status

2 minutes

Disruptions

1 minute

Trips, boards, journeys, composition

30 seconds

Development

npm test              # vitest, against recorded responses in test/fixtures
npm run lint
npm run typecheck
npm run format:check
npm run probe         # calls every NS endpoint once and saves raw responses to fixtures/
node scripts/smoke.ts http://localhost:3000/mcp   # calls every tool on a running server

The NS OpenAPI specs are not in this repository. Download them from the API portal into specs/, which is ignored by git. A few things the specs do not tell you, found by calling the real API:

  • /reisinformatie-api/api/v3/price returns a 500 for every request on the Ns-App product; perron uses /api/v2/price, which returns the same fares.

  • The Virtual Train API spec lists both /v1/... and /api/v1/...; only /v1/... answers.

  • The Places API rejects Accept-Language: *, which Node's fetch sends by default, so every request sets a language explicitly.

  • Departures and arrivals use /api/v2/departures and /api/v2/arrivals. Their successor, the timetable API, is not part of the Ns-App product. Both calls live in one function in src/ns/reisinformatie.ts.

The parts that know nothing about NS live in their own packages: @ultimatelemon-eu/fetch-client (rate limiting, Retry-After, retries) and @ultimatelemon-eu/mcp-http (the Streamable HTTP server). src/lib/ keeps the cache, with Redis or in-memory storage.

License

MIT

Related MCP Connectors

Related MCP Servers