Skip to content

vbhjckfd/timetable-api-node

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

276 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Timetable API Node

CI Node.js License: WTFPL

Express-based API for Lviv transport timetable data with a read-only MCP endpoint.

Requirements

  • Node.js 22 (see .nvmrc)

Run locally

nvm use
make start

Test

nvm use && make test

MCP Server

This 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.

LLM and /mcp flow

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;
Loading

Try the live API

MCP server card REST stops.json REST routes.json

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".

Exposed tools

  • get_stop_realtime
  • get_vehicles_by_stop
  • get_stop_geometry
  • get_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_vehicles_by_stop — input & example

Arguments:

Field Type Required
stop_ids array of positive integers and/or digits-only strings yes

Example result:

{
  "view": "transit_realtime",
  "data": {
    "stop_ids": ["707"],
    "stops": [{ "id": "707", "name": "Стадіон Сільмаш", "lat": 49.84, "lng": 24.03 }],
    "vehicles": [
      {
        "id": "tram_123",
        "route": "T30",
        "lat": 49.83,
        "lng": 24.02,
        "bearing": 120,
        "next_stop_id": "707",
        "eta_minutes": 4,
        "eta_status": "assigned"
      }
    ]
  },
  "ui_blocks": [{ "type": "map", "data": { "vehicles": [] } }]
}
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).

Nearby stops over REST (GET /closest)

Same stop search as get_stops_around_location, for non-MCP clients:

  • URL: GET /closest?latitude={lat}&longitude={lng}
  • Optional: radius — meters, clamped between 50 and 3000 (default 1000).
  • Response: JSON array of { code, name, latitude, longitude, distance_meters } (sorted by distance).

Security model

  • Public read-only (no authentication).
  • No mutating tools are exposed.
  • robots.txt is only a best-effort discovery hint and not a protocol contract.

About

NodeJS version of lad.lviv.ua API

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages