{
  "markdown": "# Lombardia Trains MCP\n\n<!-- mcp-name: io.github.DenisRaimondi/lombardia-trains -->\n\nAn MCP server that answers questions about Lombardy trains: departure and\narrival boards, live delays, platforms, stop-by-stop progress, cancellations\nand crowding — and plans journeys with a change, from the regional timetable\nRegione Lombardia publishes as open data. It reads the public ViaggiaTreno\n(RFI/Trenitalia) and Trenord APIs. No API key, no account, no scraping.\n\n```\n> is the next train to Malpensa on time?\n\nDepartures — MILANO CADORNA (S01066), 21:29\n  21:23  REG787    SEVESO                       +2'  platform 9\n  21:26  REG387    MALPENSA AEROPORTO TERMINA   -2'  platform 1\n  21:32  REG887    SARONNO                       0'  platform 6\n```\n\n## Install\n\n```bash\ndotnet tool install -g LombardiaTrains.Mcp\n```\n\nThen register it with your MCP client. For Claude Desktop, in\n`claude_desktop_config.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"lombardia-trains\": {\n      \"command\": \"lombardia-trains-mcp\"\n    }\n  }\n}\n```\n\nFor Claude Code:\n\n```bash\nclaude mcp add lombardia-trains -- lombardia-trains-mcp\n```\n\n## Tools\n\n| Tool | What it answers |\n|---|---|\n| `now` | \"what time and day is it in Italy?\" |\n| `search_station` | \"what is the station code for Milano Centrale?\" |\n| `get_departures` | \"what is leaving Milano Cadorna on Saturday morning?\" |\n| `get_arrivals` | \"when does the train from Varese get in?\" |\n| `find_connection` | \"which direct trains go from Milano Cadorna to Como?\" |\n| `find_journey` | \"how do I get from Castellanza to Como Lago on Monday morning?\" |\n| `get_train` | \"where is train 4307 right now, and how late is it?\" |\n\nStation arguments take either a code (`S01700`) or a name (`milano centrale`), and\ntimes take either `HH:mm` for today or ISO `2026-08-29T09:00` for another day.\n\nThree behaviours exist because the caller is a language model rather than a\nperson, and each of them prevents a confident wrong answer:\n\n- **`now` exists at all** because a model has no reliable idea of the date in\n  Rome, and every relative question — \"tonight\", \"Saturday\" — needs one before\n  any other tool can be called.\n- **Ambiguous names are never resolved silently.** \"Milano\" matches twenty-six\n  stations, and many towns have both a national station and a separate \"Nord\"\n  one served by entirely different trains. The tools return the list and ask, rather than picking the\n  first and sounding certain.\n- **Unknown places say where coverage ends.** Asking for Lugano returns an\n  explanation of what the service covers, so the model can decline instead of\n  inventing a train to Switzerland.\n\n`find_connection` finds **direct** trains only, and says so. It reads live\ndeparture boards and each candidate train's stop list rather than a timetable,\nso it carries delays and platforms but cannot compose a change.\n\n`find_journey` composes one. It plans from the regional timetable, falls back to\nSwiss open data for anywhere that timetable does not reach, and returns\nscheduled times with no delays in them — the two tools answer different halves\nof the same question and are meant to be used together.\n\nJourneys are ranked by **arrival**, not departure. Someone asking how to get\nsomewhere wants to be there soonest, and ranking by departure puts a train that\nleaves four minutes earlier and arrives forty minutes later at the top of the\nlist.\n\nOnly one change is searched for. Two multiply both the search space and the\nways to be quietly wrong, and on this network almost everything worth reaching\nis reachable with one — so the limit is stated rather than hidden behind an\nincomplete search.\n\n## The part worth reading\n\nBoth upstream APIs are public, undocumented and a little hostile. Most of the\nwork in this repository is not calling them, it is surviving them. Each of the\nfollowing is enforced in code and covered by a test.\n\n### Trenord wants two headers, not one\n\nTrenord answers `403 Forbidden` unless the request carries **both** an `Accept`\nheader and a `User-Agent`:\n\n| Request | Result |\n|---|---|\n| no headers | 403 |\n| `Accept: */*` only | 403 |\n| `User-Agent` only | 403 |\n| both | 200 |\n\nThis is easy to get half right. Probing the endpoint with curl or Python\nsuggests that `Accept` alone is enough, because both send a `User-Agent` of\ntheir own without being asked. .NET's `HttpClient` sends neither header unless\ntold to — so a client that sets only `Accept` keeps getting 403, and finds out\nin production rather than on the machine where the call was first tried by hand.\n\nThat is the whole reason `TrenordClient` sets both in its constructor.\n\n### Optional fields are absent, not null\n\n`average_crowding`, `average_crowding_label`, `suppression_type` and `alerts`\ndo not appear in the Trenord payload at all when there is nothing to report.\nThey are not `null`: the properties are missing.\n\nVerified against five regular services (S5, S11, RE_5, R27): none of them\ncarried any of the four. Code that assumes these fields exist therefore works\non the trains that have problems and crashes on the ones that do not, which is\nthe worst possible way round.\n\n### Timestamps come in three different shapes\n\n- Trenord `dep_time` / `arr_time` — local time, `\"HH:MM:SS\"`. Safe to display.\n- Trenord `dep_date_time` / `arr_date_time` — ISO 8601 in **UTC**, with the `Z`.\n  Convenient for date arithmetic, wrong if you show it as it is.\n- ViaggiaTreno — **epoch milliseconds**, always to be converted declaring\n  `Europe/Rome` explicitly. Converting with the machine's local time gives the\n  right answer on an Italian laptop and the wrong one on a UTC server.\n\n### ViaggiaTreno wants JavaScript's idea of a date\n\nBoard endpoints take the timestamp spelled the way `Date.toString()` spells it:\n\n```\nMon Aug 10 2026 20:20:00 GMT+0200\n```\n\nDay and month names must be English. They are built from fixed arrays on\npurpose: formatting them through the machine's culture produces `lun ago` on an\nItalian system and the endpoint silently returns nothing.\n\n### Two more small ones\n\n- ViaggiaTreno's train lookup answers with **plain text**, not JSON — one line\n  per run, with the fields needed by the live-progress endpoint after a `|`.\n- It also returns an **empty body** instead of an empty array when there is\n  nothing to report, which makes a naive deserializer throw.\n- `train_operator` uses `$:$` as its separator, literally.\n\n### Read the operator's file, not the portal's copy of it\n\nTrenord publishes this timetable as a GTFS zip under CC0, and the same portal\nalso serves it exploded into one queryable table per file. The tables look like\nthe easier option — paged JSON, filterable, no zip to unpack — and they are a\nlossy copy. Measured against the file they are built from:\n\n| | the zip | the tables |\n|---|---|---|\n| stop times | 90,553 | 68,952 |\n| trips | 8,470 | 6,265 |\n\nA quarter of the timetable does not survive the import, and it does not go\nmissing tidily: trips arrive **truncated**. Train 11827 runs Varese to Milano\nand on; in the tables it stops at Porta Garibaldi, so everything reachable by\nstaying on it is invisible, and Varese to Bergamo came back an hour worse than\nthe published answer. Reading the zip, it matches to the minute.\n\nThree more things the import breaks, each of which costs real code to work\naround and none of which is wrong in the file:\n\n- **Times land inside a placeholder date.** GTFS writes a service past midnight\n  as `24:05`; a datetime cannot hold that, so it becomes `00:05` and the train\n  arrives eighteen hours before it left. The file says `24:05`.\n- **Service ids are rewritten with a hash** and no longer match the ones in\n  `calendar_dates`, so the two files cannot be joined on the key they share.\n  Cutting both back to the number before the hyphen makes them join again — and\n  merges every seasonal variant of a train into one, leaving nothing to say\n  which of them runs today. In the file, `trips` and `calendar_dates` both write\n  `1001A-2025-12-14-2026-12-12`. The join is exact and the ambiguity does not\n  exist.\n- **`trip_short_name` is dropped entirely.** That is the train number — the one\n  field that connects a planned leg to the live data.\n\nThe decimal point also goes missing from coordinates, and `route_type` differs:\nthe tables call R23 and RE4 trains, the file calls them buses.\n\n### Ask the operator, then say so\n\nThe file is not the operator's own answer either, and it is worth being precise\nabout the size of the gap. Checked against 28 journeys published by the\noperator's own planner, on the same day:\n\n- Milano Centrale to Bergamo: the feed times train 2217 at 48 minutes, the\n  operator at 52.\n- Pavia to Mortara: train 10668 arrives 09:28 in the feed, 09:23 in the answer.\n- Lecco to Bergamo: train 10719 reaches Ponte S.Pietro at 08:52 in the feed and\n  its connecting coach leaves at 08:51 — a change the feed itself makes\n  impossible and the operator has working with five minutes to spare.\n\nThe operator runs HAFAS over an internal timetable with real-time folded in. A\nGTFS export of it is not it. But the live sources here are the operator's own,\nand the file carries the train number, so every leg can simply be looked up and\nasked. Where it answers, its times are the ones shown, the timetable's are kept\nin brackets beside them, and platforms, delays and cancellations come with them.\n\nAgainst those same 28 journeys that takes exact agreement from 22 to **27**.\n\nThe one that remains is the shape of what this cannot do. Lecco to Bergamo is\nstill answered with the 09:01 coach rather than the 08:51 one, because the feed\nsaid the train arrived at 08:52 and the search discarded that connection before\nanything was checked. Correcting after the planning fixes what is shown; it\ndoes not recover what the wrong data excluded. Live data is asked for today and\ntomorrow only — beyond that there is nothing live to ask.\n\n### The planner behind the border does not know it is lost\n\nWhere the regional timetable does not reach, journeys fall back to Swiss open\ndata. That planner covers Switzerland and reaches into Italy near the border. It\ndoes not cover the rest of the country — and asked about it, it does not say so.\nIt matches the name against its own index and answers about whatever it found.\n\nAsked to plan Milano Centrale to **Roma Termini**, it returned a confident,\ncorrectly formatted, two-hour itinerary to *LaCLINIQUE of Switzerland, Locarno,\nVia Bossi 2*. Roma Termini to Napoli Centrale became a four-change, four-hour\njourney ending at a street address in Lucens, canton Vaud.\n\nNationality is not the test that catches this: the Swiss index holds Zurich, and\nViaggiaTreno holds Zurich Altstetten, so \"are both stations Italian\" rejects a\nreal journey to Zurich while letting the clinic through. The test that works is\nwhether the answer is about the places that were asked for — one substantial\nword in common, accents folded, so *Zurich* matches *Zürich HB* and *Roma\nTermini* matches nothing in Locarno. An answer that fails it is discarded rather\nthan passed on, and the reply names the live tools, which do work for those\nstations.\n\n### How accurate this is, and how that was measured\n\nTwenty-eight journeys published by the operator's own planner were compared\nagainst what this returns — same day, same times, thirteen routes across the\nregion — and where the two disagreed, the live train data was asked to settle\nit. **Twenty-seven of the twenty-eight match to the minute.**\n\nThat is a measurement of two things at once, and they are worth separating. The\nrouting was never really the hard part on a network this size: what the number\nmostly measures is how faithfully open timetable data reproduces the timetable\nthe operator actually runs. The answer is: closely, and not exactly.\n\nAn open feed is an export. The operator plans on an internal system with\nreal-time folded into it and publishes a snapshot of that system as GTFS, on its\nown schedule. A snapshot trails the thing it is a snapshot of — that is what a\nsnapshot is, not a defect of anyone's — and the trailing shows up as a few\nminutes on a few trains. Four into Bergamo. Five into Mortara. Six into Ponte\nS.Pietro, where it is the difference between a coach that can be caught and one\nthat cannot.\n\nNone of that can be repaired from open timetable data, because the correct value\nis not in it. What can be done is to stop treating the timetable as the last\nword: every leg carries a train number, the live sources belong to the operator,\nso each leg is looked up and asked. That is where twenty-two of twenty-eight\nbecame twenty-seven.\n\n**The twenty-eighth is the honest edge of the approach.** Lecco to Bergamo is\nstill answered with the 09:01 coach rather than the 08:51 one. The feed puts the\ninbound train into Ponte S.Pietro at 08:52, one minute after that coach leaves,\nso the search discarded the connection before anything was checked against the\noperator. Correcting after planning fixes what is shown; it cannot recover what\nwrong data excluded. Doing better would mean planning on corrected times, which\nmeans correcting the whole timetable rather than the handful of legs an answer\nhappens to use — a different project, and a much larger one.\n\nSo the position this takes is: be exact about what is known, name the source of\nevery number, and where a minute matters, go and ask the operator. A tool that\nknows which of its answers to distrust is more useful than one that is confident\neverywhere.\n\n### Coverage\n\nTrenord covers its own fleet, FNM included. ViaggiaTreno covers the RFI network\nand, unpredictably, part of FNM. So `get_train` asks Trenord first and falls\nback to ViaggiaTreno, while the station boards only exist on ViaggiaTreno.\n\nThe regional timetable reaches along the cross-border lines, so stations beyond\nthe frontier are planned domestically rather than as an international journey.\nThe live APIs stop at the border, and a leg past it therefore carries scheduled\ntimes only.\n\n## Development\n\n```bash\ndotnet build\ndotnet test\n```\n\nFifty tests, on three levels:\n\n- **the clients**, against the live endpoints — the header rule, the timestamp\n  formats, the empty body where an empty array was expected;\n- **the tools**, called the way a model calls them, asserting on what the answer\n  claims rather than on how it is worded: an ambiguous name comes back as a\n  question, an unknown place says where coverage ends, a planned journey says\n  its times carry no delays;\n- **the server**, started as a process and spoken to in JSON-RPC — the\n  handshake, the advertised tools and their schemas, and the rule that nothing\n  but protocol may reach stdout.\n\nNothing asserts a departure time. The timetable is republished daily, and a test\nwritten around today's 08:24 fails next month for no reason, which teaches\nwhoever reads it to ignore the suite. What is asserted are the properties that\nhold whatever the timetable says: time moves forward, a change is long enough to\nmake, the journey starts and ends where it was asked to, results are ordered the\nway the tool claims. Those are also the ones that were actually broken.\n\nThey run against the live endpoints on purpose. Mocking would only prove the\nmocks match what was assumed, and every bug worth catching here came from the\nreal payload disagreeing with the assumption — including the two-header rule\nabove, found by a test contradicting the documentation it was written from.\n\nRequires the .NET 10 SDK.\n\n## Limits\n\n- **Journey planning covers Lombardy and the cross-border lines. Live data\n  covers all of Italy.** Departure boards, arrivals, direct connections and\n  train tracking work at Roma Termini, Napoli Centrale, Palermo and Bari;\n  planning a route between them does not, and says so rather than improvising.\n- **One change.** Two multiply both the search space and the ways to be quietly\n  wrong. Where a journey needs more, the answer says which limit was hit rather\n  than reporting nothing found.\n- **Times are the operator's where it was asked, and timetabled where it was\n  not.** The live sources are asked for today and tomorrow; for any other day\n  there is nothing live to ask, and the answer says so.\n- **A few minutes on a few trains.** The published timetable trails the\n  operator's own by four to six minutes on some services. Legs where the two\n  disagree are marked, and are the legs not to build a four-minute change on.\n- **Walking between stations is not modelled.** Several towns have two stations\n  a few hundred metres apart, served by different lines; a journey that would\n  change between them on foot is not found.\n- Read-only. No booking, no ticketing, no account access.\n- ViaggiaTreno is served over plain HTTP and is occasionally unavailable.\n- The live APIs are undocumented and can change without notice. The tests run\n  against them on purpose: if one starts failing, that is the intended alarm.\n\n## Licence\n\nMIT.\n",
  "bytes": 16860,
  "sha": "86def9f90bafbcd014b11c344b5d236d18cab35655e92f72f1b42af837386986",
  "repo_slug": "denisraimondi/lombardia-trains-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_denisraimondi_lombardia_trains_fa93a9aa/readme"
}