Skip to main content
Glama

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-mcp on 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.

Related MCP server: db-mcp

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-index at 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 inside metro_gtfs before 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.py enforces the CLI-to-MCP contract itself: tool naming, the four annotations on every tool, that core/ never imports cli/ or mcp/ and never prints, and that no MCP tool exists without a CLI equivalent to debug it from.

  • scripts/verify_fixes.py re-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.py speaks real JSON-RPC to stl-mcp over 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.

Available Tools

45 tools
stl_assert_explainA
Read-onlyIdempotent

One assumption in full: why it matters, which code path depends on it, and how to remediate a failure. Call this on anything stl_assert_run reports as failing, before deciding what to do about it.

Args: assumption_id: an id from stl_assert_list, e.g. 'stop_code_unique' or 'rt_join_rate'.

Inputs: assumption_id (required). Returns: result

stl_assert_listA
Read-onlyIdempotent

The assumptions this app makes about the feed that Metro never promised.

Each one names what it checks and, more usefully, what breaks in the app if it stops holding. Read this before adding a feature that depends on feed behaviour, so the dependency gets encoded rather than discovered later by a user.

Inputs: no named parameters. Returns: result

stl_assert_runA
Read-onlyIdempotent

Evaluate the assumption suite against the current feed.

Every result carries the OBSERVED value beside the threshold, so a failure is actionable without a second call: "stop_code coverage 0.982, threshold 0.99" tells you how bad it is, "FAIL" does not.

Three outcomes, not two. skip means the measurement could not be taken -- a stability check with no baseline to compare against has not been performed, and reporting that as a pass would be a lie.

Args: only: assumption ids to run. Omit for all. baseline: snapshot id or pin name for the stability assumptions (stop_ids_stable, rail_route_ids_stable). Without it those skip.

Inputs: only, baseline, snapshot. Returns: result

stl_bundle_faresA
Read-onlyIdempotent

The fare table the app ships, with its as_of date and source URL baked in.

Fares are NOT in the GTFS feed -- this reads the latest capture of Metro's fares page, so run stl_web_capture first if it reports nothing. Prices are integer cents; a fare table carrying 2.4999999 is a bug that reaches riders.

Args: fmt: 'json', or 'kotlin' to emit compilable Kotlin source. Hand-copying a fare table into Kotlin is how a stale fare reaches a rider.

Inputs: fmt. Returns: result

stl_bundle_holidaysA
Read-onlyIdempotent

Holiday to service-type mapping, bus and rail kept distinct.

On a holiday MetroBus runs SUNDAY service while MetroLink runs WEEKEND service. Those are different concepts that happen to coincide most of the time, and merging them produces a wrong answer on exactly the days a rider is most likely to check.

Args: year: the calendar year to resolve holiday dates against. Defaults to the current year, which is usually but not always what you want near a year boundary.

Inputs: year. Returns: result

stl_bundle_size_reportB
Read-onlyIdempotent

On-device size budget: bytes per table, with index cost isolated.

The raw feed is ~29 MB expanded and the LP3 is a minimalist device, so this is what decides which pruning strategy the shipped app uses.

Args: compact_path: a database built by stl bundle compact, to compare against the full feed.

Inputs: snapshot, compact_path. Returns: result

stl_diff_stop_idsA
Read-onlyIdempotent

Survival rate of stop_id and stop_code across a service change.

The single most consequential number in this whole tool. The app's saved- stops feature lives or dies on it: every code that does not survive a pick is a user whose saved stop silently stops working, with no error and no way for them to tell what happened.

Args: a: the earlier snapshot id or pin name. b: the later one. Run stl_snapshot_list to see what is stored.

Inputs: a (required), b (required). Returns: result

stl_diff_summaryA
Read-onlyIdempotent

Everything that changed between two GTFS snapshots, in one screen.

Findings are graded, because a pick that renames three headsigns is routine and one that retires four hundred stop codes is not, and an ungraded list of deltas makes the reader do that triage themselves.

Args: a: the earlier snapshot id or pin name. b: the later one. Direction matters and is never normalized.

Inputs: a (required), b (required). Returns: result

stl_doctorA
Read-onlyIdempotent

Health of the local environment: store location, snapshot count, disk use, which configured sources are usable and which are blocked on an unresolved URL. Call this first if you do not know what data is available locally.

Inputs: no named parameters. Returns: result

stl_gtfs_calendarB
Read-onlyIdempotent

service_ids active on a date, showing the calendar.txt weekly pattern and each calendar_dates.txt exception SEPARATELY rather than pre-merged, so you can see whether a date's behaviour came from the weekly pattern or from an exception.

Args: on: ISO date (YYYY-MM-DD). Defaults to today.

Inputs: on, snapshot. Returns: result

stl_gtfs_coverageA
Read-onlyIdempotent

Service date range and days remaining before the feed expires.

Metro publishes a feed whose service data ends at the next quarterly pick. Check this before trusting any departure result: an empty departures list is frequently an expired feed rather than an absent bus.

Inputs: snapshot. Returns: result

stl_gtfs_departuresA
Read-onlyIdempotent

Scheduled departures at a stop for a time window. Schedule only, no realtime.

Correctly attributes departures encoded past 24:00:00 to the previous service date, and resolves service_ids through both calendar.txt and calendar_dates.txt. Each result carries its service_date and raw gtfs_time alongside the resolved local time, so a wrong service-date attribution is visible rather than hidden.

Args: stop: stop_code (number on the sign) or stop_id. at: ISO-8601 instant. Naive values are read as America/Chicago local time. Defaults to now. window_minutes: how far ahead to look. route: optional route_id or route_short_name filter.

Inputs: stop (required), at, limit, route, +2 more. Returns: result

stl_gtfs_featuresB
Read-onlyIdempotent

Which GTFS features this feed provides, phrased to line up with the badges on the Mobility Database feed page (route colors, shapes, headsigns, wheelchair accessibility, fares, pathways, transfers, frequencies).

Inputs: snapshot. Returns: result

stl_gtfs_filesB
Read-onlyIdempotent

Inventory of files in the GTFS zip with row counts and columns, plus a list of optional GTFS files that are ABSENT and why each absence matters (transfers, fares, frequencies, pathways...).

Inputs: snapshot. Returns: result

stl_gtfs_late_nightB
Read-onlyIdempotent

Trips whose stop times cross the service-day boundary, plus the maximum departure_time anywhere in the feed. Use it to find edge-case test material.

Args: threshold: GTFS time string; departures at or after it are returned.

Inputs: limit, snapshot, threshold. Returns: result

stl_gtfs_queryA
Read-onlyIdempotent

Run one read-only SQL query against the imported GTFS feed.

Tables are the GTFS filenames without .txt: agency, stops, routes, trips, stop_times, calendar, calendar_dates, shapes, feed_info. All columns are TEXT, including numeric-looking ids -- leading zeros are meaningful in GTFS.

Writes, ATTACH and PRAGMA are denied at the database driver; a wall-clock timeout and row/byte caps are enforced. Only a single statement is accepted.

This is the general-purpose escape hatch: anything the named tools do not cover can be expressed here.

Args: sql: a single SELECT or WITH statement. limit: max rows (hard cap 1000).

Inputs: sql (required), limit, snapshot. Returns: result

stl_gtfs_routeB
Read-onlyIdempotent

One route in detail: directions, headsigns, per-service trip counts, and the first and last departure time in each direction.

Args: route_id: the GTFS route_id, NOT the number on the front of the bus. Metro's route_ids look like '19731B'; run stl_gtfs_routes with a search term to map a rider-facing number onto one.

Inputs: route_id (required), snapshot. Returns: result

stl_gtfs_routesB
Read-onlyIdempotent

List routes with ids, short and long names, type and trip counts.

Args: route_type: GTFS route_type as a string. '3' is bus, '0' tram/streetcar, '1' subway, '2' rail. search: case-insensitive substring match across all route fields.

Inputs: limit, offset, search, snapshot, +1 more. Returns: result

stl_gtfs_schemaB
Read-onlyIdempotent

Columns, null rates, distinct counts and sample values for one GTFS file.

Args: table: GTFS file name with or without .txt, e.g. 'stops' or 'stop_times.txt'.

Inputs: table (required), snapshot. Returns: result

stl_gtfs_service_dayA
Read-onlyIdempotent

Which GTFS service date(s) a wall-clock instant could belong to, with the corresponding gtfs_time for each.

Use this whenever a departure time looks off by a day. GTFS measures times from noon-minus-twelve-hours, not local midnight -- on DST transition days those differ by an hour.

Args: timestamp: ISO-8601 instant. Naive values read as America/Chicago.

Inputs: timestamp. Returns: result

stl_gtfs_statsA
Read-onlyIdempotent

Headline counts: agencies, routes broken down by route type, stops, trips, stop_times rows, shape points, service_ids.

Inputs: snapshot. Returns: result

stl_gtfs_stopA
Read-onlyIdempotent

One stop resolved by rider-facing code or internal id, with both identifiers, parent station, accessibility flags, coordinates, and the routes serving it.

Args: stop: a stop_code (the number on the sign) or a stop_id.

Inputs: stop (required), snapshot. Returns: result

stl_gtfs_stop_resolveA
Read-onlyIdempotent

Determine which GTFS field holds the number printed on a bus stop sign.

Reports coverage, uniqueness and observed format for both stop_code and stop_id, checks Metro's own published example (15111), and returns a verdict.

This matters more than it looks: the Light SDK exposes no usable location API, so 'stops near me' is not buildable and the app's entire input UX is stop-number entry. Run this once and rely on the verdict.

Inputs: snapshot. Returns: result

stl_gtfs_stopsA
Read-onlyIdempotent

Search stops by name substring, rider-facing stop code, or serving route.

Args: search: substring of stop_name, case-insensitive. code: exact stop_code match. route_id: return every stop served by this route.

Inputs: code, limit, offset, search, +2 more. Returns: result

stl_oracle_casesB
Read-onlyIdempotent

The golden-fixture case list for the Kotlin test gate, each with the specific failure mode it pins down (DST transitions, 24:xx rollover, holiday service mapping, expired feed, realtime absent, and so on).

Inputs: no named parameters. Returns: result

stl_oracle_generateA
Idempotent

Compute expected departure outputs and write committed fixture JSON files.

Output is byte-stable for a given snapshot (sorted keys, fixed indent) so that a later verify run is a meaningful drift check.

Args: spec_path: JSON file binding each case id to concrete inputs, e.g. {"weekday_midday": {"stop": "15111", "at": "2026-08-05T12:00:00"}}. out_dir: directory to write fixtures into. case: generate only this case id.

Inputs: spec_path (required), case, out_dir, snapshot. Returns: result

stl_oracle_verifyA
Read-onlyIdempotent

Recompute every committed fixture against the current feed and report which ones no longer match. Drift means either the feed changed or the fixtures are stale -- both are things you want to learn from a scheduled run, not a user.

A case that legitimately raises (unknown stop code, expired feed) is a first-class expectation, compared on error type rather than message, so it does not read as permanent drift.

Args: fixtures_dir: directory of committed fixture JSON, normally the tool repo's test resources rather than anywhere in this store.

Inputs: snapshot, fixtures_dir. Returns: result

stl_report_briefA
Read-onlyIdempotent

The state of the feed right now, in one call, with the next command to run.

Composes coverage, the assumption suite, realtime health and web drift. Every input is optional and absences are reported rather than silently passed, so this still works on a machine that has only ever fetched the static feed. Start here if you do not know what is wrong.

Inputs: snapshot. Returns: result

stl_report_handoffA
Read-onlyIdempotent

Verified facts about the feed as a markdown block, with citations.

Written for pasting into a CLAUDE.md handoff document. Every claim carries the snapshot id and date it was verified against, so a later reader can re-verify rather than trust -- the feed moves, and an uncited fact in a handoff doc silently becomes a lie. Includes the sharp edges that bite a Kotlin port: 24:xx encoding, DST arithmetic, stop_code vs stop_id, the absent protobuf runtime, fares living off-feed.

Inputs: snapshot. Returns: result

stl_rt_decodeB
Read-onlyIdempotent

Decode a stored GTFS-Realtime snapshot into normalized JSON.

Fields present in the bytes but absent from the schema map are preserved under '_unknown' rather than dropped, because silently discarding fields is how you ship a decoder that is wrong in ways nobody notices.

Args: entity: 'trip_updates', 'vehicle_positions', or 'alerts'.

Inputs: limit, entity, snapshot. Returns: result

stl_rt_healthA
Read-onlyIdempotent

Staleness and entity counts for the locally stored realtime feeds, and whether the three feeds agree on their header timestamp.

Args: entity: 'trip_updates', 'vehicle_positions', or 'alerts'. Omit for all.

Inputs: entity. Returns: result

stl_rt_referenceA
Read-onlyIdempotent

The full GTFS-Realtime field map as a flat table (message, field number, name, kind, repeated) plus all enum value mappings.

This is the porting reference for the on-device decoder. The Light SDK dependency allow-list contains no protobuf runtime, so the Kotlin decoder is either kotlinx-serialization-protobuf or hand-written from this table.

Inputs: no named parameters. Returns: result

stl_rt_schema_censusA
Read-onlyIdempotent

Which protobuf fields this feed actually populates, and at what rate, across N stored snapshots.

Decides what to model in Kotlin: low-rate fields can be skipped in v1, and any path reported as unmodelled is present in the bytes but missing from the schema map, which needs investigating before porting.

Args: samples: how many recent snapshots to census.

Inputs: entity, samples. Returns: result

stl_rt_stop_arrivalsA
Read-onlyIdempotent

Scheduled departures with realtime predictions merged in -- exactly what the app should render.

When no realtime snapshot is available it degrades to scheduled-only and says so explicitly, which is the behaviour the app must also have.

Args: stop: stop_code or stop_id. at: ISO-8601 instant, America/Chicago if naive. Defaults to now.

Inputs: stop (required), at, limit, snapshot, +1 more. Returns: result

stl_rt_wireA
Read-onlyIdempotent

Raw protobuf wire-format dump of a realtime snapshot: field number, wire type, length, bytes and nesting, with the named path for each numeric path.

This is the ground-truth artifact for validating a hand-written decoder. Point the Kotlin implementation at the same snapshot and compare trees.

Args: depth: how deep to recurse into submessages. max_entities: how many feed entities to dump (keeps output bounded).

Inputs: depth, entity, snapshot, max_entities. Returns: result

stl_snapshot_fetchA

Download a feed from the network into the local snapshot store.

Uses conditional requests, so an unchanged feed returns unchanged=true and costs a 304 rather than re-downloading. The GTFS zip is ~3.5 MB and expands to ~29 MB, so this can take a few seconds.

Args: source: 'metro_gtfs', 'metro_rt_trips', 'metro_rt_vehicles', 'metro_rt_alerts'. force: bypass the conditional-request cache.

Inputs: force, source. Returns: result

stl_snapshot_listA
Read-onlyIdempotent

List stored snapshots newest-first, with pins.

Args: kind: 'gtfs' or 'rt'. Omit for all. source: source name, e.g. 'metro_gtfs'. Omit for all.

Inputs: kind, limit, offset, source. Returns: result

stl_snapshot_sourcesA
Read-onlyIdempotent

List every configured feed and page: agency, region, URL, whether it is usable, how many snapshots exist locally, and when the latest was fetched.

Inputs: no named parameters. Returns: result

stl_support_diff_deviceA
Read-onlyIdempotent

Diff what a device actually rendered against what it should have.

Deliberately forgiving about the shape of actual_json: it accepts a bare list, several wrapper shapes, and differing key names, and reports what it assumed. The realistic input is something pasted out of a bug report, and a support tool that rejects the user's paste over a key name has failed at its one job.

Args: expected_json: a file path or inline JSON -- typically the output of stl_support_repro. actual_json: a file path or inline JSON captured from the device.

Inputs: actual_json (required), expected_json (required). Returns: result

stl_support_explain_emptyA
Read-onlyIdempotent

Diagnose why a stop shows no departures, by walking the decision tree and naming the branch: unknown stop code, expired feed, no service that date, stop present but never served, or simply too narrow a window.

Use this whenever stl_gtfs_departures returns an empty list, instead of guessing at the cause.

Args: stop: stop_code or stop_id. at: ISO-8601 instant, America/Chicago if naive.

Inputs: stop (required), at, snapshot, window_minutes. Returns: result

stl_support_reproA
Read-onlyIdempotent

Reconstruct exactly what the app should have shown at a stop and instant.

This is how "stop 15111 showed nothing at 11:47 last Tuesday" gets answered without a device and without waiting for Tuesday. When the answer is empty it also returns WHY it is empty, rather than leaving you to guess.

Args: stop: stop_code (the number on the sign) or stop_id. at: ISO-8601 instant; naive values read as America/Chicago. rt_snapshot: a stored realtime snapshot id to merge in, for reproducing a complaint about a wrong prediction.

Inputs: stop (required), at, snapshot, rt_snapshot, +1 more. Returns: result

stl_web_captureA

Fetch, normalize, extract and store a Metro web page.

Hashes the EXTRACTED content, never the raw HTML: raw HTML changes on every request (analytics ids, nonces, rotating images), so hashing it would make every later drift check a false positive.

Rate-limited to one fetch per page per day by default. Metro is a public agency whose infrastructure this tool is an unpaid guest on.

Args: page: 'fares', 'holidays', 'purchase', 'schedule_changes', 'developer_terms', 'rider_alerts'. Omit to capture all. force: bypass the interval gate and the conditional-request cache.

Inputs: page, force. Returns: result

stl_web_checkA
Read-onlyIdempotent

Has any watched page changed since its last capture?

This is the surveillance job. A changed fares page means the app's bundled fare table is now lying to riders; a changed developer-terms page means the redistribution rights this whole project rests on may have moved.

Inputs: no named parameters. Returns: result

stl_web_extractA
Read-onlyIdempotent

Structured data pulled out of a stored page capture.

fares -> fare rows with prices in integer cents; holidays -> holiday rows with BUS and RAIL service kept separate; schedule_changes -> the pick id; others -> normalized text.

Args: page: the page key. Run stl_web_list for valid values.

Inputs: page (required), snapshot. Returns: result

stl_web_listA
Read-onlyIdempotent

Metro web pages configured for capture, with their last capture and content hash. Fares, holiday schedules, the developer terms, and the upcoming-schedule-changes page -- everything the app needs that is not in the GTFS feed.

Inputs: no named parameters. Returns: result

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

    TDQS

    A3.7/5.0

    Scored across 45 tools

    Disambiguation5/5

    Each tool has a clearly distinct purpose: static GTFS inspection, realtime decoding, oracle fixtures, web capture, diffing, and support reconstruction are cleanly separated even where they touch similar data. Potentially adjacent tools like stl_gtfs_departures, stl_rt_stop_arrivals, and stl_support_repro are explicitly differentiated by schedule-only vs realtime-merged vs device-reproduction intent.

    Naming Consistency5/5

    Names follow a highly consistent stl_<domain>_<topic> snake_case pattern, with domain prefixes like gtfs, rt, oracle, web, diff, assert, bundle, report, and support. Plural/singular distinctions and verb choices are uniform and predictable across the set.

    Tool Count2/5

    45 tools is far beyond the 3-15 well-scoped range and well above the 25+ 'too many' threshold. The breadth is justified by the domain's complexity, but the raw count still makes the surface heavy and harder for an agent to scan efficiently.

    Completeness5/5

    The tool surface covers the full lifecycle: fetching static and realtime feeds, inspecting GTFS schema and data, querying departures and calendars, decoding realtime protobuf, generating/verifying oracle fixtures, diffing snapshots, capturing web pages, bundling fares/holidays, running assumptions, and producing reports and support reconstructions. No obvious dead-end workflow remains.

    Maintenance

    ActivityInactive
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers