{
  "markdown": "<!-- mcp-name: io.github.ajwann/queenscoach -->\n\n# QueensCoach ♔\n\n[![CI](https://github.com/ajwann/queenscoach/actions/workflows/ci.yml/badge.svg)](https://github.com/ajwann/queenscoach/actions/workflows/ci.yml)\n\n**QueensCoach** is an MCP server for live **Charlotte Area Transit System (CATS)** bus\nand light rail data, built on the agency's public GTFS-Realtime feeds. It runs over\n**stdio**, launched by the MCP client that uses it, or over **HTTP** with Google OAuth\nin front of it, for a hosted server. Both transports serve the same three tools.\n\n## Hosted server\n\nA public instance runs on Google Cloud Run. Sign in with any Google account:\n\n```\nhttps://queenscoach.adamwanninger.com/mcp\n```\n\n**There is zero guarantee of uptime.** The hosted server is provided as-is. It may be\nslow, down, switched off by its spending cap, or retired without notice. For anything\nyou rely on, run your own: over [stdio](#stdio), or on your own Google Cloud project\nwith [`deploy/GCP.md`](deploy/GCP.md).\n\nSigning in tells the server your Google account's email address, which is used only\nto decide whether to admit you, and is never stored. The tokens it issues record\nyour account's opaque Google ID and nothing else about you. The\n[privacy policy](https://adamwanninger.com/privacy/) and\n[terms of service](https://adamwanninger.com/terms/) cover the hosted server.\n\n### Adding it to Claude\n\nClaude calls a remote MCP server a **connector**. Custom connectors are available on\nClaude's paid plans.\n\n1. Open **Settings → Connectors**. On the web that's\n   [claude.ai/settings/connectors](https://claude.ai/settings/connectors); in the\n   desktop app, Settings then Connectors.\n2. Click **Add custom connector** at the bottom of the list.\n3. Give it a name, `QueensCoach`, and paste the URL above as the remote MCP server\n   URL. Leave the advanced OAuth fields empty: this server registers your client\n   automatically.\n4. Click **Add**, then **Connect** on the connector that appears. A browser window\n   opens for the Google sign-in; approve it and it closes itself.\n5. In a chat, open the tools menu and check that QueensCoach is enabled. Its three\n   tools then appear.\n\nThe connector belongs to your Claude account, so it follows you across web, desktop,\nand mobile. To disconnect, remove it from that same Connectors page; that revokes the\ntokens this server issued.\n\n### Adding it to Claude Code\n\n```bash\nclaude mcp add --transport http queenscoach https://queenscoach.adamwanninger.com/mcp\n```\n\nThen run `/mcp`, pick `queenscoach`, and choose **Authenticate**, which opens the same\nGoogle sign-in. `/mcp` shows the connection's state afterwards. A server added this way\nloads when Claude Code next starts.\n\n### Any other client\n\nAny MCP client that supports remote servers over streamable HTTP with OAuth works:\ngive it the same URL and it discovers the rest.\n\n## Tools\n\n| Tool | Purpose |\n| --- | --- |\n| `find_vehicle` | Locate one bus/train by vehicle number, or every vehicle on a route, and return GPS coordinates. |\n| `list_vehicles` | Current GPS coordinates of every bus and train in service. |\n| `get_arrivals` | Estimated arrival times at a specific stop or station. |\n\n### `find_vehicle`\n\n| Argument | Type | Notes |\n| --- | --- | --- |\n| `vehicle` | string | Vehicle number as shown on the bus/train, e.g. `2301`, `LRV307`. |\n| `route` | string | Route to locate: `9`, `501`, `Blue Line`, `Mt. Holly Road`. |\n| `mode` | `bus` \\| `train` | Optional filter. |\n\nAt least one of `vehicle` or `route` is required. Returns position, heading, speed,\noccupancy, headsign, and the next scheduled stop.\n\n### `list_vehicles`\n\n| Argument | Type | Notes |\n| --- | --- | --- |\n| `mode` | `bus` \\| `train` | Optional filter. |\n| `route` | string | Optional single-route filter. |\n| `limit` | integer | Max vehicles to return (default and cap: 250). |\n\nIncludes `countsByMode` and `totalInService` so the total is visible even when the\nlist is truncated.\n\n### `get_arrivals`\n\n| Argument | Type | Notes |\n| --- | --- | --- |\n| `stop` | string | **Required.** Stop id (`02400`), stop code, or part of a stop name (`CTC Station`). |\n| `route` | string | Optional route filter. |\n| `mode` | `bus` \\| `train` | Optional filter. |\n| `limit` | integer | Max arrivals (default 10, cap 50). |\n\nReturns minutes away, predicted and scheduled times, schedule deviation, the vehicle\nnumber, and that vehicle's live position. When a name query is ambiguous, the best\nmatch is used and the runners-up are listed under `otherStopsMatchingQuery`. Service\nalerts affecting the stop or its routes are attached when present.\n\n## Install\n\nRequires Python 3.11+.\n\n```bash\npip install queenscoach\n```\n\nOr run it without installing anything, which is how most MCP clients launch it:\n\n```bash\nuvx queenscoach\n```\n\nFrom a clone instead, to hack on it or to run the HTTP transport from source:\n\n```bash\npython3 -m venv .venv\n.venv/bin/pip install .          # '.[gcp]' adds the Firestore token store\n```\n\n## Transports\n\nPick one with `--transport` or `QUEENSCOACH_TRANSPORT`; the default is `stdio`.\n\n```bash\nqueenscoach                                  # stdio (default)\nqueenscoach --transport http --port 8000     # streamable HTTP + Google OAuth\n```\n\n### stdio\n\nFor a server the client launches itself. No authentication: the client already owns\nthe process.\n\nRegister it with Claude Code:\n\n```bash\nclaude mcp add queenscoach -- uvx queenscoach\n```\n\nOr in an MCP client config file:\n\n```json\n{\n  \"mcpServers\": {\n    \"queenscoach\": {\n      \"command\": \"uvx\",\n      \"args\": [\"queenscoach\"]\n    }\n  }\n}\n```\n\nAn installed copy works just as well, given an absolute path\n(`/absolute/path/to/.venv/bin/queenscoach`): MCP clients rarely share your shell's\n`PATH`. `python -m queenscoach` runs the same server, so any interpreter with the\npackage installed works as the command.\n\nstdout carries MCP protocol traffic only; all diagnostics go to stderr.\n\n### HTTP with Google OAuth\n\nFor a hosted server anyone with the URL can reach. Every request to `/mcp` needs a\nbearer token, and the only way to get one is to sign in with a Google account that is\non the allow list.\n\n**How the sign-in works.** MCP clients register themselves dynamically and expect an\nauthorization server at the MCP server's own origin. Google offers neither dynamic\nregistration nor tokens audience-restricted to a third-party resource, so this server\nis its own OAuth 2.1 authorization server and delegates only the login to Google:\n\n```\nMCP client  <--OAuth-->  queenscoach  <--OAuth-->  Google\n```\n\nGoogle's answer is used exactly once, to learn which account signed in. That email is\nchecked against the allow list, and only then does this server mint its own tokens.\nGoogle's tokens are never handed to the client.\n\n**One-time setup in Google Cloud.** At\n[console.cloud.google.com/auth/clients](https://console.cloud.google.com/auth/clients),\ncreate an **OAuth client** of type **Web application** and add one authorized\nredirect URI:\n\n```\nhttps://your-public-url/auth/google/callback\n```\n\nIt must match `QUEENSCOACH_PUBLIC_URL` exactly. The server logs the URI it expects at startup.\nCopy the client ID and secret into the environment below.\n\n**Run it.** `.env.example` lists every setting; the shell form is:\n\n```bash\nexport QUEENSCOACH_GOOGLE_CLIENT_ID=...apps.googleusercontent.com\nexport QUEENSCOACH_GOOGLE_CLIENT_SECRET=...\nexport QUEENSCOACH_ALLOWED_EMAILS=you@example.com\nexport QUEENSCOACH_PUBLIC_URL=https://queenscoach.example.com\n\nqueenscoach --transport http --port 8000\n```\n\nThen point a client at `https://queenscoach.example.com/mcp`; it discovers the rest and opens\na browser for the Google sign-in. In Claude Code:\n\n```bash\nclaude mcp add --transport http queenscoach https://queenscoach.example.com/mcp\n```\n\n**Access is denied by default.** Startup fails unless `QUEENSCOACH_ALLOWED_EMAILS`,\n`QUEENSCOACH_ALLOWED_DOMAINS`, or an explicit `QUEENSCOACH_ALLOW_ANY_GOOGLE_ACCOUNT=true` says who\nmay get in, so a misconfigured deployment is unreachable rather than open to every\nGoogle account on the internet. Unverified Google addresses are always refused.\n\n**Endpoints.**\n\n| Path | Purpose |\n| --- | --- |\n| `/mcp` | The MCP endpoint. Requires `Authorization: Bearer <token>`. |\n| `/.well-known/oauth-protected-resource/mcp` | Points clients at the authorization server. |\n| `/.well-known/oauth-authorization-server` | This server's OAuth metadata. |\n| `/register` | Dynamic client registration (RFC 7591). |\n| `/authorize`, `/token`, `/revoke` | The OAuth endpoints. |\n| `/auth/google/callback` | Where Google returns the user. |\n\n[`scripts/install.sh`](scripts/install.sh) does a whole deployment: a system\nuser under `/opt`, a Cloudflare tunnel and its DNS record created over the API,\nboth systemd units, and a verification pass. No port forwarding, so it works\nbehind CGNAT or a locked router. See [`deploy/`](deploy/README.md).\n\n[`scripts/deploy-gcp.sh`](scripts/deploy-gcp.sh) does the same on **Google Cloud\nRun**, in your own GCP project: the project itself, Firestore for sign-ins, the\nclient secret in Secret Manager, a container built by Cloud Build, a monthly\nbudget with an optional hard spend cap, and the same verification pass. It scales\nto zero, so a personal server costs next to nothing. See\n[`deploy/GCP.md`](deploy/GCP.md).\n\n**Deployment notes.**\n\n- By default the server speaks plain HTTP and expects a tunnel or proxy to\n  terminate TLS, which is what the install script sets up. Setting\n  `QUEENSCOACH_TLS_CERT` and `QUEENSCOACH_TLS_KEY` instead makes it serve HTTPS itself, for a\n  deployment with nothing in front of it.\n- `QUEENSCOACH_PUBLIC_URL` is what clients dial and is this server's OAuth issuer\n  identifier, so it must be the external URL, not the bind address.\n- Token state is in memory by default and therefore per-process: restarting\n  invalidates outstanding tokens. `QUEENSCOACH_TOKEN_STORE=firestore` keeps it in\n  Firestore instead (install the `gcp` extra: `pip install 'queenscoach[gcp]'`), so\n  sign-ins survive restarts and every instance shares them. Pair it with\n  `QUEENSCOACH_STATELESS_HTTP=true` so that any instance can answer any request.\n- Access tokens last an hour and refresh tokens 30 days, both rotated on refresh.\n\n## Data sources\n\nRealtime (GTFS-Realtime protobuf, refreshed every 20s):\n\n- `https://gtfsrealtime.ridetransit.org/GTFSRealTime/Vehicle/VehiclePositions.pb`\n- `https://gtfsrealtime.ridetransit.org/GTFSRealTime/TripUpdate/TripUpdates.pb`\n- `https://gtfsrealtime.ridetransit.org/GTFSRealTime/Alert/Alerts.pb`\n\nStatic schedule (cached 6h), used to turn feed identifiers into route names, stop\nnames, and coordinates:\n\n- `https://gtfsrealtime.ridetransit.org/GTFSStatic/api/GTFSDownload/GTFS.zip`\n\nOnly `routes.txt`, `stops.txt`, and `trips.txt` are read; `stop_times.txt` and\n`shapes.txt` are the bulk of the archive and are not needed.\n\n## Feed quirks this server works around\n\nVerified against live feed captures:\n\n- **`VehiclePosition.stop_id` and `current_stop_sequence` are unusable.** None of the\n  158 vehicle stop ids in a sample capture matched any stop in the published schedule,\n  and reported sequence numbers exceeded the trip's own stop count (e.g. sequence 192\n  on a 52-stop trip). This server never surfaces them; next-stop data comes from the\n  TripUpdates feed instead, whose stop ids resolve 100%.\n- **`StopTimeEvent.delay` is never populated.** Schedule deviation is computed from\n  `time` minus `scheduled_time`, which are both present.\n- **TripUpdates cover ~83% of active vehicles**, so `nextStop` is omitted rather than\n  guessed for the remainder.\n- **Route matching is exact-first**, so a query of `5` returns route 5, not 501 or 510.\n\n## Behavior notes\n\n- Arrival predictions already in the past are filtered out; no negative ETAs.\n- Feed responses are capped in size and time-bounded; one slow feed cannot hang a call.\n- Concurrent calls share a single in-flight fetch per feed, and one call giving up does\n  not abort a fetch the others are awaiting.\n- If a refresh fails but cached data exists, the last good data is served rather than\n  an error. `feedAgeSeconds` on every response shows how stale it is.\n- The alerts feed is supplementary: if it fails, `get_arrivals` still returns arrivals.\n- Times are ISO 8601 UTC; coordinates are WGS84 decimal degrees.\n\n## Configuration\n\n### Feeds (both transports)\n\nAll optional; defaults target the CATS feeds above. Durations are in milliseconds.\n\n| Variable | Default |\n| --- | --- |\n| `QUEENSCOACH_VEHICLE_POSITIONS_URL` | CATS vehicle positions feed |\n| `QUEENSCOACH_TRIP_UPDATES_URL` | CATS trip updates feed |\n| `QUEENSCOACH_ALERTS_URL` | CATS alerts feed |\n| `QUEENSCOACH_STATIC_GTFS_URL` | CATS static GTFS zip |\n| `QUEENSCOACH_REALTIME_TTL_MS` | `20000` |\n| `QUEENSCOACH_STATIC_TTL_MS` | `21600000` |\n| `QUEENSCOACH_REQUEST_TIMEOUT_MS` | `30000` |\n| `QUEENSCOACH_MAX_FEED_BYTES` | `33554432` |\n| `QUEENSCOACH_MAX_STATIC_BYTES` | `268435456` |\n\nFeed URLs must be `http` or `https`; anything else is rejected at startup.\n\n### Transport\n\n| Variable | CLI | Default |\n| --- | --- | --- |\n| `QUEENSCOACH_TRANSPORT` | `--transport` | `stdio` |\n\n### HTTP transport\n\nRead only when `--transport http` is selected.\n\n| Variable | CLI | Default | Notes |\n| --- | --- | --- | --- |\n| `QUEENSCOACH_HTTP_HOST` | `--host` | `127.0.0.1` | Bind address. |\n| `QUEENSCOACH_HTTP_PORT` | `--port` | `8000` | Bind port. |\n| `QUEENSCOACH_PUBLIC_URL` | `--public-url` | `http://localhost:<port>` | External origin; the OAuth issuer. |\n| `QUEENSCOACH_GOOGLE_CLIENT_ID` | | **required** | From Google Cloud credentials. |\n| `QUEENSCOACH_GOOGLE_CLIENT_SECRET` | | **required** | From Google Cloud credentials. |\n| `QUEENSCOACH_ALLOWED_EMAILS` | | — | Allowed addresses, comma- or space-separated. |\n| `QUEENSCOACH_ALLOWED_DOMAINS` | | — | Allowed bare domains, e.g. `example.com`. |\n| `QUEENSCOACH_ALLOW_ANY_GOOGLE_ACCOUNT` | | `false` | Opt in to admitting every Google account. |\n| `QUEENSCOACH_TLS_CERT` | `--tls-cert` | — | PEM chain, to serve HTTPS directly. |\n| `QUEENSCOACH_TLS_KEY` | `--tls-key` | — | PEM private key. Required with the above. |\n| `QUEENSCOACH_ACCESS_TOKEN_TTL_MS` | | `3600000` | Access token lifetime. |\n| `QUEENSCOACH_REFRESH_TOKEN_TTL_MS` | | `2592000000` | Refresh token lifetime. |\n| `QUEENSCOACH_TOKEN_STORE` | | `memory` | `memory`, or `firestore` (needs the `gcp` extra). |\n| `QUEENSCOACH_FIRESTORE_DATABASE` | | `(default)` | Firestore database for the token store. |\n| `QUEENSCOACH_STATELESS_HTTP` | | `false` | Serve without MCP sessions, for restarts and multiple instances. |\n\nOne of the three allow-list settings is required; see above.\n\n## Layout\n\n| Module | Role |\n| --- | --- |\n| `config.py` | Environment parsing and validation |\n| `feed_http.py` | Bounded, time-limited HTTP fetch |\n| `cache.py` | TTL cache with single-flight refresh |\n| `gtfs_csv.py` | GTFS-flavored CSV reading |\n| `static_gtfs.py` | Static schedule: routes, stops, trips |\n| `realtime.py` | GTFS-Realtime protobuf decoding |\n| `transit.py` | Domain layer: joins realtime to schedule, resolves queries |\n| `tools.py` | The three tools' behavior and JSON payloads |\n| `server.py` | MCP tool registration and schemas |\n| `oauth.py` | OAuth authorization server, with Google as the login |\n| `token_store.py` | Where OAuth state is kept, and the in-memory default |\n| `token_store_firestore.py` | The Firestore token store (the `gcp` extra only) |\n| `http.py` | Streamable HTTP transport and the Google callback route |\n| `main.py` | CLI entry point and transport selection |\n\nPlus [`scripts/install.sh`](scripts/install.sh), which deploys the HTTP\ntransport onto a Debian host, and [`scripts/deploy-gcp.sh`](scripts/deploy-gcp.sh)\nwith the [`Dockerfile`](Dockerfile), which deploy it to Google Cloud Run.\n\n## Development\n\n```bash\n.venv/bin/pip install -e '.[dev]'\n.venv/bin/pytest        # offline, against recorded feed fixtures\n.venv/bin/mypy          # strict\n.venv/bin/ruff check .\n.venv/bin/ruff format .\n```\n\nTests run against protobuf and GTFS fixtures captured from the live feeds, so they are\ndeterministic and make no network calls. `tests/test_feed_http.py` is the exception: it\nserves canned responses from a loopback socket so the byte cap and timeout are exercised\nfor real.\n\n`tests/test_http.py` drives the whole OAuth handshake against the real ASGI app -\nregistration, `/authorize`, the Google callback, `/token`, then an authenticated\n`tools/list` - with Google's token endpoint replaced by a stub, so no account or network\nis needed.\n\n`tests/test_token_store.py` runs every token-store test against both stores. The\nFirestore half needs the emulator (`gcloud emulators firestore start`, then set\n`FIRESTORE_EMULATOR_HOST`) and is skipped without it. `tests/test_stdio.py` starts the\nreal stdio server in a child process; CI also runs it against a plain `pip install .`,\nto prove stdio needs none of the optional extras.\n\n## License\n\nMIT - see [LICENSE](LICENSE).\n",
  "bytes": 16983,
  "sha": "1f50963a44ccb944c0cca38f0792892ca8c3b1abc2c28fc3d2afc942a3f5a223",
  "repo_slug": "ajwann/queenscoach",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_ajwann_queenscoach_c90a0bed/readme"
}