Skip to main content
Glama

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.

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: 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.

Related MCP server: Oura Ring MCP Server

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).

  • 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.

  • 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 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.

  • 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. 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 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).

  • 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. 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. Every decision that changes behavior, cost or security gets an entry there.

Licence and security

MIT; see LICENSE. To report a security problem, see SECURITY.md.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    C
    maintenance
    Provides Claude with real-time access to local health data including sleep, recovery, strain, and workouts from WHOOP and Apple Health, enabling informed context-aware interactions.
    6
    1
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables Claude Desktop and Claude Code to access your personal Oura Ring health data—including sleep, readiness, activity, heart rate, and workouts—through local MCP tools.
    3 npm
    MIT