Skip to main content
Glama
Barbaroso

apple-health-semantic-mcp

by Barbaroso
README.md
# apple-health-semantic-mcp

Query your Apple Health export with an LLM, over the [Model Context Protocol](https://modelcontextprotocol.io).

The hard part of this is not moving the data. It is that Apple Health data is
full of traps that produce **confidently wrong answers**: percentages stored as
fractions, sleep that changes date at midnight, three apps recording the same
steps, device names containing an invisible character that makes `LIKE` filters
match nothing. A model that reads the raw tables will answer fluently and
incorrectly, and you will have no way to tell.

So this server ships a **semantic layer**: for every metric it states what the
value means, which aggregate function is valid, and what is known to go wrong —
and injects that into the model's context *before* it writes any SQL.

It optionally imports CPAP therapy data, so a single query can correlate machine
data with wearable data.

```sql
SELECT o.nightOf, o.ahi, ROUND(AVG(h.value), 1) AS sdnn_ms
FROM oscar_daily o
JOIN hkquantitytypeidentifierheartratevariabilitysdnn h USING (nightOf)
GROUP BY 1, 2 ORDER BY 1;
```

---

## Quick start

```bash
git clone https://github.com/Barbaroso/apple-health-semantic-mcp
cd apple-health-semantic-mcp
npm install
```

On your iPhone: **Health → your profile picture → Export All Health Data**. Get
the resulting `export.zip` onto this machine — AirDrop, iCloud Drive, a cable,
whatever suits. Do **not** unzip it; point the tool at the archive as it is.

```bash
node bin/cli.js setup /path/to/export.zip --write-config
```

That one command reads the archive, converts it, imports CPAP data if it finds
any, verifies the result, and registers the server with Claude Desktop (keeping a
backup of your previous config, and leaving every other server alone). Restart
the client and you are done.

For VS Code, Visual Studio, GitHub Copilot or Codex, add `--client` — see
[Connecting a client](#connecting-a-client).

Drop `--write-config` if you would rather paste the configuration yourself — it
gets printed either way, in that client's own format, with absolute paths already
filled in.

Reading straight from the archive is not just less typing. A real 83 MB
`export.zip` holding a 1.4 GB `export.xml` converts in **16 seconds**, faster
than converting the extracted file, because the disk only has to deliver 83 MB.
It also saves you 1.4 GB. Memory stays flat regardless of export size: the XML is
streamed through a decompressor, never held.

`export.xml` and the extracted `apple_health_export/` folder are accepted too, if
you have already unpacked one.

Restart the client and ask something. Good first questions:

- *"What's my sleep been like over the last month, and is it trending?"*
- *"Show me nights where my blood oxygen dropped below 90%."*
- *"Does my resting heart rate go up after short nights?"*

<details>
<summary>Doing it in separate steps</summary>

`setup` is a wrapper. The individual commands remain, and are what you want when
re-converting a newer export into an existing directory:

```bash
node bin/cli.js convert    --in /path/to/export.zip --out ~/health-data
node bin/cli.js sync-oscar --out ~/health-data          # optional, CPAP
node bin/cli.js doctor     --dir ~/health-data
```

The configuration block, if you are writing it by hand, is in
[Connecting a client](#connecting-a-client) — one per client, since they disagree
on the format.

</details>

---

## Connecting a client

`setup --write-config` targets Claude Desktop unless told otherwise. Each client
below has been checked against a running server:

```bash
node bin/cli.js setup /path/to/export.zip --client vscode --write-config
```

| `--client` | Application | Configuration file |
| --- | --- | --- |
| `claude` *(default)* | Claude Desktop | `%APPDATA%\Claude\claude_desktop_config.json`, `~/Library/Application Support/Claude/…`, `~/.config/Claude/…` |
| `vscode` | VS Code (GitHub Copilot Chat) | `%APPDATA%\Code\User\mcp.json`, `~/Library/Application Support/Code/User/mcp.json`, `~/.config/Code/User/mcp.json` |
| `visual-studio` | Visual Studio | `~/.mcp.json` |
| `copilot-cli` | GitHub Copilot CLI and desktop app | `~/.copilot/mcp-config.json` |
| `codex` | Codex CLI and the ChatGPT desktop app | `~/.codex/config.toml` |

Without `--write-config` the block is printed in that client's own format for you
to paste. Nothing is ever rewritten blind: the previous file is copied to a
timestamped sibling first, and every other server and unrelated setting is kept.

Three differences matter, because each one produces a client that starts cleanly
and then lists no tools at all:

- **VS Code and Visual Studio read `servers`, not `mcpServers`,** and expect an
  explicit `"type": "stdio"`.
- **Codex uses TOML**, as `[mcp_servers.<name>]` with the environment in its own
  `[mcp_servers.<name>.env]` table. That file is edited by section rather than
  parsed and reserialised, so comments and hand-written ordering survive.
- **The GitHub Copilot CLI reads its configuration when a session starts.** A
  session that was already open when you added the server keeps its original
  list; start a new one.

<details>
<summary>The blocks, if you would rather paste them by hand</summary>

Claude Desktop and the GitHub Copilot CLI:

```json
{
  "mcpServers": {
    "apple-health": {
      "command": "node",
      "args": ["/absolute/path/to/apple-health-semantic-mcp/bin/cli.js", "serve"],
      "env": { "HEALTH_DATA_DIR": "/absolute/path/to/health-data" }
    }
  }
}
```

VS Code and Visual Studio:

```json
{
  "servers": {
    "apple-health": {
      "type": "stdio",
      "command": "node",
      "args": ["/absolute/path/to/apple-health-semantic-mcp/bin/cli.js", "serve"],
      "env": { "HEALTH_DATA_DIR": "/absolute/path/to/health-data" }
    }
  }
}
```

Codex:

```toml
[mcp_servers.apple-health]
command = "node"
args = ["/absolute/path/to/apple-health-semantic-mcp/bin/cli.js", "serve"]

[mcp_servers.apple-health.env]
HEALTH_DATA_DIR = "/absolute/path/to/health-data"
```

On Windows, write the absolute path to `node.exe` rather than `node`, and escape
the backslashes (`C:\\Program Files\\nodejs\\node.exe`). `setup` does this for you.

</details>

---

## Why a semantic layer

These are real defects in real exports, all of which produce a plausible wrong
answer rather than an error.

| Trap | What the data looks like | The wrong answer it produces |
|---|---|---|
| Blood oxygen stored as a fraction | `unit="%"`, `value="0.95"` | "your oxygen saturation is 0.95%" |
| Heart rate is a discrete sample | 475,000 rows | `SUM(value)` returns a large meaningless number |
| Step count is an interval total | one row per partial interval | `AVG(value)` reports a fraction of the real daily total |
| `inBed` overlaps `asleep*` | both cover the same hours | "you slept 13 hours" |
| Sleep crosses midnight | a session starts at 01:15 | `DATE(startDate)` splits one night into two, inventing a short night |
| Several apps record the same metric | watch + phone + third-party app | step counts inflated by 80–90% |
| `SleepApneaEvent` is a notification | one row spanning 30 days, value `NotApplicable` | "a 30-day apnoeic episode" |
| Source names contain U+00A0 | `Apple<U+00A0>Watch` | `LIKE '%Apple Watch%'` matches **nothing**, with no error |

The last one deserves emphasis, because it is invisible in every tool that
displays it. Apple writes device and source names with a NO-BREAK SPACE. A query
that looks correct returns zero rows, and the natural conclusion — "this device
has no data" — is wrong. The converter normalises it. See
[docs/DATA-TRAPS.md](docs/DATA-TRAPS.md) for the full catalogue.

---

## Tools

### `health_schema`
Lists every table and returns the semantic layer: measurement class, correct
aggregate function, units, observed value ranges, recording sources, date
coverage, and per-metric caveats. **Call this first.** Everything else depends on
the model having read it.

### `health_query`
Read-only DuckDB SQL. Writes are rejected with an explanation, not a generic
failure. Tables load on demand. Rows come back as labelled objects rather than
positional arrays, because column misalignment is exactly the class of silent
error this project exists to prevent.

If a query names a column that doesn't exist, the error lists the columns that
do:

```
Binder Error: Referenced column "measurementClass" not found ...

Columns that actually exist -> hkquantitytypeidentifierheartrate: type,
sourceName, sourceVersion, unit, startDate, endDate, value, valueText, device,
productType, nightOf. Note that measurementClass, aggregation and caveats are
metadata returned by health_schema, not columns in any table.
```

### `health_report`
A summary across heart rate, resting heart rate, HRV, sleep, respiratory rate,
blood oxygen, activity, workouts and CPAP therapy.

Every section declares how it was computed:

```
## Sleep  [per night (noon to noon), source: Apple Watch]
   Average 7.81 h across 14 nights (6.65-8.53)
   ! 2 sources recorded this metric. Restricted to 'Apple Watch' (14 days,
     140 readings); combining them would count the same activity more than once.
```

Three rules are enforced throughout, because breaking any of them yields a
number that looks reasonable and is wrong:

1. **Nocturnal metrics group by `nightOf`, never by calendar day.** Otherwise a
   night that crosses midnight is split in two and a fictitious short night drags
   the average down.
2. **Per-day and per-night values are computed first, then averaged.** Averaging
   raw samples weights each day by how often the device happened to sample it.
3. **Where several apps recorded the same metric, one source is used** and the
   rest are named in `sourcesExcluded`.

#### Choosing the window

Explicit dates take effect on their own — `period` does not have to be set to
`custom`, and one endpoint is enough:

```jsonc
{ "start_date": "2026-07-22", "end_date": "2026-07-30" }  // exactly those 9 days
{ "start_date": "2026-07-22" }                            // that date to today
{ "period": "week", "end_date": "2026-06-15" }            // the 7 days ending then
{ "period": "quarter" }                                   // 90 days ending today
```

Named periods are inclusive of both endpoints, so a week is seven days, and they
are dated in local time rather than UTC — the same wall-clock the data uses.

Every reply reports the window it actually used, and why:

```jsonc
"period": {
  "start": "2026-07-22", "end": "2026-07-30", "days": 9,
  "basis": "the requested start_date and end_date, inclusive"
}
```

That field exists because of a shipped defect: dates were honoured only when
`period` was `custom`, so a caller passing dates the obvious way silently got the
default window, correctly computed and confidently labelled. A report on the
wrong period is worse than an error — nothing about it looks wrong. An
unrecognised parameter is now refused rather than dropped, for the same reason.

---

## Data model

One table per HealthKit type, named after the identifier in lower case
(`HKQuantityTypeIdentifierHeartRate` → `hkquantitytypeidentifierheartrate`).
Every table has exactly these columns:

| Column | Type | Notes |
|---|---|---|
| `type` | VARCHAR | HealthKit identifier, or the activity for workouts |
| `sourceName` | VARCHAR | App or device that wrote the record |
| `sourceVersion` | VARCHAR | |
| `unit` | VARCHAR | Check it before comparing or summing |
| `startDate` | TIMESTAMP | Local wall-clock time |
| `endDate` | TIMESTAMP | Local wall-clock time |
| `value` | DOUBLE | NULL for category types |
| `valueText` | VARCHAR | The label — use this for sleep stages |
| `device` | VARCHAR | |
| `productType` | VARCHAR | Hardware model |
| `nightOf` | DATE | The night this record belongs to |

### `nightOf`

```sql
CAST(DATE_TRUNC('day', startDate - INTERVAL 12 HOUR) AS DATE)
```

A night runs noon to noon and is labelled with the day it began. Sleep starting
at 23:40 on the 22nd and sleep starting at 01:10 on the 23rd both belong to
`nightOf = 2024-01-22`.

Measured on a real six-year export: **87% of asleep records** are assigned to the
wrong night by `DATE(startDate)`. This is not an edge case.

Timestamps are local wall-clock time. The CSV keeps the original UTC offset and
the loader truncates it at load, so "last night" means what the wearer would mean
by it, in whatever timezone they were in.

---

## CPAP correlation (optional)

If you use a CPAP or BiPAP machine and review your therapy in
[OSCAR](https://gitlab.com/pholy/OSCAR-code), you can pull the nightly summaries
into the same database:

```bash
node bin/cli.js sync-oscar --out ~/health-data
```

This reads the `oscar.db` that OSCAR 2.x writes in its own data folder — there is
no export step, and OSCAR can stay open. The database is copied together with its
write-ahead log before being read, so nothing is locked. OSCAR 1.x used a
different on-disk layout and is not supported.

This adds an `oscar_daily` table: one row per night, with AHI, RDI, event counts
by class, pressure and leak statistics, usage hours and compliance.

The join needs no date adjustment — OSCAR and this pipeline both define a night
as noon-to-noon, so `nightOf` matches directly.

```sql
SELECT o.nightOf,
       o.ahi,
       o.leakTotal95th,
       ROUND(MIN(s.value), 1) AS lowest_spo2
FROM oscar_daily o
JOIN hkquantitytypeidentifieroxygensaturation s USING (nightOf)
WHERE o.maskOnHours >= 4
GROUP BY 1, 2, 3
ORDER BY o.ahi DESC;
```

Two things worth knowing:

- OSCAR stores "not recorded" as `0` for SpO2 and pulse. If no oximeter was
  attached, every value is a placeholder. The importer exports these as NULL so
  the model cannot report a saturation of 0%.
- Apple's "breathing disturbances" metric is **not** an AHI and the two are not
  comparable. It is a relative index from a wrist accelerometer; AHI is a count
  of scored respiratory events per hour from a flow sensor.

This bridge is deliberately narrow: one row per night, enough to correlate
therapy with wearable data in a single SQL statement. To interrogate CPAP data on
its own — session detail, event clustering through the night, pressure and
setting changes — use [oscar-mcp](https://github.com/Barbaroso/oscar-mcp), a
separate read-only MCP server for exactly that. The two run side by side.

More examples in [examples/queries.md](examples/queries.md).

---

## Configuration

| Variable | Default | Purpose |
|---|---|---|
| `HEALTH_DATA_DIR` | *(required)* | Directory produced by `convert` |
| `HEALTH_MAX_MEMORY` | `2048` | DuckDB memory ceiling, MB |
| `HEALTH_WINDOW_DAYS` | `0` | Load only the last N days. `0` loads everything |
| `HEALTH_NIGHT_SPLIT_HOUR` | `12` | Hour separating one night from the next |
| `OSCAR_DATA_DIR` | auto-discovered | Folder containing `oscar.db` |

### CLI

```
apple-health-semantic-mcp setup       <export.zip|export.xml|dir> [--out <dir>]
                                      [--client <name>] [--write-config] [--config <path>]
                                      [--memory <mb>] [--no-oscar] [--limit n]
apple-health-semantic-mcp convert     --in <export.zip|export.xml|dir> --out <dir> [--limit n] [--dry]
apple-health-semantic-mcp sync-oscar  --out <dir> [--db <oscar.db>] [--dry]
apple-health-semantic-mcp doctor      [--dir <dir>]
apple-health-semantic-mcp serve
```

`setup` is the whole pipeline in one call and is what a first install should use.
`--client` picks which application to configure (`claude`, `vscode`,
`visual-studio`, `copilot-cli`, `codex` — see
[Connecting a client](#connecting-a-client)); `--config` overrides the file it
would otherwise find for that client. `--write-config` merges the server in,
keeping a timestamped backup and every other server you have; without it the
block is printed for you to paste. A configuration file that cannot be parsed is
refused rather than overwritten.

`doctor` is the one to run when something looks wrong. It validates the data
directory, loads a table, and checks for un-normalised non-breaking spaces.

---

## Privacy

Everything runs locally. The server reads files on your disk and speaks MCP over
stdio; it opens no network connection and sends nothing anywhere. Your data goes
to whichever model you have connected, and nowhere else.

`.gitignore` excludes `*.csv`, `export.xml`, `semantics.json` and `*.db`, so
health data cannot be committed by accident. The test suite generates its own
synthetic data — there is no real health data in this repository, and none should
ever be added.

**This is not a medical device.** Consumer wearable data supports trend and
correlation analysis, not diagnosis. Wrist reflectance oximetry in particular is
not comparable to a pulse oximeter, and Apple's own documentation says so. Take
clinical decisions with a clinician.

---

## Development

```bash
npm test
```

75 end-to-end tests. They generate a synthetic export containing every trap
listed above, pack it into real ZIP archives (including a ZIP64 one, which is
what a multi-gigabyte export produces), convert it, build a synthetic CPAP
database, and drive the real server over stdio. Nothing is mocked, so a failure
means a client would see the same failure.

`test/period.test.js` is deliberately exhaustive about report windows. That is
where the worst defect in this project's history lived, and it survived a green
suite because every test had pinned `period: 'custom'` — encoding the
implementation rather than the requirement. Tests should ask for things the way a
caller would.

Requires Node 22.5 or newer (for the built-in `node:sqlite`).

```
bin/cli.js       setup | convert | sync-oscar | doctor | serve
src/ontology.js  the semantic layer: every metric's meaning, aggregation, caveats
src/convert.js   streaming export.xml -> CSV + semantics.json
src/zip.js       streaming reader for the archive the phone produces
src/setup.js     one-shot install: convert, verify, configure the client
src/oscar.js     CPAP bridge
src/doctor.js    data directory health checks
src/db.js        DuckDB storage, catalogue, on-demand loading
src/tools.js     the three MCP tools
src/server.js    stdio wiring
```

The ontology is where contributions are most valuable. If Apple adds a metric, or
you find a trap that isn't documented, add it to `src/ontology.js` and the model
will be told about it everywhere.

## Related

[oscar-mcp](https://github.com/Barbaroso/oscar-mcp) — a read-only MCP server for
CPAP therapy data, reading OSCAR's own `oscar.db` directly. Where this project
pulls one summary row per night for correlation, that one exposes the therapy
data properly: per-session detail, when events clustered during the night,
pressure and setting changes. The two run side by side, and an assistant with
both can reason across them.

## Acknowledgements

The idea of exposing an Apple Health export over MCP comes from
[@neiltron/apple-health-mcp](https://github.com/neiltron/apple-health-mcp).
This is an independent implementation; several bugs found along the way were
reported back upstream.

## License

MIT

TDQS

A4.4/5.0

Scored across 3 tools

Disambiguation5/5

Each tool has a distinct role: health_schema provides metadata, health_query executes arbitrary SQL, and health_report generates a curated summary. The cross-references between them clarify when to use which, leaving no ambiguity.

Naming Consistency5/5

All tools follow the consistent health_<noun> pattern: health_schema, health_query, health_report. This uniform convention makes the toolset predictable and easy to navigate.

Tool Count5/5

With only three tools, the set is tightly scoped for a semantic layer: schema discovery, query execution, and report generation. Each tool is essential and the count is appropriate for the purpose.

Completeness5/5

The domain is read-only exploration of Apple Health data, and the three tools cover the full workflow: understanding the schema, running specific queries, and generating high-level summaries. There are no obvious gaps or dead ends.

Maintenance

ActivityStale
ResponsivenessNo issues