health-export-mcp 1.4.4 → 1.5.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 +46 -0
- package/envelope.mjs +239 -0
- package/health-export.mcpb +0 -0
- package/healthstore.mjs +210 -11
- package/manifest.json +10 -2
- package/package.json +2 -1
- package/receiver.mjs +69 -15
- package/server.json +2 -2
- package/server.mjs +13 -5
package/README.md
CHANGED
|
@@ -200,11 +200,57 @@ node server.mjs --help
|
|
|
200
200
|
- **Read-only.** The server only reads your exported data, it never touches HealthKit and never writes back.
|
|
201
201
|
- **Local-first.** It runs on your machine over stdio. There is **no developer server** in the path.
|
|
202
202
|
- **Optional pairing.** Set `PAIRING_SECRET` to the code the iOS app shows (Settings → Agent pairing) to gate access.
|
|
203
|
+
- **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
204
|
- **Auditable.** Zero dependencies and a few hundred lines of readable JavaScript, read every line.
|
|
204
205
|
- **Signed releases.** Hosted artifacts are minisign-signed and checksummed, see [Verifying releases](#verifying-releases).
|
|
205
206
|
|
|
206
207
|
---
|
|
207
208
|
|
|
209
|
+
## Encrypted exports
|
|
210
|
+
|
|
211
|
+
The iOS app can encrypt what it exports with a passphrase you choose (Settings > Export encryption,
|
|
212
|
+
switched on per destination: iCloud Drive, a folder, your server, the local network). Give the server
|
|
213
|
+
the same passphrase and every file is decrypted transparently, in memory, on this machine:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
HEALTH_EXPORT_PASSPHRASE='your passphrase' HEALTH_DATA_DIR=... node server.mjs
|
|
217
|
+
# or keep it out of your shell history and process list:
|
|
218
|
+
HEALTH_EXPORT_PASSPHRASE_FILE=~/.config/metricbridge/passphrase HEALTH_DATA_DIR=... node server.mjs
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
If both are set, `HEALTH_EXPORT_PASSPHRASE` wins; one trailing newline in the file is ignored and an
|
|
222
|
+
empty value counts as unset. The `.mcpb` bundle has an optional **Export passphrase** field. Plaintext
|
|
223
|
+
exports keep working either way. A missing or wrong passphrase, or a file that fails its integrity
|
|
224
|
+
check, is reported as such (`get_mcp_status` says `encrypted: true` plus the reason, `--doctor` prints
|
|
225
|
+
an `encryption:` line), never as "no data". The passphrase is never logged or echoed.
|
|
226
|
+
|
|
227
|
+
The LAN receiver (`HEALTH_LISTEN=1`) decrypts encrypted pushes with the same variable and answers
|
|
228
|
+
`422` with a machine `reason` (`passphrase_missing`, `passphrase_mismatch`, `tampered`, `malformed`,
|
|
229
|
+
`unsupported_version`, `passphrase_file_unreadable`, `encryption_required`). The pairing token rides
|
|
230
|
+
in a plain `http` header on that leg, so set `HEALTH_REQUIRE_ENCRYPTED=1` to refuse plaintext pushes
|
|
231
|
+
once the app encrypts that leg. To decrypt one body yourself (a webhook you run, a script):
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
HEALTH_EXPORT_PASSPHRASE='...' node envelope.mjs open body.json # prints the decrypted JSON
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Format** (`metricbridge.enc` v1, `envelope.mjs`, `node:crypto` only): a JSON object
|
|
238
|
+
`{format, v, alg: "A256GCM", kdf: "PBKDF2-HMAC-SHA256", iter, salt, kcv, nonce, ct}`.
|
|
239
|
+
`master = PBKDF2-HMAC-SHA256(NFC(passphrase), salt[16], iter)` (600,000 iterations, the OWASP
|
|
240
|
+
figure); an AES-256-GCM key and an 8-byte key-check value come off it with HKDF-SHA256; each message
|
|
241
|
+
gets a random 12-byte nonce; every header field is bound in as AAD as the string
|
|
242
|
+
`metricbridge.enc|v=1|alg=A256GCM|kdf=PBKDF2-HMAC-SHA256|iter=<n>|salt=<b64>|kcv=<b64>|nonce=<b64>`.
|
|
243
|
+
Base64 is canonical with padding. `test/fixtures/` holds envelopes made by the iOS app's Swift encoder
|
|
244
|
+
and by this module; `envelope.test.mjs` proves both open and regenerate byte for byte.
|
|
245
|
+
|
|
246
|
+
**What it protects:** the export at rest in the destination (iCloud Drive, a synced folder, a
|
|
247
|
+
webhook host's storage) and the LAN push on the wire, plus integrity of every sealed file. **What it
|
|
248
|
+
does not:** an unlocked phone, this computer (it holds the passphrase), metadata (sizes, timing),
|
|
249
|
+
rollback or replay of a whole sealed file, files you share by hand from the app, or a weak passphrase
|
|
250
|
+
(anyone with the files can guess offline; the app requires at least 8 characters).
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
208
254
|
## Verifying releases
|
|
209
255
|
|
|
210
256
|
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.
|
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/health-export.mcpb
CHANGED
|
Binary file
|
package/healthstore.mjs
CHANGED
|
@@ -26,6 +26,9 @@ import os from 'node:os';
|
|
|
26
26
|
// functions, never at module-evaluation time.
|
|
27
27
|
import { DEMO, demoData } from './demo.mjs';
|
|
28
28
|
import * as ev from './events.mjs';
|
|
29
|
+
// Encrypted exports (opt-in in the iOS app): every data file may be a "metricbridge.enc" envelope.
|
|
30
|
+
// readJSON opens it with HEALTH_EXPORT_PASSPHRASE, so every reader below is transparent to it.
|
|
31
|
+
import { isEnvelope, openEnvelope, configuredPassphrase, EnvelopeError } from './envelope.mjs';
|
|
29
32
|
|
|
30
33
|
// Expand a leading ~ (Node, unlike the shell, does not) so manual configs resolve correctly.
|
|
31
34
|
const RAW_DIR = process.env.HEALTH_DATA_DIR || '.';
|
|
@@ -48,7 +51,9 @@ const MAX_BYTES = Number(process.env.HEALTH_MAX_CACHE_BYTES || 512 * 1024 * 1024
|
|
|
48
51
|
|
|
49
52
|
class CacheTooLarge extends Error {}
|
|
50
53
|
|
|
51
|
-
|
|
54
|
+
// `info.encrypted` is set when the file was an envelope, so callers that memoize the parse (and
|
|
55
|
+
// `encryptionStatus`) know without parsing a tens-of-MB file a second time.
|
|
56
|
+
function readJSON(file, fallback, info = {}) {
|
|
52
57
|
let st;
|
|
53
58
|
try {
|
|
54
59
|
st = fs.statSync(file);
|
|
@@ -59,9 +64,54 @@ function readJSON(file, fallback) {
|
|
|
59
64
|
`health cache is ${(st.size / 1048576).toFixed(1)} MB, above the ${(MAX_BYTES / 1048576).toFixed(0)} MB read limit. ` +
|
|
60
65
|
`Raise HEALTH_MAX_CACHE_BYTES if this is expected.`);
|
|
61
66
|
}
|
|
67
|
+
let parsed;
|
|
62
68
|
try {
|
|
63
|
-
|
|
69
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
64
70
|
} catch { return fallback; } // corrupt -> fall back rather than crash the server
|
|
71
|
+
// An encrypted export is NOT corrupt and must never read as "no data": no passphrase, the wrong
|
|
72
|
+
// passphrase and a failed integrity check each THROW an EnvelopeError whose message says exactly
|
|
73
|
+
// what to do. The passphrase itself never appears in any message.
|
|
74
|
+
if (isEnvelope(parsed)) {
|
|
75
|
+
info.encrypted = true;
|
|
76
|
+
const plain = openEnvelope(parsed);
|
|
77
|
+
// An envelope that authenticates but holds bytes that are not JSON (a truncated write the app
|
|
78
|
+
// sealed as raw bytes) is a damaged export, not a crash: reported as an EnvelopeError so
|
|
79
|
+
// get_mcp_status explains it instead of rethrowing a bare SyntaxError.
|
|
80
|
+
try { return JSON.parse(plain.toString('utf8')); }
|
|
81
|
+
catch {
|
|
82
|
+
throw new EnvelopeError('corrupt',
|
|
83
|
+
'An encrypted health export opened with your passphrase but its contents are damaged. ' +
|
|
84
|
+
'Run an export from the MetricBridge app to rewrite it.');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return parsed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** What the data directory's encryption looks like, for get_mcp_status and `doctor`. Reads only
|
|
91
|
+
* the envelope header (no decryption is attempted beyond what loading the data already does). */
|
|
92
|
+
export function encryptionStatus() {
|
|
93
|
+
if (DEMO) return { encrypted: false };
|
|
94
|
+
const file = path.join(DATA_DIR, '.health-cache.json');
|
|
95
|
+
// The cache was almost always just parsed (status() loads it first): reuse that answer rather
|
|
96
|
+
// than parsing a file that reaches tens of MB again. Fall back to a header read when it was not.
|
|
97
|
+
let encrypted;
|
|
98
|
+
const hit = _memo.get(file);
|
|
99
|
+
if (hit) {
|
|
100
|
+
try {
|
|
101
|
+
const st = fs.statSync(file);
|
|
102
|
+
if (hit.stamp === `${st.mtimeMs}:${st.size}`) encrypted = hit.encrypted;
|
|
103
|
+
} catch { return { encrypted: false }; }
|
|
104
|
+
}
|
|
105
|
+
if (encrypted === undefined) {
|
|
106
|
+
let raw;
|
|
107
|
+
try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
108
|
+
catch { return { encrypted: false }; }
|
|
109
|
+
encrypted = isEnvelope(raw);
|
|
110
|
+
}
|
|
111
|
+
if (!encrypted) return { encrypted: false };
|
|
112
|
+
let configured = false;
|
|
113
|
+
try { configured = !!configuredPassphrase(); } catch { configured = false; }
|
|
114
|
+
return { encrypted: true, passphraseConfigured: configured };
|
|
65
115
|
}
|
|
66
116
|
|
|
67
117
|
// Pairing gate: if the iOS app wrote `.health-pair.json`, the configured PAIRING_SECRET must
|
|
@@ -103,8 +153,9 @@ export function readJSONCached(file, fallback) {
|
|
|
103
153
|
const stamp = `${st.mtimeMs}:${st.size}`;
|
|
104
154
|
const hit = _memo.get(file);
|
|
105
155
|
if (hit && hit.stamp === stamp) return hit.value;
|
|
106
|
-
const
|
|
107
|
-
|
|
156
|
+
const info = {};
|
|
157
|
+
const value = readJSON(file, fallback, info);
|
|
158
|
+
_memo.set(file, { stamp, value, encrypted: !!info.encrypted });
|
|
108
159
|
return value;
|
|
109
160
|
}
|
|
110
161
|
|
|
@@ -149,6 +200,76 @@ export async function loadMetrics() {
|
|
|
149
200
|
/// invalidated exactly when `readJSONCached` re-reads the file.
|
|
150
201
|
let viewCache = { raw: null, view: Object.create(null) };
|
|
151
202
|
|
|
203
|
+
/** The cache's `_meta` (schema, app, writtenAt, schemaMinor, recompute), or null. Absent on caches
|
|
204
|
+
* written before app 1.2, and in demo mode (no file is read). */
|
|
205
|
+
export function cacheMeta() {
|
|
206
|
+
if (DEMO || !pairing().ok) return null;
|
|
207
|
+
const raw = readJSONCached(path.join(DATA_DIR, '.health-cache.json'), {});
|
|
208
|
+
return raw && typeof raw._meta === 'object' ? raw._meta : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- Late-arrival recompute provenance (cache schema minor 1, docs/SCHEMA-CONTRACTS.md s.9) ----
|
|
212
|
+
// The app re-reads only the trailing days on each run. When Apple Health later receives samples
|
|
213
|
+
// dated BEFORE that window (a scale or toothbrush syncing after a gap, a back-logged entry), or
|
|
214
|
+
// samples are deleted, the app rebuilds the affected past days from HealthKit and stamps each one
|
|
215
|
+
// with `r` (when it was rebuilt). Days older than its rebuild cap are not rebuilt; the app records
|
|
216
|
+
// that in `_meta.recompute.capped[metric]` instead of dropping it silently. Everything here is
|
|
217
|
+
// additive: an older cache has neither field and every answer is exactly what it was.
|
|
218
|
+
// A deletion marker has no dates and nothing resolves it, so it expires after this many days
|
|
219
|
+
// (the app drops it on its next write too).
|
|
220
|
+
const DELETION_MARKER_DAYS = 30;
|
|
221
|
+
const markerLive = (c) => !(c?.reason === 'deleted_samples'
|
|
222
|
+
&& (!c.at || Date.parse(c.at) < Date.now() - DELETION_MARKER_DAYS * 86400000));
|
|
223
|
+
const recomputeCapped = (name) => {
|
|
224
|
+
const c = cacheMeta()?.recompute?.capped?.[name];
|
|
225
|
+
return c && typeof c === 'object' && markerLive(c) ? c : null;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/** Rebuild provenance for `points` (already filtered to the caller's range) of metric `name`, or
|
|
229
|
+
* null when nothing in range was rebuilt and no cap marker applies. `rangeStart`/`rangeEnd` decide
|
|
230
|
+
* whether an un-rebuilt (capped) stretch overlaps what the caller asked about. */
|
|
231
|
+
export function recomputeOf(name, points, rangeStart, rangeEnd, explicitStart) {
|
|
232
|
+
const rebuilt = points.filter((p) => typeof p.r === 'string');
|
|
233
|
+
const capped = recomputeCapped(name);
|
|
234
|
+
const capDays = cacheMeta()?.recompute?.capDays ?? null;
|
|
235
|
+
// A capped marker only matters when the question reaches back before the rebuild window. A
|
|
236
|
+
// deletion marker has no known dates at all, so it is shown only when the caller explicitly
|
|
237
|
+
// asked about days before it; otherwise every default-range answer would carry it for ever.
|
|
238
|
+
const isDeletion = capped?.reason === 'deleted_samples';
|
|
239
|
+
const cappedApplies = capped
|
|
240
|
+
&& (!isDeletion || (!!explicitStart && !!capped.uncheckedBefore && explicitStart < capped.uncheckedBefore))
|
|
241
|
+
&& (!rangeStart || !capped.uncheckedBefore || rangeStart < capped.uncheckedBefore)
|
|
242
|
+
&& (!rangeEnd || !capped.oldest || rangeEnd >= capped.oldest);
|
|
243
|
+
if (!rebuilt.length && !cappedApplies) return null;
|
|
244
|
+
const latest = rebuilt.reduce((a, p) => (a == null || p.r > a ? p.r : a), null);
|
|
245
|
+
const notes = [];
|
|
246
|
+
if (rebuilt.length) {
|
|
247
|
+
notes.push(`${rebuilt.length} day(s) in this range were rebuilt from Apple Health after samples arrived late or were deleted (recomputed_at is the most recent rebuild). Days not listed in rebuiltDays were not touched by a rebuild.`);
|
|
248
|
+
}
|
|
249
|
+
if (cappedApplies) {
|
|
250
|
+
notes.push(capped.reason === 'deleted_samples'
|
|
251
|
+
? `Samples were deleted in Apple Health and the app could not tell which days they were on. Days before ${capped.uncheckedBefore} were not re-checked, so they may still include the deleted samples.`
|
|
252
|
+
: `Samples dated ${capped.oldest ?? '?'} to ${capped.newest ?? '?'} arrived late but are older than the ${capDays ?? 'rebuild'}-day rebuild window, so those days were NOT rebuilt and may be stale. Exporting "Your full history" in the iOS app rebuilds them.`);
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
backfilled_days: rebuilt.length,
|
|
256
|
+
recomputed_at: latest,
|
|
257
|
+
...(rebuilt.length && { rebuiltDays: rebuilt.slice(-31).map((p) => p.d) }),
|
|
258
|
+
...(rebuilt.length > 31 && { rebuiltDaysTruncated: rebuilt.length - 31 }),
|
|
259
|
+
...(cappedApplies && {
|
|
260
|
+
capped: {
|
|
261
|
+
reason: capped.reason ?? 'late_samples',
|
|
262
|
+
...(capped.oldest && { oldest: capped.oldest }),
|
|
263
|
+
...(capped.newest && { newest: capped.newest }),
|
|
264
|
+
...(capped.lastDays != null && { days: capped.lastDays }),
|
|
265
|
+
...(capped.uncheckedBefore && { uncheckedBefore: capped.uncheckedBefore }),
|
|
266
|
+
...(capped.at && { at: capped.at }),
|
|
267
|
+
},
|
|
268
|
+
}),
|
|
269
|
+
note: notes.join(' '),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
152
273
|
export async function loadWorkouts() {
|
|
153
274
|
if (!pairing().ok) return [];
|
|
154
275
|
if (DEMO) return demoData().workouts;
|
|
@@ -229,17 +350,24 @@ function resolveGranularity(pointCount, requested, limit) {
|
|
|
229
350
|
// averages -- the same rule HealthCache uses on the writing side, so a monthly step total and a
|
|
230
351
|
// monthly resting-HR average both mean what a reader expects.
|
|
231
352
|
function rollUp(points, granularity, cumulative) {
|
|
232
|
-
|
|
353
|
+
// A day rebuilt after late or deleted samples carries `recomputed_at`; an untouched day carries
|
|
354
|
+
// nothing extra, so the two are distinguishable point by point (schema minor 1).
|
|
355
|
+
if (granularity === 'day') {
|
|
356
|
+
return points.map((p) => ({ date: p.d, value: round(p.v), ...(typeof p.r === 'string' && { recomputed_at: p.r }) }));
|
|
357
|
+
}
|
|
233
358
|
const buckets = new Map();
|
|
234
359
|
for (const p of points) {
|
|
235
360
|
const k = bucketKey(p.d, granularity);
|
|
236
|
-
if (!buckets.has(k)) buckets.set(k, []);
|
|
237
|
-
buckets.get(k)
|
|
361
|
+
if (!buckets.has(k)) buckets.set(k, { vs: [], rebuilt: 0 });
|
|
362
|
+
const b = buckets.get(k);
|
|
363
|
+
b.vs.push(p.v);
|
|
364
|
+
if (typeof p.r === 'string') b.rebuilt += 1;
|
|
238
365
|
}
|
|
239
|
-
return [...buckets.entries()].map(([k, vs]) => ({
|
|
366
|
+
return [...buckets.entries()].map(([k, { vs, rebuilt }]) => ({
|
|
240
367
|
date: k,
|
|
241
368
|
value: round(cumulative ? vs.reduce((a, b) => a + b, 0) : vs.reduce((a, b) => a + b, 0) / vs.length),
|
|
242
369
|
days: vs.length,
|
|
370
|
+
...(rebuilt && { backfilled_days: rebuilt }),
|
|
243
371
|
}));
|
|
244
372
|
}
|
|
245
373
|
|
|
@@ -281,8 +409,28 @@ const round = (n) => (n == null ? null : Math.round(n * 1000) / 1000);
|
|
|
281
409
|
|
|
282
410
|
export async function status() {
|
|
283
411
|
const p = pairing();
|
|
284
|
-
|
|
285
|
-
|
|
412
|
+
let metrics, workouts;
|
|
413
|
+
try {
|
|
414
|
+
metrics = await loadMetrics();
|
|
415
|
+
workouts = await loadWorkouts();
|
|
416
|
+
} catch (e) {
|
|
417
|
+
// An encrypted export this server cannot open is reported, not thrown: get_mcp_status is the
|
|
418
|
+
// tool agents call first, and it is where the fix (the passphrase) has to be spelled out.
|
|
419
|
+
if (!(e instanceof EnvelopeError)) throw e;
|
|
420
|
+
return {
|
|
421
|
+
ok: false,
|
|
422
|
+
source: sourceLabel(),
|
|
423
|
+
encrypted: true,
|
|
424
|
+
encryptionError: e.code,
|
|
425
|
+
note: e.message,
|
|
426
|
+
paired: p.required,
|
|
427
|
+
locked: p.required && !p.ok,
|
|
428
|
+
metricCount: 0,
|
|
429
|
+
workoutCount: 0,
|
|
430
|
+
lastDataDate: null,
|
|
431
|
+
metrics: [],
|
|
432
|
+
};
|
|
433
|
+
}
|
|
286
434
|
const names = Object.keys(metrics);
|
|
287
435
|
let lastDate = null;
|
|
288
436
|
for (const m of Object.values(metrics)) {
|
|
@@ -301,12 +449,40 @@ export async function status() {
|
|
|
301
449
|
}),
|
|
302
450
|
paired: p.required,
|
|
303
451
|
locked: p.required && !p.ok,
|
|
452
|
+
// Additive: true when the app sealed the export with a passphrase (and this server opened it).
|
|
453
|
+
encrypted: encryptionStatus().encrypted,
|
|
304
454
|
note: (p.required && !p.ok)
|
|
305
455
|
? 'Locked: set PAIRING_SECRET to the code shown in the iOS app (Settings → Agent pairing → scan/paste).'
|
|
306
456
|
: undefined,
|
|
307
457
|
metricCount: names.length,
|
|
308
458
|
workoutCount: Array.isArray(workouts) ? workouts.length : 0,
|
|
309
459
|
lastDataDate: lastDate,
|
|
460
|
+
// Late-arrival recompute (cache schema minor 1). `supported: false` means the app that wrote
|
|
461
|
+
// this cache predates it: past days are only as fresh as the run that last covered them.
|
|
462
|
+
recompute: (() => {
|
|
463
|
+
const meta = cacheMeta();
|
|
464
|
+
const rec = meta?.recompute ?? null;
|
|
465
|
+
let rebuiltDays = 0;
|
|
466
|
+
const rebuiltMetrics = [];
|
|
467
|
+
for (const [name, m] of Object.entries(metrics)) {
|
|
468
|
+
const n = (m?.daily || []).filter((p) => typeof p.r === 'string').length;
|
|
469
|
+
if (n) { rebuiltDays += n; rebuiltMetrics.push(name); }
|
|
470
|
+
}
|
|
471
|
+
const capped = Object.entries(rec?.capped ?? {}).filter(([, c]) => markerLive(c)).map(([metric, c]) => ({ metric, ...c }))
|
|
472
|
+
.sort((x, y) => x.metric.localeCompare(y.metric));
|
|
473
|
+
return {
|
|
474
|
+
// A LAN receiver cache has no _meta but does carry `r` stamps pushed by the app.
|
|
475
|
+
supported: Number(meta?.schemaMinor ?? 0) >= 1 || rebuiltDays > 0,
|
|
476
|
+
...(rec?.capDays != null && { capDays: rec.capDays }),
|
|
477
|
+
...(rec?.lastRebuiltAt && { recomputed_at: rec.lastRebuiltAt, lastRebuiltDays: rec.lastRebuiltDays ?? null }),
|
|
478
|
+
backfilled_days: rebuiltDays,
|
|
479
|
+
...(rebuiltMetrics.length && { metricsWithRebuiltDays: rebuiltMetrics.sort() }),
|
|
480
|
+
...(capped.length && {
|
|
481
|
+
capped,
|
|
482
|
+
cappedNote: 'Some late samples (or deletions) affect days older than the app rebuilds per run, so those days may be stale. Data tools flag them per metric; exporting "Your full history" in the iOS app rebuilds them.',
|
|
483
|
+
}),
|
|
484
|
+
};
|
|
485
|
+
})(),
|
|
310
486
|
// Additive: lets an agent see whether a live hourly window exists without calling the tool.
|
|
311
487
|
intraday: (() => {
|
|
312
488
|
const st = intradayStat();
|
|
@@ -498,6 +674,8 @@ export async function getHealthMetrics({ metric, start, end, aggregation, granul
|
|
|
498
674
|
pointsInRange: points.length,
|
|
499
675
|
coverage: coverageOf(m),
|
|
500
676
|
};
|
|
677
|
+
const rc = recomputeOf(name, points, start || all[0]?.d, end || all[all.length - 1]?.d, start);
|
|
678
|
+
if (rc) out[name].recompute = rc;
|
|
501
679
|
if (gran !== 'day') {
|
|
502
680
|
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.`;
|
|
503
681
|
}
|
|
@@ -605,7 +783,13 @@ export async function getTrends({ metric, window = 7, excludeTravelDays } = {})
|
|
|
605
783
|
const spanStart = Number.isFinite(anchor) ? new Date(priorFrom).toISOString().slice(0, 10) : null;
|
|
606
784
|
const spanEnd = daily.length ? daily[daily.length - 1].d : null;
|
|
607
785
|
const bounds = spanStart && spanEnd ? ev.boundariesIn(spanStart, spanEnd) : null;
|
|
786
|
+
// Rebuilt days inside the compared span: a trend whose prior window was just rebuilt from late
|
|
787
|
+
// samples is a different finding from one computed over settled days.
|
|
788
|
+
const trendRc = Number.isFinite(anchor)
|
|
789
|
+
? recomputeOf(metric, daily.filter((p2) => inSpan(p2, priorFrom, anchor)), spanStart, spanEnd)
|
|
790
|
+
: null;
|
|
608
791
|
return {
|
|
792
|
+
...(trendRc && { recompute: trendRc }),
|
|
609
793
|
...(bounds && bounds.length && {
|
|
610
794
|
segmentBoundaries: bounds,
|
|
611
795
|
segmentNote: ev.SEGMENT_NOTE(bounds.length),
|
|
@@ -724,6 +908,13 @@ export async function comparePeriods({ metric, periodA, periodB, anchor, exclude
|
|
|
724
908
|
const travelA = a[metric]?.excludedDays ?? 0, travelB = b[metric]?.excludedDays ?? 0;
|
|
725
909
|
return {
|
|
726
910
|
metric, unit: a[metric]?.unit || '',
|
|
911
|
+
// Per side, so an agent can see WHICH period contains rebuilt (or un-rebuilt) days.
|
|
912
|
+
...((a[metric]?.recompute || b[metric]?.recompute) && {
|
|
913
|
+
recompute: {
|
|
914
|
+
...(a[metric]?.recompute && { periodA: a[metric].recompute }),
|
|
915
|
+
...(b[metric]?.recompute && { periodB: b[metric].recompute }),
|
|
916
|
+
},
|
|
917
|
+
}),
|
|
727
918
|
...(anchorInfo && { anchor: anchorInfo }),
|
|
728
919
|
...(bounds.length && {
|
|
729
920
|
segmentBoundaries: bounds,
|
|
@@ -820,11 +1011,19 @@ export async function getStructuredExport({ metrics: names, start, end, granular
|
|
|
820
1011
|
const rolled = rollUp(points, gran, !!m.cumulative);
|
|
821
1012
|
// Stop BEFORE exceeding the budget, so a page is never half a metric.
|
|
822
1013
|
if (used > 0 && used + rolled.length > cap) break;
|
|
1014
|
+
const rc = recomputeOf(name, points, start || m.daily?.[0]?.d, end || m.daily?.[m.daily.length - 1]?.d, start);
|
|
823
1015
|
data[name] = {
|
|
824
1016
|
unit: m.unit || '', cumulative: !!m.cumulative, granularity: gran,
|
|
825
|
-
|
|
1017
|
+
// `r` mirrors the cache's own per-day key: present only on days rebuilt after late or
|
|
1018
|
+
// deleted samples (rolled-up buckets carry a count instead).
|
|
1019
|
+
daily: rolled.map((p) => ({
|
|
1020
|
+
d: p.date, v: p.value,
|
|
1021
|
+
...(p.recomputed_at && { r: p.recomputed_at }),
|
|
1022
|
+
...(p.backfilled_days && { backfilled_days: p.backfilled_days }),
|
|
1023
|
+
})),
|
|
826
1024
|
pointsInRange: points.length,
|
|
827
1025
|
coverage: coverageOf(m),
|
|
1026
|
+
...(rc && { recompute: rc }),
|
|
828
1027
|
};
|
|
829
1028
|
used += rolled.length;
|
|
830
1029
|
}
|
package/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": "0.3",
|
|
3
3
|
"name": "health-export-ai",
|
|
4
4
|
"display_name": "MetricBridge",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.5.0",
|
|
6
6
|
"description": "Query your Apple Health data, exported by the MetricBridge iOS app to iCloud Drive, from any MCP agent. Local-first, read-only, zero dependencies.",
|
|
7
7
|
"long_description": "A zero-dependency MCP server that exposes 190 Apple Health metrics (exported by the MetricBridge iOS app into an iCloud Drive folder) to any MCP-compatible agent: Claude Desktop, opencode, Cursor, OpenClaw, Hermes. No bridge, no Docker, no HTTP receiver: the server reads the synced cache file directly over stdio. 14 tools: get_mcp_status, list_metrics, get_health_metrics, get_trends, compare_periods, get_structured_export, get_intraday, query_health_data, list_events, get_profile, get_workouts, get_sleep_sessions, get_cycle_context, correlate_metrics. Plus 22 MCP prompts and a --demo mode with a synthetic dataset. Get the companion iOS app on the App Store: https://apps.apple.com/app/id6784185201",
|
|
8
8
|
"author": {
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
],
|
|
33
33
|
"env": {
|
|
34
34
|
"HEALTH_DATA_DIR": "${user_config.health_data_dir}",
|
|
35
|
-
"PAIRING_SECRET": "${user_config.pairing_secret}"
|
|
35
|
+
"PAIRING_SECRET": "${user_config.pairing_secret}",
|
|
36
|
+
"HEALTH_EXPORT_PASSPHRASE": "${user_config.export_passphrase}"
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
},
|
|
@@ -109,6 +110,13 @@
|
|
|
109
110
|
"description": "If you turned on Agent pairing in the iOS app (Settings → Agent pairing), paste the code here. Leave blank if pairing is off.",
|
|
110
111
|
"required": false,
|
|
111
112
|
"sensitive": true
|
|
113
|
+
},
|
|
114
|
+
"export_passphrase": {
|
|
115
|
+
"type": "string",
|
|
116
|
+
"title": "Export passphrase (optional)",
|
|
117
|
+
"description": "Only if you turned on Export encryption in the iOS app (Settings > Export encryption): the same passphrase. It decrypts your exports on this computer and is never sent anywhere. Leave blank if encryption is off.",
|
|
118
|
+
"required": false,
|
|
119
|
+
"sensitive": true
|
|
112
120
|
}
|
|
113
121
|
}
|
|
114
122
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "health-export-mcp",
|
|
3
3
|
"mcpName": "io.github.PhilipAD/health-export-mcp",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.5.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Open-source, zero-dependency Model Context Protocol (MCP) server to query Apple Health / HealthKit data (190 metrics) from Claude, ChatGPT, Cursor, OpenClaw, Hermes and any MCP agent.",
|
|
7
7
|
"keywords": [
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"healthstore.mjs",
|
|
37
37
|
"receiver.mjs",
|
|
38
38
|
"events.mjs",
|
|
39
|
+
"envelope.mjs",
|
|
39
40
|
"prompts.mjs",
|
|
40
41
|
"demo.mjs",
|
|
41
42
|
"apply-mcp-config.mjs",
|
package/receiver.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import path from 'node:path';
|
|
|
24
24
|
import os from 'node:os';
|
|
25
25
|
import net from 'node:net';
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { isEnvelope, openEnvelopeJSON, sealEnvelope, parseHeader, configuredPassphrase, EnvelopeError } from './envelope.mjs';
|
|
27
28
|
|
|
28
29
|
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
29
30
|
const MAX_BODY = 64 * 1024 * 1024;
|
|
@@ -34,16 +35,26 @@ const log = (...a) => process.stderr.write('[receiver] ' + a.map(String).join('
|
|
|
34
35
|
function expandTilde(p) {
|
|
35
36
|
return (p === '~' || p.startsWith('~/')) ? path.join(os.homedir(), p.slice(1)) : p;
|
|
36
37
|
}
|
|
37
|
-
function readJSON(file, fb) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fb; } }
|
|
38
38
|
// The merge base for an accumulated cache. ABSENT means start fresh; PRESENT-BUT-CORRUPT
|
|
39
39
|
// must never become {} and overwrite the whole history — quarantine it, then start fresh.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
//
|
|
41
|
+
// Returns { value, sealedLike } where sealedLike is the envelope header when the existing cache was
|
|
42
|
+
// encrypted (the receiver's directory can be the same iCloud folder the app seals into): the merged
|
|
43
|
+
// result is then written back sealed under the SAME salt, so the app (which holds only the key for
|
|
44
|
+
// that salt) can still read it and nothing is left behind in plaintext. An envelope this receiver
|
|
45
|
+
// cannot open THROWS: quarantining it would restart a perfectly good history from one push.
|
|
46
|
+
function readMergeBase(file, fb, passphrase) {
|
|
47
|
+
if (!fs.existsSync(file)) return { value: fb, sealedLike: null };
|
|
48
|
+
let parsed;
|
|
49
|
+
try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
50
|
+
catch { try { const bak = `${file}.corrupt-${process.pid}`; fs.renameSync(file, bak); log(`quarantined unreadable ${file} -> ${bak}`); } catch {} return { value: fb, sealedLike: null }; }
|
|
51
|
+
if (!isEnvelope(parsed)) return { value: parsed, sealedLike: null };
|
|
52
|
+
return { value: openEnvelopeJSON(parsed, passphrase), sealedLike: parseHeader(parsed) };
|
|
44
53
|
}
|
|
45
54
|
const isLoopback = (h) => h === '127.0.0.1' || h === '::1' || h === 'localhost';
|
|
46
55
|
|
|
56
|
+
const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
|
57
|
+
|
|
47
58
|
// Validate/sanitise a pushed summary so a malicious LAN client can't poison the agent's context.
|
|
48
59
|
function sanitize(incoming) {
|
|
49
60
|
const out = {};
|
|
@@ -58,7 +69,11 @@ function sanitize(incoming) {
|
|
|
58
69
|
if (!p || typeof p.d !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(p.d)) continue;
|
|
59
70
|
const v = Number(p.v);
|
|
60
71
|
if (!Number.isFinite(v)) continue;
|
|
61
|
-
|
|
72
|
+
// `r`: the app rebuilt this past day after late-arriving samples (cache schema minor 1).
|
|
73
|
+
// Kept only when it is a plain ISO 8601 instant, so a pushed payload cannot smuggle anything
|
|
74
|
+
// else through to what the MCP server reads.
|
|
75
|
+
const r = typeof p.r === 'string' && ISO_INSTANT.test(p.r) ? p.r : null;
|
|
76
|
+
daily.push(r ? { d: p.d, v, r } : { d: p.d, v });
|
|
62
77
|
}
|
|
63
78
|
if (!daily.length) continue;
|
|
64
79
|
out[name] = { unit: typeof m.unit === 'string' ? m.unit.slice(0, 32) : '', cumulative: !!m.cumulative, daily };
|
|
@@ -87,7 +102,7 @@ function sanitizeAuthoritative(incoming) {
|
|
|
87
102
|
// `HealthCache.merge(_:_:authoritativeDays:authoritativeMetrics:)`; without it a LAN-only cache
|
|
88
103
|
// kept deletions and TZ ghost days forever. Metrics not listed (e.g. sensitive types excluded from
|
|
89
104
|
// the run) are never pruned. `yyyy-MM-dd` is fixed-width, so string order IS chronological.
|
|
90
|
-
function mergeCache(existing, incoming) {
|
|
105
|
+
export function mergeCache(existing, incoming) {
|
|
91
106
|
const merged = { ...(existing || {}) };
|
|
92
107
|
const cleaned = sanitize(incoming);
|
|
93
108
|
const auth = sanitizeAuthoritative(incoming);
|
|
@@ -106,24 +121,36 @@ function mergeCache(existing, incoming) {
|
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
for (const [name, m] of Object.entries(cleaned)) {
|
|
109
|
-
|
|
124
|
+
// A day keeps its recompute stamp (`r`) across later pushes that re-send the same value without
|
|
125
|
+
// one, matching HealthCache.merge on the app side; a newer stamp replaces it.
|
|
126
|
+
const byDay = new Map((merged[name]?.daily || []).map((p) => [p.d, p]));
|
|
110
127
|
if (auth && auth.metrics.has(name)) {
|
|
111
128
|
const incomingDays = new Set(m.daily.map((p) => p.d));
|
|
112
129
|
for (const d of [...byDay.keys()]) if (inRange(d) && !incomingDays.has(d)) byDay.delete(d);
|
|
113
130
|
}
|
|
114
|
-
for (const p of m.daily)
|
|
131
|
+
for (const p of m.daily) {
|
|
132
|
+
const prev = byDay.get(p.d);
|
|
133
|
+
const keep = !p.r && typeof prev?.r === 'string' && prev.v === p.v;
|
|
134
|
+
byDay.set(p.d, keep ? { ...p, r: prev.r } : p);
|
|
135
|
+
}
|
|
115
136
|
merged[name] = { unit: m.unit || merged[name]?.unit || '', cumulative: !!m.cumulative,
|
|
116
|
-
daily: [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
|
137
|
+
daily: [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
|
138
|
+
.map(([d, p]) => (typeof p.r === 'string' ? { d, v: p.v, r: p.r } : { d, v: p.v })) };
|
|
117
139
|
}
|
|
118
140
|
return merged;
|
|
119
141
|
}
|
|
120
142
|
|
|
121
|
-
function writeCache(dir, json) {
|
|
143
|
+
function writeCache(dir, json, passphrase = configuredPassphrase()) {
|
|
122
144
|
const file = path.join(dir, '.health-cache.json');
|
|
123
145
|
const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`; // unique → no concurrent-write race
|
|
124
146
|
fs.mkdirSync(dir, { recursive: true });
|
|
125
|
-
const
|
|
126
|
-
|
|
147
|
+
const base = readMergeBase(file, {}, passphrase);
|
|
148
|
+
const merged = mergeCache(base.value, json);
|
|
149
|
+
const text = JSON.stringify(merged, null, 2);
|
|
150
|
+
const out = base.sealedLike
|
|
151
|
+
? JSON.stringify(sealEnvelope(text, passphrase, { salt: base.sealedLike.salt, iter: base.sealedLike.iter }))
|
|
152
|
+
: text;
|
|
153
|
+
fs.writeFileSync(tmp, out, { mode: 0o600 });
|
|
127
154
|
fs.renameSync(tmp, file);
|
|
128
155
|
log(`merged ${Object.keys(sanitize(json)).length} -> ${Object.keys(merged).length} metrics in ${file}`);
|
|
129
156
|
return Object.keys(merged).length;
|
|
@@ -135,7 +162,12 @@ export function startReceiver({
|
|
|
135
162
|
host = process.env.HEALTH_LISTEN_HOST || '127.0.0.1',
|
|
136
163
|
port = Number(process.env.HEALTH_LISTEN_PORT || 27184),
|
|
137
164
|
token = process.env.HEALTH_LISTEN_TOKEN || '',
|
|
138
|
-
|
|
165
|
+
// The default merge uses the same passphrase source as the push decrypt below (the merge base
|
|
166
|
+
// can itself be sealed when this directory is the folder the app encrypts into).
|
|
167
|
+
onCache = (json) => writeCache(dir, json, passphrase()),
|
|
168
|
+
// A function, read per request: the environment is the source in production, tests inject one.
|
|
169
|
+
passphrase = () => configuredPassphrase(),
|
|
170
|
+
requireEncrypted = () => process.env.HEALTH_REQUIRE_ENCRYPTED === '1',
|
|
139
171
|
} = {}) {
|
|
140
172
|
// Fail closed: never expose an unauthenticated write endpoint on the network.
|
|
141
173
|
if (!isLoopback(host) && !token) {
|
|
@@ -185,6 +217,22 @@ export function startReceiver({
|
|
|
185
217
|
try { parsed = JSON.parse(body); }
|
|
186
218
|
catch (e) { return json(res, 400, { ok: false, hae: true, error: 'invalid JSON' }); } // 400 = parse only
|
|
187
219
|
if (parsed && parsed._test === true) return json(res, 200, { ok: true, hae: true, test: true }); // probe: never touch the cache
|
|
220
|
+
// HEALTH_REQUIRE_ENCRYPTED=1: refuse plaintext pushes. The pairing token travels in a plain
|
|
221
|
+
// http header on this leg, so anyone who sniffed one push could otherwise forge plaintext
|
|
222
|
+
// data with it; requiring envelopes means a forger also needs the passphrase.
|
|
223
|
+
if (requireEncrypted() && !isEnvelope(parsed)) {
|
|
224
|
+
return json(res, 422, { ok: false, hae: true, reason: 'encryption_required', error: 'this receiver only accepts encrypted pushes' });
|
|
225
|
+
}
|
|
226
|
+
// Encrypted push (the app's "Local network" encryption switch). 422 + a machine `reason`
|
|
227
|
+
// the app turns into a sentence that says where to fix it. Never echoes the passphrase.
|
|
228
|
+
if (isEnvelope(parsed)) {
|
|
229
|
+
try { parsed = openEnvelopeJSON(parsed, passphrase()); }
|
|
230
|
+
catch (e) {
|
|
231
|
+
const reason = e instanceof EnvelopeError ? e.code : 'malformed';
|
|
232
|
+
log('encrypted push refused:', reason);
|
|
233
|
+
return json(res, 422, { ok: false, hae: true, reason, error: e instanceof EnvelopeError ? e.message : 'could not decrypt' });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
188
236
|
try { const n = onCache(parsed); json(res, 200, { ok: true, hae: true, metrics: n }); }
|
|
189
237
|
catch (e) { log('write failed', e.message); json(res, 500, { ok: false, hae: true, error: 'write failed: ' + (e.code || e.message) }); } // 500 = disk/write
|
|
190
238
|
});
|
|
@@ -218,7 +266,13 @@ export function startReceiver({
|
|
|
218
266
|
if (opcode === 0xa) { buf = Buffer.alloc(0); return; } // pong → ignore
|
|
219
267
|
const msg = decodeTextFrame(buf);
|
|
220
268
|
if (msg === null) return; // incomplete or unsupported → wait
|
|
221
|
-
try {
|
|
269
|
+
try {
|
|
270
|
+
let parsedMsg = JSON.parse(msg);
|
|
271
|
+
if (requireEncrypted() && !isEnvelope(parsedMsg)) throw new Error('plaintext push refused (HEALTH_REQUIRE_ENCRYPTED)');
|
|
272
|
+
if (isEnvelope(parsedMsg)) parsedMsg = openEnvelopeJSON(parsedMsg, passphrase());
|
|
273
|
+
const n = onCache(parsedMsg);
|
|
274
|
+
socket.write(encodeTextFrame(JSON.stringify({ ok: true, metrics: n })));
|
|
275
|
+
}
|
|
222
276
|
catch (e) { log('bad WS json', e.message); socket.write(encodeTextFrame(JSON.stringify({ ok: false }))); }
|
|
223
277
|
socket.end();
|
|
224
278
|
});
|
package/server.json
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
"url": "https://github.com/PhilipAD/health-export-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.5.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
14
14
|
"identifier": "health-export-mcp",
|
|
15
|
-
"version": "1.
|
|
15
|
+
"version": "1.5.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
package/server.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import * as ev from './events.mjs';
|
|
|
21
21
|
import { PROMPTS } from './prompts.mjs';
|
|
22
22
|
import { DEMO } from './demo.mjs';
|
|
23
23
|
|
|
24
|
-
const SERVER = { name: 'health-export-ai', version: '1.
|
|
24
|
+
const SERVER = { name: 'health-export-ai', version: '1.5.0' };
|
|
25
25
|
const DEFAULT_PROTOCOL = '2025-06-18';
|
|
26
26
|
const log = (...a) => process.stderr.write('[mcp] ' + a.join(' ') + '\n');
|
|
27
27
|
|
|
@@ -57,7 +57,7 @@ const ANNOTATIONS = { readOnlyHint: true, idempotentHint: true, openWorldHint: f
|
|
|
57
57
|
const TOOLS = [
|
|
58
58
|
{
|
|
59
59
|
name: 'get_mcp_status',
|
|
60
|
-
description: 'Health check: data source, how many metrics/workouts are available, which optional context files exist,
|
|
60
|
+
description: 'Health check: data source, how many metrics/workouts are available, which optional context files exist, the most recent data date, and a `recompute` summary of past days rebuilt after late-arriving samples (with any days too old to rebuild). Call this first to confirm the bridge is connected.',
|
|
61
61
|
inputSchema: { type: 'object', properties: {} },
|
|
62
62
|
handler: () => store.status(),
|
|
63
63
|
},
|
|
@@ -71,7 +71,7 @@ const TOOLS = [
|
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
name: 'get_health_metrics',
|
|
74
|
-
description: "Get values for a metric (or all metrics) over an optional date range, with an aggregate (avg/sum/min/max/latest). The core data-retrieval tool. Every result carries a `coverage` block giving the metric's real firstDate/lastDate/days: check it before trusting a long window, and note that `aggregate` is always computed over the full range even when `points` are rolled up. Single-metric answers also list any logged point events inside the window as segmentBoundaries.",
|
|
74
|
+
description: "Get values for a metric (or all metrics) over an optional date range, with an aggregate (avg/sum/min/max/latest). The core data-retrieval tool. Every result carries a `coverage` block giving the metric's real firstDate/lastDate/days: check it before trusting a long window, and note that `aggregate` is always computed over the full range even when `points` are rolled up. Single-metric answers also list any logged point events inside the window as segmentBoundaries. A day the app rebuilt after samples arrived late (or were deleted) carries `recomputed_at`, and a metric with rebuilt or possibly stale past days carries a `recompute` block (backfilled_days, recomputed_at, capped); untouched days carry neither.",
|
|
75
75
|
inputSchema: {
|
|
76
76
|
type: 'object',
|
|
77
77
|
properties: {
|
|
@@ -412,9 +412,17 @@ async function doctor() {
|
|
|
412
412
|
if (!newest || st.mtimeMs > newest.mtimeMs) newest = { name, mtimeMs: st.mtimeMs };
|
|
413
413
|
console.log(` ${name.padEnd(30)} ${fmtBytes(st.size).padStart(9)} ${new Date(st.mtimeMs).toISOString()}`);
|
|
414
414
|
}
|
|
415
|
-
const
|
|
415
|
+
const enc = store.encryptionStatus();
|
|
416
|
+
console.log(` encryption: ${!enc.encrypted ? 'off (plaintext export)' : enc.passphraseConfigured ? 'on, passphrase configured' : 'on, NO PASSPHRASE: set HEALTH_EXPORT_PASSPHRASE to the passphrase from the iOS app'}`);
|
|
417
|
+
let raw = null;
|
|
418
|
+
try {
|
|
419
|
+
raw = statFile('.health-cache.json') ? store.readJSONCached(store.dataPath('.health-cache.json'), null) : null;
|
|
420
|
+
} catch (e) {
|
|
421
|
+
console.log(` cache: cannot be read: ${e.message}`);
|
|
422
|
+
}
|
|
416
423
|
const meta = raw?._meta;
|
|
417
|
-
console.log(` cache schema: ${meta?.schema ?? (raw ? '1 (no _meta: pre-1.2 app)' : 'no cache file')}${meta?.app ? ` (app ${meta.app})` : ''}`);
|
|
424
|
+
console.log(` cache schema: ${meta?.schema ?? (raw ? '1 (no _meta: pre-1.2 app)' : 'no cache file')}${meta?.schemaMinor != null ? `.${meta.schemaMinor}` : ''}${meta?.app ? ` (app ${meta.app})` : ''}`);
|
|
425
|
+
if (meta?.recompute?.capped && Object.keys(meta.recompute.capped).length) console.log(` recompute: days older than the ${meta.recompute.capDays ?? '?'}-day rebuild cap may be stale for: ${Object.keys(meta.recompute.capped).sort().join(', ')}`);
|
|
418
426
|
try {
|
|
419
427
|
const metrics = p.ok ? await store.listMetrics() : null;
|
|
420
428
|
if (metrics) {
|