health-export-mcp 1.10.0 → 1.10.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/README.md CHANGED
@@ -73,7 +73,7 @@ The companion iOS app **[MetricBridge](https://www.healthexport.dev)** exports y
73
73
  |---|---|
74
74
  | **iCloud Drive** (default) | Your Mac reads the synced folder automatically |
75
75
  | **Local folder** | Any folder that syncs to your Mac (Dropbox, Google Drive, OneDrive, …) |
76
- | **LAN** (HTTP / WebSocket) | Direct push to the server, great over Tailscale |
76
+ | **LAN** (HTTP / WebSocket) | Direct push to the server, great over Tailscale. Carries the daily summary and, when you switch on Settings > Local network > Strength log, the strength log (written to `health-strength.json`) |
77
77
 
78
78
  > Using a non-MCP tool (ChatGPT, n8n, Home Assistant)? The app can also POST to a **webhook** those tools read directly, see [Works with](#works-with-claude-cursor-chatgpt-openclaw-hermes).
79
79
 
@@ -163,7 +163,7 @@ Full reference with request/response examples: **[healthexport.dev/mcp](https://
163
163
  | `get_profile` | Opted-in context fields (conditions, medications, goals, allergies, notes) plus `presentFields`. Absent fields were withheld, never "none". |
164
164
  | `get_workouts` | Workouts by activity/date with pagination; includes heart rate, running dynamics, cycling power, intervals and `hasRoute` when exported. |
165
165
  | `get_sleep_sessions` | Clustered sleep sessions attributed to the waking day; split nights returned as separate sessions, duplicate copies of one night collapsed (`collapsedDuplicates`). |
166
- | `get_strength_log` | The strength log typed into the iOS app (`health-strength.json`, opt-in, off by default): each session with every set as entered, `volumeKg`, `workingSets`, an Epley `bestEstimateKg` per exercise, all-time records, and the user's saved routines. Filter by `exercise`, `start`, `end`. Missing file → `available:false`. |
166
+ | `get_strength_log` | The strength log typed into the iOS app (`health-strength.json`, opt-in, off by default, from the iCloud or folder export or from the LAN push): each session with every set as entered, `volumeKg`, `workingSets`, an Epley `bestEstimateKg` per exercise, all-time records, and the user's saved routines. Filter by `exercise`, `start`, `end`. Missing file → `available:false`. |
167
167
  | `get_lab_results` | Lab results from Apple Health Records (`health-labs.json`; opt-in, off by default, folder export only, never iCloud Drive): each test with its results oldest first, value, date, institution and the lab's own reference range, exactly as reported. Nothing is flagged or interpreted, and units are never converted. Filter by `test`, `loinc`, `start`, `end`. Missing file → `available:false`. |
168
168
  | `get_check_ins` | Morning check-ins the user chose to share (`health-check-ins.json`, off by default in the app): self-rated feeling 1 to 5, note, intention and whether it happened, plus a summary. A self-rating, not a measurement. |
169
169
  | `get_cycle_context` | Day-in-cycle and coarse phase derived from logged period starts. Never predictive. |
@@ -175,7 +175,7 @@ Full reference with request/response examples: **[healthexport.dev/mcp](https://
175
175
 
176
176
  ### Data files
177
177
 
178
- The daily cache (`.health-cache.json`) and workouts cache are joined by optional context files, all optional: `health-events.json` (the logging surface), `health-profile.json` (opt-in context), `health-sessions.json` (sleep sessions), `health-cycles.json` (observed cycle starts), `health-days.json` (timezone change log), `health-readings.json` (individual blood pressure readings), `health-check-ins.json` (morning check-ins, opt-in), `health-strength.json` (the strength log, when its export is switched on), `health-labs.json` (lab results from Apple Health Records, opt-in, folder export only, never iCloud Drive), `health-tags.json` (logged tags with supplements marked, opt-in in the app) and `.health-manifest.json` (what the phone last wrote, for the stale-copy check). The server reads all of them and writes only `health-events-agent.json` (events logged with `add_event`). An absent file is reported as `available:false` with a note, never as "no data". Formats: [docs/SCHEMA-CONTRACTS.md](docs/SCHEMA-CONTRACTS.md).
178
+ The daily cache (`.health-cache.json`) and workouts cache are joined by optional context files, all optional: `health-events.json` (the logging surface), `health-profile.json` (opt-in context), `health-sessions.json` (sleep sessions), `health-cycles.json` (observed cycle starts), `health-days.json` (timezone change log), `health-readings.json` (individual blood pressure readings), `health-check-ins.json` (morning check-ins, opt-in), `health-strength.json` (the strength log, when its export is switched on, or pushed by the app over LAN when Local network > Strength log is on), `health-labs.json` (lab results from Apple Health Records, opt-in, folder export only, never iCloud Drive), `health-tags.json` (logged tags with supplements marked, opt-in in the app) and `.health-manifest.json` (what the phone last wrote, for the stale-copy check). The server reads all of them and writes only `health-events-agent.json` (events logged with `add_event`). An absent file is reported as `available:false` with a note, never as "no data". Formats: [docs/SCHEMA-CONTRACTS.md](docs/SCHEMA-CONTRACTS.md).
179
179
 
180
180
  Two patterns worth knowing: [query a parent's Apple Health from your own AI](docs/caregiver-setup.md) (consent-first, the parent's phone is the authority) and [logging data into Apple Health via Shortcuts](docs/shortcuts-write-bridge.md) (the server never writes to Apple Health).
181
181
 
@@ -283,6 +283,8 @@ exports keep working either way. A missing or wrong passphrase, or a file that f
283
283
  check, is reported as such (`get_mcp_status` says `encrypted: true` plus the reason, `--doctor` prints
284
284
  an `encryption:` line), never as "no data". The passphrase is never logged or echoed.
285
285
 
286
+ **LAN receiver and the strength log.** `POST /health-cache` (and the single text frame of `/health-cache-ws`) takes two bodies. The daily summary is merged into `.health-cache.json` as before. A document with `"type": "strength.log"` (the app's opt-in, off-by-default Local network > Strength log switch; same body as the webhook's `strength.log`, see [docs/SCHEMA-CONTRACTS.md](docs/SCHEMA-CONTRACTS.md) sections 10 and 14) is validated, rebuilt from an allowlist and written whole to `health-strength.json` in `HEALTH_DATA_DIR`, which `get_strength_log` already reads. It is a replace, never a merge, so a session deleted in the app disappears on the next push. The reply is `200 {"ok":true,"type":"strength.log","sessions":N}`, or `400` for a document with no `data.strengthLog.sessions`. Tokens, the Host/Origin checks, `HEALTH_EXPORT_PASSPHRASE` and `HEALTH_REQUIRE_ENCRYPTED=1` apply exactly as for the summary. No new tool and no new endpoint; an older receiver ignores the document.
287
+
286
288
  The LAN receiver (`HEALTH_LISTEN=1`) decrypts encrypted pushes with the same variable and answers
287
289
  `422` with a machine `reason` (`passphrase_missing`, `passphrase_mismatch`, `tampered`, `malformed`,
288
290
  `unsupported_version`, `passphrase_file_unreadable`, `encryption_required`). The pairing token rides
package/events.mjs CHANGED
@@ -609,7 +609,7 @@ export async function getStrengthLog({ exercise, start, end } = {}) {
609
609
  const wanted = exercise?.trim() ? exercise.trim().toLocaleLowerCase('en') : null;
610
610
  const file = loadStrengthFile();
611
611
  if (!file) {
612
- return absent(STRENGTH_FILE, 'The strength log appears when the user switches on Training > Include in exports in the iOS app and exports to iCloud Drive or a folder. It is off by default.');
612
+ return absent(STRENGTH_FILE, 'The strength log appears when the user switches on Training > Include in exports in the iOS app and exports to iCloud Drive or a folder, or switches on Strength log for the local network destination (Settings > Local network). It is off by default.');
613
613
  }
614
614
  const all = file.sessions.filter((s) => s && typeof s.day === 'string' && Array.isArray(s.exercises));
615
615
  let sessions = all;
Binary file
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.10.0",
5
+ "version": "1.10.2",
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 194 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. 22 tools: get_mcp_status, get_freshness, list_metrics, get_health_metrics, get_trends, compare_periods, get_structured_export, get_intraday, query_health_data, list_events, add_event, remove_event, get_profile, get_check_ins, get_workouts, get_sleep_sessions, get_strength_log, get_lab_results, get_cycle_context, list_tags, correlate_metrics, resolve_metric. Every tool is read-only except add_event and remove_event, which write only health-events-agent.json in the export folder (never Apple Health, never a file the app writes). Plus 23 MCP prompts and a --demo mode with a synthetic dataset. Get the companion iOS app on the App Store: https://apps.apple.com/app/apple-store/id6784185201?pt=129083290&ct=githubreadme&mt=8&ppid=2c28f88a-d94f-48f9-8e6a-74a8d89ce66e",
8
8
  "author": {
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.10.0",
4
+ "version": "1.10.2",
5
5
  "type": "module",
6
6
  "description": "MCP server for your own Apple Health data: 194 metrics, 22 tools (20 read-only), 23 prompts. Zero dependencies, local stdio, no account. Try it: npx -y health-export-mcp --demo",
7
7
  "keywords": [
package/receiver.mjs CHANGED
@@ -8,6 +8,11 @@
8
8
  // POST /health-cache (HTTP — reliable, works from iOS background uploads)
9
9
  // GET /health-cache-ws (WebSocket upgrade — foreground real-time push)
10
10
  // Both write <HEALTH_DATA_DIR>/.health-cache.json, which the MCP server then reads.
11
+ // Both also accept ONE typed document, `strength.log` (the app's opt-in "Strength log" switch for
12
+ // this destination): it is validated and written whole to <HEALTH_DATA_DIR>/health-strength.json,
13
+ // the file `get_strength_log` reads. Any other `type` is still treated as the daily summary (an
14
+ // unknown key is not a metric and is skipped), so a newer app never breaks an older receiver and
15
+ // the other way round.
11
16
  //
12
17
  // Security:
13
18
  // - Binds 127.0.0.1 by default; HEALTH_LISTEN_HOST=0.0.0.0 for LAN.
@@ -59,6 +64,33 @@ const isLoopback = (h) => h === '127.0.0.1' || h === '::1' || h === 'localhost';
59
64
 
60
65
  const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
61
66
 
67
+ // Source attribution (cache schema minor 2, whole-person reconciliation layer 1): `src` is the
68
+ // label of the source the day's value came from; `by` maps every source that recorded that day to
69
+ // its value. The app stamps these onto daily points and the MCP server reads them back verbatim
70
+ // (healthstore.mjs rollUp → `src`/`by`), so a LAN push MUST carry them through untouched — dropping
71
+ // them here silently strips the provenance every agent answer cites. They are still untrusted from a
72
+ // client and validated like everything else: a short non-empty label, and a flat map of short
73
+ // non-empty labels to finite numbers.
74
+ const SRC_LABEL_MAX = 64, BY_SOURCES_MAX = 64;
75
+ function sanitizeSrc(src) {
76
+ if (typeof src !== 'string') return null;
77
+ if (src.length === 0 || src.length > SRC_LABEL_MAX) return null;
78
+ return src;
79
+ }
80
+ function sanitizeBy(by) {
81
+ if (!by || typeof by !== 'object' || Array.isArray(by)) return null;
82
+ const entries = Object.entries(by);
83
+ if (entries.length === 0 || entries.length > BY_SOURCES_MAX) return null;
84
+ const out = {};
85
+ for (const [label, v] of entries) {
86
+ if (typeof label !== 'string' || label.length === 0 || label.length > SRC_LABEL_MAX) return null;
87
+ const n = Number(v);
88
+ if (!Number.isFinite(n)) return null;
89
+ out[label] = n;
90
+ }
91
+ return out;
92
+ }
93
+
62
94
  // Validate/sanitise a pushed summary so a malicious LAN client can't poison the agent's context.
63
95
  function sanitize(incoming) {
64
96
  const out = {};
@@ -77,7 +109,13 @@ function sanitize(incoming) {
77
109
  // Kept only when it is a plain ISO 8601 instant, so a pushed payload cannot smuggle anything
78
110
  // else through to what the MCP server reads.
79
111
  const r = typeof p.r === 'string' && ISO_INSTANT.test(p.r) ? p.r : null;
80
- daily.push(r ? { d: p.d, v, r } : { d: p.d, v });
112
+ const point = { d: p.d, v };
113
+ if (r) point.r = r;
114
+ const src = sanitizeSrc(p.src);
115
+ if (src !== null) point.src = src;
116
+ const by = sanitizeBy(p.by);
117
+ if (by !== null) point.by = by;
118
+ daily.push(point);
81
119
  }
82
120
  if (!daily.length) continue;
83
121
  out[name] = { unit: typeof m.unit === 'string' ? m.unit.slice(0, 32) : '', cumulative: !!m.cumulative, daily };
@@ -134,19 +172,135 @@ export function mergeCache(existing, incoming) {
134
172
  }
135
173
  for (const p of m.daily) {
136
174
  const prev = byDay.get(p.d);
137
- const keep = !p.r && typeof prev?.r === 'string' && prev.v === p.v;
138
- byDay.set(p.d, keep ? { ...p, r: prev.r } : p);
175
+ // A day keeps its recompute stamp (`r`) and source attribution (`src`/`by`) across a later
176
+ // push that re-sends the same value without them, matching HealthCache.merge on the app side;
177
+ // a changed value makes both stale, so they are replaced by the incoming point's own (or none).
178
+ const same = prev && prev.v === p.v;
179
+ const keep = same && !p.r && typeof prev.r === 'string';
180
+ const mergedPoint = { ...p };
181
+ if (keep) mergedPoint.r = prev.r;
182
+ if (same && !('src' in mergedPoint) && typeof prev.src === 'string') mergedPoint.src = prev.src;
183
+ if (same && !('by' in mergedPoint) && prev.by && typeof prev.by === 'object' && !Array.isArray(prev.by)) mergedPoint.by = prev.by;
184
+ byDay.set(p.d, mergedPoint);
139
185
  }
140
186
  merged[name] = { unit: m.unit || merged[name]?.unit || '', cumulative: !!m.cumulative,
141
187
  daily: [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0]))
142
- .map(([d, p]) => (typeof p.r === 'string' ? { d, v: p.v, r: p.r } : { d, v: p.v })) };
188
+ .map(([d, p]) => {
189
+ const out = { d, v: p.v };
190
+ if (typeof p.r === 'string') out.r = p.r;
191
+ if (typeof p.src === 'string') out.src = p.src;
192
+ if (p.by && typeof p.by === 'object' && !Array.isArray(p.by)) out.by = p.by;
193
+ return out;
194
+ }) };
143
195
  }
144
196
  return merged;
145
197
  }
146
198
 
199
+
200
+ // ---- strength.log (typed document, docs/SCHEMA-CONTRACTS.md sections 10 and 14) ----------------
201
+ //
202
+ // The app sends the SAME body the webhook gets: {type:"strength.log", schema:1, id, app, sentAt,
203
+ // data:{metrics:[], strengthLog:{mode:"replace", unit, formula, sessions, records, routines}}}.
204
+ // It is a whole-state replace, so this writes health-strength.json (the exact shape the file export
205
+ // writes: schema, app, writtenAt, unit, formula, sessions, records, routines) and never merges.
206
+ // Everything is rebuilt field by field from an allowlist: the body comes from a LAN client and ends
207
+ // up in an agent's context, so unknown keys are dropped, strings are length-capped, numbers must be
208
+ // finite and the sizes are bounded.
209
+ export const STRENGTH_TYPE = 'strength.log';
210
+ export const STRENGTH_FILE = 'health-strength.json';
211
+ const MAX_SESSIONS = 20000, MAX_EXERCISES = 100, MAX_SETS = 300, MAX_RECORDS = 2000, MAX_ROUTINES = 500, MAX_REP_BESTS = 10;
212
+ const str = (v, max) => (typeof v === 'string' && v.length > 0 ? v.slice(0, max) : undefined);
213
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
214
+ const dayStr = (v) => (typeof v === 'string' && DAY_RE.test(v) ? v : undefined);
215
+ const pick = (src, spec) => {
216
+ const out = {};
217
+ for (const [k, f] of Object.entries(spec)) { const v = f(src?.[k]); if (v !== undefined) out[k] = v; }
218
+ return out;
219
+ };
220
+ const sName = (v) => str(v, 120);
221
+ const setSpec = {
222
+ reps: num, kg: num, rpe: num,
223
+ kind: (v) => (v === 'warmup' || v === 'working' ? v : undefined),
224
+ };
225
+ const cleanSets = (a) => (Array.isArray(a) ? a.slice(0, MAX_SETS).filter((x) => x && typeof x === 'object')
226
+ .map((x) => pick(x, setSpec)).filter((x) => x.reps !== undefined && x.kind) : []);
227
+ const cleanExercises = (a, extra) => (Array.isArray(a) ? a.slice(0, MAX_EXERCISES).map((e) => {
228
+ const name = sName(e?.name);
229
+ return name ? { name, ...pick(e, extra), sets: cleanSets(e.sets) } : null;
230
+ }).filter(Boolean) : []);
231
+ const sessionSpec = {
232
+ id: (v) => str(v, 64), start: (v) => str(v, 40), day: dayStr, minutes: num, effort: num,
233
+ workoutId: (v) => str(v, 64), routineId: (v) => str(v, 64), notes: (v) => str(v, 2000),
234
+ volumeKg: num, workingSets: num,
235
+ };
236
+ const recordSpec = {
237
+ exercise: sName, bestEstimateKg: num, bestEstimateDay: dayStr, heaviestKg: num, sessions: num, lastDay: dayStr,
238
+ bestSetVolumeKg: num, bestSetVolumeDay: dayStr, bestSessionVolumeKg: num, bestSessionVolumeDay: dayStr,
239
+ };
240
+ const routineSpec = { id: (v) => str(v, 64), name: (v) => str(v, 120), createdAt: (v) => str(v, 40), lastUsedDay: dayStr };
241
+
242
+ /// Rebuild the strength log from a pushed `strength.log` document, or throw if it has no sessions list.
243
+ export function sanitizeStrengthLog(doc, now = new Date()) {
244
+ const log = doc?.data?.strengthLog;
245
+ if (!log || typeof log !== 'object' || !Array.isArray(log.sessions)) {
246
+ throw Object.assign(new Error('strength.log without data.strengthLog.sessions'), { status: 400 });
247
+ }
248
+ const sessions = log.sessions.slice(0, MAX_SESSIONS).filter((s) => s && typeof s === 'object' && dayStr(s.day)).map((s) => ({
249
+ ...pick(s, sessionSpec),
250
+ exercises: cleanExercises(s.exercises, { volumeKg: num, bestEstimateKg: num }),
251
+ }));
252
+ const records = (Array.isArray(log.records) ? log.records.slice(0, MAX_RECORDS) : []).filter((r) => r && sName(r.exercise)).map((r) => {
253
+ const o = pick(r, recordSpec);
254
+ const reps = (Array.isArray(r.repBests) ? r.repBests.slice(0, MAX_REP_BESTS) : [])
255
+ .map((b) => pick(b, { reps: num, kg: num, day: dayStr })).filter((b) => b.reps !== undefined && b.kg !== undefined && b.day);
256
+ if (reps.length) o.repBests = reps;
257
+ return o;
258
+ });
259
+ const routines = (Array.isArray(log.routines) ? log.routines.slice(0, MAX_ROUTINES) : []).filter((r) => r && sName(r.name)).map((r) => ({
260
+ ...pick(r, routineSpec), exercises: cleanExercises(r.exercises, {}),
261
+ }));
262
+ const sent = typeof doc.sentAt === 'string' && ISO_INSTANT.test(doc.sentAt) ? doc.sentAt : null;
263
+ return {
264
+ schema: 1,
265
+ app: str(doc.app, 32) ?? '',
266
+ writtenAt: sent ?? now.toISOString().replace(/\.\d+Z$/, 'Z'),
267
+ unit: 'kg',
268
+ ...(str(log.formula, 32) && { formula: str(log.formula, 32) }),
269
+ sessions, records, routines,
270
+ };
271
+ }
272
+
273
+ function writeStrength(dir, doc, passphrase = configuredPassphrase(), sealFresh = false) {
274
+ const file = path.join(dir, STRENGTH_FILE);
275
+ const clean = sanitizeStrengthLog(doc);
276
+ fs.mkdirSync(dir, { recursive: true });
277
+ // Same at-rest rule as the cache: an existing envelope keeps its salt (the app and this server
278
+ // hold the key for that salt), a fresh file is sealed when encryption is required.
279
+ const base = readMergeBase(file, null, passphrase);
280
+ const text = JSON.stringify(clean, null, 2);
281
+ const out = sealOutput(base.sealedLike, text, passphrase, sealFresh);
282
+ if (Buffer.byteLength(out) > MAX_CACHE_FILE) throw new Error('strength log would exceed the size ceiling');
283
+ atomicWrite(file, out);
284
+ log(`strength.log -> ${clean.sessions.length} sessions in ${file}`);
285
+ return clean.sessions.length;
286
+ }
287
+
288
+ function sealOutput(sealedLike, text, passphrase, sealFresh) {
289
+ if (sealedLike || (sealFresh && passphrase)) {
290
+ return JSON.stringify(sealEnvelope(text, passphrase, { salt: sealedLike?.salt, iter: sealedLike?.iter }));
291
+ }
292
+ if (sealFresh) throw new Error('encryption required but no passphrase configured - refusing to store plaintext');
293
+ return text;
294
+ }
295
+
296
+ function atomicWrite(file, out) {
297
+ const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`; // unique → no concurrent-write race
298
+ fs.writeFileSync(tmp, out, { mode: 0o600 });
299
+ fs.renameSync(tmp, file);
300
+ }
301
+
147
302
  function writeCache(dir, json, passphrase = configuredPassphrase(), sealFresh = false) {
148
303
  const file = path.join(dir, '.health-cache.json');
149
- const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`; // unique → no concurrent-write race
150
304
  fs.mkdirSync(dir, { recursive: true });
151
305
  const base = readMergeBase(file, {}, passphrase);
152
306
  const merged = mergeCache(base.value, json);
@@ -157,19 +311,11 @@ function writeCache(dir, json, passphrase = configuredPassphrase(), sealFresh =
157
311
  // every incoming push was encrypted — the flag promised full at-rest encryption and silently
158
312
  // delivered plaintext. Refuse to store plaintext when encryption is required but no passphrase
159
313
  // is configured: a misconfiguration, not a downgrade path.
160
- let out;
161
- if (base.sealedLike || (sealFresh && passphrase)) {
162
- out = JSON.stringify(sealEnvelope(text, passphrase, { salt: base.sealedLike?.salt, iter: base.sealedLike?.iter }));
163
- } else if (sealFresh) {
164
- throw new Error('encryption required but no passphrase configured - refusing to store plaintext');
165
- } else {
166
- out = text;
167
- }
314
+ const out = sealOutput(base.sealedLike, text, passphrase, sealFresh);
168
315
  if (Buffer.byteLength(out) > MAX_CACHE_FILE) {
169
316
  throw new Error('cache would exceed the size ceiling; refusing to grow it further');
170
317
  }
171
- fs.writeFileSync(tmp, out, { mode: 0o600 });
172
- fs.renameSync(tmp, file);
318
+ atomicWrite(file, out);
173
319
  log(`merged ${Object.keys(sanitize(json)).length} -> ${Object.keys(merged).length} metrics in ${file}`);
174
320
  return Object.keys(merged).length;
175
321
  }
@@ -185,6 +331,8 @@ export function startReceiver({
185
331
  // passes HEALTH_REQUIRE_ENCRYPTED through to first-write sealing, closing the fresh-dir
186
332
  // plaintext-at-rest gap.
187
333
  onCache = (json) => writeCache(dir, json, passphrase(), requireEncrypted()),
334
+ // The `strength.log` typed document (whole-state replace into health-strength.json). Tests inject one.
335
+ onStrength = (doc) => writeStrength(dir, doc, passphrase(), requireEncrypted()),
188
336
  // A function, read per request: the environment is the source in production, tests inject one.
189
337
  passphrase = () => configuredPassphrase(),
190
338
  requireEncrypted = () => process.env.HEALTH_REQUIRE_ENCRYPTED === '1',
@@ -260,6 +408,11 @@ export function startReceiver({
260
408
  return finish(422, { ok: false, hae: true, reason, error: e instanceof EnvelopeError ? e.message : 'could not decrypt' });
261
409
  }
262
410
  }
411
+ if (parsed?.type === STRENGTH_TYPE) {
412
+ try { const n = onStrength(parsed); finish(200, { ok: true, hae: true, type: STRENGTH_TYPE, sessions: n }); }
413
+ catch (e) { log('strength.log refused', e.message); finish(e.status || 500, { ok: false, hae: true, error: 'strength.log: ' + (e.code || e.message) }); }
414
+ return;
415
+ }
263
416
  try { const n = onCache(parsed); finish(200, { ok: true, hae: true, metrics: n }); }
264
417
  catch (e) { log('write failed', e.message); finish(500, { ok: false, hae: true, error: 'write failed: ' + (e.code || e.message) }); } // 500 = disk/write
265
418
  });
@@ -297,8 +450,13 @@ export function startReceiver({
297
450
  let parsedMsg = JSON.parse(msg);
298
451
  if (requireEncrypted() && !isEnvelope(parsedMsg)) throw new Error('plaintext push refused (HEALTH_REQUIRE_ENCRYPTED)');
299
452
  if (isEnvelope(parsedMsg)) parsedMsg = openEnvelopeJSON(parsedMsg, passphrase());
300
- const n = onCache(parsedMsg);
301
- socket.write(encodeTextFrame(JSON.stringify({ ok: true, metrics: n })));
453
+ if (parsedMsg?.type === STRENGTH_TYPE) {
454
+ const n = onStrength(parsedMsg);
455
+ socket.write(encodeTextFrame(JSON.stringify({ ok: true, type: STRENGTH_TYPE, sessions: n })));
456
+ } else {
457
+ const n = onCache(parsedMsg);
458
+ socket.write(encodeTextFrame(JSON.stringify({ ok: true, metrics: n })));
459
+ }
302
460
  }
303
461
  catch (e) { log('bad WS json', e.message); socket.write(encodeTextFrame(JSON.stringify({ ok: false }))); }
304
462
  socket.end();
package/server.json CHANGED
@@ -8,13 +8,13 @@
8
8
  "url": "https://github.com/PhilipAD/health-export-mcp",
9
9
  "source": "github"
10
10
  },
11
- "version": "1.10.0",
11
+ "version": "1.10.2",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "registryBaseUrl": "https://registry.npmjs.org",
16
16
  "identifier": "health-export-mcp",
17
- "version": "1.10.0",
17
+ "version": "1.10.2",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },
@@ -41,9 +41,9 @@
41
41
  },
42
42
  {
43
43
  "registryType": "mcpb",
44
- "identifier": "https://github.com/PhilipAD/health-export-mcp/releases/download/v1.10.0/health-export.mcpb",
45
- "version": "1.10.0",
46
- "fileSha256": "85cb4acc408ee1f9ba35150c7cf397a970c53fbeb2f58b2886faf6e74d1e28a9",
44
+ "identifier": "https://github.com/PhilipAD/health-export-mcp/releases/download/v1.10.2/health-export.mcpb",
45
+ "version": "1.10.2",
46
+ "fileSha256": "ed575969205de53d77611fd674ced59bc48023e5e34d6230f120bec46e1e0b94",
47
47
  "transport": {
48
48
  "type": "stdio"
49
49
  },
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.10.0' };
24
+ const SERVER = { name: 'health-export-ai', version: '1.10.2' };
25
25
  const DEFAULT_PROTOCOL = '2025-06-18';
26
26
  const log = (...a) => process.stderr.write('[mcp] ' + a.join(' ') + '\n');
27
27