Skip to main content
Glama
devbathani

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 |