oura-mcp
Integrates with Apple Health data by ingesting readings pushed from Health Auto Export, normalizing metrics such as weight, blood pressure, glucose, ketones, macros, and calories, and providing tools to query, deduplicate, mark removed, and undo deletion of Apple Health readings.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@oura-mcphow did I sleep last night and what's my readiness today?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 hereOperating 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 |
| 92 days |
|
| 31 days |
|
| 92 days |
|
| 92 days |
|
How the Oura fields are defined:
Sleep metrics belong to the day you woke up.
resting_hr_bpmis the lowest 5-minute average during the night's main sleep, which is what the Oura app shows.avg_hrv_msis 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.
metricisglucoseorketones.unitmust be exactlymg/dLfor glucose ormmol/Lfor 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.
timestampdefaults to now. A time without an offset, such as2026-09-27T07:30, is read as Los Angeles time.contextisfasting,post-mealorother.noteis optional, up to 200 characters.Saved with
source: "claude-log". It returns the stored row, including the exacttimestampthatdelete_readingneeds.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,valuepicks 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: truerestores a removed Apple Health reading.Workouts are removed the same way:
metricisworkout,timestampis the workout's start time (fromget_workouts), andvalueis its duration in minutes if several started in that minute. A workout is always marked removed, never deleted, andundo: truerestores it.
get_health_metrics(metric, start_date?, end_date?)
metricis one ofweight waist body_fat lean_mass bp glucose ketones protein carbs fiber fat calories, orall.carbsalways 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_gandcalories_kcalas daily sums, pluswarningswhen net carbs had to be clamped or some fiber wasn't subtractedweight(lb),waist(in),body_fat(%),lean_mass(lb),bp,glucoseandketonesas lists of readings{time, value | systolic+diastolic, unit, context?, source, note?, timestamp?}
timestampappears onclaude-logreadings. Apple Health readings showtime(HH:MM), which is enough fordelete_reading.Duplicates: an Apple Health reading and a
claude-logreading 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-logone 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?}.workoutshas one row per workout:date,startandend(local HH:MM),type,duration_min,active_kcal,avg_hr_bpm,max_hr_bpmandsource. The energy and heart-rate fields appear only when the workout recorded them.dayshas the daily totals:workouts,duration_minandactive_kcal.
A workout belongs to the day it started, in
USER_TZ.sourceis the recording app when the payload names one, otherwiseunknown(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_readingwithmetric: "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 |
| 366 days |
|
Each section is exactly what the matching tool returns for the same range:
healthisget_health_metricswithmetric: "all", thenget_sleep,get_activity,get_readinessandget_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 listsfailed_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 |
|
| Runs the same query as the tool and attaches the data, with instructions to show it as a table |
|
| Same, for Apple Health and chat readings |
|
| Same, for workouts: one row per workout, then totals |
|
| Pre-fills a request; Claude then saves the reading with the tool |
|
| Pre-fills a request to remove a reading or a workout, or asks you to pick one from the last 7 days |
* required
rangetakes7d/30d/90d,YYYY-MM-DD, orYYYY-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_gandfiber_gcount only samples from the switchover on, and are omitted for days entirely before it. So on the switchover day,carbs_gcan be more thantotal_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.
tsstarts 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_readingfinds 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, orsystolic+diastolicfor BP, in the normalized unitunitoriginal_value(ororiginal_systolic/original_diastolic) andoriginal_unitsource: the recording app, orclaude-logvia:haeorchatoptional
contextandnoterecorded_atandingested_at
Normalized units:
weight and lean mass
lbwaist
in(cm is ÷ 2.54)body fat
%(a value of 1 or less is a fraction and is × 100)bp
mmHgglucose
mg/dL(mmol/L from Apple Health is × 18.016)ketones
mmol/Lprotein, carbs, fiber and fat
gcalories
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.tsis the start (UTC second) plus a fingerprint of the workout's Health Auto Exportid. Each row storesname(the workout type),start,end,duration_s,active_kcal,avg_hr_bpm,max_hr_bpm,source,hae_id, andrecorded_at/recorded_end(the times as sent).Bookkeeping rows.
metric = _metarows 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,DeleteItemandBatchWriteItemon 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 like2026-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 aswaist),body_fat_percentage(body_fat),lean_body_mass(lean_mass),blood_pressure,blood_glucose,protein,carbohydrates,fiber,total_fat(fat) anddietary_energy.Everything else (for example
saturated_fat, which the automation also sends) is skipped and listed inignored_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
sourcecontains "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",
contextbecomespost-meal.Same-timestamp samples: Apple Health often stamps several food entries with the same time. Each sample is stored as its own row (see
tsabove), 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, thensuperseded_atif 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_metricsignores 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), andavgHeartRate/maxHeartRate. If those two are missing, theheartRatemin/avg/max object is used, then theheartRateDataseries. Export Version 1 (noidorduration,activeEnergyas 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
sourceif one arrives, otherwise the most common source in the active-energy series, then in any other series, andunknownif 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
workoutsobject 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_metricslists 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 withsam 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
Log in to AWS. Use
aws login,aws configure sso, oraws configure.Deploy. Run
npm install, thennpm run deploy.First copy
deploy.env.exampletodeploy.envand fill it in: region, budget email and amount, timezone and reserved concurrency.deploy.envis 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 urlandnpm run ingest-urlto see them.
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.Connect Oura. Run
npm run oura-auth. It stores the client credentials and the first token pair in SSM.Add the connector in claude.ai. Go to Settings → Connectors → Add custom connector. Paste the output of
npm run urland leave the OAuth fields empty.Set up Health Auto Export as described above.
Secrets (SSM SecureString, AWS-managed aws/ssm key)
Parameter | Used for |
| Path segment of the MCP URL ( |
| Path segment of the ingest URL ( |
| Value of the |
| Oura client ID and secret |
| 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 testruns 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_readingvalidation, the duplicate rule, anddelete_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_workoutsend 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/listenrefused at once (it used to hang the Lambda until the runtime exited).
GitHub Actions runs the type check and
npm teston every push and pull request (.github/workflows/test.yml).npm run smokechecks the deployed stack with curl and writes nothing: the auth rejection, the MCP handshake, the tool list, the prompt list (one prompt per tool exceptget_report_data), one Oura tool, and read-only calls toget_health_metrics,get_workoutsandget_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.
This server cannot be deployed
Maintenance
Related MCP Connectors
Connect your health, fitness, nutrition, sleep, and wearable data to your AI assistant.
Your Apple Health data, food diary and lifts, for ChatGPT, Claude and Gemini. Approved on iPhone.
WHOOP recovery, strain, sleep and workouts in Claude via official WHOOP OAuth. Free, open source.
- E-LabusOAuthcom.e-labus
Your lab results and health record in Claude: trends, retests, meds, conditions and symptoms.
1
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceIntegrates Oura Ring health data with Claude Desktop, providing resources, tools, and advanced analytics for sleep, activity, readiness, and health trends.1-
- AlicenseNot gradedqualityDmaintenanceEnables Claude to access and query Oura Ring health data including sleep, activity, readiness, heart rate, and more via the Oura API.MIT
- AlicenseAqualityCmaintenanceProvides 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.61MIT
- AlicenseNot gradedqualityDmaintenanceEnables 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 npmMIT