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.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。