mcp-lad-lviv-ua
Express-based API for Lviv transport timetable data with a read-only MCP endpoint.
README
Timetable API Node
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;
Try the live API
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
<details> <summary><strong>Postman / curl: call a tool on production</strong></summary>
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".
</details>
Exposed tools
get_stop_realtimeget_vehicles_by_stopget_stop_geometryget_stops_around_location
<details> <summary><code>get_stop_realtime</code> — input & example</summary>
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": [] }
}
]
}
</details>
<details> <summary><code>get_vehicles_by_stop</code> — input & example</summary>
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": [] } }]
}
</details>
<details> <summary><code>get_stop_geometry</code> — input & example</summary>
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": [] } }]
}
</details>
<details> <summary><code>get_stops_around_location</code> — input & example</summary>
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).
</details>
Security model
- Public read-only (no authentication).
- No mutating tools are exposed.
robots.txtis only a best-effort discovery hint and not a protocol contract.
REST API
All endpoints return JSON. :code is a numeric stop code; :name is a route short name (e.g. T1, 32A) or numeric external ID.
Stops
GET /stops.json
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.)
GET /stops/:code
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 }.
GET /stops/:code/timetable
Live timetable only for a stop. Short-cached (5–10 s).
- Response: array of timetable items.
GET /stops/:code/static
Static stop info without live data. Long-cached (30 days).
- Response:
{ code, name, eng_name, latitude, longitude, transfers }.
GET /closest?latitude={lat}&longitude={lng}
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).
Routes
GET /routes.json
All routes as a JSON array, sorted by short name.
- Response: raw route objects from the timetable store.
(GET /routes returns an HTML table.)
GET /routes/static/:name
Route shape, stop list, and metadata. Long-cached (30 days).
- Response:
{ id, color, type, route_short_name, route_long_name, stops: [[dir0…], [dir1…]], shapes }.
GET /routes/dynamic/:name
Live vehicle positions for a route. Short-cached (10 s).
- Response: array of
{ id, direction, location: [lat, lng], bearing, lowfloor }.
Vehicles
GET /vehicle/:vehicleId
Live position and upcoming stop arrivals for one vehicle. Short-cached (5 s).
- Response:
{ location: [lat, lng], routeId, bearing, direction, licensePlate, arrivals }.
GET /transport?latitude={lat}&longitude={lng}
Vehicles within 1 km of a point. Short-cached (10 s).
- Response: array of
{ id, route, vehicle_type, color, location: [lat, lng], bearing, lowfloor }.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.