health-export-mcp 1.2.2

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/glama.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "$schema": "https://glama.ai/mcp/schemas/server.json",
3
+ "maintainers": [
4
+ "PhilipAD"
5
+ ],
6
+ "name": "health-export-mcp",
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 7 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
+ "categories": [
10
+ "healthcare",
11
+ "lifestyle",
12
+ "data-analysis"
13
+ ],
14
+ "tags": [
15
+ "apple-health",
16
+ "healthkit",
17
+ "health-data",
18
+ "quantified-self",
19
+ "hrv",
20
+ "sleep-tracking",
21
+ "fitness"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/PhilipAD/health-export-mcp.git"
26
+ },
27
+ "homepage": "https://www.healthexport.dev",
28
+ "license": "MIT",
29
+ "installCommand": "git clone https://github.com/PhilipAD/health-export-mcp.git && cd health-export-mcp && node apply-mcp-config.mjs",
30
+ "runtime": {
31
+ "type": "stdio",
32
+ "command": "node",
33
+ "args": ["server.mjs"]
34
+ },
35
+ "requirements": {
36
+ "node": ">=18"
37
+ },
38
+ "packages": [
39
+ {
40
+ "registry": "npm",
41
+ "name": "health-export-mcp",
42
+ "command": "npx",
43
+ "args": ["health-export-mcp"]
44
+ }
45
+ ]
46
+ }
Binary file
@@ -0,0 +1,613 @@
1
+ // healthstore.mjs — reads the health data the iOS app exported to iCloud Drive.
2
+ //
3
+ // The Health Export AI iOS app writes the cache directly into its iCloud Drive
4
+ // container; this server reads it locally. No receiver, no Docker, no HTTP bridge.
5
+ //
6
+ // HEALTH_DATA_DIR=/path → read <dir>/.health-cache.json (+ .health-workouts-cache.json)
7
+ // (default on macOS: ~/Library/Mobile Documents/iCloud~ai~healthexport~app/Documents)
8
+ //
9
+ // Cache shape (written by the iOS app's ICloudExporter / HealthCache):
10
+ // { "<metric>": { unit, cumulative, daily: [ { d:"YYYY-MM-DD", v:Number } ] }, ... }
11
+ //
12
+ // Conventions agents should know:
13
+ // • `d` is the device's LOCAL calendar day (not UTC) — the same day boundary the iOS dashboard
14
+ // and the HAE webhook envelope use, so all three surfaces agree for "the same day".
15
+ // • Percentage metrics (unit "%", e.g. blood_oxygen_saturation, body_fat_percentage) are stored
16
+ // as 0–1 FRACTIONS (0.97 = 97%). The iOS dashboard multiplies by 100 for display only; values
17
+ // served here are the raw fraction — don't ×100 again.
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import crypto from 'node:crypto';
22
+ import os from 'node:os';
23
+
24
+ // Expand a leading ~ (Node, unlike the shell, does not) so manual configs resolve correctly.
25
+ const RAW_DIR = process.env.HEALTH_DATA_DIR || '.';
26
+ const DATA_DIR = (RAW_DIR === '~' || RAW_DIR.startsWith('~/'))
27
+ ? path.join(os.homedir(), RAW_DIR.slice(1))
28
+ : RAW_DIR;
29
+
30
+ // Guard against reading something pathological, but sized for a real full-history cache.
31
+ // Measured on this cache's exact shape (189 metrics x 3,653 days): 22.0 MB compact. The old
32
+ // 64 MB ceiling was within reach of a long history once workouts were included -- and worse, the
33
+ // old code SILENTLY returned the fallback when it was exceeded, so the server reported "no health
34
+ // data" and "unknown metric" for a user whose file was simply large. Oversize is now a thrown,
35
+ // explainable error; only genuinely absent or corrupt files fall back.
36
+ const MAX_BYTES = Number(process.env.HEALTH_MAX_CACHE_BYTES || 512 * 1024 * 1024);
37
+
38
+ class CacheTooLarge extends Error {}
39
+
40
+ function readJSON(file, fallback) {
41
+ let st;
42
+ try {
43
+ st = fs.statSync(file);
44
+ } catch { return fallback; } // absent -> a normal first run
45
+ if (!st.isFile()) return fallback;
46
+ if (st.size > MAX_BYTES) {
47
+ throw new CacheTooLarge(
48
+ `health cache is ${(st.size / 1048576).toFixed(1)} MB, above the ${(MAX_BYTES / 1048576).toFixed(0)} MB read limit. ` +
49
+ `Raise HEALTH_MAX_CACHE_BYTES if this is expected.`);
50
+ }
51
+ try {
52
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
53
+ } catch { return fallback; } // corrupt -> fall back rather than crash the server
54
+ }
55
+
56
+ // Pairing gate: if the iOS app wrote `.health-pair.json`, the configured PAIRING_SECRET must
57
+ // hash (sha256) to the stored value before any data is served. No pair file ⇒ open (the data is
58
+ // already local-only). So a copied bundle / another iCloud user can't read without the secret.
59
+ export function pairing() {
60
+ const pair = readJSON(path.join(DATA_DIR, '.health-pair.json'), null);
61
+ if (!pair || !pair.hash) return { required: false, ok: true };
62
+ const secret = process.env.PAIRING_SECRET || '';
63
+ const h = crypto.createHash('sha256').update(secret).digest('hex');
64
+ let ok = false;
65
+ if (secret.length > 0 && typeof pair.hash === 'string' && pair.hash.length === h.length) {
66
+ ok = crypto.timingSafeEqual(Buffer.from(h), Buffer.from(pair.hash)); // constant-time
67
+ }
68
+ return { required: true, ok };
69
+ }
70
+
71
+ // Throw a clear "locked" error from the data tools when pairing is required but not satisfied,
72
+ // so an agent sees the real reason instead of a misleading "unknown metric".
73
+ function assertUnlocked() {
74
+ const p = pairing();
75
+ if (p.required && !p.ok) throw new Error('Locked: set PAIRING_SECRET to the code shown in the iOS app (Settings → Agent pairing).');
76
+ }
77
+
78
+ // Parsing the cache is the single most expensive thing this server does, and every tool call used
79
+ // to redo it from scratch. MEASURED on a full 10-year cache (189 metrics x 3,653 days, 22 MB): 13.5
80
+ // seconds per call. Memoized on (mtime, size) so a re-read only happens when the iOS app has
81
+ // actually rewritten the file — which is exactly the signal we want, since the app writes
82
+ // atomically via a rename.
83
+ const _memo = new Map();
84
+ function readJSONCached(file, fallback) {
85
+ let st;
86
+ try { st = fs.statSync(file); } catch { _memo.delete(file); return fallback; }
87
+ const stamp = `${st.mtimeMs}:${st.size}`;
88
+ const hit = _memo.get(file);
89
+ if (hit && hit.stamp === stamp) return hit.value;
90
+ const value = readJSON(file, fallback);
91
+ _memo.set(file, { stamp, value });
92
+ return value;
93
+ }
94
+
95
+ /** Highest cache schema this server understands. The app writes `_meta.schema`; anything with a
96
+ * higher MAJOR is a newer app paired with an older `.mcpb`, which we refuse loudly rather than
97
+ * silently mis-reading. Absent `_meta` means a pre-1.2 cache, which is schema 1 by definition. */
98
+ export const SUPPORTED_SCHEMA = 1;
99
+
100
+ export async function loadMetrics() {
101
+ if (!pairing().ok) return {};
102
+ const raw = readJSONCached(path.join(DATA_DIR, '.health-cache.json'), {});
103
+ const meta = raw._meta;
104
+ if (meta && Number(meta.schema) > SUPPORTED_SCHEMA) {
105
+ throw new Error(
106
+ `This health cache was written by Health Export ${meta.app ?? 'a newer version'} using ` +
107
+ `cache schema ${meta.schema}, but this MCP server only understands ${SUPPORTED_SCHEMA}. ` +
108
+ // The package `@healthexport/mcp` does not exist — package.json is `private: true`.
109
+ // These are the two channels the site and SKILL.md actually document.
110
+ `Update the Health Export MCP server — reinstall the bundle from https://www.healthexport.dev/health-export.mcpb, or re-download https://www.healthexport.dev/mcp/healthstore.mjs — reading it anyway ` +
111
+ `could report wrong numbers.`);
112
+ }
113
+ // NULL PROTOTYPE. Every existence check here is `metrics[name]`, so inherited keys answered for
114
+ // metrics that do not exist: `get_health_metrics({metric:"constructor"})` returned a fabricated
115
+ // entry with an empty unit instead of "unknown metric", and so did "toString" and
116
+ // "hasOwnProperty". An agent probing names — or a user typo — got a plausible empty result rather
117
+ // than an error. A prototype-less object makes the whole class unreachable in one line, rather
118
+ // than requiring every call site to remember Object.hasOwn.
119
+ //
120
+ // Derived ONCE per parsed file and memoized alongside it. Rebuilding it per call would have
121
+ // silently defeated the (mtime, size) memoization — every caller would get a fresh object and
122
+ // pay a full copy of a cache that reaches tens of MB.
123
+ if (viewCache.raw !== raw) {
124
+ const { _meta, ...rest } = raw;
125
+ viewCache = { raw, view: Object.assign(Object.create(null), meta === undefined ? raw : rest) };
126
+ }
127
+ return viewCache.view;
128
+ }
129
+
130
+ /// The prototype-less view of the last parsed cache, keyed on the parsed object's identity so it is
131
+ /// invalidated exactly when `readJSONCached` re-reads the file.
132
+ let viewCache = { raw: null, view: Object.create(null) };
133
+
134
+ export async function loadWorkouts() {
135
+ if (!pairing().ok) return [];
136
+ return readJSONCached(path.join(DATA_DIR, '.health-workouts-cache.json'), []);
137
+ }
138
+
139
+ export function sourceLabel() {
140
+ return `file ${path.resolve(DATA_DIR, '.health-cache.json')}`;
141
+ }
142
+
143
+ // ---- helpers ----
144
+ const inRange = (d, start, end) => (!start || d >= start) && (!end || d <= end);
145
+
146
+ // Dates arrive as strings and were previously compared lexicographically with no validation, so
147
+ // "2026-8-1" or "last tuesday" silently matched nothing and the tool returned an empty series with
148
+ // isError:false -- an agent could not tell "no data" from "you typed the date wrong".
149
+ const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
150
+ function assertDay(v, label) {
151
+ if (v === undefined || v === null || v === '') return undefined;
152
+ if (typeof v !== 'string' || !DAY_RE.test(v)) {
153
+ throw new Error(`${label} must be YYYY-MM-DD (got ${JSON.stringify(v)})`);
154
+ }
155
+ const [y, m, d] = v.split('-').map(Number);
156
+ const dt = new Date(Date.UTC(y, m - 1, d));
157
+ if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d) {
158
+ throw new Error(`${label} is not a real date: ${v}`);
159
+ }
160
+ return v;
161
+ }
162
+
163
+ // How many daily points a caller gets before the server starts aggregating instead of enumerating.
164
+ // Claude Code caps tool responses at 25,000 tokens (MAX_MCP_OUTPUT_TOKENS) and warns above 10,000.
165
+ // One daily point serializes to roughly 30 characters, so ~365 points per metric is a comfortable
166
+ // ceiling for a single-metric answer and forces the multi-metric paths through `granularity`.
167
+ const DEFAULT_LIMIT = 365;
168
+ const MAX_LIMIT = 3000;
169
+
170
+ const GRANULARITIES = ['day', 'week', 'month', 'quarter', 'year'];
171
+
172
+ function bucketKey(day, granularity) {
173
+ const [y, m, d] = day.split('-');
174
+ switch (granularity) {
175
+ case 'year': return y;
176
+ case 'quarter': return `${y}-Q${Math.floor((Number(m) - 1) / 3) + 1}`;
177
+ case 'month': return `${y}-${m}`;
178
+ case 'week': {
179
+ // ISO week, so a bucket label is unambiguous across year boundaries.
180
+ const dt = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d)));
181
+ const dayNum = (dt.getUTCDay() + 6) % 7;
182
+ dt.setUTCDate(dt.getUTCDate() - dayNum + 3);
183
+ const firstThursday = new Date(Date.UTC(dt.getUTCFullYear(), 0, 4));
184
+ const week = 1 + Math.round(((dt - firstThursday) / 86400000 - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7);
185
+ return `${dt.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
186
+ }
187
+ default: return day;
188
+ }
189
+ }
190
+
191
+ // Pick the finest granularity whose bucket count fits the limit. This is what makes `auto` safe:
192
+ // "ten years of steps" returns ~120 monthly points instead of 3,653 daily ones, and the caller is
193
+ // told what happened rather than silently receiving a truncated series.
194
+ function resolveGranularity(pointCount, requested, limit) {
195
+ if (requested && requested !== 'auto') {
196
+ if (!GRANULARITIES.includes(requested)) {
197
+ throw new Error(`granularity must be one of auto, ${GRANULARITIES.join(', ')}`);
198
+ }
199
+ return requested;
200
+ }
201
+ if (pointCount <= limit) return 'day';
202
+ if (pointCount <= limit * 7) return 'week';
203
+ if (pointCount <= limit * 31) return 'month';
204
+ if (pointCount <= limit * 92) return 'quarter';
205
+ return 'year';
206
+ }
207
+
208
+ // Roll daily points up to `granularity`. Cumulative metrics sum within a bucket, everything else
209
+ // averages -- the same rule HealthCache uses on the writing side, so a monthly step total and a
210
+ // monthly resting-HR average both mean what a reader expects.
211
+ function rollUp(points, granularity, cumulative) {
212
+ if (granularity === 'day') return points.map((p) => ({ date: p.d, value: round(p.v) }));
213
+ const buckets = new Map();
214
+ for (const p of points) {
215
+ const k = bucketKey(p.d, granularity);
216
+ if (!buckets.has(k)) buckets.set(k, []);
217
+ buckets.get(k).push(p.v);
218
+ }
219
+ return [...buckets.entries()].map(([k, vs]) => ({
220
+ date: k,
221
+ value: round(cumulative ? vs.reduce((a, b) => a + b, 0) : vs.reduce((a, b) => a + b, 0) / vs.length),
222
+ days: vs.length,
223
+ }));
224
+ }
225
+
226
+ // Coverage is the honest answer to "how much history is in this file?", and every data tool
227
+ // returns it so an agent can see when its question is deeper than the data.
228
+ function coverageOf(m) {
229
+ const daily = m?.daily || [];
230
+ return { firstDate: daily[0]?.d || null, lastDate: daily[daily.length - 1]?.d || null, days: daily.length };
231
+ }
232
+
233
+ /** True when `how` cannot be honoured for this metric and a different one was used instead. */
234
+ export function aggregationWasAdjusted(how, cumulative) {
235
+ return how === 'sum' && !cumulative;
236
+ }
237
+
238
+ function aggregate(values, how, cumulative) {
239
+ if (!values.length) return null;
240
+ switch (how) {
241
+ // Summing a NON-cumulative series is arithmetic on the wrong axis: 365 daily resting heart
242
+ // rates add up to ~22,000 bpm, a number that means nothing but renders like any other. The
243
+ // Swift twin (`AskEngine.aggregate`) already fell back to the mean here; this side did not, so
244
+ // the same question asked through the MCP server and through Ask returned different answers.
245
+ // Callers are told via `aggregationAdjusted` rather than being silently corrected.
246
+ case 'sum': return round(cumulative
247
+ ? values.reduce((a, b) => a + b, 0)
248
+ : values.reduce((a, b) => a + b, 0) / values.length);
249
+ case 'min': return round(Math.min(...values));
250
+ case 'max': return round(Math.max(...values));
251
+ case 'avg': return round(values.reduce((a, b) => a + b, 0) / values.length);
252
+ case 'latest':return round(values[values.length - 1]);
253
+ default: return round(cumulative
254
+ ? values.reduce((a, b) => a + b, 0)
255
+ : values.reduce((a, b) => a + b, 0) / values.length);
256
+ }
257
+ }
258
+ const round = (n) => (n == null ? null : Math.round(n * 1000) / 1000);
259
+
260
+ // ---- query API used by the MCP tools ----
261
+
262
+ export async function status() {
263
+ const p = pairing();
264
+ const metrics = await loadMetrics();
265
+ const workouts = await loadWorkouts();
266
+ const names = Object.keys(metrics);
267
+ let lastDate = null;
268
+ for (const m of Object.values(metrics)) {
269
+ const d = m?.daily?.[m.daily.length - 1]?.d;
270
+ if (d && (!lastDate || d > lastDate)) lastDate = d;
271
+ }
272
+ return {
273
+ ok: names.length > 0 && p.ok,
274
+ source: sourceLabel(),
275
+ paired: p.required,
276
+ locked: p.required && !p.ok,
277
+ note: (p.required && !p.ok)
278
+ ? 'Locked: set PAIRING_SECRET to the code shown in the iOS app (Settings → Agent pairing → scan/paste).'
279
+ : undefined,
280
+ metricCount: names.length,
281
+ workoutCount: Array.isArray(workouts) ? workouts.length : 0,
282
+ lastDataDate: lastDate,
283
+ metrics: names.sort(),
284
+ };
285
+ }
286
+
287
+ export async function listMetrics() {
288
+ // Locked returns {} from loadMetrics, so without this the tool answered {metrics: []} with
289
+ // isError:false — telling an agent the user simply has no health data. get_mcp_status already
290
+ // reports the lock correctly, which made the inconsistency worse.
291
+ assertUnlocked();
292
+ const metrics = await loadMetrics();
293
+ return Object.entries(metrics).map(([name, m]) => ({
294
+ name,
295
+ unit: m.unit || '',
296
+ cumulative: !!m.cumulative,
297
+ days: m.daily?.length || 0,
298
+ firstDate: m.daily?.[0]?.d || null,
299
+ lastDate: m.daily?.[m.daily.length - 1]?.d || null,
300
+ latest: m.daily?.[m.daily.length - 1]?.v ?? null,
301
+ })).sort((a, b) => a.name.localeCompare(b.name));
302
+ }
303
+
304
+ const AGGREGATIONS = new Set(['sum', 'avg', 'min', 'max', 'latest']);
305
+
306
+ export async function getHealthMetrics({ metric, start, end, aggregation, granularity, limit } = {}) {
307
+ assertUnlocked();
308
+ // The tool schema declares an enum and nothing enforced it, so `aggregation:"median"` fell through
309
+ // to the default branch, returned the MEAN, and echoed "median" back — a wrong number wearing a
310
+ // label the caller chose.
311
+ if (aggregation != null && !AGGREGATIONS.has(aggregation)) {
312
+ throw new Error(`aggregation must be one of ${[...AGGREGATIONS].join(', ')} (got "${aggregation}")`);
313
+ }
314
+ start = assertDay(start, 'start');
315
+ end = assertDay(end, 'end');
316
+ if (start && end && start > end) throw new Error(`start (${start}) is after end (${end})`);
317
+ const cap = Math.min(Number(limit) > 0 ? Number(limit) : DEFAULT_LIMIT, MAX_LIMIT);
318
+
319
+ const metrics = await loadMetrics();
320
+ const names = metric ? [metric] : Object.keys(metrics);
321
+ if (metric && !metrics[metric]) {
322
+ throw new Error(`unknown metric "${metric}". Use list_metrics to see available names.`);
323
+ }
324
+ // Asking for every metric at once used to return every point of every metric. On a full history
325
+ // that is tens of MB and blows any client's context window before the agent sees a single number.
326
+ // Multi-metric calls get a proportionally smaller per-metric budget so the whole answer stays
327
+ // readable; `granularity` (not truncation) is what absorbs the difference.
328
+ const perMetric = metric ? cap : Math.max(24, Math.floor(cap / Math.max(1, names.length)));
329
+
330
+ const out = {};
331
+ for (const name of names) {
332
+ const m = metrics[name];
333
+ if (!m) continue;
334
+ const all = m.daily || [];
335
+ const points = all.filter((p) => inRange(p.d, start, end));
336
+ const gran = resolveGranularity(points.length, granularity, perMetric);
337
+ const rolled = rollUp(points, gran, !!m.cumulative);
338
+ out[name] = {
339
+ unit: m.unit || '',
340
+ cumulative: !!m.cumulative,
341
+ granularity: gran,
342
+ points: rolled,
343
+ // The aggregate is always computed over the FULL filtered range, never over the rolled-up
344
+ // points, so rolling up can never change the answer to "what was my total".
345
+ aggregate: aggregate(points.map((p) => p.v), aggregation, m.cumulative),
346
+ // Report what was ACTUALLY computed, not what was asked for.
347
+ aggregation: aggregationWasAdjusted(aggregation, m.cumulative)
348
+ ? 'avg' : (aggregation || (m.cumulative ? 'sum' : 'avg')),
349
+ ...(aggregationWasAdjusted(aggregation, m.cumulative) && {
350
+ aggregationAdjusted: `"${name}" is not a cumulative metric, so summing it would add values on the wrong axis (365 daily resting heart rates total ~22,000 bpm). The mean was returned instead.`,
351
+ }),
352
+ pointsInRange: points.length,
353
+ coverage: coverageOf(m),
354
+ };
355
+ if (gran !== 'day') {
356
+ out[name].note = `${points.length} daily values rolled up to ${gran}. Narrow start/end, or pass granularity:"day" with a smaller range, for day-level detail.`;
357
+ }
358
+ }
359
+ return out;
360
+ }
361
+
362
+ export async function getTrends({ metric, window = 7 } = {}) {
363
+ if (!metric) throw new Error('metric is required');
364
+ assertUnlocked();
365
+ // Floor FIRST, then validate. `window: 0.5` passed `> 0` and floored to 0, producing an empty
366
+ // result with no error — an agent asking for half a day was told, in effect, that there is no
367
+ // data. A window is a whole number of days or it is not a window.
368
+ if (!Number.isFinite(Number(window))) throw new Error(`window must be a number of days (got ${window})`);
369
+ window = Math.floor(Number(window));
370
+ if (!(window >= 1)) throw new Error(`window must be at least 1 whole day (got ${arguments[0].window})`);
371
+ const metrics = await loadMetrics();
372
+ const m = metrics[metric];
373
+ if (!m) throw new Error(`unknown metric "${metric}"`);
374
+ const daily = (m.daily || []).slice();
375
+ // Windows are CALENDAR days, not "the last N recorded points". Slicing by count meant a 7-day
376
+ // window on a sparse metric — body mass, VO2 max, anything logged weekly — silently spanned two
377
+ // months, and the comparison was then between two arbitrary and unequal stretches of time
378
+ // labelled "7 days". Anchored to the newest recorded day rather than today, so a metric that
379
+ // stopped updating a month ago still compares its own last two windows instead of two empty ones.
380
+ const dayMs = 86400000;
381
+ const anchor = daily.length ? Date.parse(daily[daily.length - 1].d) : NaN;
382
+ const recentFrom = anchor - (window - 1) * dayMs;
383
+ const priorFrom = anchor - (window * 2 - 1) * dayMs;
384
+ const inSpan = (p, from, to) => {
385
+ const t = Date.parse(p.d);
386
+ return Number.isFinite(t) && t >= from && t <= to;
387
+ };
388
+ const recent = Number.isFinite(anchor)
389
+ ? daily.filter((p) => inSpan(p, recentFrom, anchor)).map((p) => p.v) : [];
390
+ const prior = Number.isFinite(anchor)
391
+ ? daily.filter((p) => inSpan(p, priorFrom, recentFrom - dayMs)).map((p) => p.v) : [];
392
+ // NORMALISE BY THE NUMBER OF POINTS ACTUALLY IN EACH SIDE. `slice(-window*2, -window)` clamps
393
+ // silently, so a series shorter than 2*window gave a prior side with fewer points than recent —
394
+ // and for a cumulative metric both sides are SUMMED, so ten identical 10,000-step days with
395
+ // window: 7 returned changePercent: +133.3, direction: "up" for a perfectly flat series. The only
396
+ // hint was windowSatisfied: false, which an agent reading `direction` never sees.
397
+ //
398
+ // Comparing per-day rates makes unequal sides honest: a sum over 7 days against a sum over 3 is
399
+ // meaningless, but 10,000/day against 10,000/day is exactly right.
400
+ const agg = (vs) => aggregate(vs, m.cumulative ? 'sum' : 'avg', m.cumulative);
401
+ const rate = (vs) => {
402
+ const a = agg(vs);
403
+ if (a == null || vs.length === 0) return null;
404
+ return m.cumulative ? a / vs.length : a; // discrete metrics are already per-day means
405
+ };
406
+ const r = agg(recent), p = agg(prior);
407
+ const rRate = rate(recent), pRate = rate(prior);
408
+ const changePct = (rRate != null && pRate != null && pRate !== 0)
409
+ ? round(((rRate - pRate) / Math.abs(pRate)) * 100) : null;
410
+ return {
411
+ metric, unit: m.unit || '', window,
412
+ recent: r, prior: p,
413
+ // Point counts are part of the answer: without them a caller cannot tell a real change from an
414
+ // artefact of one side being shorter.
415
+ recentDays: recent.length, priorDays: prior.length,
416
+ change: (r != null && p != null && recent.length === prior.length) ? round(r - p) : null,
417
+ changePercent: changePct,
418
+ direction: changePct == null
419
+ ? (r != null && p === 0 ? (r > 0 ? 'up' : 'flat') : 'unknown') // rose from a 0 baseline
420
+ : changePct > 1 ? 'up' : changePct < -1 ? 'down' : 'flat',
421
+ recentRange: Number.isFinite(anchor)
422
+ ? { from: new Date(recentFrom).toISOString().slice(0, 10), to: daily[daily.length - 1]?.d }
423
+ : { from: undefined, to: undefined },
424
+ // An agent asking for a 90-day trend against 12 days of data was previously given a confident
425
+ // answer with no hint that the window was not met. Both numbers are now explicit.
426
+ daysAvailable: daily.length,
427
+ // The SPAN must be satisfied, not merely non-empty on both sides. A previous revision of this
428
+ // relaxed it to `recent.length > 0 && prior.length > 0`, which made a 365-day window against
429
+ // 400 days of history report satisfied while comparing 365 days to 35 — and the tool
430
+ // description tells agents that false means insufficient history.
431
+ windowSatisfied: Number.isFinite(anchor)
432
+ && Date.parse(daily[0].d) <= priorFrom
433
+ && recent.length > 0 && prior.length > 0,
434
+ ...(recent.length !== prior.length && {
435
+ note: `Windows are unequal (${recent.length} vs ${prior.length} recorded days). changePercent compares per-day rates; the raw change is omitted because the totals cover different spans.`,
436
+ }),
437
+ coverage: coverageOf(m),
438
+ };
439
+ }
440
+
441
+ export async function comparePeriods({ metric, periodA, periodB } = {}) {
442
+ if (!metric || !periodA || !periodB) throw new Error('metric, periodA {start,end}, periodB {start,end} required');
443
+ // Both bounds of both periods, explicitly. The old guard checked only truthiness, so `{}` or a
444
+ // bare "2026-01" passed, start/end came out undefined, `inRange` then matched the ENTIRE history
445
+ // for both sides, and the tool returned change: 0, changePercent: 0 with isError:false — an
446
+ // agent cannot tell that from a genuine no-change result.
447
+ for (const [label, p] of [['periodA', periodA], ['periodB', periodB]]) {
448
+ if (typeof p !== 'object' || !p.start || !p.end) {
449
+ throw new Error(`${label} must be an object with both start and end as YYYY-MM-DD dates`);
450
+ }
451
+ }
452
+ const a = await getHealthMetrics({ metric, start: periodA.start, end: periodA.end });
453
+ const b = await getHealthMetrics({ metric, start: periodB.start, end: periodB.end });
454
+ const av = a[metric]?.aggregate, bv = b[metric]?.aggregate;
455
+ const na = a[metric]?.pointsInRange ?? 0, nb = b[metric]?.pointsInRange ?? 0;
456
+ // Span lengths ride along: comparing a 31-day month against a 28-day one is a legitimate thing to
457
+ // ask for and an illegitimate thing to do silently, especially for a cumulative metric.
458
+ const spanDays = (p) => Math.round((Date.parse(p.end) - Date.parse(p.start)) / 86400000) + 1;
459
+ const daysA = spanDays(periodA), daysB = spanDays(periodB);
460
+ // Per-day RATES, matching get_trends, WeeklyBrief and AskEngine. Summing both sides made two
461
+ // periods with different amounts of DATA look different even when the daily figures were
462
+ // identical: a 31-day span missing three days read as a 10% decline. `comparable` used to test
463
+ // span equality, which does not imply the totals are comparable — a gap inside an equal span is
464
+ // exactly the case it missed.
465
+ const cumulative = a[metric]?.cumulative ?? false;
466
+ const rate = (v, n) => (v == null || n === 0) ? null : (cumulative ? v / n : v);
467
+ const ra = rate(av, na), rb = rate(bv, nb);
468
+ const changePct = (ra != null && rb != null && rb !== 0) ? round(((ra - rb) / Math.abs(rb)) * 100) : null;
469
+ return {
470
+ metric, unit: a[metric]?.unit || '',
471
+ // When coverage is UNEQUAL the reported value is the per-day rate, not the total — matching
472
+ // `AskEngine.comparePeriods`, which switches to a daily average for exactly this case and says
473
+ // so in its label. Reporting one side's total against the other's is the arithmetic that made a
474
+ // 31-day span missing three days look like a 10% decline.
475
+ periodA: { ...periodA, value: (na === nb ? av : ra), days: daysA, pointsInRange: na },
476
+ periodB: { ...periodB, value: (na === nb ? bv : rb), days: daysB, pointsInRange: nb },
477
+ // A non-cumulative metric's value is a MEAN whatever the coverage, so calling it a "total"
478
+ // when the two spans happened to match was simply wrong for every such metric.
479
+ valueBasis: !cumulative ? 'per-day average' : (na === nb ? 'total' : 'per-day average'),
480
+ // The delta is stated on the SAME basis as the values above: totals when coverage matches,
481
+ // per-day rates when it does not. Omitting it entirely was the more conservative choice and the
482
+ // less useful one — "no difference in daily average" is a real answer, and it is what
483
+ // AskEngine.comparePeriods has always given. What must never happen is a total minus a total
484
+ // across different amounts of data.
485
+ change: na === nb
486
+ ? ((av != null && bv != null) ? round(av - bv) : null) // same coverage ⇒ totals
487
+ : ((ra != null && rb != null) ? round(ra - rb) : null), // otherwise per-day, as above
488
+ changePercent: changePct,
489
+ comparable: daysA === daysB && na === nb,
490
+ ...((daysA !== daysB || na !== nb) && {
491
+ note: `Periods cover ${daysA} vs ${daysB} days and contain ${na} vs ${nb} recorded days. changePercent and change are both stated as PER-DAY rates, because the totals cover different amounts of data and are not comparable.`,
492
+ }),
493
+ };
494
+ }
495
+
496
+ export async function getStructuredExport({ metrics: names, start, end, granularity, limit, cursor } = {}) {
497
+ assertUnlocked();
498
+ start = assertDay(start, 'start');
499
+ end = assertDay(end, 'end');
500
+ if (start && end && start > end) throw new Error(`start (${start}) is after end (${end})`);
501
+ const all = await loadMetrics();
502
+ // Unknown names are an ERROR here too. `.filter((n) => all[n])` dropped them silently, so an
503
+ // agent that asked for three metrics and got two had no way to tell which was missing — or that
504
+ // anything had been dropped. `get_health_metrics` has always thrown for the same mistake.
505
+ if (names && names.length) {
506
+ const unknown = names.filter((n) => !all[n]);
507
+ if (unknown.length) {
508
+ throw new Error(`unknown metric${unknown.length > 1 ? 's' : ''} ` +
509
+ `${unknown.map((n) => `"${n}"`).join(', ')}. Use list_metrics to see available names.`);
510
+ }
511
+ }
512
+ const pick = (names && names.length ? names : Object.keys(all)).filter((n) => all[n]).sort();
513
+ const cap = Math.min(Number(limit) > 0 ? Number(limit) : DEFAULT_LIMIT, MAX_LIMIT);
514
+
515
+ // MCP paginates resources/list and tools/list at the protocol level, but NOT tools/call. The
516
+ // 2026-07-28 spec's "Stateful Tools" section blesses the alternative used here: a server-minted
517
+ // opaque cursor returned in the result and handed back as an ordinary argument. The cursor is a
518
+ // metric name, so it stays valid even if the cache is rewritten between calls.
519
+ // An unrecognised cursor must be an ERROR, not a silent restart. `indexOf` returns -1 for a
520
+ // cursor whose metric is no longer in `pick` — because the caller narrowed `metrics` between
521
+ // pages, or the cache was rewritten without it — and `Math.max(0, -1)` turned that into "start
522
+ // from the beginning", so an agent paginating in a loop re-served page 1 forever and could never
523
+ // finish. Failing loudly lets it restart deliberately instead of spinning.
524
+ // Resume at the first metric AT OR AFTER the cursor, rather than requiring an exact hit.
525
+ //
526
+ // `indexOf` alone re-served page one for an unrecognised cursor (`-1` → `Math.max(0,-1)` → 0), so
527
+ // an agent paginating in a loop could never finish. But rejecting an unrecognised cursor outright
528
+ // has the opposite failure: `pick` is sorted, and the app syncing a new metric mid-pagination
529
+ // inserts a name before the cursor — with a strict match the pages after it are fine, and the
530
+ // newly inserted metric is skipped forever while the caller believes it received the full
531
+ // catalogue. A successor search terminates in both cases and skips nothing.
532
+ let startIdx = 0;
533
+ if (cursor) {
534
+ const c = String(cursor);
535
+ const found = pick.findIndex((n) => n >= c);
536
+ startIdx = found < 0 ? pick.length : found; // cursor past the end ⇒ an empty final page
537
+ }
538
+
539
+ // Resolve granularity against each metric's SHARE of the budget, not the whole of it. Against the
540
+ // whole budget a decade of daily data rolls up to ~120 monthly points per metric, so only three
541
+ // metrics fit a page and a caller wanting the full catalogue needs ~60 round trips. Sharing the
542
+ // budget makes `auto` pick a coarser bucket when many metrics are in play, which is what the
543
+ // caller wanted: a broad, shallow answer rather than a deep one on three arbitrary metrics.
544
+ const remaining = Math.max(1, pick.length - startIdx);
545
+ const share = names && names.length ? cap : Math.max(12, Math.floor(cap / Math.min(remaining, 24)));
546
+
547
+ const data = {};
548
+ let used = 0, i = startIdx;
549
+ for (; i < pick.length; i++) {
550
+ const name = pick[i];
551
+ const m = all[name];
552
+ const points = (m.daily || []).filter((p) => inRange(p.d, start, end));
553
+ const gran = resolveGranularity(points.length, granularity, share);
554
+ const rolled = rollUp(points, gran, !!m.cumulative);
555
+ // Stop BEFORE exceeding the budget, so a page is never half a metric.
556
+ if (used > 0 && used + rolled.length > cap) break;
557
+ data[name] = {
558
+ unit: m.unit || '', cumulative: !!m.cumulative, granularity: gran,
559
+ daily: rolled.map((p) => ({ d: p.date, v: p.value })),
560
+ pointsInRange: points.length,
561
+ coverage: coverageOf(m),
562
+ };
563
+ used += rolled.length;
564
+ }
565
+
566
+ const nextCursor = i < pick.length ? pick[i] : null;
567
+ return {
568
+ generatedAt: new Date().toISOString(),
569
+ range: { start: start || null, end: end || null },
570
+ metrics: data,
571
+ returnedMetrics: Object.keys(data).length,
572
+ totalMetrics: pick.length,
573
+ nextCursor,
574
+ note: nextCursor
575
+ ? `Returned ${Object.keys(data).length} of ${pick.length} metrics. Call again with cursor:"${nextCursor}" for the next page.`
576
+ : undefined,
577
+ };
578
+ }
579
+
580
+ // Lightweight NL router: detect metric + time range + intent from a question.
581
+ const ALIASES = {
582
+ steps: 'step_count', step: 'step_count', 'heart rate': 'heart_rate', hr: 'heart_rate',
583
+ hrv: 'heart_rate_variability', 'resting heart rate': 'resting_heart_rate', rhr: 'resting_heart_rate',
584
+ sleep: 'sleep_analysis', 'vo2': 'vo2_max', 'vo2 max': 'vo2_max', weight: 'weight_body_mass',
585
+ 'blood oxygen': 'blood_oxygen_saturation', spo2: 'blood_oxygen_saturation', oxygen: 'blood_oxygen_saturation',
586
+ 'respiratory rate': 'respiratory_rate', distance: 'walking_running_distance',
587
+ 'active energy': 'active_energy', calories: 'active_energy', energy: 'active_energy',
588
+ };
589
+ export async function queryHealthData({ question } = {}) {
590
+ if (!question) throw new Error('question is required');
591
+ assertUnlocked(); // same reason as list_metrics: locked must not read as "no data"
592
+
593
+ const q = question.toLowerCase();
594
+ const all = await loadMetrics();
595
+ let metric = null;
596
+ // longest alias first so "hrv" beats the "hr" substring, etc.
597
+ const aliases = Object.entries(ALIASES).sort((a, b) => b[0].length - a[0].length);
598
+ for (const [alias, name] of aliases) if (q.includes(alias)) { metric = name; break; }
599
+ if (!metric) for (const name of Object.keys(all)) if (q.includes(name.replace(/_/g, ' '))) { metric = name; break; }
600
+
601
+ const wantsTrend = /(trend|compare|vs|versus|last month|this month|change|improv|better|worse)/.test(q);
602
+ const wantsAvg = /(average|avg|mean|typical)/.test(q);
603
+ if (!metric) return { interpreted: 'no specific metric detected', suggestion: 'Call list_metrics, then get_health_metrics.', available: Object.keys(all).sort() };
604
+ if (wantsTrend) return { interpreted: `trend for ${metric}`, result: await getTrends({ metric, window: 30 }) };
605
+ const agg = wantsAvg ? 'avg' : undefined;
606
+ // Bounded on purpose: this convenience path used to return the metric's ENTIRE history, which on
607
+ // a full-history cache is ~119k tokens for a single metric. `granularity:'auto'` keeps the answer
608
+ // proportionate and the accompanying coverage tells the agent what it did not see.
609
+ return {
610
+ interpreted: `${agg || 'summary'} for ${metric}`,
611
+ result: await getHealthMetrics({ metric, aggregation: agg, granularity: 'auto' }),
612
+ };
613
+ }