oura-mcp
README.md
# Personal health MCP backend on AWS Lambda
One Lambda Function URL serves two things:
- **An MCP server for claude.ai.** It has read-only Oura Ring tools, tools to log, delete and read home health readings, a workouts tool, and one tool that feeds a report page.
- **An ingest endpoint for Apple Health data,** sent by the iPhone app Health Auto Export (HAE).
Health readings from both paths, and workouts, share one DynamoDB table.
```
claude.ai ──POST /mcp/<path-secret>──────┐ Health Auto Export (iPhone)
│ │ POST /ingest/<ingest-path-secret>
▼ ▼ + header X-Ingest-Key
Lambda (Node 24, arm64, 512 MB, reserved concurrency 5)
@modelcontextprotocol/server v2, stateless Streamable HTTP
│ │ │
SSM Parameter Store (SecureString) │ api.ouraring.com DynamoDB (on-demand)
/oura-mcp/path-secret │ (OAuth2) PK metric, SK ts
/oura-mcp/ingest-path-secret │ weight waist body_fat lean_mass
/oura-mcp/ingest-key │ bp glucose ketones protein
/oura-mcp/oauth-client │ carbs fiber fat calories workout
/oura-mcp/tokens ◀── rotated Oura refresh token written back here
```
Operating it (redeploy, re-authorizing Oura, rotating secrets, restores, the budget alert) is covered in [docs/RUNBOOK.md](docs/RUNBOOK.md).
## Tools
Tools that take a date range accept optional `start_date` and `end_date` (`YYYY-MM-DD`, inclusive). By default they cover the last 7 days in `USER_TZ` (America/Los_Angeles). `end_date` defaults to today even when only `start_date` is given.
### Oura (read-only)
Each tool returns `{range, rows, days_without_data, notes?}`, one compact row per day. It never returns raw Oura JSON.
| Tool | Max range | Row fields |
|---|---|---|
| `get_sleep` | 92 days | `sleep_score, total_sleep_h, time_in_bed_h, deep_h, rem_h, light_h, efficiency_pct, bedtime, wake_time, resting_hr_bpm, avg_hrv_ms, avg_breath_rpm, breathing_disturbance_index, spo2_avg_pct, nap_h` |
| `get_heart_rate` | 31 days | `resting_hr_bpm, avg_hrv_ms, avg_hr_bpm, min_hr_bpm, max_hr_bpm, avg_awake_hr_bpm, avg_sleep_hr_bpm, max_workout_hr_bpm, samples` |
| `get_readiness` | 92 days | `readiness_score, temp_deviation_c, temp_trend_deviation_c, resting_hr_bpm, avg_hrv_ms` |
| `get_activity` | 92 days | `activity_score, steps, active_kcal, total_kcal, walking_equiv_km, high/medium/low_activity_min, sedentary_h, non_wear_h` |
How the Oura fields are defined:
- Sleep metrics belong to the day you woke up.
- `resting_hr_bpm` is the lowest 5-minute average during the night's main sleep, which is what the Oura app shows.
- `avg_hrv_ms` is the average HRV of that main sleep.
- Heart-rate samples arrive in UTC and are grouped into days in `USER_TZ`.
### Health readings
**`log_reading(metric, value, unit, timestamp?, context?, note?)`**
- Saves a home glucose or ketone reading you report in chat. The tool description tells the model to call it whenever you report one.
- `metric` is `glucose` or `ketones`.
- `unit` must be exactly `mg/dL` for glucose or `mmol/L` for ketones.
- Allowed ranges are glucose 20–600 mg/dL and ketones 0–10 mmol/L, inclusive. A wrong unit, an out-of-range value or a future time returns an error and **nothing is written**.
- `timestamp` defaults to now. A time without an offset, such as `2026-09-27T07:30`, is read as Los Angeles time.
- `context` is `fasting`, `post-meal` or `other`. `note` is optional, up to 200 characters.
- Saved with `source: "claude-log"`. It returns the stored row, including the exact `timestamp` that `delete_reading` needs.
- It refuses to overwrite an Apple Health reading stored at the exact same second.
**`delete_reading(metric, timestamp, value?, undo?)`**
- **Identifies a reading** by metric and time: the exact timestamp, or to the minute (e.g. `2026-10-05T07:02`, local time). If several readings share that minute, `value` picks one; otherwise the tool lists them and changes nothing.
- **Chat readings** (`claude-log`) are deleted.
- **Apple Health readings** are marked removed (`superseded_at`, `superseded_by: "chat"`) and stop counting. The reply reminds you to delete the reading in Apple Health first, because a later push that still contains it clears the mark.
- **`undo: true`** restores a removed Apple Health reading.
- **Workouts** are removed the same way: `metric` is `workout`, `timestamp` is the workout's start time (from `get_workouts`), and `value` is its duration in minutes if several started in that minute. A workout is always marked removed, never deleted, and `undo: true` restores it.
**`get_health_metrics(metric, start_date?, end_date?)`**
- `metric` is one of `weight waist body_fat lean_mass bp glucose ketones protein carbs fiber fat calories`, or `all`. `carbs` always returns **net carbs** (see *Net carbs* below). The range can be up to 92 days.
- Returns `{range, timezone, days, days_without_data, notes?}`. Each day has:
- `protein_g`, `carbs_g` (net carbs), `total_carbs_g`, `fiber_g`, `fat_g` and `calories_kcal` as daily sums, plus `warnings` when net carbs had to be clamped or some fiber wasn't subtracted
- `weight` (lb), `waist` (in), `body_fat` (%), `lean_mass` (lb), `bp`, `glucose` and `ketones` as lists of readings `{time, value | systolic+diastolic, unit, context?, source, note?, timestamp?}`
- `timestamp` appears on `claude-log` readings. Apple Health readings show `time` (HH:MM), which is enough for `delete_reading`.
- **Duplicates:** an Apple Health reading and a `claude-log` reading of the same metric, within 15 minutes and within 5% of each other (both systolic and diastolic for BP), count as one reading.
- Only the `claude-log` one is returned, and a note says how many were hidden.
- Each reading pairs with at most one other, closest in time first. Neither row is deleted.
- Responses are capped at 1,000 readings, with a note if any were left out.
### Workouts
**`get_workouts(start_date?, end_date?)`**
- Workouts recorded in Apple Health (for example Peloton rides), sent by a Health Auto Export "Workouts" automation. The range can be up to 92 days.
- Returns `{range, timezone, workouts, days, days_without_workouts, notes?}`.
- `workouts` has one row per workout: `date`, `start` and `end` (local HH:MM), `type`, `duration_min`, `active_kcal`, `avg_hr_bpm`, `max_hr_bpm` and `source`. The energy and heart-rate fields appear only when the workout recorded them.
- `days` has the daily totals: `workouts`, `duration_min` and `active_kcal`.
- A workout belongs to the day it started, in `USER_TZ`.
- `source` is the recording app when the payload names one, otherwise `unknown` (see *Workouts* under ingest).
- Workouts edited or deleted in Apple Health drop out after two pushes at least 15 minutes apart, like other samples. To remove a wrong one by hand, use `delete_reading` with `metric: "workout"`.
### Report page data
**`get_report_data(start_date?, end_date?)`**, read-only. It serves the Weekly Oura Summary report page (a claude.ai artifact that calls this connector); chats should keep using the individual tools.
| Tool | Max range | Returns |
|---|---|---|
| `get_report_data` | 366 days | `{range, timezone, health, sleep, activity, readiness, workouts}` |
- **Each section** is exactly what the matching tool returns for the same range: `health` is `get_health_metrics` with `metric: "all"`, then `get_sleep`, `get_activity`, `get_readiness` and `get_workouts`.
- **One call instead of five:** the sections run in parallel inside one invocation, and Oura's sleep periods are fetched once for both sleep and readiness.
- **A failing section** is `{error: {message}}` in its place (for example Oura's re-authorize message), and the other sections still return. The log line lists `failed_sections`.
- **366 days** lets the page load everything from its first day for a year in one call. With 10 food entries, a weigh-in, and a night of sleep, readiness and activity every day, a 366-day response is about 300 KB of JSON (about 340 KB as the HTTP response; a workout every day adds about 90 KB). Lambda's response limit is 6 MB.
### Prompts
Tools never appear in a chat's menus; Claude calls them itself. So the server also publishes a **prompt** for each tool except `get_report_data` (8 prompts), under the same name. `get_report_data` has none on purpose: it only serves the report page. Clients list these as ready-made commands:
- **claude.ai chats:** the message box's "+" menu → Connectors → this connector.
- **Claude Code:** `/mcp__<connector>__<name>`.
| Prompt | Arguments | What it does |
|---|---|---|
| `get_sleep`, `get_heart_rate`, `get_readiness`, `get_activity` | `range` | Runs the same query as the tool and attaches the data, with instructions to show it as a table |
| `get_health_metrics` | `metric`, `range` | Same, for Apple Health and chat readings |
| `get_workouts` | `range` | Same, for workouts: one row per workout, then totals |
| `log_reading` | `metric`*, `value`*, `time`, `context`, `note` | Pre-fills a request; Claude then saves the reading with the tool |
| `delete_reading` | `metric`, `timestamp` | Pre-fills a request to remove a reading or a workout, or asks you to pick one from the last 7 days |
\* required
- **`range`** takes `7d` / `30d` / `90d`, `YYYY-MM-DD`, or `YYYY-MM-DD YYYY-MM-DD`. It defaults to the last 7 days.
- **Write prompts never write.** Fetching a prompt never changes data; the actual save or delete goes through the tool.
### Net carbs
`carbs_g` always means net carbs. The switchover is set in one place, `NET_CARBS_SWITCHOVER` in [src/health.ts](src/health.ts): **2026-09-29 15:44 Pacific**. Each sample is judged by its own timestamp, not by when it arrived.
- **Before the switchover,** carbs were edited to net in Cal AI before reaching Apple Health's Carbohydrates. They count as-is, and fiber samples are ignored.
- **From the switchover on,** Carbohydrates is total carbs and net carbs = carbs − fiber.
- Cal AI writes all of a food entry's nutrients with the same timestamp. So a fiber sample is subtracted only when a carbs sample has the same second and source app.
- Fiber with no matching carbs isn't subtracted: it's logged and listed in the day's `warnings`. That way an entry with fiber but no carbs can't pull the total down.
- **A day never goes below 0 net carbs.** A clamp adds a warning.
- **`total_carbs_g` and `fiber_g`** count only samples from the switchover on, and are omitted for days entirely before it. So on the switchover day, `carbs_g` can be more than `total_carbs_g − fiber_g`: it also includes the net carbs entered earlier that day.
## Health data table
On-demand DynamoDB, `PK = metric`, `SK = ts`. It's named in the stack output `HealthTableName`.
- **`ts`** starts with the reading's time as ISO 8601 in UTC, to the second, e.g. `2026-09-27T14:30:00Z`.
- UTC keeps keys sorting chronologically across DST changes and across readings sent with different offsets.
- **Chat readings** use just that time. `delete_reading` finds them by it.
- **Apple Health samples** add `#` and a 12-character fingerprint of the sample's source app, values and unit as sent, e.g. `2026-09-27T14:30:00Z#0ac0a6ff8250`. So:
- a re-sent sample always lands on the same key, and ingest is safe to repeat;
- different samples at the same second (another value, another app) get separate rows;
- exact duplicates within one request get `~1`, `~2`, and so on.
- The original timestamp, with its offset, is kept in `recorded_at`.
- **Each row stores:**
- `value`, or `systolic` + `diastolic` for BP, in the normalized unit
- `unit`
- `original_value` (or `original_systolic` / `original_diastolic`) and `original_unit`
- `source`: the recording app, or `claude-log`
- `via`: `hae` or `chat`
- optional `context` and `note`
- `recorded_at` and `ingested_at`
- **Normalized units:**
- weight and lean mass `lb`
- waist `in` (cm is ÷ 2.54)
- body fat `%` (a value of 1 or less is a fraction and is × 100)
- bp `mmHg`
- glucose `mg/dL` (mmol/L from Apple Health is × 18.016)
- ketones `mmol/L`
- protein, carbs, fiber and fat `g`
- calories `kcal` (kJ is ÷ 4.184)
- **Retained on stack delete.** The table has `DeletionPolicy: Retain`, so readings you logged in chat survive a teardown.
- **Workouts** are rows with `metric = workout`. `ts` is the start (UTC second) plus a fingerprint of the workout's Health Auto Export `id`. Each row stores `name` (the workout type), `start`, `end`, `duration_s`, `active_kcal`, `avg_hr_bpm`, `max_hr_bpm`, `source`, `hae_id`, and `recorded_at` / `recorded_end` (the times as sent).
- **Bookkeeping rows.** `metric = _meta` rows record that the first Health Metrics payload and the first Workouts payload have been logged (`hae-first-payload-logged`, `hae-first-workout-payload-logged`), and when a skipped reconciliation was last logged. No tool ever reads them.
- **Permissions:** the Lambda can only `Query`, `PutItem`, `DeleteItem` and `BatchWriteItem` on this table.
## Apple Health ingest (Health Auto Export)
`POST <FunctionUrl>ingest/<ingest-path-secret>` with the header `X-Ingest-Key: <ingest-key>`. Run `npm run ingest-url` to print both.
- **Auth:** a wrong path gets a 404, and a missing or wrong key gets a 401. Both secrets are separate from the MCP secret.
- **Format:** HAE's JSON: `{"data": {"metrics": [{"name", "units", "data": [{"date", "qty" | "systolic"+"diastolic", "source", "metadata"?}]}]}}`. Dates look like `2026-09-27 07:30:00 -0700`. The shape and metric names were checked against HAE's reference server ([HealthyApps/health-auto-export-server](https://github.com/HealthyApps/health-auto-export-server)).
- **Accepted metrics:** `weight_body_mass`, `waist_circumference` (stored as `waist`), `body_fat_percentage` (`body_fat`), `lean_body_mass` (`lean_mass`), `blood_pressure`, `blood_glucose`, `protein`, `carbohydrates`, `fiber`, `total_fat` (`fat`) and `dietary_energy`.
- Everything else (for example `saturated_fat`, which the automation also sends) is skipped and listed in `ignored_metrics`. A malformed metric entry never fails the request.
- The log line records each metric's unit as sent (`units`).
- **Oura samples dropped:** any sample whose `source` contains "Oura" (in any case) is dropped.
- **`source`:** set to the sample's original app, e.g. "OMRON connect".
- **Meal timing:** if HAE passes through HealthKit's glucose meal time and it says "after meal", `context` becomes `post-meal`.
- **Same-timestamp samples:** Apple Health often stamps several food entries with the same time. Each sample is stored as its own row (see `ts` above), and the daily totals add them up.
- **Re-sends:** samples already stored unchanged aren't written again. The reply counts them as `rows_unchanged`.
- **Deleted or edited samples:** on full "Previous 7 Days" and "Today" pushes, a stored sample that disappears is marked `missing_since`, then `superseded_at` if a push 15 or more minutes later still lacks it. "Today" pushes cover today's rows, so same-day edits are retired the same day. `get_health_metrics` ignores superseded rows, and nothing is ever deleted. The guard rules are in [docs/RUNBOOK.md](docs/RUNBOOK.md#health-auto-export-settings).
- **Workouts:** a "Workouts" automation sends `{"data": {"workouts": [...]}}` instead of metrics, to the same URL.
- Read from each workout: `id`, `name`, `start`, `end`, `duration` (seconds), `activeEnergyBurned` (kcal or kJ), and `avgHeartRate` / `maxHeartRate`. If those two are missing, the `heartRate` min/avg/max object is used, then the `heartRateData` series. Export Version 1 (no `id` or `duration`, `activeEnergy` as one object) is read too.
- The field names follow HAE's [Export Version 2 documentation](https://help.healthyapps.dev/en/health-auto-export/export-format/workouts/) and its reference server.
- **Source:** the documented format has no source on the workout itself, only on the samples inside its series. The server uses a top-level `source` if one arrives, otherwise the most common source in the active-energy series, then in any other series, and `unknown` if there is none.
- Workouts whose source contains "Oura" are dropped, like other Oura samples.
- Re-sends land on the same row (keyed by the workout's `id`), so a workout whose numbers change is updated in place.
- Reconciliation follows the same rules as other samples, with one difference described in the runbook.
- The reply adds a `workouts` object with its own counts.
- **Size limit:** 6 MB (6,291,456 bytes), the most AWS allows; in practice about 5.5 MB of Health Auto Export JSON. A gzip body may inflate to the same 6 MB. Details, and what to do about a push that is too large, are in [docs/RUNBOOK.md → Payload size](docs/RUNBOOK.md#payload-size).
- **Response:** HTTP 200 with the counts, e.g. `{"accepted":4,"skipped":1,"skipped_reasons":{"oura_source":1},"rows_written":1,"rows_unchanged":3}`. `ignored_metrics` lists any unsupported metric names that were sent. The log line also records how many samples each metric carried and the automation's period.
- **First payload:** the first Health Metrics payload and the first Workouts payload are each logged once, redacted (every number becomes `"<number>"`; only 2 samples per metric or workout series and 3 workouts are kept). Find them in CloudWatch with `sam logs --stack-name oura-mcp --filter 'hae first payload'` and `--filter 'hae first workout payload'`. To log another one, delete the bookkeeping row: `aws dynamodb delete-item --table-name <HealthTableName> --key '{"metric":{"S":"_meta"},"ts":{"S":"hae-first-payload-logged"}}'`.
### Health Auto Export settings
The full settings, and what happens to samples deleted in Apple Health, are in [docs/RUNBOOK.md → Health Auto Export settings](docs/RUNBOOK.md#health-auto-export-settings). In short: REST API automations sending JSON Version 2, with Summarize Data OFF and Batch Requests OFF:
- one re-sends the **Previous 7 Days** every 5 minutes. That's the 7 days *before* today; it never includes today.
- a second sends **Today** every 5 minutes, so today's entries arrive the same day, and entries edited or deleted today are reconciled the same day.
- two more do the same for **Workouts** (Data Type "Workouts" instead of "Health Metrics").
## Setup
1. **Log in to AWS.** Use `aws login`, `aws configure sso`, or `aws configure`.
2. **Deploy.** Run `npm install`, then `npm run deploy`.
- First copy `deploy.env.example` to `deploy.env` and fill it in: region, budget email and amount, timezone and reserved concurrency. `deploy.env` is git-ignored.
- The deploy creates any missing secrets (`path-secret`, `ingest-path-secret`, `ingest-key`), builds with esbuild and deploys the stack.
- It doesn't print the connector or ingest URLs, because they contain secrets. Run `npm run url` and `npm run ingest-url` to see them.
3. **Register an Oura app.** Create it at <https://cloud.ouraring.com/oauth/applications> (or on the newer developer portal) with redirect URI `http://localhost:8787/callback`.
4. **Connect Oura.** Run `npm run oura-auth`. It stores the client credentials and the first token pair in SSM.
5. **Add the connector in claude.ai.** Go to Settings → Connectors → Add custom connector. Paste the output of `npm run url` and leave the OAuth fields empty.
6. **Set up Health Auto Export** as described above.
## Secrets (SSM SecureString, AWS-managed `aws/ssm` key)
| Parameter | Used for |
|---|---|
| `/oura-mcp/path-secret` | Path segment of the MCP URL (`/mcp/<secret>`) |
| `/oura-mcp/ingest-path-secret` | Path segment of the ingest URL (`/ingest/<secret>`) |
| `/oura-mcp/ingest-key` | Value of the `X-Ingest-Key` header on ingest |
| `/oura-mcp/oauth-client` | Oura client ID and secret |
| `/oura-mcp/tokens` | Oura access and refresh tokens; the Lambda rewrites them on each rotation |
## Refresh-token rotation (Oura)
Oura refresh tokens are single-use. The token manager in [src/tokens.ts](src/tokens.ts) handles this as follows:
- It refreshes 5 minutes before expiry, or after a 401.
- It re-reads SSM first, in case another instance has already rotated the tokens.
- It writes the new pair to SSM *before* using it.
- On `invalid_grant`, it polls SSM for the pair the winning instance saved.
- If the SSM write fails, it keeps the new pair in memory and retries the write.
Refreshes go to the token endpoint that issued the grant (`moi.ouraring.com` for current apps). If Oura revokes the grant, the tools tell you to run `npm run oura-auth`.
## Tests
- **`npm test`** runs offline, using the official MCP client against the Lambda handler through a fake Function URL. An in-memory store applies the same conditions as DynamoDB, so no test touches a real table.
- **Oura:** the tools, both protocol eras, and refresh-token rotation, with a fake Oura that enforces single-use refresh tokens.
- **Ingest:** HAE parsing, unit conversion, Oura filtering, auth, the size limit, the 415/400 responses, gzip and redaction.
- **Health tools:** `log_reading` validation, the duplicate rule, and `delete_reading` (deleting chat readings; removing, undoing and re-sending Apple Health readings; ambiguous minutes).
- **Nutrition and body measurements:** fat, fiber and net carbs, waist, body fat and lean mass: units, re-sends, reconciliation, delete and undo.
- **Report page data:** each section equals the individual tool's payload, sleep periods fetched once, per-section errors (an Oura collection refused, a revoked Oura grant, the health table failing), the 366-day limit.
- **Workouts:** both export versions, the fallbacks for energy, heart rate and source, re-sends, reconciliation with the window guard, `get_workouts` end to end, and removing and restoring a workout.
- **Re-sends and reconciliation:** stable sample keys, separate same-second samples, skip-unchanged, the 15-minute two-push rule, un-marking, the more-than-half skip, window edges, "Today" pushes, and metrics that are missing, empty or Oura-only.
- **Connector:** `subscriptions/listen` refused at once (it used to hang the Lambda until the runtime exited).
- **GitHub Actions** runs the type check and `npm test` on every push and pull request ([.github/workflows/test.yml](.github/workflows/test.yml)).
- **`npm run smoke`** checks the deployed stack with curl and writes nothing: the auth rejection, the MCP handshake, the tool list, the prompt list (one prompt per tool except `get_report_data`), one Oura tool, and read-only calls to `get_health_metrics`, `get_workouts` and `get_report_data` (it prints counts and sizes, not readings).
## Cost
- **Lambda, SSM, KMS (`aws/ssm`) and CloudWatch Logs** stay within always-free limits, as before. Logs are kept for 14 days.
- **DynamoDB on-demand** isn't covered by the always-free tier, which applies to provisioned capacity. At personal volumes it costs fractions of a cent a month, since writes cost well under $1 per million and storage is under 25 GB free.
The account-wide **monthly AWS Budget** (`BUDGET_LIMIT_USD` in `deploy.env`) emails `BUDGET_EMAIL` at 50% and 100% of actual spend, and at 100% of forecast spend. Change the amount there and re-run `npm run deploy`.
## Operations and decisions
- **Operating it** (logs, redeploying, rotating a secret, restores, the size limit, tearing it down) is in [docs/RUNBOOK.md](docs/RUNBOOK.md). URL secrets, the ingest key and tokens are never logged.
- **Why it is built this way,** and what is still undecided, is in [docs/DECISIONS.md](docs/DECISIONS.md). Every decision that changes behavior, cost or security gets an entry there.
## Licence and security
MIT; see [LICENSE](LICENSE). To report a security problem, see [SECURITY.md](SECURITY.md).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues