Skip to main content
Glama
README.md
# Hearth

Hearth is a self-hosted MCP server that lets a family look after an aging parent by voice: medication check-ins, appointments, a daily summary, missed-dose escalation, and sharing that the parent controls. It ships with **voice-sim**, an MIT-licensed voice harness that drives any MCP server through an LLM and scores it against a golden eval.

## Architecture

```
┌──────────────────────── voice-sim (MIT, generic) ─────────────────────────┐
│  Browser: index.html + app.js        Agent server (Node, 127.0.0.1 only)  │
│  ─ mic / TTS (Web Speech) ◄──SSE──►  ─ LLM provider: Gemini | Groq | Mock │
│  ─ push-to-talk, text fallback       ─ MCP client (Streamable HTTP)       │
│  ─ profile switch (id/label/emoji)   ─ confirmation gate (FR-HARN-2)      │
│  ─ trace list                        ─ headless `eval` CLI                │
└───────────────────────────────────────────────┬───────────────────────────┘
                                                │ POST/GET/DELETE /mcp (SSE)
                                                │ Authorization: Bearer
┌──────────────────────── hearth-mcp ────────────▼──────────────────────────┐
│ node:http ─► guard (Host/Origin/public-mode) ─► auth → Principal ─►       │
│ session router (404/403) ─► per-session McpServer (role tools only) ─►    │
│ handlers (strict zod in-handler) ─► domain services ─► db.tx ─► SQLite    │
│ Scheduler evaluate(now): boot / 60 s / clock routes · Clock (injected)    │
│ pino (redacted) · /healthz · /dev/* (gated)                               │
└───────────────────────────────────────────────────────────────────────────┘
```

MCP spec 2025-11-25 on `@modelcontextprotocol/sdk@1.32.1`, TypeScript on Node ≥ 22.16, SQLite through `node:sqlite`. The full specification is [docs/SPEC.md](docs/SPEC.md).

## Quick start

1. **Run it.** Docker Compose v2.24 or newer is the only requirement:

   ```bash
   docker compose up
   ```

   The first run builds the image. The server seeds a synthetic "Shah family" on first boot. Both ports are published on `127.0.0.1` only:
   - Hearth MCP server: `http://127.0.0.1:8787/mcp` (health check: `http://127.0.0.1:8787/healthz`)
   - voice-sim: open **http://localhost:5173** in Chrome
2. **Add an LLM key (optional).** voice-sim needs one to answer; the MCP server does not.

   ```bash
   cp .env.example .env    # then set GEMINI_API_KEY= (and optionally GROQ_API_KEY= as a fallback)
   docker compose up
   ```

   `.env` is git-ignored and is read only at runtime. Keys never reach the browser. For the snappiest voice, set `LLM_PROVIDERS=groq,gemini` and `LLM_RPM=30` in `.env`: on Groq a turn answers in about 2.5 s ([FL-16](docs/FRICTION_LOG.md)).
3. **Connect MCP Inspector.** Run `npx @modelcontextprotocol/inspector@2.11.0`, choose Streamable HTTP, use `http://127.0.0.1:8787/mcp`, and add the header `Authorization: Bearer demo_caregiver` (or `demo_parent`). The full checklist is in [docs/inspector.md](docs/inspector.md).

## Metrics

| ID | Metric | Target | Measured | Source |
|---|---|---|---|---|
| M1 | Tool routing on the 24-item golden set (`gemini-3.5-flash-lite`) | ≥ 22/24 | 24/24 | [eval/results/latest.json](eval/results/latest.json) |
| M2a | Confirmation compliance on the golden set | 24/24 | 24/24 | [eval/results/latest.json](eval/results/latest.json) |
| M2b | Tier ≥ 2 commits without an elicitation in the audit log | 0 | 0 | `npm run eval:mock` (CI) |
| M3 | Final spoken replies of 25 words or fewer | ≥ 23/24 | 24/24 | [eval/results/latest.json](eval/results/latest.json) |
| M4 | Tool latency p95, excluding time spent waiting on the human | ≤ 300 ms | 51 ms | [eval/results/latency.txt](eval/results/latency.txt) |
| M5 | Median end of speech → start of spoken reply, 10 voice turns | ≤ 3.5 s | 2.56 s (Groq `gpt-oss-120b` first, Chrome's recogniser) | `scripts/voice-timing.mjs`, 2026-10-21 |
| M6 | Inspector lists the 11 caregiver tools and completes a confirmed write | pass | pass (7/7 checks) | [docs/inspector.md](docs/inspector.md) |
| M7 | Fresh clone → `docker compose up` → Inspector connected / first voice answer | ≤ 5 min each | 4 min 24 s / 48 s | timed run, 2026-10-20 (cold build 187 s) |

CI runs typecheck, lint, the test suites, the keyless mock eval, the M1–M3 threshold check and a secret scan on every push ([.github/workflows/ci.yml](.github/workflows/ci.yml)).

## Screenshots

MCP Inspector connected as the caregiver, with an elicitation-confirmed write:

![MCP Inspector](docs/images/inspector.png)

voice-sim, push-to-talk with the tool trace:

![voice-sim](docs/images/voice-sim.png)

## Roles & tools

Each MCP session gets only its role's tools, so an out-of-role call is simply "tool not found". Writes are tiered: T0 reads, T1 low-risk writes without confirmation, T2/T3 writes confirmed by the user through elicitation.

| Tool | Parent (Mom) | Caregiver (Arnav) | Tier |
|---|---|---|---|
| `get_daily_summary`, `list_medications`, `get_appointments`, `get_status_help` | ✓ | ✓ | T0 |
| `get_pending_messages` | ✓ | ✓ | T1 |
| `log_dose` | ✓ | | T1 |
| `get_sharing` | ✓ | | T0 |
| `set_sharing` | ✓ | | T2 |
| `get_alerts` | | ✓ | T0 |
| `ack_alert` | | ✓ | T1 (T2 when clearing several) |
| `upsert_medication`, `upsert_appointment`, `send_check_in` | | ✓ | T2 |
| `cancel_appointment` | | ✓ | T3 |

The parent gets 8 tools and the caregiver gets 11. Demo bearer tokens are `demo_parent` and `demo_caregiver`.

## Trust boundary

Confirmation is enforced by the server, not by the LLM or the client. Every T2/T3 write asks the human through MCP elicitation, reads back exactly what will change, and re-validates the plan before committing. It follows that **a client without elicitation support cannot run T2/T3 tools**; they fail closed. A confirm-token fallback for such clients is future work (decision D8 in the spec).

## Privacy

- All seed data is synthetic: the "Shah family", its medications and its appointments are invented.
- **Free-tier LLM providers may retain prompts** for training or review. Don't put real health data through voice-sim with a free-tier key.
- With a Groq key, **push-to-talk audio goes to Groq** for transcription (Whisper). Set `VOICE_SIM_STT=browser` to keep speech recognition in Chrome instead.
- API keys and bearer tokens stay in `.env` and the server process. They never reach the browser, the event stream, logs or eval reports, and CI fails on a secret-pattern scan.
- Error logs carry the error class, code and stack, never the message, which can echo user input.

## Tunnels

The server binds `127.0.0.1` by default. To reach it through a tunnel:

- Add the **exact** tunnel hostname to `HEARTH_ALLOWED_HOSTS`. Never rewrite the `Host` header to get past the check.
- A non-loopback allowed host or any forwarding header (`cf-ray`, `x-forwarded-for`, `forwarded`, …) switches on **public mode**: `/dev/*` returns 404 and the `demo_` tokens are refused with 401.
- Mint real tokens with `npm run seed -w packages/hearth-mcp -- --public`; they are printed once. In public mode the server never seeds an empty database itself, since nobody would see its tokens; it logs this command instead.
- voice-sim is never tunnelled. It holds your LLM keys and listens on loopback only.

## voice-sim as a standalone MCP harness

`packages/voice-sim` is generic and MIT-licensed: it contains nothing Hearth-specific and works with any Streamable HTTP MCP server.

- **Profiles.** `profiles.json` lists who can talk, as `[{ "id", "label", "emoji", "headers" }]`. Headers (for example an `Authorization` bearer) stay on the agent server; the browser sees only id, label and emoji. Point `VOICE_SIM_PROFILES` at the file and `VOICE_SIM_MCP_URL` at your server, then run `npm run serve -w packages/voice-sim`. `VOICE_SIM_LANG` sets the speech recognition language (Compose uses `en-IN`; empty means the browser's language).
- **Speech to text.** With `GROQ_API_KEY` set, push-to-talk audio is recorded in the browser and transcribed on the agent server by Whisper (`GROQ_STT_MODEL`, default `whisper-large-v3-turbo`), which is far more accurate than Chrome's recogniser for accented English. A clip without voice in it is never sent, because Whisper invents words from silence ([FL-18](docs/FRICTION_LOG.md)). Without a Groq key, or with `VOICE_SIM_STT=browser`, Chrome's recogniser is used.
- **Providers.** Gemini, Groq and a scripted Mock, tried in `LLM_PROVIDERS` order with retry, backoff and a per-minute throttle. A turn is capped at 4 tool calls and 15 s, excluding waits on the human and on the throttle.
- **Eval CLI.** `voice-sim eval --server URL --file golden.jsonl --profiles P --dev-token T [--provider gemini|groq|chain|mock]` runs a JSONL golden set and scores routing, confirmation compliance, brevity and final status into JSON and Markdown reports. Hearth's set is [eval/golden.jsonl](eval/golden.jsonl).

## License

MIT. See [LICENSE](LICENSE).