health-export-mcp 1.4.4 → 1.6.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 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 14 query tools to your AI agent" width="100%" />
19
+ <img src="assets/architecture.svg" alt="Apple Health exports to iCloud, a folder, or your LAN; health-export-mcp reads it and serves 16 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**.
@@ -109,13 +109,14 @@ Restart the client and try:
109
109
 
110
110
  ---
111
111
 
112
- ## The 14 MCP tools
112
+ ## The 16 MCP tools
113
113
 
114
114
  Full reference with request/response examples: **[healthexport.dev/mcp](https://www.healthexport.dev/mcp/#tools)**.
115
115
 
116
116
  | Tool | What it does |
117
117
  |---|---|
118
- | `get_mcp_status` | Health check: source, metric/workout counts, which context files exist, latest data date. **Call first.** |
118
+ | `get_mcp_status` | Health check: source, metric/workout counts, which context files exist, latest data date, and a `freshness` summary. **Call first.** |
119
+ | `get_freshness` | How current the export is: `stale` against a stated threshold (26 h by default, `HEALTH_STALE_AFTER_HOURS`, or `maxAgeHours`), `age_hours`, `as_of`, `last_data_date`, per-file write times. Same rule as the `status --max-age` cron gate. |
119
120
  | `list_metrics` | Every available metric with unit, day count, and date range. |
120
121
  | `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`). |
121
122
  | `get_trends` | Recent N-day window vs the prior N days: change, % change, direction. Supports `excludeTravelDays`. |
@@ -129,6 +130,7 @@ Full reference with request/response examples: **[healthexport.dev/mcp](https://
129
130
  | `get_sleep_sessions` | Clustered sleep sessions attributed to the waking day; split nights returned as-is. |
130
131
  | `get_cycle_context` | Day-in-cycle and coarse phase derived from logged period starts. Never predictive. |
131
132
  | `correlate_metrics` | Pearson correlation between two metrics with a 0..3 day lag. Association, not causation, always stated. |
133
+ | `resolve_metric` | Canonical name and unit for any spelling (`steps`, `StepCount`, `Body Weight`, `hrv`), unit variants with exact formulas, an optional `{value, unit}` conversion, and whether the export holds it. Ambiguous words come back with candidates, never guessed. The data tools accept the same aliases and report `resolvedFrom`. |
132
134
 
133
135
  **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.
134
136
 
@@ -200,11 +202,57 @@ node server.mjs --help
200
202
  - **Read-only.** The server only reads your exported data, it never touches HealthKit and never writes back.
201
203
  - **Local-first.** It runs on your machine over stdio. There is **no developer server** in the path.
202
204
  - **Optional pairing.** Set `PAIRING_SECRET` to the code the iOS app shows (Settings → Agent pairing) to gate access.
205
+ - **Encrypted exports (optional).** If you switch on Export encryption in the iOS app, set `HEALTH_EXPORT_PASSPHRASE` (or `HEALTH_EXPORT_PASSPHRASE_FILE`) to the same passphrase and the server decrypts on your machine. See [Encrypted exports](#encrypted-exports).
203
206
  - **Auditable.** Zero dependencies and a few hundred lines of readable JavaScript, read every line.
204
207
  - **Signed releases.** Hosted artifacts are minisign-signed and checksummed, see [Verifying releases](#verifying-releases).
205
208
 
206
209
  ---
207
210
 
211
+ ## Encrypted exports
212
+
213
+ The iOS app can encrypt what it exports with a passphrase you choose (Settings > Export encryption,
214
+ switched on per destination: iCloud Drive, a folder, your server, the local network). Give the server
215
+ the same passphrase and every file is decrypted transparently, in memory, on this machine:
216
+
217
+ ```bash
218
+ HEALTH_EXPORT_PASSPHRASE='your passphrase' HEALTH_DATA_DIR=... node server.mjs
219
+ # or keep it out of your shell history and process list:
220
+ HEALTH_EXPORT_PASSPHRASE_FILE=~/.config/metricbridge/passphrase HEALTH_DATA_DIR=... node server.mjs
221
+ ```
222
+
223
+ If both are set, `HEALTH_EXPORT_PASSPHRASE` wins; one trailing newline in the file is ignored and an
224
+ empty value counts as unset. The `.mcpb` bundle has an optional **Export passphrase** field. Plaintext
225
+ exports keep working either way. A missing or wrong passphrase, or a file that fails its integrity
226
+ check, is reported as such (`get_mcp_status` says `encrypted: true` plus the reason, `--doctor` prints
227
+ an `encryption:` line), never as "no data". The passphrase is never logged or echoed.
228
+
229
+ The LAN receiver (`HEALTH_LISTEN=1`) decrypts encrypted pushes with the same variable and answers
230
+ `422` with a machine `reason` (`passphrase_missing`, `passphrase_mismatch`, `tampered`, `malformed`,
231
+ `unsupported_version`, `passphrase_file_unreadable`, `encryption_required`). The pairing token rides
232
+ in a plain `http` header on that leg, so set `HEALTH_REQUIRE_ENCRYPTED=1` to refuse plaintext pushes
233
+ once the app encrypts that leg. To decrypt one body yourself (a webhook you run, a script):
234
+
235
+ ```bash
236
+ HEALTH_EXPORT_PASSPHRASE='...' node envelope.mjs open body.json # prints the decrypted JSON
237
+ ```
238
+
239
+ **Format** (`metricbridge.enc` v1, `envelope.mjs`, `node:crypto` only): a JSON object
240
+ `{format, v, alg: "A256GCM", kdf: "PBKDF2-HMAC-SHA256", iter, salt, kcv, nonce, ct}`.
241
+ `master = PBKDF2-HMAC-SHA256(NFC(passphrase), salt[16], iter)` (600,000 iterations, the OWASP
242
+ figure); an AES-256-GCM key and an 8-byte key-check value come off it with HKDF-SHA256; each message
243
+ gets a random 12-byte nonce; every header field is bound in as AAD as the string
244
+ `metricbridge.enc|v=1|alg=A256GCM|kdf=PBKDF2-HMAC-SHA256|iter=<n>|salt=<b64>|kcv=<b64>|nonce=<b64>`.
245
+ Base64 is canonical with padding. `test/fixtures/` holds envelopes made by the iOS app's Swift encoder
246
+ and by this module; `envelope.test.mjs` proves both open and regenerate byte for byte.
247
+
248
+ **What it protects:** the export at rest in the destination (iCloud Drive, a synced folder, a
249
+ webhook host's storage) and the LAN push on the wire, plus integrity of every sealed file. **What it
250
+ does not:** an unlocked phone, this computer (it holds the passphrase), metadata (sizes, timing),
251
+ rollback or replay of a whole sealed file, files you share by hand from the app, or a weak passphrase
252
+ (anyone with the files can guess offline; the app requires at least 8 characters).
253
+
254
+ ---
255
+
208
256
  ## Verifying releases
209
257
 
210
258
  The server artifacts hosted at `healthexport.dev/mcp/` (used by the [setup skill](https://healthexport.dev/SKILL.md)) are **minisign-signed**, and every download is **SHA-256 checksummed**. The signing public key is published **in this repo** (`minisign.pub`) and in `SKILL.md`, **pin it from here, not only from the website**, so a compromise of the website alone cannot swap both an artifact and its key.
@@ -270,7 +318,7 @@ stdio transport (newline-delimited JSON-RPC 2.0), the universal MCP transport. O
270
318
 
271
319
  ## How it works
272
320
 
273
- 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.
321
+ 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 16 tools above over MCP. No bridge, no Docker, no database: just files and stdio.
274
322
 
275
323
  ```
276
324
  Apple Health → MetricBridge (iOS) → .health-cache.json → health-export-mcp → MCP client → you
package/envelope.mjs ADDED
@@ -0,0 +1,239 @@
1
+ // envelope.mjs: reads (and, for tests and tooling, writes) the MetricBridge encrypted export
2
+ // envelope, format "metricbridge.enc" version 1. Zero dependencies: node:crypto only.
3
+ //
4
+ // The iOS app can seal what it exports (iCloud Drive / folder files, webhook and local-network
5
+ // bodies) under a passphrase the user chose. This module is the matching decrypt step, so the MCP
6
+ // server and the LAN receiver read encrypted exports transparently once they know the passphrase.
7
+ //
8
+ // Envelope (UTF-8 JSON, keys sorted):
9
+ // { "alg":"A256GCM", "ct":"<b64 ciphertext||16-byte tag>", "format":"metricbridge.enc",
10
+ // "iter":600000, "kcv":"<b64 8 bytes>", "kdf":"PBKDF2-HMAC-SHA256",
11
+ // "nonce":"<b64 12 bytes>", "salt":"<b64 16 bytes>", "v":1 }
12
+ //
13
+ // master = PBKDF2-HMAC-SHA256(NFC(passphrase), salt, iter, 32)
14
+ // encKey = HKDF-SHA256(master, salt = empty, info = "metricbridge.enc/v1/aes-256-gcm", 32)
15
+ // kcv = HKDF-SHA256(master, salt = empty, info = "metricbridge.enc/v1/key-check", 8)
16
+ // AAD = "metricbridge.enc|v=1|alg=A256GCM|kdf=PBKDF2-HMAC-SHA256|iter=<n>|salt=<b64>|kcv=<b64>|nonce=<b64>"
17
+ // (base64 re-encoded from the decoded bytes, canonical with padding)
18
+ //
19
+ // Full specification and threat model: docs/ENCRYPTED-EXPORTS.md in the app repo, and the
20
+ // "Encrypted exports" section of README.md.
21
+ //
22
+ // Passphrase source, in order: HEALTH_EXPORT_PASSPHRASE, then the file named by
23
+ // HEALTH_EXPORT_PASSPHRASE_FILE (one trailing newline is stripped). The passphrase is never logged,
24
+ // never echoed in an error and never written anywhere.
25
+ //
26
+ // CLI (for webhook receivers and scripts that want the plaintext of one envelope):
27
+ // HEALTH_EXPORT_PASSPHRASE=... node envelope.mjs open <file|-> # prints the decrypted JSON
28
+
29
+ import crypto from 'node:crypto';
30
+ import fs from 'node:fs';
31
+ import { fileURLToPath } from 'node:url';
32
+
33
+ export const FORMAT = 'metricbridge.enc';
34
+ export const VERSION = 1;
35
+ export const ALG = 'A256GCM';
36
+ export const KDF = 'PBKDF2-HMAC-SHA256';
37
+ export const DEFAULT_ITERATIONS = 600_000; // OWASP Password Storage Cheat Sheet, PBKDF2-HMAC-SHA256
38
+ export const MIN_ITERATIONS = 100_000; // a forged header cannot steer us into a cheap derivation
39
+ export const MAX_ITERATIONS = 10_000_000; // ...or pin the CPU for minutes
40
+ const SALT_LEN = 16, NONCE_LEN = 12, KCV_LEN = 8, TAG_LEN = 16, KEY_LEN = 32;
41
+ const ENC_INFO = Buffer.from('metricbridge.enc/v1/aes-256-gcm', 'utf8');
42
+ const KCV_INFO = Buffer.from('metricbridge.enc/v1/key-check', 'utf8');
43
+
44
+ /** Machine-readable failure. `code` is stable; `message` never contains the passphrase. */
45
+ export class EnvelopeError extends Error {
46
+ constructor(code, message) { super(message); this.name = 'EnvelopeError'; this.code = code; }
47
+ }
48
+
49
+ export const isEnvelope = (obj) =>
50
+ !!obj && typeof obj === 'object' && !Array.isArray(obj) && obj.format === FORMAT;
51
+
52
+ /** The configured passphrase, or null. Read on every call so tests (and a restarted config) see
53
+ * the current environment; it is cheap. Empty means unset: the .mcpb manifest maps an empty
54
+ * optional field to an empty string. */
55
+ export function configuredPassphrase(env = process.env) {
56
+ const direct = env.HEALTH_EXPORT_PASSPHRASE;
57
+ if (typeof direct === 'string' && direct.length > 0) return direct;
58
+ const file = env.HEALTH_EXPORT_PASSPHRASE_FILE;
59
+ if (typeof file === 'string' && file.length > 0) {
60
+ let text;
61
+ try { text = fs.readFileSync(file, 'utf8'); }
62
+ catch (e) {
63
+ throw new EnvelopeError('passphrase_file_unreadable',
64
+ `HEALTH_EXPORT_PASSPHRASE_FILE points at a file that could not be read (${e.code || 'error'}).`);
65
+ }
66
+ const p = text.replace(/\r?\n$/, '');
67
+ return p.length > 0 ? p : null;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // PBKDF2 at 600,000 rounds is ~0.3 s; every file the app writes shares one salt, so derive once per
73
+ // (passphrase, salt, iter). Keyed by an HMAC of the passphrase under a per-process random key, so the
74
+ // map holds neither a second copy of the passphrase nor a fast unsalted hash of it.
75
+ // Bounded (LRU): the app uses one salt per passphrase, so a handful of entries is plenty, and an
76
+ // unbounded map would let a stream of fresh-salt envelopes grow memory without limit.
77
+ const keyCache = new Map();
78
+ const KEY_CACHE_MAX = 16;
79
+ const CACHE_ID_KEY = crypto.randomBytes(32);
80
+
81
+ function b64(buf) { return Buffer.from(buf).toString('base64'); }
82
+
83
+ function decodeField(env, name, len) {
84
+ const s = env[name];
85
+ // Canonical padded base64 only: Buffer.from is lenient (drops padding, ignores stray characters)
86
+ // and the Swift reader is not, so both apply the same rule and accept exactly the same envelopes.
87
+ if (typeof s !== 'string' || s.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(s)) {
88
+ throw new EnvelopeError('malformed', `encrypted export is malformed (field "${name}")`);
89
+ }
90
+ const buf = Buffer.from(s, 'base64');
91
+ if (buf.toString('base64') !== s) {
92
+ throw new EnvelopeError('malformed', `encrypted export is malformed (field "${name}")`);
93
+ }
94
+ if (len !== undefined && buf.length !== len) {
95
+ throw new EnvelopeError('malformed', `encrypted export is malformed (field "${name}" length)`);
96
+ }
97
+ return buf;
98
+ }
99
+
100
+ function exactInt(v) { return Number.isInteger(v) ? v : null; }
101
+
102
+ /** Parse and validate the header of an envelope object. */
103
+ export function parseHeader(env) {
104
+ if (!isEnvelope(env)) throw new EnvelopeError('malformed', 'not an encrypted export');
105
+ const v = exactInt(env.v);
106
+ if (v === null) throw new EnvelopeError('malformed', 'encrypted export is malformed (field "v")');
107
+ if (v !== VERSION) {
108
+ throw new EnvelopeError('unsupported_version',
109
+ `This export was encrypted with envelope version ${v}, but this server only reads version ${VERSION}. ` +
110
+ 'Update the MetricBridge MCP server.');
111
+ }
112
+ if (env.alg !== ALG) throw new EnvelopeError('malformed', 'encrypted export is malformed (field "alg")');
113
+ if (env.kdf !== KDF) throw new EnvelopeError('malformed', 'encrypted export is malformed (field "kdf")');
114
+ const iter = exactInt(env.iter);
115
+ if (iter === null || iter < MIN_ITERATIONS || iter > MAX_ITERATIONS) {
116
+ throw new EnvelopeError('malformed', 'encrypted export is malformed (field "iter")');
117
+ }
118
+ const salt = decodeField(env, 'salt', SALT_LEN);
119
+ const kcv = decodeField(env, 'kcv', KCV_LEN);
120
+ const nonce = decodeField(env, 'nonce', NONCE_LEN);
121
+ const sealed = decodeField(env, 'ct');
122
+ if (sealed.length < TAG_LEN) throw new EnvelopeError('malformed', 'encrypted export is malformed (field "ct")');
123
+ return { v, iter, salt, kcv, nonce, sealed };
124
+ }
125
+
126
+ export function associatedData({ v, iter, salt, kcv, nonce }) {
127
+ return Buffer.from(
128
+ `${FORMAT}|v=${v}|alg=${ALG}|kdf=${KDF}|iter=${iter}|salt=${b64(salt)}|kcv=${b64(kcv)}|nonce=${b64(nonce)}`,
129
+ 'utf8');
130
+ }
131
+
132
+ /** Derive { encKey, kcv } for a passphrase + salt + iteration count (memoized). */
133
+ export function deriveKeys(passphrase, salt, iter) {
134
+ if (typeof passphrase !== 'string' || passphrase.length === 0) {
135
+ throw new EnvelopeError('passphrase_missing', 'no passphrase');
136
+ }
137
+ const pw = Buffer.from(passphrase.normalize('NFC'), 'utf8');
138
+ const id = crypto.createHmac('sha256', CACHE_ID_KEY).update(pw).digest('hex') + '|' + b64(salt) + '|' + iter;
139
+ const hit = keyCache.get(id);
140
+ if (hit) { keyCache.delete(id); keyCache.set(id, hit); return hit; }
141
+ const master = crypto.pbkdf2Sync(pw, salt, iter, KEY_LEN, 'sha256');
142
+ const keys = {
143
+ encKey: Buffer.from(crypto.hkdfSync('sha256', master, Buffer.alloc(0), ENC_INFO, KEY_LEN)),
144
+ kcv: Buffer.from(crypto.hkdfSync('sha256', master, Buffer.alloc(0), KCV_INFO, KCV_LEN)),
145
+ };
146
+ master.fill(0);
147
+ pw.fill(0);
148
+ keyCache.set(id, keys);
149
+ while (keyCache.size > KEY_CACHE_MAX) keyCache.delete(keyCache.keys().next().value);
150
+ return keys;
151
+ }
152
+
153
+ /** Decrypt an envelope object to a Buffer of plaintext. Throws EnvelopeError with code
154
+ * 'passphrase_missing' | 'passphrase_mismatch' | 'tampered' | 'malformed' | 'unsupported_version'. */
155
+ export function openEnvelope(env, passphrase = configuredPassphrase()) {
156
+ const h = parseHeader(env);
157
+ if (!passphrase) {
158
+ throw new EnvelopeError('passphrase_missing',
159
+ 'This health export is encrypted. Set HEALTH_EXPORT_PASSPHRASE (or HEALTH_EXPORT_PASSPHRASE_FILE) ' +
160
+ 'to the passphrase you chose in the MetricBridge app (Settings > Export encryption), then restart the server.');
161
+ }
162
+ const { encKey, kcv } = deriveKeys(passphrase, h.salt, h.iter);
163
+ if (!crypto.timingSafeEqual(kcv, h.kcv)) {
164
+ throw new EnvelopeError('passphrase_mismatch',
165
+ 'This health export is encrypted with a different passphrase than HEALTH_EXPORT_PASSPHRASE. ' +
166
+ 'Use the passphrase set in the MetricBridge app (Settings > Export encryption).');
167
+ }
168
+ const ct = h.sealed.subarray(0, h.sealed.length - TAG_LEN);
169
+ const tag = h.sealed.subarray(h.sealed.length - TAG_LEN);
170
+ try {
171
+ const d = crypto.createDecipheriv('aes-256-gcm', encKey, h.nonce, { authTagLength: TAG_LEN });
172
+ d.setAAD(associatedData(h));
173
+ d.setAuthTag(tag);
174
+ return Buffer.concat([d.update(ct), d.final()]);
175
+ } catch {
176
+ throw new EnvelopeError('tampered',
177
+ 'An encrypted health export failed its integrity check: the file is damaged or was modified. ' +
178
+ 'Run an export from the MetricBridge app to rewrite it.');
179
+ }
180
+ }
181
+
182
+ /** Decrypt and JSON.parse. */
183
+ export function openEnvelopeJSON(env, passphrase) {
184
+ return JSON.parse(openEnvelope(env, passphrase).toString('utf8'));
185
+ }
186
+
187
+ /** If `obj` is an envelope, return its decrypted JSON; otherwise return it unchanged. */
188
+ export function maybeOpen(obj, passphrase) {
189
+ return isEnvelope(obj) ? openEnvelopeJSON(obj, passphrase === undefined ? configuredPassphrase() : passphrase) : obj;
190
+ }
191
+
192
+ /** Seal plaintext (Buffer or string) into an envelope object. Used by tests, by the cross-language
193
+ * fixture and by the receiver when it rewrites a cache that was already sealed. `nonce` and `salt`
194
+ * are injectable for deterministic fixtures ONLY: production use must leave both to the CSPRNG. */
195
+ export function sealEnvelope(plaintext, passphrase, { salt, iter = DEFAULT_ITERATIONS, nonce } = {}) {
196
+ const s = salt ? Buffer.from(salt) : crypto.randomBytes(SALT_LEN);
197
+ const n = nonce ? Buffer.from(nonce) : crypto.randomBytes(NONCE_LEN);
198
+ if (s.length !== SALT_LEN || n.length !== NONCE_LEN) throw new EnvelopeError('malformed', 'bad salt or nonce length');
199
+ const { encKey, kcv } = deriveKeys(passphrase, s, iter);
200
+ const header = { v: VERSION, iter, salt: s, kcv, nonce: n };
201
+ const c = crypto.createCipheriv('aes-256-gcm', encKey, n, { authTagLength: TAG_LEN });
202
+ c.setAAD(associatedData(header));
203
+ const ct = Buffer.concat([c.update(Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), 'utf8')), c.final()]);
204
+ // Same key order the app writes (sorted), so a fixture regenerates byte-for-byte.
205
+ return {
206
+ alg: ALG,
207
+ ct: b64(Buffer.concat([ct, c.getAuthTag()])),
208
+ format: FORMAT,
209
+ iter,
210
+ kcv: b64(kcv),
211
+ kdf: KDF,
212
+ nonce: b64(n),
213
+ salt: b64(s),
214
+ v: VERSION,
215
+ };
216
+ }
217
+
218
+ // ---- CLI ----
219
+ const isEntryPoint = (() => {
220
+ if (!process.argv[1]) return false;
221
+ try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
222
+ catch { return false; }
223
+ })();
224
+ if (isEntryPoint) {
225
+ const [cmd, file] = process.argv.slice(2);
226
+ if (cmd !== 'open' || !file) {
227
+ process.stderr.write('usage: HEALTH_EXPORT_PASSPHRASE=... node envelope.mjs open <file|->\n');
228
+ process.exit(2);
229
+ }
230
+ try {
231
+ const text = fs.readFileSync(file === '-' ? 0 : file, 'utf8');
232
+ const obj = JSON.parse(text);
233
+ if (!isEnvelope(obj)) { process.stdout.write(text); process.exit(0); }
234
+ process.stdout.write(openEnvelope(obj).toString('utf8'));
235
+ } catch (e) {
236
+ process.stderr.write(`envelope: ${e instanceof EnvelopeError ? e.message : 'could not read input (' + (e.code || e.name) + ')'}\n`);
237
+ process.exit(1);
238
+ }
239
+ }
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 MetricBridge app.",
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.",
8
+ "longDescription": "An open-source, zero-dependency MCP server that turns your Apple Health export into 16 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",
Binary file