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 +5 -3
- package/events.mjs +1 -1
- package/health-export.mcpb +0 -0
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/receiver.mjs +175 -17
- package/server.json +5 -5
- package/server.mjs +1 -1
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;
|
package/health-export.mcpb
CHANGED
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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]) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
45
|
-
"version": "1.10.
|
|
46
|
-
"fileSha256": "
|
|
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.
|
|
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
|
|