Skip to main content
Glama

hk-bus-mcp

CI Python License: MIT

A Hong Kong bus assistant exposed as a local MCP server. The same code runs in Xiaozhi (小智) voice hardware and in Codex / any MCP client.

Ask it "我想去淘大花園" and it answers with walk-plus-one-bus options it can actually verify, spoken in Cantonese:

一,六一九號,三分鐘後到牛頭角道,坐約八分鐘,落車行五分鐘。

All data comes from public, keyless Hong Kong government and operator APIs. There is no account, no API key, and no bundled map service.


Contents

Related MCP server: Hong Kong Health Data MCP Server

Why one bus and no transfers

Neither the KMB nor the Citybus API does journey planning. Reliable transfer routing would need a validated stop-interchange graph that no operator publishes, and a confidently wrong two-bus instruction is the worst possible answer for the people this is built for. So v1 answers only:

  1. which stops are near a place, and how far the walk is;

  2. when the next bus arrives (real-time ETA, per route);

  3. which single route connects an origin to a destination, ranked by walk + wait + estimated ride + walk;

  4. and when no direct bus exists, it says so instead of improvising a transfer.

Quick start

Requires Python 3.10+ and uv.

git clone https://github.com/wedsamuel1230/hk-bus-mcp.git
cd hk-bus-mcp
uv sync --extra dev

uv run hk-bus-cli find-stops 太古城中心
uv run hk-bus-cli next-bus 720 --place 太古城中心
uv run hk-bus-cli plan-trip 淘大花園 --origin 太古城中心
uv run hk-bus-cli route-info 1A --include-stops

A prebuilt src/hk_bus_mcp/data/snapshot.json ships with the repository, so the first command works offline for stop lookup and needs no build step. Rebuild it (a few minutes on the first run) when you want current routes and stops:

uv run hk-bus-refresh

Use it from Codex

codex mcp add hk-bus -- uv run --directory "$(pwd)" hk-bus-mcp
codex mcp list

Any MCP client that supports stdio servers can use the same command: uv run hk-bus-mcp (or .venv/bin/hk-bus-mcp).

Use it from Xiaozhi

Xiaozhi does not connect to an HTTP MCP URL. xiaozhi-esp32-server publishes an "MCP 接入点" WebSocket address, and your tool runs as a local process that dials out to it. tools/mcp_pipe.py is that bridge: it starts this stdio server and forwards frames in both directions, reconnecting with exponential backoff.

cp .env.example .env        # put your ws://... MCP_ENDPOINT in it
uv sync --extra xiaozhi
uv run python tools/mcp_pipe.py

Then refresh the MCP tool list in the Xiaozhi console. Tools appear as plan_trip, next_bus, find_stops, route_info.

To keep the bridge running after you close the terminal, load it as a macOS agent. The endpoint token stays in the gitignored .env, not in the plist.

mkdir -p logs
sed -e "s|__PROJECT_DIR__|$(pwd)|g" -e "s|__UV_BIN__|$(command -v uv)|g" \
  deploy/com.xiaozhi.hk-bus-mcp.plist > ~/Library/LaunchAgents/com.xiaozhi.hk-bus-mcp.plist
launchctl load ~/Library/LaunchAgents/com.xiaozhi.hk-bus-mcp.plist
launchctl list | grep hk-bus-mcp          # status
tail -f logs/mcp_pipe.log                 # what the server is asking for

Set MCP_PIPE_DEBUG=1 in .env to log every WebSocket frame, which is how you see an incoming tools/call and its answer. Tokens in log lines are redacted.

Verify the bridge without a real endpoint first:

uv run python tools/mock_mcp_endpoint.py --serve       # terminal 1
MCP_ENDPOINT=ws://127.0.0.1:8765/mcp_endpoint/mcp/?token=test uv run python tools/mcp_pipe.py   # terminal 2

Tools

Four narrow tools rather than one broad one: the caller is usually a language model, and a tool with one obvious meaning per parameter is much harder to call wrongly.

Tool

Question it answers

Arguments

plan_trip

我想去淘大花園,點去?

destination (required), origin (optional, defaults to places.yaml → default_origin)

next_bus

720 幾時到太古城?

route (required), place or stop, optional operator (KMB/CTB), optional towards

find_stops

太古城中心附近有咩站?

place (required), limit (default 5)

route_info

1A 由邊度去邊度?

route (required), optional operator, direction, include_stops

Every tool returns a single JSON object containing speech_zh_hk, one short Cantonese sentence the assistant can read verbatim, plus structured fields for programmatic use.

Status values

status is part of the product, not error handling. A host should speak speech_zh_hk and, when the status says the request was underspecified, ask a follow-up question.

status

Meaning

ok

A verified answer follows.

need_origin

The trip needs a starting point and no default is configured.

need_place

next_bus needs a stop or place.

need_disambiguation

The place name matches several distinct locations; options lists them.

not_found

No such place, stop, or route in the data.

no_stop_found

The place resolved, but the route serves no stop near it.

route_not_serving

The stop exists, but this route does not call there.

no_arrivals

A direct bus exists, but there is no live ETA right now.

no_direct_route

No single route connects the two ends; the tool refuses to invent a transfer.

no_direct_route and no_arrivals are deliberately different: conflating them tells someone "there is no bus" when a bus is simply unmeasured.

Response contract

plan_trip (abridged):

{
  "status": "ok",
  "speech_zh_hk": "一,六一九號,三分鐘後到牛頭角道,坐約八分鐘,落車行五分鐘。",
  "origin": {"name": "太古城中心", "lat": 22.28676, "lon": 114.21734},
  "destination": {"name": "淘大花園", "lat": 22.32409, "lon": 114.21643},
  "options": [
    {
      "route": "619",
      "operator": "KMB",
      "board_stop_tc": "太古城中心",
      "alight_stop_tc": "牛頭角道",
      "eta_minutes": 3,
      "estimated_ride_minutes": 8,
      "walk_to_minutes": 2,
      "walk_from_minutes": 5,
      "total_minutes": 18,
      "stops_on_board": 6,
      "destination_tc": "順利"
    }
  ],
  "note": "eta_minutes 係實時數據;estimated_ride_minutes、walk_* 同 total_minutes 都係估算。只支援行路加一程巴士,唔支援轉車。"
}

Field semantics are a truthfulness rule:

  • eta_minutes is live operator data — a fact, refreshed at call time.

  • estimated_ride_minutes, walk_* and total_minutes are derived estimates and are always labelled as such. The official APIs publish no journey times.

  • Missing data stays missing. The tool never fills a gap with a guess.

next_bus searches sibling stops by base name within 250 m, groups the answer by direction, and uses towards to pick the one you actually want. This matters because a route's two directions normally stop at different stop ids on opposite sides of the road — 九龍灣站 (KT673) towards 九龍城碼頭 and 九龍灣站(東九文化中心) (KT319) towards 觀塘 are 81 m apart.

Data sources

Source

Endpoint

Used for

KMB / LWB

https://data.etabus.gov.hk/v1/transport/kmb

routes, stops, route-stop, real-time ETA

Citybus (CTB)

https://rt.data.gov.hk/v2/transport/citybus

routes, stops, route-stop, real-time ETA

Address Lookup Service

https://www.als.gov.hk/lookup

turning place names into coordinates

Notes that cost real debugging time and are encoded in the code:

  • direction is outbound / inbound in the URL path, but O / I inside the records — in both APIs.

  • Only CTB is accepted as a Citybus company code. NWFB answers with an empty list; BTH, LWB, KMB, GMB are rejected with HTTP 422.

  • There is no green-minibus endpoint under rt.data.gov.hk/v2/transport/gmb/ (404), so minibuses are out of scope.

  • ALS is an address service. Ask it for 旺角 and the top hit is a refuse collection point; ask it for 迪士尼樂園 and you get 康樂園 in Tai Po. It also never fails cleanly — a nonsense query still returns addresses. The code therefore combines a curated alias table, a score threshold, a distance cluster check, and a fallback that matches official bus-stop names.

Refreshing the snapshot

uv run hk-bus-refresh rebuilds src/hk_bus_mcp/data/snapshot.json from the live APIs. The snapshot holds stops, routes, route-stop lists, and a simplified→traditional character table learned from the operators' own name_sc/name_tc pairs.

KMB publishes whole-table endpoints, so three calls cover it. Citybus only publishes per-route and per-stop endpoints, so the refresh walks every route in both directions and then fetches each unique stop it does not already have. Re-runs reuse the previous snapshot's stops and are fast. Expect a few minutes on the first run.

The committed snapshot is a convenience, not a contract. Re-run the refresh for current routes; the ETA calls are always live and never served from the snapshot.

Customising places

places.yaml has three sections:

  • pinned — names you use, with coordinates you trust (fastest, no network);

  • aliases — spoken name → text sent to ALS;

  • my_places — your own names, e.g. 屋企.

Set default_origin: 屋企 so plan_trip can answer without asking where you are. Every coordinate currently in pinned came from a live ALS response and is listed in docs/VERIFICATION.md.

Environment variable

Purpose

HK_BUS_PLACES

Path to an alternative places.yaml.

HK_BUS_SNAPSHOT

Path to an alternative snapshot.json.

HK_BUS_ALS_MIN_SCORE

Minimum ALS match score (default 70).

MCP_ENDPOINT

Xiaozhi WebSocket endpoint, used only by tools/mcp_pipe.py.

MCP_PIPE_DEBUG

Set to 1 for per-frame bridge logging.

Architecture

flowchart LR
  subgraph hosts["MCP hosts"]
    X["Xiaozhi / ESP32<br/>WebSocket"]
    C["Codex / MCP client<br/>stdio"]
    L["hk-bus-cli"]
  end
  P["tools/mcp_pipe.py<br/>stdio ↔ WebSocket bridge"]
  S["hk-bus-mcp<br/>stdio MCP server"]
  V["BusService<br/>resolve · nearest · rank"]
  D[("snapshot.json<br/>stops · routes · sc→tc")]
  K[("KMB / LWB API")]
  T[("Citybus API")]
  A[("Address Lookup Service")]

  X --> P --> S
  C --> S
  L --> V
  S --> V
  V --> D
  V --> K
  V --> T
  V --> A

One business-logic layer (service.py) backs the MCP tools, the CLI, and the tests, so what the tests prove is what the device runs.

Development

uv sync --extra dev --extra xiaozhi
uv run pytest -q                      # 49 tests, offline, < 1 s

The suite is offline by design: tests/fixtures/als/*.xml are recorded Address Lookup Service responses, and the snapshot fixture is hand-built. No test may depend on live traffic, because a test that needs the internet cannot gate a release.

Live checks against the real APIs, run the way a host would:

uv run python tools/mcp_battery.py          # 11 scenarios, PASS/FAIL per scenario
uv run python tools/mcp_battery.py --json   # machine readable

The battery exits non-zero if any scenario fails, so it doubles as a deployment smoke test. tools/mcp_smoke.py is the minimal single-request version.

Path

Contents

src/hk_bus_mcp/server.py

MCP tool definitions and stdio transport

src/hk_bus_mcp/service.py

Business logic: resolution, nearest stops, ranking, speech

src/hk_bus_mcp/journey.py

Single-ride candidate search and ETA collection

src/hk_bus_mcp/places.py

ALS client, alias/pinned resolution, disambiguation

src/hk_bus_mcp/api.py

HTTP clients for KMB, Citybus, ALS

src/hk_bus_mcp/snapshot.py

Snapshot loading, indexes, simplified→traditional map

src/hk_bus_mcp/refresh.py

Snapshot rebuild script

src/hk_bus_mcp/speech.py

Cantonese sentence generation

src/hk_bus_mcp/cli.py

Developer CLI over the same service

tools/

Xiaozhi bridge, mock endpoint, live smoke tests

docs/

Verification log and lessons learned

deploy/

macOS LaunchAgent for the bridge

Limitations

  • No transfers, no MTR, no green minibus.

  • Ride time is estimated from stop counts (2 minutes per stop), not measured.

  • Speech recognition often returns simplified Chinese while operator data is traditional. The snapshot carries a simplified→traditional table learned from the operators' own name_sc/name_tc pairs (359 characters), so 九龙湾 and 九龍灣 both work.

  • Live diversions are not covered. The Transport Department publishes https://resource.data.one.gov.hk/td/tc/specialtrafficnews.xml, but it is road-level traffic news, not per-route bus changes. What the operator APIs do expose — destination, service_type (特別班次) and rmk_tc on each arrival — is surfaced instead.

  • Device-side behaviour (wake word, ASR accuracy, the Xiaozhi LLM's intent detection) is not verified here; this repository guarantees the MCP tool layer and its data.

Evidence and lessons

docs/VERIFICATION.md records the live API probes, the ALS observations behind places.yaml, the snapshot build output, the test run, real-ETA tool runs, the stdio and Xiaozhi-bridge handshakes, and an explicit list of what was not verified. Six bugs found during verification — including two that only appear on real data — are listed there with their regression tests.

docs/LESSONS.md collects the durable lessons: why the tool's real client is a language model, why simplified-Chinese input broke every lookup, why "no data" must not become "no bus", and the firmware bring-up traps.

License

MIT. Bus and stop data remains subject to the terms of the respective operators and DATA.GOV.HK.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    Related MCP Connectors

    Related MCP Servers