Find the last lift before it fails. Every MBTA station's step-free routes as a flow network in FalkorDB: certified max-flow redundancy, live lift closures, what-ifs and step-free reroutes, served to...
Overview
Unofficial decision-support prototype. Not affiliated with or endorsed by the MBTA or MassDOT. Uses MBTA GTFS and V3 API data under the MassDOT Developers License Agreement. Data may be incomplete or out of date. Always check mbta.com/alerts/access before travelling. Elevator status line 617-222-2828; customer support 617-222-3200 (TTY 711). Results describe the open-data model and may differ from conditions on the ground. LastLift turns the MBTA's published station layouts into a flow network in FalkorDB, so for every platform it knows how many lift failures it takes to cut riders who need step-free access off, which lift that is, and what to do when it happens. An agent answers questions about it through tools that any MCP client can call. - Every step-free route through a station is a path through lifts, walkways and fare gates. Give each lift a capacity of one and . One means a single point of failure.
README
LastLift
Find the last lift before it fails. No prediction. Just the graph.
Unofficial decision-support prototype. Not affiliated with or endorsed by the MBTA or MassDOT. Uses MBTA GTFS and V3 API data under the MassDOT Developers License Agreement. Data may be incomplete or out of date. Always check mbta.com/alerts/access before travelling. Elevator status line 617-222-2828; customer support 617-222-3200 (TTY 711).
Results describe the open-data model and may differ from conditions on the ground.
89 of 366 step-free platforms in Boston’s MBTA station data hang on a single lift. LastLift turns the MBTA’s published station layouts into a flow network in FalkorDB, so for every platform it knows how many lift failures it takes to cut riders who need step-free access off, which lift that is, and what to do when it happens. An agent answers questions about it through tools that any MCP client can call.
Graph Hacks track: Agents That Act on Connected Data.
| Live API (FastAPI + FalkorDB) | https://lastlift-api.onrender.com/v1/docs (free hosting: the first request may take a minute to wake it) |
| MCP endpoint | https://lastlift-api.onrender.com/mcp/ |
| Website | link added when deployed |
| Demo video | link added when recorded |
If you have 60 seconds
- Every step-free route through a station is a path through lifts, walkways and fare gates. Give each lift a capacity of one and max-flow is the number of lifts that must fail before a platform is cut off. One means a single point of failure.
- Today, with lift 961 closed at Alewife, lift 813 is newly the only lift for the Red Line, Track 1 and Track 2 platforms. No alert says that; the graph does.
- 74 of the single-lift platforms are at stations with two or more lifts: counting lifts is not enough.
- Every number is a certified FalkorDB result, cross-checked against NetworkX, and every agent answer cites the exact Cypher it ran.
Instead of “Elevator out of service”, LastLift says what it means for you
| The alert | LastLift |
|---|---|
| “Revere Beach Elevator 885 is unavailable.” | The Wonderland platform loses step-free access in the open-data model. To board step-free: lift 884, ride the Blue Line to Beachmont, change platforms there step-free, ride back (about 17 minutes). There is no backup avoiding lifts 884, 886 and 887. |
| “Alewife Elevator 961 is unavailable.” | No platform loses access, but lift 813 is now the only lift for three platforms. Protect it. |
| “Elevator 782 at Worcester is unavailable.” | Cannot assess: MBTA publishes no station layout for Worcester. |
Judge it in 90 seconds
POST https://lastlift-api.onrender.com/v1/tools/platform_redundancywith{"station_id": "place-alfcl"}: Alewife, with the lift that became the last lift and why.POST .../v1/tools/what_if_closewith{"facility_ids": ["813"]}: close a working lift on a copy of the graph; three platforms lose step-free access; the live state is untouched.POST .../api/askwith{"question": "Can I get step-free onto the Wonderland-bound platform at Revere Beach right now?"}: the agent’s answer and every tool call it made, via MCP.GET .../v1/queries: every Cypher statement LastLift runs.python scripts/smoke.py --prodchecks all of the above from your machine.
Every submission requirement, and exactly where it is
| Rule 10 requirement | Where |
|---|---|
| Public source-code repository | this repository |
| README with setup instructions and the graph data model | Run it yourself, The graph data model |
| The Cypher queries or graph algorithms the product depends on | The Cypher and the algorithms (generated from lastlift/queries.py) |
| A demo video | added when recorded |
| A live deployment, or complete local setup | https://lastlift-api.onrender.com and Run it yourself |
| The track | Agents That Act on Connected Data |
Rule 3 (the graph does real work): see Turn FalkorDB off. Rule 6 (data you may use): public MBTA open data only. Rule 11 (AI disclosure): AI assistance.
Every redundancy number comes with a certificate
FalkorDB 4.22’s algo.maxFlow can return a wrong value on some networks: on an 11-edge reproduction it returns 300 where the true max-flow is 453 (reported by the Chokepoint project; reproduced in docs/falkordb-maxflow-note.md). So no FalkorDB flow is trusted on its own:
- each result is certified by checking that the returned flow is a feasible flow whose residual graph has no augmenting path, and repaired with an exact Dinic run if it is not;
- the minimum cut names the lifts; each lift on a single route is removed in turn to find every single point of failure;
- an independent checker validates the k lift-disjoint routes as a proof.
On LastLift’s network: 734 max-flow calls, 734 certified on the first pass, 0 repaired, 734 equal to NetworkX, 333 route proofs accepted and 0 rejected. scripts/verify_claims.py re-derives every headline number from the committed snapshot with NetworkX alone.
The same caution applies to path search: on 4.22 algo.SPpaths enumerates paths and ignored the server timeout in our tests, so LastLift routes with Dijkstra over edges read from the graph and re-checks every edge of a route in FalkorDB (docs/falkordb-sppaths-note.md).
One closed lift, seen across the whole network
A lift can cut a platform off, yet riders may still ride a train to another station and back. So LastLift also asks the network question: which journeys between stations does a closure break?
- 69 lifts can each cut a platform off on their own; only 15 can cut a whole station off from the step-free network (13 stations; each such closure breaks 176 of the 7,832 journeys between the 89 stations with an accessible entrance). For the other 54 the network re-routes riders: median worst detour 11 min, longest 20.2 min.
- How: dominator trees. Every lift is node-split, so closing it deletes one node; the journeys it breaks from an origin are exactly the stations in that node’s dominator subtree. One tree per origin (Cooper-Harvey-Kennedy, on the network contracted to its lift-free strongly connected parts) gives the whole N-1 table in a fraction of a second. A plain search with each lift removed agrees for 198 of 198 lifts, and the tests apply closures in FalkorDB with Cypher and re-read the graph to confirm it.
- Conditional last lifts: with one lift closed, its partner can become the only link between a station and the network. There are 42 such pairs, and 0 of them span two stations: in the open data the turnaround stations always have a spare. In the demo state, lift 885 is closed, so 884 alone now links Revere Beach to every other station.
- Critical pairs (N-2): 42 lift pairs are each harmless alone but cut platforms off together; the worst is 821+973 (18 platform-directions). Each pair is re-checked in FalkorDB: max-flow 0 with both closed, above 0 with either alone.
- Every minimum cut, not just one: for each of the 333 lift-dependent platform-directions, all minimum cuts come from the certified flow’s residual graph (Picard-Queyranne); 333 equal a brute-force search over every set of lifts, and 71 have more than one.
- Exact reliability: no platform depends on more than twelve lifts, so the chance of losing access when each lift is out with probability q is computed exactly by enumeration (the station page has a slider). With every lift out 2% of the time, about 99% of journeys between stations stay step-free (Monte-Carlo, seeded).
- Repair order: for the lifts closed now, the order that gives back the most soonest is computed exactly (dynamic programming over subsets) and compared with greedy.
- Query plans:
GET /v1/queries/{id}/plan?mode=explain|profilereturns FalkorDB’s own plan (profiles run on a private copy; write queries are refused); examples are indocs/profiles/.
The Silver Line (SL1-SL5) counts as a ride in this analysis (model rules version 2): Courthouse and World Trade Center are reachable step-free only by it.
Seven years of closures, replayed through the graph
MBTA keeps no lift-outage history of its own, but MassDOT publishes every service alert, and MBTA’s LAMP archive goes back to 2019. LastLift replays every reported elevator closure since 1 January 2024 through today’s step-free graph (validation/HISTORY.md):
- 285 platform-directions lost step-free access at least once, for 58,286 platform-direction hours within scheduled service; 93% of those hours trace to a single lift. Redundancy was defeated (two or more lifts out together) 390 times.
- The costliest single lift was 811 (6,994 service hours). Each lift’s availability, closures and median closure length are in the graph (
(:Outage)-[:OF]->(:Lift)) and in the agent’slift_historytool. - The certified k explains real exposure: single-lift platform-directions lost access in 47% of months, those with two or more lift routes in 6%.
- What history cannot do (a negative result we publish): ranking lifts by their 2024 record does not pick out the lifts that cost the most in 2025 (Spearman 0.17 over 69 lifts, precision of the top ten 0.1), and a monthly closure model fails the calibration bar set in advance (Brier skill 0.084). So LastLift shows historical frequencies and never a failure forecast.
- With each lift’s own outage rate instead of a uniform guess, 20 platform-directions would be without step-free access more than 5% of the time.
- Checks: 20 random moments of the replay re-checked with FalkorDB’s certified max-flow on a copy of the graph; totals reproduce from the committed files in the tests; a sensitivity table (imputed ends in or out, episode gaps of 0, 30 and 120 minutes, calendar or service hours); the 2025 total from the portal alone reproduces the planning probe within 1%, and the sources are compared side by side. The portal’s timestamps turned out to be Boston local time labelled as UTC; the history reads them as local time.
Checked against the operator’s own words
Fourteen live MBTA lift alerts were labelled blind by the author, from MBTA’s alert text only, before the evaluation ran (validation/). Of the 8 that both the author and the model could assess: 6 agree, 2 partial, 0 disagree (agreement interval 41%, 93%, Wilson 95%). False accessible: 1 (L04 board): for State 803 MBTA sends riders to Downtown Crossing, while the open data holds a longer in-station route; LastLift shows both and tells riders to follow MBTA’s advice. The model abstains where MBTA publishes no layout (unknown rate 6/14). Every row, including every disagreement, is in validation/EVALUATION.md. A second sweep over the alternative-service text of every MBTA elevator is labelled by the author before it is scored (awaiting author review (69 of 215 rows flagged)).
Turn FalkorDB off, and here is exactly what stops working
Everything LastLift computes is computed in FalkorDB: redundancy (algo.maxFlow on the step-free subgraph), closures (relationship-type swaps SF → SF_CLOSED), what-ifs (GRAPH.COPY of the live graph, closures applied to the copy), incident memory (Incident nodes linked to lifts), the repair ranking (DEPENDS_ON / DEPENDS_NOW relationships), the station drawings and the route certificates. Stop the database and:
/healthzreturns 503 and every tool and the agent answer 503graph_unavailable, never a guess (tested intests/test_memory.py);- no what-if, reroute, ranking or answer can be produced;
- the website falls back to a frozen copy of the demo state that FalkorDB computed earlier, and says so.
The agent acts through the same tools as everyone
- Ten tools (
lastlift/tools.py):resolve_place,current_closures,platform_redundancy,what_if_close,step_free_route,repair_priority,lift_history,draft_action,network_impact,repair_order. The LLM never writes Cypher; every tool runs fixed, parameterised queries and reports their ids. - The agent reaches the tools only through MCP (an in-memory MCP client; a test breaks every direct path and the agent still answers).
- Every answer is grounded: an id or number that is not in a tool result, or wording our copy rules forbid, sends the answer back to a deterministic template built from the tool results.
- Repair priority ranks lifts to repair now (closed, platforms cut off), to protect (working, but newly the last lift) and structural single points of failure;
draft_actiondrafts a ticket and never files it. - Providers fail over in a fixed chain: Groq
qwen/qwen3.8-27b, thenopenai/gpt-oss-120b, then Geminigemini-3.1-flash-lite, then the template. Each switch appears in the run’s trace, the answer names the model that wrote it, and a daily token budget (counted from the providers’ usage figures) stops LLM calls before the free quotas run out. A fault-injection test (tests/test_chain.py) fails each tier in turn. - Every run has a W3C trace id (send a
traceparentheader to join your own trace); each tool call has its own span id, and LLM calls are logged with OpenTelemetry GenAI attribute names (gen_ai.system,gen_ai.request.model,gen_ai.usage.input_tokens,gen_ai.usage.output_tokens). - Seven recorded runs in
web/public/replay/re-execute against the graph in CI (scripts/verify_replays.py): a recorded fact that no longer matches the graph fails the build.
Use the same tools from any MCP client
Point any MCP client that supports streamable HTTP at the endpoint:
{ "mcpServers": { "lastlift": { "url": "https://lastlift-api.onrender.com/mcp/" } } }
All ten tools are read-only (readOnlyHint); what_if_close works on a throwaway copy. The free host sleeps when idle, so the first call may take a minute.
Built for the people it is about
- Every state is shown as a glyph, a number and a word, never colour alone; “good” is blue, never green.
- The station drawing is decorative for assistive technology; the platform, lift and route tables say the same in words.
- Atkinson Hyperlegible type, a two-tone focus ring, 44-pixel targets, reflow at 320 pixels, reduced-motion and forced-colours support.
- CI runs axe on every core screen in light and dark themes with the API blocked, plus keyboard-only and 320-pixel checks (
web/tests/a11y.spec.ts). - Wording rules and exact disclaimers are enforced by a lint (
lastlift/wording.py); seedocs/ETHICS.md. Not yet reviewed by disabled riders; designed for that review.
The graph data model
graph LR
Station -- HAS_PLATFORM --> Platform
Station -- HAS_ENTRANCE --> Entrance
Station -- HAS_LIFT --> Lift
Street -- "SF (street)" --> Entrance
Entrance -- "SF (walk / gate)" --> Generic
Generic -- "SF (lift_access)" --> Lift
Lift -- "SF (lift, capacity 1) / SF_CLOSED" --> LiftOut
LiftOut -- "SF (lift_access)" --> Platform
Generic -. "STEP (stairs, escalator)" .-> Platform
Platform -- "RIDE (train, minutes)" --> Platform
Platform -- "DEPENDS_ON / DEPENDS_NOW {dir}" --> Lift
Lift -- CLOSED_IN --> Incident
Every node in a station complex carries cx; step-free walkways, gates and lift-access edges have capacity 1,000,000 and lift edges capacity 1, so a platform’s max-flow from its station’s street node is its number of lift-disjoint step-free routes. A closed lift’s edge is retyped SF_CLOSED, which takes it out of every algorithm at once. Model rules, including five evidence-backed overrides of the published data, are in docs/model_rules.md.
| Node labels | Count |
|---|---|
Lift |
42 |
Node:Entrance |
330 |
Node:Generic |
1,924 |
Node:Lift |
202 |
Node:LiftOut |
202 |
Node:Platform |
388 |
Node:Street |
89 |
Platform |
312 |
Station |
276 |
| Relationship | Count |
|---|---|
HAS_ENTRANCE |
330 |
HAS_LIFT |
244 |
HAS_PLATFORM |
388 |
RIDE |
650 |
SF |
9,248 |
STEP |
1,069 |
Step-free (SF) edge kind |
Count |
|---|---|
gate |
274 |
lift |
202 |
lift_access |
856 |
street |
424 |
walk |
7,492 |
Snapshot 2026-10-15, GTFS feed “Fall 2026, 2026-10-14T18:47:38+00:00, version D”, seed sha256 8277887f216a…: 3,765 nodes, 11,929 relationships.
The Cypher and the algorithms
FalkorDB algorithms used: algo.maxFlow (redundancy, certified), algo.WCC (load verification), full-text index (station search), GRAPH.COPY (what-ifs). Every statement LastLift runs, generated from lastlift/queries.py:
| Query | Cypher |
|---|---|
Q_CLEAR_DEPENDS_NOW |
MATCH (p:Node:Platform {cx:$cx})-[d:DEPENDS_NOW]->() DELETE d |
Q_CLEAR_INCIDENTS |
MATCH (i:Incident) DETACH DELETE i |
Q_CLOSED_AT_COMPLEX |
MATCH (l:Lift {cx:$cx})-[:SF_CLOSED]->() RETURN collect(DISTINCT l.facility_id) |
Q_CLOSED_FACILITIES |
MATCH (i:Lift)-[:SF_CLOSED]->() RETURN collect(DISTINCT i.facility_id) |
Q_CLOSE_LIFTS |
UNWIND $fids AS fid MATCH (i:Lift {facility_id:fid})-[r:SF {kind:'lift'}]->(o) CREATE (i)-[c:SF_CLOSED]->(o) SET c = properties(r), i.closed = true DELETE r RETURN count(c) |
Q_COMPLEX_FLOW_EDGES |
MATCH (a:Node {cx:$cx})-[e:SF]->(b:Node) RETURN e.eid, a.uid, b.uid, e.cap, e.kind, e.facility_id |
Q_COMPLEX_PLATFORMS |
MATCH (p:Node:Platform {cx:$cx}) RETURN p.stop_id, collect(p.uid), head(collect(p.name)), head(collect(p.wheelchair_boarding)), head(collect(p.platform_name)) |
Q_COMPLEX_STREETS |
MATCH (s:Street {cx:$cx}) RETURN collect(s.uid) |
Q_CONDITIONAL |
MATCH (a:Node:Lift)-[c:CRITICAL_PAIR]->(b:Node:Lift) WHERE c.scope = 'network' RETURN a.facility_id, b.facility_id, b.station_id, c.journeys, c.same_station ORDER BY c.journeys DESC, a.facility_id, b.facility_id |
Q_CREATE_DEPENDS |
UNWIND $rows AS r MATCH (p:Node:Platform) WHERE p.stop_id = r.stop MATCH (l:Node:Lift {{facility_id:r.fid, cx:p.cx}}) MERGE (p)-[d:{rel} {{dir:r.dir}}]->(l) |
Q_CRITICAL_PAIRS |
MATCH (a:Node:Lift)-[c:CRITICAL_PAIR]->(b:Node:Lift) WHERE c.scope = 'platform' RETURN a.facility_id, b.facility_id, a.station_id, c.n, c.dirs ORDER BY c.n DESC, a.facility_id, b.facility_id |
Q_DOMINATES_FOR |
MATCH (l:Node:Lift {facility_id:$fid})-[:DOMINATES_FOR]->(s:Station) RETURN collect(DISTINCT s.uid) |
Q_LIFT_BLAST |
MATCH (l:Node:Lift) WHERE l.facility_id IN $fids WITH l.facility_id AS fid, head(collect(l)) AS l RETURN fid, l.name, l.station_id, l.od_cut_base, l.od_cut_off, l.od_slow_base, l.detour_max_s, l.detour_mean_s ORDER BY fid |
Q_LIFT_COMPLEXES |
MATCH (l:Node:Lift) WHERE l.facility_id IN $fids RETURN collect(DISTINCT l.cx) |
Q_LIFT_HISTORY |
MATCH (l:Lift {facility_id:$fid})-[:CLOSED_IN]->(i:Incident) RETURN DISTINCT i.alert_id, i.effect, i.header, i.started, i.first_seen, i.last_seen, i.ongoing, i.ended_by, i.versions ORDER BY i.first_seen |
Q_LIFT_INCIDENT_COUNT |
MATCH (l:Lift {facility_id:$fid}) OPTIONAL MATCH (l)-[:CLOSED_IN]->(i:Incident) RETURN count(DISTINCT i) |
Q_LIFT_INFO |
MATCH (l:Lift {facility_id:$fid}) RETURN l.facility_id, l.name, l.short_name, l.station_id, l.cx, l.modelled, coalesce(l.closed, false) LIMIT 1 |
Q_LIFT_RECORD |
MATCH (l:Lift) WHERE l.facility_id = $fid WITH l ORDER BY l.uid LIMIT 1 OPTIONAL MATCH (o:Outage)-[:OF]->(l) RETURN l.avail_12m, l.closed_h_12m, l.outages_12m, l.mttr_h, l.single_svc_h, count(o), sum(o.hours), max(o.end), sum(CASE WHEN o.imputed THEN 1 ELSE 0 END) |
Q_LOST_NOW |
MATCH (p:Node:Platform) WHERE (p.k_board = 0 AND (p.k_board_base > 0 OR p.at_grade_board)) OR (p.k_exit = 0 AND (p.k_exit_base > 0 OR p.at_grade_exit)) RETURN p.stop_id, p.name, p.platform_name, p.station_id, p.k_board, p.k_exit, p.deps_day |
Q_MAXFLOW |
MATCH (s:Node) WHERE s.uid IN $src WITH collect(s) AS S MATCH (t:Node) WHERE t.uid IN $dst WITH S, collect(t) AS T CALL algo.maxFlow({sourceNodes:S, targetNodes:T, relationshipTypes:['SF'], capacityProperty:'cap'}) YIELD edges, edgeFlows, maxFlow RETURN [e IN edges | e.eid], edgeFlows, maxFlow |
Q_META |
MATCH (m:Meta) RETURN m.snapshot_id, m.seed_sha256 LIMIT 1 |
Q_MODELLED_LIFTS |
MATCH (l:Node:Lift) WHERE l.facility_id IN $fids RETURN collect(DISTINCT l.facility_id) |
Q_NETWORK_SPOF |
MATCH (l:Node:Lift) WHERE l.od_cut_base > 0 WITH l.facility_id AS fid, head(collect(l)) AS l RETURN fid, l.name, l.station_id, l.od_cut_base, l.od_cut_off ORDER BY l.od_cut_base DESC, fid |
Q_NEWLY_LAST |
MATCH (p:Node:Platform)-[n:DEPENDS_NOW]->(l:Lift) WHERE NOT (p)-[:DEPENDS_ON {dir:n.dir}]->(l) RETURN l.facility_id, l.name, l.station_id, collect(DISTINCT p.stop_id + ' ' + n.dir) |
Q_PAIR_OVERLAP |
MATCH (x:Outage)-[:OF]->(a:Lift {facility_id:$a}) MATCH (y:Outage)-[:OF]->(b:Lift {facility_id:$b}) WHERE x.start y.start THEN x.start ELSE y.start END, CASE WHEN x.end < y.end THEN x.end ELSE y.end END])[0..20] |
Q_PLATFORM_CUTS |
MATCH (p:Node:Platform {cx:$cx}) RETURN p.stop_id AS stop, head(collect(p.mincuts_board)), head(collect(p.mincuts_exit)), head(collect(p.rel_counts_board)), head(collect(p.rel_counts_exit)), head(collect(p.rel_relevant_board)), head(collect(p.rel_relevant_exit)) ORDER BY stop |
Q_PRIORITY |
MATCH (p:Node:Platform)-[d:DEPENDS_NOW]->(l:Lift) OPTIONAL MATCH (p)-[o:DEPENDS_ON]->(l) WHERE o.dir = d.dir WITH l, p, d, count(o) AS before WITH l, count(DISTINCT p.stop_id + d.dir) AS deps, sum(p.deps_day) AS departures, toInteger(sum(CASE WHEN before = 0 THEN 1 ELSE 0 END)) AS newly OPTIONAL MATCH (l)-[:CLOSED_IN]->(i:Incident) WITH l, deps, departures, newly, count(DISTINCT i) AS incidents RETURN l.facility_id, l.name, l.station_id, coalesce(l.closed, false), deps, newly, departures, incidents |
Q_REACHES_FROM |
MATCH (a:Station {uid:$sid})-[r:REACHES]->(b:Station) RETURN b.uid, b.name, r.base_s ORDER BY r.base_s, b.uid |
Q_REOPEN_LIFTS |
MATCH (i:Lift)-[r:SF_CLOSED]->(o) WHERE NOT i.facility_id IN $fids CREATE (i)-[c:SF]->(o) SET c = properties(r), i.closed = false DELETE r RETURN count(c) |
Q_ROUTE_CHECK_RIDE |
UNWIND $pairs AS p MATCH (a:Platform {uid:p[0]})-[e:RIDE]->(b:Platform {uid:p[1]}) RETURN a.uid, b.uid, e.wt |
Q_ROUTE_CHECK_SF |
UNWIND $pairs AS p MATCH (a:Node {uid:p[0]})-[e:SF]->(b:Node {uid:p[1]}) RETURN a.uid, b.uid, e.wt |
Q_ROUTE_EDGES |
MATCH (a)-[e:SF|RIDE]->(b) RETURN a.uid, b.uid, e.eid, type(e), e.kind, e.wt, e.facility_id, e.routes |
Q_ROUTE_NODES |
MATCH (n) WHERE n:Node OR n:Platform RETURN n.uid, labels(n), n.name, n.station_id, n.platform_name, n.short_name |
Q_SET_PLATFORM_NOW |
MATCH (p:Node:Platform) WHERE p.stop_id = $stop SET p.k_board = $kb, p.k_exit = $ke, p.at_grade_board_now = $ab, p.at_grade_exit_now = $ae, p.cut_board_now = $cb, p.cut_exit_now = $ce |
Q_STATION |
MATCH (s:Station {uid:$sid}) RETURN s.uid, s.name, s.cx, s.has_pathways, s.has_street |
Q_STATION_EDGES |
MATCH (a:Node {cx:$cx})-[e]->(b:Node) WHERE type(e) IN ['SF','SF_CLOSED','STEP'] RETURN e.eid, a.uid, b.uid, type(e), e.kind, e.facility_id, e.pathway_ids |
Q_STATION_NODES |
MATCH (n:Node {cx:$cx}) RETURN n.uid, labels(n), n.name, n.platform_name, n.facility_id, n.unit, coalesce(n.closed, false), n.stop_id, n.wheelchair_boarding |
Q_STATION_PLATFORMS |
MATCH (p:Node:Platform {cx:$cx}) RETURN p.stop_id AS stop, collect(p.uid), head(collect(p.name)), head(collect(p.platform_name)), min(p.k_board), min(p.k_exit), min(p.k_board_base), min(p.k_exit_base), head(collect(p.at_grade_board)), head(collect(p.at_grade_exit)), head(collect(p.at_grade_board_now)), head(collect(p.at_grade_exit_now)), head(collect(p.cut_board_now)), head(collect(p.cut_exit_now)), coalesce(head(collect(p.no_accessible_entrance)), false) ORDER BY stop |
Q_STATION_SEARCH |
CALL db.idx.fulltext.queryNodes('Station', $q) YIELD node, score RETURN node.uid, node.name, node.has_pathways, node.has_street, score ORDER BY score DESC LIMIT 5 |
Q_STATION_UNMODELLED_LIFTS |
MATCH (l:Lift) WHERE l.station_id = $sid AND NOT l:Node RETURN l.facility_id, l.name ORDER BY l.facility_id |
Q_WRITE_INCIDENTS |
UNWIND $rows AS r CREATE (i:Incident) SET i = r.p WITH i, r UNWIND r.fids AS fid MATCH (l:Lift {facility_id:fid}) MERGE (l)-[:CLOSED_IN]->(i) |
Architecture
graph LR
GTFS[MBTA GTFS + alerts] --> Ingest[ingest/build_snapshot.py] --> Snap[(snapshot JSONL)] --> Seed[seed.py] --> FDB[(FalkorDB 4.22)]
FDB --> Tools[8 tools] --> API[FastAPI /v1] --> Web[website]
Tools --> MCP[MCP server /mcp] --> Agent[agent: LLM chain or template]
V3[MBTA V3 alerts] -.->|LIVE_FEED=on| Poller[live poller] --> FDB
MCP --> Clients[any MCP client]
FDB --> Mirror[scripts/build_mirror.py] --> Web
Run it yourself
Requirements: Python 3.14 (3.12+ works), Node 24, a FalkorDB 4.22 instance (Docker falkordb/falkordb-server:v4.22.0, or FalkorDB Cloud).
python -m venv .venv
.venv/bin/python -m pip install -r requirements-dev.txt # Windows: .venv\Scripts\python
.venv/bin/python dev.py doctor # checks Python, FalkorDB and .env key names
.venv/bin/python -m lastlift.seed --verify # load the committed snapshot into FalkorDB
.venv/bin/python -m uvicorn lastlift.main:app --port 8000 # API, MCP at /mcp/, agent at /api/ask
cd web && npm ci && npm run dev # website (VITE_API_BASE=http://localhost:8000)
No API key is needed: without an LLM key the agent answers through its deterministic template. The website shows the frozen demo alerts by default; its Live toggle switches to the live scope, which follows the MBTA’s current alerts when the server runs with LIVE_FEED=on (polled every LIVE_POLL_S seconds; GET /v1/live reports the last poll and marks it stale after five minutes) and otherwise uses the latest committed alert harvest. Configuration comes from environment variables or a .env file one level above the repository (names in .env.example); secrets are never committed.
Reproduce every number: python scripts/analyze.py (the max-flow sweep in FalkorDB), python scripts/verify_claims.py (an independent NetworkX re-derivation), python scripts/eval.py (the validation), python scripts/readme_sync.py --check (this README against claims.json).
What we assume, and where the model can be wrong
- Fare gates are passable for riders who need step-free access; only reaching the platform is modelled, not boarding the train; widths and surfaces are not in the data.
- MBTA publishes no level data; the station drawings infer levels from lift and stair connections.
- Where MBTA publishes no station layout (many commuter-rail stations), LastLift says it cannot assess.
- Lift status comes from MBTA’s alerts, which can differ from a lift’s actual state. The judged demo uses alerts frozen on 15 Oct 2026.
We found defects in the published data
Five overrides correct the published layout, each with evidence (data/overrides.csv): centre platforms at Sullivan Square and Wellington that the data joined to the side platforms (which made them look reachable when they are not), two Quincy Adams pathways tagged with the wrong elevator, and a Copley entrance. Reported to MBTA’s developer contact: to be added by the author.
Decisions I made, and a correction I made
Written by the author.
Data, licence and attribution
Code: MIT. Data: MBTA GTFS (feed “Fall 2026, 2026-10-14T18:47:38+00:00, version D”) and MBTA V3 API alerts, Massachusetts Department of Transportation open data, used under the MassDOT Developers License Agreement. LastLift is unofficial and not affiliated with or endorsed by the MBTA or MassDOT.
AI assistance
This project was built with the help of an AI coding assistant. Design decisions and the final review are the author’s own. The validation key (validation/gold_live_author.csv) was labelled by the author, blind, from MBTA’s alert text only; a second label set made by the AI assistant (validation/gold_live.csv) is kept only to report labeller agreement.
Credits
The algo.maxFlow defect reproduction is credited to the Chokepoint project. Built on FalkorDB, FastAPI, FastMCP, NetworkX, React and Vite.
Feedback
Found a problem with a route or a station? Report an accessibility problem with LastLift (every field is optional). Prefer not to post publicly? Email [email protected]. How LastLift handles people’s experiences is set out in docs/ETHICS.md.
Install
This server does not publish a one-line install command.
Open the repository installation guideConfiguration
{ "mcpServers": { "lastlift": { "url": "https://lastlift-api.onrender.com/mcp/" } } }