Skip to main content
Glama
README.md
# gbfs-mcp

An MCP server for GBFS bikeshare feeds. It works with any of the ~1,500 systems that publish
GBFS — Citi Bike, Capital Bikeshare, Vélib', Ecobici — and it's guaranteed to work with BIXI
Montréal, because BIXI is the default and the one I actually test against.

The thing I wanted was simple. I'm at work, I want to bike home, and I want to ask Claude
whether that's going to work before I walk outside:

```
me    → "I'm at work, can I bike home?"

Claude→ check_route(origin, destination)

      → Yes. 138 bikes across 15 stations near you.
        At the other end Regina / de Verdun is empty right now,
        but there are 35 bikes and 35 free docks within a 9 minute walk.
        Feed last reported 4 minutes ago.
```

That's the whole point. Everything below is in service of that answer being trustworthy.

## What it does and what it doesn't

It tells you what a feed says right now. That's it. No history, no predictions, and no saved
addresses — you pass coordinates in. I kept it that way on purpose: it stays general enough
to work in any city, and nothing personal ends up on a public endpoint.

Two rules run through all of it.

**Unknown is not zero.** If a feed doesn't report dock counts, that means it doesn't know. If
I returned `0` there you'd ride to a station believing it's full when it might be half empty.
So unknowns say they're unknown. Ask for cargo bikes on a feed that can't count them and you
get `unknown`, not `0`.

**Nearby beats exact.** These feeds lag. BIXI's median station last reported about 7 minutes
ago, and the slowest 10% are closer to half an hour. So "this station has 2 docks" is a much
weaker answer than "there are 30 docks within 400m". `get_neighbourhood` and `check_route`
pool everything nearby for that reason, and every station tells you how old its reading is.

## Tools

Nine of them. Full reference with real output in [docs/tools.md](docs/tools.md).

| tool | what it answers |
|---|---|
| `find_stations` | Where are the stations near here, or named this? Sorted by distance, with walk times. |
| `get_station` | Everything about one station. |
| `get_neighbourhood` | How many bikes and docks within a radius. The answer that survives a stale feed. |
| `check_route` | Can I ride A to B right now? Grades both ends good / tight / poor / unknown. |
| `get_network_status` | Whole system at a glance — totals, empty and full stations, how fresh the data is. |
| `get_alerts` | Service alerts, filtered to the ones actually in effect. |
| `get_system_info` | Operator details — timezone, contact, website. |
| `list_vehicle_types` | What the vehicle type ids mean for this system. |
| `describe_system` | What version this feed is, how I worked that out, and what it can and can't tell you. |

Every tool takes an optional `system`. Before you conclude a system "has no e-bikes", call
`describe_system` — plenty of older feeds simply cannot report them, and that's a different
thing from having none.

## Running it

```bash
npm install
npm test           # 69 tests against saved real feeds, no network needed
npm run test:live  # hits the actual feeds
```

### Locally, over stdio

```json
{
  "mcpServers": {
    "gbfs": {
      "command": "npx",
      "args": ["tsx", "/absolute/path/to/gbfs-mcp/src/stdio.ts"]
    }
  }
}
```

### Deployed, as a Cloudflare Worker

```bash
npx wrangler login
npm run deploy
```

It's stateless JSON-RPC over HTTP. No Durable Objects, no KV, no database. The server is
read-only and never pushes anything to the client, so it doesn't need sessions or SSE — which
is why it scales to zero and costs nothing sitting idle. `GET /` gives you a description, MCP
clients POST to the same URL.

## Adding another city

Four systems are built in: `bixi`, `citibike`, `capitalbikeshare`, `ecobici`. Add more with
the `GBFS_SYSTEMS` env var, no code change. Discovery URLs are in
[MobilityData's catalog](https://github.com/MobilityData/gbfs/blob/master/systems.csv):

```toml
GBFS_SYSTEMS = '''[
  {"id":"velib","name":"Vélib' Métropole",
   "discoveryUrl":"https://velib-metropole-opendata.smovengo.cloud/opendata/Velib_Metropole/gbfs.json"}
]'''
```

There's a `pinVersion` field too, for when a system's newer feed is broken and you want to
stay on the old one.

## The version problem

This is the part that took the most work, so it gets its own doc:
[docs/gbfs.md](docs/gbfs.md).

Short version: GBFS runs from 1.0 to 3.0 and the versions genuinely break each other. Of the
systems in the catalog, 964 are on 2.3, 562 on 3.0, 239 on 2.2 (that's BIXI), 167 on 1.1, and
114 on 1.0. You can't just pick one and ignore the rest.

So the client negotiates. If a system publishes `gbfs_versions.json` it takes the newest
version it understands and follows that URL. Otherwise it reads the `version` field. If
there's no `version` field at all, the feed predates 1.1, so it's 1.0.

This isn't box-ticking. Citi Bike defaults to 1.1, and **1.1 has no `vehicle_types` at all**.
Its 2.3 tree does. Negotiating up is the difference between being able to answer "is there an
e-bike here" and not, across 2,509 stations.

## Feed quirks I ran into

Every one of these came from pointing the code at a live feed, not from reading the spec.
Details in [docs/gbfs.md](docs/gbfs.md).

- BIXI's root `gbfs.json` only advertises the old 1.0 tree. The `2-2` tree it actually
  registers isn't linked from it.
- BIXI publishes no `gbfs_versions` feed — 404 everywhere I looked for it.
- BIXI has a station with a **negative** `last_reported`. Do the arithmetic naively and it
  looks 56 years old.
- BIXI's alerts feed still serves entries from 2023 and 2024. Show those unfiltered and
  you'd tell someone the network is closed for a snowstorm three years ago.
- Citi Bike writes its versions as `"v2.3"`, not `"2.3"`. The spec says bare `X.Y`. I
  rejected the prefix at first and it silently stuck the client on 1.1.
- v3.0 feed URLs drop the `.json` extension, so I match feeds by name instead of building
  paths.
- Station ids are opaque strings. `"345"` at BIXI, a UUID at Citi Bike, `"ICONIC"` at
  Ecobici. Don't assume they're numbers.

## Layout

More detail in [docs/architecture.md](docs/architecture.md).

```
src/core/     pure logic, imports nothing from MCP or Workers
src/mcp/      tool definitions, shared by both transports
src/worker.ts Cloudflare entrypoint
src/stdio.ts  local entrypoint
test/fixtures real feeds I captured — v1.1, v2.2 and v3.0
```

`src/core/` doesn't know MCP exists. If I ever want this logic behind a plain HTTP API
instead, it lifts out unchanged.

## Related

- [bixi-monitor](https://github.com/MackTr/bixi-monitor) — long-run history for one BIXI
  station, plus the dashboard
- A second MCP for station 345 specifically — its history and prediction models — is planned
  separately. This one stays general and holds nothing personal.