@etweisberg/garmin-connect-mcp 0.1.13 → 0.1.14

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.
Files changed (3) hide show
  1. package/dist/test.js +250 -101
  2. package/dist/tools.js +42 -26
  3. package/package.json +1 -1
package/dist/test.js CHANGED
@@ -1,207 +1,356 @@
1
1
  /**
2
2
  * Integration test suite for garmin-connect-mcp.
3
+ * Tests the actual MCP tool handlers (not just raw API endpoints)
4
+ * to catch bugs in zip extraction, file I/O, date defaults, etc.
5
+ *
3
6
  * Requires a valid session at ~/.garmin-connect-mcp/session.json.
4
7
  * Run: npm test
5
8
  */
6
- import { GarminClient } from "./garmin-client.js";
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { registerTools } from "./tools.js";
11
+ import { existsSync, rmSync } from "node:fs";
12
+ import { getSharedClient } from "./garmin-client.js";
13
+ const TEST_FIT_DIR = "/tmp/garmin-mcp-test-fit";
14
+ async function callTool(server, name, args = {}) {
15
+ // Access internal tool handler via the server's registered tools
16
+ const result = (await server._registeredTools[name].handler({ ...args }, { signal: new AbortController().signal }));
17
+ return result;
18
+ }
19
+ function getToolText(result) {
20
+ return result.content[0]?.text ?? "";
21
+ }
22
+ function getToolJson(result) {
23
+ return JSON.parse(getToolText(result));
24
+ }
7
25
  const tests = [
8
- // ── Session / Profile ──────────────────────────────────────────────
26
+ // ── Session ────────────────────────────────────────────────────────
9
27
  {
10
28
  name: "check-session",
11
- run: ({ client }) => client.get("userprofile-service/userprofile/user-settings/"),
29
+ run: async (server) => {
30
+ const result = await callTool(server, "check-session");
31
+ if (result.isError)
32
+ throw new Error(getToolText(result));
33
+ const data = getToolJson(result);
34
+ if (data.status !== "ok")
35
+ throw new Error("status not ok");
36
+ },
12
37
  },
13
38
  {
14
39
  name: "get-user-profile",
15
- run: ({ client }) => client.get("userprofile-service/userprofile/user-settings/"),
40
+ run: async (server) => {
41
+ const result = await callTool(server, "get-user-profile");
42
+ if (result.isError)
43
+ throw new Error(getToolText(result));
44
+ const data = getToolJson(result);
45
+ if (!data.id)
46
+ throw new Error("no user id");
47
+ },
16
48
  },
17
49
  // ── Activities ─────────────────────────────────────────────────────
18
50
  {
19
51
  name: "list-activities",
20
- run: ({ client }) => client.get("activitylist-service/activities/search/activities", {
21
- limit: 2,
22
- start: 0,
23
- }),
52
+ run: async (server) => {
53
+ const result = await callTool(server, "list-activities", {
54
+ limit: 2,
55
+ start: 0,
56
+ });
57
+ if (result.isError)
58
+ throw new Error(getToolText(result));
59
+ const data = getToolJson(result);
60
+ if (!Array.isArray(data) || data.length === 0)
61
+ throw new Error("no activities");
62
+ },
24
63
  },
25
64
  {
26
65
  name: "get-activity",
27
- run: ({ client, activityId }) => client.get(`activity-service/activity/${activityId}`),
66
+ run: async (server) => {
67
+ const result = await callTool(server, "get-activity", {
68
+ activityId: activityId,
69
+ });
70
+ if (result.isError)
71
+ throw new Error(getToolText(result));
72
+ const data = getToolJson(result);
73
+ if (!data.summaryDTO)
74
+ throw new Error("no summaryDTO");
75
+ },
28
76
  },
29
77
  {
30
78
  name: "get-activity-details",
31
- run: ({ client, activityId }) => client.get(`activity-service/activity/${activityId}/details`, {
32
- maxChartSize: 100,
33
- maxPolylineSize: 0,
34
- maxHeatMapSize: 100,
35
- }),
79
+ run: async (server) => {
80
+ const result = await callTool(server, "get-activity-details", {
81
+ activityId: activityId,
82
+ maxChartSize: 10000,
83
+ });
84
+ if (result.isError)
85
+ throw new Error(getToolText(result));
86
+ const data = getToolJson(result);
87
+ if (!data.metricDescriptors)
88
+ throw new Error("no metricDescriptors");
89
+ },
36
90
  },
37
91
  {
38
92
  name: "get-activity-splits",
39
- run: ({ client, activityId }) => client.get(`activity-service/activity/${activityId}/splits`),
93
+ run: async (server) => {
94
+ const result = await callTool(server, "get-activity-splits", {
95
+ activityId: activityId,
96
+ });
97
+ if (result.isError)
98
+ throw new Error(getToolText(result));
99
+ const data = getToolJson(result);
100
+ if (!data.lapDTOs)
101
+ throw new Error("no lapDTOs");
102
+ },
40
103
  },
41
104
  {
42
105
  name: "get-activity-hr-zones",
43
- run: ({ client, activityId }) => client.get(`activity-service/activity/${activityId}/hrTimeInZones`),
106
+ run: async (server) => {
107
+ const result = await callTool(server, "get-activity-hr-zones", {
108
+ activityId: activityId,
109
+ });
110
+ if (result.isError)
111
+ throw new Error(getToolText(result));
112
+ const data = getToolJson(result);
113
+ if (!Array.isArray(data) || data.length !== 5)
114
+ throw new Error(`expected 5 zones, got ${data.length}`);
115
+ },
44
116
  },
45
117
  {
46
118
  name: "get-activity-polyline",
47
- run: ({ client, activityId }) => client.get(`activity-service/activity/${activityId}/polyline/full-resolution/`),
119
+ run: async (server) => {
120
+ const result = await callTool(server, "get-activity-polyline", {
121
+ activityId: activityId,
122
+ });
123
+ if (result.isError)
124
+ throw new Error(getToolText(result));
125
+ },
48
126
  },
49
127
  {
50
128
  name: "get-activity-weather",
51
- run: ({ client, activityId }) => client.get(`activity-service/activity/${activityId}/weather`),
129
+ run: async (server) => {
130
+ const result = await callTool(server, "get-activity-weather", {
131
+ activityId: activityId,
132
+ });
133
+ if (result.isError)
134
+ throw new Error(getToolText(result));
135
+ },
52
136
  },
53
137
  {
54
- name: "download-fit",
55
- run: ({ client, activityId }) => client.getBytes(`download-service/files/activity/${activityId}`),
138
+ name: "download-fit (zip extraction + file write)",
139
+ run: async (server) => {
140
+ // Clean up from previous runs
141
+ if (existsSync(TEST_FIT_DIR))
142
+ rmSync(TEST_FIT_DIR, { recursive: true });
143
+ const result = await callTool(server, "download-fit", {
144
+ activityId: activityId,
145
+ outputDir: TEST_FIT_DIR,
146
+ });
147
+ if (result.isError)
148
+ throw new Error(getToolText(result));
149
+ const text = getToolText(result);
150
+ if (!text.includes("Downloaded FIT file"))
151
+ throw new Error(`unexpected response: ${text}`);
152
+ // Verify file actually exists on disk
153
+ const expectedPath = `${TEST_FIT_DIR}/${activityId}.fit`;
154
+ if (!existsSync(expectedPath))
155
+ throw new Error(`FIT file not found at ${expectedPath}`);
156
+ },
56
157
  },
57
- // ── Daily Health ───────────────────────────────────────────────────
158
+ // ── Daily Health (test date defaults) ──────────────────────────────
58
159
  {
59
- name: "get-daily-summary",
60
- run: ({ client, displayName, today }) => client.get(`usersummary-service/usersummary/daily/${displayName}`, {
61
- calendarDate: today,
62
- }),
160
+ name: "get-daily-summary (default date)",
161
+ run: async (server) => {
162
+ const result = await callTool(server, "get-daily-summary");
163
+ if (result.isError)
164
+ throw new Error(getToolText(result));
165
+ const data = getToolJson(result);
166
+ if (!data.calendarDate)
167
+ throw new Error("no calendarDate");
168
+ },
63
169
  },
64
170
  {
65
171
  name: "get-daily-heart-rate",
66
- run: ({ client, today }) => client.get("wellness-service/wellness/dailyHeartRate", { date: today }),
172
+ run: async (server) => {
173
+ const result = await callTool(server, "get-daily-heart-rate");
174
+ if (result.isError)
175
+ throw new Error(getToolText(result));
176
+ },
67
177
  },
68
178
  {
69
179
  name: "get-daily-stress",
70
- run: ({ client, today }) => client.get(`wellness-service/wellness/dailyStress/${today}`),
180
+ run: async (server) => {
181
+ const result = await callTool(server, "get-daily-stress");
182
+ if (result.isError)
183
+ throw new Error(getToolText(result));
184
+ },
71
185
  },
72
186
  {
73
187
  name: "get-daily-summary-chart",
74
- run: ({ client, today }) => client.get("wellness-service/wellness/dailySummaryChart/", {
75
- date: today,
76
- }),
188
+ run: async (server) => {
189
+ const result = await callTool(server, "get-daily-summary-chart");
190
+ if (result.isError)
191
+ throw new Error(getToolText(result));
192
+ },
77
193
  },
78
194
  {
79
195
  name: "get-daily-intensity-minutes",
80
- run: ({ client, today }) => client.get(`wellness-service/wellness/daily/im/${today}`),
196
+ run: async (server) => {
197
+ const result = await callTool(server, "get-daily-intensity-minutes");
198
+ if (result.isError)
199
+ throw new Error(getToolText(result));
200
+ },
81
201
  },
82
202
  {
83
203
  name: "get-daily-movement",
84
- run: ({ client, today }) => client.get("wellness-service/wellness/dailyMovement", {
85
- calendarDate: today,
86
- }),
204
+ run: async (server) => {
205
+ const result = await callTool(server, "get-daily-movement");
206
+ if (result.isError)
207
+ throw new Error(getToolText(result));
208
+ },
87
209
  },
88
210
  {
89
211
  name: "get-daily-respiration",
90
- run: ({ client, today }) => client.get(`wellness-service/wellness/daily/respiration/${today}`),
212
+ run: async (server) => {
213
+ const result = await callTool(server, "get-daily-respiration");
214
+ if (result.isError)
215
+ throw new Error(getToolText(result));
216
+ },
91
217
  },
92
- // ── Sleep, Body Battery, HRV ───────────────────────────────────────
218
+ // ── Sleep / Body Battery / HRV ─────────────────────────────────────
93
219
  {
94
220
  name: "get-sleep",
95
- run: ({ client, today }) => client.get("sleep-service/sleep/dailySleepData", {
96
- date: today,
97
- nonSleepBufferMinutes: 60,
98
- }),
221
+ run: async (server) => {
222
+ const result = await callTool(server, "get-sleep");
223
+ if (result.isError)
224
+ throw new Error(getToolText(result));
225
+ },
99
226
  },
100
227
  {
101
228
  name: "get-body-battery",
102
- run: ({ client }) => client.get("wellness-service/wellness/bodyBattery/messagingToday"),
229
+ run: async (server) => {
230
+ const result = await callTool(server, "get-body-battery");
231
+ if (result.isError)
232
+ throw new Error(getToolText(result));
233
+ },
103
234
  },
104
235
  {
105
236
  name: "get-hrv",
106
- run: ({ client, today }) => client.get(`hrv-service/hrv/${today}`),
237
+ run: async (server) => {
238
+ const result = await callTool(server, "get-hrv");
239
+ if (result.isError)
240
+ throw new Error(getToolText(result));
241
+ // noData is acceptable
242
+ },
107
243
  },
108
- // ── Weight ─────────────────────────────────────────────────────────
244
+ // ── Weight / Records / Fitness ─────────────────────────────────────
109
245
  {
110
246
  name: "get-weight",
111
- run: ({ client, today, thirtyDaysAgo }) => client.get(`weight-service/weight/range/${thirtyDaysAgo}/${today}`, {
112
- includeAll: "true",
113
- }),
247
+ run: async (server) => {
248
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 86400000)
249
+ .toISOString()
250
+ .slice(0, 10);
251
+ const today = new Date().toISOString().slice(0, 10);
252
+ const result = await callTool(server, "get-weight", {
253
+ startDate: thirtyDaysAgo,
254
+ endDate: today,
255
+ });
256
+ if (result.isError)
257
+ throw new Error(getToolText(result));
258
+ },
114
259
  },
115
- // ── Personal Records ───────────────────────────────────────────────
116
260
  {
117
- name: "get-personal-records",
118
- run: ({ client, displayName }) => client.get(`personalrecord-service/personalrecord/prs/${displayName}`, {
119
- includeHistory: "true",
120
- }),
261
+ name: "get-personal-records (displayName resolution)",
262
+ run: async (server) => {
263
+ const result = await callTool(server, "get-personal-records");
264
+ if (result.isError)
265
+ throw new Error(getToolText(result));
266
+ const data = getToolJson(result);
267
+ if (!Array.isArray(data))
268
+ throw new Error("expected array");
269
+ },
121
270
  },
122
- // ── Fitness Stats / Reports ────────────────────────────────────────
123
271
  {
124
272
  name: "get-fitness-stats",
125
- run: ({ client, today, thirtyDaysAgo }) => client.get("fitnessstats-service/activity", {
126
- aggregation: "daily",
127
- startDate: thirtyDaysAgo,
128
- endDate: today,
129
- groupByActivityType: "true",
130
- standardizedUnits: "true",
131
- groupByParentActivityType: "false",
132
- userFirstDay: "sunday",
133
- metric: "duration",
134
- }),
273
+ run: async (server) => {
274
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 86400000)
275
+ .toISOString()
276
+ .slice(0, 10);
277
+ const today = new Date().toISOString().slice(0, 10);
278
+ const result = await callTool(server, "get-fitness-stats", {
279
+ startDate: thirtyDaysAgo,
280
+ endDate: today,
281
+ aggregation: "daily",
282
+ metric: "duration",
283
+ });
284
+ if (result.isError)
285
+ throw new Error(getToolText(result));
286
+ },
135
287
  },
136
288
  {
137
289
  name: "get-vo2max",
138
- run: ({ client, today }) => client.get(`metrics-service/metrics/maxmet/latest/${today}`),
290
+ run: async (server) => {
291
+ const result = await callTool(server, "get-vo2max");
292
+ if (result.isError)
293
+ throw new Error(getToolText(result));
294
+ },
139
295
  },
140
296
  {
141
297
  name: "get-hr-zones-config",
142
- run: ({ client }) => client.get("biometric-service/heartRateZones/"),
298
+ run: async (server) => {
299
+ const result = await callTool(server, "get-hr-zones-config");
300
+ if (result.isError)
301
+ throw new Error(getToolText(result));
302
+ const data = getToolJson(result);
303
+ if (!Array.isArray(data) || data.length === 0)
304
+ throw new Error("no zones returned");
305
+ },
143
306
  },
144
307
  ];
308
+ // Resolved during bootstrap
309
+ let activityId = "";
145
310
  async function main() {
146
- console.log("garmin-connect-mcp integration tests\n");
147
- const client = new GarminClient();
148
- const today = new Date().toISOString().slice(0, 10);
149
- const thirtyDaysAgo = new Date(Date.now() - 30 * 86400000)
150
- .toISOString()
151
- .slice(0, 10);
152
- // Bootstrap: resolve displayName
153
- console.log("Bootstrapping...");
154
- const settings = (await client.get("userprofile-service/userprofile/settings"));
155
- const displayName = settings.displayName;
156
- if (!displayName) {
157
- console.error("FATAL: Could not resolve displayName from settings");
158
- console.error("Response:", JSON.stringify(settings).slice(0, 500));
159
- await client.close();
160
- process.exit(1);
161
- }
162
- console.log(` displayName: ${displayName}`);
311
+ console.log("garmin-connect-mcp integration tests (tool-level)\n");
312
+ // Set up a real MCP server with all tools registered
313
+ const server = new McpServer({
314
+ name: "garmin-connect-mcp-test",
315
+ version: "0.0.0",
316
+ });
317
+ registerTools(server);
163
318
  // Bootstrap: get a recent activityId
164
- const activities = (await client.get("activitylist-service/activities/search/activities", { limit: 1, start: 0 }));
165
- const activityId = String(activities?.[0]?.activityId ?? "");
319
+ console.log("Bootstrapping...");
320
+ const listResult = await callTool(server, "list-activities", {
321
+ limit: 1,
322
+ start: 0,
323
+ });
324
+ const activities = getToolJson(listResult);
325
+ activityId = String(activities[0]?.activityId ?? "");
166
326
  if (!activityId) {
167
327
  console.error("FATAL: No activities found");
168
- await client.close();
169
328
  process.exit(1);
170
329
  }
171
- console.log(` activityId: ${activityId}`);
172
- console.log(` date range: ${thirtyDaysAgo} → ${today}\n`);
173
- const ctx = {
174
- client,
175
- displayName,
176
- activityId,
177
- today,
178
- thirtyDaysAgo,
179
- };
330
+ console.log(` activityId: ${activityId}\n`);
180
331
  let passed = 0;
181
332
  let failed = 0;
182
333
  for (const test of tests) {
183
334
  const start = Date.now();
184
335
  try {
185
- const result = await test.run(ctx);
186
- if (result === undefined) {
187
- throw new Error("undefined response");
188
- }
336
+ await test.run(server);
189
337
  const ms = Date.now() - start;
190
- const noData = result && typeof result === "object" && "noData" in result;
191
- console.log(` PASS ${test.name} (${ms}ms)${noData ? " [no data for date]" : ""}`);
338
+ console.log(` PASS ${test.name} (${ms}ms)`);
192
339
  passed++;
193
340
  }
194
341
  catch (e) {
195
342
  const ms = Date.now() - start;
196
343
  const msg = e instanceof Error ? e.message : String(e);
197
- // Truncate long error messages
198
344
  const short = msg.length > 120 ? msg.slice(0, 120) + "..." : msg;
199
345
  console.log(` FAIL ${test.name} (${ms}ms) — ${short}`);
200
346
  failed++;
201
347
  }
202
348
  }
349
+ // Cleanup
350
+ if (existsSync(TEST_FIT_DIR))
351
+ rmSync(TEST_FIT_DIR, { recursive: true });
203
352
  console.log(`\nResults: ${passed} passed, ${failed} failed, ${passed + failed} total`);
204
- await client.close();
353
+ await getSharedClient().close();
205
354
  process.exit(failed > 0 ? 1 : 0);
206
355
  }
207
356
  main().catch((err) => {
package/dist/tools.js CHANGED
@@ -396,43 +396,59 @@ Present results as a markdown table: | Tool | Status | Notes |
396
396
  Count total passed vs failed at the end.`);
397
397
  });
398
398
  }
399
+ import { inflateRawSync } from "node:zlib";
399
400
  /**
400
- * Minimal zip extraction — finds and extracts the first .fit file from a zip buffer.
401
- * Avoids needing a zip library dependency.
401
+ * Minimal zip extraction — finds the first .fit file using the central
402
+ * directory (which always has correct sizes, unlike local headers that
403
+ * may use data descriptors with size=0).
402
404
  */
403
405
  function extractFitFromZip(buf, activityId) {
404
- // ZIP local file header signature: PK\x03\x04
405
- let offset = 0;
406
- while (offset < buf.length - 30) {
407
- if (buf[offset] === 0x50 &&
408
- buf[offset + 1] === 0x4b &&
409
- buf[offset + 2] === 0x03 &&
410
- buf[offset + 3] === 0x04) {
411
- const compressionMethod = buf.readUInt16LE(offset + 8);
412
- const compressedSize = buf.readUInt32LE(offset + 18);
413
- const uncompressedSize = buf.readUInt32LE(offset + 22);
414
- const nameLength = buf.readUInt16LE(offset + 26);
415
- const extraLength = buf.readUInt16LE(offset + 28);
416
- const name = buf.toString("utf-8", offset + 30, offset + 30 + nameLength);
417
- const dataStart = offset + 30 + nameLength + extraLength;
418
- if (name.endsWith(".fit") && compressionMethod === 0) {
419
- // Stored (no compression) — just slice the data
406
+ // Find End of Central Directory (EOCD): PK\x05\x06
407
+ let eocdOffset = buf.length - 22;
408
+ while (eocdOffset >= 0) {
409
+ if (buf[eocdOffset] === 0x50 &&
410
+ buf[eocdOffset + 1] === 0x4b &&
411
+ buf[eocdOffset + 2] === 0x05 &&
412
+ buf[eocdOffset + 3] === 0x06)
413
+ break;
414
+ eocdOffset--;
415
+ }
416
+ if (eocdOffset < 0)
417
+ return null;
418
+ const cdOffset = buf.readUInt32LE(eocdOffset + 16);
419
+ const cdEntries = buf.readUInt16LE(eocdOffset + 10);
420
+ // Walk central directory entries: PK\x01\x02
421
+ let pos = cdOffset;
422
+ for (let i = 0; i < cdEntries; i++) {
423
+ if (buf[pos] !== 0x50 ||
424
+ buf[pos + 1] !== 0x4b ||
425
+ buf[pos + 2] !== 0x01 ||
426
+ buf[pos + 3] !== 0x02)
427
+ break;
428
+ const method = buf.readUInt16LE(pos + 10);
429
+ const compressedSize = buf.readUInt32LE(pos + 20);
430
+ const uncompressedSize = buf.readUInt32LE(pos + 24);
431
+ const nameLength = buf.readUInt16LE(pos + 28);
432
+ const extraLength = buf.readUInt16LE(pos + 30);
433
+ const commentLength = buf.readUInt16LE(pos + 32);
434
+ const localHeaderOffset = buf.readUInt32LE(pos + 42);
435
+ const name = buf.toString("utf-8", pos + 46, pos + 46 + nameLength);
436
+ if (name.endsWith(".fit")) {
437
+ // Read local header to find data start
438
+ const localNameLen = buf.readUInt16LE(localHeaderOffset + 26);
439
+ const localExtraLen = buf.readUInt16LE(localHeaderOffset + 28);
440
+ const dataStart = localHeaderOffset + 30 + localNameLen + localExtraLen;
441
+ if (method === 0) {
420
442
  const data = buf.subarray(dataStart, dataStart + uncompressedSize);
421
443
  return { name: `${activityId}.fit`, data: Buffer.from(data) };
422
444
  }
423
- if (name.endsWith(".fit") && compressionMethod === 8) {
424
- // Deflate compressed — use Node's zlib
445
+ if (method === 8) {
425
446
  const compressed = buf.subarray(dataStart, dataStart + compressedSize);
426
447
  const data = inflateRawSync(compressed);
427
448
  return { name: `${activityId}.fit`, data };
428
449
  }
429
- // Skip to next file header
430
- offset = dataStart + compressedSize;
431
- }
432
- else {
433
- offset++;
434
450
  }
451
+ pos += 46 + nameLength + extraLength + commentLength;
435
452
  }
436
453
  return null;
437
454
  }
438
- import { inflateRawSync } from "node:zlib";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@etweisberg/garmin-connect-mcp",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "MCP server for Garmin Connect — access activities, metrics, and FIT files via Claude Code",
5
5
  "type": "module",
6
6
  "bin": {