Server data from the Official MCP Registry
Lviv public transport MCP: stops, timetables, routes, and live vehicle positions. No API key.
About
Lviv public transport MCP: stops, timetables, routes, and live vehicle positions. No API key.
Remote endpoints: streamable-http: https://api.lad.lviv.ua/mcp
Security Report
This is a read-only public transit MCP server for Lviv, Ukraine with no authentication required. The code is well-structured with proper input validation, output schema enforcement via Zod, and appropriate caching. However, there are some moderate concerns: environment variable exposure via error messages, potential for information disclosure through detailed error responses, and moderate logging of structured data that could contain sensitive fields. Permissions are appropriate for a public transit API (network_http, env_vars for configuration). The server is stateless, rate-limited at the HTTP layer, and properly scoped to read-only operations. Supply chain analysis found 4 known vulnerabilities in dependencies (0 critical, 1 high severity).
3 files analyzed · 10 issues found
Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.
Permissions Required
This plugin requests these system permissions. Most are normal for its category.
How to Install & Connect
Available as Local & Remote
This plugin can run on your machine or connect to a hosted endpoint. during install.
Documentation
View on GitHubFrom the project's GitHub README.
Timetable API Node
Express-based API for Lviv transport timetable data with a read-only MCP endpoint.
Requirements
- Node.js 26 (see
.nvmrc)
Run locally
nvm use
make start
Test
nvm use && make test
Monitoring
Two optional integrations, both off unless their environment variable is set:
| Variable | Effect |
|---|---|
SENTRY_DSN | Error reporting via instrument.js |
NEW_RELIC_LICENSE_KEY | New Relic APM via newrelic.cjs |
New Relic runs as a preloaded agent, so npm start carries the flags:
node -r dotenv/config -r newrelic --import newrelic/esm-loader.mjs index.js
dotenv/config is preloaded first so .env is populated before the agent
reads its configuration. The config file is newrelic.cjs (the agent is
CommonJS and this project is ESM) and holds no secrets — the key comes from the
environment. /health is excluded from transactions via rules.ignore.
The account is in the EU region; its license key starts with eu01xx and
the agent picks the collector from that prefix. Use the 40-character ingest
license key, not an NRAK-... user API key.
Cloud Run reads the key from Secret Manager:
gcloud run services update timetable-api-node --region=us-central1 --project=timetable-252615 --set-secrets=NEW_RELIC_LICENSE_KEY=new-relic-license-key:latest
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
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 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:
{
"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_realtimeget_route_staticget_route_realtimeget_stop_geometryget_stops_around_locationget_nearby_vehiclesget_vehicle_info
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": [] }
}
]
}
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"],
"schedule": { "workday": ["05:30", "05:52", "06:10"], "weekend": ["07:00", "07:30"] }
},
{ "id": "707", "name": "Стадіон Сільмаш", "lat": 49.838, "lng": 24.021, "departures": [], "schedule": { "workday": [], "weekend": [] } }
],
[
{ "id": "707", "name": "Стадіон Сільмаш", "lat": 49.838, "lng": 24.021, "departures": [], "schedule": { "workday": [], "weekend": [] } },
{ "id": "101", "name": "Головний вокзал", "lat": 49.841, "lng": 24.003, "departures": [], "schedule": { "workday": [], "weekend": [] } }
]
],
"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 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.
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.
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": [] } }]
}
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).
Returns live positions for all transit vehicles within 1 km of given coordinates. Wraps the same backend as GET /transport.
Arguments (JSON):
| Field | Type | Required |
|---|---|---|
latitude | number, −90…90 | yes |
longitude | number, −180…180 | yes |
Example result (shape only):
{
"view": "transit_realtime",
"data": {
"center_lat": 49.84,
"center_lng": 24.03,
"vehicles": [
{
"id": "tram_123",
"route": "T01",
"vehicle_type": "tram",
"lat": 49.841,
"lng": 24.031,
"bearing": 90,
"lowfloor": true
}
],
"updated_at": "2026-01-23T12:00:00Z"
},
"ui_blocks": [
{
"type": "map",
"data": {
"center": [49.84, 24.03],
"zoom": 14,
"vehicles": [{ "id": "tram_123", "route": "T01", "lat": 49.841, "lng": 24.031, "bearing": 90, "eta_status": "unassigned" }]
}
}
]
}
Full 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.
Arguments (JSON):
| Field | Type | Required |
|---|---|---|
vehicle_id | string | yes |
Example result (shape only):
{
"view": "transit_realtime",
"data": {
"vehicle_id": "tram_123",
"route": "route-ext-1",
"license_plate": "BC-1234-AB",
"lat": 49.841,
"lng": 24.031,
"bearing": 90,
"direction": 0,
"upcoming_stops": [
{ "code": 707, "arrival": "2026-01-23T12:05:00Z", "departure": null },
{ "code": 708, "arrival": "2026-01-23T12:08:00Z", "departure": null }
],
"updated_at": "2026-01-23T12:00:00Z"
},
"ui_blocks": [
{
"type": "map",
"data": { "center": [49.841, 24.031], "zoom": 15, "vehicles": [{ "id": "tram_123", "eta_status": "unassigned" }] }
}
]
}
Resources and resource templates
In addition to tools, the server exposes MCP resources for reference data that doesn't require a tool call:
| URI | Description |
|---|---|
timetable://about | Scope, usage, and data caveats for this server (Markdown) |
timetable://reference/tools | Tools reference table (Markdown) |
timetable://reference/prompts | Prompt templates catalog (Markdown) |
timetable://stop/{code} | Static info for a stop by numeric code — name, coordinates, serving routes (JSON) |
timetable://route/{name} | Static metadata for a route by short name — color, type, stop counts (JSON) |
Security model
- Public read-only (no authentication).
- No mutating tools are exposed.
POST /mcpis rate-limited to 60 requests/min per IP (in-memory, resets on restart). Excess requests receive HTTP 429 with a JSON-RPC error body.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.)
Per-stop route overrides
The upstream route list for a stop is sometimes behind reality. GET /stops
applies a stored override to its Маршрути column — removed routes shown red and
struck through, added ones green — and hangs the matching ?add=/?remove= on
that row's SVG and PDF links, which offline.lad.lviv.ua and pdf.lad.lviv.ua
both understand.
The route column is always clickable: click a route to drop or restore it, type
one into the + box to add it.
Overrides live in the browser's own localStorage (see
public/stopOverrides.js), not on a server — no
account to edit through, no cache to purge, an edit applies at once. The trade
is scope: an override is visible only in the browser that made it, not to
anyone else who opens /stops.
/stops.json reports sign and sign_pdf without overrides applied.
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 }. - Each stop object:
{ code, name, loc, transfers, departures, schedule }.departures— today's departure times (HH:MM), populated only for direction 0 first stop. Kept for backward compatibility.schedule—{ workday: string[], weekend: string[] }departure times by day type, populated only for direction 0 first stop.
GET /routes/dynamic/:name
Live vehicle positions for a route. Short-cached (10 s).
- Response: array of
{ id, direction, location: [lat, lng], bearing, speed, lowfloor }.speedis m/s from the GPS unit, ornullwhen not reported.
Vehicles
GET /vehicle/:vehicleId
Live position and upcoming stop arrivals for one vehicle. Short-cached (5 s).
- Response:
{ location: [lat, lng], routeId, bearing, speed, direction, licensePlate, arrivals }.speedis m/s from the GPS unit, ornullwhen not reported.
GET /vehicle-by-plate/:plate
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.
GET /transport?latitude={lat}&longitude={lng}
Vehicles within 1 km of a point. Short-cached (10 s).
- Response: array of
{ id, route, routeId, direction, vehicle_type, color, location: [lat, lng], bearing, speed, lowfloor }.routeIdis usable as:namein/routes/static/:name;directionmatches the index intostops/shapes(0 = outbound, 1 = return, null if unknown).speedis m/s ornull.
Reviews
No reviews yet
Be the first to review this server!
More Developer Tools MCP Servers
Git
Freeby Modelcontextprotocol · Developer Tools
Read, search, and manipulate Git repositories programmatically
Fetch
Freeby Modelcontextprotocol · Developer Tools
Web content fetching and conversion for efficient LLM usage
Toleno
Freeby Toleno · Developer Tools
Toleno Network MCP Server — Manage your Toleno mining account with Claude AI using natural language.
mcp-creator-python
Freeby mcp-marketplace · Developer Tools
Create, build, and publish Python MCP servers to PyPI — conversationally.
MCP Marketplace
Freeby mcp-marketplace · Developer Tools
Search and install MCP servers from inside your AI client.
MarkItDown
Freeby Microsoft · Content & Media
Convert files (PDF, Word, Excel, images, audio) to Markdown for LLM consumption
