uktrains
by aaryan-gulia
README.md
# uktrains
An agent-first UK train journey CLI and stdio MCP server. It's built for a
personal LLM agent to search journeys, compare fares, and hand the human a
pre-filled booking link — the tool never takes payment and never claims a
booking was made. Fulfilment means one thing: a correct, pre-filled retailer
checkout link the human taps to pay.
JSON is the default output everywhere (CLI and MCP alike); pass `--pretty`
on the CLI for a human-readable, indented rendering instead of raw JSON.
## What it does today
- Resolves UK station names/aliases to CRS codes, including group stations
("Manchester", "London").
- Builds a journey intent from `--from`/`--to` plus `--depart-after` or
`--arrive-by` (and the return-leg equivalents), and produces a pre-filled
National Rail Enquiries journey planner booking link — the only live
provider today (see "Capability tiers" below for the dormant Trainline
provider). A return leg with `arrive-by` is flagged with a note: NRE's
URL can't pre-fill that constraint on the return leg, so it's pre-filled
as depart-after and the caller should verify the return train actually
arrives by the deadline on the retail page.
- At Tier 1, fetches BR Fares flow-level fares (railcard-aware), sorted by
price (the `cheapest`/`fastest`/`balanced` `rankBy` modes are accepted
today but don't change ordering yet — journey-level ranking arrives with
Tier 2), including return-vs-two-singles comparison.
- `doctor` reports which tier is active. The BR Fares key is validated live;
the RDM key is only reported as stored (its live validation arrives with
Tier 2, per `doctor`'s own output).
- `setup` walks an agent (or a human) through the two optional signups.
`live` (CLI) and `live_status` (MCP) exist as structured stubs today — they
return a `TIER_UNAVAILABLE` error rather than pretending to have live data.
If both keys are already stored, the recovery text says plainly that live
departures aren't implemented in this build yet (pointing back at `setup`
would be a dead end); otherwise it points at `uktrains setup` to unlock
Tier 2. Live departures/delays and richer in-tool journey planning land in a
later plan (Tier 2).
## Capability tiers
No free API combines journey planning with fares, and Darwin live data
requires a Rail Data Marketplace account with human approval — so the tool
degrades gracefully instead of demanding setup upfront.
| Tier | Requires | Unlocks |
|---|---|---|
| **0** | Nothing (bundled station data) | Station resolution, journey intent construction, a pre-filled National Rail Enquiries booking link. The core "get me a train" flow works the moment you build it — the human sees times and fares on the retail page. |
| **1** | BR Fares API key (free, non-commercial, ~minutes to register, 100 calls/day) | In-tool fare quotes with railcard discounts, sorted by price — the `cheapest`/`fastest`/`balanced` `rankBy` modes are accepted today but don't change ordering yet (all sort by price; journey-level ranking arrives with Tier 2) — compare return vs. two singles, before handing over a link. The parser is validated against docs-authored fixtures; run `npx tsx scripts/record-brfares.ts KGX YRK <key>` (and again with a 4th railcard argument for the `-rlc` fixture) with your first real key to re-record a live response against the ONLY station pair the test suite's fixtures currently cover, then run the test suite (`npm test`) to confirm the shape still matches — the script itself only writes the fixture file, it does not validate it, and it writes to `tests/fixtures/brfares-<orig>-<dest>.json`, so recording any OTHER pair writes a file no test loads and `npm test` passing tells you nothing about it — if EVERY record fails shape validation, fares present as `UPSTREAM_DOWN`; if every record is structurally valid but none maps to a known fare category, the result reports `fareError: {code: 'NO_FARES'}` with top-level `droppedRecords`/`unrecognisedCategories` fields (there's no surviving `fares` object to hang them off); a partial drift still returns the surviving fares, disclosed via a note plus machine-readable `fares.droppedRecords`/`fares.unrecognisedCategories` fields so an incomplete list is never presented as complete. |
| **2** | RDM account + "LDBWS – Public" subscription (free, 1–3 day approval) | Live departures, delays, platforms, cancellations; real in-tool journey planning with a realtime overlay. **Not implemented yet** — arrives in the next plan. |
Run `uktrains doctor` at any time to see the active tier and exactly what's
missing to reach the next one.
**National Rail Enquiries is the sole live booking-link provider today.**
The code also carries a Trainline provider, but it's dormant: a Trainline
link needs a verified station-ID mapping (URN) for BOTH the origin and
destination, and `data/trainline-urns.json` currently has exactly one
verified entry (KGX) — so no station pair can produce a Trainline link yet.
The code path and its fixture-backed tests stay in place for when more URNs
get recorded; until then, expect NRE-only links.
## Install
### For agents
**If you are an LLM agent: read [`AGENTS.md`](AGENTS.md) — it is written for you** and covers install, MCP registration, setup shepherding, and how to present options to your human.
```bash
git clone https://github.com/aaryan-gulia/uktrains.git
cd uktrains
npm install
npm run build
```
The built CLI is at `dist/cli/index.js` and runnable directly:
```bash
node dist/cli/index.js search --from KGX --to YRK --depart-after 2031-08-01T09:00:00+01:00
```
Or invoke it via `npx` once linked/published, or point an MCP client at the
built entry point (below) — no global install required.
### For humans
Same steps, then optionally `npm link` (or install globally once published)
so the `uktrains` command is on `PATH`.
Requires Node.js 22.12+ (commander@15's own floor).
## MCP client config
If `uktrains` is on `PATH` (e.g. via `npm link`):
```json
{
"command": "uktrains",
"args": ["mcp"]
}
```
From a source checkout, without installing globally:
```json
{
"command": "node",
"args": ["/absolute/path/to/uktrains/dist/cli/index.js", "mcp"]
}
```
The server exposes four tools: `search_journeys`, `get_booking_link`,
`live_status` (Tier 2 stub), and `lookup_station`. Tool descriptions are
normative — they tell the calling agent, for example, that `arriveBy` is a
*station* arrival time and that the agent is responsible for adding transit
and buffer time to the actual appointment.
## CLI examples
```bash
# Search a journey (JSON by default; --pretty for a human-readable rendering)
uktrains search --from KGX --to YRK --depart-after 2031-08-01T09:00:00+01:00 --pretty
# Same, but restrict fares to a railcard (fares are always price-sorted;
# --rank-by is accepted but doesn't change ordering yet — see "Capability tiers")
uktrains search --from "London" --to "Manchester" \
--depart-after 2031-08-01T09:00:00+01:00 \
--railcard 16-25 --rank-by cheapest
# Booking links only, no fare lookup
uktrains link --from KGX --to YRK --depart-after 2031-08-01T09:00:00+01:00
# Resolve a station name or CRS code
uktrains stations york
# Agent-drivable setup: machine-readable next steps for both signups
uktrains setup --json
# Store a key once the human has completed a signup
uktrains setup --set brfares=<key>
# Check active tier and validate configured keys
uktrains doctor
```
All time flags (`--depart-after`, `--arrive-by`, `--return-depart-after`,
`--return-arrive-by`) take ISO-8601 timestamps and **require an explicit
timezone offset** (e.g. `2031-08-01T09:00:00+01:00` or `Z`). Offset-less
times are rejected with a structured `INVALID_INPUT` error — silently
guessing a timezone produces silently wrong booking links.
A time may not be in the past (a time more than ~60 seconds behind the
server's clock is rejected as `INVALID_INPUT`); a stamp generated as "right
now" — including minute-precision ISO-8601, which truncates to the current
minute — is tolerated even though a few hundred milliseconds elapse between
generating it and validating it.
`--adults <n>` (CLI) / `adults` (MCP `search_journeys`/`get_booking_link`)
sets the produced booking link's `adults=` query param — an integer from 1
to 9, default 1. It does NOT change any quoted fare: BR Fares quotes are
always PER PASSENGER, never multiplied or annotated by party size — for
`adults > 1`, `search`/`search_journeys` adds a note reminding the caller
to multiply the quote for the total, and `link`/`get_booking_link` adds a
note confirming only the link (not a fare) reflects the passenger count.
## Setup walkthrough
`uktrains setup --json` (the default) prints machine-readable steps so an
agent can shepherd the human through both signups in the background, while
Tier 0 already delivers working booking links:
1. **BR Fares** (Tier 1) — open `https://www.brfares.com/api/login.html`,
register a free non-commercial account, obtain an API key, then run
`uktrains setup --set brfares=<key>`.
2. **Rail Data Marketplace** (Tier 2, live data — not yet consumed by this
build) — open `https://raildata.org.uk`, create an individual account
(approval typically takes 1–3 working days), subscribe to the free
"LDBWS – Public" product, then run `uktrains setup --set rdm=<key>`.
Config is stored at `~/.config/uktrains/config.json` and holds only
rail-domain state: API keys, railcards, and ranking preferences. No home
station, no calendar integration — the calling agent already knows that.
If the config file exists but part of it fails to parse (a corrupted
`keys` or `prefs` section), that section is silently defaulted so the tool
still answers — but every CLI/MCP result, success or error, carries a
sibling `configWarning: string` field naming what happened, so a
corrupted config is never invisible. (One shape exception: `setup --json`'s
output is a bare array, so `configWarning` is written to stderr as its own
JSON line instead of being folded into stdout.)
`setup --set` also accepts a `--force` flag, for two situations: (1) BR
Fares key validation failed because the NETWORK request itself failed (not
because the key was rejected) — `--force` stores the value unvalidated
instead of refusing to save a possibly-good key; (2) the on-disk config
did not load CLEANLY, for any of these reasons — `--force` allows the
overwrite that would otherwise be refused (the old file is backed up to
`<path>.bak` first when it can still be read):
- the file isn't valid JSON at all
- the file's top-level JSON is valid but isn't an object (an array, string, number, or `null`)
- the file exists but can't be read (permissions, etc.)
- the `keys` section fails its schema and would be reset to empty — even
when `prefs` is fine
- the `prefs` section fails its schema and would be reset to defaults —
even when `keys` is fine
- both `keys` and `prefs` fail together
In every one of these, writing without `--force` would silently discard
whatever was still on disk in the failing section(s) (which may include a
stored API key), so `setup --set` refuses by default. Without `--force`,
`setup --set` refuses and reports which of the above happened.
## Attribution and licensing notes
- The two link-bearing results — `search`/`search_journeys` and
`link`/`get_booking_link` — carry "Powered by National Rail Enquiries"
per NRE's OGL terms. `stations`/`lookup_station`, `live`/`live_status`,
`setup`, `doctor`, and error envelopes on either surface carry no
attribution (there is no NRE-derived data in those results to attribute).
- Each user brings their own API keys. RDM and BR Fares agreements are
per-account — this tool never shares keys, and never redistributes the
per-account data those keys unlock (fares, live departures).
- **Station data is ODbL, not MIT.** The bundled dataset
(`data/stations.json`, regenerated by `scripts/fetch-stations.ts`) is
derived from
[davwheat/uk-railway-stations](https://github.com/davwheat/uk-railway-stations),
itself derived from the
[Trainline EU stations dataset](https://github.com/trainline-eu/stations),
and is distributed under the
[Open Database License (ODbL) v1.0](https://opendatacommons.org/licenses/odbl/1-0/)
with attribution to David Wheatley (davwheat), Trainline EU, and their
upstream sources. Our modifications (field mapping, group pseudo-stations,
curated aliases) are published in this repository per ODbL share-alike —
full notice in [`data/LICENSE-DATA.md`](data/LICENSE-DATA.md). The MIT
licence covers uktrains' own code only.
- The BR Fares free tier is registered for **non-commercial** use only.
- Fares are flow-level (origin→destination, railcard-adjusted), not
per-train, and are stamped indicative and time-stamped at quote time.
Advance fares are additionally flagged subject to availability, since
Advance quota lives in the closed National Reservation Service that no
open source exposes. Re-search before presenting a fare older than
~15 minutes.
- The tool never takes payment and never states or implies that a booking
was completed — only that a booking link was produced.
- Every booking link carries a `notCarried: string[]` field (typically
`["railcards", "seatPreferences", "travelClass"]`) naming booking-page
preferences the retailer's URL does NOT pre-fill — including railcards,
so a Tier-1 in-tool fare quote that already reflects a railcard discount
can still land the human on the retail page at the undiscounted price
unless they (re)apply the railcard there. Say so when handing over a
link with a railcard-discounted quote attached.
## Further reading
- Design spec: [`docs/superpowers/specs/2026-07-31-uktrains-design.md`](https://github.com/aaryan-gulia/uktrains/blob/main/docs/superpowers/specs/2026-07-31-uktrains-design.md)
- Implementation plan: [`docs/superpowers/plans/2026-07-31-uktrains-core-tier0-1.md`](https://github.com/aaryan-gulia/uktrains/blob/main/docs/superpowers/plans/2026-07-31-uktrains-core-tier0-1.md)
- Agent guide: [`AGENTS.md`](AGENTS.md)
<!-- UKT-168: `docs/` is excluded from package.json's `files`, so a relative
link to it 404s for anyone reading this README on npm (the published
tarball never contains docs/). The two links above point at GitHub
instead, where docs/ actually lives; AGENTS.md is unaffected since it
IS in `files` and ships in the package. -->
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues