health-export-mcp 1.6.7 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -158,7 +158,7 @@ Full reference with request/response examples: **[healthexport.dev/mcp](https://
158
158
  | `get_sleep_sessions` | Clustered sleep sessions attributed to the waking day; split nights returned as-is. |
159
159
  | `get_cycle_context` | Day-in-cycle and coarse phase derived from logged period starts. Never predictive. |
160
160
  | `correlate_metrics` | Pearson correlation between two metrics with a 0..3 day lag. Association, not causation, always stated. |
161
- | `resolve_metric` | Canonical name and unit for any spelling (`steps`, `StepCount`, `Body Weight`, `hrv`), unit variants with exact formulas, an optional `{value, unit}` conversion, and whether the export holds it. Ambiguous words come back with candidates, never guessed. The data tools accept the same aliases and report `resolvedFrom`. |
161
+ | `resolve_metric` | Canonical name and unit for any spelling (`steps`, `StepCount`, `Body Weight`, `hrv`), unit variants with exact formulas, an optional `{value, unit}` conversion, and whether the export holds it. Ambiguous words come back with candidates, never guessed. The data tools accept the same aliases and report `resolvedFrom`. When the export carries per-source attribution (app builds that write `src`/`by` on daily points and a `health-sources.json`, cache schema minor 2), the answer adds a `sources` block naming the devices that fed the export, which one won each metric and the priority rules, and `sourceMerge` reports disagreement as visible; older exports report it as not available. |
162
162
 
163
163
  **Coverage:** 190 Apple Health metrics across activity, heart, HRV, mobility, respiratory, body, sleep, hearing, and nutrition, plus workouts. Data answers carry honest `coverage` blocks, and single-metric answers list logged events inside the window as `segmentBoundaries` so an average across a medication start or life change cannot masquerade as one regime.
164
164
 
@@ -209,6 +209,18 @@ node server.mjs --help
209
209
 
210
210
  ---
211
211
 
212
+ ## Building a wellness or coaching product?
213
+
214
+ This server reads a file the MetricBridge iOS app writes **on the user's own device**, with no central
215
+ health-data processor in the path. The same app can POST signed JSON (Standard Webhooks v1, published
216
+ JSON Schemas) straight to **your** endpoint, so your users bring their own Apple Health to your product
217
+ without an SDK in your app and without any vendor server holding the data. The offer, flat pricing and
218
+ boundaries: [healthexport.dev/platforms](https://www.healthexport.dev/platforms/). The integration guide
219
+ and the zero-dependency conformance kit: [healthexport.dev/docs/platforms](https://www.healthexport.dev/docs/platforms/).
220
+ Wellness-scoped and end-user-directed; no clinical or HIPAA work.
221
+
222
+ ---
223
+
212
224
  ## The app that feeds it
213
225
 
214
226
  <p align="center">
@@ -227,11 +239,16 @@ node server.mjs --help
227
239
 
228
240
  ## Privacy & security
229
241
 
242
+ The one non-health thing the server reads from the export is `_meta.license`, a marker the app writes
243
+ with exactly two shapes: `{"state": "trial", "daysRemaining": N}` or `{"state": "unlocked"}`. A fresh
244
+ export from a trial adds one sentence to `get_freshness` naming when the trial ends; an unlocked export
245
+ adds nothing. No network call is involved and nothing is sent anywhere.
246
+
230
247
  - **Read-only.** The server only reads your exported data, it never touches HealthKit and never writes back.
231
248
  - **Local-first.** It runs on your machine over stdio. There is **no developer server** in the path.
232
249
  - **Optional pairing.** Set `PAIRING_SECRET` to the code the iOS app shows (Settings → Agent pairing) to gate access.
233
250
  - **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).
234
- - **No telemetry.** The server makes no network requests. When it finds no usable export (none yet, a stale one, or `--demo`), `get_mcp_status` includes one static link to the companion app's website. That link carries fixed campaign labels (`utm_source=mcp`, `utm_medium=agent-tool`, `utm_campaign=mcp-unlock`) that are identical for every user and contain nothing about you or your data; they only count if you choose to open it.
251
+ - **No telemetry.** The server makes no network requests. `get_mcp_status` always includes one static link to the companion app's website (and its App Store page), so the bridge is attributable back to install. Both links carry fixed campaign labels (`utm_source=mcp`, `utm_medium=agent-tool`, `utm_campaign=mcp-unlock`; `ct=mcpserver`, `ppid=…`) that are identical for every user and contain nothing about you or your data; they only count if you choose to open one.
235
252
  - **Auditable.** Zero dependencies and a few hundred lines of readable JavaScript, read every line.
236
253
  - **Signed releases.** Hosted artifacts are minisign-signed and checksummed, see [Verifying releases](#verifying-releases).
237
254
 
Binary file
package/healthstore.mjs CHANGED
@@ -65,11 +65,15 @@ export const UNLOCK_URL = 'https://www.healthexport.dev/?utm_source=mcp&utm_medi
65
65
  // arrives from an MCP client sees the page written for them. Text only, like UNLOCK_URL.
66
66
  export const APP_STORE_URL = 'https://apps.apple.com/app/apple-store/id6784185201?pt=129083290&ct=mcpserver&mt=8&ppid=2c28f88a-d94f-48f9-8e6a-74a8d89ce66e';
67
67
 
68
- /** The user-facing app card. get_mcp_status (the tool agents call first) returns it ONLY when this
69
- * machine has no usable export: none at all, a stale one (a paused trial stops the writes), or the
70
- * synthetic demo dataset. A user with a working export already has the app, and a link on every
71
- * answer would be promotion rather than a description of what is missing, which is what MCP
72
- * directory review rules object to. Exported so the tests read the same string. */
68
+ /** The user-facing app card. get_mcp_status (the tool agents call first) returns it on EVERY
69
+ * answer, not only when this machine has no usable export. The old gate (link only for no export /
70
+ * stale / demo) hid the one attributable link to the app exactly where it mattered: the common
71
+ * real-world order is buy the app, export, then wire up the server, so that user always had a
72
+ * usable export and never saw the payment surface. Measured result: 1 visit to the tagged link in
73
+ * 90 days against ~290 npm installs/week. The card is text only and describes the companion app
74
+ * that wrote the export; the sole exception is status()'s encrypted early return, where the export
75
+ * came from the app and the fix is the passphrase, not a link. Exported so the tests read the same
76
+ * string. */
73
77
  export const APP_INFO = {
74
78
  name: 'MetricBridge',
75
79
  unlockUrl: UNLOCK_URL,
@@ -258,6 +262,51 @@ export function cacheMeta() {
258
262
  return raw && typeof raw._meta === 'object' ? raw._meta : null;
259
263
  }
260
264
 
265
+ /** The per-source attribution file (Track B3, cache schema minor 2): which devices feed the
266
+ * export, which source won each metric, and the priority rules. Absent before app builds that
267
+ * ship source reconciliation, so every consumer treats it as optional and degrades to today's
268
+ * single-source behaviour when it is not there. */
269
+ export function loadSources() {
270
+ if (DEMO || !pairing().ok) return null;
271
+ return readJSONCached(path.join(DATA_DIR, 'health-sources.json'), null);
272
+ }
273
+
274
+ /** True when this export carries per-source attribution: either the app stamped schemaMinor 2 onto
275
+ * the cache (daily points then carry `src`/`by`) or a health-sources.json is present. Both are
276
+ * additive and independent, so either is enough to flip the sourceMerge surface from the
277
+ * "per-source values are not in the export" baseline to "available". */
278
+ export function hasSourceAttribution() {
279
+ if (DEMO || !pairing().ok) return false;
280
+ if (Number(cacheMeta()?.schemaMinor ?? 0) >= 2) return true;
281
+ return fileExists('health-sources.json');
282
+ }
283
+
284
+ /** Compact per-source summary for tool answers (schema minor 2). Null when there is no attribution,
285
+ * so an answer stays byte-for-byte what a pre-B3 reader produced when the export predates it. */
286
+ export function sourcesBlock() {
287
+ const s = loadSources();
288
+ if (!s || typeof s !== 'object') return null;
289
+ const metrics = {};
290
+ for (const [name, m] of Object.entries(s.metrics ?? {})) {
291
+ metrics[name] = {
292
+ rule: m.rule,
293
+ ...(m.priority && { priority: m.priority }),
294
+ ...(m.comparableAcrossSources === false && { comparableAcrossSources: false }),
295
+ ...(m.daysBySource && { daysBySource: m.daysBySource }),
296
+ ...(m.multiSourceDays != null && { multiSourceDays: m.multiSourceDays }),
297
+ ...(m.alsoRecordedDays != null && { alsoRecordedDays: m.alsoRecordedDays }),
298
+ ...(m.mergedDays != null && { mergedDays: m.mergedDays }),
299
+ };
300
+ }
301
+ return {
302
+ sourceCount: s.sourceCount,
303
+ sources: (s.sources ?? []).map((x) => ({ label: x.label, class: x.class, days: x.days, firstDay: x.firstDay, lastDay: x.lastDay })),
304
+ metrics,
305
+ ...(s.workouts && typeof s.workouts === 'object' && { workouts: s.workouts }),
306
+ ...(s.rules && { rules: s.rules }),
307
+ };
308
+ }
309
+
261
310
  // ---- Late-arrival recompute provenance (cache schema minor 1, docs/SCHEMA-CONTRACTS.md s.9) ----
262
311
  // The app re-reads only the trailing days on each run. When Apple Health later receives samples
263
312
  // dated BEFORE that window (a scale or toothbrush syncing after a gap, a back-logged entry), or
@@ -415,7 +464,13 @@ function rollUp(points, granularity, cumulative) {
415
464
  // A day rebuilt after late or deleted samples carries `recomputed_at`; an untouched day carries
416
465
  // nothing extra, so the two are distinguishable point by point (schema minor 1).
417
466
  if (granularity === 'day') {
418
- return points.map((p) => ({ date: p.d, value: round(p.v), ...(typeof p.r === 'string' && { recomputed_at: p.r }) }));
467
+ return points.map((p) => ({
468
+ date: p.d,
469
+ value: round(p.v),
470
+ ...(typeof p.r === 'string' && { recomputed_at: p.r }),
471
+ ...(typeof p.src === 'string' && { src: p.src }),
472
+ ...(p.by && typeof p.by === 'object' && !Array.isArray(p.by) && { by: p.by }),
473
+ }));
419
474
  }
420
475
  const buckets = new Map();
421
476
  for (const p of points) {
@@ -504,14 +559,12 @@ export async function status() {
504
559
  if (d && (!lastDate || d > lastDate)) lastDate = d;
505
560
  }
506
561
  const fresh = await freshness({ detail: false });
507
- // Locked is neither case: a pairing gate file exists only because the app wrote it.
508
- const noUsableExport = DEMO || (p.ok && (names.length === 0 || fresh.stale === true));
509
562
  return {
510
563
  ok: names.length > 0 && p.ok,
511
564
  source: sourceLabel(),
512
- // The campaign-tagged companion-app link, only when there is no usable export (see APP_INFO).
513
- // Additive and text only: no runtime network call is made.
514
- ...(noUsableExport && { app: APP_INFO }),
565
+ // The campaign-tagged companion-app link, on EVERY status answer (see APP_INFO). It is text
566
+ // only and additive: no runtime network call is made, and the user's own data is untouched.
567
+ app: APP_INFO,
515
568
  // Demo mode is reported LOUDLY and first-class here, not only in the per-answer watermark:
516
569
  // get_mcp_status is the tool agents are told to call first, so this is where a human debugging
517
570
  // "why do my numbers look wrong" finds out they are not their numbers at all.
@@ -567,14 +620,20 @@ export async function status() {
567
620
  // Additive presence flags for the optional context files, so an agent knows which of the
568
621
  // context tools can answer before calling them. Absence means "not exported", never "none".
569
622
  contextFiles: DEMO
570
- ? { events: true, profile: true, sessions: true, cycles: true, days: true }
623
+ ? { events: true, profile: true, sessions: true, cycles: true, days: true, sources: false }
571
624
  : {
572
625
  events: !!ev.loadEventsFile(),
573
626
  profile: fileExists('health-profile.json'),
574
627
  sessions: fileExists('health-sessions.json'),
575
628
  cycles: fileExists('health-cycles.json'),
576
629
  days: !!ev.loadDaysFile(),
630
+ // health-sources.json (schema minor 2): which devices feed the export and which won each
631
+ // metric. False means the app build that wrote this export predates source attribution.
632
+ sources: fileExists('health-sources.json'),
577
633
  },
634
+ // Additive: true when daily points carry src/by or health-sources.json exists, so an agent
635
+ // knows before calling resolve_metric whether per-source disagreement is visible in this export.
636
+ sourceAttribution: hasSourceAttribution(),
578
637
  metrics: names.sort(),
579
638
  };
580
639
  }
@@ -601,6 +660,7 @@ export const DATA_FILES = [
601
660
  'health-sessions.json',
602
661
  'health-cycles.json',
603
662
  'health-days.json',
663
+ 'health-sources.json',
604
664
  ];
605
665
 
606
666
  /** One daily automation run plus two hours of slack: iOS schedules background work loosely, so a
@@ -743,6 +803,19 @@ export async function freshness({ maxAgeHours, detail = true, now = Date.now() }
743
803
  // be read (an encrypted export without its passphrase): the app wrote that file, so the fix is
744
804
  // the passphrase, not the app -- the same rule as status()'s encrypted early return.
745
805
  const trialHint = stale && rawAge > Math.max(staleAfterHours(), DEFAULT_STALE_AFTER_HOURS) && !cacheReadError;
806
+ // The other half of the money moment: a FRESH export written by an app still inside its 7-day
807
+ // free trial (the stale/trialHint path above only reaches a trial that already ENDED). The app
808
+ // stamps `_meta.license` (non-health metadata) onto the cache from 1.x onward; an older cache
809
+ // simply lacks it and this degrades to the status quo. We add ONE sentence to the prose. The
810
+ // neutral `app` card is returned on every status answer regardless (see APP_INFO); the trial
811
+ // wording itself appears only here, only when the marker says trial, never for an unlocked app
812
+ // or an older export without the marker.
813
+ // Read the marker only when the cache is readable: cacheMeta() re-reads the file, which throws
814
+ // for an encrypted export without its passphrase (status() has its own early return for that).
815
+ let license = null;
816
+ if (!stale && !cacheReadError) { try { license = cacheMeta()?.license ?? null; } catch { license = null; } }
817
+ const midTrial = license?.state === 'trial' && Number.isInteger(license?.daysRemaining) && license.daysRemaining >= 0;
818
+ const trialEnds = midTrial ? (license.daysRemaining === 0 ? 'ends today' : `ends in ${license.daysRemaining} day${license.daysRemaining === 1 ? '' : 's'}`) : '';
746
819
  const readNote = cacheReadError
747
820
  ? ` The daily cache could not be read (${cacheReadError}), so data tools will fail until that is fixed; run get_mcp_status for the details.`
748
821
  : '';
@@ -767,7 +840,10 @@ export async function freshness({ maxAgeHours, detail = true, now = Date.now() }
767
840
  // prose is what makes the agent's answer carry the tagged link, not just the nested field.
768
841
  ? `The newest export is ${age} hours old, older than the ${threshold}-hour window. Answers only cover data up to ${lastDataDate ?? 'the last export'}; if current numbers matter, ask the user to open MetricBridge or run an export, then check again.`
769
842
  + (trialHint ? ` MetricBridge also stops exporting when its free trial ends; if that is why, the one-time unlock inside the iOS app resumes the exports (app website: ${UNLOCK_URL}).` : '')
770
- : `Within the ${threshold}-hour freshness window: the newest export landed ${age} hours ago.`)
843
+ : `Within the ${threshold}-hour freshness window: the newest export landed ${age} hours ago.`
844
+ + (midTrial
845
+ ? ` MetricBridge's free trial ${trialEnds}; the one-time unlock inside the iOS app keeps exports running after that (app website: ${UNLOCK_URL}).`
846
+ : ''))
771
847
  + readNote,
772
848
  };
773
849
  }
@@ -1415,6 +1491,8 @@ export async function resolveMetric({ name, value, unit } = {}) {
1415
1491
  const present = unlocked ? await loadMetrics() : null;
1416
1492
  const inExport = (id) => (present ? !!present[id] : null);
1417
1493
  const lockedNote = unlocked ? undefined : 'Pairing is locked, so whether each metric is in this export is unknown (null). The naming and unit table itself does not depend on your data.';
1494
+ const sourceMerge = norm.sourceMerge(hasSourceAttribution());
1495
+ const sources = sourcesBlock();
1418
1496
  const r = norm.lookup(requested);
1419
1497
  // A name the export holds but the catalog does not (an older or newer app's metric) is still a
1420
1498
  // real metric: report it as present with its stored unit rather than "unknown".
@@ -1426,7 +1504,7 @@ export async function resolveMetric({ name, value, unit } = {}) {
1426
1504
  unit: m.unit || '', cumulative: !!m.cumulative, inExport: true, coverage: coverageOf(m),
1427
1505
  note: 'This metric is in the export under exactly this name but is not in this server\'s canonical table (the export may come from a newer app or another tool), so no aliases or unit variants are listed for it.'
1428
1506
  + (r.status === 'resolved' ? ` The table maps this spelling to "${r.metric}", which is a separate entry; the data tools use the exact name "${requested}".` : ''),
1429
- sourceMerge: norm.SOURCE_MERGE,
1507
+ sourceMerge,
1430
1508
  };
1431
1509
  }
1432
1510
  if (r.status !== 'resolved') {
@@ -1465,7 +1543,8 @@ export async function resolveMetric({ name, value, unit } = {}) {
1465
1543
  unitMismatch: `The export stores this metric in "${present[row.id].unit || ''}", not the canonical "${row.unit}". Values are served exactly as stored; nothing was converted.`,
1466
1544
  }),
1467
1545
  ...(row.id === 'heart_rate_variability' && { measureNote: 'SDNN in milliseconds, as Apple Health records it. Not comparable with RMSSD values from other devices.' }),
1468
- sourceMerge: norm.SOURCE_MERGE,
1546
+ sourceMerge,
1547
+ ...(sources && { sources }),
1469
1548
  ...(lockedNote && { lockedNote }),
1470
1549
  };
1471
1550
  if (value != null) out.conversion = { input: { value, unit: String(unit) }, ...norm.convertToCanonical(row.id, value, unit) };
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.6.7",
5
+ "version": "1.7.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. 16 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, get_profile, get_workouts, get_sleep_sessions, get_cycle_context, correlate_metrics, resolve_metric. 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/apple-store/id6784185201?pt=129083290&ct=githubreadme&mt=8&ppid=2c28f88a-d94f-48f9-8e6a-74a8d89ce66e",
8
8
  "author": {
package/normalize.mjs CHANGED
@@ -372,3 +372,18 @@ export const SOURCE_MERGE = {
372
372
  disagreementDetection: 'not_available',
373
373
  note: 'Each daily value in this export is the single figure Apple Health produces after merging every source on the device (for example an iPhone and a Watch counting the same steps). The export holds one value per metric per day, not one per source, so it cannot show whether two sources disagreed, and nothing here adjusts values to make sources agree.',
374
374
  };
375
+
376
+ /** The sourceMerge surface for an export that DOES carry per-source attribution (schema minor 2,
377
+ * written by app builds shipping Track B3 source reconciliation). Each daily point then carries
378
+ * `src` (the source whose value won) and `by` (every source's own value when two or more recorded
379
+ * the day), and a health-sources.json beside the cache lists which devices feed the export, which
380
+ * source wins each metric, and the priority rules. Disagreement between sources is then visible
381
+ * rather than pre-merged out of sight, so the answer stops claiming it cannot be seen. */
382
+ export function sourceMerge(hasAttribution) {
383
+ if (!hasAttribution) return SOURCE_MERGE;
384
+ return {
385
+ perSourceValuesInExport: true,
386
+ disagreementDetection: 'available',
387
+ note: 'This export records per-source values: each daily point carries `src` (the source whose value was used) and, when two or more sources recorded the day, `by` (every source that recorded, the winner included). health-sources.json lists which devices feed the export, which source wins each metric and the priority rules, so an agent can say which source a value came from and where two sources disagreed.',
388
+ };
389
+ }
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.6.7",
4
+ "version": "1.7.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": [
@@ -106,4 +106,4 @@
106
106
  "engines": {
107
107
  "node": ">=18"
108
108
  }
109
- }
109
+ }
package/server.json CHANGED
@@ -8,22 +8,22 @@
8
8
  "url": "https://github.com/PhilipAD/health-export-mcp",
9
9
  "source": "github"
10
10
  },
11
- "version": "1.6.7",
11
+ "version": "1.7.0",
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.6.7",
17
+ "version": "1.7.0",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  }
21
21
  },
22
22
  {
23
23
  "registryType": "mcpb",
24
- "identifier": "https://github.com/PhilipAD/health-export-mcp/releases/download/v1.6.7/health-export.mcpb",
25
- "version": "1.6.7",
26
- "fileSha256": "194f9c3ecf81307bac975f753e833beba563d024dfa53646a007b63586817890",
24
+ "identifier": "https://github.com/PhilipAD/health-export-mcp/releases/download/v1.7.0/health-export.mcpb",
25
+ "version": "1.7.0",
26
+ "fileSha256": "167102086bcf5551fadc3ac56d8745088279f4ed71388b425a70433348dd1fe9",
27
27
  "transport": {
28
28
  "type": "stdio"
29
29
  }
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.6.7' };
24
+ const SERVER = { name: 'health-export-ai', version: '1.7.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, 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. When there is no usable export (none found, a stale one, or demo data), the result also includes `app.unlockUrl` and `app.appStoreUrl`, the website and App Store page of the companion iOS app that writes the export; both links carry fixed campaign attribution labels.',
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. The result also includes `app.unlockUrl` and `app.appStoreUrl`, the website and App Store page of the companion iOS app that writes the export; both links carry fixed campaign attribution labels.',
61
61
  inputSchema: { type: 'object', properties: {} },
62
62
  handler: () => store.status(),
63
63
  },
@@ -244,7 +244,7 @@ const TOOLS = [
244
244
  // ---- canonical naming and units (cross-source normalisation, first slice) ----
245
245
  {
246
246
  name: 'resolve_metric',
247
- description: "Look a metric name up in the canonical naming and unit table. Accepts the export's own name (step_count), a HealthKit identifier (StepCount, HKQuantityTypeIdentifierStepCount), a display title (Step Count) or a common alias (steps, hrv, spo2, weight) and returns the canonical metric, its unit (percentages are 0 to 1 fractions), the unit variants other apps use with the exact formula into the canonical unit, and whether this export holds it. Pass {value, unit} to convert one value, e.g. 180 lb to kg. A word naming several metrics (distance, calories) comes back ambiguous with candidates, and a different measure (RMSSD vs the SDNN that Apple Health stores) is refused with the reason: nothing is guessed. get_health_metrics, get_trends, compare_periods, get_structured_export and correlate_metrics accept the same aliases and report `resolvedFrom` when they used one; other tools take the export's own names. The export holds one value per metric per day, already merged across sources by Apple Health, so this tool states that per-source disagreement cannot be seen in the file rather than inventing it.",
247
+ description: "Look a metric name up in the canonical naming and unit table. Accepts the export's own name (step_count), a HealthKit identifier (StepCount, HKQuantityTypeIdentifierStepCount), a display title (Step Count) or a common alias (steps, hrv, spo2, weight) and returns the canonical metric, its unit (percentages are 0 to 1 fractions), the unit variants other apps use with the exact formula into the canonical unit, and whether this export holds it. Pass {value, unit} to convert one value, e.g. 180 lb to kg. A word naming several metrics (distance, calories) comes back ambiguous with candidates, and a different measure (RMSSD vs the SDNN that Apple Health stores) is refused with the reason: nothing is guessed. get_health_metrics, get_trends, compare_periods, get_structured_export and correlate_metrics accept the same aliases and report `resolvedFrom` when they used one; other tools take the export's own names. The export holds one value per metric per day, merged across sources by Apple Health, so this tool states honestly whether per-source disagreement can be seen in the file (exports that record per-source attribution carry `src`/`by` and a `sources` block; others report it as not available) rather than inventing it.",
248
248
  inputSchema: {
249
249
  type: 'object',
250
250
  properties: {