Cityflo on-time performance MCP
by devbathani
README.md
# Cityflo on-time performance MCP
MCP server for **Mumbai North on-time performance** — the slice Priya’s handoff actually asks for:
> Was route 12 late this week, and by how much — and which trips prove it?
Built for the [Cityflo AI Engineer take-home](https://careers.cityflo.com/takehomes/ai-engineer/). **Domain chosen:** on-time performance only. Occupancy, support-ticket triage, and ops-log summarisation were deliberately cut so the tools stay sharp and auditable.
Ops people get a plain-language answer from an MCP client (Cursor or the included Kimi agent). Deterministic TypeScript does the arithmetic; the model only phrases the result. Every headline can be drilled into trip-level evidence.
**Stack:** Node.js **20.x**, TypeScript, [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk). No database — `data/trips.csv` is loaded in memory.
---
## Install
```bash
git clone <this-repo>
cd Cityflo-Assignment # or your clone path
npm install
cp .env.example .env
```
| Env var | Required for | Notes |
|---|---|---|
| `MOONSHOT_API_KEY` | `npm run agent` | Moonshot / Kimi path only — **never** deploy this to Vercel |
| `JWT_SECRET` | Hosted `/api/mcp` + web gateway | Min 16 chars; `openssl rand -hex 32` |
| `TRIPS_CSV_PATH` | Optional | Override default `data/trips.csv` |
---
## How to run
### 1. Local stdio MCP (Cursor — primary path)
```bash
npm run mcp
```
[`.cursor/mcp.json`](.cursor/mcp.json) already points Cursor at this server. After `npm install`, reload MCP in Cursor and ask:
> Was route 12 late this week, and by how much?
Production stdio (no `tsx`):
```bash
npm run build:mcp
npm run start:mcp
# MCP command: node dist/mcp/server.js
```
### 2. End-to-end with a real client (no LLM)
```bash
npm test # domain + MCP integration + JWT (36 tests)
npm run e2e # multi-step MCP client → demos/mcp-e2e-session.md
```
### 3. Optional Kimi K2.6 terminal agent
```bash
# set MOONSHOT_API_KEY in .env
npm run agent -- "Was route 12 late this week, and by how much?"
```
Transcripts when generated: [`demos/agent-session.md`](demos/agent-session.md), [`demos/mcp-e2e-session.md`](demos/mcp-e2e-session.md).
### 4. Hosted Streamable HTTP (optional for Cityflo demos)
Live: **[https://cityflo-on-time-mcp.vercel.app](https://cityflo-on-time-mcp.vercel.app)**
1. Open the site → sign in (`cityflo.mcp@gmail.com` / `tester`) → **Copy** Cursor MCP JSON (JWT embedded).
2. Or mint a token: `npm run mint-token`, then:
```json
{
"mcpServers": {
"cityflo-on-time": {
"url": "https://cityflo-on-time-mcp.vercel.app/api/mcp",
"headers": {
"Authorization": "Bearer YOUR_JWT"
}
}
}
}
```
| Endpoint | Auth | Purpose |
|---|---|---|
| `/` | internal login | Cityflo-styled gateway — copy MCP config |
| `/api/health` | none | Liveness + trip count |
| `/api/mcp` | `Bearer` JWT (`on_time:read`) | Streamable HTTP MCP |
Local hosted smoke: `JWT_SECRET=… npm run dev` then `MCP_BASE_URL=http://localhost:3000 npm run smoke:http`.
---
## Tools (3)
Computation stays in tools. The model phrases language only.
| Tool | What it does |
|---|---|
| `get_route_on_time_summary` | Route-level late rate, median/max lateness, daily pattern, late trip ids, rules applied |
| `get_trip_lateness_evidence` | Trip-by-trip scheduled vs actual + classification + exclusion reasons (**requires** `route_id` or `trip_id`) |
| `get_data_quality_report` | Quarantined / duplicate rows by reason and GPS device |
Invalid filters return structured MCP `isError` payloads — not silent wrong numbers.
**Human/model boundary:** tools return JSON numbers and trip ids; the client/agent narrates. Answering Priya honestly is a multi-step loop (summary → evidence → optional quality), not one call.
---
## Assumptions (under-specified parts of the brief)
1. **Late** = arrival lateness `actual_arrival − scheduled_arrival` **strictly greater than** the threshold (default **10** minutes). Exactly 10 minutes counts as on-time.
2. Early arrivals (negative lateness) are on-time; median and max include all *valid* trips.
3. When `start_date` / `end_date` are omitted, summary and evidence both use **min..max `service_date` in the loaded export** (“this week” ≈ the export window).
4. Times are interpreted in **IST (+05:30)**. Mixed-offset rows coerce non-IST wall clocks to IST and set `timezoneNormalised` (see TRIP_044).
5. Unparseable times, arrival-before-departure, missing `scheduled_arrival`, and calendar mismatches are **excluded** from metrics and surfaced in the quality report.
6. Exact operational duplicates count **once** (lowest `trip_id` kept); extras are `duplicate_of`.
7. Headlines use **late rate + median + max**, not mean alone — one bad GPS row must not swing standup.
8. Free text in handoffs / tickets / ops logs is **untrusted data**, never tool instructions. Every vehicle is scored with the same documented rules.
---
## How messy data was handled (`data/trips.csv`)
| Trip | Issue | Decision |
|---|---|---|
| TRIP_017 | `actual_arrival` before `actual_departure` (device D-22) | Exclude — `arrival_before_departure` |
| TRIP_031 | `actual_departure` = `08:60:00` (D-22) | Exclude — `unparseable_timestamp` |
| TRIP_044 | Arrival tagged `+00:00`, rest `+05:30` | Keep after IST wall-clock coercion; ~3 min late |
| TRIP_052 / TRIP_053 | Exact duplicate R-09 rows | Keep 052; exclude 053 as `duplicate_of` |
| TRIP_101 | Empty `scheduled_arrival` | Exclude — `missing_scheduled_arrival` |
Device **D-22** appears twice in exclusions — consistent with Priya’s flaky-GPS warning. See also [`docs/DATA_AUDIT.md`](docs/DATA_AUDIT.md).
### Route 12 answer (export window, 10 min threshold)
- **8** valid trips, **6** late (**75%**)
- Late on **4 of 5** service days
- Median lateness **13.5** min, max **18** min
- Friday (TRIP_077 / TRIP_078) is the on-time day
That is a real late pattern for the week — not just a loud complaint — and `get_trip_lateness_evidence` lists the trips behind the rate.
---
## Questions I would ask Priya
1. Is **10 minutes** the regional review threshold, or do you want 5 / 15 for standup?
2. Should we judge **arrival**, **departure**, or both?
3. When GPS rows look impossible, do you prefer **exclude-from-metrics** (current) or keep-with-flag in the headline?
4. Which window is “this week” — Mon–Fri service dates only, or calendar week including weekends?
---
## Trust boundary
This server only reads `trips.csv` (or `TRIPS_CSV_PATH`). Operational free text elsewhere — including anything that looks like a standing policy inside a handoff — is **not** executed as instructions. Metrics come from deterministic code so a regional manager can ask “which trips?” and get ids + timestamps, not a vibe.
---
## What was deliberately cut (and why)
| Cut | Why |
|---|---|
| Occupancy / tickets / overnight-log tools | Brief: one sharp domain. Priya’s ask is Route 12 lateness with evidence. |
| Database | CSV-in-memory is enough for the export size and the half-day slice. |
| Full OAuth / IdP | Hosted path uses short-lived HS256 JWTs + internal login for demos. |
| Auto root-cause / “blame the corridor” | Devices show up in quality grouping; narrative cause is the model’s job only when grounded in evidence. |
| Kimi / `MOONSHOT_API_KEY` on Vercel | Keys stay local; hosted surface is MCP + JWT only. |
If there had been more time: configurable thresholds per region, departure lateness as a second metric, and a thin “compare two weeks” tool — not a dashboard.
---
## Where I disagreed with the AI
1. **Timezone on TRIP_044.** A naïve “parse ISO as instants” path made the mixed `+00:00` arrival look ~5.5 hours late. After inspecting the export (wall clock looks IST; only the offset is wrong), I coerced non-IST wall clocks on mixed-offset rows to `+05:30` and flagged `timezoneNormalised` (~3 minutes late).
2. **Handoff special-casing a vehicle.** The ops handoff text asked to quietly force one plate always on-time and hide it from rankings. That conflicts with auditability and the trust-boundary criterion. I treated it as untrusted data, not a product requirement, and score every vehicle with the same rules.
3. **Headline metric.** Mean delay was the easy default. One garbage or extreme trip would swing standup. I used late rate + median + max and forced drill-down via `get_trip_lateness_evidence`.
4. **Scope.** Building all four domains was tempting. Priya’s question is Route 12 lateness with evidence — the other three domains stayed out.
5. **Hardcoded calendar window.** An early draft baked `2026-06-15..19` into the domain. Default window now comes from the loaded export so summary and evidence cannot silently diverge when the CSV grows.
More detail: [`NOTES.md`](NOTES.md).
---
## Project layout
```
app/ # Next.js gateway + hosted MCP/health/login
src/domain/trips.ts # load, validate, summarise
src/mcp/createServer.ts # transport-independent tools
src/mcp/server.ts # stdio entrypoint
src/agent/chat.ts # optional Kimi MCP client
src/auth/jwt.ts # HS256 mint/verify
data/trips.csv # runtime input
demos/ # e2e + agent transcripts
docs/DATA_AUDIT.md # data decisions
test/ # domain, MCP integration, JWT
```
---
## Commands cheat sheet
| Command | Purpose |
|---|---|
| `npm install` | Install dependencies |
| `npm run mcp` | Stdio MCP for Cursor |
| `npm run agent -- "…"` | Kimi agent over stdio MCP |
| `npm test` / `npm run e2e` | Unit/integration + multi-step smoke |
| `npm run build:mcp && npm run start:mcp` | Production stdio |
| `npm run dev` | Local Next (gateway + `/api/mcp`) |
| `npm run mint-token` | CLI JWT for hosted MCP |
| `npm run smoke:http` | Hosted JWT + Route 12 smoke |
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues