hk-bus-mcp
README.md
# hk-bus-mcp
[](https://github.com/wedsamuel1230/hk-bus-mcp/actions/workflows/ci.yml)
[](pyproject.toml)
[](LICENSE)
A Hong Kong bus assistant exposed as a local [MCP](https://modelcontextprotocol.io) 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
- [Why one bus and no transfers](#why-one-bus-and-no-transfers)
- [Quick start](#quick-start)
- [Use it from Codex](#use-it-from-codex)
- [Use it from Xiaozhi](#use-it-from-xiaozhi)
- [Tools](#tools)
- [Response contract](#response-contract)
- [Data sources](#data-sources)
- [Refreshing the snapshot](#refreshing-the-snapshot)
- [Customising places](#customising-places)
- [Architecture](#architecture)
- [Development](#development)
- [Limitations](#limitations)
- [Evidence and lessons](#evidence-and-lessons)
- [License](#license)
## 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](https://docs.astral.sh/uv/).
```bash
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:
```bash
uv run hk-bus-refresh
```
## Use it from Codex
```bash
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`](tools/mcp_pipe.py) is that bridge: it starts this stdio server
and forwards frames in both directions, reconnecting with exponential backoff.
```bash
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.
```bash
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:
```bash
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):
```json
{
"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`](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`](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
```mermaid
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
```bash
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:
```bash
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`](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`](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](LICENSE). Bus and stop data remains subject to the terms of the respective
operators and DATA.GOV.HK.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues