1. Conduid
  2. Commerce
  3. Lviv Public Transport
MCP server · Commerce

Lviv Public Transport

Lviv public transport MCP: stops, timetables, routes, and live vehicle positions. No API key.

Unclaimed commerce
34Low

Scored 5 months ago · breakdown

About Lviv Public Transport

Lviv Public Transport is an MCP server in the Commerce category: lviv public transport MCP: stops, timetables, routes, and live vehicle positions. No API key.

Install

From source
git clone https://github.com/vbhjckfd/timetable-api-node

This server has no ConduID identity, so agent calls to it are not receipted. Pin the version you install and review the source before granting it credentials.

Ask AI

✦

Ask AI about Lviv Public Transport

Powered by Claude · Grounded in docs

I know everything about Lviv Public Transport. Ask me about installation, configuration, usage, or troubleshooting.

Security checks

  • ✓README presentThe repository ships documentation.

5 checks have not been run yet.

README

Timetable API Node

CI Node.js MCP Registry

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

smithery badge vbhjckfd/timetable-api-node MCP server

timetable-api-node MCP server

Requirements

  • Node.js 24 (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 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

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_route_static
  • get_route_realtime
  • get_stop_geometry
  • get_stops_around_location

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"] },
        { "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.

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

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.

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 /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, and bc1234aa are all equivalent).
  • Response: { vehicleId } — use the returned ID with GET /vehicle/:vehicleId.

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

README copy captured when this server was indexed; it may lag the repository. The original is authoritative.

Questions

About Lviv Public Transport

How do I install Lviv Public Transport?

No package has been published for this server, so install it from source: clone the repository and follow its README.

Is Lviv Public Transport safe to use with an AI agent?

Its trust score is 34 out of 100 (low). It passes 1 of the 1 static security checks run so far. It has no ConduID identity yet, so agent calls to it are not receipted.

Is Lviv Public Transport still maintained?

Conduid hasn't recorded a commit date for this repository yet. Check the repository directly for recent activity.