Skip to main content
Glama
README.md
# transit-mcp-server

MCP server for the [511.org SF Bay Open Data](https://511.org/open-data/transit) transit API. Gives an LLM live Bay Area transit data — agencies, routes, stops, real-time departures, vehicle positions and service alerts — across BART, Muni, AC Transit, Caltrain, VTA and every other 511-reporting operator.

6 tools, all read-only.

## Requirements

- Node.js 18+
- A free 511 API token from https://511.org/open-data/token

## Install

```bash
npm install
npm run build
```

## Configure

```json
{
  "mcpServers": {
    "transit": {
      "command": "node",
      "args": ["/absolute/path/to/transit-mcp-server/dist/index.js"],
      "env": { "TRANSIT_511_API_KEY": "your-token-here" }
    }
  }
}
```

| Variable | Required | Default | Purpose |
|---|---|---|---|
| `TRANSIT_511_API_KEY` | yes | — | Token from https://511.org/open-data/token |
| `TRANSIT_511_BASE_URL` | no | `https://api.511.org` | Override the API host |
| `TRANSIT_511_REQUEST_TIMEOUT_MS` | no | `30000` | Per-request timeout |
| `TRANSPORT` | no | `stdio` | `stdio` or `http` |
| `PORT` / `HOST` | no | `3000` / `127.0.0.1` | HTTP transport bind address |
| `MCP_PATH_SECRET` | when hosted | — | Serves the endpoint at `/mcp/<secret>`. **Required** when `HOST` is not loopback |
| `ALLOWED_ORIGINS` | no | localhost + claude.ai | Comma-separated origin allowlist |

## The quota is the main constraint

511 allows **60 requests per hour per key, shared across every endpoint**. That is low enough to shape how these tools should be used:

- Resolve operator codes and stop codes once, then reuse them. They do not change.
- Prefer `transit_list_service_alerts` with no `operator_id` — one call covers every agency.
- Never poll `transit_next_departures` in a loop. Ten checks over a commute is a sixth of the hourly budget.

`transit_list_operators` reports how much budget is left, read from the `RateLimit-Remaining` header 511 returns on every response. Exceeding the quota returns 429; request an increase from `transitdata@511.org`.

## Deploying (for Claude mobile / claude.ai connectors)

Same shape as any hosted MCP server: generate a path secret with `openssl rand -hex 32`, set `TRANSIT_511_API_KEY` and `MCP_PATH_SECRET` in the platform dashboard, and the included `Dockerfile` and `railway.json` work as-is on Railway, Render or Fly. The server refuses to start on a public interface without a secret. `/healthz` is an unauthenticated liveness probe.

Then on claude.ai **in a browser**: **Customize → Connectors → Add custom connector**, URL `https://your-app.up.railway.app/mcp/<secret>`.

## Tools

**Network** — `transit_list_operators`, `transit_list_lines`, `transit_find_stops`

**Real-time** — `transit_next_departures`, `transit_list_vehicles`

**Alerts** — `transit_list_service_alerts`

Every tool takes `response_format: "markdown" | "json"`. Markdown is the default and is optimized for an LLM reading it; JSON is the full structured payload. `structuredContent` is always populated regardless of format.

## Examples

**"When's the next N Judah?"**
→ `transit_find_stops` with `operator_id="SF"`, `query="judah"` to get the stop code, then `transit_next_departures` with that code and `line="N"`.

**"Is BART running normally?"**
→ `transit_list_service_alerts` with `operator_id="BA"`.

**"Anything wrong on my commute?"**
→ `transit_list_service_alerts` with no operator — one call sweeps every Bay Area agency.

**"Where are the trains right now?"**
→ `transit_list_vehicles` with `operator_id="BA"`.

## Design notes

**Read-only by construction.** 511 publishes no write endpoints, and every tool carries `readOnlyHint: true`. A test asserts it.

**One `operator_id`, mapped per endpoint.** 511 calls this parameter `operator_id` on its static endpoints and `agency` on its real-time ones, for the same value. Every tool here takes `operator_id` and the client maps it. That split is 511's problem, not the caller's.

**The two real-time endpoints have genuinely different envelopes.** `StopMonitoring` has **no** `Siri` root wrapper; `VehicleMonitoring` **does**. The published spec shows one for both — the spec is wrong, and parsing the documented shape would return nothing at all for departures. Both are parsed as the live API actually emits them, with a test pinning each.

**Arrivals carry the countdown, not departures.** `ExpectedDepartureTime` is null in essentially every real row, so keying a countdown off it would show a stop with no service. `ExpectedArrivalTime` is the reliable field.

**A UTF-8 BOM is stripped before parsing.** 511 prefixes JSON bodies with `U+FEFF`, which makes a naive `JSON.parse` throw on a perfectly valid payload. Auth failures are plain text with no BOM, so the strip happens after the status check.

**Values that look like numbers and booleans often are not.** Coordinates and bearings arrive as JSON strings, `VehicleAtStop` is the string `"false"`, and `""` is used throughout where null is meant. Coercing blindly would turn a missing position into a valid-looking 0,0 off the coast of Africa, so empty strings are treated as absent rather than zero.

**The epoch-zero sentinel is not a timestamp.** A trip that is scheduled but has no vehicle assigned reports `RecordedAtTime` of `1970-01-01T00:00:00Z`. It renders as "no vehicle assigned yet" rather than "recorded 56 years ago".

**GTFS-Realtime enums are decoded.** 511's JSON alert rendering emits `"effect": 3` where the XML rendering says `SignificantDelays`. Both cause and effect are mapped back to words.

**511-internal pseudo-agencies are filtered out.** `5E`, `5F`, `5O` and `5S` are 511 Emergency, Flap Sign, Operations and Staff — they appear in the operator list carrying no service data.

**Everything is Pacific.** Timestamps arrive as UTC and are rendered in `America/Los_Angeles`, so daylight saving is handled once here rather than by the model twice a year. Note that 511's own `TimeZone` field reports `America/Vancouver` for every Bay Area agency — a known upstream data bug, ignored deliberately.

**Truncation is always stated.** 511 does not paginate; it returns whole collections, and a large agency has thousands of stops. Tools take a client-side `limit` and every trimmed result says how much was withheld, because a silently shortened list reads as "that is everything".

## Caveats

- The hourly quota is 60 requests across all endpoints. This is the binding constraint on any workflow.
- Operator codes are easy to guess wrong: VTA is `SC` (not `VT`), Capitol Corridor is `AM` (not `CC`), Tri Delta is `3D`. `transit_list_operators` prints these traps in its output.
- Stop codes belong to one operator and are not interchangeable between agencies.
- `transit_find_stops` filters on this server, so a narrow query does not save quota — the full stop list is fetched either way.
- Real-time predictions extend roughly 90 minutes ahead, and 511 omits a route's final arrival-only stop from the departures feed.
- `tripupdates` and `vehiclepositions` are protobuf-only with no JSON option, so they are deliberately not exposed — supporting them would mean taking on a protobuf dependency for data the SIRI endpoints already cover.

## Project layout

```
src/
├── index.ts               # entry point, transport selection
├── constants.ts           # enums, limits, operator-code traps
├── types.ts               # interfaces for every 511 entity
├── services/
│   └── transit-client.ts  # fetch wrapper, auth, BOM stripping, quota tracking, errors
├── schemas/
│   ├── inputs.ts          # Zod input schemas
│   └── outputs.ts         # structuredContent schemas
├── formatters/
│   ├── response.ts        # limiting, truncation, Pacific-time rendering
│   └── entities.ts        # per-entity markdown rendering
└── tools/
    ├── network.ts         # operators, lines, stops
    ├── departures.ts      # real-time arrivals and vehicles
    └── alerts.ts          # service alerts
```

## Tests

```bash
npm run build
npm test            # 42 checks: handshake, BOM, envelopes, quirks, errors (mocked API)
npm run test:http   # 17 checks: config validation, path-secret gating, method handling, origins
```

Both suites run against a local mock that deliberately reproduces 511's real quirks — the BOM, the missing `Siri` wrapper, stringified booleans and coordinates, the epoch sentinel, and plain-text error bodies — because those are exactly what a naive client gets wrong.

TDQS

A4.8/5.0

Scored across 6 tools

Disambiguation5/5

Each tool targets a distinct resource: operators, lines, stops, departures, vehicles, and alerts. Descriptions explicitly cross-reference when to use which (e.g., 'Don't use when' sections), eliminating ambiguity. No two tools overlap in purpose.

Naming Consistency5/5

All tools share the 'transit_' prefix and follow a consistent verb-object pattern: list_operators, list_lines, find_stops, next_departures, list_vehicles, list_service_alerts. Minor variation between 'list', 'find', and 'next' but the style is uniform and predictable.

Tool Count5/5

Six tools are well-scoped for a transit information server, covering discovery (operators, lines, stops), real-time queries (departures, vehicles), and alerts. No tool is redundant, and the count feels neither sparse nor bloated.

Completeness4/5

The tool surface covers the full workflow of querying transit info: discover agencies, lines, stops, then get live departures, vehicle positions, and alerts. A minor gap is the lack of a dedicated scheduled timetable tool, though the departures endpoint includes aimed times. Overall, the domain is well covered.

Maintenance

ActivityMaintained
ResponsivenessNo issues