@etweisberg/garmin-connect-mcp 0.1.13 → 0.1.15
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 +55 -56
- package/dist/test.js +250 -101
- package/dist/tools.js +42 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,51 +59,56 @@ Session cookies expire after a few hours. Re-run the login flow when they do.
|
|
|
59
59
|
## Available Tools
|
|
60
60
|
|
|
61
61
|
### Session & Auth
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
62
|
+
|
|
63
|
+
| Tool | Description |
|
|
64
|
+
| --------------- | --------------------------------------------------------- |
|
|
65
|
+
| `garmin-login` | Returns login instructions for the Playwright MCP browser |
|
|
66
|
+
| `check-session` | Validates the saved session is still active |
|
|
67
|
+
| `run-tests` | Returns a test plan to verify all tools work |
|
|
67
68
|
|
|
68
69
|
### Activities
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
|
72
|
-
| `
|
|
73
|
-
| `get-activity
|
|
74
|
-
| `get-activity-
|
|
75
|
-
| `get-activity-
|
|
76
|
-
| `get-activity-
|
|
77
|
-
| `get-activity-
|
|
78
|
-
| `
|
|
70
|
+
|
|
71
|
+
| Tool | Description |
|
|
72
|
+
| ----------------------- | -------------------------------------------------------- |
|
|
73
|
+
| `list-activities` | List activities with pagination |
|
|
74
|
+
| `get-activity` | Full activity summary (distance, duration, HR, calories) |
|
|
75
|
+
| `get-activity-details` | Time-series metrics (HR, cadence, elevation over time) |
|
|
76
|
+
| `get-activity-splits` | Lap/split data |
|
|
77
|
+
| `get-activity-hr-zones` | Heart rate time-in-zone breakdown |
|
|
78
|
+
| `get-activity-polyline` | Full-resolution GPS track |
|
|
79
|
+
| `get-activity-weather` | Weather conditions during activity |
|
|
80
|
+
| `download-fit` | Download original FIT file |
|
|
79
81
|
|
|
80
82
|
### Daily Health
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
|
84
|
-
| `get-daily-
|
|
85
|
-
| `get-daily-
|
|
86
|
-
| `get-daily-
|
|
87
|
-
| `get-daily-
|
|
88
|
-
| `get-daily-
|
|
89
|
-
| `get-daily-
|
|
83
|
+
|
|
84
|
+
| Tool | Description |
|
|
85
|
+
| ----------------------------- | -------------------------------------------- |
|
|
86
|
+
| `get-daily-summary` | Steps, calories, distance, intensity minutes |
|
|
87
|
+
| `get-daily-heart-rate` | Heart rate data throughout the day |
|
|
88
|
+
| `get-daily-stress` | Stress levels throughout the day |
|
|
89
|
+
| `get-daily-summary-chart` | Combined wellness chart data |
|
|
90
|
+
| `get-daily-intensity-minutes` | Intensity minutes for a date |
|
|
91
|
+
| `get-daily-movement` | Movement/activity data |
|
|
92
|
+
| `get-daily-respiration` | Respiration rate data |
|
|
90
93
|
|
|
91
94
|
### Sleep / Body Battery / HRV
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
|
95
|
+
|
|
96
|
+
| Tool | Description |
|
|
97
|
+
| ------------------ | ----------------------------------- |
|
|
98
|
+
| `get-sleep` | Sleep score, duration, stages, SpO2 |
|
|
95
99
|
| `get-body-battery` | Body battery charged/drained values |
|
|
96
|
-
| `get-hrv`
|
|
100
|
+
| `get-hrv` | Heart rate variability data |
|
|
97
101
|
|
|
98
102
|
### Weight / Records / Fitness
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
|
102
|
-
| `get-
|
|
103
|
-
| `get-
|
|
104
|
-
| `get-
|
|
105
|
-
| `get-
|
|
106
|
-
| `get-
|
|
103
|
+
|
|
104
|
+
| Tool | Description |
|
|
105
|
+
| ---------------------- | ------------------------------------- |
|
|
106
|
+
| `get-weight` | Weight measurements over a date range |
|
|
107
|
+
| `get-personal-records` | All personal records with history |
|
|
108
|
+
| `get-fitness-stats` | Aggregated activity stats by type |
|
|
109
|
+
| `get-vo2max` | Latest VO2 Max estimate |
|
|
110
|
+
| `get-hr-zones-config` | Heart rate zone boundaries |
|
|
111
|
+
| `get-user-profile` | User profile and settings |
|
|
107
112
|
|
|
108
113
|
## Architecture
|
|
109
114
|
|
|
@@ -141,13 +146,13 @@ npm run build
|
|
|
141
146
|
|
|
142
147
|
### Scripts
|
|
143
148
|
|
|
144
|
-
| Command
|
|
145
|
-
|
|
146
|
-
| `npm run build`
|
|
147
|
-
| `npm run lint`
|
|
148
|
-
| `npm run format`
|
|
149
|
-
| `npm run typecheck` | Type check without emitting
|
|
150
|
-
| `npm test`
|
|
149
|
+
| Command | Description |
|
|
150
|
+
| ------------------- | ---------------------------------------------- |
|
|
151
|
+
| `npm run build` | Compile TypeScript |
|
|
152
|
+
| `npm run lint` | Run ESLint |
|
|
153
|
+
| `npm run format` | Format with Prettier |
|
|
154
|
+
| `npm run typecheck` | Type check without emitting |
|
|
155
|
+
| `npm test` | Run integration tests (requires valid session) |
|
|
151
156
|
|
|
152
157
|
### Local Integration Testing
|
|
153
158
|
|
|
@@ -159,7 +164,7 @@ npm test
|
|
|
159
164
|
|
|
160
165
|
## Contributing
|
|
161
166
|
|
|
162
|
-
1.
|
|
167
|
+
1. Create a feature branch off `main`
|
|
163
168
|
2. Make your changes
|
|
164
169
|
3. Run checks:
|
|
165
170
|
```bash
|
|
@@ -175,20 +180,14 @@ CI runs lint, format check, typecheck, and build on every PR. Integration tests
|
|
|
175
180
|
|
|
176
181
|
### Releasing
|
|
177
182
|
|
|
178
|
-
Releases are automated
|
|
179
|
-
|
|
180
|
-
```bash
|
|
181
|
-
# Bump version
|
|
182
|
-
npm version patch # or minor, major
|
|
183
|
+
Releases are fully automated. Every merge to `main` triggers the release workflow which:
|
|
183
184
|
|
|
184
|
-
|
|
185
|
-
|
|
185
|
+
1. Runs CI (lint, format, typecheck, build)
|
|
186
|
+
2. Bumps the patch version
|
|
187
|
+
3. Publishes to npm with provenance
|
|
188
|
+
4. Creates a GitHub Release
|
|
186
189
|
|
|
187
|
-
|
|
188
|
-
# 1. Build the package
|
|
189
|
-
# 2. Publish to npm with provenance
|
|
190
|
-
# 3. Create a GitHub Release
|
|
191
|
-
```
|
|
190
|
+
No manual version bumping or tagging needed — just merge your PR.
|
|
192
191
|
|
|
193
192
|
## License
|
|
194
193
|
|
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 {
|
|
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
|
|
26
|
+
// ── Session ────────────────────────────────────────────────────────
|
|
9
27
|
{
|
|
10
28
|
name: "check-session",
|
|
11
|
-
run: (
|
|
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: (
|
|
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: (
|
|
21
|
-
|
|
22
|
-
|
|
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: (
|
|
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: (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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: (
|
|
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: (
|
|
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: (
|
|
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: (
|
|
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: (
|
|
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: (
|
|
61
|
-
|
|
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: (
|
|
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: (
|
|
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: (
|
|
75
|
-
|
|
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: (
|
|
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: (
|
|
85
|
-
|
|
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: (
|
|
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
|
|
218
|
+
// ── Sleep / Body Battery / HRV ─────────────────────────────────────
|
|
93
219
|
{
|
|
94
220
|
name: "get-sleep",
|
|
95
|
-
run: (
|
|
96
|
-
|
|
97
|
-
|
|
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: (
|
|
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: (
|
|
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: (
|
|
112
|
-
|
|
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: (
|
|
119
|
-
|
|
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: (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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: (
|
|
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: (
|
|
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
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
.
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
165
|
-
const
|
|
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
|
-
|
|
186
|
-
if (result === undefined) {
|
|
187
|
-
throw new Error("undefined response");
|
|
188
|
-
}
|
|
336
|
+
await test.run(server);
|
|
189
337
|
const ms = Date.now() - start;
|
|
190
|
-
|
|
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
|
|
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
|
|
401
|
-
*
|
|
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
|
-
//
|
|
405
|
-
let
|
|
406
|
-
while (
|
|
407
|
-
if (buf[
|
|
408
|
-
buf[
|
|
409
|
-
buf[
|
|
410
|
-
buf[
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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 (
|
|
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";
|