Skip to main content
Glama
README.md
# Gotta Go

**An Alexa+ MCP server that finds a restroom you can actually use, because the nearest restroom is often the wrong one.**

> "Alexa, I need to pee! Right now!"
>
> "The closest one, Bukchon Culture Center, 40 meters southwest, is closed right now (closed since 6 PM). Go to Gyedong Church, 200 meters north. It closes at 10 PM, in 20 minutes, so go now."
>
> "Anything that stays open later?"
>
> "Gyedong Church closes at 10 PM. The nearest one that stays open later is Wonseo Park, 200 meters southeast, and it's open 24 hours."

A map app shows the closest restroom. It doesn't tell you that the door is closed after 6 PM, that the stall is too narrow for a wheelchair, or that there is nowhere to change a diaper. Gotta Go checks those things against the public record before it answers. It skips the places that won't work, says why, and says "I can't confirm" when the record is silent.

Built for the Alexa+ track of the *Build, Ship, Shape: Amazon Developer Hackathon* (2026).

![Gotta Go simulator](docs/screenshot.png)

## What it does

- **Open now, in the place's own time zone.** Korea's national restroom file writes opening hours as free text. In the October 9, 2026 file, the two hours columns hold 2,129 different combinations ("09:00~18:00", "9시~18시", "0900-1800", "(평일)09:00~18:00+(주말)09:00~18:00", "화-금(09:00~18:00), 토-일(09:00~17:00), 월 휴관" ...). Gotta Go reads them into rules: weekdays, weekly closing days, overnight hours, lunch breaks, closed months.
- **Never guesses.** 49,284 of the file's 53,585 rows (92.0%) turn into a clear schedule or "open 24 hours", and 329 (0.6%) say "not open to the public". The other 3,972 (7.4%) stay "unknown", with the reason ("only a duration is recorded", "the record says not open to the public but also lists hours"). Unknown is never reported as open.
- **Needs, not just distance.** Accessible stall, changing table, children's toilet, emergency bell, free entry. A place whose record says "no" is skipped; a place whose record is silent is ranked lower and the answer says so.
- **Explains the skip.** When a nearer place was passed over, the answer names it and says why. That one sentence is why you can trust the rest.
- **Closing soon counts as a risk.** A restroom that closes before you could walk there (about 50 m a minute plus 5 minutes) is not treated as open. If it closes within 45 minutes, the answer says "so go now".
- **A conversation, not a single lookup.** Each MCP session remembers the conversation. Say "my mom uses a wheelchair" once and every later search includes it. "Take me to the second one", "anything that stays open later?" and "is there a changing table there?" refer to the place you were just talking about. A new session starts clean.
- **Speakable names.** Korean names are turned into something an English voice can say (Anguk Station (Line 3), Namdaemun Market Public Restroom), with the original Korean on screen.

## How it works

```
 Alexa+ (simulated web app, browser)          Gotta Go server (Node.js)
 ┌───────────────────────────────┐   MCP     ┌─────────────────────────────┐
 │ speech in / speech out        │ JSON-RPC  │ /mcp  Streamable HTTP        │
 │ agent loop (LLM tool calling) │──────────▶│ protocol 2025-11-25          │
 │ MCP client: initialize,       │◀──────────│ tools: find_restroom,        │
 │ tools/list, tools/call        │           │   remember_needs,            │
 │ screen card + traffic log     │           │   other_option,              │
 └──────────────┬────────────────┘           │   about_restroom,            │
                │ /api/chat                   │   get_restroom, data_sources │
                ▼                             │ per-session conversation     │
   OpenAI-compatible LLM (default: local)    │ hours parser · ranking       │
   or a keyword fallback                      └──────────────┬──────────────┘
                                                data/places-seoul.json   (5,491)
                                                data/places-seattle.json (258)
```

- **MCP server**: `src/mcp.js`, `src/server.js`. Built with the official TypeScript SDK (`@modelcontextprotocol/sdk` 1.32.1), Streamable HTTP transport with sessions, protocol revision 2025-11-25. Tools are annotated (`readOnlyHint`, `idempotentHint`); `find_restroom` and `remember_needs` declare an `outputSchema` and return `structuredContent`. Server `instructions` carry the voice rules and say which tool handles which follow-up. Each session gets its own conversation state (`src/conversation.js`), readable as the `gotta-go://conversation` resource; nothing is written to disk.
- **Alexa+ simulator**: `web/`. The Alexa+ developer tools are open only to selected partners, and the hackathon FAQ says a web page that acts as a real MCP client is the way to demo a self-hosted server. The page is a real MCP client (`web/mcp-client.js`, about 60 lines, hand-written so every message is visible in the traffic panel). Voice in uses the browser's speech recognition (Chrome), voice out uses speech synthesis.
- **Agent**: an LLM with tool calling decides which tool to call and with what needs. The default is a local model through any OpenAI-compatible endpoint, so there is no cloud cost. Without an LLM, the page falls back to a small keyword router (new search, remember, "the second one", "stays open later", "is it ...?") and says so in the header. The conversation state lives in the server either way. The demo video uses the built-in keyword router to play Alexa's part; the same six MCP tools are called identically when a tool-calling model (I tested a local open-weight model on the same Mac) is the client.
- **Agent Skill**: `skills/gotta-go/SKILL.md` teaches any skill-aware agent how to use the server.
- **Hours parser**: `src/kr-hours.js`, also released on its own as [kr-open-hours](https://github.com/jejuolledev/kr-open-hours).

Demo video: see the Devpost page.

## Run it

Requires Node.js 20 or later.

```bash
npm install
npm start            # http://127.0.0.1:8787
npm test             # 27 tests: hours parser, real-data scenarios, conversation, MCP end-to-end
```

Open http://127.0.0.1:8787 in Chrome. Pick a location and a device time on the right, then type or say a request.
The demo scenario is preloaded with: http://127.0.0.1:8787/?loc=bukchon&time=2026-10-21T21:40
Try, in order: "Alexa, I need to pee! Right now!", "Anything that stays open later?", "Is there a changing table there?". Then switch to Namdaemun Market and say "My mom uses a wheelchair. Where can we go?"

### With an LLM (optional)

The page talks to any OpenAI-compatible `/v1/chat/completions` endpoint that supports tool calling.

```bash
LLM_BASE_URL=http://127.0.0.1:8193/v1 LLM_MODEL=<model-name> npm start
```

Tested with a local open-weight model (8-bit, MLX) served on a MacBook through an OpenAI-compatible endpoint. Other servers (Ollama, LM Studio, vLLM) should work the same way through `LLM_BASE_URL`, `LLM_MODEL`, and `LLM_API_KEY`, but they were not tested. Without a reachable endpoint the simulator uses the keyword fallback (`?agent=keywords` forces it).

### From any MCP client

```bash
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8787/mcp --transport http \
  --method tools/call --tool-name find_restroom \
  --tool-arg latitude=37.5586 --tool-arg longitude=126.9787 --tool-arg 'needs=["wheelchair"]'
```

Browser requests from other origins are refused (`ALLOWED_ORIGINS` to change). The server binds to 127.0.0.1 unless `HOST` is set.

## Tools

| Tool | What it returns |
|---|---|
| `find_restroom(latitude, longitude, needs[], radius_meters=800, max_results=3, at?)` | `speech` (ready to read aloud), ranked `results`, `skipped_nearer` with reasons, `coverage` counts for the radius |
| `remember_needs(needs[], forget=false)` | Keeps needs for the rest of the conversation ("my mom uses a wheelchair"); every later search includes them |
| `other_option(choice?, open_later=false)` | Follow-up to the last search: "the second one", "what else?", or "anything that stays open later?" (searches again for places open 30+ minutes past the current one) |
| `about_restroom(topic, choice?)` | Answers about the place being discussed: wheelchair, changing table (and in which room), hours, phone, emergency bell. Says when the record is silent |
| `get_restroom(id, latitude?, longitude?, at?)` | Everything the record says about one place: hours as written, accessible stalls by gender, changing table location, emergency bell, operator phone, record date |
| `data_sources()` | Publisher, license, download date and record counts for each dataset |

`needs`: `open_now`, `wheelchair`, `changing_table`, `child_toilet`, `emergency_bell`, `free`.

## Data

| Region | Places | Source | License |
|---|---|---|---|
| Seoul | 5,491 | Details: Korea Ministry of the Interior and Safety, *Public Restroom Information* (data.go.kr dataset 15012892, file.localdata.go.kr, downloaded 2026-10-09). Coordinates: Seoul Metropolitan Government, *Seoul public restroom locations* (data.seoul.go.kr OA-22586, downloaded 2026-10-15) | National file: Korea public data, no restrictions on use (data.go.kr: 이용허락범위 제한 없음). Seoul file: Korea Open Government License (KOGL) Type 1, attribution required, commercial use and changes allowed. Source: Seoul Metropolitan Government |
| Seattle | 258 | OpenStreetMap `amenity=toilets` via Overpass, 2026-10-14 (private ones removed) | © OpenStreetMap contributors, available under the Open Database License (ODbL 1.0): https://www.openstreetmap.org/copyright. `data/places-seattle.json` is a derived database under the same license. |

Korea's national file has the details (hours, accessible stalls, changing tables) but stopped carrying coordinates in February 2025 (data.go.kr notice on dataset 15012892). Seoul publishes its own restroom points with coordinates under an open license. `scripts/build-data.mjs` puts the two together:

- Of the national file's 5,609 Seoul rows, 3,610 are placed by matching the road address (3,159) or the parcel address (451) to a point in Seoul's file. 1,999 don't match and are left out.
- 1,881 places in Seoul's file match no national row. They are added with Seoul's own fields (hours, accessible restroom yes/blank, changing table yes/blank).
- Matching by name was tried and dropped, because it put too many places hundreds of meters off.
- No geocoding API is used, so no geocoder results are stored or shipped.

The Seattle data shows the other side of the problem: only 17 of the 258 OpenStreetMap toilets list opening hours, so most Seattle answers say "the record doesn't confirm its hours".

Rebuild the data:

```bash
node scripts/build-data.mjs korea data/raw/kr_public_restrooms_localdata_20261009.csv data/raw/seoul_public_toilets_OA-22586_20261015.csv data/places-seoul.json
node scripts/fetch-osm.mjs seattle 47.4919 -122.4596 47.7341 -122.2244
node scripts/build-data.mjs osm data/raw/osm_seattle_<date>.json America/Los_Angeles data/places-seattle.json
```

## Limits

- Distances are straight-line, not walking routes. Slopes and stairs are not checked yet.
- Public holidays are not modeled; when a record mentions them, the hours note says so.
- "Accessible stall" means the record counts at least one disabled-use toilet. A count of 0 is reported as "not on record", because the file can't tell "none" from "not entered".
- Records are entered by local governments. The record date is returned with every national-file place.
- Outside Seoul, Korea's national file has no coordinates. Other regions need an open coordinate source like Seoul's, or the government's road-address location database (provided after an application and purpose review under the Road Name Address Act).
- Romanized names follow the Revised Romanization letter by letter, so some spellings are close but not official.

## License

Code: MIT (see `LICENSE`). Data keeps its own license (table above); the MIT license does not cover the files in `data/`.