stl-transit
MCP server for St. Louis transit developer tooling, providing 45 tools for GTFS and GTFS-Realtime inspection, feed surveillance, schedule and arrival queries, assertions, drift detection, and golden-fixture generation for the Light Phone 3 Kotlin transit app, all backed by public unauthenticated sources.
README
stl-transit
Developer tooling for the Light Phone 3 St. Louis transit tool: GTFS and GTFS-Realtime inspection, golden-fixture generation for the Kotlin test gate, and feed surveillance.
Never ships in the APK. This is build-time tooling and Light never vets it. The shipped tool is a separate Kotlin repo, and unlike this one it cannot use Metro's trademarks.
Ships as one thing usable two ways:
- CLI —
stl ..., 64 commands, for interactive work and cron/CI. - MCP server —
stl-mcpon stdio, 45 tools, for Claude Cowork / Desktop / Code.
Both are thin shells over stl_transit.core.service. See SPEC.md for the full
design and the CLI-to-MCP contract; tests/test_wiring.py enforces it.
Install
git clone https://github.com/tyleryancey/stl-transit
cd stl-transit
python3 -m venv .venv
.venv/bin/pip install -e .
.venv/bin/stl doctor # sanity check
Requires Python 3.12+. No API keys — every source is public and unauthenticated, which is what keeps MCP configuration trivial.
First run
.venv/bin/stl snapshot fetch metro_gtfs # ~3.5 MB zip, expands to ~29 MB
.venv/bin/stl gtfs import # build the SQLite index
.venv/bin/stl report brief # is anything wrong right now?
.venv/bin/stl gtfs stop-resolve # answers the stop_code vs stop_id question
.venv/bin/stl rt fetch --entity trip_updates
.venv/bin/stl assert run # the assumptions the app depends on
Snapshots land in ~/.local/share/stl-transit (override with $STL_HOME).
Attaching to Claude Cowork / Desktop
Add to your MCP config (Cowork: Settings → Connectors → Add local server;
Desktop: claude_desktop_config.json). Use absolute paths:
{
"mcpServers": {
"stl-transit": {
"command": "/absolute/path/to/stl-transit/.venv/bin/stl-mcp",
"env": {
"STL_HOME": "/absolute/path/to/stl-transit"
}
}
}
}
Verify before wiring it up:
.venv/bin/stl-mcp # should sit silently waiting on stdio; Ctrl-C to exit
Pointing STL_HOME at a directory inside the repo (gitignored) keeps every
snapshot the agent touches next to the code, which makes a Cowork session
reproducible after the fact.
What the 45 tools cover
| Group | Tools |
|---|---|
| Orientation | stl_doctor, stl_snapshot_sources, _list, _fetch |
| Feed shape | stl_gtfs_coverage, _files, _stats, _features, _schema |
| Entities | stl_gtfs_routes, _route, _stops, _stop, _stop_resolve |
| Schedule | stl_gtfs_departures, _calendar, _service_day, _late_night |
| Realtime | stl_rt_health, _decode, _wire, _schema_census, _reference, _stop_arrivals |
| Assumptions | stl_assert_list, _run, _explain |
| Drift | stl_diff_summary, _stop_ids |
| Web sources | stl_web_list, _capture, _extract, _check |
| Ship artifacts | stl_bundle_fares, _holidays, _size_report |
| Digests | stl_report_brief, _handoff |
| Oracle | stl_oracle_cases, _generate, _verify |
| Support | stl_support_explain_empty, _repro, _diff_device |
| Escape hatch | stl_gtfs_query |
stl_gtfs_query is why the surface stays at 45 rather than 64: anything the
named tools do not cover is expressible as read-only SQL. It is also the only
tool with real blast radius, so it is constrained at the SQLite driver —
mode=ro, an authorizer denying ATTACH/PRAGMA/every write, a wall-clock
timeout, and row/byte caps. DROP TABLE routes comes back as a structured
UNSAFE_QUERY error with a remedy, not a stack trace.
A note on surface size.
SPEC.md§10 targets 20–25 MCP tools, arguing from context-window economics. This server exposes 45, which is a deliberate departure: every group added since answers a question no other tool can, and the ones that would genuinely flood a context (bundle stops-indexat 646 KB,bundle compact,support bundle) are CLI-only for exactly that reason. The mitigation for a larger surface is the question-to-tool index at the top of the server instructions, so a cold-start model does not have to read 45 descriptions to find its entry point.
The parts that carry the most weight
core/gtfs/calendar.py + departures.py — the reference implementation the
Kotlin engine is graded against. GTFS measures times from noon-minus-twelve-hours,
not local midnight; a 00:12 departure is usually encoded 24:12:00 on the
previous service date; pickup_type=1 means a rider cannot board. Every
result carries service_date and gtfs_time beside the resolved local time, so
an implementation that gets the instant right by luck and the service date wrong
fails visibly instead of quietly.
The time arithmetic is done in UTC throughout, and that is not stylistic.
Subtracting or adding a timedelta on a zone-aware datetime is wall-clock
arithmetic in Python — and in java.time.LocalDateTime — so noon - 12h
collapses back to local midnight and every departure on the two DST transition
days each year shifts by an hour. Port the UTC conversion, not just the formula.
core/rt/wire.py + schema.py — a hand-rolled protobuf reader, deliberately
not gtfs-realtime-bindings. The LP3 has no protobuf runtime on the Light SDK
dependency allow-list, so the on-device decoder is either
kotlinx-serialization-protobuf or hand-written. schema.py is the porting
table and it marks signedness explicitly: delay is int32, and a decoder that
reads it unsigned turns "three minutes early" into 18446744073709551436. About
31% of the delay values in Metro's feed are negative, so this is the common case,
not the edge case.
core/assertions/ — 16 things the app depends on that Metro never promised:
that stop_code is populated and unique, that no trip runs past 28:00, that the
agency timezone does not move, that ≥95% of realtime trip ids resolve into the
static feed. Every result reports the observed value beside the threshold,
because "coverage 0.982, threshold 0.99" is actionable and "FAIL" is not. Three
outcomes, not two: skip means the measurement could not be taken, and a
stability check with no baseline has not been performed.
core/oracle.py — 19 golden-fixture cases, each present because it can break
independently: DST spring-forward and fall-back, 24:xx rollover queried from both
sides of midnight, Labor Day (MetroBus → Sunday, MetroLink → Weekend — different
concepts), an ordinary Monday that is not a holiday, expired feed as a distinct
state, realtime absent. A case that legitimately raises is a first-class
expectation compared on error type, so it does not read as permanent drift.
Coverage
metro_gtfs covers MetroBus in both Missouri and Illinois — St. Clair County
service is operated by Metro under contract and lives in this feed — plus
MetroLink. Two Illinois-side sources are configured but blocked on unresolved
URLs; stl snapshot sources reports them with discovery notes:
mct_gtfs— Madison County Transit, own buses, not in Metro's feed. Resolve via Transitland (f-madison~county~transit~il~us) or Mobility Database.loop_trolley— seasonal streetcar. Check whether its trips are already insidemetro_gtfsbefore adding it separately.
Resolving either is a one-line edit to src/stl_transit/data/sources.toml.
Exit codes
0 ok · 1 error · 2 usage · 3 assertion violated · 4 drift detected ·
5 network unavailable · 6 feed expired. Codes 3, 4 and 6 exist so
stl assert run, stl web check, stl oracle verify and stl report brief
drop into cron or a GitHub Action without anyone parsing output.
Tests
.venv/bin/pip install -e '.[dev]'
.venv/bin/pytest -q # 431 tests, no network
Everything runs against miniature synthetic feeds in tests/fixtures.py — three
stops, two routes, a calendar exception, a 24:12 rollover trip — small enough to
reason about completely, which is the only way to be sure the calendar math is
right rather than merely self-consistent. Protobuf tests run against
hand-encoded bytes with known field numbers.
Two suites go further than unit coverage:
tests/test_wiring.pyenforces the CLI-to-MCP contract itself: tool naming, the four annotations on every tool, thatcore/never importscli/ormcp/and never prints, and that no MCP tool exists without a CLI equivalent to debug it from.scripts/verify_fixes.pyre-runs the 2026-08-03 audit's failures against a real snapshot. The synthetic feeds have no negative RT delays and no 489k-row table to time out on, so these checks cannot be unit tests.scripts/smoke_mcp.pyspeaks real JSON-RPC tostl-mcpover stdio. Importing the tool functions proves the wiring but not that the server starts, negotiates a protocol version, or serializes its schemas — and those failures all happen before any of this code runs.
tests/test_review_fixes.py is worth reading on its own: it pins a class of bug
that appeared six separate times in this codebase. Adding a timedelta to a
zone-aware datetime, or subtracting or comparing two datetimes that share a
tzinfo object, is wall-clock arithmetic in Python. It is correct on 363 days
a year. On the fall-back night it made a bus 45 minutes away report as
−15 minutes and disappear from the board entirely; a fixed 1440-minute probe
missed the 25th hour and told a developer a stop had no service when it did.
If you touch time arithmetic here, do it in UTC, and add a test dated in March
or November — the shipped feed only covers about a month, so no real snapshot
can exercise a transition.
The second recurring theme is reporting success on a measurement never taken:
a survival rate of null beside meets_assumption: true, a drift check that
returned ok having verified zero fixtures, an assumption that failed every
morning because it measured how long since you last fetched rather than what it
claimed to. That failure mode does not announce itself — it looks exactly like
good news. skip is a first-class outcome here for that reason.
Evaluations
evaluations/ holds 10 question/answer pairs pinned to a specific snapshot,
each requiring several tool calls to answer. evaluations/verify_answers.py
recomputes all ten by two independent routes and tells you which have moved when
the pinned snapshot is eventually replaced.
Not yet built
Per SPEC.md: job handles for long operations (snapshot fetch, rt poll,
history pull, gtfs validate still block rather than returning a job id), the
history group over Mobility Database archives, and rt record / rt replay.
rt replay is a file emitter when built; the local HTTP server that mimics
Metro's endpoints for on-device testing is backlogged.
Terms
Metro's data is licensed non-exclusively, limited, and revocably, with no
trademark use permitted. Full terms:
https://www.metrostlouis.org/developer-resources/. Be a good guest — the HTTP
layer sends an identifying User-Agent, uses conditional requests, rate-limits
per host, and caps web-page fetches at one per day. stl assert run watches the
terms page for changes, because the redistribution right this project rests on
is one Metro can withdraw.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.