health-export-mcp 1.2.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.
@@ -0,0 +1,118 @@
1
+ // End-to-end integration test for the MCP layer (no-bridge architecture).
2
+ //
3
+ // iOS app → iCloud Drive (.health-cache.json) → MCP server → agent (JSON-RPC)
4
+ //
5
+ // Writes a cache file exactly as the iOS app's ICloudExporter does, then spawns the
6
+ // MCP server over stdio and exercises every tool an agent calls — proving the full
7
+ // file → MCP → tools pipeline with no receiver and no Docker.
8
+
9
+ import { spawn } from 'node:child_process';
10
+ import { fileURLToPath } from 'node:url';
11
+ import path from 'node:path';
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+
15
+ const here = path.dirname(fileURLToPath(import.meta.url));
16
+ const MCP = path.join(here, '..', 'server.mjs');
17
+ const DATA = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-e2e-'));
18
+
19
+ let mcp, failures = 0;
20
+ const ok = (c, m) => { if (c) console.log(` ✓ ${m}`); else { console.log(` ✗ ${m}`); failures++; } };
21
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
+ function isoDay(offset) { const d = new Date(); d.setUTCDate(d.getUTCDate() - offset); return d.toISOString().slice(0, 10); }
23
+
24
+ // Build a 14-day cache in the exact shape the iOS app writes.
25
+ function sampleCache() {
26
+ const days = (fn) => Array.from({ length: 14 }, (_, i) => ({ d: isoDay(13 - i), v: fn(i) }));
27
+ return {
28
+ heart_rate: { unit: 'count/min', cumulative: false, daily: days((i) => 60 + (i % 5) * 1.5) },
29
+ step_count: { unit: 'count', cumulative: true, daily: days((i) => 8000 + (i % 5) * 400) },
30
+ heart_rate_variability: { unit: 'ms', cumulative: false, daily: days((i) => 55 + (i % 5) * 2) },
31
+ sleep_analysis: { unit: 'hr', cumulative: false, daily: days((i) => 7 + (i % 3) * 0.3) },
32
+ };
33
+ }
34
+
35
+ // --- MCP stdio JSON-RPC client ---
36
+ function mcpClient(proc) {
37
+ let buf = ''; const pending = new Map();
38
+ proc.stdout.setEncoding('utf8');
39
+ proc.stdout.on('data', (c) => {
40
+ buf += c; let nl;
41
+ while ((nl = buf.indexOf('\n')) >= 0) {
42
+ const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);
43
+ if (!line) continue;
44
+ let m; try { m = JSON.parse(line); } catch { continue; }
45
+ if (m.id != null && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
46
+ }
47
+ });
48
+ let id = 0;
49
+ const req = (method, params) => new Promise((resolve, reject) => {
50
+ const myId = ++id; pending.set(myId, resolve);
51
+ proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: myId, method, params }) + '\n');
52
+ setTimeout(() => { if (pending.has(myId)) { pending.delete(myId); reject(new Error(`timeout ${method}`)); } }, 5000);
53
+ });
54
+ const notify = (method, params) => proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
55
+ const call = async (name, args) => {
56
+ const r = await req('tools/call', { name, arguments: args || {} });
57
+ return { raw: r, data: r.result?.structuredContent, isError: r.result?.isError };
58
+ };
59
+ return { req, notify, call };
60
+ }
61
+
62
+ async function main() {
63
+ console.log(`\nMCP end-to-end integration (data dir: ${DATA})\n`);
64
+
65
+ // 1. iOS-app-equivalent: write the cache file into the (test) iCloud folder.
66
+ fs.writeFileSync(path.join(DATA, '.health-cache.json'), JSON.stringify(sampleCache(), null, 2));
67
+ ok(fs.existsSync(path.join(DATA, '.health-cache.json')), 'app wrote .health-cache.json (no receiver)');
68
+
69
+ // 2. MCP server reads the same data dir.
70
+ mcp = spawn('node', [MCP], { env: { ...process.env, HEALTH_DATA_DIR: DATA }, stdio: ['pipe', 'pipe', 'inherit'] });
71
+ const c = mcpClient(mcp);
72
+
73
+ const init = await c.req('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'integration-test', version: '1.0' } });
74
+ ok(init.result?.serverInfo?.name === 'health-export-ai', `initialize → ${init.result?.serverInfo?.name}`);
75
+ ok(!!init.result?.capabilities?.tools, 'advertises tools capability');
76
+ c.notify('notifications/initialized');
77
+
78
+ const tl = await c.req('tools/list');
79
+ const names = (tl.result?.tools || []).map((t) => t.name);
80
+ ok(names.length === 7, `tools/list → ${names.length} tools`);
81
+ for (const t of ['get_mcp_status', 'list_metrics', 'get_health_metrics', 'get_trends', 'compare_periods', 'get_structured_export', 'query_health_data'])
82
+ ok(names.includes(t), `tool present: ${t}`);
83
+
84
+ const st = await c.call('get_mcp_status');
85
+ ok(st.data?.ok && st.data.metricCount >= 4, `get_mcp_status → ${st.data?.metricCount} metrics, last ${st.data?.lastDataDate}`);
86
+
87
+ const lm = await c.call('list_metrics');
88
+ ok(Array.isArray(lm.data?.metrics) && lm.data.metrics.find((m) => m.name === 'step_count')?.days === 14, 'list_metrics → step_count has 14 days');
89
+
90
+ const hm = await c.call('get_health_metrics', { metric: 'step_count', aggregation: 'sum' });
91
+ ok(hm.data?.step_count?.points?.length === 14 && hm.data.step_count.aggregate > 0, `get_health_metrics(step_count,sum) → ${hm.data?.step_count?.aggregate}`);
92
+
93
+ const hr = await c.call('get_health_metrics', { metric: 'heart_rate', start: isoDay(2), end: isoDay(0), aggregation: 'avg' });
94
+ ok(hr.data?.heart_rate?.points?.length <= 3 && hr.data.heart_rate.aggregate > 0, `date-range filter → ${hr.data?.heart_rate?.points?.length} points`);
95
+
96
+ const tr = await c.call('get_trends', { metric: 'heart_rate_variability', window: 7 });
97
+ ok(tr.data?.recent != null && tr.data?.prior != null && ['up', 'down', 'flat'].includes(tr.data.direction), `get_trends(hrv) → ${tr.data?.direction} (${tr.data?.changePercent}%)`);
98
+
99
+ const cp = await c.call('compare_periods', { metric: 'step_count', periodA: { start: isoDay(6), end: isoDay(0) }, periodB: { start: isoDay(13), end: isoDay(7) } });
100
+ ok(cp.data?.periodA?.value != null && cp.data?.periodB?.value != null, `compare_periods → A ${cp.data?.periodA?.value} vs B ${cp.data?.periodB?.value}`);
101
+
102
+ const ex = await c.call('get_structured_export', { metrics: ['sleep_analysis'] });
103
+ ok(ex.data?.metrics?.sleep_analysis?.daily?.length === 14, `get_structured_export(sleep) → ${ex.data?.metrics?.sleep_analysis?.daily?.length} days`);
104
+
105
+ const nl = await c.call('query_health_data', { question: 'what is my average HRV?' });
106
+ ok(nl.data?.interpreted?.includes('heart_rate_variability'), `query_health_data routed → "${nl.data?.interpreted}"`);
107
+
108
+ const err = await c.call('get_health_metrics', { metric: 'does_not_exist' });
109
+ ok(err.isError === true, 'unknown metric → graceful tool error (isError:true)');
110
+
111
+ console.log(`\n${failures === 0 ? '✅ ALL PASSED' : `❌ ${failures} FAILED`}\n`);
112
+ }
113
+
114
+ main().catch((e) => { console.error('FATAL', e); failures++; }).finally(() => {
115
+ try { mcp?.kill(); } catch {}
116
+ try { fs.rmSync(DATA, { recursive: true, force: true }); } catch {}
117
+ process.exit(failures ? 1 : 0);
118
+ });