MCP server: One MCP server.

Charlotte Area Transit (CATS)

@ajwannMCP server · External

CATS bus and light rail trip planning, live arrivals, timetables, routes, and service alerts

Public transportv2.1.1MITPython
Freeno charge per call

One address for any MCP client. You sign in once, after adding it.

What it does

  1. Reads
  2. Writes
  3. High impact

At most: only reads or looks up information.

What it costs

Free

Odel charges nothing per call. The publisher may charge for its own service.

Tools

None listed · checked 26 Sep 2026

Odel could not list its tools without signing in.

Try it in the Inspector
README19 min read

QueensCoach ♔♘

CI

QueensCoach is an MCP server for Charlotte Area Transit System (CATS) buses and light rail, built on the agency's public GTFS schedule and GTFS-Realtime feeds. It plans trips, predicts arrivals, reads the timetable, describes routes, and reports service alerts. It runs over stdio, launched by the MCP client that uses it, or over HTTP with Google OAuth in front of it, for a hosted server. Both transports serve the same tools and resources.

Hosted server

A public instance runs on Google Cloud Run. Sign in with any Google account:

https://queenscoach.adamwanninger.com/mcp

There is zero guarantee of uptime. The hosted server is provided as-is. It may be slow, down, switched off by its spending cap, or retired without notice. For anything you rely on, run your own: over stdio, or on your own Google Cloud project with deploy/GCP.md.

Signing in tells the server your Google account's email address, which is used only to decide whether to admit you, and is never stored. The tokens it issues record your account's opaque Google ID and nothing else about you. A location you share with a tool arrives as an argument to that call, is used to answer it, and is not stored. The privacy policy and terms of service cover the hosted server.

Adding it to Claude

Claude calls a remote MCP server a connector. Custom connectors are available on Claude's paid plans.

  1. Open Settings → Connectors. On the web that's claude.ai/settings/connectors; in the desktop app, Settings then Connectors.
  2. Click Add custom connector at the bottom of the list.
  3. Give it a name, QueensCoach, and paste the URL above as the remote MCP server URL. Leave the advanced OAuth fields empty: this server registers your client automatically.
  4. Click Add, then Connect on the connector that appears. A browser window opens for the Google sign-in; approve it and it closes itself.
  5. In a chat, open the tools menu and check that QueensCoach is enabled. Its seven tools then appear.

The connector belongs to your Claude account, so it follows you across web, desktop, and mobile. To disconnect, remove it from that same Connectors page; that revokes the tokens this server issued.

Adding it to Claude Code

bash
claude mcp add --transport http queenscoach https://queenscoach.adamwanninger.com/mcp

Then run /mcp, pick queenscoach, and choose Authenticate, which opens the same Google sign-in. /mcp shows the connection's state afterwards. A server added this way loads when Claude Code next starts.

Any other client

Any MCP client that supports remote servers over streamable HTTP with OAuth works: give it the same URL and it discovers the rest.

Tools

ToolPurpose
plan_tripTrips between two places by bus and train, with walking and transfers, adjusted by live delays.
get_arrivalsLive predicted arrivals at a named stop, or at the station nearest a location.
get_scheduleThe published timetable at a stop for any date: departures, first and last trips.
get_routeOne route on a date: destinations, stops in order, hours, and how often it runs.
get_service_alertsCurrent and upcoming detours and disruptions, system-wide or for a route, stop, or place.
list_stopsStops and stations with the routes serving each, nearest first from a location.
list_vehiclesGPS positions of buses and trains in service: one by number, a route's, or all of them.

Ask Claude "what's the closest train station to me?", "when's the next train at my stop?", or "how do I get to the airport from here?" and it passes your device's location to the tools. The server never sees a location it isn't handed, so this needs a client that shares the device's location with the model.

plan_trip

ArgumentTypeNotes
origin_stopstringWhere to start: a stop id, code, or name.
origin_latitude, origin_longitudenumberOr start from a location, walking to nearby stops.
destination_stopstringWhere to go: a stop id, code, or name.
destination_latitude, destination_longitudenumberOr finish at a location.
dateYYYY-MM-DDService date in Charlotte (default today).
depart_atHH:MMLeave no earlier than this. Default: now.
arrive_byHH:MMOr arrive no later than this.
max_transfersinteger0 to 3 (default 2).
max_walk_metersintegerLongest walk to the first stop or from the last, 100 to 2000 (default 800).

Returns up to three itineraries, each with walking and riding legs: the route, the vehicle's real destination, where to board and get off, times, the wait at each transfer, and stops travelled. Journeys with more transfers are offered only when they arrive sooner (or, for arrive_by, leave later). For trips starting within three hours of now, rides carry live delays and are marked live. walkingIsAnOption appears when the two places are within the walking limit of each other.

Walking is an estimate: the straight-line distance stretched by 30%, at 4.5 km/h. The feed has no street map, so real walks can be longer. Transfers allow up to 400 m on foot and two minutes of slack.

get_arrivals

ArgumentTypeNotes
stopstringStop id (02400), stop code, or part of a stop name (CTC Station).
latitude, longitudenumberInstead of stop: use the nearest station to this location.
routestringOptional route filter.
modebus | trainOptional filter.
limitintegerMax arrivals (default 10, cap 50).

Give either stop or a location. With a location, the filters choose the station too: mode: train finds the nearest stop a train calls at, not a closer bus stop, and the response carries its metersAway.

Returns minutes away, predicted and scheduled times, schedule deviation, the vehicle number, the platform (stopId), and that vehicle's live position. When a name query is ambiguous, the best match is used and the runners-up are listed under otherStopsMatchingQuery. Current service alerts affecting the stop or its routes are attached as serviceAlerts, in the same shape get_service_alerts returns. For times beyond the next hour or so, use get_schedule.

get_schedule

ArgumentTypeNotes
stopstringStop id, code, or name.
latitude, longitudenumberOr the nearest stop serving the route or mode.
routestringOptional route filter.
modebus | trainOptional filter.
dateYYYY-MM-DDService date (default today).
after, beforeHH:MMTime window. after defaults to now today, or the start of service.
limitintegerMax departures (default 20, cap 100).

Scheduled departures with each trip's route and destination, plus firstDeparture and lastDeparture for the day. A service day runs past midnight, as GTFS does: the last trains of Tuesday leave at 1:31 am on Wednesday, and 25:30 in after or before means 1:30 am that night. Use get_arrivals for live predictions.

get_route

ArgumentTypeNotes
routestringRequired. 9, 501, Blue Line, Airport.
dateYYYY-MM-DDService date (default today).

For each direction: the destination, the stops in order (following the pattern most trips use, with any others summarized), first and last departure, and the typical minutes between trips in the early morning, morning rush, midday, afternoon rush, evening, and late night. Also trips on each of the next seven days and the number of active alerts. A query matching several routes lists them instead.

get_service_alerts

ArgumentTypeNotes
routestringAlerts naming the route or any stop it serves.
stopstringAlerts naming the station or any route serving it.
latitude, longitudenumberAlerts affecting stops within 800 m.

Give at most one; with none, every alert. Ended alerts are left out. Each alert has its headline, description, effect and cause with CATS's detail text, whether it is active or upcoming, its periods (untilFurtherNotice for open-ended ones), and the routes and stops it names.

list_stops

ArgumentTypeNotes
latitude, longitudenumberA location; results are then nearest first, with metersAway.
querystringStop id, stop code, or part of a stop name.
routestringOnly stops this route serves.
modebus | trainOnly stops with that service, e.g. train for the nearest rail station.
limitintegerMax stations (default 10 with a location, 250 without; cap 250).

Each station lists its stopIds, coordinates, modes, and routes (id, number, long name, mode): for example 501 · Light Rail - Lynx Blue Line, 510 · CityLYNX Gold Line, or bus routes like 29. Stops no scheduled trip calls at are left out, and nothing farther than 50 km from the location is returned.

list_vehicles

ArgumentTypeNotes
vehiclestringVehicle number as shown on the bus/train, e.g. 2301, LRV307.
routestringRoute: 9, 501, Blue Line, Mt. Holly Road.
modebus | trainOptional filter.
limitintegerMax vehicles to return (default and cap: 250).

All arguments are optional; with none, every vehicle in service is returned. Each vehicle has position, heading, speed, occupancy (CATS currently reports none), headsign, and the next scheduled stop. matches and countsByMode report the total even when the list is truncated.

Resources

The feeds behind the tools, for clients and models that want the data itself.

URITypeContents
gtfs://staticJSONThe files in the static GTFS archive, their sizes and URIs, and when it was fetched.
gtfs://static/{file}CSVAny table in the archive as CATS publishes it. routes.txt, stops.txt, trips.txt, and stop_times.txt are also listed individually.
gtfs://realtime/vehicle-positionsJSONThe decoded VehiclePositions feed.
gtfs://realtime/trip-updatesJSONThe decoded TripUpdates feed.
gtfs://realtime/alertsJSONThe decoded Alerts feed.

Static tables come from the same cached download the tools use. Realtime resources are the decoded feed entities with their GTFS-Realtime field names, raw ids, and Unix timestamps, without the schedule joins the tools add. Each resource's fetchedAt is Eastern time, like the tools. stop_times.txt is large (about 12 MB in the live feed).

Install

Requires Python 3.11+.

bash
pip install queenscoach

Or run it without installing anything, which is how most MCP clients launch it:

bash
uvx queenscoach

From a clone instead, to hack on it or to run the HTTP transport from source:

bash
python3 -m venv .venv
.venv/bin/pip install .          # '.[gcp]' adds the Firestore token store

Transports

Pick one with --transport or QUEENSCOACH_TRANSPORT; the default is stdio.

bash
queenscoach                                  # stdio (default)
queenscoach --transport http --port 8000     # streamable HTTP + Google OAuth

stdio

For a server the client launches itself. No authentication: the client already owns the process.

Register it with Claude Code:

bash
claude mcp add queenscoach -- uvx queenscoach

Or in an MCP client config file:

json
{
  "mcpServers": {
    "queenscoach": {
      "command": "uvx",
      "args": ["queenscoach"]
    }
  }
}

An installed copy works just as well, given an absolute path (/absolute/path/to/.venv/bin/queenscoach): MCP clients rarely share your shell's PATH. python -m queenscoach runs the same server, so any interpreter with the package installed works as the command.

stdout carries MCP protocol traffic only; all diagnostics go to stderr.

HTTP with Google OAuth

For a hosted server anyone with the URL can reach. Every request to /mcp needs a bearer token, and the only way to get one is to sign in with a Google account that is on the allow list.

How the sign-in works. MCP clients register themselves dynamically and expect an authorization server at the MCP server's own origin. Google offers neither dynamic registration nor tokens audience-restricted to a third-party resource, so this server is its own OAuth 2.1 authorization server and delegates only the login to Google:

MCP client  <--OAuth-->  queenscoach  <--OAuth-->  Google

Google's answer is used exactly once, to learn which account signed in. That email is checked against the allow list, and only then does this server mint its own tokens. Google's tokens are never handed to the client.

One-time setup in Google Cloud. At console.cloud.google.com/auth/clients, create an OAuth client of type Web application and add one authorized redirect URI:

https://your-public-url/auth/google/callback

It must match QUEENSCOACH_PUBLIC_URL exactly. The server logs the URI it expects at startup. Copy the client ID and secret into the environment below.

Run it. .env.example lists every setting; the shell form is:

bash
export QUEENSCOACH_GOOGLE_CLIENT_ID=...apps.googleusercontent.com
export QUEENSCOACH_GOOGLE_CLIENT_SECRET=...
export QUEENSCOACH_ALLOWED_EMAILS=you@example.com
export QUEENSCOACH_PUBLIC_URL=https://queenscoach.example.com

queenscoach --transport http --port 8000

Then point a client at https://queenscoach.example.com/mcp; it discovers the rest and opens a browser for the Google sign-in. In Claude Code:

bash
claude mcp add --transport http queenscoach https://queenscoach.example.com/mcp

Access is denied by default. Startup fails unless QUEENSCOACH_ALLOWED_EMAILS, QUEENSCOACH_ALLOWED_DOMAINS, or an explicit QUEENSCOACH_ALLOW_ANY_GOOGLE_ACCOUNT=true says who may get in, so a misconfigured deployment is unreachable rather than open to every Google account on the internet. Unverified Google addresses are always refused.

Endpoints.

PathPurpose
/mcpThe MCP endpoint. Requires Authorization: Bearer <token>.
/.well-known/oauth-protected-resource/mcpPoints clients at the authorization server.
/.well-known/oauth-authorization-serverThis server's OAuth metadata.
/registerDynamic client registration (RFC 7591).
/authorize, /token, /revokeThe OAuth endpoints.
/auth/google/callbackWhere Google returns the user.

scripts/install.sh does a whole deployment: a system user under /opt, a Cloudflare tunnel and its DNS record created over the API, both systemd units, and a verification pass. No port forwarding, so it works behind CGNAT or a locked router. See deploy/.

scripts/deploy-gcp.sh does the same on Google Cloud Run, in your own GCP project: the project itself, Firestore for sign-ins, the client secret in Secret Manager, a container built by Cloud Build, a monthly budget with an optional hard spend cap, and the same verification pass. It scales to zero, so a personal server costs next to nothing. See deploy/GCP.md.

Deployment notes.

  • By default the server speaks plain HTTP and expects a tunnel or proxy to terminate TLS, which is what the install script sets up. Setting QUEENSCOACH_TLS_CERT and QUEENSCOACH_TLS_KEY instead makes it serve HTTPS itself, for a deployment with nothing in front of it.
  • QUEENSCOACH_PUBLIC_URL is what clients dial and is this server's OAuth issuer identifier, so it must be the external URL, not the bind address.
  • Token state is in memory by default and therefore per-process: restarting invalidates outstanding tokens. QUEENSCOACH_TOKEN_STORE=firestore keeps it in Firestore instead (install the gcp extra: pip install 'queenscoach[gcp]'), so sign-ins survive restarts and every instance shares them. Pair it with QUEENSCOACH_STATELESS_HTTP=true so that any instance can answer any request.
  • Access tokens last an hour and refresh tokens 30 days, both rotated on refresh.

Data sources

Realtime (GTFS-Realtime protobuf, refreshed every 20s):

  • https://gtfsrealtime.ridetransit.org/GTFSRealTime/Vehicle/VehiclePositions.pb
  • https://gtfsrealtime.ridetransit.org/GTFSRealTime/TripUpdate/TripUpdates.pb
  • https://gtfsrealtime.ridetransit.org/GTFSRealTime/Alert/Alerts.pb

Static schedule (cached 6h), which turns feed identifiers into route names, stop names, and coordinates, and is the timetable the schedule and planning tools read:

  • https://gtfsrealtime.ridetransit.org/GTFSStatic/api/GTFSDownload/GTFS.zip

The server reads agency.txt (for the time zone), routes.txt, stops.txt, trips.txt, stop_times.txt, and calendar.txt and calendar_dates.txt where present. stop_times.txt is streamed and packed into arrays per trip, about 15 MB in memory for the full CATS feed. shapes.txt is not read. Every file in the archive is still available as a resource.

Feed quirks this server works around

Verified against live feed captures:

  • VehiclePosition.stop_id and current_stop_sequence are unusable. None of the 158 vehicle stop ids in a sample capture matched any stop in the published schedule, and reported sequence numbers exceeded the trip's own stop count (e.g. sequence 192 on a 52-stop trip). This server never surfaces them; next-stop data comes from the TripUpdates feed instead, whose stop ids resolve 100%.
  • StopTimeEvent.delay is never populated. Schedule deviation is computed from time minus scheduled_time, which are both present.
  • TripUpdates cover ~83% of active vehicles, so nextStop is omitted rather than guessed for the remainder.
  • Route matching is exact-first, so a query of 5 returns route 5, not 501 or 510.
  • Most headsigns name only a direction. 61 of 64 routes label trips just "Inbound" or "Outbound", so the schedule and planning tools report each trip's last stop as its destination.
  • Occupancy is never reported. Every vehicle's occupancy_status is NO_DATA_AVAILABLE.
  • The schedule looks only a few weeks ahead. CATS publishes calendar_dates.txt alone, covering about four weeks (8 September to 4 October 2026 in the feed checked). Timetable answers report the range as scheduleCovers and refuse dates outside it.
  • Open-ended alerts end in the year 3000. An alert ending more than five years out is reported as untilFurtherNotice.
  • parent_station is too loose to group platforms. CATS uses it for trip-planner places spanning up to 900 m of unrelated stops, so stations are grouped by name and distance instead.

Behavior notes

  • Arrival predictions already in the past are filtered out; no negative ETAs.
  • Same-named stops within 200 m are one station: the two platforms of a Gold Line stop, or bus stops facing each other across a street. list_stops, get_arrivals, get_schedule, and plan_trip all treat them as one place.
  • Feed responses are capped in size and time-bounded; one slow feed cannot hang a call.
  • Concurrent calls share a single in-flight fetch per feed, and one call giving up does not abort a fetch the others are awaiting.
  • If a refresh fails but cached data exists, the last good data is served rather than an error. feedAgeSeconds on every response shows how stale it is.
  • The alerts feed is supplementary for get_arrivals and get_route: if it fails, they answer without alerts. plan_trip likewise falls back to the timetable, and says so, when live predictions are unavailable.
  • Dates and clock times in arguments are Charlotte local time. A service day runs past midnight, as GTFS does, and its clock times count from noon minus 12 hours on the service date, so they stay right across daylight-saving changes.
  • Parsing the schedule, trip planning, and timetable scans run in a worker thread, so a slow one does not stall other requests. The first call after the schedule is downloaded waits a second or so for it to parse; a trip search then takes well under a second.
  • Every time in a tool response is Eastern time (America/New_York), ISO 8601 with the offset in effect on that date: 2026-09-08T17:59:48-04:00 during daylight saving time, 2026-12-21T19:00:00-05:00 otherwise. The two 1:30 am's on the night clocks fall back are told apart by their offsets. Coordinates are WGS84 decimal degrees.

Configuration

Feeds (both transports)

All optional; defaults target the CATS feeds above. Durations are in milliseconds.

VariableDefault
QUEENSCOACH_VEHICLE_POSITIONS_URLCATS vehicle positions feed
QUEENSCOACH_TRIP_UPDATES_URLCATS trip updates feed
QUEENSCOACH_ALERTS_URLCATS alerts feed
QUEENSCOACH_STATIC_GTFS_URLCATS static GTFS zip
QUEENSCOACH_REALTIME_TTL_MS20000
QUEENSCOACH_STATIC_TTL_MS21600000
QUEENSCOACH_REQUEST_TIMEOUT_MS30000
QUEENSCOACH_MAX_FEED_BYTES33554432
QUEENSCOACH_MAX_STATIC_BYTES268435456

Feed URLs must be http or https; anything else is rejected at startup.

Transport

VariableCLIDefault
QUEENSCOACH_TRANSPORT--transportstdio

HTTP transport

Read only when --transport http is selected.

VariableCLIDefaultNotes
QUEENSCOACH_HTTP_HOST--host127.0.0.1Bind address.
QUEENSCOACH_HTTP_PORT--port8000Bind port.
QUEENSCOACH_PUBLIC_URL--public-urlhttp://localhost:<port>External origin; the OAuth issuer.
QUEENSCOACH_GOOGLE_CLIENT_IDrequiredFrom Google Cloud credentials.
QUEENSCOACH_GOOGLE_CLIENT_SECRETrequiredFrom Google Cloud credentials.
QUEENSCOACH_ALLOWED_EMAILS—Allowed addresses, comma- or space-separated.
QUEENSCOACH_ALLOWED_DOMAINS—Allowed bare domains, e.g. example.com.
QUEENSCOACH_ALLOW_ANY_GOOGLE_ACCOUNTfalseOpt in to admitting every Google account.
QUEENSCOACH_TLS_CERT--tls-cert—PEM chain, to serve HTTPS directly.
QUEENSCOACH_TLS_KEY--tls-key—PEM private key. Required with the above.
QUEENSCOACH_ACCESS_TOKEN_TTL_MS3600000Access token lifetime.
QUEENSCOACH_REFRESH_TOKEN_TTL_MS2592000000Refresh token lifetime.
QUEENSCOACH_TOKEN_STOREmemorymemory, or firestore (needs the gcp extra).
QUEENSCOACH_FIRESTORE_DATABASE(default)Firestore database for the token store.
QUEENSCOACH_STATELESS_HTTPfalseServe without MCP sessions, for restarts and multiple instances.

One of the three allow-list settings is required; see above.

Layout

ModuleRole
config.pyEnvironment parsing and validation
feed_http.pyBounded, time-limited HTTP fetch
cache.pyTTL cache with single-flight refresh
gtfs_csv.pyGTFS-flavored CSV reading
static_gtfs.pyStatic schedule: routes, stops, trips, their timetables, and the service calendar
realtime.pyGTFS-Realtime protobuf decoding
transit.pyDomain layer: joins realtime to schedule, resolves queries
timetable.pyDomain layer for the published timetable: service days, departures, route patterns
planner.pyTrip planning: a Connection Scan over the timetable, with live delays
tools.pyThe tools' behavior and JSON payloads
resources.pyThe GTFS feeds as MCP resources
server.pyMCP tool and resource registration, and schemas
oauth.pyOAuth authorization server, with Google as the login
token_store.pyWhere OAuth state is kept, and the in-memory default
token_store_firestore.pyThe Firestore token store (the gcp extra only)
http.pyStreamable HTTP transport and the Google callback route
main.pyCLI entry point and transport selection

Plus scripts/install.sh, which deploys the HTTP transport onto a Debian host, and scripts/deploy-gcp.sh with the Dockerfile, which deploy it to Google Cloud Run.

Development

bash
.venv/bin/pip install -e '.[dev]'
.venv/bin/pytest        # offline, against recorded feed fixtures
.venv/bin/mypy          # strict
.venv/bin/ruff check .
.venv/bin/ruff format .

Tests run against protobuf and GTFS fixtures captured from the live feeds, so they are deterministic and make no network calls. The GTFS fixture is a trimmed extract of the real archive: trips on route 29, the Blue Line, and the Gold Line, the stops they call at, and the full service calendar. The realtime capture is from a weekday evening, so a Blue-to-Gold transfer can be planned against it. tests/test_feed_http.py is the exception: it serves canned responses from a loopback socket so the byte cap and timeout are exercised for real.

tests/test_http.py drives the whole OAuth handshake against the real ASGI app - registration, /authorize, the Google callback, /token, then an authenticated tools/list - with Google's token endpoint replaced by a stub, so no account or network is needed.

tests/test_token_store.py runs every token-store test against both stores. The Firestore half needs the emulator (gcloud emulators firestore start, then set FIRESTORE_EMULATOR_HOST) and is skipped without it. tests/test_stdio.py starts the real stdio server in a child process; CI also runs it against a plain pip install ., to prove stdio needs none of the optional extras.

License

MIT - see LICENSE.

Similar modules

Browse Public transport