cats-mcp 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cats_mcp-1.0.0/.env.example +23 -0
- cats_mcp-1.0.0/.github/workflows/ci.yml +49 -0
- cats_mcp-1.0.0/.github/workflows/release.yml +115 -0
- cats_mcp-1.0.0/.gitignore +11 -0
- cats_mcp-1.0.0/PKG-INFO +331 -0
- cats_mcp-1.0.0/README.md +312 -0
- cats_mcp-1.0.0/deploy/README.md +233 -0
- cats_mcp-1.0.0/deploy/cats-mcp.env.example +22 -0
- cats_mcp-1.0.0/deploy/cats-mcp.service +45 -0
- cats_mcp-1.0.0/pyproject.toml +60 -0
- cats_mcp-1.0.0/scripts/install.sh +685 -0
- cats_mcp-1.0.0/server.json +22 -0
- cats_mcp-1.0.0/src/cats_mcp/__init__.py +6 -0
- cats_mcp-1.0.0/src/cats_mcp/__main__.py +6 -0
- cats_mcp-1.0.0/src/cats_mcp/cache.py +84 -0
- cats_mcp-1.0.0/src/cats_mcp/config.py +412 -0
- cats_mcp-1.0.0/src/cats_mcp/feed_http.py +66 -0
- cats_mcp-1.0.0/src/cats_mcp/gtfs_csv.py +41 -0
- cats_mcp-1.0.0/src/cats_mcp/http.py +151 -0
- cats_mcp-1.0.0/src/cats_mcp/main.py +138 -0
- cats_mcp-1.0.0/src/cats_mcp/oauth.py +469 -0
- cats_mcp-1.0.0/src/cats_mcp/realtime.py +298 -0
- cats_mcp-1.0.0/src/cats_mcp/server.py +178 -0
- cats_mcp-1.0.0/src/cats_mcp/static_gtfs.py +171 -0
- cats_mcp-1.0.0/src/cats_mcp/tools.py +363 -0
- cats_mcp-1.0.0/src/cats_mcp/transit.py +354 -0
- cats_mcp-1.0.0/tests/__init__.py +1 -0
- cats_mcp-1.0.0/tests/conftest.py +92 -0
- cats_mcp-1.0.0/tests/fixtures/Alerts.pb +0 -0
- cats_mcp-1.0.0/tests/fixtures/TripUpdates.pb +0 -0
- cats_mcp-1.0.0/tests/fixtures/VehiclePositions.pb +0 -0
- cats_mcp-1.0.0/tests/fixtures/gtfs-static.zip +0 -0
- cats_mcp-1.0.0/tests/test_cache.py +110 -0
- cats_mcp-1.0.0/tests/test_config.py +209 -0
- cats_mcp-1.0.0/tests/test_feed_http.py +125 -0
- cats_mcp-1.0.0/tests/test_gtfs_csv.py +36 -0
- cats_mcp-1.0.0/tests/test_http.py +280 -0
- cats_mcp-1.0.0/tests/test_main.py +53 -0
- cats_mcp-1.0.0/tests/test_oauth.py +349 -0
- cats_mcp-1.0.0/tests/test_realtime.py +69 -0
- cats_mcp-1.0.0/tests/test_server.py +89 -0
- cats_mcp-1.0.0/tests/test_static_gtfs.py +64 -0
- cats_mcp-1.0.0/tests/test_tools.py +194 -0
- cats_mcp-1.0.0/tests/test_transit.py +96 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# HTTP transport with Google OAuth. Copy to .env and fill in; .env is gitignored.
|
|
2
|
+
# Not needed for the stdio transport.
|
|
3
|
+
|
|
4
|
+
CATS_TRANSPORT=http
|
|
5
|
+
|
|
6
|
+
# From an OAuth 2.0 "Web application" client at
|
|
7
|
+
# https://console.cloud.google.com/apis/credentials
|
|
8
|
+
# Its authorized redirect URI must be exactly <CATS_PUBLIC_URL>/auth/google/callback
|
|
9
|
+
CATS_GOOGLE_CLIENT_ID=
|
|
10
|
+
CATS_GOOGLE_CLIENT_SECRET=
|
|
11
|
+
|
|
12
|
+
# Who may use the server. At least one of these three is required; without one
|
|
13
|
+
# the server refuses to start rather than admitting every Google account.
|
|
14
|
+
CATS_ALLOWED_EMAILS=you@example.com
|
|
15
|
+
# CATS_ALLOWED_DOMAINS=example.com
|
|
16
|
+
# CATS_ALLOW_ANY_GOOGLE_ACCOUNT=false
|
|
17
|
+
|
|
18
|
+
# The externally reachable origin: what clients dial, and this server's OAuth
|
|
19
|
+
# issuer. Must be https unless it is loopback.
|
|
20
|
+
CATS_PUBLIC_URL=https://cats.example.com
|
|
21
|
+
|
|
22
|
+
# CATS_HTTP_HOST=127.0.0.1
|
|
23
|
+
# CATS_HTTP_PORT=8000
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
# So the release workflow can gate a publish on this same suite.
|
|
9
|
+
workflow_call:
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
# A newer push to the same branch makes an in-flight run redundant.
|
|
15
|
+
concurrency:
|
|
16
|
+
group: ci-${{ github.ref }}
|
|
17
|
+
cancel-in-progress: true
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
test:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
strategy:
|
|
23
|
+
fail-fast: false
|
|
24
|
+
matrix:
|
|
25
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v5
|
|
28
|
+
- uses: actions/setup-python@v6
|
|
29
|
+
with:
|
|
30
|
+
python-version: ${{ matrix.python-version }}
|
|
31
|
+
cache: pip
|
|
32
|
+
cache-dependency-path: pyproject.toml
|
|
33
|
+
- run: pip install -e ".[dev]"
|
|
34
|
+
- run: pytest
|
|
35
|
+
|
|
36
|
+
lint:
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v5
|
|
40
|
+
- uses: actions/setup-python@v6
|
|
41
|
+
with:
|
|
42
|
+
# The version pyproject targets for ruff and mypy.
|
|
43
|
+
python-version: "3.11"
|
|
44
|
+
cache: pip
|
|
45
|
+
cache-dependency-path: pyproject.toml
|
|
46
|
+
- run: pip install -e ".[dev]"
|
|
47
|
+
- run: ruff check
|
|
48
|
+
- run: ruff format --check
|
|
49
|
+
- run: mypy
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Cutting a version means pushing a tag: `git tag v1.0.1 && git push origin v1.0.1`.
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
# PyPI refuses a re-upload, so a version mismatch is unrecoverable under that
|
|
13
|
+
# tag. Catch it before anything is built.
|
|
14
|
+
check-version:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v5
|
|
18
|
+
- uses: actions/setup-python@v6
|
|
19
|
+
with:
|
|
20
|
+
# tomllib needs 3.11+; don't depend on the runner's default.
|
|
21
|
+
python-version: "3.11"
|
|
22
|
+
- name: Tag must match pyproject.toml and server.json
|
|
23
|
+
run: |
|
|
24
|
+
python - <<'PY'
|
|
25
|
+
import json, os, sys, tomllib
|
|
26
|
+
|
|
27
|
+
tag = os.environ["GITHUB_REF_NAME"].removeprefix("v")
|
|
28
|
+
server = json.load(open("server.json"))
|
|
29
|
+
found = {
|
|
30
|
+
"pyproject.toml": tomllib.load(open("pyproject.toml", "rb"))["project"]["version"],
|
|
31
|
+
"server.json (version)": server["version"],
|
|
32
|
+
"server.json (packages[0].version)": server["packages"][0]["version"],
|
|
33
|
+
}
|
|
34
|
+
bad = {where: got for where, got in found.items() if got != tag}
|
|
35
|
+
for where, got in bad.items():
|
|
36
|
+
print(f"::error::tag is {tag} but {where} is {got}")
|
|
37
|
+
sys.exit(1 if bad else 0)
|
|
38
|
+
PY
|
|
39
|
+
|
|
40
|
+
test:
|
|
41
|
+
needs: check-version
|
|
42
|
+
uses: ./.github/workflows/ci.yml
|
|
43
|
+
|
|
44
|
+
publish:
|
|
45
|
+
needs: test
|
|
46
|
+
runs-on: ubuntu-latest
|
|
47
|
+
# Must match the environment named in the PyPI trusted publisher.
|
|
48
|
+
environment: pypi
|
|
49
|
+
permissions:
|
|
50
|
+
# Mints the short-lived OIDC token PyPI trusts. No API token to store.
|
|
51
|
+
id-token: write
|
|
52
|
+
steps:
|
|
53
|
+
- uses: actions/checkout@v5
|
|
54
|
+
- uses: actions/setup-python@v6
|
|
55
|
+
with:
|
|
56
|
+
python-version: "3.11"
|
|
57
|
+
- run: pip install build
|
|
58
|
+
- run: python -m build
|
|
59
|
+
- name: Check the built distributions
|
|
60
|
+
run: pipx run twine check --strict dist/*
|
|
61
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
62
|
+
- uses: actions/upload-artifact@v4
|
|
63
|
+
with:
|
|
64
|
+
name: dist
|
|
65
|
+
path: dist/
|
|
66
|
+
|
|
67
|
+
publish-registry:
|
|
68
|
+
needs: publish
|
|
69
|
+
runs-on: ubuntu-latest
|
|
70
|
+
permissions:
|
|
71
|
+
# mcp-publisher trades this for a registry token; no secret to store.
|
|
72
|
+
id-token: write
|
|
73
|
+
contents: read
|
|
74
|
+
steps:
|
|
75
|
+
- uses: actions/checkout@v5
|
|
76
|
+
- name: Wait for the release to be visible on PyPI
|
|
77
|
+
# The registry verifies the mcp-name marker by reading the package
|
|
78
|
+
# description off PyPI, so it has to be able to see this version.
|
|
79
|
+
run: |
|
|
80
|
+
version="${GITHUB_REF_NAME#v}"
|
|
81
|
+
for attempt in $(seq 1 20); do
|
|
82
|
+
if curl -sf "https://pypi.org/pypi/cats-mcp/$version/json" > /dev/null; then
|
|
83
|
+
echo "cats-mcp $version is on PyPI"
|
|
84
|
+
exit 0
|
|
85
|
+
fi
|
|
86
|
+
echo "attempt $attempt: not visible yet, retrying in 15s"
|
|
87
|
+
sleep 15
|
|
88
|
+
done
|
|
89
|
+
echo "::error::cats-mcp $version never appeared on PyPI"
|
|
90
|
+
exit 1
|
|
91
|
+
- name: Install mcp-publisher
|
|
92
|
+
run: |
|
|
93
|
+
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
|
|
94
|
+
- name: Authenticate to the MCP Registry
|
|
95
|
+
run: ./mcp-publisher login github-oidc
|
|
96
|
+
- name: Publish to the MCP Registry
|
|
97
|
+
run: ./mcp-publisher publish
|
|
98
|
+
|
|
99
|
+
github-release:
|
|
100
|
+
needs: [publish, publish-registry]
|
|
101
|
+
runs-on: ubuntu-latest
|
|
102
|
+
permissions:
|
|
103
|
+
# Creating the release writes to the repo.
|
|
104
|
+
contents: write
|
|
105
|
+
steps:
|
|
106
|
+
# Only so gh infers the repository; the notes come from the API.
|
|
107
|
+
- uses: actions/checkout@v5
|
|
108
|
+
- uses: actions/download-artifact@v4
|
|
109
|
+
with:
|
|
110
|
+
name: dist
|
|
111
|
+
path: dist/
|
|
112
|
+
- name: Create the GitHub release
|
|
113
|
+
env:
|
|
114
|
+
GH_TOKEN: ${{ github.token }}
|
|
115
|
+
run: gh release create "$GITHUB_REF_NAME" dist/* --generate-notes
|
cats_mcp-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cats-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MCP server for Charlotte Area Transit System (CATS) realtime bus and light rail data
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: gtfs-realtime-bindings<3,>=2.2
|
|
7
|
+
Requires-Dist: httpx<1,>=0.28
|
|
8
|
+
Requires-Dist: mcp<3,>=2.2
|
|
9
|
+
Requires-Dist: pyjwt[crypto]<3,>=2.10
|
|
10
|
+
Requires-Dist: starlette<2,>=0.48
|
|
11
|
+
Requires-Dist: uvicorn<1,>=0.34
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: mypy>=1.14; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-asyncio>=1; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff>=0.9; extra == 'dev'
|
|
17
|
+
Requires-Dist: types-protobuf; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
<!-- mcp-name: io.github.ajwann/cats-mcp -->
|
|
21
|
+
|
|
22
|
+
# cats-mcp
|
|
23
|
+
|
|
24
|
+
[](https://github.com/ajwann/cats-mcp/actions/workflows/ci.yml)
|
|
25
|
+
|
|
26
|
+
An MCP server for live **Charlotte Area Transit System (CATS)** bus and light rail data,
|
|
27
|
+
built on the agency's public GTFS-Realtime feeds. It runs over **stdio**, launched by
|
|
28
|
+
the MCP client that uses it, or over **HTTP** with Google OAuth in front of it, for a
|
|
29
|
+
hosted server. Both transports serve the same three tools.
|
|
30
|
+
|
|
31
|
+
## Tools
|
|
32
|
+
|
|
33
|
+
| Tool | Purpose |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `find_vehicle` | Locate one bus/train by vehicle number, or every vehicle on a route, and return GPS coordinates. |
|
|
36
|
+
| `list_vehicles` | Current GPS coordinates of every bus and train in service. |
|
|
37
|
+
| `get_arrivals` | Estimated arrival times at a specific stop or station. |
|
|
38
|
+
|
|
39
|
+
### `find_vehicle`
|
|
40
|
+
|
|
41
|
+
| Argument | Type | Notes |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| `vehicle` | string | Vehicle number as shown on the bus/train, e.g. `2301`, `LRV307`. |
|
|
44
|
+
| `route` | string | Route to locate: `9`, `501`, `Blue Line`, `Mt. Holly Road`. |
|
|
45
|
+
| `mode` | `bus` \| `train` | Optional filter. |
|
|
46
|
+
|
|
47
|
+
At least one of `vehicle` or `route` is required. Returns position, heading, speed,
|
|
48
|
+
occupancy, headsign, and the next scheduled stop.
|
|
49
|
+
|
|
50
|
+
### `list_vehicles`
|
|
51
|
+
|
|
52
|
+
| Argument | Type | Notes |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| `mode` | `bus` \| `train` | Optional filter. |
|
|
55
|
+
| `route` | string | Optional single-route filter. |
|
|
56
|
+
| `limit` | integer | Max vehicles to return (default and cap: 250). |
|
|
57
|
+
|
|
58
|
+
Includes `countsByMode` and `totalInService` so the total is visible even when the
|
|
59
|
+
list is truncated.
|
|
60
|
+
|
|
61
|
+
### `get_arrivals`
|
|
62
|
+
|
|
63
|
+
| Argument | Type | Notes |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| `stop` | string | **Required.** Stop id (`02400`), stop code, or part of a stop name (`CTC Station`). |
|
|
66
|
+
| `route` | string | Optional route filter. |
|
|
67
|
+
| `mode` | `bus` \| `train` | Optional filter. |
|
|
68
|
+
| `limit` | integer | Max arrivals (default 10, cap 50). |
|
|
69
|
+
|
|
70
|
+
Returns minutes away, predicted and scheduled times, schedule deviation, the vehicle
|
|
71
|
+
number, and that vehicle's live position. When a name query is ambiguous, the best
|
|
72
|
+
match is used and the runners-up are listed under `otherStopsMatchingQuery`. Service
|
|
73
|
+
alerts affecting the stop or its routes are attached when present.
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
Requires Python 3.11+.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
python3 -m venv .venv
|
|
81
|
+
.venv/bin/pip install .
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Transports
|
|
85
|
+
|
|
86
|
+
Pick one with `--transport` or `CATS_TRANSPORT`; the default is `stdio`.
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
cats-mcp # stdio (default)
|
|
90
|
+
cats-mcp --transport http --port 8000 # streamable HTTP + Google OAuth
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### stdio
|
|
94
|
+
|
|
95
|
+
For a server the client launches itself. No authentication: the client already owns
|
|
96
|
+
the process.
|
|
97
|
+
|
|
98
|
+
Register it with Claude Code:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
claude mcp add cats -- /absolute/path/to/cats-mcp/.venv/bin/cats-mcp
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Or in an MCP client config file:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"mcpServers": {
|
|
109
|
+
"cats": {
|
|
110
|
+
"command": "/absolute/path/to/cats-mcp/.venv/bin/cats-mcp"
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`python -m cats_mcp` runs the same server, so any interpreter with the package
|
|
117
|
+
installed works as the command.
|
|
118
|
+
|
|
119
|
+
stdout carries MCP protocol traffic only; all diagnostics go to stderr.
|
|
120
|
+
|
|
121
|
+
### HTTP with Google OAuth
|
|
122
|
+
|
|
123
|
+
For a hosted server anyone with the URL can reach. Every request to `/mcp` needs a
|
|
124
|
+
bearer token, and the only way to get one is to sign in with a Google account that is
|
|
125
|
+
on the allow list.
|
|
126
|
+
|
|
127
|
+
**How the sign-in works.** MCP clients register themselves dynamically and expect an
|
|
128
|
+
authorization server at the MCP server's own origin. Google offers neither dynamic
|
|
129
|
+
registration nor tokens audience-restricted to a third-party resource, so this server
|
|
130
|
+
is its own OAuth 2.1 authorization server and delegates only the login to Google:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
MCP client <--OAuth--> cats-mcp <--OAuth--> Google
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Google's answer is used exactly once, to learn which account signed in. That email is
|
|
137
|
+
checked against the allow list, and only then does this server mint its own tokens.
|
|
138
|
+
Google's tokens are never handed to the client.
|
|
139
|
+
|
|
140
|
+
**One-time setup in Google Cloud.** At
|
|
141
|
+
[console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials),
|
|
142
|
+
create an **OAuth client ID** of type **Web application** and add one authorized
|
|
143
|
+
redirect URI:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
https://your-public-url/auth/google/callback
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
It must match `CATS_PUBLIC_URL` exactly. The server logs the URI it expects at startup.
|
|
150
|
+
Copy the client ID and secret into the environment below.
|
|
151
|
+
|
|
152
|
+
**Run it.** `.env.example` lists every setting; the shell form is:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
export CATS_GOOGLE_CLIENT_ID=...apps.googleusercontent.com
|
|
156
|
+
export CATS_GOOGLE_CLIENT_SECRET=...
|
|
157
|
+
export CATS_ALLOWED_EMAILS=you@example.com
|
|
158
|
+
export CATS_PUBLIC_URL=https://cats.example.com
|
|
159
|
+
|
|
160
|
+
cats-mcp --transport http --port 8000
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Then point a client at `https://cats.example.com/mcp`; it discovers the rest and opens
|
|
164
|
+
a browser for the Google sign-in. In Claude Code:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
claude mcp add --transport http cats https://cats.example.com/mcp
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**Access is denied by default.** Startup fails unless `CATS_ALLOWED_EMAILS`,
|
|
171
|
+
`CATS_ALLOWED_DOMAINS`, or an explicit `CATS_ALLOW_ANY_GOOGLE_ACCOUNT=true` says who
|
|
172
|
+
may get in, so a misconfigured deployment is unreachable rather than open to every
|
|
173
|
+
Google account on the internet. Unverified Google addresses are always refused.
|
|
174
|
+
|
|
175
|
+
**Endpoints.**
|
|
176
|
+
|
|
177
|
+
| Path | Purpose |
|
|
178
|
+
| --- | --- |
|
|
179
|
+
| `/mcp` | The MCP endpoint. Requires `Authorization: Bearer <token>`. |
|
|
180
|
+
| `/.well-known/oauth-protected-resource/mcp` | Points clients at the authorization server. |
|
|
181
|
+
| `/.well-known/oauth-authorization-server` | This server's OAuth metadata. |
|
|
182
|
+
| `/register` | Dynamic client registration (RFC 7591). |
|
|
183
|
+
| `/authorize`, `/token`, `/revoke` | The OAuth endpoints. |
|
|
184
|
+
| `/auth/google/callback` | Where Google returns the user. |
|
|
185
|
+
|
|
186
|
+
[`scripts/install.sh`](scripts/install.sh) does a whole deployment: a system
|
|
187
|
+
user under `/opt`, a Cloudflare tunnel and its DNS record created over the API,
|
|
188
|
+
both systemd units, and a verification pass. No port forwarding, so it works
|
|
189
|
+
behind CGNAT or a locked router. See [`deploy/`](deploy/README.md).
|
|
190
|
+
|
|
191
|
+
**Deployment notes.**
|
|
192
|
+
|
|
193
|
+
- By default the server speaks plain HTTP and expects a tunnel or proxy to
|
|
194
|
+
terminate TLS, which is what the install script sets up. Setting
|
|
195
|
+
`CATS_TLS_CERT` and `CATS_TLS_KEY` instead makes it serve HTTPS itself, for a
|
|
196
|
+
deployment with nothing in front of it.
|
|
197
|
+
- `CATS_PUBLIC_URL` is what clients dial and is this server's OAuth issuer
|
|
198
|
+
identifier, so it must be the external URL, not the bind address.
|
|
199
|
+
- Token state is in memory and therefore per-process: restarting invalidates
|
|
200
|
+
outstanding tokens, and running several replicas behind one hostname would need a
|
|
201
|
+
shared store instead.
|
|
202
|
+
- Access tokens last an hour and refresh tokens 30 days, both rotated on refresh.
|
|
203
|
+
|
|
204
|
+
## Data sources
|
|
205
|
+
|
|
206
|
+
Realtime (GTFS-Realtime protobuf, refreshed every 20s):
|
|
207
|
+
|
|
208
|
+
- `https://gtfsrealtime.ridetransit.org/GTFSRealTime/Vehicle/VehiclePositions.pb`
|
|
209
|
+
- `https://gtfsrealtime.ridetransit.org/GTFSRealTime/TripUpdate/TripUpdates.pb`
|
|
210
|
+
- `https://gtfsrealtime.ridetransit.org/GTFSRealTime/Alert/Alerts.pb`
|
|
211
|
+
|
|
212
|
+
Static schedule (cached 6h), used to turn feed identifiers into route names, stop
|
|
213
|
+
names, and coordinates:
|
|
214
|
+
|
|
215
|
+
- `https://gtfsrealtime.ridetransit.org/GTFSStatic/api/GTFSDownload/GTFS.zip`
|
|
216
|
+
|
|
217
|
+
Only `routes.txt`, `stops.txt`, and `trips.txt` are read; `stop_times.txt` and
|
|
218
|
+
`shapes.txt` are the bulk of the archive and are not needed.
|
|
219
|
+
|
|
220
|
+
## Feed quirks this server works around
|
|
221
|
+
|
|
222
|
+
Verified against live feed captures:
|
|
223
|
+
|
|
224
|
+
- **`VehiclePosition.stop_id` and `current_stop_sequence` are unusable.** None of the
|
|
225
|
+
158 vehicle stop ids in a sample capture matched any stop in the published schedule,
|
|
226
|
+
and reported sequence numbers exceeded the trip's own stop count (e.g. sequence 192
|
|
227
|
+
on a 52-stop trip). This server never surfaces them; next-stop data comes from the
|
|
228
|
+
TripUpdates feed instead, whose stop ids resolve 100%.
|
|
229
|
+
- **`StopTimeEvent.delay` is never populated.** Schedule deviation is computed from
|
|
230
|
+
`time` minus `scheduled_time`, which are both present.
|
|
231
|
+
- **TripUpdates cover ~83% of active vehicles**, so `nextStop` is omitted rather than
|
|
232
|
+
guessed for the remainder.
|
|
233
|
+
- **Route matching is exact-first**, so a query of `5` returns route 5, not 501 or 510.
|
|
234
|
+
|
|
235
|
+
## Behavior notes
|
|
236
|
+
|
|
237
|
+
- Arrival predictions already in the past are filtered out; no negative ETAs.
|
|
238
|
+
- Feed responses are capped in size and time-bounded; one slow feed cannot hang a call.
|
|
239
|
+
- Concurrent calls share a single in-flight fetch per feed, and one call giving up does
|
|
240
|
+
not abort a fetch the others are awaiting.
|
|
241
|
+
- If a refresh fails but cached data exists, the last good data is served rather than
|
|
242
|
+
an error. `feedAgeSeconds` on every response shows how stale it is.
|
|
243
|
+
- The alerts feed is supplementary: if it fails, `get_arrivals` still returns arrivals.
|
|
244
|
+
- Times are ISO 8601 UTC; coordinates are WGS84 decimal degrees.
|
|
245
|
+
|
|
246
|
+
## Configuration
|
|
247
|
+
|
|
248
|
+
### Feeds (both transports)
|
|
249
|
+
|
|
250
|
+
All optional; defaults target the CATS feeds above. Durations are in milliseconds.
|
|
251
|
+
|
|
252
|
+
| Variable | Default |
|
|
253
|
+
| --- | --- |
|
|
254
|
+
| `CATS_VEHICLE_POSITIONS_URL` | CATS vehicle positions feed |
|
|
255
|
+
| `CATS_TRIP_UPDATES_URL` | CATS trip updates feed |
|
|
256
|
+
| `CATS_ALERTS_URL` | CATS alerts feed |
|
|
257
|
+
| `CATS_STATIC_GTFS_URL` | CATS static GTFS zip |
|
|
258
|
+
| `CATS_REALTIME_TTL_MS` | `20000` |
|
|
259
|
+
| `CATS_STATIC_TTL_MS` | `21600000` |
|
|
260
|
+
| `CATS_REQUEST_TIMEOUT_MS` | `30000` |
|
|
261
|
+
| `CATS_MAX_FEED_BYTES` | `33554432` |
|
|
262
|
+
| `CATS_MAX_STATIC_BYTES` | `268435456` |
|
|
263
|
+
|
|
264
|
+
Feed URLs must be `http` or `https`; anything else is rejected at startup.
|
|
265
|
+
|
|
266
|
+
### Transport
|
|
267
|
+
|
|
268
|
+
| Variable | CLI | Default |
|
|
269
|
+
| --- | --- | --- |
|
|
270
|
+
| `CATS_TRANSPORT` | `--transport` | `stdio` |
|
|
271
|
+
|
|
272
|
+
### HTTP transport
|
|
273
|
+
|
|
274
|
+
Read only when `--transport http` is selected.
|
|
275
|
+
|
|
276
|
+
| Variable | CLI | Default | Notes |
|
|
277
|
+
| --- | --- | --- | --- |
|
|
278
|
+
| `CATS_HTTP_HOST` | `--host` | `127.0.0.1` | Bind address. |
|
|
279
|
+
| `CATS_HTTP_PORT` | `--port` | `8000` | Bind port. |
|
|
280
|
+
| `CATS_PUBLIC_URL` | `--public-url` | `http://localhost:<port>` | External origin; the OAuth issuer. |
|
|
281
|
+
| `CATS_GOOGLE_CLIENT_ID` | | **required** | From Google Cloud credentials. |
|
|
282
|
+
| `CATS_GOOGLE_CLIENT_SECRET` | | **required** | From Google Cloud credentials. |
|
|
283
|
+
| `CATS_ALLOWED_EMAILS` | | — | Allowed addresses, comma- or space-separated. |
|
|
284
|
+
| `CATS_ALLOWED_DOMAINS` | | — | Allowed bare domains, e.g. `example.com`. |
|
|
285
|
+
| `CATS_ALLOW_ANY_GOOGLE_ACCOUNT` | | `false` | Opt in to admitting every Google account. |
|
|
286
|
+
| `CATS_TLS_CERT` | `--tls-cert` | — | PEM chain, to serve HTTPS directly. |
|
|
287
|
+
| `CATS_TLS_KEY` | `--tls-key` | — | PEM private key. Required with the above. |
|
|
288
|
+
| `CATS_ACCESS_TOKEN_TTL_MS` | | `3600000` | Access token lifetime. |
|
|
289
|
+
| `CATS_REFRESH_TOKEN_TTL_MS` | | `2592000000` | Refresh token lifetime. |
|
|
290
|
+
|
|
291
|
+
One of the three allow-list settings is required; see above.
|
|
292
|
+
|
|
293
|
+
## Layout
|
|
294
|
+
|
|
295
|
+
| Module | Role |
|
|
296
|
+
| --- | --- |
|
|
297
|
+
| `config.py` | Environment parsing and validation |
|
|
298
|
+
| `feed_http.py` | Bounded, time-limited HTTP fetch |
|
|
299
|
+
| `cache.py` | TTL cache with single-flight refresh |
|
|
300
|
+
| `gtfs_csv.py` | GTFS-flavored CSV reading |
|
|
301
|
+
| `static_gtfs.py` | Static schedule: routes, stops, trips |
|
|
302
|
+
| `realtime.py` | GTFS-Realtime protobuf decoding |
|
|
303
|
+
| `transit.py` | Domain layer: joins realtime to schedule, resolves queries |
|
|
304
|
+
| `tools.py` | The three tools' behavior and JSON payloads |
|
|
305
|
+
| `server.py` | MCP tool registration and schemas |
|
|
306
|
+
| `oauth.py` | OAuth authorization server, with Google as the login |
|
|
307
|
+
| `http.py` | Streamable HTTP transport and the Google callback route |
|
|
308
|
+
| `main.py` | CLI entry point and transport selection |
|
|
309
|
+
|
|
310
|
+
Plus [`scripts/install.sh`](scripts/install.sh), which deploys the HTTP
|
|
311
|
+
transport onto a Debian host.
|
|
312
|
+
|
|
313
|
+
## Development
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
.venv/bin/pip install -e '.[dev]'
|
|
317
|
+
.venv/bin/pytest # 130 tests, offline against recorded feed fixtures
|
|
318
|
+
.venv/bin/mypy # strict
|
|
319
|
+
.venv/bin/ruff check .
|
|
320
|
+
.venv/bin/ruff format .
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Tests run against protobuf and GTFS fixtures captured from the live feeds, so they are
|
|
324
|
+
deterministic and make no network calls. `tests/test_feed_http.py` is the exception: it
|
|
325
|
+
serves canned responses from a loopback socket so the byte cap and timeout are exercised
|
|
326
|
+
for real.
|
|
327
|
+
|
|
328
|
+
`tests/test_http.py` drives the whole OAuth handshake against the real ASGI app -
|
|
329
|
+
registration, `/authorize`, the Google callback, `/token`, then an authenticated
|
|
330
|
+
`tools/list` - with Google's token endpoint replaced by a stub, so no account or network
|
|
331
|
+
is needed.
|