health-export-mcp 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -12
- package/demo.mjs +259 -0
- package/events.mjs +288 -0
- package/glama.json +1 -1
- package/health-export.mcpb +0 -0
- package/healthstore.mjs +348 -18
- package/llms.txt +10 -3
- package/manifest.json +29 -5
- package/package.json +7 -3
- package/prompts.mjs +133 -0
- package/receiver.mjs +5 -5
- package/server.json +2 -2
- package/server.mjs +317 -39
- package/test/integration.mjs +95 -21
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
</p>
|
|
17
17
|
|
|
18
18
|
<p align="center">
|
|
19
|
-
<img src="assets/architecture.svg" alt="Apple Health exports to iCloud, a folder, or your LAN; health-export-mcp reads it and serves
|
|
19
|
+
<img src="assets/architecture.svg" alt="Apple Health exports to iCloud, a folder, or your LAN; health-export-mcp reads it and serves 14 query tools to your AI agent" width="100%" />
|
|
20
20
|
</p>
|
|
21
21
|
|
|
22
22
|
> Ask your agent: *"Compare my HRV this week vs last week and tell me if I'm recovering."* — it calls the tools and answers from your **actual numbers**.
|
|
@@ -31,7 +31,7 @@ It's an MCP server that turns your Apple Health export into a tool your AI agent
|
|
|
31
31
|
- **What it isn't:** a cloud service. There's no developer server in the path — your data goes only where *you* point it.
|
|
32
32
|
- **Setup:** point the server at your exported data and add it to your AI client.
|
|
33
33
|
|
|
34
|
-
> **Try it with no iPhone needed:** `
|
|
34
|
+
> **Try it with no iPhone needed:** `node server.mjs --demo` serves a deterministic synthetic dataset (400 days, workouts, events, sleep sessions) with every answer watermarked as synthetic. Or run `npm test` to write a sample cache and exercise every tool.
|
|
35
35
|
|
|
36
36
|
---
|
|
37
37
|
|
|
@@ -104,20 +104,51 @@ Restart the client and try:
|
|
|
104
104
|
|
|
105
105
|
---
|
|
106
106
|
|
|
107
|
-
## The
|
|
107
|
+
## The 14 MCP tools
|
|
108
|
+
|
|
109
|
+
Full reference with request/response examples: **[healthexport.dev/mcp](https://www.healthexport.dev/mcp/#tools)**.
|
|
108
110
|
|
|
109
111
|
| Tool | What it does |
|
|
110
112
|
|---|---|
|
|
111
|
-
| `get_mcp_status` | Health check
|
|
113
|
+
| `get_mcp_status` | Health check: source, metric/workout counts, which context files exist, latest data date. **Call first.** |
|
|
112
114
|
| `list_metrics` | Every available metric with unit, day count, and date range. |
|
|
113
|
-
| `get_health_metrics` | Daily values for a metric (or all) over a date range + an aggregate (avg/sum/min/max/latest). |
|
|
114
|
-
| `get_trends` | Recent N-day window vs the prior N days
|
|
115
|
-
| `compare_periods` | A metric across two arbitrary date periods (A vs B). |
|
|
116
|
-
| `get_structured_export` | Clean JSON for chosen metrics/range
|
|
117
|
-
| `get_intraday` | Today's hour-by-hour window from the app's hourly automations (`health-intraday.json`, app 1.4+)
|
|
118
|
-
| `query_health_data` | Natural-language convenience: *"average HRV last month"*
|
|
115
|
+
| `get_health_metrics` | Daily values for a metric (or all) over a date range + an aggregate (avg/sum/min/max/latest). Supports `filterDays` (restrict to days covered by logged events, with `negate`). |
|
|
116
|
+
| `get_trends` | Recent N-day window vs the prior N days: change, % change, direction. Supports `excludeTravelDays`. |
|
|
117
|
+
| `compare_periods` | A metric across two arbitrary date periods (A vs B), or around a logged event via `anchor: {eventId, days}` (the event day excluded from both sides). |
|
|
118
|
+
| `get_structured_export` | Clean JSON for chosen metrics/range, paginated with a cursor. |
|
|
119
|
+
| `get_intraday` | Today's hour-by-hour window from the app's hourly automations (`health-intraday.json`, app 1.4+), replaced each run. |
|
|
120
|
+
| `query_health_data` | Natural-language convenience: *"average HRV last month"* routed to structured results. |
|
|
121
|
+
| `list_events` | Logged context events (medication, habit, visit, life, shift, episode, travel) with type/tag/date filters. |
|
|
122
|
+
| `get_profile` | Opted-in context fields (conditions, medications, goals, allergies, notes) plus `presentFields`. Absent fields were withheld, never "none". |
|
|
123
|
+
| `get_workouts` | Workouts by activity/date with pagination; includes heart rate, running dynamics, cycling power, intervals and `hasRoute` when exported. |
|
|
124
|
+
| `get_sleep_sessions` | Clustered sleep sessions attributed to the waking day; split nights returned as-is. |
|
|
125
|
+
| `get_cycle_context` | Day-in-cycle and coarse phase derived from logged period starts. Never predictive. |
|
|
126
|
+
| `correlate_metrics` | Pearson correlation between two metrics with a 0..3 day lag. Association, not causation, always stated. |
|
|
127
|
+
|
|
128
|
+
**Coverage:** 190 Apple Health metrics across activity, heart, HRV, mobility, respiratory, body, sleep, hearing, and nutrition, plus workouts. Data answers carry honest `coverage` blocks, and single-metric answers list logged events inside the window as `segmentBoundaries` so an average across a medication start or life change cannot masquerade as one regime.
|
|
129
|
+
|
|
130
|
+
### Data files
|
|
131
|
+
|
|
132
|
+
The daily cache (`.health-cache.json`) and workouts cache are joined by optional context files, all read-only and all optional: `health-events.json` (the logging surface), `health-profile.json` (opt-in context), `health-sessions.json` (sleep sessions), `health-cycles.json` (observed cycle starts), `health-days.json` (timezone change log). An absent file is reported as `available:false` with a note, never as "no data". Formats: [docs/SCHEMA-CONTRACTS.md](docs/SCHEMA-CONTRACTS.md).
|
|
133
|
+
|
|
134
|
+
Two patterns worth knowing: [query a parent's Apple Health from your own AI](docs/caregiver-setup.md) (consent-first, the parent's phone is the authority) and [logging data into Apple Health via Shortcuts](docs/shortcuts-write-bridge.md) (the server and app stay strictly read-only).
|
|
135
|
+
|
|
136
|
+
### MCP prompts
|
|
119
137
|
|
|
120
|
-
|
|
138
|
+
The server ships 22 prompts over `prompts/list` / `prompts/get`: daily brief, weekly review, doctor visit prep, what changed since my last visit, sleep quality and regularity, HRV trend, training and race week reviews, zone minutes, n-of-1 experiment, medication before/after, GLP-1 dose-step compare, sobriety milestone, shift block compare, travel-honest monthly review, cycle-aware trend read, caregiver check-in, glucose day summary, long-term activity narrative, data coverage audit, and a profile-aware context bootstrap. Every prompt leads with coverage honesty and never turns data into diagnosis.
|
|
139
|
+
|
|
140
|
+
### Demo mode
|
|
141
|
+
|
|
142
|
+
`node server.mjs --demo` (or `HEALTH_DEMO=1`) serves a deterministic synthetic dataset: ~15 metrics over 400 days, 30 workouts with intervals, 8 events, a profile, 60 sleep sessions, cycle starts and a timezone change, anchored to a fixed date with a seeded PRNG. Every answer carries `demo: true` and a `[SYNTHETIC DEMO DATA]` text prefix, so demo output can never pass for a real export.
|
|
143
|
+
|
|
144
|
+
### CLI
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
node server.mjs --doctor # diagnostics: files, sizes, schema, freshness, pairing
|
|
148
|
+
node server.mjs status --max-age 24 # exit 0 if data is fresh, 1 if stale (cron gate)
|
|
149
|
+
node server.mjs receive # standalone LAN receiver (binds 127.0.0.1 by default)
|
|
150
|
+
node server.mjs --help
|
|
151
|
+
```
|
|
121
152
|
|
|
122
153
|
---
|
|
123
154
|
|
|
@@ -234,7 +265,7 @@ stdio transport (newline-delimited JSON-RPC 2.0) — the universal MCP transport
|
|
|
234
265
|
|
|
235
266
|
## How it works
|
|
236
267
|
|
|
237
|
-
The iOS app reads Apple Health (read-only) and writes a compact `.health-cache.json` to the destination you choose. This server reads
|
|
268
|
+
The iOS app reads Apple Health (read-only) and writes a compact `.health-cache.json` (plus optional context files) to the destination you choose. This server reads those files and exposes the 14 tools above over MCP. No bridge, no Docker, no database: just files and stdio.
|
|
238
269
|
|
|
239
270
|
```
|
|
240
271
|
Apple Health → Health Export AI (iOS) → .health-cache.json → health-export-mcp → MCP client → you
|
package/demo.mjs
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// demo.mjs, the deterministic synthetic dataset behind --demo / HEALTH_DEMO=1.
|
|
2
|
+
//
|
|
3
|
+
// Purpose: let anyone exercise every tool with zero real data and zero iPhone. The dataset is
|
|
4
|
+
// SYNTHETIC and every answer the server gives in demo mode is watermarked (demo: true plus a
|
|
5
|
+
// [SYNTHETIC DEMO DATA] text prefix in server.mjs), so a screenshot or a pasted answer can never
|
|
6
|
+
// pass for a real export.
|
|
7
|
+
//
|
|
8
|
+
// Determinism rules, both load-bearing:
|
|
9
|
+
// 1. A seeded PRNG (mulberry32, fixed seed). Math.random would make every run a different
|
|
10
|
+
// "person", so a bug reproduced in demo mode could vanish on the next spawn.
|
|
11
|
+
// 2. NO Date.now anywhere in generation. The date range is anchored to a FIXED end day
|
|
12
|
+
// (2026-08-18), so demo answers are byte-identical across runs and across machines, which is
|
|
13
|
+
// what makes them assertable in tests and comparable in bug reports.
|
|
14
|
+
|
|
15
|
+
export const DEMO = process.argv.includes('--demo') || process.env.HEALTH_DEMO === '1';
|
|
16
|
+
|
|
17
|
+
// Fixed anchor: the newest synthetic day. Never derived from the clock (see rule 2 above).
|
|
18
|
+
export const DEMO_END = '2026-08-18';
|
|
19
|
+
const DEMO_END_UTC = Date.UTC(2026, 7, 18);
|
|
20
|
+
const DAYS = 400;
|
|
21
|
+
const WRITTEN_AT = '2026-08-18T21:00:00Z';
|
|
22
|
+
const SEED = 0x5EED1E;
|
|
23
|
+
|
|
24
|
+
// mulberry32: tiny, fast, good-enough 32-bit PRNG. Cryptographic quality is irrelevant here;
|
|
25
|
+
// reproducibility is the whole point.
|
|
26
|
+
function mulberry32(seed) {
|
|
27
|
+
let t = seed >>> 0;
|
|
28
|
+
return function () {
|
|
29
|
+
t += 0x6D2B79F5;
|
|
30
|
+
let x = t;
|
|
31
|
+
x = Math.imul(x ^ (x >>> 15), x | 1);
|
|
32
|
+
x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
|
|
33
|
+
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// back = how many days before DEMO_END. back 0 is the anchor day itself.
|
|
38
|
+
function day(back) {
|
|
39
|
+
return new Date(DEMO_END_UTC - back * 86400000).toISOString().slice(0, 10);
|
|
40
|
+
}
|
|
41
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
42
|
+
const r3 = (n) => Math.round(n * 1000) / 1000;
|
|
43
|
+
|
|
44
|
+
// The synthetic person has a few deliberate storylines so the segment/anchor/correlate tools have
|
|
45
|
+
// something honest to find: a medication start that lowers resting HR, a night-shift block and a
|
|
46
|
+
// migraine episode that dent HRV, and a trip that shortens sleep. Everything else is base + weekly
|
|
47
|
+
// rhythm + noise.
|
|
48
|
+
const MED_START_BACK = 200; // medication event lands on day(back=200)
|
|
49
|
+
const SHIFT_BACK = [90, 84]; // night shift block, inclusive day range
|
|
50
|
+
const EPISODE_BACK = [60, 58]; // migraine episode
|
|
51
|
+
const TRAVEL_BACK = [39, 32]; // trip; the tz change entry lands on the RETURN day (back=32)
|
|
52
|
+
|
|
53
|
+
const inBack = (back, [hi, lo]) => back <= hi && back >= lo;
|
|
54
|
+
|
|
55
|
+
function buildMetrics(rng) {
|
|
56
|
+
const m = {};
|
|
57
|
+
const put = (name, unit, cumulative, fn) => {
|
|
58
|
+
const daily = [];
|
|
59
|
+
for (let back = DAYS - 1; back >= 0; back--) {
|
|
60
|
+
const v = fn(back);
|
|
61
|
+
if (v == null) continue; // sparse metrics skip days, like real ones do
|
|
62
|
+
daily.push({ d: day(back), v });
|
|
63
|
+
}
|
|
64
|
+
m[name] = { unit, cumulative: !!cumulative, daily };
|
|
65
|
+
};
|
|
66
|
+
const week = (back) => Math.sin(((DAYS - back) % 7) / 7 * 2 * Math.PI);
|
|
67
|
+
|
|
68
|
+
put('step_count', 'count', true, (b) => Math.round(8500 + week(b) * 2200 + (rng() - 0.5) * 3000));
|
|
69
|
+
put('heart_rate', 'count/min', false, (b) => r2(68 + week(b) * 3 + (rng() - 0.5) * 6));
|
|
70
|
+
put('resting_heart_rate', 'count/min', false, (b) =>
|
|
71
|
+
r2((b <= MED_START_BACK ? 54 : 58) + (rng() - 0.5) * 3));
|
|
72
|
+
put('heart_rate_variability', 'ms', false, (b) => {
|
|
73
|
+
let base = 52 + week(b) * 4;
|
|
74
|
+
if (inBack(b, SHIFT_BACK) || inBack(b, EPISODE_BACK)) base -= 9;
|
|
75
|
+
return r2(base + (rng() - 0.5) * 10);
|
|
76
|
+
});
|
|
77
|
+
put('sleep_analysis', 'hr', false, (b) => {
|
|
78
|
+
let base = 7.4 + week(b) * 0.4;
|
|
79
|
+
if (inBack(b, TRAVEL_BACK)) base -= 1.1;
|
|
80
|
+
return r2(base + (rng() - 0.5) * 1.0);
|
|
81
|
+
});
|
|
82
|
+
put('weight_body_mass', 'kg', false, (b) => r2(81.5 - (DAYS - b) * 0.008 + (rng() - 0.5) * 0.6));
|
|
83
|
+
put('blood_oxygen_saturation', '%', false, () => r3(0.955 + rng() * 0.035));
|
|
84
|
+
put('active_energy', 'kcal', true, (b) => Math.round(520 + week(b) * 140 + (rng() - 0.5) * 200));
|
|
85
|
+
put('vo2_max', 'ml/kg*min', false, (b) => (b % 7 === 0 ? r2(44 + (DAYS - b) * 0.004 + (rng() - 0.5) * 0.8) : null));
|
|
86
|
+
put('respiratory_rate', 'count/min', false, () => r2(14 + (rng() - 0.5) * 1.6));
|
|
87
|
+
|
|
88
|
+
// Glucose plus the contract-7 derived metrics, present only on the "CGM period" (last 150 days),
|
|
89
|
+
// because a real cache only carries them on days with at least 24 readings.
|
|
90
|
+
const CGM_DAYS = 150;
|
|
91
|
+
const glucoseMean = new Map();
|
|
92
|
+
put('blood_glucose', 'mg/dL', false, (b) => {
|
|
93
|
+
if (b >= CGM_DAYS) return null;
|
|
94
|
+
const v = r2(112 + week(b) * 6 + (rng() - 0.5) * 14);
|
|
95
|
+
glucoseMean.set(b, v);
|
|
96
|
+
return v;
|
|
97
|
+
});
|
|
98
|
+
const gm = (b) => glucoseMean.get(b);
|
|
99
|
+
put('glucose_time_in_range_pct', '%', false, (b) => (b >= CGM_DAYS ? null : r3(0.86 - Math.max(0, gm(b) - 100) * 0.004 - rng() * 0.04)));
|
|
100
|
+
put('glucose_time_below_range_pct', '%', false, (b) => (b >= CGM_DAYS ? null : r3(rng() * 0.03)));
|
|
101
|
+
put('glucose_time_above_range_pct', '%', false, (b) => {
|
|
102
|
+
if (b >= CGM_DAYS) return null;
|
|
103
|
+
const tir = m.glucose_time_in_range_pct.daily.find((p) => p.d === day(b))?.v ?? 0.8;
|
|
104
|
+
const below = m.glucose_time_below_range_pct.daily.find((p) => p.d === day(b))?.v ?? 0.01;
|
|
105
|
+
return r3(Math.max(0, 1 - tir - below));
|
|
106
|
+
});
|
|
107
|
+
put('glucose_cv_pct', '%', false, (b) => (b >= CGM_DAYS ? null : r3(0.24 + rng() * 0.08)));
|
|
108
|
+
// GMI per Bergenstal 2018: 3.31 + 0.02392 x mean mg/dL, stored /100 like every % metric.
|
|
109
|
+
put('glucose_gmi_pct', '%', false, (b) => (b >= CGM_DAYS ? null : r3((3.31 + 0.02392 * gm(b)) / 100)));
|
|
110
|
+
|
|
111
|
+
put('exercise_time', 'min', true, (b) => Math.round(Math.max(0, 32 + week(b) * 18 + (rng() - 0.5) * 24)));
|
|
112
|
+
put('walking_running_distance', 'km', true, (b) => r2(6.2 + week(b) * 2 + (rng() - 0.5) * 2.4));
|
|
113
|
+
return m;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildWorkouts(rng) {
|
|
117
|
+
const workouts = [];
|
|
118
|
+
for (let n = 0; n < 30; n++) {
|
|
119
|
+
const back = 393 - n * 13; // oldest first, newest ~1 week ago
|
|
120
|
+
const d = day(back);
|
|
121
|
+
const kinds = [
|
|
122
|
+
{ name: 'Running', activityType: 37 },
|
|
123
|
+
{ name: 'Cycling', activityType: 13 },
|
|
124
|
+
{ name: 'Walking', activityType: 52 },
|
|
125
|
+
];
|
|
126
|
+
const k = kinds[n % 3];
|
|
127
|
+
const durationMin = Math.round(40 + rng() * 35);
|
|
128
|
+
const start = `${d}T07:30:00+01:00`;
|
|
129
|
+
const endMs = Date.parse(start) + durationMin * 60000;
|
|
130
|
+
// Render the end instant on the same +01:00 wall clock as the start: shift by the offset, then
|
|
131
|
+
// relabel the Z. Rendering the raw instant with a +01:00 suffix would silently shift end times
|
|
132
|
+
// an hour early.
|
|
133
|
+
const end = new Date(endMs + 3600000).toISOString().replace(/\.\d{3}Z$/, '+01:00');
|
|
134
|
+
const w = {
|
|
135
|
+
id: `demo-w-${String(n + 1).padStart(2, '0')}`,
|
|
136
|
+
name: k.name,
|
|
137
|
+
activityType: k.activityType,
|
|
138
|
+
start,
|
|
139
|
+
end,
|
|
140
|
+
duration: durationMin * 60,
|
|
141
|
+
activeEnergyBurned: Math.round(durationMin * (5 + rng() * 6)),
|
|
142
|
+
distanceMeters: Math.round(durationMin * (k.name === 'Cycling' ? 420 : 160) * (0.9 + rng() * 0.2)),
|
|
143
|
+
distanceSource: 'watch',
|
|
144
|
+
avgHeartRate: Math.round(128 + rng() * 22),
|
|
145
|
+
maxHeartRate: Math.round(160 + rng() * 20),
|
|
146
|
+
elevationAscendedMeters: Math.round(rng() * 220),
|
|
147
|
+
};
|
|
148
|
+
if (k.name === 'Running') {
|
|
149
|
+
w.runningPowerAvgWatts = Math.round(255 + rng() * 40);
|
|
150
|
+
w.strideLengthAvgMeters = r2(1.02 + rng() * 0.18);
|
|
151
|
+
w.verticalOscillationAvgCm = r2(7.6 + rng() * 1.8);
|
|
152
|
+
w.groundContactTimeAvgMs = Math.round(240 + rng() * 40);
|
|
153
|
+
w.hasRoute = true;
|
|
154
|
+
const lap = Math.floor((durationMin * 60) / 3);
|
|
155
|
+
w.intervals = [0, 1, 2].map((i) => ({
|
|
156
|
+
start: new Date(Date.parse(start) + i * lap * 1000).toISOString(),
|
|
157
|
+
end: new Date(Date.parse(start) + (i + 1) * lap * 1000).toISOString(),
|
|
158
|
+
duration: lap,
|
|
159
|
+
kind: 'lap',
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
if (k.name === 'Cycling') {
|
|
163
|
+
w.cyclingPowerAvgWatts = Math.round(180 + rng() * 60);
|
|
164
|
+
w.cadenceAvg = Math.round(82 + rng() * 12);
|
|
165
|
+
w.hasRoute = true;
|
|
166
|
+
}
|
|
167
|
+
workouts.push(w);
|
|
168
|
+
}
|
|
169
|
+
return workouts;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function buildEvents() {
|
|
173
|
+
const events = [
|
|
174
|
+
{ id: 'demo-evt-med-1', date: day(MED_START_BACK), type: 'medication', title: 'Started propranolol 40mg', tags: ['propranolol'] },
|
|
175
|
+
{ id: 'demo-evt-visit-1', date: day(180), type: 'visit', title: 'GP review', tags: ['gp'] },
|
|
176
|
+
{ id: 'demo-evt-habit-1', date: day(150), type: 'habit', title: 'Stopped caffeine after midday', tags: ['caffeine'] },
|
|
177
|
+
{ id: 'demo-evt-life-1', date: day(120), type: 'life', title: 'Moved house' },
|
|
178
|
+
{ id: 'demo-evt-shift-1', date: day(SHIFT_BACK[0]), endDate: day(SHIFT_BACK[1]), type: 'shift', title: 'Night shift block', tags: ['nights'] },
|
|
179
|
+
{ id: 'demo-evt-episode-1', date: day(EPISODE_BACK[0]), endDate: day(EPISODE_BACK[1]), type: 'episode', title: 'Migraine episode', tags: ['migraine'] },
|
|
180
|
+
{ id: 'demo-evt-travel-1', date: day(TRAVEL_BACK[0]), endDate: day(TRAVEL_BACK[1]), type: 'travel', title: 'Trip to New York', tags: ['travel'] },
|
|
181
|
+
{ id: 'demo-evt-visit-2', date: day(40), type: 'visit', title: 'GP follow up', tags: ['gp'] },
|
|
182
|
+
].sort((a, b) => a.date.localeCompare(b.date));
|
|
183
|
+
return { schema: 1, app: '1.5', writtenAt: WRITTEN_AT, events };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function buildSessions(rng) {
|
|
187
|
+
const sessions = [];
|
|
188
|
+
for (let back = 58; back >= 0; back--) {
|
|
189
|
+
const hours = r2(inBack(back, TRAVEL_BACK) ? 5.4 + rng() * 0.8 : 6.9 + rng() * 1.4);
|
|
190
|
+
const startH = 22 + Math.floor(rng() * 2);
|
|
191
|
+
const startMin = Math.floor(rng() * 60);
|
|
192
|
+
const endH = Math.floor((startH + hours) % 24);
|
|
193
|
+
sessions.push({
|
|
194
|
+
start: `${day(back + 1)}T${String(startH).padStart(2, '0')}:${String(startMin).padStart(2, '0')}:00+01:00`,
|
|
195
|
+
end: `${day(back)}T${String(endH).padStart(2, '0')}:${String(startMin).padStart(2, '0')}:00+01:00`,
|
|
196
|
+
day: day(back),
|
|
197
|
+
hours,
|
|
198
|
+
stages: { core: r2(hours * 0.55), deep: r2(hours * 0.16), rem: r2(hours * 0.24), awake: r2(hours * 0.05) },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// One split night: the same waking day carries a second, short session (a 90 minute early wake
|
|
202
|
+
// and a return to bed). Split nights are the exact case get_sleep_sessions must return as-is.
|
|
203
|
+
sessions.push({
|
|
204
|
+
start: `${day(3)}T05:40:00+01:00`,
|
|
205
|
+
end: `${day(3)}T07:10:00+01:00`,
|
|
206
|
+
day: day(3),
|
|
207
|
+
hours: 1.5,
|
|
208
|
+
stages: { core: 1.1, deep: 0.1, rem: 0.25, awake: 0.05 },
|
|
209
|
+
});
|
|
210
|
+
sessions.sort((a, b) => a.start.localeCompare(b.start));
|
|
211
|
+
return { schema: 1, writtenAt: WRITTEN_AT, sessions };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function buildCycles() {
|
|
215
|
+
return { schema: 1, writtenAt: WRITTEN_AT, cycleStarts: [day(77), day(49), day(20)] };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function buildDaysFile() {
|
|
219
|
+
return {
|
|
220
|
+
schema: 1,
|
|
221
|
+
writtenAt: WRITTEN_AT,
|
|
222
|
+
currentTz: 'Europe/London',
|
|
223
|
+
// One change entry: the return from the synthetic New York trip. The day a change lands on is
|
|
224
|
+
// by definition not 24 hours long, which is what excludeTravelDays keys off.
|
|
225
|
+
changes: [{ from: day(TRAVEL_BACK[1]), tz: 'Europe/London', utcOffsetMin: 60 }],
|
|
226
|
+
note: 'Timezone history is recorded from 2026-07-17 onward. Days before the first change entry were bucketed in the timezone current at export time.',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function buildProfile() {
|
|
231
|
+
return {
|
|
232
|
+
schema: 1,
|
|
233
|
+
writtenAt: WRITTEN_AT,
|
|
234
|
+
fields: {
|
|
235
|
+
conditions: ['migraine'],
|
|
236
|
+
medications: ['propranolol 40mg'],
|
|
237
|
+
goals: ['average 7.5 hours of sleep', '10k steps most days'],
|
|
238
|
+
notes: 'Training for an autumn half marathon.',
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Built once and cached: generation is cheap but the memoized loaders upstream key on object
|
|
244
|
+
// identity, so handing out a fresh object per call would defeat their caching.
|
|
245
|
+
let _demo = null;
|
|
246
|
+
export function demoData() {
|
|
247
|
+
if (_demo) return _demo;
|
|
248
|
+
const rng = mulberry32(SEED);
|
|
249
|
+
_demo = {
|
|
250
|
+
metrics: buildMetrics(rng),
|
|
251
|
+
workouts: buildWorkouts(rng),
|
|
252
|
+
events: buildEvents(),
|
|
253
|
+
profile: buildProfile(),
|
|
254
|
+
sessions: buildSessions(rng),
|
|
255
|
+
cycles: buildCycles(),
|
|
256
|
+
days: buildDaysFile(),
|
|
257
|
+
};
|
|
258
|
+
return _demo;
|
|
259
|
+
}
|
package/events.mjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// events.mjs, readers for the context files added by the 2026-08 feature wave
|
|
2
|
+
// (docs/SCHEMA-CONTRACTS.md is the authoritative contract):
|
|
3
|
+
//
|
|
4
|
+
// health-events.json the one logging surface (medication, habit, visit, life, shift, episode, travel, other)
|
|
5
|
+
// health-profile.json per-field opt-in context the user chose to share
|
|
6
|
+
// health-sessions.json clustered sleep sessions, attributed to the WAKING day
|
|
7
|
+
// health-cycles.json user-logged menstrual cycle starts (observed only, never predictive)
|
|
8
|
+
// health-days.json the timezone change log (the travel-day source)
|
|
9
|
+
//
|
|
10
|
+
// Every file is OPTIONAL. An absent file answers {available:false, note}, never "no data": a
|
|
11
|
+
// consumer must never read a missing file as "nothing happened". All reads share healthstore's
|
|
12
|
+
// (mtime, size) memo and sit behind the same pairing gate as the cache, because these files carry
|
|
13
|
+
// exactly the kind of context (medication, cycles) that the pairing gate exists to protect.
|
|
14
|
+
//
|
|
15
|
+
// This module and healthstore.mjs import each other. The cycle is deliberate and safe: both sides
|
|
16
|
+
// reference the other's bindings only INSIDE functions, never during module evaluation, so ESM's
|
|
17
|
+
// in-progress module record is fully initialized by the first call. Moving the shared low-level
|
|
18
|
+
// helpers out would mean relocating DATA_DIR resolution, which healthstore.test.mjs pins by
|
|
19
|
+
// setting HEALTH_DATA_DIR before import.
|
|
20
|
+
|
|
21
|
+
import { readJSONCached, assertUnlocked, assertDay, dataPath } from './healthstore.mjs';
|
|
22
|
+
import { DEMO, demoData } from './demo.mjs';
|
|
23
|
+
|
|
24
|
+
export const KNOWN_EVENT_TYPES = ['medication', 'habit', 'visit', 'life', 'shift', 'episode', 'travel', 'other'];
|
|
25
|
+
// Range events describe a STATE that covers days (a shift block, a trip, an episode). They are not
|
|
26
|
+
// segment boundaries: an average across a state is an average of that state, not a before/after mix.
|
|
27
|
+
const RANGE_TYPES = new Set(['shift', 'travel', 'episode']);
|
|
28
|
+
|
|
29
|
+
const EVENTS_FILE = 'health-events.json';
|
|
30
|
+
const PROFILE_FILE = 'health-profile.json';
|
|
31
|
+
const SESSIONS_FILE = 'health-sessions.json';
|
|
32
|
+
const CYCLES_FILE = 'health-cycles.json';
|
|
33
|
+
const DAYS_FILE = 'health-days.json';
|
|
34
|
+
|
|
35
|
+
// YYYY-MM-DD day arithmetic without timezones: date-only strings parse as UTC midnight, so adding
|
|
36
|
+
// whole days can never cross a DST boundary.
|
|
37
|
+
export function addDays(d, n) {
|
|
38
|
+
return new Date(Date.parse(d) + n * 86400000).toISOString().slice(0, 10);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function absent(file, what) {
|
|
42
|
+
return {
|
|
43
|
+
available: false,
|
|
44
|
+
note: `No ${file} found. ${what} Absence of the file means it was never exported (or the app predates it), not that nothing happened.`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- raw loaders (memoized via healthstore's readJSONCached) ---------------
|
|
49
|
+
|
|
50
|
+
/** The parsed events file, or null when absent/corrupt. Callers treat null as "unavailable". */
|
|
51
|
+
export function loadEventsFile() {
|
|
52
|
+
if (DEMO) return demoData().events;
|
|
53
|
+
const raw = readJSONCached(dataPath(EVENTS_FILE), null);
|
|
54
|
+
return raw && Array.isArray(raw.events) ? raw : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function loadDaysFile() {
|
|
58
|
+
if (DEMO) return demoData().days;
|
|
59
|
+
const raw = readJSONCached(dataPath(DAYS_FILE), null);
|
|
60
|
+
return raw && Array.isArray(raw.changes) ? raw : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function loadProfileFile() {
|
|
64
|
+
if (DEMO) return demoData().profile;
|
|
65
|
+
const raw = readJSONCached(dataPath(PROFILE_FILE), null);
|
|
66
|
+
return raw && raw.fields && typeof raw.fields === 'object' ? raw : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadSessionsFile() {
|
|
70
|
+
if (DEMO) return demoData().sessions;
|
|
71
|
+
const raw = readJSONCached(dataPath(SESSIONS_FILE), null);
|
|
72
|
+
return raw && Array.isArray(raw.sessions) ? raw : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function loadCyclesFile() {
|
|
76
|
+
if (DEMO) return demoData().cycles;
|
|
77
|
+
const raw = readJSONCached(dataPath(CYCLES_FILE), null);
|
|
78
|
+
return raw && Array.isArray(raw.cycleStarts) ? raw : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- derived helpers shared with healthstore's segment honesty -------------
|
|
82
|
+
|
|
83
|
+
/** Every local day an event covers: endDate ranges inclusive, point events their single day. */
|
|
84
|
+
export function eventDays(e) {
|
|
85
|
+
if (!e?.date) return [];
|
|
86
|
+
if (!e.endDate || e.endDate < e.date) return [e.date];
|
|
87
|
+
const days = [];
|
|
88
|
+
for (let d = e.date; d <= e.endDate; d = addDays(d, 1)) {
|
|
89
|
+
days.push(d);
|
|
90
|
+
if (days.length > 3700) break; // a corrupt endDate must not spin a ten-year loop
|
|
91
|
+
}
|
|
92
|
+
return days;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The set of days covered by events matching {eventType, eventTag}. Null when the file is absent,
|
|
96
|
+
* which callers must surface honestly rather than treating as an empty match. */
|
|
97
|
+
export function matchingEventDaySet({ eventType, eventTag } = {}) {
|
|
98
|
+
const file = loadEventsFile();
|
|
99
|
+
if (!file) return null;
|
|
100
|
+
const days = new Set();
|
|
101
|
+
for (const e of file.events) {
|
|
102
|
+
if (eventType && e.type !== eventType) continue;
|
|
103
|
+
if (eventTag && !(Array.isArray(e.tags) && e.tags.includes(eventTag))) continue;
|
|
104
|
+
for (const d of eventDays(e)) days.add(d);
|
|
105
|
+
}
|
|
106
|
+
return days;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Days on which a timezone change landed (day length was not 24h). Null when no log exists. */
|
|
110
|
+
export function travelDaySet() {
|
|
111
|
+
const file = loadDaysFile();
|
|
112
|
+
if (!file) return null;
|
|
113
|
+
const days = new Set();
|
|
114
|
+
for (const c of file.changes) {
|
|
115
|
+
if (typeof c?.from === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(c.from)) days.add(c.from);
|
|
116
|
+
}
|
|
117
|
+
return days;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Segment boundaries inside [start, end]: POINT events (no endDate) of the boundary types.
|
|
121
|
+
* Range events (shift, travel, episode) are states, not boundaries, and are excluded; so are
|
|
122
|
+
* visits, which observe rather than change anything. Point events of a type this server does not
|
|
123
|
+
* know are INCLUDED: an unknown type written by a newer app is more likely an intervention we
|
|
124
|
+
* cannot classify than noise, and silently ignoring it would hide a real segment. */
|
|
125
|
+
export function boundariesIn(start, end) {
|
|
126
|
+
const file = loadEventsFile();
|
|
127
|
+
if (!file) return null;
|
|
128
|
+
const boundaryType = (t) => t === 'medication' || t === 'habit' || t === 'life' || t === 'other'
|
|
129
|
+
|| !KNOWN_EVENT_TYPES.includes(t);
|
|
130
|
+
return file.events
|
|
131
|
+
.filter((e) => e?.date && !e.endDate && boundaryType(e.type)
|
|
132
|
+
&& (!start || e.date >= start) && (!end || e.date <= end))
|
|
133
|
+
.map((e) => ({ date: e.date, type: e.type, title: e.title ?? null }))
|
|
134
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const SEGMENT_NOTE = (n) =>
|
|
138
|
+
`The window spans ${n} logged event(s). An average across a boundary can be a number that was true on no actual day; consider compare_periods anchored at the event date.`;
|
|
139
|
+
|
|
140
|
+
/** Find one event by id. Returns {available:false} | {available:true, event|null}. */
|
|
141
|
+
export function findEvent(id) {
|
|
142
|
+
const file = loadEventsFile();
|
|
143
|
+
if (!file) return { available: false, event: null };
|
|
144
|
+
return { available: true, event: file.events.find((e) => e?.id === id) ?? null };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- tool-level answers ----------------------------------------------------
|
|
148
|
+
|
|
149
|
+
export async function listEvents({ type, tag, start, end } = {}) {
|
|
150
|
+
assertUnlocked();
|
|
151
|
+
start = assertDay(start, 'start');
|
|
152
|
+
end = assertDay(end, 'end');
|
|
153
|
+
if (start && end && start > end) throw new Error(`start (${start}) is after end (${end})`);
|
|
154
|
+
const file = loadEventsFile();
|
|
155
|
+
if (!file) {
|
|
156
|
+
return absent(EVENTS_FILE, 'Events appear when the iOS app (1.5 or later) logs one (medication start, habit change, visit, shift block, trip) and exports.');
|
|
157
|
+
}
|
|
158
|
+
// Unknown types pass through unharmed: no allowlist here. A type filter matches the stored
|
|
159
|
+
// string exactly, so a newer app's types remain reachable by name.
|
|
160
|
+
let events = file.events.filter((e) => e && typeof e.date === 'string');
|
|
161
|
+
if (type) events = events.filter((e) => e.type === type);
|
|
162
|
+
if (tag) events = events.filter((e) => Array.isArray(e.tags) && e.tags.includes(tag));
|
|
163
|
+
// A range event is "in" the window when it OVERLAPS it, matching how its days are counted.
|
|
164
|
+
if (start || end) {
|
|
165
|
+
events = events.filter((e) => {
|
|
166
|
+
const last = e.endDate && e.endDate >= e.date ? e.endDate : e.date;
|
|
167
|
+
return (!start || last >= start) && (!end || e.date <= end);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
events = [...events].sort((a, b) => a.date.localeCompare(b.date) || String(a.id).localeCompare(String(b.id)));
|
|
171
|
+
return {
|
|
172
|
+
available: true,
|
|
173
|
+
count: events.length,
|
|
174
|
+
knownTypes: KNOWN_EVENT_TYPES,
|
|
175
|
+
events,
|
|
176
|
+
...(events.length === 0 && {
|
|
177
|
+
note: 'No events matched. The file is present; nothing matching this filter has been logged.',
|
|
178
|
+
}),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function getProfile() {
|
|
183
|
+
assertUnlocked();
|
|
184
|
+
const file = loadProfileFile();
|
|
185
|
+
const withheldNote =
|
|
186
|
+
'Fields are per-field opt-in. An absent field was withheld by the user or never enabled; absence must not be read as "none". Age, sex, height and weight are HealthKit metrics in the daily cache, not profile fields.';
|
|
187
|
+
if (!file) {
|
|
188
|
+
return absent(PROFILE_FILE, 'The profile appears when the user opts in to sharing context fields in the iOS app (1.5 or later) and exports. ' + withheldNote);
|
|
189
|
+
}
|
|
190
|
+
const presentFields = Object.keys(file.fields);
|
|
191
|
+
return {
|
|
192
|
+
available: true,
|
|
193
|
+
presentFields,
|
|
194
|
+
fields: file.fields,
|
|
195
|
+
writtenAt: file.writtenAt ?? null,
|
|
196
|
+
note: withheldNote,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function getSleepSessions({ start, end, day } = {}) {
|
|
201
|
+
assertUnlocked();
|
|
202
|
+
start = assertDay(start, 'start');
|
|
203
|
+
end = assertDay(end, 'end');
|
|
204
|
+
day = assertDay(day, 'day');
|
|
205
|
+
if (start && end && start > end) throw new Error(`start (${start}) is after end (${end})`);
|
|
206
|
+
const file = loadSessionsFile();
|
|
207
|
+
if (!file) {
|
|
208
|
+
return absent(SESSIONS_FILE, 'Sleep sessions appear when the iOS app (1.5 or later) exports its clustered sessions.');
|
|
209
|
+
}
|
|
210
|
+
let sessions = file.sessions.filter((s) => s && typeof s.day === 'string');
|
|
211
|
+
if (day) sessions = sessions.filter((s) => s.day === day);
|
|
212
|
+
if (start) sessions = sessions.filter((s) => s.day >= start);
|
|
213
|
+
if (end) sessions = sessions.filter((s) => s.day <= end);
|
|
214
|
+
sessions = [...sessions].sort((a, b) => String(a.start).localeCompare(String(b.start)));
|
|
215
|
+
return {
|
|
216
|
+
available: true,
|
|
217
|
+
count: sessions.length,
|
|
218
|
+
sessions,
|
|
219
|
+
note: 'Sessions are attributed to the WAKING day: a night from 23:00 on the 18th to 06:50 on the 19th belongs to day 2026-08-19, matching the daily cache\'s sleep_analysis attribution. Multiple sessions on one day are a split night, returned as-is; union the hours, never sum overlapping sessions.',
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---- cycle context ---------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
const CYCLE_BASIS = 'Derived from logged periods, not predictive. Phase labels are coarse: follicular from day 1 to the observed midpoint of that cycle (day 14 for the ongoing cycle, whose length is not yet observed), luteal for the remainder. No ovulation estimate is made.';
|
|
226
|
+
|
|
227
|
+
function cycleDayContext(date, starts) {
|
|
228
|
+
// starts is ascending. Find the last start <= date.
|
|
229
|
+
let idx = -1;
|
|
230
|
+
for (let i = 0; i < starts.length; i++) if (starts[i] <= date) idx = i;
|
|
231
|
+
if (idx < 0) return null;
|
|
232
|
+
const start = starts[idx];
|
|
233
|
+
const next = starts[idx + 1]; // undefined for the ongoing cycle
|
|
234
|
+
const dayInCycle = Math.round((Date.parse(date) - Date.parse(start)) / 86400000) + 1;
|
|
235
|
+
const observedLength = next ? Math.round((Date.parse(next) - Date.parse(start)) / 86400000) : null;
|
|
236
|
+
const midpoint = observedLength ? Math.floor(observedLength / 2) : 14;
|
|
237
|
+
return {
|
|
238
|
+
date,
|
|
239
|
+
cycleStart: start,
|
|
240
|
+
dayInCycle,
|
|
241
|
+
phase: dayInCycle <= midpoint ? 'follicular' : 'luteal',
|
|
242
|
+
observedCycleLengthDays: observedLength,
|
|
243
|
+
ongoing: !next,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function getCycleContext({ date } = {}) {
|
|
248
|
+
assertUnlocked();
|
|
249
|
+
date = assertDay(date, 'date');
|
|
250
|
+
const file = loadCyclesFile();
|
|
251
|
+
// The derived-not-predictive basis rides on EVERY answer, the unavailable one included: an
|
|
252
|
+
// agent that only ever sees the absent branch must still learn what this surface is and is not.
|
|
253
|
+
const starts = file ? [...file.cycleStarts].filter((d) => typeof d === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d)).sort() : [];
|
|
254
|
+
if (!starts.length) {
|
|
255
|
+
return {
|
|
256
|
+
...absent(CYCLES_FILE, 'Cycle context appears when menstrual flow is logged in Apple Health and the iOS app (1.5 or later) exports the observed cycle starts.'),
|
|
257
|
+
basis: CYCLE_BASIS,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (date) {
|
|
261
|
+
const ctx = cycleDayContext(date, starts);
|
|
262
|
+
if (!ctx) {
|
|
263
|
+
return {
|
|
264
|
+
available: true,
|
|
265
|
+
date,
|
|
266
|
+
context: null,
|
|
267
|
+
cycleStarts: starts,
|
|
268
|
+
note: `${date} is before the first logged cycle start (${starts[0]}), so no day-in-cycle can be derived for it. ` + CYCLE_BASIS,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
return { available: true, date, context: ctx, cycleStarts: starts, basis: CYCLE_BASIS };
|
|
272
|
+
}
|
|
273
|
+
// Default: per-day context for the last cycle, from its start through the current day (in demo
|
|
274
|
+
// mode the fixed synthetic anchor, so demo answers stay deterministic).
|
|
275
|
+
const lastStart = starts[starts.length - 1];
|
|
276
|
+
const today = DEMO ? '2026-08-18' : new Date().toISOString().slice(0, 10);
|
|
277
|
+
const until = today >= lastStart ? today : lastStart;
|
|
278
|
+
const days = [];
|
|
279
|
+
for (let d = lastStart; d <= until && days.length < 120; d = addDays(d, 1)) {
|
|
280
|
+
days.push(cycleDayContext(d, starts));
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
available: true,
|
|
284
|
+
cycleStarts: starts,
|
|
285
|
+
lastCycle: { start: lastStart, days },
|
|
286
|
+
basis: CYCLE_BASIS,
|
|
287
|
+
};
|
|
288
|
+
}
|
package/glama.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
],
|
|
6
6
|
"name": "health-export-mcp",
|
|
7
7
|
"description": "Query 190 Apple Health/HealthKit metrics from any MCP AI agent — zero-dependency, local-first, read-only. The open-source server for the Health Export AI app.",
|
|
8
|
-
"longDescription": "An open-source, zero-dependency MCP server that turns your Apple Health export into
|
|
8
|
+
"longDescription": "An open-source, zero-dependency MCP server that turns your Apple Health export into 14 query tools any AI agent can call in natural language. Tracks HRV, sleep, resting heart rate, steps, workouts, VO₂ max, and 180+ more metrics — all local-first with no developer server in the path.",
|
|
9
9
|
"categories": [
|
|
10
10
|
"healthcare",
|
|
11
11
|
"lifestyle",
|
package/health-export.mcpb
CHANGED
|
Binary file
|