{
  "markdown": "# Timetable API Node\n\n[![CI](https://img.shields.io/github/actions/workflow/status/vbhjckfd/timetable-api-node/ci.yml?branch=master&logo=github&label=CI)](https://github.com/vbhjckfd/timetable-api-node/actions/workflows/ci.yml)\n[![Node.js](https://img.shields.io/badge/node.js-26-43853d?logo=node.js&logoColor=white)](https://github.com/vbhjckfd/timetable-api-node/blob/master/.nvmrc)\n[![License: WTFPL](https://img.shields.io/github/license/vbhjckfd/timetable-api-node?label=license)](https://github.com/vbhjckfd/timetable-api-node/blob/master/LICENSE)\n[![MCP Registry](https://img.shields.io/badge/MCP_Registry-listed-6366f1?style=flat-square)](https://registry.modelcontextprotocol.io/v0/servers/io.github.vbhjckfd%2Ftimetable-api-node/versions)\n\nExpress-based API for Lviv transport timetable data with a read-only MCP endpoint.\n\n[![smithery badge](https://smithery.ai/badge/@vbhjckfd/lad-lviv-ua)](https://smithery.ai/servers/vbhjckfd/lad-lviv-ua)\n[![vbhjckfd/timetable-api-node MCP server](https://glama.ai/mcp/servers/vbhjckfd/timetable-api-node/badges/score.svg)](https://glama.ai/mcp/servers/vbhjckfd/timetable-api-node)\n\n[![timetable-api-node MCP server](https://glama.ai/mcp/servers/vbhjckfd/timetable-api-node/badges/card.svg)](https://glama.ai/mcp/servers/vbhjckfd/timetable-api-node)\n\n## Requirements\n\n- Node.js 26 (see `.nvmrc`)\n\n## Run locally\n\n```bash\nnvm use\nmake start\n```\n\n## Test\n\n```bash\nnvm use && make test\n```\n\n## Monitoring\n\nTwo optional integrations, both off unless their environment variable is set:\n\n| Variable | Effect |\n| --- | --- |\n| `SENTRY_DSN` | Error reporting via `instrument.js` |\n| `NEW_RELIC_LICENSE_KEY` | New Relic APM via `newrelic.cjs` |\n\nNew Relic runs as a preloaded agent, so `npm start` carries the flags:\n\n```bash\nnode -r dotenv/config -r newrelic --import newrelic/esm-loader.mjs index.js\n```\n\n`dotenv/config` is preloaded first so `.env` is populated before the agent\nreads its configuration. The config file is `newrelic.cjs` (the agent is\nCommonJS and this project is ESM) and holds no secrets — the key comes from the\nenvironment. `/health` is excluded from transactions via `rules.ignore`.\n\nThe account is in the **EU** region; its license key starts with `eu01xx` and\nthe agent picks the collector from that prefix. Use the 40-character ingest\nlicense key, not an `NRAK-...` user API key.\n\nCloud Run reads the key from Secret Manager:\n\n```bash\ngcloud run services update timetable-api-node --region=us-central1 --project=timetable-252615 --set-secrets=NEW_RELIC_LICENSE_KEY=new-relic-license-key:latest\n```\n\n## MCP Server\n\nThis service exposes a public read-only MCP endpoint over Streamable HTTP.\n\n- MCP endpoint: `/mcp`\n- Server card: `/.well-known/mcp/server-card.json`\n- Discovery hint: `/robots.txt` (non-standard comment hint)\n\nProduction deployment (see `cloudbuild.yaml` for Cloud Run) serves **REST and MCP** from **[api.lad.lviv.ua](https://api.lad.lviv.ua)**. The main site **[lad.lviv.ua](https://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.\n\n### LLM and `/mcp` flow\n\nAn 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`).\n\n```mermaid\ngraph LR;\n  Client[LLM or MCP client] -->|JSON-RPC Streamable HTTP| Mcp[\"POST /mcp\"];\n  Mcp --> Tools[Tool handlers];\n  Tools --> Actions[Express actions];\n  Actions --> Loki[(LokiJS)];\n  Actions --> Gtfs[(GTFS SQLite)];\n  Actions --> Rt[GTFS-RT upstream];\n  Loki --> Actions;\n  Gtfs --> Actions;\n  Rt --> Actions;\n  Actions --> Tools;\n  Tools --> Mcp;\n  Mcp -->|MCP tool result| Client;\n```\n\n### Try the live API\n\n[![MCP server card](https://img.shields.io/badge/MCP-server_card-6366f1?style=flat-square)](https://api.lad.lviv.ua/.well-known/mcp/server-card.json)\n[![REST stops.json](https://img.shields.io/badge/REST-stops.json-222?style=flat-square)](https://api.lad.lviv.ua/stops.json)\n[![REST routes.json](https://img.shields.io/badge/REST-routes.json-222?style=flat-square)](https://api.lad.lviv.ua/routes.json)\n\n**MCP Inspector (local):** run `npx @modelcontextprotocol/inspector`, then open the UI with transport and server URL prefilled (from the [inspector README](https://github.com/modelcontextprotocol/inspector/blob/main/README.md)):\n\n`http://localhost:6274/?transport=streamable-http&serverUrl=https%3A%2F%2Fapi.lad.lviv.ua%2Fmcp`\n\n<details>\n<summary><strong>Postman / curl: call a tool on production</strong></summary>\n\n`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:\n\n```json\n{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 1,\n  \"method\": \"tools/call\",\n  \"params\": {\n    \"name\": \"get_stop_realtime\",\n    \"arguments\": { \"stop_id\": 101 }\n  }\n}\n```\n\nSuccessful tool responses return a **natural-language text summary** inside MCP `content` items (`type: \"text\"`) — e.g. *\"Stop «Opera»: 3 arrivals. Next: T01 → «Rynok» in 2 min.\"* The full structured payload is in the `structuredContent` field (for schema-aware clients). Each `structuredContent` payload follows a strict UI contract:\n\n```json\n{\n  \"view\": \"transit_realtime\",\n  \"data\": { \"...\": \"tool-specific source data\" },\n  \"ui_blocks\": [\n    { \"type\": \"map\", \"data\": { \"...\": \"map renderer input\" } },\n    { \"type\": \"arrival_list\", \"data\": { \"...\": \"arrival list renderer input\" } }\n  ]\n}\n```\n\nConsistency rule: each vehicle rendered on map must either have a matching ETA in list data or `eta_status: \"unassigned\"`.\n\n</details>\n\n### Exposed tools\n\n- `get_stop_realtime`\n- `get_route_static`\n- `get_route_realtime`\n- `get_stop_geometry`\n- `get_stops_around_location`\n- `get_nearby_vehicles`\n- `get_vehicle_info`\n\n<details>\n<summary><code>get_stop_realtime</code> — input &amp; example</summary>\n\n**Arguments (JSON):**\n\n| Field | Type | Required |\n|-------|------|----------|\n| `stop_id` | positive integer or digits-only string | yes |\n\n**Example result** (shape only; values from upstream):\n\n```json\n{\n  \"view\": \"transit_realtime\",\n  \"data\": {\n    \"stop\": { \"id\": \"707\", \"name\": \"Стадіон Сільмаш\", \"lat\": 49.84, \"lng\": 24.03 },\n    \"arrivals\": [\n      {\n        \"route\": \"T30\",\n        \"direction\": \"Рясівська\",\n        \"vehicle_type\": \"tram\",\n        \"arrival_minutes\": 4,\n        \"vehicle_id\": \"tram_123\",\n        \"lat\": 49.83,\n        \"lng\": 24.02,\n        \"bearing\": 120\n      }\n    ],\n    \"updated_at\": \"2026-01-23T12:00:00Z\"\n  },\n  \"ui_blocks\": [\n    {\n      \"type\": \"map\",\n      \"data\": { \"center\": [49.84, 24.03], \"vehicles\": [] }\n    },\n    {\n      \"type\": \"arrival_list\",\n      \"data\": { \"arrivals\": [] }\n    }\n  ]\n}\n```\n\n</details>\n\n<details>\n<summary><code>get_route_static</code> — input &amp; example</summary>\n\n**Arguments (JSON):**\n\n| Field | Type | Required |\n|-------|------|----------|\n| `route_name` | route short name (e.g. `\"T30\"`, `\"32A\"`) or numeric external ID | yes |\n\n**Example result** (shape only; stops truncated for brevity):\n\n```json\n{\n  \"view\": \"transit_realtime\",\n  \"data\": {\n    \"route\": {\n      \"name\": \"T30\",\n      \"long_name\": \"Рясне-2 — Сихів\",\n      \"color\": \"#e81717\",\n      \"type\": \"tram\"\n    },\n    \"stops\": [\n      [\n        {\n          \"id\": \"101\", \"name\": \"Головний вокзал\", \"lat\": 49.841, \"lng\": 24.003,\n          \"departures\": [\"05:30\", \"05:52\"],\n          \"schedule\": { \"workday\": [\"05:30\", \"05:52\", \"06:10\"], \"weekend\": [\"07:00\", \"07:30\"] }\n        },\n        { \"id\": \"707\", \"name\": \"Стадіон Сільмаш\", \"lat\": 49.838, \"lng\": 24.021, \"departures\": [], \"schedule\": { \"workday\": [], \"weekend\": [] } }\n      ],\n      [\n        { \"id\": \"707\", \"name\": \"Стадіон Сільмаш\", \"lat\": 49.838, \"lng\": 24.021, \"departures\": [], \"schedule\": { \"workday\": [], \"weekend\": [] } },\n        { \"id\": \"101\", \"name\": \"Головний вокзал\", \"lat\": 49.841, \"lng\": 24.003, \"departures\": [], \"schedule\": { \"workday\": [], \"weekend\": [] } }\n      ]\n    ],\n    \"shapes\": [\n      [[49.841, 24.003], [49.839, 24.012], [49.838, 24.021]],\n      [[49.838, 24.021], [49.839, 24.012], [49.841, 24.003]]\n    ],\n    \"updated_at\": \"2026-01-23T12:00:00Z\"\n  },\n  \"ui_blocks\": [\n    {\n      \"type\": \"map\",\n      \"data\": {\n        \"center\": [49.841, 24.003],\n        \"zoom\": 13,\n        \"polylines\": [[[49.841, 24.003], [49.839, 24.012], [49.838, 24.021]]],\n        \"stops\": [\n          { \"id\": \"101\", \"name\": \"Головний вокзал\", \"lat\": 49.841, \"lng\": 24.003 },\n          { \"id\": \"707\", \"name\": \"Стадіон Сільмаш\", \"lat\": 49.838, \"lng\": 24.021 }\n        ],\n        \"vehicles\": []\n      }\n    }\n  ]\n}\n```\n\n`stops[0]` is direction 0 (outbound), `stops[1]` is direction 1 (return). `departures` and `schedule` are populated only for the **first stop of direction 0**; all other stops have empty arrays. `schedule.workday` contains Monday–Friday departure times; `schedule.weekend` contains Saturday–Sunday departure times. `departures` keeps today's schedule for backward compatibility. `shapes` follows the same two-element order. The map block uses direction-0 polyline and all unique stops as markers.\n\n</details>\n\n<details>\n<summary><code>get_route_realtime</code> — input &amp; example</summary>\n\n**Arguments (JSON):**\n\n| Field | Type | Required |\n|-------|------|----------|\n| `route_name` | route short name (e.g. `\"T30\"`, `\"32A\"`) or numeric external ID | yes |\n\n**Example result:**\n\n```json\n{\n  \"view\": \"transit_realtime\",\n  \"data\": {\n    \"route_name\": \"T30\",\n    \"vehicles\": [\n      {\n        \"id\": \"tram_123\",\n        \"direction\": 0,\n        \"lat\": 49.838,\n        \"lng\": 24.021,\n        \"bearing\": 120,\n        \"lowfloor\": true\n      }\n    ],\n    \"updated_at\": \"2026-01-23T12:00:00Z\"\n  },\n  \"ui_blocks\": [\n    {\n      \"type\": \"map\",\n      \"data\": {\n        \"center\": [49.838, 24.021],\n        \"zoom\": 13,\n        \"vehicles\": [\n          {\n            \"id\": \"tram_123\",\n            \"direction\": 0,\n            \"lat\": 49.838,\n            \"lng\": 24.021,\n            \"bearing\": 120,\n            \"lowfloor\": true\n          }\n        ]\n      }\n    }\n  ]\n}\n```\n\n`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.\n\n</details>\n\n<details>\n<summary><code>get_stop_geometry</code> — input &amp; example</summary>\n\n**Arguments:**\n\n| Field | Type | Required |\n|-------|------|----------|\n| `stop_id` | positive integer or digits-only string | yes |\n\n**Example result:**\n\n```json\n{\n  \"view\": \"transit_realtime\",\n  \"data\": {\n    \"stop\": { \"id\": \"707\", \"name\": \"Стадіон Сільмаш\", \"lat\": 49.84, \"lng\": 24.03 },\n    \"routes\": [\n      {\n        \"route\": \"T30\",\n        \"polyline\": [[49.84, 24.03], [49.83, 24.02]]\n      }\n    ]\n  },\n  \"ui_blocks\": [{ \"type\": \"map\", \"data\": { \"routes\": [] } }]\n}\n```\n\n</details>\n\n<details>\n<summary><code>get_stops_around_location</code> — input &amp; example</summary>\n\nReturns 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).\n\n**Arguments (JSON):**\n\n| Field | Type | Required |\n|-------|------|----------|\n| `latitude` | number, −90…90 | yes |\n| `longitude` | number, −180…180 | yes |\n| `radius_meters` | integer, 50…3000 | no (default **1000**) |\n\n**Example result** (shape only):\n\n```json\n{\n  \"view\": \"transit_realtime\",\n  \"data\": {\n    \"center_lat\": 49.84,\n    \"center_lng\": 24.03,\n    \"radius_meters\": 1000,\n    \"stops\": [\n      {\n        \"id\": \"707\",\n        \"name\": \"Стадіон Сільмаш\",\n        \"lat\": 49.841,\n        \"lng\": 24.031,\n        \"distance_meters\": 120\n      }\n    ],\n    \"updated_at\": \"2026-01-23T12:00:00Z\"\n  },\n  \"ui_blocks\": [\n    {\n      \"type\": \"map\",\n      \"data\": {\n        \"center\": [49.84, 24.03],\n        \"zoom\": 15,\n        \"stops\": [\n          {\n            \"id\": \"707\",\n            \"name\": \"Стадіон Сільмаш\",\n            \"lat\": 49.841,\n            \"lng\": 24.031,\n            \"distance_meters\": 120\n          }\n        ],\n        \"vehicles\": []\n      }\n    }\n  ]\n}\n```\n\nMap zoom is **15** for radius ≤ 1500 m and **14** for larger radii (up to 3000 m).\n\n</details>\n\n<details>\n<summary><code>get_nearby_vehicles</code> — input &amp; example</summary>\n\nReturns live positions for all transit vehicles within 1 km of given coordinates. Wraps the same backend as `GET /transport`.\n\n**Arguments (JSON):**\n\n| Field | Type | Required |\n|-------|------|----------|\n| `latitude` | number, −90…90 | yes |\n| `longitude` | number, −180…180 | yes |\n\n**Example result** (shape only):\n\n```json\n{\n  \"view\": \"transit_realtime\",\n  \"data\": {\n    \"center_lat\": 49.84,\n    \"center_lng\": 24.03,\n    \"vehicles\": [\n      {\n        \"id\": \"tram_123\",\n        \"route\": \"T01\",\n        \"vehicle_type\": \"tram\",\n        \"lat\": 49.841,\n        \"lng\": 24.031,\n        \"bearing\": 90,\n        \"lowfloor\": true\n      }\n    ],\n    \"updated_at\": \"2026-01-23T12:00:00Z\"\n  },\n  \"ui_blocks\": [\n    {\n      \"type\": \"map\",\n      \"data\": {\n        \"center\": [49.84, 24.03],\n        \"zoom\": 14,\n        \"vehicles\": [{ \"id\": \"tram_123\", \"route\": \"T01\", \"lat\": 49.841, \"lng\": 24.031, \"bearing\": 90, \"eta_status\": \"unassigned\" }]\n      }\n    }\n  ]\n}\n```\n\n</details>\n\n<details>\n<summary><code>get_vehicle_info</code> — input &amp; example</summary>\n\nFull details for one vehicle by its ID: position, route, license plate, direction, and upcoming stop arrival times. Vehicle IDs come from `get_route_realtime`, `get_nearby_vehicles`, or `get_stop_realtime`.\n\n**Arguments (JSON):**\n\n| Field | Type | Required |\n|-------|------|----------|\n| `vehicle_id` | string | yes |\n\n**Example result** (shape only):\n\n```json\n{\n  \"view\": \"transit_realtime\",\n  \"data\": {\n    \"vehicle_id\": \"tram_123\",\n    \"route\": \"route-ext-1\",\n    \"license_plate\": \"BC-1234-AB\",\n    \"lat\": 49.841,\n    \"lng\": 24.031,\n    \"bearing\": 90,\n    \"direction\": 0,\n    \"upcoming_stops\": [\n      { \"code\": 707, \"arrival\": \"2026-01-23T12:05:00Z\", \"departure\": null },\n      { \"code\": 708, \"arrival\": \"2026-01-23T12:08:00Z\", \"departure\": null }\n    ],\n    \"updated_at\": \"2026-01-23T12:00:00Z\"\n  },\n  \"ui_blocks\": [\n    {\n      \"type\": \"map\",\n      \"data\": { \"center\": [49.841, 24.031], \"zoom\": 15, \"vehicles\": [{ \"id\": \"tram_123\", \"eta_status\": \"unassigned\" }] }\n    }\n  ]\n}\n```\n\n</details>\n\n\n### Resources and resource templates\n\nIn addition to tools, the server exposes MCP **resources** for reference data that doesn't require a tool call:\n\n| URI | Description |\n|-----|-------------|\n| `timetable://about` | Scope, usage, and data caveats for this server (Markdown) |\n| `timetable://reference/tools` | Tools reference table (Markdown) |\n| `timetable://reference/prompts` | Prompt templates catalog (Markdown) |\n| `timetable://stop/{code}` | Static info for a stop by numeric code — name, coordinates, serving routes (JSON) |\n| `timetable://route/{name}` | Static metadata for a route by short name — color, type, stop counts (JSON) |\n\n### Security model\n\n- Public read-only (no authentication).\n- No mutating tools are exposed.\n- `POST /mcp` is rate-limited to **60 requests/min per IP** (in-memory, resets on restart). Excess requests receive HTTP 429 with a JSON-RPC error body.\n- `robots.txt` is only a best-effort discovery hint and not a protocol contract.\n\n## REST API\n\nAll endpoints return JSON. `:code` is a numeric stop code; `:name` is a route short name (e.g. `T1`, `32A`) or numeric external ID.\n\n### Stops\n\n#### `GET /stops.json`\n\nAll stops as a JSON array, sorted by code.\n\n- **Response:** array of `{ code, name, eng_name, location: [lat, lng], routes, sign, sign_pdf }`.\n\n(`GET /stops` returns an HTML table instead.)\n\n#### Per-stop route overrides\n\nThe upstream route list for a stop is sometimes behind reality. `GET /stops`\napplies a stored override to its `Маршрути` column — removed routes shown red and\nstruck through, added ones green — and hangs the matching `?add=`/`?remove=` on\nthat row's SVG and PDF links, which `offline.lad.lviv.ua` and `pdf.lad.lviv.ua`\nboth understand.\n\nThe route column is always clickable: click a route to drop or restore it, type\none into the `+` box to add it.\n\nOverrides live in the browser's own `localStorage` (see\n[`public/stopOverrides.js`](public/stopOverrides.js)), not on a server — no\naccount to edit through, no cache to purge, an edit applies at once. The trade\nis scope: an override is visible only in the browser that made it, not to\nanyone else who opens `/stops`.\n\n`/stops.json` reports `sign` and `sign_pdf` without overrides applied.\n\n#### `GET /stops/:code`\n\nSingle stop with live realtime timetable. Short-cached (5–10 s).\n\n- **Optional:** `skipTimetableData=1` — omit live arrivals (long-cached response).\n- **Response:** `{ code, name, eng_name, latitude, longitude, transfers, timetable }`.\n\n#### `GET /stops/:code/timetable`\n\nLive timetable only for a stop. Short-cached (5–10 s).\n\n- **Response:** array of timetable items.\n\n#### `GET /stops/:code/static`\n\nStatic stop info without live data. Long-cached (30 days).\n\n- **Response:** `{ code, name, eng_name, latitude, longitude, transfers }`.\n\n#### `GET /closest?latitude={lat}&longitude={lng}`\n\nNearby stops — same search as `get_stops_around_location`, for non-MCP clients.\n\n- **Optional:** `radius` — meters, clamped between **50** and **3000** (default **1000**).\n- **Response:** JSON array of `{ code, name, latitude, longitude, distance_meters }` (sorted by distance).\n\n### Routes\n\n#### `GET /routes.json`\n\nAll routes as a JSON array, sorted by short name.\n\n- **Response:** raw route objects from the timetable store.\n\n(`GET /routes` returns an HTML table.)\n\n#### `GET /routes/static/:name`\n\nRoute shape, stop list, and metadata. Long-cached (30 days).\n\n- **Response:** `{ id, color, type, route_short_name, route_long_name, stops: [[dir0…], [dir1…]], shapes }`.\n- Each stop object: `{ code, name, loc, transfers, departures, schedule }`.\n  - `departures` — today's departure times (HH:MM), populated only for direction 0 first stop. Kept for backward compatibility.\n  - `schedule` — `{ workday: string[], weekend: string[] }` departure times by day type, populated only for direction 0 first stop.\n\n#### `GET /routes/dynamic/:name`\n\nLive vehicle positions for a route. Short-cached (10 s).\n\n- **Response:** array of `{ id, direction, location: [lat, lng], bearing, speed, lowfloor }`. `speed` is m/s from the GPS unit, or `null` when not reported.\n\n### Vehicles\n\n#### `GET /vehicle/:vehicleId`\n\nLive position and upcoming stop arrivals for one vehicle. Short-cached (5 s).\n\n- **Response:** `{ location: [lat, lng], routeId, bearing, speed, direction, licensePlate, arrivals }`. `speed` is m/s from the GPS unit, or `null` when not reported.\n\n#### `GET /vehicle-by-plate/:plate`\n\nLook up a vehicle ID by its license plate. Short-cached (5 s).\n\n- The plate is matched case-insensitively with spaces and dashes ignored (`BC-1234-AA`, `bc 1234 aa`, and `bc1234aa` are all equivalent).\n- **Response:** `{ vehicleId }` — use the returned ID with `GET /vehicle/:vehicleId`.\n\n#### `GET /transport?latitude={lat}&longitude={lng}`\n\nVehicles within 1 km of a point. Short-cached (10 s).\n\n- **Response:** array of `{ id, route, routeId, direction, vehicle_type, color, location: [lat, lng], bearing, speed, lowfloor }`. `routeId` is usable as `:name` in `/routes/static/:name`; `direction` matches the index into `stops`/`shapes` (0 = outbound, 1 = return, null if unknown). `speed` is m/s or `null`.\n",
  "bytes": 19885,
  "sha": "6f6845d5ae0245a4b5b9130a044fc1e0c69a6f3557552a2cd36ee4d50321c6ba",
  "repo_slug": "vbhjckfd/timetable-api-node",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_vbhjckfd_timetable_api_node_94d47a58/readme"
}