Express-based API for Lviv transport timetable data with a read-only MCP endpoint.
- Node.js 24 (see
.nvmrc)
nvm use
make startnvm use && make testThis service exposes a public read-only MCP endpoint over Streamable HTTP.
- MCP endpoint:
/mcp - Server card:
/.well-known/mcp/server-card.json - Discovery hint:
/robots.txt(non-standard comment hint)
Production deployment (see cloudbuild.yaml for Cloud Run) serves REST and MCP from api.lad.lviv.ua. The main site lad.lviv.ua is the public transport website (this repo still links there in HTML sitemap and tables for people, not for the API host). Use your own origin when running locally.
An MCP client (Claude, Cursor, or the MCP SDK) talks JSON-RPC over Streamable HTTP to POST /mcp. Tool handlers reuse the same Express actions as the REST API, backed by LokiJS timetable data, GTFS SQLite (via gtfs), and live GTFS-RT feeds (for example track.ua-gis.com).
graph LR;
Client[LLM or MCP client] -->|JSON-RPC Streamable HTTP| Mcp["POST /mcp"];
Mcp --> Tools[Tool handlers];
Tools --> Actions[Express actions];
Actions --> Loki[(LokiJS)];
Actions --> Gtfs[(GTFS SQLite)];
Actions --> Rt[GTFS-RT upstream];
Loki --> Actions;
Gtfs --> Actions;
Rt --> Actions;
Actions --> Tools;
Tools --> Mcp;
Mcp -->|MCP tool result| Client;
MCP Inspector (local): run npx @modelcontextprotocol/inspector, then open the UI with transport and server URL prefilled (from the inspector README):
http://localhost:6274/?transport=streamable-http&serverUrl=https%3A%2F%2Fapi.lad.lviv.ua%2Fmcp
Postman / curl: call a tool on production
POST https://api.lad.lviv.ua/mcp with Content-Type: application/json. The Streamable HTTP transport may require additional headers your MCP client sets automatically; for a quick manual test, follow the same sequence your MCP SDK uses (session initialize, then tools/call). Example tools/call body shape:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_stop_realtime",
"arguments": { "stop_id": 101 }
}
}Successful tool responses return stringified JSON inside MCP content items (type: "text"), and each payload follows a strict UI contract:
{
"view": "transit_realtime",
"data": { "...": "tool-specific source data" },
"ui_blocks": [
{ "type": "map", "data": { "...": "map renderer input" } },
{ "type": "arrival_list", "data": { "...": "arrival list renderer input" } }
]
}Consistency rule: each vehicle rendered on map must either have a matching ETA in list data or eta_status: "unassigned".
get_stop_realtimeget_route_staticget_route_realtimeget_stop_geometryget_stops_around_location
get_stop_realtime — input & example
Arguments (JSON):
| Field | Type | Required |
|---|---|---|
stop_id |
positive integer or digits-only string | yes |
Example result (shape only; values from upstream):
{
"view": "transit_realtime",
"data": {
"stop": { "id": "707", "name": "Стадіон Сільмаш", "lat": 49.84, "lng": 24.03 },
"arrivals": [
{
"route": "T30",
"direction": "Рясівська",
"vehicle_type": "tram",
"arrival_minutes": 4,
"vehicle_id": "tram_123",
"lat": 49.83,
"lng": 24.02,
"bearing": 120
}
],
"updated_at": "2026-01-23T12:00:00Z"
},
"ui_blocks": [
{
"type": "map",
"data": { "center": [49.84, 24.03], "vehicles": [] }
},
{
"type": "arrival_list",
"data": { "arrivals": [] }
}
]
}get_route_static — input & example
Arguments (JSON):
| Field | Type | Required |
|---|---|---|
route_name |
route short name (e.g. "T30", "32A") or numeric external ID |
yes |
Example result (shape only; stops truncated for brevity):
{
"view": "transit_realtime",
"data": {
"route": {
"name": "T30",
"long_name": "Рясне-2 — Сихів",
"color": "#e81717",
"type": "tram"
},
"stops": [
[
{ "id": "101", "name": "Головний вокзал", "lat": 49.841, "lng": 24.003, "departures": ["05:30", "05:52"] },
{ "id": "707", "name": "Стадіон Сільмаш", "lat": 49.838, "lng": 24.021, "departures": [] }
],
[
{ "id": "707", "name": "Стадіон Сільмаш", "lat": 49.838, "lng": 24.021, "departures": [] },
{ "id": "101", "name": "Головний вокзал", "lat": 49.841, "lng": 24.003, "departures": [] }
]
],
"shapes": [
[[49.841, 24.003], [49.839, 24.012], [49.838, 24.021]],
[[49.838, 24.021], [49.839, 24.012], [49.841, 24.003]]
],
"updated_at": "2026-01-23T12:00:00Z"
},
"ui_blocks": [
{
"type": "map",
"data": {
"center": [49.841, 24.003],
"zoom": 13,
"polylines": [[[49.841, 24.003], [49.839, 24.012], [49.838, 24.021]]],
"stops": [
{ "id": "101", "name": "Головний вокзал", "lat": 49.841, "lng": 24.003 },
{ "id": "707", "name": "Стадіон Сільмаш", "lat": 49.838, "lng": 24.021 }
],
"vehicles": []
}
}
]
}stops[0] is direction 0 (outbound), stops[1] is direction 1 (return). departures is populated only for direction 0. shapes follows the same two-element order. The map block uses direction-0 polyline and all unique stops as markers.
get_route_realtime — input & example
Arguments (JSON):
| Field | Type | Required |
|---|---|---|
route_name |
route short name (e.g. "T30", "32A") or numeric external ID |
yes |
Example result:
{
"view": "transit_realtime",
"data": {
"route_name": "T30",
"vehicles": [
{
"id": "tram_123",
"direction": 0,
"lat": 49.838,
"lng": 24.021,
"bearing": 120,
"lowfloor": true
}
],
"updated_at": "2026-01-23T12:00:00Z"
},
"ui_blocks": [
{
"type": "map",
"data": {
"center": [49.838, 24.021],
"zoom": 13,
"vehicles": [
{
"id": "tram_123",
"direction": 0,
"lat": 49.838,
"lng": 24.021,
"bearing": 120,
"lowfloor": true
}
]
}
}
]
}direction matches the index into get_route_static's stops array (0 = outbound, 1 = return). lowfloor: true indicates a low-floor vehicle. Returns an empty vehicles array when no vehicles are currently active on the route.
get_stop_geometry — input & example
Arguments:
| Field | Type | Required |
|---|---|---|
stop_id |
positive integer or digits-only string | yes |
Example result:
{
"view": "transit_realtime",
"data": {
"stop": { "id": "707", "name": "Стадіон Сільмаш", "lat": 49.84, "lng": 24.03 },
"routes": [
{
"route": "T30",
"polyline": [[49.84, 24.03], [49.83, 24.02]]
}
]
},
"ui_blocks": [{ "type": "map", "data": { "routes": [] } }]
}get_stops_around_location — input & example
Returns stops near a map point (numeric code, name, coordinates, distance). Intended for hosts that render map UI blocks (for example ChatGPT): one block with multiple stop markers and the search center. Uses the same backend as GET /closest (see below).
Arguments (JSON):
| Field | Type | Required |
|---|---|---|
latitude |
number, −90…90 | yes |
longitude |
number, −180…180 | yes |
radius_meters |
integer, 50…3000 | no (default 1000) |
Example result (shape only):
{
"view": "transit_realtime",
"data": {
"center_lat": 49.84,
"center_lng": 24.03,
"radius_meters": 1000,
"stops": [
{
"id": "707",
"name": "Стадіон Сільмаш",
"lat": 49.841,
"lng": 24.031,
"distance_meters": 120
}
],
"updated_at": "2026-01-23T12:00:00Z"
},
"ui_blocks": [
{
"type": "map",
"data": {
"center": [49.84, 24.03],
"zoom": 15,
"stops": [
{
"id": "707",
"name": "Стадіон Сільмаш",
"lat": 49.841,
"lng": 24.031,
"distance_meters": 120
}
],
"vehicles": []
}
}
]
}Map zoom is 15 for radius ≤ 1500 m and 14 for larger radii (up to 3000 m).
- Public read-only (no authentication).
- No mutating tools are exposed.
robots.txtis only a best-effort discovery hint and not a protocol contract.
All endpoints return JSON. :code is a numeric stop code; :name is a route short name (e.g. T1, 32A) or numeric external ID.
All stops as a JSON array, sorted by code.
- Response: array of
{ code, name, eng_name, location: [lat, lng], routes, sign, sign_pdf }.
(GET /stops returns an HTML table instead.)
Single stop with live realtime timetable. Short-cached (5–10 s).
- Optional:
skipTimetableData=1— omit live arrivals (long-cached response). - Response:
{ code, name, eng_name, latitude, longitude, transfers, timetable }.
Live timetable only for a stop. Short-cached (5–10 s).
- Response: array of timetable items.
Static stop info without live data. Long-cached (30 days).
- Response:
{ code, name, eng_name, latitude, longitude, transfers }.
Nearby stops — same search as get_stops_around_location, for non-MCP clients.
- Optional:
radius— meters, clamped between 50 and 3000 (default 1000). - Response: JSON array of
{ code, name, latitude, longitude, distance_meters }(sorted by distance).
All routes as a JSON array, sorted by short name.
- Response: raw route objects from the timetable store.
(GET /routes returns an HTML table.)
Route shape, stop list, and metadata. Long-cached (30 days).
- Response:
{ id, color, type, route_short_name, route_long_name, stops: [[dir0…], [dir1…]], shapes }.
Live vehicle positions for a route. Short-cached (10 s).
- Response: array of
{ id, direction, location: [lat, lng], bearing, lowfloor }.
Live position and upcoming stop arrivals for one vehicle. Short-cached (5 s).
- Response:
{ location: [lat, lng], routeId, bearing, direction, licensePlate, arrivals }.
Look up a vehicle ID by its license plate. Short-cached (5 s).
- The plate is matched case-insensitively with spaces and dashes ignored (
BC-1234-AA,bc 1234 aa, andbc1234aaare all equivalent). - Response:
{ vehicleId }— use the returned ID withGET /vehicle/:vehicleId.
Vehicles within 1 km of a point. Short-cached (10 s).
- Response: array of
{ id, route, vehicle_type, color, location: [lat, lng], bearing, lowfloor }.