intervals-mcp-server 1.0.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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2345 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2345 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/config.ts
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from "fs";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import path from "path";
|
|
10
|
+
|
|
11
|
+
// src/utils/dates.ts
|
|
12
|
+
function toLocalDateString(date) {
|
|
13
|
+
const year = date.getFullYear();
|
|
14
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
15
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
16
|
+
return `${year}-${month}-${day}`;
|
|
17
|
+
}
|
|
18
|
+
function getDefaultEndDate() {
|
|
19
|
+
return toLocalDateString(/* @__PURE__ */ new Date());
|
|
20
|
+
}
|
|
21
|
+
function getDefaultStartDate(daysAgo = 30) {
|
|
22
|
+
const d = /* @__PURE__ */ new Date();
|
|
23
|
+
d.setDate(d.getDate() - daysAgo);
|
|
24
|
+
return toLocalDateString(d);
|
|
25
|
+
}
|
|
26
|
+
function getDefaultFutureEndDate(daysAhead = 30) {
|
|
27
|
+
const d = /* @__PURE__ */ new Date();
|
|
28
|
+
d.setDate(d.getDate() + daysAhead);
|
|
29
|
+
return toLocalDateString(d);
|
|
30
|
+
}
|
|
31
|
+
function parseDateRange(startDate, endDate, defaultStartDaysAgo = 30) {
|
|
32
|
+
const start = startDate || getDefaultStartDate(defaultStartDaysAgo);
|
|
33
|
+
const end = endDate || getDefaultEndDate();
|
|
34
|
+
return [start, end];
|
|
35
|
+
}
|
|
36
|
+
function formatIsoDateTime(value) {
|
|
37
|
+
const match = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})/.exec(value);
|
|
38
|
+
return match ? `${match[1]} ${match[2]}` : value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/utils/validation.ts
|
|
42
|
+
var ATHLETE_ID_PATTERN = /^i?\d+$/;
|
|
43
|
+
function validateAthleteId(athleteId) {
|
|
44
|
+
if (athleteId && !ATHLETE_ID_PATTERN.test(athleteId)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"ATHLETE_ID must be all digits (e.g. 123456) or start with 'i' followed by digits (e.g. i123456)"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function validateDate(dateStr) {
|
|
51
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
|
52
|
+
const [y, m, d] = dateStr.split("-").map(Number);
|
|
53
|
+
const parsed = new Date(y, m - 1, d);
|
|
54
|
+
if (parsed.getFullYear() === y && parsed.getMonth() === m - 1 && parsed.getDate() === d) {
|
|
55
|
+
return dateStr;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw new Error("Invalid date format. Please use YYYY-MM-DD.");
|
|
59
|
+
}
|
|
60
|
+
function resolveAthleteId(athleteId, defaultAthleteId) {
|
|
61
|
+
const idToUse = athleteId ?? defaultAthleteId;
|
|
62
|
+
if (!idToUse) {
|
|
63
|
+
return {
|
|
64
|
+
athleteId: "",
|
|
65
|
+
error: "Error: No athlete ID provided and no default ATHLETE_ID found. Run `npx intervals-mcp-server auth` to configure your credentials."
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { athleteId: idToUse, error: null };
|
|
69
|
+
}
|
|
70
|
+
function resolveActivityType(name, activityType) {
|
|
71
|
+
if (activityType) return activityType;
|
|
72
|
+
const nameLower = name?.toLowerCase() ?? "";
|
|
73
|
+
const mapping = [
|
|
74
|
+
["Ride", ["bike", "cycle", "cycling", "ride"]],
|
|
75
|
+
["Run", ["run", "running", "jog", "jogging"]],
|
|
76
|
+
["Swim", ["swim", "swimming", "pool"]],
|
|
77
|
+
["Walk", ["walk", "walking", "hike", "hiking"]],
|
|
78
|
+
["Row", ["row", "rowing"]]
|
|
79
|
+
];
|
|
80
|
+
for (const [workout, keywords] of mapping) {
|
|
81
|
+
if (keywords.some((keyword) => nameLower.includes(keyword))) {
|
|
82
|
+
return workout;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return "Ride";
|
|
86
|
+
}
|
|
87
|
+
function resolveDateParams(startDate, endDate, defaultStartDaysAgo = 30) {
|
|
88
|
+
return parseDateRange(startDate, endDate, defaultStartDaysAgo);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/config.ts
|
|
92
|
+
var DEFAULT_API_BASE_URL = "https://intervals.icu/api/v1";
|
|
93
|
+
function readPackageVersion() {
|
|
94
|
+
try {
|
|
95
|
+
const pkgUrl = new URL("../package.json", import.meta.url);
|
|
96
|
+
const pkg = JSON.parse(readFileSync(pkgUrl, "utf8"));
|
|
97
|
+
return pkg.version ?? "1.0.0";
|
|
98
|
+
} catch {
|
|
99
|
+
return "1.0.0";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function configDir() {
|
|
103
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
104
|
+
const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(homedir(), ".config");
|
|
105
|
+
return path.join(base, "intervals-mcp-server");
|
|
106
|
+
}
|
|
107
|
+
function configFilePath() {
|
|
108
|
+
return path.join(configDir(), "config.json");
|
|
109
|
+
}
|
|
110
|
+
function readStoredCredentials() {
|
|
111
|
+
const file = configFilePath();
|
|
112
|
+
if (!existsSync(file)) return null;
|
|
113
|
+
try {
|
|
114
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
115
|
+
if (typeof raw.apiKey === "string" && typeof raw.athleteId === "string") {
|
|
116
|
+
return { apiKey: raw.apiKey, athleteId: raw.athleteId };
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function saveStoredCredentials(creds) {
|
|
124
|
+
validateAthleteId(creds.athleteId);
|
|
125
|
+
mkdirSync(configDir(), { recursive: true });
|
|
126
|
+
const file = configFilePath();
|
|
127
|
+
writeFileSync(file, JSON.stringify(creds, null, 2) + "\n", { mode: 384 });
|
|
128
|
+
try {
|
|
129
|
+
chmodSync(file, 384);
|
|
130
|
+
} catch {
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function getConfig() {
|
|
134
|
+
const stored = readStoredCredentials();
|
|
135
|
+
const apiKey = process.env.API_KEY || stored?.apiKey || "";
|
|
136
|
+
const athleteId = process.env.ATHLETE_ID || stored?.athleteId || "";
|
|
137
|
+
if (athleteId) validateAthleteId(athleteId);
|
|
138
|
+
return {
|
|
139
|
+
apiKey,
|
|
140
|
+
athleteId,
|
|
141
|
+
apiBaseUrl: process.env.INTERVALS_API_BASE_URL || DEFAULT_API_BASE_URL,
|
|
142
|
+
userAgent: `intervalsicu-mcp-server-ts/${readPackageVersion()}`
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function hasCredentials() {
|
|
146
|
+
const cfg = getConfig();
|
|
147
|
+
return Boolean(cfg.apiKey && cfg.athleteId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/auth.ts
|
|
151
|
+
import * as p from "@clack/prompts";
|
|
152
|
+
|
|
153
|
+
// src/api/client.ts
|
|
154
|
+
function isApiError(result) {
|
|
155
|
+
return typeof result === "object" && result !== null && !Array.isArray(result) && result["error"] === true;
|
|
156
|
+
}
|
|
157
|
+
function isEmptyResult(result) {
|
|
158
|
+
if (result == null) return true;
|
|
159
|
+
if (Array.isArray(result)) return result.length === 0;
|
|
160
|
+
return Object.keys(result).length === 0;
|
|
161
|
+
}
|
|
162
|
+
function buildSearchString(params) {
|
|
163
|
+
if (!params) return "";
|
|
164
|
+
const search = new URLSearchParams();
|
|
165
|
+
for (const [key, value] of Object.entries(params)) {
|
|
166
|
+
if (Array.isArray(value)) {
|
|
167
|
+
for (const item of value) search.append(key, String(item));
|
|
168
|
+
} else {
|
|
169
|
+
search.append(key, String(value));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const s = search.toString();
|
|
173
|
+
return s ? `?${s}` : "";
|
|
174
|
+
}
|
|
175
|
+
function basicAuthHeader(apiKey) {
|
|
176
|
+
return `Basic ${Buffer.from(`API_KEY:${apiKey}`).toString("base64")}`;
|
|
177
|
+
}
|
|
178
|
+
function statusErrorMessage(status) {
|
|
179
|
+
switch (status) {
|
|
180
|
+
case 401:
|
|
181
|
+
return "401 Unauthorized: Please check your API key.";
|
|
182
|
+
case 403:
|
|
183
|
+
return "403 Forbidden: You may not have permission to access this resource.";
|
|
184
|
+
case 404:
|
|
185
|
+
return "404 Not Found: The requested endpoint or ID doesn't exist.";
|
|
186
|
+
case 422:
|
|
187
|
+
return "422 Unprocessable Entity: The server couldn't process the request (invalid parameters or unsupported operation).";
|
|
188
|
+
case 429:
|
|
189
|
+
return "429 Too Many Requests: Too many requests in a short time period.";
|
|
190
|
+
case 500:
|
|
191
|
+
return "500 Internal Server Error: The Intervals.icu server encountered an internal error.";
|
|
192
|
+
case 503:
|
|
193
|
+
return "503 Service Unavailable: The Intervals.icu server might be down or undergoing maintenance.";
|
|
194
|
+
default:
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function makeIntervalsRequest(url, options = {}) {
|
|
199
|
+
const { apiKey, params, method = "GET", data, timeoutMs = 3e4 } = options;
|
|
200
|
+
const config = getConfig();
|
|
201
|
+
const keyToUse = apiKey ?? config.apiKey;
|
|
202
|
+
if (!keyToUse) {
|
|
203
|
+
return {
|
|
204
|
+
error: true,
|
|
205
|
+
message: "API key is required. Run `npx intervals-mcp-server auth` or set the API_KEY environment variable."
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const headers = {
|
|
209
|
+
"User-Agent": config.userAgent,
|
|
210
|
+
Accept: "application/json",
|
|
211
|
+
Authorization: basicAuthHeader(keyToUse)
|
|
212
|
+
};
|
|
213
|
+
if (method === "POST" || method === "PUT") {
|
|
214
|
+
headers["Content-Type"] = "application/json";
|
|
215
|
+
}
|
|
216
|
+
const fullUrl = `${config.apiBaseUrl}${url}${buildSearchString(params)}`;
|
|
217
|
+
try {
|
|
218
|
+
const response = await fetch(fullUrl, {
|
|
219
|
+
method,
|
|
220
|
+
headers,
|
|
221
|
+
body: method === "POST" || method === "PUT" ? JSON.stringify(data ?? null) : void 0,
|
|
222
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
223
|
+
});
|
|
224
|
+
const text2 = await response.text();
|
|
225
|
+
if (!response.ok) {
|
|
226
|
+
const friendly = statusErrorMessage(response.status);
|
|
227
|
+
return {
|
|
228
|
+
error: true,
|
|
229
|
+
status_code: response.status,
|
|
230
|
+
message: friendly ?? (text2 || `HTTP ${response.status}`)
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
let responseData;
|
|
234
|
+
try {
|
|
235
|
+
responseData = text2 ? JSON.parse(text2) : {};
|
|
236
|
+
} catch {
|
|
237
|
+
return { error: true, message: "Invalid JSON in response" };
|
|
238
|
+
}
|
|
239
|
+
return responseData;
|
|
240
|
+
} catch (err) {
|
|
241
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
242
|
+
return { error: true, message: `Request error: ${message}` };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/openUrl.ts
|
|
247
|
+
import { spawn } from "child_process";
|
|
248
|
+
function openUrl(url) {
|
|
249
|
+
try {
|
|
250
|
+
let command;
|
|
251
|
+
let args;
|
|
252
|
+
switch (process.platform) {
|
|
253
|
+
case "darwin":
|
|
254
|
+
command = "open";
|
|
255
|
+
args = [url];
|
|
256
|
+
break;
|
|
257
|
+
case "win32":
|
|
258
|
+
command = "cmd";
|
|
259
|
+
args = ["/c", "start", "", url];
|
|
260
|
+
break;
|
|
261
|
+
default:
|
|
262
|
+
command = "xdg-open";
|
|
263
|
+
args = [url];
|
|
264
|
+
}
|
|
265
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
266
|
+
child.unref();
|
|
267
|
+
return true;
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function terminalLink(url, label = url) {
|
|
273
|
+
const esc = "\x1B";
|
|
274
|
+
return `${esc}]8;;${url}${esc}\\${label}${esc}]8;;${esc}\\`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// src/auth.ts
|
|
278
|
+
var SETTINGS_URL = "https://intervals.icu/settings";
|
|
279
|
+
var CLIENT_SNIPPETS = `
|
|
280
|
+
Add the server to your MCP client (pick one):
|
|
281
|
+
|
|
282
|
+
Claude Code
|
|
283
|
+
claude mcp add intervals -s user -- npx intervals-mcp-server
|
|
284
|
+
|
|
285
|
+
Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json)
|
|
286
|
+
{
|
|
287
|
+
"mcpServers": {
|
|
288
|
+
"intervals": { "command": "npx", "args": ["intervals-mcp-server"] }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
Cursor (~/.cursor/mcp.json)
|
|
293
|
+
{
|
|
294
|
+
"mcpServers": {
|
|
295
|
+
"intervals": { "command": "npx", "args": ["intervals-mcp-server"] }
|
|
296
|
+
}
|
|
297
|
+
}`;
|
|
298
|
+
function cancel2() {
|
|
299
|
+
p.cancel("Setup cancelled \u2014 nothing was saved.");
|
|
300
|
+
process.exit(0);
|
|
301
|
+
}
|
|
302
|
+
async function verifyCredentials(apiKey, athleteId) {
|
|
303
|
+
const today = getDefaultEndDate();
|
|
304
|
+
const result = await makeIntervalsRequest(`/athlete/${athleteId}/wellness`, {
|
|
305
|
+
apiKey,
|
|
306
|
+
params: { oldest: today, newest: today }
|
|
307
|
+
});
|
|
308
|
+
if (isApiError(result)) {
|
|
309
|
+
if (result.status_code === 401) return "The API key was rejected (401). Please re-check it.";
|
|
310
|
+
if (result.status_code === 404) return `Athlete ID "${athleteId}" was not found (404).`;
|
|
311
|
+
return result.message;
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
async function runOnboarding() {
|
|
316
|
+
const existing = readStoredCredentials();
|
|
317
|
+
console.log("");
|
|
318
|
+
p.intro("Intervals.icu MCP server \u2014 first-time setup");
|
|
319
|
+
p.log.message(
|
|
320
|
+
`This server needs your Intervals.icu ${terminalLink("API Key", SETTINGS_URL)} and Athlete ID.`
|
|
321
|
+
);
|
|
322
|
+
p.log.step("Step 1 \u2014 opening the Intervals.icu settings page in your browser");
|
|
323
|
+
const opened = openUrl(SETTINGS_URL);
|
|
324
|
+
if (!opened) {
|
|
325
|
+
p.log.warn("Could not open a browser automatically \u2014 open this URL manually:");
|
|
326
|
+
console.log(` ${SETTINGS_URL}`);
|
|
327
|
+
} else {
|
|
328
|
+
console.log(` ${SETTINGS_URL}`);
|
|
329
|
+
}
|
|
330
|
+
p.log.step("Step 2 \u2014 copy your credentials from that page");
|
|
331
|
+
console.log(
|
|
332
|
+
[
|
|
333
|
+
" \u2022 API Key: find the \u201CAPI Key\u201D row and click Show, then copy the value",
|
|
334
|
+
" \u2022 Athlete ID: shown on the same settings page (all digits, e.g. 123456,",
|
|
335
|
+
" sometimes prefixed with \u201Ci\u201D, e.g. i12345)"
|
|
336
|
+
].join("\n")
|
|
337
|
+
);
|
|
338
|
+
let apiKey = existing?.apiKey ?? "";
|
|
339
|
+
let athleteId = existing?.athleteId ?? "";
|
|
340
|
+
let verified = false;
|
|
341
|
+
for (let attempt = 1; attempt <= 3 && !verified; attempt++) {
|
|
342
|
+
p.log.step(`Step 3 \u2014 enter your credentials (attempt ${attempt}/3)`);
|
|
343
|
+
const keyAnswer = await p.password({
|
|
344
|
+
message: "Paste your Intervals.icu API Key:",
|
|
345
|
+
validate: (value) => {
|
|
346
|
+
if (!value || value.trim().length < 8) return "The API key looks too short \u2014 please re-paste it.";
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
if (p.isCancel(keyAnswer)) cancel2();
|
|
350
|
+
apiKey = keyAnswer.trim();
|
|
351
|
+
const idAnswer = await p.text({
|
|
352
|
+
message: "Enter your Athlete ID:",
|
|
353
|
+
placeholder: "e.g. 123456 or i12345",
|
|
354
|
+
initialValue: athleteId,
|
|
355
|
+
validate: (value) => {
|
|
356
|
+
if (!value) return "Athlete ID is required.";
|
|
357
|
+
try {
|
|
358
|
+
validateAthleteId(value.trim());
|
|
359
|
+
} catch {
|
|
360
|
+
return "Athlete ID must be all digits or \u201Ci\u201D followed by digits.";
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
if (p.isCancel(idAnswer)) cancel2();
|
|
365
|
+
athleteId = idAnswer.trim();
|
|
366
|
+
const spinner2 = p.spinner();
|
|
367
|
+
spinner2.start("Verifying credentials against intervals.icu\u2026");
|
|
368
|
+
const problem = await verifyCredentials(apiKey, athleteId);
|
|
369
|
+
if (problem == null) {
|
|
370
|
+
spinner2.stop("Credentials verified \u2713");
|
|
371
|
+
verified = true;
|
|
372
|
+
} else {
|
|
373
|
+
spinner2.stop("Verification failed");
|
|
374
|
+
p.log.error(problem);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (!verified) {
|
|
378
|
+
p.log.warn("Saving the credentials without live verification.");
|
|
379
|
+
const saveAnyway = await p.confirm({
|
|
380
|
+
message: "Save them anyway?",
|
|
381
|
+
initialValue: false
|
|
382
|
+
});
|
|
383
|
+
if (p.isCancel(saveAnyway) || !saveAnyway) cancel2();
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
saveStoredCredentials({ apiKey, athleteId });
|
|
387
|
+
} catch (e) {
|
|
388
|
+
p.log.error(`Could not save the config file: ${e.message}`);
|
|
389
|
+
p.outro("Setup failed. Try again with `npx intervals-mcp-server auth`.");
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
p.log.success(`Credentials saved to ${configFilePath()} (readable only by you)`);
|
|
393
|
+
if (hasCredentials()) {
|
|
394
|
+
p.log.success("Configuration complete \u2014 the server is ready.");
|
|
395
|
+
}
|
|
396
|
+
console.log(CLIENT_SNIPPETS);
|
|
397
|
+
p.outro("Done! Re-run `npx intervals-mcp-server auth` any time to change credentials.");
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// src/server.ts
|
|
402
|
+
import http from "http";
|
|
403
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
404
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
405
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
406
|
+
|
|
407
|
+
// src/tools/activities.ts
|
|
408
|
+
import { z as z2 } from "zod";
|
|
409
|
+
|
|
410
|
+
// src/utils/formatting.ts
|
|
411
|
+
function formatActivitySummary(activity) {
|
|
412
|
+
let startTime = activity["startTime"] ?? activity["start_date"] ?? "Unknown";
|
|
413
|
+
if (typeof startTime === "string" && startTime.length > 10) {
|
|
414
|
+
startTime = formatIsoDateTime(startTime);
|
|
415
|
+
}
|
|
416
|
+
let rpe = activity["perceived_exertion"] ?? activity["icu_rpe"] ?? "N/A";
|
|
417
|
+
if (typeof rpe === "number") rpe = `${rpe}/10`;
|
|
418
|
+
let feel = activity["feel"] ?? "N/A";
|
|
419
|
+
if (typeof feel === "number") feel = `${feel}/5`;
|
|
420
|
+
const resolvedName = activity["_resolved_gear_name"];
|
|
421
|
+
const gearRaw = activity["gear"];
|
|
422
|
+
let gearName;
|
|
423
|
+
let gearId;
|
|
424
|
+
if (resolvedName) {
|
|
425
|
+
gearName = resolvedName;
|
|
426
|
+
gearId = typeof gearRaw === "object" && gearRaw !== null ? gearRaw["id"] ?? activity["gear_id"] ?? "N/A" : activity["gear_id"] ?? "N/A";
|
|
427
|
+
} else if (typeof gearRaw === "object" && gearRaw !== null) {
|
|
428
|
+
gearName = gearRaw["name"] || gearRaw["display_name"] || "N/A";
|
|
429
|
+
gearId = gearRaw["id"] ?? "N/A";
|
|
430
|
+
} else {
|
|
431
|
+
gearName = activity["gear_name"] ?? "N/A";
|
|
432
|
+
gearId = activity["gear_id"] ?? "N/A";
|
|
433
|
+
}
|
|
434
|
+
return `Activity: ${activity["name"] ?? "Unnamed"}
|
|
435
|
+
ID: ${activity["id"] ?? "N/A"}
|
|
436
|
+
Type: ${activity["type"] ?? "Unknown"}
|
|
437
|
+
Date: ${startTime}
|
|
438
|
+
Description: ${activity["description"] ?? "N/A"}
|
|
439
|
+
Distance: ${activity["distance"] ?? 0} meters
|
|
440
|
+
Duration: ${activity["duration"] ?? activity["elapsed_time"] ?? 0} seconds
|
|
441
|
+
Moving Time: ${activity["moving_time"] ?? "N/A"} seconds
|
|
442
|
+
Elevation Gain: ${activity["elevationGain"] ?? activity["total_elevation_gain"] ?? 0} meters
|
|
443
|
+
Elevation Loss: ${activity["total_elevation_loss"] ?? "N/A"} meters
|
|
444
|
+
|
|
445
|
+
Power Data:
|
|
446
|
+
Average Power: ${activity["avgPower"] ?? activity["icu_average_watts"] ?? activity["average_watts"] ?? "N/A"} watts
|
|
447
|
+
Weighted Avg Power: ${activity["icu_weighted_avg_watts"] ?? "N/A"} watts
|
|
448
|
+
Training Load: ${activity["trainingLoad"] ?? activity["icu_training_load"] ?? "N/A"}
|
|
449
|
+
FTP: ${activity["icu_ftp"] ?? "N/A"} watts
|
|
450
|
+
Kilojoules: ${activity["icu_joules"] ?? "N/A"}
|
|
451
|
+
Intensity: ${activity["icu_intensity"] ?? "N/A"}
|
|
452
|
+
Power:HR Ratio: ${activity["icu_power_hr"] ?? "N/A"}
|
|
453
|
+
Variability Index: ${activity["icu_variability_index"] ?? "N/A"}
|
|
454
|
+
|
|
455
|
+
Heart Rate Data:
|
|
456
|
+
Average Heart Rate: ${activity["avgHr"] ?? activity["average_heartrate"] ?? "N/A"} bpm
|
|
457
|
+
Max Heart Rate: ${activity["max_heartrate"] ?? "N/A"} bpm
|
|
458
|
+
LTHR: ${activity["lthr"] ?? "N/A"} bpm
|
|
459
|
+
Resting HR: ${activity["icu_resting_hr"] ?? "N/A"} bpm
|
|
460
|
+
Decoupling: ${activity["decoupling"] ?? "N/A"}
|
|
461
|
+
|
|
462
|
+
Other Metrics:
|
|
463
|
+
Cadence: ${activity["average_cadence"] ?? "N/A"} rpm
|
|
464
|
+
Calories burned: ${activity["calories"] ?? "N/A"} kcal
|
|
465
|
+
Average Speed: ${activity["average_speed"] ?? "N/A"} m/s
|
|
466
|
+
Max Speed: ${activity["max_speed"] ?? "N/A"} m/s
|
|
467
|
+
Average Stride: ${activity["average_stride"] ?? "N/A"}
|
|
468
|
+
L/R Balance: ${activity["avg_lr_balance"] ?? "N/A"}
|
|
469
|
+
Weight: ${activity["icu_weight"] ?? "N/A"} kg
|
|
470
|
+
RPE: ${rpe}
|
|
471
|
+
Session RPE: ${activity["session_rpe"] ?? "N/A"}
|
|
472
|
+
Feel: ${feel}
|
|
473
|
+
|
|
474
|
+
Environment:
|
|
475
|
+
Trainer: ${activity["trainer"] ?? "N/A"}
|
|
476
|
+
Average Temp: ${activity["average_temp"] ?? "N/A"}\xB0C
|
|
477
|
+
Min Temp: ${activity["min_temp"] ?? "N/A"}\xB0C
|
|
478
|
+
Max Temp: ${activity["max_temp"] ?? "N/A"}\xB0C
|
|
479
|
+
Avg Wind Speed: ${activity["average_wind_speed"] ?? "N/A"} km/h
|
|
480
|
+
Headwind %: ${activity["headwind_percent"] ?? "N/A"}%
|
|
481
|
+
Tailwind %: ${activity["tailwind_percent"] ?? "N/A"}%
|
|
482
|
+
|
|
483
|
+
Training Metrics:
|
|
484
|
+
Fitness (CTL): ${activity["icu_ctl"] ?? "N/A"}
|
|
485
|
+
Fatigue (ATL): ${activity["icu_atl"] ?? "N/A"}
|
|
486
|
+
TRIMP: ${activity["trimp"] ?? "N/A"}
|
|
487
|
+
Polarization Index: ${activity["polarization_index"] ?? "N/A"}
|
|
488
|
+
Power Load: ${activity["power_load"] ?? "N/A"}
|
|
489
|
+
HR Load: ${activity["hr_load"] ?? "N/A"}
|
|
490
|
+
Pace Load: ${activity["pace_load"] ?? "N/A"}
|
|
491
|
+
Efficiency Factor: ${activity["icu_efficiency_factor"] ?? "N/A"}
|
|
492
|
+
|
|
493
|
+
Device Info:
|
|
494
|
+
Device: ${activity["device_name"] ?? "N/A"}
|
|
495
|
+
Power Meter: ${activity["power_meter"] ?? "N/A"}
|
|
496
|
+
File Type: ${activity["file_type"] ?? "N/A"}
|
|
497
|
+
|
|
498
|
+
Gear:
|
|
499
|
+
Name: ${gearName}
|
|
500
|
+
ID: ${gearId}`;
|
|
501
|
+
}
|
|
502
|
+
function formatActivityMessage(message) {
|
|
503
|
+
let created = message["created"] ?? "Unknown";
|
|
504
|
+
if (typeof created === "string" && created.length > 10) {
|
|
505
|
+
created = formatIsoDateTime(created);
|
|
506
|
+
}
|
|
507
|
+
return `Author: ${message["name"] ?? "Unknown"}
|
|
508
|
+
Date: ${created}
|
|
509
|
+
Type: ${message["type"] ?? "TEXT"}
|
|
510
|
+
Content: ${message["content"] ?? ""}`;
|
|
511
|
+
}
|
|
512
|
+
function formatTrainingMetrics(get) {
|
|
513
|
+
const rows = [
|
|
514
|
+
["ctl", "Fitness (CTL)"],
|
|
515
|
+
["atl", "Fatigue (ATL)"],
|
|
516
|
+
["rampRate", "Ramp Rate"],
|
|
517
|
+
["ctlLoad", "CTL Load"],
|
|
518
|
+
["atlLoad", "ATL Load"]
|
|
519
|
+
];
|
|
520
|
+
const lines = [];
|
|
521
|
+
for (const [k, label] of rows) {
|
|
522
|
+
if (get(k) != null) lines.push(`- ${label}: ${get(k)}`);
|
|
523
|
+
}
|
|
524
|
+
return lines;
|
|
525
|
+
}
|
|
526
|
+
function formatSportInfo(get) {
|
|
527
|
+
const lines = [];
|
|
528
|
+
const sportInfo = get("sportInfo");
|
|
529
|
+
if (Array.isArray(sportInfo)) {
|
|
530
|
+
for (const sport of sportInfo) {
|
|
531
|
+
if (typeof sport === "object" && sport !== null && sport["eftp"] != null) {
|
|
532
|
+
lines.push(`- ${sport["type"]}: eFTP = ${sport["eftp"]}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return lines;
|
|
537
|
+
}
|
|
538
|
+
function formatVitalSigns(get) {
|
|
539
|
+
const rows = [
|
|
540
|
+
["weight", "Weight", "kg"],
|
|
541
|
+
["restingHR", "Resting HR", "bpm"],
|
|
542
|
+
["hrv", "HRV", ""],
|
|
543
|
+
["hrvSDNN", "HRV SDNN", ""],
|
|
544
|
+
["avgSleepingHR", "Average Sleeping HR", "bpm"],
|
|
545
|
+
["spO2", "SpO2", "%"],
|
|
546
|
+
["systolic", "Systolic BP", ""],
|
|
547
|
+
["diastolic", "Diastolic BP", ""],
|
|
548
|
+
["respiration", "Respiration", "breaths/min"],
|
|
549
|
+
["bloodGlucose", "Blood Glucose", "mmol/L"],
|
|
550
|
+
["lactate", "Lactate", "mmol/L"],
|
|
551
|
+
["vo2max", "VO2 Max", "ml/kg/min"],
|
|
552
|
+
["bodyFat", "Body Fat", "%"],
|
|
553
|
+
["abdomen", "Abdomen", "cm"],
|
|
554
|
+
["baevskySI", "Baevsky Stress Index", ""]
|
|
555
|
+
];
|
|
556
|
+
const lines = [];
|
|
557
|
+
for (const [k, label, unit] of rows) {
|
|
558
|
+
if (get(k) == null) continue;
|
|
559
|
+
if (k === "systolic" && get("diastolic") != null) {
|
|
560
|
+
lines.push(`- Blood Pressure: ${get("systolic")}/${get("diastolic")} mmHg`);
|
|
561
|
+
} else if (k !== "systolic" && k !== "diastolic") {
|
|
562
|
+
lines.push(`- ${label}: ${get(k)}${unit ? ` ${unit}` : ""}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return lines;
|
|
566
|
+
}
|
|
567
|
+
var SLEEP_QUALITY_LABELS = {
|
|
568
|
+
1: "Great",
|
|
569
|
+
2: "Good",
|
|
570
|
+
3: "Average",
|
|
571
|
+
4: "Poor"
|
|
572
|
+
};
|
|
573
|
+
function formatSleepRecovery(get) {
|
|
574
|
+
const lines = [];
|
|
575
|
+
let sleepHours = null;
|
|
576
|
+
if (get("sleepSecs") != null) {
|
|
577
|
+
const secs = get("sleepSecs");
|
|
578
|
+
sleepHours = (secs / 3600).toFixed(2);
|
|
579
|
+
} else if (get("sleepHours") != null) {
|
|
580
|
+
sleepHours = `${get("sleepHours")}`;
|
|
581
|
+
}
|
|
582
|
+
if (sleepHours !== null) lines.push(` Sleep: ${sleepHours} hours`);
|
|
583
|
+
if (get("sleepQuality") != null) {
|
|
584
|
+
const quality = get("sleepQuality");
|
|
585
|
+
lines.push(` Sleep Quality: ${quality} (${SLEEP_QUALITY_LABELS[quality] ?? String(quality)})`);
|
|
586
|
+
}
|
|
587
|
+
if (get("sleepScore") != null) lines.push(` Device Sleep Score: ${get("sleepScore")}/100`);
|
|
588
|
+
if (get("readiness") != null) lines.push(` Readiness: ${get("readiness")}/10`);
|
|
589
|
+
return lines;
|
|
590
|
+
}
|
|
591
|
+
function formatMenstrualTracking(get) {
|
|
592
|
+
const lines = [];
|
|
593
|
+
if (get("menstrualPhase") != null) {
|
|
594
|
+
const phase = String(get("menstrualPhase"));
|
|
595
|
+
lines.push(` Menstrual Phase: ${phase.charAt(0).toUpperCase()}${phase.slice(1)}`);
|
|
596
|
+
}
|
|
597
|
+
if (get("menstrualPhasePredicted") != null) {
|
|
598
|
+
const phase = String(get("menstrualPhasePredicted"));
|
|
599
|
+
lines.push(` Predicted Phase: ${phase.charAt(0).toUpperCase()}${phase.slice(1)}`);
|
|
600
|
+
}
|
|
601
|
+
return lines;
|
|
602
|
+
}
|
|
603
|
+
function formatSubjectiveFeelings(get) {
|
|
604
|
+
const rows = [
|
|
605
|
+
["soreness", "Soreness"],
|
|
606
|
+
["fatigue", "Fatigue"],
|
|
607
|
+
["stress", "Stress"],
|
|
608
|
+
["mood", "Mood"],
|
|
609
|
+
["motivation", "Motivation"],
|
|
610
|
+
["injury", "Injury Level"]
|
|
611
|
+
];
|
|
612
|
+
const lines = [];
|
|
613
|
+
for (const [k, label] of rows) {
|
|
614
|
+
if (get(k) != null) lines.push(` ${label}: ${get(k)}/10`);
|
|
615
|
+
}
|
|
616
|
+
return lines;
|
|
617
|
+
}
|
|
618
|
+
function formatNutritionHydration(get) {
|
|
619
|
+
const rows = [
|
|
620
|
+
["kcalConsumed", "Calories Consumed", ""],
|
|
621
|
+
["carbohydrates", "Carbohydrates", "g"],
|
|
622
|
+
["protein", "Protein", "g"],
|
|
623
|
+
["fatTotal", "Fat", "g"],
|
|
624
|
+
["hydrationVolume", "Hydration Volume", ""]
|
|
625
|
+
];
|
|
626
|
+
const lines = [];
|
|
627
|
+
for (const [k, label, unit] of rows) {
|
|
628
|
+
if (get(k) != null) lines.push(`- ${label}: ${get(k)}${unit ? ` ${unit}` : ""}`);
|
|
629
|
+
}
|
|
630
|
+
if (get("hydration") != null) lines.push(` Hydration Score: ${get("hydration")}/10`);
|
|
631
|
+
return lines;
|
|
632
|
+
}
|
|
633
|
+
function formatWellnessEntry(entries, includeAllFields = false) {
|
|
634
|
+
const accessed = /* @__PURE__ */ new Set();
|
|
635
|
+
const get = (k) => {
|
|
636
|
+
accessed.add(k);
|
|
637
|
+
return entries[k];
|
|
638
|
+
};
|
|
639
|
+
if (includeAllFields) {
|
|
640
|
+
for (const k of ["date", "updated", "tempWeight", "tempRestingHR"]) accessed.add(k);
|
|
641
|
+
}
|
|
642
|
+
const lines = ["Wellness Data:"];
|
|
643
|
+
lines.push(`Date: ${get("id") ?? "N/A"}`);
|
|
644
|
+
lines.push("");
|
|
645
|
+
const trainingMetrics = formatTrainingMetrics(get);
|
|
646
|
+
if (trainingMetrics.length) {
|
|
647
|
+
lines.push("Training Metrics:");
|
|
648
|
+
lines.push(...trainingMetrics);
|
|
649
|
+
lines.push("");
|
|
650
|
+
}
|
|
651
|
+
const sportInfo = formatSportInfo(get);
|
|
652
|
+
if (sportInfo.length) {
|
|
653
|
+
lines.push("Sport-Specific Info:");
|
|
654
|
+
lines.push(...sportInfo);
|
|
655
|
+
lines.push("");
|
|
656
|
+
}
|
|
657
|
+
const vitalSigns = formatVitalSigns(get);
|
|
658
|
+
if (vitalSigns.length) {
|
|
659
|
+
lines.push("Vital Signs:");
|
|
660
|
+
lines.push(...vitalSigns);
|
|
661
|
+
lines.push("");
|
|
662
|
+
}
|
|
663
|
+
const sleepLines = formatSleepRecovery(get);
|
|
664
|
+
if (sleepLines.length) {
|
|
665
|
+
lines.push("Sleep & Recovery:");
|
|
666
|
+
lines.push(...sleepLines);
|
|
667
|
+
lines.push("");
|
|
668
|
+
}
|
|
669
|
+
const menstrualLines = formatMenstrualTracking(get);
|
|
670
|
+
if (menstrualLines.length) {
|
|
671
|
+
lines.push("Menstrual Tracking:");
|
|
672
|
+
lines.push(...menstrualLines);
|
|
673
|
+
lines.push("");
|
|
674
|
+
}
|
|
675
|
+
const subjectiveLines = formatSubjectiveFeelings(get);
|
|
676
|
+
if (subjectiveLines.length) {
|
|
677
|
+
lines.push("Subjective Feelings:");
|
|
678
|
+
lines.push(...subjectiveLines);
|
|
679
|
+
lines.push("");
|
|
680
|
+
}
|
|
681
|
+
const nutritionLines = formatNutritionHydration(get);
|
|
682
|
+
if (nutritionLines.length) {
|
|
683
|
+
lines.push("Nutrition & Hydration:");
|
|
684
|
+
lines.push(...nutritionLines);
|
|
685
|
+
lines.push("");
|
|
686
|
+
}
|
|
687
|
+
if (get("steps") != null) {
|
|
688
|
+
lines.push("Activity:");
|
|
689
|
+
lines.push(`- Steps: ${get("steps")}`);
|
|
690
|
+
lines.push("");
|
|
691
|
+
}
|
|
692
|
+
if (get("comments")) lines.push(`Comments: ${get("comments")}`);
|
|
693
|
+
if (accessed.has("locked")) {
|
|
694
|
+
lines.push(`Status: ${get("locked") ? "Locked" : "Unlocked"}`);
|
|
695
|
+
}
|
|
696
|
+
if (includeAllFields) {
|
|
697
|
+
const otherLines = [];
|
|
698
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
699
|
+
if (!accessed.has(key) && value != null) {
|
|
700
|
+
otherLines.push(
|
|
701
|
+
`- ${key}: ${typeof value === "object" ? JSON.stringify(value) : value}`
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (otherLines.length) {
|
|
706
|
+
lines.push("");
|
|
707
|
+
lines.push("Other Fields:");
|
|
708
|
+
lines.push(...otherLines);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return lines.join("\n");
|
|
712
|
+
}
|
|
713
|
+
function formatEventSummary(event) {
|
|
714
|
+
const eventDate = event["start_date_local"] ?? event["date"] ?? "Unknown";
|
|
715
|
+
const eventType = event["workout"] ? "Workout" : event["race"] ? "Race" : "Other";
|
|
716
|
+
return `Date: ${eventDate}
|
|
717
|
+
ID: ${event["id"] ?? "N/A"}
|
|
718
|
+
Type: ${eventType}
|
|
719
|
+
Name: ${event["name"] ?? "Unnamed"}
|
|
720
|
+
Description: ${event["description"] ?? "No description"}`;
|
|
721
|
+
}
|
|
722
|
+
function formatEventDetails(event) {
|
|
723
|
+
let details = `Event Details:
|
|
724
|
+
|
|
725
|
+
ID: ${event["id"] ?? "N/A"}
|
|
726
|
+
Date: ${event["date"] ?? "Unknown"}
|
|
727
|
+
Name: ${event["name"] ?? "Unnamed"}
|
|
728
|
+
Description: ${event["description"] ?? "No description"}`;
|
|
729
|
+
const workout = event["workout"];
|
|
730
|
+
if (typeof workout === "object" && workout !== null) {
|
|
731
|
+
details += `
|
|
732
|
+
|
|
733
|
+
Workout Information:
|
|
734
|
+
Workout ID: ${workout["id"] ?? "N/A"}
|
|
735
|
+
Sport: ${workout["sport"] ?? "Unknown"}
|
|
736
|
+
Duration: ${workout["duration"] ?? 0} seconds
|
|
737
|
+
TSS: ${workout["tss"] ?? "N/A"}`;
|
|
738
|
+
if (Array.isArray(workout["intervals"])) {
|
|
739
|
+
details += `
|
|
740
|
+
Intervals: ${workout["intervals"].length}`;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (event["race"]) {
|
|
744
|
+
details += `
|
|
745
|
+
|
|
746
|
+
Race Information:
|
|
747
|
+
Priority: ${event["priority"] ?? "N/A"}
|
|
748
|
+
Result: ${event["result"] ?? "N/A"}`;
|
|
749
|
+
}
|
|
750
|
+
const cal = event["calendar"];
|
|
751
|
+
if (typeof cal === "object" && cal !== null) {
|
|
752
|
+
details += `
|
|
753
|
+
|
|
754
|
+
Calendar: ${cal["name"] ?? "N/A"}`;
|
|
755
|
+
}
|
|
756
|
+
return details;
|
|
757
|
+
}
|
|
758
|
+
function formatCustomItemDetails(item) {
|
|
759
|
+
const lines = ["Custom Item Details:", ""];
|
|
760
|
+
lines.push(`ID: ${item["id"] ?? "N/A"}`);
|
|
761
|
+
lines.push(`Name: ${item["name"] ?? "N/A"}`);
|
|
762
|
+
lines.push(`Type: ${item["type"] ?? "N/A"}`);
|
|
763
|
+
if (item["description"]) lines.push(`Description: ${item["description"]}`);
|
|
764
|
+
if (item["visibility"]) lines.push(`Visibility: ${item["visibility"]}`);
|
|
765
|
+
if (item["index"] != null) lines.push(`Index: ${item["index"]}`);
|
|
766
|
+
if (item["hide_script"] != null) lines.push(`Hide Script: ${item["hide_script"]}`);
|
|
767
|
+
if (item["content"]) lines.push(`Content: ${JSON.stringify(item["content"], null, 2)}`);
|
|
768
|
+
return lines.join("\n");
|
|
769
|
+
}
|
|
770
|
+
function formatIntervals(intervalsData) {
|
|
771
|
+
let result = `Intervals Analysis:
|
|
772
|
+
|
|
773
|
+
ID: ${intervalsData["id"] ?? "N/A"}
|
|
774
|
+
Analyzed: ${intervalsData["analyzed"] ?? "N/A"}
|
|
775
|
+
|
|
776
|
+
`;
|
|
777
|
+
const intervals = intervalsData["icu_intervals"];
|
|
778
|
+
if (Array.isArray(intervals) && intervals.length > 0) {
|
|
779
|
+
result += "Individual Intervals:\n\n";
|
|
780
|
+
intervals.forEach((interval, i) => {
|
|
781
|
+
result += `[${i + 1}] ${interval["label"] ?? `Interval ${i + 1}`} (${interval["type"] ?? "Unknown"})
|
|
782
|
+
Duration: ${interval["elapsed_time"] ?? 0} seconds (moving: ${interval["moving_time"] ?? 0} seconds)
|
|
783
|
+
Distance: ${interval["distance"] ?? 0} meters
|
|
784
|
+
Start-End Indices: ${interval["start_index"] ?? 0}-${interval["end_index"] ?? 0}
|
|
785
|
+
|
|
786
|
+
Power Metrics:
|
|
787
|
+
Average Power: ${interval["average_watts"] ?? 0} watts (${interval["average_watts_kg"] ?? 0} W/kg)
|
|
788
|
+
Max Power: ${interval["max_watts"] ?? 0} watts (${interval["max_watts_kg"] ?? 0} W/kg)
|
|
789
|
+
Weighted Avg Power: ${interval["weighted_average_watts"] ?? 0} watts
|
|
790
|
+
Intensity: ${interval["intensity"] ?? 0}
|
|
791
|
+
Training Load: ${interval["training_load"] ?? 0}
|
|
792
|
+
Joules: ${interval["joules"] ?? 0}
|
|
793
|
+
Joules > FTP: ${interval["joules_above_ftp"] ?? 0}
|
|
794
|
+
Power Zone: ${interval["zone"] ?? "N/A"} (${interval["zone_min_watts"] ?? 0}-${interval["zone_max_watts"] ?? 0} watts)
|
|
795
|
+
W' Balance: Start ${interval["wbal_start"] ?? 0}, End ${interval["wbal_end"] ?? 0}
|
|
796
|
+
L/R Balance: ${interval["avg_lr_balance"] ?? 0}
|
|
797
|
+
Variability: ${interval["w5s_variability"] ?? 0}
|
|
798
|
+
Torque: Avg ${interval["average_torque"] ?? 0}, Min ${interval["min_torque"] ?? 0}, Max ${interval["max_torque"] ?? 0}
|
|
799
|
+
|
|
800
|
+
Heart Rate & Metabolic:
|
|
801
|
+
Heart Rate: Avg ${interval["average_heartrate"] ?? 0}, Min ${interval["min_heartrate"] ?? 0}, Max ${interval["max_heartrate"] ?? 0} bpm
|
|
802
|
+
Decoupling: ${interval["decoupling"] ?? 0}
|
|
803
|
+
DFA \u03B11: ${interval["average_dfa_a1"] ?? 0}
|
|
804
|
+
Respiration: ${interval["average_respiration"] ?? 0} breaths/min
|
|
805
|
+
EPOC: ${interval["average_epoc"] ?? 0}
|
|
806
|
+
SmO2: ${interval["average_smo2"] ?? 0}% / ${interval["average_smo2_2"] ?? 0}%
|
|
807
|
+
THb: ${interval["average_thb"] ?? 0} / ${interval["average_thb_2"] ?? 0}
|
|
808
|
+
|
|
809
|
+
Speed & Cadence:
|
|
810
|
+
Speed: Avg ${interval["average_speed"] ?? 0}, Min ${interval["min_speed"] ?? 0}, Max ${interval["max_speed"] ?? 0} m/s
|
|
811
|
+
GAP: ${interval["gap"] ?? 0} m/s
|
|
812
|
+
Cadence: Avg ${interval["average_cadence"] ?? 0}, Min ${interval["min_cadence"] ?? 0}, Max ${interval["max_cadence"] ?? 0} rpm
|
|
813
|
+
Stride: ${interval["average_stride"] ?? 0}
|
|
814
|
+
|
|
815
|
+
Elevation & Environment:
|
|
816
|
+
Elevation Gain: ${interval["total_elevation_gain"] ?? 0} meters
|
|
817
|
+
Altitude: Min ${interval["min_altitude"] ?? 0}, Max ${interval["max_altitude"] ?? 0} meters
|
|
818
|
+
Gradient: ${interval["average_gradient"] ?? 0}%
|
|
819
|
+
Temperature: ${interval["average_temp"] ?? 0}\xB0C (Weather: ${interval["average_weather_temp"] ?? 0}\xB0C, Feels like: ${interval["average_feels_like"] ?? 0}\xB0C)
|
|
820
|
+
Wind: Speed ${interval["average_wind_speed"] ?? 0} km/h, Gust ${interval["average_wind_gust"] ?? 0} km/h, Direction ${interval["prevailing_wind_deg"] ?? 0}\xB0
|
|
821
|
+
Headwind: ${interval["headwind_percent"] ?? 0}%, Tailwind: ${interval["tailwind_percent"] ?? 0}%
|
|
822
|
+
|
|
823
|
+
`;
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
const groups = intervalsData["icu_groups"];
|
|
827
|
+
if (Array.isArray(groups) && groups.length > 0) {
|
|
828
|
+
result += "Interval Groups:\n\n";
|
|
829
|
+
groups.forEach((group, i) => {
|
|
830
|
+
result += `Group: ${group["id"] ?? `Group ${i + 1}`} (Contains ${group["count"] ?? 0} intervals)
|
|
831
|
+
Duration: ${group["elapsed_time"] ?? 0} seconds (moving: ${group["moving_time"] ?? 0} seconds)
|
|
832
|
+
Distance: ${group["distance"] ?? 0} meters
|
|
833
|
+
Start-End Indices: ${group["start_index"] ?? 0}-N/A
|
|
834
|
+
|
|
835
|
+
Power: Avg ${group["average_watts"] ?? 0} watts (${group["average_watts_kg"] ?? 0} W/kg), Max ${group["max_watts"] ?? 0} watts
|
|
836
|
+
W. Avg Power: ${group["weighted_average_watts"] ?? 0} watts, Intensity: ${group["intensity"] ?? 0}
|
|
837
|
+
Heart Rate: Avg ${group["average_heartrate"] ?? 0}, Max ${group["max_heartrate"] ?? 0} bpm
|
|
838
|
+
Speed: Avg ${group["average_speed"] ?? 0}, Max ${group["max_speed"] ?? 0} m/s
|
|
839
|
+
Cadence: Avg ${group["average_cadence"] ?? 0}, Max ${group["max_cadence"] ?? 0} rpm
|
|
840
|
+
|
|
841
|
+
`;
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
return result;
|
|
845
|
+
}
|
|
846
|
+
function formatDurationLabel(secs) {
|
|
847
|
+
if (secs < 60) return `${secs}s`;
|
|
848
|
+
if (secs < 3600) {
|
|
849
|
+
const mins = Math.floor(secs / 60);
|
|
850
|
+
const remainder2 = secs % 60;
|
|
851
|
+
return remainder2 ? `${mins}m${remainder2}s` : `${mins}m`;
|
|
852
|
+
}
|
|
853
|
+
const hours = Math.floor(secs / 3600);
|
|
854
|
+
const remainder = Math.floor(secs % 3600 / 60);
|
|
855
|
+
return remainder ? `${hours}h${remainder}m` : `${hours}h`;
|
|
856
|
+
}
|
|
857
|
+
function formatPowerCurves(curves, activityType, includeNormalised) {
|
|
858
|
+
const lines = [`Power Curves (${activityType}):`, ""];
|
|
859
|
+
for (const curve of curves) {
|
|
860
|
+
const label = curve.label || curve.id || "Unknown";
|
|
861
|
+
let dateRange = "";
|
|
862
|
+
if (curve.start && curve.end) {
|
|
863
|
+
const startShort = curve.start.length > 10 ? curve.start.slice(0, 10) : curve.start;
|
|
864
|
+
const endShort = curve.end.length > 10 ? curve.end.slice(0, 10) : curve.end;
|
|
865
|
+
dateRange = ` (${startShort} to ${endShort})`;
|
|
866
|
+
}
|
|
867
|
+
lines.push(`${label}${dateRange}:`);
|
|
868
|
+
const dataPoints = curve.data_points ?? [];
|
|
869
|
+
if (!dataPoints.length) {
|
|
870
|
+
lines.push(" No data available for requested durations.");
|
|
871
|
+
lines.push("");
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
for (const point of dataPoints) {
|
|
875
|
+
const durLabel = formatDurationLabel(point.secs);
|
|
876
|
+
const aid = point.activity_id ?? "";
|
|
877
|
+
const parts = [` ${durLabel}: ${point.watts ?? "N/A"}W`];
|
|
878
|
+
if (includeNormalised && point.watts_per_kg != null) {
|
|
879
|
+
parts.push(`${point.watts_per_kg.toFixed(2)}W/kg`);
|
|
880
|
+
const wkgAid = point.wkg_activity_id ?? "";
|
|
881
|
+
if (wkgAid && wkgAid !== aid) parts.push(`[${aid}|wkg:${wkgAid}]`);
|
|
882
|
+
else parts.push(`[${aid}]`);
|
|
883
|
+
} else {
|
|
884
|
+
parts.push(`[${aid}]`);
|
|
885
|
+
}
|
|
886
|
+
lines.push(parts.join(" "));
|
|
887
|
+
}
|
|
888
|
+
lines.push("");
|
|
889
|
+
}
|
|
890
|
+
return lines.join("\n");
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// src/tools/gear.ts
|
|
894
|
+
import { z } from "zod";
|
|
895
|
+
|
|
896
|
+
// src/tools/shared.ts
|
|
897
|
+
function textResult(text2) {
|
|
898
|
+
return { content: [{ type: "text", text: text2 }] };
|
|
899
|
+
}
|
|
900
|
+
function apiErrorMessage(result) {
|
|
901
|
+
return typeof result["message"] === "string" ? result["message"] : "Unknown error";
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// src/tools/gear.ts
|
|
905
|
+
var gearRawCache = /* @__PURE__ */ new Map();
|
|
906
|
+
function extractGearId(activity) {
|
|
907
|
+
const gearRaw = activity["gear"];
|
|
908
|
+
if (typeof gearRaw === "object" && gearRaw !== null) {
|
|
909
|
+
const id = gearRaw["id"];
|
|
910
|
+
if (id) return String(id);
|
|
911
|
+
}
|
|
912
|
+
const gearId = activity["gear_id"];
|
|
913
|
+
if (gearId) return String(gearId);
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
function itemsFromResponse(result) {
|
|
917
|
+
if (Array.isArray(result)) return result.filter((i) => typeof i === "object" && i !== null);
|
|
918
|
+
if (typeof result === "object" && result !== null) {
|
|
919
|
+
for (const value of Object.values(result)) {
|
|
920
|
+
if (Array.isArray(value)) {
|
|
921
|
+
return value.filter((i) => typeof i === "object" && i !== null);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return [];
|
|
926
|
+
}
|
|
927
|
+
function deriveGearMap(items) {
|
|
928
|
+
const map = /* @__PURE__ */ new Map();
|
|
929
|
+
for (const item of items) {
|
|
930
|
+
const gid = item["id"];
|
|
931
|
+
const name = item["name"] || item["display_name"];
|
|
932
|
+
if (gid && name) map.set(String(gid), String(name));
|
|
933
|
+
}
|
|
934
|
+
return map;
|
|
935
|
+
}
|
|
936
|
+
async function getGearRaw(opts = {}) {
|
|
937
|
+
const config = getConfig();
|
|
938
|
+
const { athleteId, error } = resolveAthleteId(opts.athleteId, config.athleteId);
|
|
939
|
+
if (error || !athleteId) return [];
|
|
940
|
+
if (!opts.refresh && gearRawCache.has(athleteId)) {
|
|
941
|
+
return gearRawCache.get(athleteId);
|
|
942
|
+
}
|
|
943
|
+
const result = await makeIntervalsRequest(`/athlete/${athleteId}/gear`, {
|
|
944
|
+
apiKey: opts.apiKey
|
|
945
|
+
});
|
|
946
|
+
const items = itemsFromResponse(result);
|
|
947
|
+
gearRawCache.set(athleteId, items);
|
|
948
|
+
return items;
|
|
949
|
+
}
|
|
950
|
+
async function getGearMap(opts = {}) {
|
|
951
|
+
const items = await getGearRaw(opts);
|
|
952
|
+
return deriveGearMap(items);
|
|
953
|
+
}
|
|
954
|
+
async function resolveGearForActivity(activity, opts = {}) {
|
|
955
|
+
const gearId = extractGearId(activity);
|
|
956
|
+
if (!gearId) return;
|
|
957
|
+
const gearMap = await getGearMap(opts);
|
|
958
|
+
const name = gearMap.get(gearId);
|
|
959
|
+
if (name) activity["_resolved_gear_name"] = name;
|
|
960
|
+
}
|
|
961
|
+
async function resolveGearForActivities(activities, opts = {}) {
|
|
962
|
+
if (!activities.length) return;
|
|
963
|
+
await getGearMap(opts);
|
|
964
|
+
for (const activity of activities) {
|
|
965
|
+
if (typeof activity === "object" && activity !== null) {
|
|
966
|
+
await resolveGearForActivity(activity, opts);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
var registerGearTools = (server) => {
|
|
971
|
+
server.registerTool(
|
|
972
|
+
"get_gear_list",
|
|
973
|
+
{
|
|
974
|
+
title: "Get Gear List",
|
|
975
|
+
description: "Get the gear catalog (bikes, shoes, etc.) for an athlete from Intervals.icu.\n\nReturns one line per gear item with id, type, name, and basic stats.\nThe result is cached for the MCP process lifetime; pass refresh=true to re-fetch.",
|
|
976
|
+
inputSchema: {
|
|
977
|
+
athlete_id: z.string().optional().describe("Intervals.icu athlete ID (defaults to configured ATHLETE_ID)"),
|
|
978
|
+
api_key: z.string().optional().describe("Intervals.icu API key (defaults to configured API_KEY)"),
|
|
979
|
+
refresh: z.boolean().default(false).describe("Bypass the cache and re-fetch from the API")
|
|
980
|
+
}
|
|
981
|
+
},
|
|
982
|
+
async ({ athlete_id, api_key, refresh }) => {
|
|
983
|
+
const config = getConfig();
|
|
984
|
+
const { athleteId, error } = resolveAthleteId(athlete_id, config.athleteId);
|
|
985
|
+
if (error) return textResult(error);
|
|
986
|
+
if (!athleteId) {
|
|
987
|
+
return textResult(
|
|
988
|
+
"Error: athlete_id is required (either as argument or via ATHLETE_ID)."
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
const items = await getGearRaw({ athleteId, apiKey: api_key, refresh });
|
|
992
|
+
if (!items.length) return textResult(`No gear found for athlete ${athleteId}.`);
|
|
993
|
+
let output = `Gear catalog for athlete ${athleteId}:
|
|
994
|
+
|
|
995
|
+
`;
|
|
996
|
+
output += `${"ID".padEnd(14)} ${"Type".padEnd(8)} ${"Name".padEnd(32)} ${"Default".padEnd(8)} ${"Acts".padEnd(6)} ${"Dist (km)".padEnd(10)} ${"Retired".padEnd(8)}
|
|
997
|
+
`;
|
|
998
|
+
output += `${"-".repeat(14)} ${"-".repeat(8)} ${"-".repeat(32)} ${"-".repeat(8)} ${"-".repeat(6)} ${"-".repeat(10)} ${"-".repeat(8)}
|
|
999
|
+
`;
|
|
1000
|
+
for (const it of items) {
|
|
1001
|
+
const gid = String(it["id"] ?? "?");
|
|
1002
|
+
const gtype = String(it["component_type"] ?? it["type"] ?? "?");
|
|
1003
|
+
const name = String(it["name"] ?? "?").slice(0, 32);
|
|
1004
|
+
const defaultFor = String(it["default_for_type"] || it["default_for"] || "");
|
|
1005
|
+
const acts = String(it["activities"] ?? it["activity_count"] ?? "?");
|
|
1006
|
+
const distM = it["distance"] ?? 0;
|
|
1007
|
+
const distKm = typeof distM === "number" ? (distM / 1e3).toFixed(1) : "?";
|
|
1008
|
+
const retired = it["retired"] ? "yes" : "";
|
|
1009
|
+
output += `${gid.padEnd(14)} ${gtype.padEnd(8)} ${name.padEnd(32)} ${defaultFor.padEnd(8)} ${acts.padEnd(6)} ${distKm.padEnd(10)} ${retired.padEnd(8)}
|
|
1010
|
+
`;
|
|
1011
|
+
}
|
|
1012
|
+
return textResult(output);
|
|
1013
|
+
}
|
|
1014
|
+
);
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
// src/tools/activities.ts
|
|
1018
|
+
function parseActivitiesFromResult(result) {
|
|
1019
|
+
if (Array.isArray(result)) {
|
|
1020
|
+
return result.filter((item) => typeof item === "object" && item !== null);
|
|
1021
|
+
}
|
|
1022
|
+
if (typeof result === "object" && result !== null) {
|
|
1023
|
+
const obj = result;
|
|
1024
|
+
for (const value of Object.values(obj)) {
|
|
1025
|
+
if (Array.isArray(value)) {
|
|
1026
|
+
return value.filter((item) => typeof item === "object" && item !== null);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
if (["name", "startTime", "distance"].some((key) => key in obj)) return [obj];
|
|
1030
|
+
}
|
|
1031
|
+
return [];
|
|
1032
|
+
}
|
|
1033
|
+
function filterNamedActivities(activities) {
|
|
1034
|
+
return activities.filter(
|
|
1035
|
+
(activity) => activity["name"] && activity["name"] !== "Unnamed"
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
async function fetchMoreActivities(athleteId, startDate, apiKey, apiLimit) {
|
|
1039
|
+
const oldest = /* @__PURE__ */ new Date(`${startDate}T00:00:00`);
|
|
1040
|
+
const olderStart = new Date(oldest);
|
|
1041
|
+
olderStart.setDate(olderStart.getDate() - 60);
|
|
1042
|
+
const olderEnd = new Date(oldest);
|
|
1043
|
+
olderEnd.setDate(olderEnd.getDate() - 1);
|
|
1044
|
+
const olderStartDate = toLocalDateString(olderStart);
|
|
1045
|
+
const olderEndDate = toLocalDateString(olderEnd);
|
|
1046
|
+
if (olderStartDate >= olderEndDate) return [];
|
|
1047
|
+
const moreResult = await makeIntervalsRequest(`/athlete/${athleteId}/activities`, {
|
|
1048
|
+
apiKey,
|
|
1049
|
+
params: { oldest: olderStartDate, newest: olderEndDate, limit: apiLimit }
|
|
1050
|
+
});
|
|
1051
|
+
if (Array.isArray(moreResult)) return filterNamedActivities(moreResult);
|
|
1052
|
+
return [];
|
|
1053
|
+
}
|
|
1054
|
+
function formatActivitiesResponse(activities, athleteId, includeUnnamed) {
|
|
1055
|
+
if (!activities.length) {
|
|
1056
|
+
if (includeUnnamed) {
|
|
1057
|
+
return `No valid activities found for athlete ${athleteId} in the specified date range.`;
|
|
1058
|
+
}
|
|
1059
|
+
return `No named activities found for athlete ${athleteId} in the specified date range. Try with include_unnamed=true to see all activities.`;
|
|
1060
|
+
}
|
|
1061
|
+
let summary = "Activities:\n\n";
|
|
1062
|
+
for (const activity of activities) {
|
|
1063
|
+
summary += `${formatActivitySummary(activity)}
|
|
1064
|
+
`;
|
|
1065
|
+
}
|
|
1066
|
+
return summary;
|
|
1067
|
+
}
|
|
1068
|
+
var DEFAULT_STREAM_TYPES = "time,watts,heartrate,cadence,altitude,distance,velocity_smooth";
|
|
1069
|
+
var registerActivityTools = (server) => {
|
|
1070
|
+
server.registerTool(
|
|
1071
|
+
"get_activities",
|
|
1072
|
+
{
|
|
1073
|
+
title: "Get Activities",
|
|
1074
|
+
description: "Get a list of activities for an athlete from Intervals.icu.\n\nArgs:\n athlete_id: The Intervals.icu athlete ID (optional, uses the configured default)\n api_key: The Intervals.icu API key (optional, uses the configured default)\n start_date: Start date in YYYY-MM-DD format (optional, defaults to 30 days ago)\n end_date: End date in YYYY-MM-DD format (optional, defaults to today)\n limit: Maximum number of activities to return (optional, defaults to 10)\n include_unnamed: Whether to include unnamed activities (optional, defaults to false)",
|
|
1075
|
+
inputSchema: {
|
|
1076
|
+
athlete_id: z2.string().optional(),
|
|
1077
|
+
api_key: z2.string().optional(),
|
|
1078
|
+
start_date: z2.string().optional(),
|
|
1079
|
+
end_date: z2.string().optional(),
|
|
1080
|
+
limit: z2.number().int().positive().default(10),
|
|
1081
|
+
include_unnamed: z2.boolean().default(false)
|
|
1082
|
+
}
|
|
1083
|
+
},
|
|
1084
|
+
async ({ athlete_id, api_key, start_date, end_date, limit, include_unnamed }) => {
|
|
1085
|
+
const config = getConfig();
|
|
1086
|
+
const { athleteId, error } = resolveAthleteId(athlete_id, config.athleteId);
|
|
1087
|
+
if (error) return textResult(error);
|
|
1088
|
+
const [startDate, endDate] = resolveDateParams(start_date, end_date);
|
|
1089
|
+
const apiLimit = !include_unnamed ? limit * 3 : limit;
|
|
1090
|
+
const result = await makeIntervalsRequest(`/athlete/${athleteId}/activities`, {
|
|
1091
|
+
apiKey: api_key,
|
|
1092
|
+
params: { oldest: startDate, newest: endDate, limit: apiLimit }
|
|
1093
|
+
});
|
|
1094
|
+
if (isApiError(result)) {
|
|
1095
|
+
return textResult(`Error fetching activities: ${apiErrorMessage(result)}`);
|
|
1096
|
+
}
|
|
1097
|
+
if (isEmptyResult(result)) {
|
|
1098
|
+
return textResult(
|
|
1099
|
+
`No activities found for athlete ${athleteId} in the specified date range.`
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
let activities = parseActivitiesFromResult(result);
|
|
1103
|
+
if (!activities.length) {
|
|
1104
|
+
return textResult(
|
|
1105
|
+
`No valid activities found for athlete ${athleteId} in the specified date range.`
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
if (!include_unnamed) {
|
|
1109
|
+
activities = filterNamedActivities(activities);
|
|
1110
|
+
if (activities.length < limit) {
|
|
1111
|
+
const more = await fetchMoreActivities(athleteId, startDate, api_key, apiLimit);
|
|
1112
|
+
activities.push(...more);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
activities = activities.slice(0, limit);
|
|
1116
|
+
await resolveGearForActivities(activities, { athleteId, apiKey: api_key });
|
|
1117
|
+
return textResult(formatActivitiesResponse(activities, athleteId, include_unnamed));
|
|
1118
|
+
}
|
|
1119
|
+
);
|
|
1120
|
+
server.registerTool(
|
|
1121
|
+
"get_activity_details",
|
|
1122
|
+
{
|
|
1123
|
+
title: "Get Activity Details",
|
|
1124
|
+
description: "Get detailed information for a specific activity from Intervals.icu.",
|
|
1125
|
+
inputSchema: {
|
|
1126
|
+
activity_id: z2.string().describe("The Intervals.icu activity ID"),
|
|
1127
|
+
api_key: z2.string().optional()
|
|
1128
|
+
}
|
|
1129
|
+
},
|
|
1130
|
+
async ({ activity_id, api_key }) => {
|
|
1131
|
+
const result = await makeIntervalsRequest(`/activity/${activity_id}`, {
|
|
1132
|
+
apiKey: api_key
|
|
1133
|
+
});
|
|
1134
|
+
if (isApiError(result)) {
|
|
1135
|
+
return textResult(`Error fetching activity details: ${apiErrorMessage(result)}`);
|
|
1136
|
+
}
|
|
1137
|
+
if (isEmptyResult(result)) {
|
|
1138
|
+
return textResult(`No details found for activity ${activity_id}.`);
|
|
1139
|
+
}
|
|
1140
|
+
const activityData = Array.isArray(result) ? result[0] : result;
|
|
1141
|
+
if (typeof activityData !== "object" || activityData === null) {
|
|
1142
|
+
return textResult(`Invalid activity format for activity ${activity_id}.`);
|
|
1143
|
+
}
|
|
1144
|
+
await resolveGearForActivity(activityData, { apiKey: api_key });
|
|
1145
|
+
let detailedView = formatActivitySummary(activityData);
|
|
1146
|
+
const zones = activityData["zones"];
|
|
1147
|
+
if (typeof zones === "object" && zones !== null) {
|
|
1148
|
+
detailedView += "\nPower Zones:\n";
|
|
1149
|
+
for (const zone of zones["power"] ?? []) {
|
|
1150
|
+
detailedView += `Zone ${zone["number"]}: ${zone["secondsInZone"]} seconds
|
|
1151
|
+
`;
|
|
1152
|
+
}
|
|
1153
|
+
detailedView += "\nHeart Rate Zones:\n";
|
|
1154
|
+
for (const zone of zones["hr"] ?? []) {
|
|
1155
|
+
detailedView += `Zone ${zone["number"]}: ${zone["secondsInZone"]} seconds
|
|
1156
|
+
`;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
return textResult(detailedView);
|
|
1160
|
+
}
|
|
1161
|
+
);
|
|
1162
|
+
server.registerTool(
|
|
1163
|
+
"get_activity_intervals",
|
|
1164
|
+
{
|
|
1165
|
+
title: "Get Activity Intervals",
|
|
1166
|
+
description: "Get interval data for a specific activity from Intervals.icu.\n\nThis endpoint returns detailed metrics for each interval in an activity, including power, heart rate,\ncadence, speed, and environmental data. It also includes grouped intervals if applicable.",
|
|
1167
|
+
inputSchema: {
|
|
1168
|
+
activity_id: z2.string().describe("The Intervals.icu activity ID"),
|
|
1169
|
+
api_key: z2.string().optional()
|
|
1170
|
+
}
|
|
1171
|
+
},
|
|
1172
|
+
async ({ activity_id, api_key }) => {
|
|
1173
|
+
const result = await makeIntervalsRequest(`/activity/${activity_id}/intervals`, {
|
|
1174
|
+
apiKey: api_key
|
|
1175
|
+
});
|
|
1176
|
+
if (isApiError(result)) {
|
|
1177
|
+
return textResult(`Error fetching intervals: ${apiErrorMessage(result)}`);
|
|
1178
|
+
}
|
|
1179
|
+
if (isEmptyResult(result)) {
|
|
1180
|
+
return textResult(`No interval data found for activity ${activity_id}.`);
|
|
1181
|
+
}
|
|
1182
|
+
if (typeof result !== "object" || Array.isArray(result) || !("icu_intervals" in result || "icu_groups" in result)) {
|
|
1183
|
+
return textResult(`No interval data or unrecognized format for activity ${activity_id}.`);
|
|
1184
|
+
}
|
|
1185
|
+
return textResult(formatIntervals(result));
|
|
1186
|
+
}
|
|
1187
|
+
);
|
|
1188
|
+
server.registerTool(
|
|
1189
|
+
"get_activity_streams",
|
|
1190
|
+
{
|
|
1191
|
+
title: "Get Activity Streams",
|
|
1192
|
+
description: "Get stream data for a specific activity from Intervals.icu.\n\nThis endpoint returns time-series data for an activity, including metrics like power, heart rate,\ncadence, altitude, distance, temperature, and velocity data.\n\nAvailable stream types: time, watts, heartrate, cadence, altitude, distance,\ncore_temperature, skin_temperature, velocity_smooth",
|
|
1193
|
+
inputSchema: {
|
|
1194
|
+
activity_id: z2.string().describe("The Intervals.icu activity ID"),
|
|
1195
|
+
api_key: z2.string().optional(),
|
|
1196
|
+
stream_types: z2.string().optional().describe(
|
|
1197
|
+
"Comma-separated list of stream types to retrieve (optional, defaults to common types)"
|
|
1198
|
+
)
|
|
1199
|
+
}
|
|
1200
|
+
},
|
|
1201
|
+
async ({ activity_id, api_key, stream_types }) => {
|
|
1202
|
+
const result = await makeIntervalsRequest(`/activity/${activity_id}/streams`, {
|
|
1203
|
+
apiKey: api_key,
|
|
1204
|
+
params: { types: stream_types || DEFAULT_STREAM_TYPES }
|
|
1205
|
+
});
|
|
1206
|
+
if (isApiError(result)) {
|
|
1207
|
+
return textResult(`Error fetching activity streams: ${apiErrorMessage(result)}`);
|
|
1208
|
+
}
|
|
1209
|
+
const streams = Array.isArray(result) ? result : [];
|
|
1210
|
+
if (!streams.length) {
|
|
1211
|
+
return textResult(`No stream data found for activity ${activity_id}.`);
|
|
1212
|
+
}
|
|
1213
|
+
let summary = `Activity Streams for ${activity_id}:
|
|
1214
|
+
|
|
1215
|
+
`;
|
|
1216
|
+
for (const stream of streams) {
|
|
1217
|
+
const streamType = stream["type"] ?? "unknown";
|
|
1218
|
+
const streamName = stream["name"] ?? streamType;
|
|
1219
|
+
const data = stream["data"] ?? [];
|
|
1220
|
+
const valueType = stream["valueType"] ?? "";
|
|
1221
|
+
summary += `Stream: ${streamName} (${streamType})
|
|
1222
|
+
`;
|
|
1223
|
+
summary += ` Value Type: ${valueType}
|
|
1224
|
+
`;
|
|
1225
|
+
summary += ` Data Points: ${data.length}
|
|
1226
|
+
`;
|
|
1227
|
+
if (data.length > 0) {
|
|
1228
|
+
if (data.length <= 10) {
|
|
1229
|
+
summary += ` Values: ${JSON.stringify(data)}
|
|
1230
|
+
`;
|
|
1231
|
+
} else {
|
|
1232
|
+
summary += ` First 5 values: ${JSON.stringify(data.slice(0, 5))}
|
|
1233
|
+
`;
|
|
1234
|
+
summary += ` Last 5 values: ${JSON.stringify(data.slice(-5))}
|
|
1235
|
+
`;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
summary += "\n";
|
|
1239
|
+
}
|
|
1240
|
+
return textResult(summary);
|
|
1241
|
+
}
|
|
1242
|
+
);
|
|
1243
|
+
server.registerTool(
|
|
1244
|
+
"get_activity_messages",
|
|
1245
|
+
{
|
|
1246
|
+
title: "Get Activity Messages",
|
|
1247
|
+
description: "Get messages (notes/comments) for a specific activity from Intervals.icu.",
|
|
1248
|
+
inputSchema: {
|
|
1249
|
+
activity_id: z2.string().describe("The Intervals.icu activity ID"),
|
|
1250
|
+
api_key: z2.string().optional()
|
|
1251
|
+
}
|
|
1252
|
+
},
|
|
1253
|
+
async ({ activity_id, api_key }) => {
|
|
1254
|
+
const result = await makeIntervalsRequest(`/activity/${activity_id}/messages`, {
|
|
1255
|
+
apiKey: api_key
|
|
1256
|
+
});
|
|
1257
|
+
if (isApiError(result)) {
|
|
1258
|
+
return textResult(`Error fetching activity messages: ${apiErrorMessage(result)}`);
|
|
1259
|
+
}
|
|
1260
|
+
const messages = Array.isArray(result) ? result : [];
|
|
1261
|
+
if (!messages.length) {
|
|
1262
|
+
return textResult(`No messages found for activity ${activity_id}.`);
|
|
1263
|
+
}
|
|
1264
|
+
let output = `Messages for activity ${activity_id}:
|
|
1265
|
+
|
|
1266
|
+
`;
|
|
1267
|
+
for (const msg of messages) {
|
|
1268
|
+
output += `${formatActivityMessage(msg)}
|
|
1269
|
+
|
|
1270
|
+
`;
|
|
1271
|
+
}
|
|
1272
|
+
return textResult(output);
|
|
1273
|
+
}
|
|
1274
|
+
);
|
|
1275
|
+
server.registerTool(
|
|
1276
|
+
"add_activity_message",
|
|
1277
|
+
{
|
|
1278
|
+
title: "Add Activity Message",
|
|
1279
|
+
description: "Add a message (note/comment) to an activity on Intervals.icu.",
|
|
1280
|
+
inputSchema: {
|
|
1281
|
+
activity_id: z2.string().describe("The Intervals.icu activity ID"),
|
|
1282
|
+
content: z2.string().describe("The message text to add"),
|
|
1283
|
+
api_key: z2.string().optional()
|
|
1284
|
+
}
|
|
1285
|
+
},
|
|
1286
|
+
async ({ activity_id, content, api_key }) => {
|
|
1287
|
+
const result = await makeIntervalsRequest(`/activity/${activity_id}/messages`, {
|
|
1288
|
+
apiKey: api_key,
|
|
1289
|
+
method: "POST",
|
|
1290
|
+
data: { content }
|
|
1291
|
+
});
|
|
1292
|
+
if (isApiError(result)) {
|
|
1293
|
+
return textResult(`Error adding message to activity: ${apiErrorMessage(result)}`);
|
|
1294
|
+
}
|
|
1295
|
+
if (typeof result !== "object" || result === null || Array.isArray(result)) {
|
|
1296
|
+
return textResult("Error: Unexpected response when adding message.");
|
|
1297
|
+
}
|
|
1298
|
+
const msgId = result["id"];
|
|
1299
|
+
if (msgId != null) {
|
|
1300
|
+
return textResult(
|
|
1301
|
+
`Successfully added message (ID: ${msgId}) to activity ${activity_id}.`
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
return textResult(
|
|
1305
|
+
`Message appears to have been added to activity ${activity_id}, but no ID was returned. Please verify manually.`
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
);
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
// src/tools/customItems.ts
|
|
1312
|
+
import { z as z3 } from "zod";
|
|
1313
|
+
var ContentSchema = z3.union([z3.record(z3.unknown()), z3.string()]).optional().transform((val, ctx) => {
|
|
1314
|
+
if (val == null) return null;
|
|
1315
|
+
if (typeof val !== "string") return val;
|
|
1316
|
+
try {
|
|
1317
|
+
return JSON.parse(val);
|
|
1318
|
+
} catch {
|
|
1319
|
+
ctx.addIssue({ code: z3.ZodIssueCode.custom, message: "content must be valid JSON when passed as a string." });
|
|
1320
|
+
return z3.NEVER;
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
var ITEM_TYPE_DESCRIPTION = "Type of custom item (e.g. FITNESS_CHART, TRACE_CHART, INPUT_FIELD, ACTIVITY_FIELD, INTERVAL_FIELD, ACTIVITY_STREAM, ACTIVITY_CHART, ACTIVITY_HISTOGRAM, ACTIVITY_HEATMAP, ACTIVITY_MAP, ACTIVITY_PANEL, ZONES)";
|
|
1324
|
+
var CONTENT_DESCRIPTION = 'Configuration content for the custom item as an object (optional). Important enum values:\n- "type" field for INPUT_FIELD/ACTIVITY_FIELD: must be "numeric", "text", or "select" (NOT "number")\n- "aggregate" field: must be "MIN", "SUM", "MAX", or "AVERAGE" (NOT "AVG")';
|
|
1325
|
+
var registerCustomItemTools = (server) => {
|
|
1326
|
+
server.registerTool(
|
|
1327
|
+
"get_custom_items",
|
|
1328
|
+
{
|
|
1329
|
+
title: "Get Custom Items",
|
|
1330
|
+
description: "Get custom items (charts, custom fields, zones, etc.) for an athlete from Intervals.icu.",
|
|
1331
|
+
inputSchema: {
|
|
1332
|
+
athlete_id: z3.string().optional(),
|
|
1333
|
+
api_key: z3.string().optional()
|
|
1334
|
+
}
|
|
1335
|
+
},
|
|
1336
|
+
async ({ athlete_id, api_key }) => {
|
|
1337
|
+
const config = getConfig();
|
|
1338
|
+
const { athleteId, error } = resolveAthleteId(athlete_id, config.athleteId);
|
|
1339
|
+
if (error) return textResult(error);
|
|
1340
|
+
const result = await makeIntervalsRequest(`/athlete/${athleteId}/custom-item`, {
|
|
1341
|
+
apiKey: api_key
|
|
1342
|
+
});
|
|
1343
|
+
if (isApiError(result)) {
|
|
1344
|
+
return textResult(`Error fetching custom items: ${apiErrorMessage(result)}`);
|
|
1345
|
+
}
|
|
1346
|
+
if (result == null || Array.isArray(result) && result.length === 0) {
|
|
1347
|
+
return textResult(`No custom items found for athlete ${athleteId}.`);
|
|
1348
|
+
}
|
|
1349
|
+
let output = "Custom Items:\n\n";
|
|
1350
|
+
for (const item of result) {
|
|
1351
|
+
if (typeof item !== "object" || item === null) continue;
|
|
1352
|
+
output += `- ID: ${item["id"]}
|
|
1353
|
+
`;
|
|
1354
|
+
output += ` Name: ${item["name"] ?? "N/A"}
|
|
1355
|
+
`;
|
|
1356
|
+
output += ` Type: ${item["type"] ?? "N/A"}
|
|
1357
|
+
`;
|
|
1358
|
+
if (item["description"]) output += ` Description: ${item["description"]}
|
|
1359
|
+
`;
|
|
1360
|
+
output += "\n";
|
|
1361
|
+
}
|
|
1362
|
+
return textResult(output);
|
|
1363
|
+
}
|
|
1364
|
+
);
|
|
1365
|
+
server.registerTool(
|
|
1366
|
+
"get_custom_item_by_id",
|
|
1367
|
+
{
|
|
1368
|
+
title: "Get Custom Item By ID",
|
|
1369
|
+
description: "Get detailed information for a specific custom item from Intervals.icu.",
|
|
1370
|
+
inputSchema: {
|
|
1371
|
+
item_id: z3.number().int().describe("The custom item ID"),
|
|
1372
|
+
athlete_id: z3.string().optional(),
|
|
1373
|
+
api_key: z3.string().optional()
|
|
1374
|
+
}
|
|
1375
|
+
},
|
|
1376
|
+
async ({ item_id, athlete_id, api_key }) => {
|
|
1377
|
+
const config = getConfig();
|
|
1378
|
+
const { athleteId, error } = resolveAthleteId(athlete_id, config.athleteId);
|
|
1379
|
+
if (error) return textResult(error);
|
|
1380
|
+
const result = await makeIntervalsRequest(`/athlete/${athleteId}/custom-item/${item_id}`, {
|
|
1381
|
+
apiKey: api_key
|
|
1382
|
+
});
|
|
1383
|
+
if (isApiError(result)) {
|
|
1384
|
+
return textResult(`Error fetching custom item: ${apiErrorMessage(result)}`);
|
|
1385
|
+
}
|
|
1386
|
+
if (typeof result !== "object" || result === null || Array.isArray(result)) {
|
|
1387
|
+
return textResult(`No custom item found with ID ${item_id}.`);
|
|
1388
|
+
}
|
|
1389
|
+
return textResult(formatCustomItemDetails(result));
|
|
1390
|
+
}
|
|
1391
|
+
);
|
|
1392
|
+
server.registerTool(
|
|
1393
|
+
"create_custom_item",
|
|
1394
|
+
{
|
|
1395
|
+
title: "Create Custom Item",
|
|
1396
|
+
description: "Create a new custom item for an athlete on Intervals.icu.",
|
|
1397
|
+
inputSchema: {
|
|
1398
|
+
name: z3.string().describe("Name of the custom item"),
|
|
1399
|
+
item_type: z3.string().describe(ITEM_TYPE_DESCRIPTION),
|
|
1400
|
+
athlete_id: z3.string().optional(),
|
|
1401
|
+
api_key: z3.string().optional(),
|
|
1402
|
+
description: z3.string().optional().describe("Description of the custom item (optional)"),
|
|
1403
|
+
content: ContentSchema.describe(CONTENT_DESCRIPTION),
|
|
1404
|
+
visibility: z3.string().optional().describe("Visibility setting: PRIVATE, FOLLOWERS, or PUBLIC (optional)")
|
|
1405
|
+
}
|
|
1406
|
+
},
|
|
1407
|
+
async ({ name, item_type, athlete_id, api_key, description, content, visibility }) => {
|
|
1408
|
+
const config = getConfig();
|
|
1409
|
+
const { athleteId, error } = resolveAthleteId(athlete_id, config.athleteId);
|
|
1410
|
+
if (error) return textResult(error);
|
|
1411
|
+
const data = { name, type: item_type };
|
|
1412
|
+
if (description != null) data["description"] = description;
|
|
1413
|
+
if (content != null) data["content"] = content;
|
|
1414
|
+
if (visibility != null) data["visibility"] = visibility;
|
|
1415
|
+
const result = await makeIntervalsRequest(`/athlete/${athleteId}/custom-item`, {
|
|
1416
|
+
apiKey: api_key,
|
|
1417
|
+
data,
|
|
1418
|
+
method: "POST"
|
|
1419
|
+
});
|
|
1420
|
+
if (isApiError(result)) {
|
|
1421
|
+
return textResult(`Error creating custom item: ${apiErrorMessage(result)}`);
|
|
1422
|
+
}
|
|
1423
|
+
if (typeof result !== "object" || result === null || Array.isArray(result)) {
|
|
1424
|
+
return textResult("Error: Unexpected response when creating custom item.");
|
|
1425
|
+
}
|
|
1426
|
+
return textResult(
|
|
1427
|
+
`Successfully created custom item:
|
|
1428
|
+
|
|
1429
|
+
${formatCustomItemDetails(result)}`
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
);
|
|
1433
|
+
server.registerTool(
|
|
1434
|
+
"update_custom_item",
|
|
1435
|
+
{
|
|
1436
|
+
title: "Update Custom Item",
|
|
1437
|
+
description: "Update an existing custom item for an athlete on Intervals.icu.",
|
|
1438
|
+
inputSchema: {
|
|
1439
|
+
item_id: z3.number().int().describe("The custom item ID to update"),
|
|
1440
|
+
athlete_id: z3.string().optional(),
|
|
1441
|
+
api_key: z3.string().optional(),
|
|
1442
|
+
name: z3.string().optional().describe("New name for the custom item (optional)"),
|
|
1443
|
+
item_type: z3.string().optional().describe("New type for the custom item (optional)"),
|
|
1444
|
+
description: z3.string().optional().describe("New description for the custom item (optional)"),
|
|
1445
|
+
content: ContentSchema.describe(CONTENT_DESCRIPTION),
|
|
1446
|
+
visibility: z3.string().optional().describe("New visibility setting: PRIVATE, FOLLOWERS, or PUBLIC (optional)")
|
|
1447
|
+
}
|
|
1448
|
+
},
|
|
1449
|
+
async ({ item_id, athlete_id, api_key, name, item_type, description, content, visibility }) => {
|
|
1450
|
+
const config = getConfig();
|
|
1451
|
+
const { athleteId, error } = resolveAthleteId(athlete_id, config.athleteId);
|
|
1452
|
+
if (error) return textResult(error);
|
|
1453
|
+
const data = {};
|
|
1454
|
+
if (name != null) data["name"] = name;
|
|
1455
|
+
if (item_type != null) data["type"] = item_type;
|
|
1456
|
+
if (description != null) data["description"] = description;
|
|
1457
|
+
if (content != null) data["content"] = content;
|
|
1458
|
+
if (visibility != null) data["visibility"] = visibility;
|
|
1459
|
+
const result = await makeIntervalsRequest(`/athlete/${athleteId}/custom-item/${item_id}`, {
|
|
1460
|
+
apiKey: api_key,
|
|
1461
|
+
data,
|
|
1462
|
+
method: "PUT"
|
|
1463
|
+
});
|
|
1464
|
+
if (isApiError(result)) {
|
|
1465
|
+
return textResult(`Error updating custom item: ${apiErrorMessage(result)}`);
|
|
1466
|
+
}
|
|
1467
|
+
if (typeof result !== "object" || result === null || Array.isArray(result)) {
|
|
1468
|
+
return textResult("Error: Unexpected response when updating custom item.");
|
|
1469
|
+
}
|
|
1470
|
+
return textResult(
|
|
1471
|
+
`Successfully updated custom item:
|
|
1472
|
+
|
|
1473
|
+
${formatCustomItemDetails(result)}`
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
);
|
|
1477
|
+
server.registerTool(
|
|
1478
|
+
"delete_custom_item",
|
|
1479
|
+
{
|
|
1480
|
+
title: "Delete Custom Item",
|
|
1481
|
+
description: "Delete a custom item for an athlete from Intervals.icu.",
|
|
1482
|
+
inputSchema: {
|
|
1483
|
+
item_id: z3.number().int().describe("The custom item ID to delete"),
|
|
1484
|
+
athlete_id: z3.string().optional(),
|
|
1485
|
+
api_key: z3.string().optional()
|
|
1486
|
+
}
|
|
1487
|
+
},
|
|
1488
|
+
async ({ item_id, athlete_id, api_key }) => {
|
|
1489
|
+
const config = getConfig();
|
|
1490
|
+
const { athleteId, error } = resolveAthleteId(athlete_id, config.athleteId);
|
|
1491
|
+
if (error) return textResult(error);
|
|
1492
|
+
const result = await makeIntervalsRequest(`/athlete/${athleteId}/custom-item/${item_id}`, {
|
|
1493
|
+
apiKey: api_key,
|
|
1494
|
+
method: "DELETE"
|
|
1495
|
+
});
|
|
1496
|
+
if (isApiError(result)) {
|
|
1497
|
+
return textResult(`Error deleting custom item: ${apiErrorMessage(result)}`);
|
|
1498
|
+
}
|
|
1499
|
+
return textResult(`Successfully deleted custom item ${item_id}.`);
|
|
1500
|
+
}
|
|
1501
|
+
);
|
|
1502
|
+
};
|
|
1503
|
+
|
|
1504
|
+
// src/tools/events.ts
|
|
1505
|
+
import { z as z5 } from "zod";
|
|
1506
|
+
|
|
1507
|
+
// src/utils/types.ts
|
|
1508
|
+
import { z as z4 } from "zod";
|
|
1509
|
+
var WorkoutTargetSchema = z4.enum(["AUTO", "POWER", "HR", "PACE"]);
|
|
1510
|
+
var HrTargetSchema = z4.enum(["lap", "1s", "3s", "10s", "30s"]);
|
|
1511
|
+
var IntensitySchema = z4.enum([
|
|
1512
|
+
"active",
|
|
1513
|
+
"rest",
|
|
1514
|
+
"warmup",
|
|
1515
|
+
"cooldown",
|
|
1516
|
+
"recovery",
|
|
1517
|
+
"interval",
|
|
1518
|
+
"other"
|
|
1519
|
+
]);
|
|
1520
|
+
var PaceUnitsSchema = z4.enum([
|
|
1521
|
+
"SECS_100M",
|
|
1522
|
+
"SECS_100Y",
|
|
1523
|
+
"MINS_KM",
|
|
1524
|
+
"MINS_MILE",
|
|
1525
|
+
"SECS_500M"
|
|
1526
|
+
]);
|
|
1527
|
+
var ValueUnitsSchema = z4.enum([
|
|
1528
|
+
"%mmp",
|
|
1529
|
+
"%hr",
|
|
1530
|
+
"%lthr",
|
|
1531
|
+
"%pace",
|
|
1532
|
+
"power_zone",
|
|
1533
|
+
"hr_zone",
|
|
1534
|
+
"pace_zone",
|
|
1535
|
+
"w",
|
|
1536
|
+
"%ftp",
|
|
1537
|
+
"cadence",
|
|
1538
|
+
"MINS_KM",
|
|
1539
|
+
"MINS_MILE",
|
|
1540
|
+
"SECS_100M",
|
|
1541
|
+
"SECS_500M"
|
|
1542
|
+
]);
|
|
1543
|
+
var ValueSchema = z4.object({
|
|
1544
|
+
value: z4.number().optional(),
|
|
1545
|
+
start: z4.number().optional(),
|
|
1546
|
+
end: z4.number().optional(),
|
|
1547
|
+
units: ValueUnitsSchema.optional(),
|
|
1548
|
+
target: HrTargetSchema.optional()
|
|
1549
|
+
});
|
|
1550
|
+
var StepSchema = z4.lazy(
|
|
1551
|
+
() => z4.object({
|
|
1552
|
+
text: z4.string().optional(),
|
|
1553
|
+
text_locale: z4.record(z4.string()).optional(),
|
|
1554
|
+
duration: z4.number().optional(),
|
|
1555
|
+
distance: z4.number().optional(),
|
|
1556
|
+
until_lap_press: z4.boolean().optional(),
|
|
1557
|
+
reps: z4.number().optional(),
|
|
1558
|
+
warmup: z4.boolean().optional(),
|
|
1559
|
+
cooldown: z4.boolean().optional(),
|
|
1560
|
+
intensity: IntensitySchema.optional(),
|
|
1561
|
+
steps: z4.array(StepSchema).optional(),
|
|
1562
|
+
ramp: z4.boolean().optional(),
|
|
1563
|
+
freeride: z4.boolean().optional(),
|
|
1564
|
+
maxeffort: z4.boolean().optional(),
|
|
1565
|
+
power: ValueSchema.optional(),
|
|
1566
|
+
hr: ValueSchema.optional(),
|
|
1567
|
+
pace: ValueSchema.optional(),
|
|
1568
|
+
cadence: ValueSchema.optional(),
|
|
1569
|
+
hidepower: z4.boolean().optional(),
|
|
1570
|
+
_power: ValueSchema.optional(),
|
|
1571
|
+
_hr: ValueSchema.optional(),
|
|
1572
|
+
_pace: ValueSchema.optional(),
|
|
1573
|
+
_distance: z4.number().optional()
|
|
1574
|
+
})
|
|
1575
|
+
);
|
|
1576
|
+
var WorkoutDocSchema = z4.object({
|
|
1577
|
+
description: z4.string().optional(),
|
|
1578
|
+
description_locale: z4.record(z4.string()).optional(),
|
|
1579
|
+
duration: z4.number().optional(),
|
|
1580
|
+
distance: z4.number().optional(),
|
|
1581
|
+
ftp: z4.number().optional(),
|
|
1582
|
+
lthr: z4.number().optional(),
|
|
1583
|
+
threshold_pace: z4.number().optional(),
|
|
1584
|
+
// meters/sec
|
|
1585
|
+
pace_units: PaceUnitsSchema.optional(),
|
|
1586
|
+
sport_settings: z4.record(z4.unknown()).optional(),
|
|
1587
|
+
category: z4.string().optional(),
|
|
1588
|
+
target: WorkoutTargetSchema.optional(),
|
|
1589
|
+
steps: z4.array(StepSchema).optional(),
|
|
1590
|
+
zone_times: z4.array(z4.union([z4.number(), z4.unknown()])).optional(),
|
|
1591
|
+
options: z4.record(z4.string()).optional(),
|
|
1592
|
+
locales: z4.array(z4.string()).optional()
|
|
1593
|
+
});
|
|
1594
|
+
function floatToStr(value) {
|
|
1595
|
+
return Number.isInteger(value) ? String(value) : String(value);
|
|
1596
|
+
}
|
|
1597
|
+
var PERCENT_UNITS = /* @__PURE__ */ new Set(["%hr", "%mmp", "%lthr", "%pace", "%ftp"]);
|
|
1598
|
+
var ZONE_UNITS = /* @__PURE__ */ new Set(["power_zone", "hr_zone", "pace_zone"]);
|
|
1599
|
+
var UNITS_LABEL = {
|
|
1600
|
+
"%hr": "HR",
|
|
1601
|
+
hr_zone: "HR",
|
|
1602
|
+
"%mmp": "MMP",
|
|
1603
|
+
"%lthr": "LTHR",
|
|
1604
|
+
"%pace": "Pace",
|
|
1605
|
+
pace_zone: "Pace",
|
|
1606
|
+
"%ftp": "ftp",
|
|
1607
|
+
power_zone: "W",
|
|
1608
|
+
cadence: "Cadence"
|
|
1609
|
+
};
|
|
1610
|
+
function formatScalar(value, units) {
|
|
1611
|
+
if (units && PERCENT_UNITS.has(units)) return `${floatToStr(value)}%`;
|
|
1612
|
+
if (units && ZONE_UNITS.has(units)) return `Z${floatToStr(value)}`;
|
|
1613
|
+
if (units === "w") return `${floatToStr(value)}W`;
|
|
1614
|
+
if (units === "cadence") return `${floatToStr(value)}rpm`;
|
|
1615
|
+
return floatToStr(value);
|
|
1616
|
+
}
|
|
1617
|
+
function formatValue(v) {
|
|
1618
|
+
let val = "";
|
|
1619
|
+
if (v.start != null && v.end != null) {
|
|
1620
|
+
val += `${formatScalar(v.start, v.units)}-${formatScalar(v.end, v.units)} `;
|
|
1621
|
+
}
|
|
1622
|
+
if (v.value != null) {
|
|
1623
|
+
val += `${formatScalar(v.value, v.units)} `;
|
|
1624
|
+
}
|
|
1625
|
+
if (v.units != null) {
|
|
1626
|
+
const label = UNITS_LABEL[v.units] ?? "";
|
|
1627
|
+
if (label) val += `${label} `;
|
|
1628
|
+
}
|
|
1629
|
+
if (v.target != null) {
|
|
1630
|
+
val += `hr=${v.target} `;
|
|
1631
|
+
}
|
|
1632
|
+
return val.trim();
|
|
1633
|
+
}
|
|
1634
|
+
function formatDuration(durationSecs) {
|
|
1635
|
+
let remaining = durationSecs;
|
|
1636
|
+
let val = "";
|
|
1637
|
+
if (remaining >= 3600) {
|
|
1638
|
+
val += `${Math.floor(remaining / 3600)}h`;
|
|
1639
|
+
remaining %= 3600;
|
|
1640
|
+
}
|
|
1641
|
+
if (remaining > 100 || remaining === 60) {
|
|
1642
|
+
val += `${Math.floor(remaining / 60)}m`;
|
|
1643
|
+
remaining %= 60;
|
|
1644
|
+
}
|
|
1645
|
+
if (remaining > 0) {
|
|
1646
|
+
val += `${remaining}s`;
|
|
1647
|
+
}
|
|
1648
|
+
return val;
|
|
1649
|
+
}
|
|
1650
|
+
function formatDistance(distance) {
|
|
1651
|
+
if (distance < 1e3) return `${floatToStr(distance)}mtr`;
|
|
1652
|
+
return `${floatToStr(distance / 1e3)}km`;
|
|
1653
|
+
}
|
|
1654
|
+
function formatStep(step, nested = false) {
|
|
1655
|
+
let val = "";
|
|
1656
|
+
if (step.reps != null) {
|
|
1657
|
+
val += `
|
|
1658
|
+
${step.reps}x `;
|
|
1659
|
+
} else {
|
|
1660
|
+
if (!nested && step.warmup) val += "\nWarmup\n";
|
|
1661
|
+
if (!nested && step.cooldown) val += "\nCooldown\n";
|
|
1662
|
+
if (step.duration != null) {
|
|
1663
|
+
val += `- ${formatDuration(step.duration)} `;
|
|
1664
|
+
} else if (step.distance != null) {
|
|
1665
|
+
val += `- ${formatDistance(step.distance)} `;
|
|
1666
|
+
}
|
|
1667
|
+
if (step.freeride) val += "freeride ";
|
|
1668
|
+
if (step.maxeffort) val += "maxeffort ";
|
|
1669
|
+
if (step.ramp) val += "ramp ";
|
|
1670
|
+
if (step.hidepower) val += "hidepower ";
|
|
1671
|
+
if (step.intensity != null) val += `intensity=${step.intensity} `;
|
|
1672
|
+
if (step.power) val += `${formatValue(step.power)} `;
|
|
1673
|
+
if (step.hr) val += `${formatValue(step.hr)} `;
|
|
1674
|
+
if (step.pace) val += `${formatValue(step.pace)} `;
|
|
1675
|
+
if (step.cadence) val += `${formatValue(step.cadence)} `;
|
|
1676
|
+
}
|
|
1677
|
+
if (step.text != null) {
|
|
1678
|
+
val += `${step.text} `;
|
|
1679
|
+
}
|
|
1680
|
+
if (step.reps != null && step.steps != null) {
|
|
1681
|
+
for (const child of step.steps) {
|
|
1682
|
+
val += `
|
|
1683
|
+
${formatStep(child, true)}`;
|
|
1684
|
+
}
|
|
1685
|
+
val += "\n";
|
|
1686
|
+
} else if (!nested && (step.warmup || step.cooldown)) {
|
|
1687
|
+
val += "\n";
|
|
1688
|
+
}
|
|
1689
|
+
return val;
|
|
1690
|
+
}
|
|
1691
|
+
function formatWorkoutDoc(doc) {
|
|
1692
|
+
let val = "";
|
|
1693
|
+
if (doc.description != null) {
|
|
1694
|
+
val += `${doc.description}
|
|
1695
|
+
`;
|
|
1696
|
+
}
|
|
1697
|
+
if (doc.steps != null) {
|
|
1698
|
+
for (const step of doc.steps) {
|
|
1699
|
+
val += `${formatStep(step)}
|
|
1700
|
+
`;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
return val;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// src/tools/events.ts
|
|
1707
|
+
var WORKOUT_DOC_HELP = `Post event for an athlete to Intervals.icu. If event_id is provided, the event will be updated instead of created.
|
|
1708
|
+
|
|
1709
|
+
Example:
|
|
1710
|
+
"workout_doc": {
|
|
1711
|
+
"description": "High-intensity workout for increasing VO2 max",
|
|
1712
|
+
"steps": [
|
|
1713
|
+
{"power": {"value": 80, "units": "%ftp"}, "duration": 900, "warmup": true},
|
|
1714
|
+
{"reps": 2, "text": "High-intensity intervals", "steps": [
|
|
1715
|
+
{"power": {"value": 110, "units": "%ftp"}, "distance": 500, "text": "High-intensity"},
|
|
1716
|
+
{"power": {"value": 80, "units": "%ftp"}, "duration": 90, "text": "Recovery"}
|
|
1717
|
+
]},
|
|
1718
|
+
{"power": {"value": 80, "units": "%ftp"}, "duration": 600, "cooldown": true},
|
|
1719
|
+
{"text": ""}
|
|
1720
|
+
]
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
Step properties:
|
|
1724
|
+
distance: Distance of step in meters
|
|
1725
|
+
{"distance": 5000}
|
|
1726
|
+
duration: Duration of step in seconds
|
|
1727
|
+
{"duration": 1800}
|
|
1728
|
+
power/hr/pace/cadence: Define step intensity
|
|
1729
|
+
Percentage of FTP: {"power": {"value": 80, "units": "%ftp"}}
|
|
1730
|
+
Absolute power: {"power": {"value": 200, "units": "w"}}
|
|
1731
|
+
Heart rate: {"hr": {"value": 75, "units": "%hr"}}
|
|
1732
|
+
Heart rate (LTHR): {"hr": {"value": 85, "units": "%lthr"}}
|
|
1733
|
+
Cadence: {"cadence": {"value": 90, "units": "cadence"}}
|
|
1734
|
+
Pace by ftp: {"pace": {"value": 80, "units": "%pace"}}
|
|
1735
|
+
Pace by zone: {"pace": {"value": 2, "units": "pace_zone"}}
|
|
1736
|
+
Zone by power: {"power": {"value": 2, "units": "power_zone"}}
|
|
1737
|
+
Zone by heart rate: {"hr": {"value": 2, "units": "hr_zone"}}
|
|
1738
|
+
Ranges: Specify ranges for power, heart rate, or cadence:
|
|
1739
|
+
{"power": {"start": 80, "end": 90, "units": "%ftp"}}
|
|
1740
|
+
Ramps: Instead of a range, indicate a gradual change in intensity (useful for ERG workouts):
|
|
1741
|
+
{"ramp": true, "power": {"start": 80, "end": 90, "units": "%ftp"}}
|
|
1742
|
+
Repeats: include the reps property and add nested steps
|
|
1743
|
+
{"reps": 3,
|
|
1744
|
+
"steps": [
|
|
1745
|
+
{"power": {"value": 110, "units": "%ftp"}, "distance": 500, "text": "High-intensity"},
|
|
1746
|
+
{"power": {"value": 80, "units": "%ftp"}, "duration": 90, "text": "Recovery"}
|
|
1747
|
+
]}
|
|
1748
|
+
Free Ride: Include freeride to indicate a segment without ERG control, optionally with a suggested power range:
|
|
1749
|
+
{"freeride": true, "power": {"value": 80, "units": "%ftp"}}
|
|
1750
|
+
Comments and Labels: Add descriptive text to label steps:
|
|
1751
|
+
{"text": "Warmup"}
|
|
1752
|
+
|
|
1753
|
+
How to use steps:
|
|
1754
|
+
- Set distance or duration as appropriate for step
|
|
1755
|
+
- Use "reps" with nested steps to define repeat intervals (as in example above)
|
|
1756
|
+
- Define one of "power", "hr" or "pace" to define step intensity`;
|
|
1757
|
+
function prepareEventData(name, workoutType, startDate, workoutDoc, movingTime, distance) {
|
|
1758
|
+
const resolvedWorkoutType = resolveActivityType(name, workoutType);
|
|
1759
|
+
return {
|
|
1760
|
+
start_date_local: `${startDate}T00:00:00`,
|
|
1761
|
+
category: "WORKOUT",
|
|
1762
|
+
name,
|
|
1763
|
+
description: workoutDoc ? formatWorkoutDoc(workoutDoc) : null,
|
|
1764
|
+
type: resolvedWorkoutType,
|
|
1765
|
+
moving_time: movingTime,
|
|
1766
|
+
distance
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
async function handleEventResponse(result, action, athleteId, startDate) {
|
|
1770
|
+
if (isApiError(result)) {
|
|
1771
|
+
return `Error ${action} event: ${apiErrorMessage(result)}`;
|
|
1772
|
+
}
|
|
1773
|
+
if (result == null || typeof result === "object" && !Array.isArray(result) && Object.keys(result).length === 0) {
|
|
1774
|
+
return `No events ${action} for athlete ${athleteId}.`;
|
|
1775
|
+
}
|
|
1776
|
+
if (typeof result === "object" && !Array.isArray(result)) {
|
|
1777
|
+
return `Successfully ${action} event id: ${result["id"]}`;
|
|
1778
|
+
}
|
|
1779
|
+
return `Event ${action} successfully at ${startDate}`;
|
|
1780
|
+
}
|
|
1781
|
+
async function createOrUpdateEventRequest(athleteId, apiKey, eventData, startDate, eventId) {
|
|
1782
|
+
let url = `/athlete/${athleteId}/events`;
|
|
1783
|
+
if (eventId) url += `/${eventId}`;
|
|
1784
|
+
const result = await makeIntervalsRequest(url, {
|
|
1785
|
+
apiKey,
|
|
1786
|
+
data: eventData,
|
|
1787
|
+
method: eventId ? "PUT" : "POST"
|
|
1788
|
+
});
|
|
1789
|
+
const action = eventId ? "updated" : "created";
|
|
1790
|
+
return handleEventResponse(result, action, athleteId, startDate);
|
|
1791
|
+
}
|
|
1792
|
+
var registerEventTools = (server) => {
|
|
1793
|
+
server.registerTool(
|
|
1794
|
+
"get_events",
|
|
1795
|
+
{
|
|
1796
|
+
title: "Get Events",
|
|
1797
|
+
description: "Get events for an athlete from Intervals.icu.\n\nArgs:\n athlete_id: The Intervals.icu athlete ID (optional, uses the configured default)\n api_key: The Intervals.icu API key (optional, uses the configured default)\n start_date: Start date in YYYY-MM-DD format (optional, defaults to today)\n end_date: End date in YYYY-MM-DD format (optional, defaults to 30 days from today)",
|
|
1798
|
+
inputSchema: {
|
|
1799
|
+
athlete_id: z5.string().optional(),
|
|
1800
|
+
api_key: z5.string().optional(),
|
|
1801
|
+
start_date: z5.string().optional(),
|
|
1802
|
+
end_date: z5.string().optional()
|
|
1803
|
+
}
|
|
1804
|
+
},
|
|
1805
|
+
async ({ athlete_id, api_key, start_date, end_date }) => {
|
|
1806
|
+
const config = getConfig();
|
|
1807
|
+
const { athleteId, error } = resolveAthleteId(athlete_id, config.athleteId);
|
|
1808
|
+
if (error) return textResult(error);
|
|
1809
|
+
const oldest = start_date || getDefaultEndDate();
|
|
1810
|
+
const newest = end_date || getDefaultFutureEndDate();
|
|
1811
|
+
const result = await makeIntervalsRequest(`/athlete/${athleteId}/events`, {
|
|
1812
|
+
apiKey: api_key,
|
|
1813
|
+
params: { oldest, newest }
|
|
1814
|
+
});
|
|
1815
|
+
if (isApiError(result)) {
|
|
1816
|
+
return textResult(`Error fetching events: ${apiErrorMessage(result)}`);
|
|
1817
|
+
}
|
|
1818
|
+
const events = Array.isArray(result) ? result : [];
|
|
1819
|
+
if (!events.length) {
|
|
1820
|
+
return textResult(
|
|
1821
|
+
`No events found for athlete ${athleteId} in the specified date range.`
|
|
1822
|
+
);
|
|
1823
|
+
}
|
|
1824
|
+
let summary = "Events:\n\n";
|
|
1825
|
+
for (const event of events) {
|
|
1826
|
+
if (typeof event !== "object" || event === null) continue;
|
|
1827
|
+
summary += `${formatEventSummary(event)}
|
|
1828
|
+
|
|
1829
|
+
`;
|
|
1830
|
+
}
|
|
1831
|
+
return textResult(summary);
|
|
1832
|
+
}
|
|
1833
|
+
);
|
|
1834
|
+
server.registerTool(
|
|
1835
|
+
"get_event_by_id",
|
|
1836
|
+
{
|
|
1837
|
+
title: "Get Event By ID",
|
|
1838
|
+
description: "Get detailed information for a specific event from Intervals.icu.",
|
|
1839
|
+
inputSchema: {
|
|
1840
|
+
event_id: z5.string().describe("The Intervals.icu event ID"),
|
|
1841
|
+
athlete_id: z5.string().optional(),
|
|
1842
|
+
api_key: z5.string().optional()
|
|
1843
|
+
}
|
|
1844
|
+
},
|
|
1845
|
+
async ({ event_id, athlete_id, api_key }) => {
|
|
1846
|
+
const config = getConfig();
|
|
1847
|
+
const { athleteId, error } = resolveAthleteId(athlete_id, config.athleteId);
|
|
1848
|
+
if (error) return textResult(error);
|
|
1849
|
+
const result = await makeIntervalsRequest(`/athlete/${athleteId}/event/${event_id}`, {
|
|
1850
|
+
apiKey: api_key
|
|
1851
|
+
});
|
|
1852
|
+
if (isApiError(result)) {
|
|
1853
|
+
return textResult(`Error fetching event details: ${apiErrorMessage(result)}`);
|
|
1854
|
+
}
|
|
1855
|
+
if (result == null || Array.isArray(result) || Object.keys(result).length === 0) {
|
|
1856
|
+
return textResult(`No details found for event ${event_id}.`);
|
|
1857
|
+
}
|
|
1858
|
+
return textResult(formatEventDetails(result));
|
|
1859
|
+
}
|
|
1860
|
+
);
|
|
1861
|
+
server.registerTool(
|
|
1862
|
+
"delete_event",
|
|
1863
|
+
{
|
|
1864
|
+
title: "Delete Event",
|
|
1865
|
+
description: "Delete an event for an athlete from Intervals.icu.",
|
|
1866
|
+
inputSchema: {
|
|
1867
|
+
event_id: z5.string().describe("The Intervals.icu event ID"),
|
|
1868
|
+
athlete_id: z5.string().optional(),
|
|
1869
|
+
api_key: z5.string().optional()
|
|
1870
|
+
}
|
|
1871
|
+
},
|
|
1872
|
+
async ({ event_id, athlete_id, api_key }) => {
|
|
1873
|
+
const config = getConfig();
|
|
1874
|
+
const { athleteId, error } = resolveAthleteId(athlete_id, config.athleteId);
|
|
1875
|
+
if (error) return textResult(error);
|
|
1876
|
+
if (!event_id) return textResult("Error: No event ID provided.");
|
|
1877
|
+
const result = await makeIntervalsRequest(`/athlete/${athleteId}/events/${event_id}`, {
|
|
1878
|
+
apiKey: api_key,
|
|
1879
|
+
method: "DELETE"
|
|
1880
|
+
});
|
|
1881
|
+
if (isApiError(result)) {
|
|
1882
|
+
return textResult(`Error deleting event: ${apiErrorMessage(result)}`);
|
|
1883
|
+
}
|
|
1884
|
+
return textResult(JSON.stringify(result, null, 2));
|
|
1885
|
+
}
|
|
1886
|
+
);
|
|
1887
|
+
server.registerTool(
|
|
1888
|
+
"delete_events_by_date_range",
|
|
1889
|
+
{
|
|
1890
|
+
title: "Delete Events By Date Range",
|
|
1891
|
+
description: "Delete events for an athlete from Intervals.icu in the specified date range.",
|
|
1892
|
+
inputSchema: {
|
|
1893
|
+
start_date: z5.string().describe("Start date in YYYY-MM-DD format"),
|
|
1894
|
+
end_date: z5.string().describe("End date in YYYY-MM-DD format"),
|
|
1895
|
+
athlete_id: z5.string().optional(),
|
|
1896
|
+
api_key: z5.string().optional()
|
|
1897
|
+
}
|
|
1898
|
+
},
|
|
1899
|
+
async ({ start_date, end_date, athlete_id, api_key }) => {
|
|
1900
|
+
const config = getConfig();
|
|
1901
|
+
const { athleteId, error } = resolveAthleteId(athlete_id, config.athleteId);
|
|
1902
|
+
if (error) return textResult(error);
|
|
1903
|
+
let oldest, newest;
|
|
1904
|
+
try {
|
|
1905
|
+
oldest = validateDate(start_date);
|
|
1906
|
+
newest = validateDate(end_date);
|
|
1907
|
+
} catch (e) {
|
|
1908
|
+
return textResult(`Error deleting events: ${e.message}`);
|
|
1909
|
+
}
|
|
1910
|
+
const fetchResult = await makeIntervalsRequest(`/athlete/${athleteId}/events`, {
|
|
1911
|
+
apiKey: api_key,
|
|
1912
|
+
params: { oldest, newest }
|
|
1913
|
+
});
|
|
1914
|
+
if (isApiError(fetchResult)) {
|
|
1915
|
+
return textResult(`Error deleting events: ${apiErrorMessage(fetchResult)}`);
|
|
1916
|
+
}
|
|
1917
|
+
const events = Array.isArray(fetchResult) ? fetchResult : [];
|
|
1918
|
+
const failedEvents = [];
|
|
1919
|
+
for (const event of events) {
|
|
1920
|
+
const deleteResult = await makeIntervalsRequest(
|
|
1921
|
+
`/athlete/${athleteId}/events/${event["id"]}`,
|
|
1922
|
+
{ apiKey: api_key, method: "DELETE" }
|
|
1923
|
+
);
|
|
1924
|
+
if (isApiError(deleteResult)) failedEvents.push(event["id"]);
|
|
1925
|
+
}
|
|
1926
|
+
const deletedCount = events.length - failedEvents.length;
|
|
1927
|
+
return textResult(
|
|
1928
|
+
`Deleted ${deletedCount} events. Failed to delete ${failedEvents.length} events: ${JSON.stringify(failedEvents)}`
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1931
|
+
);
|
|
1932
|
+
server.registerTool(
|
|
1933
|
+
"add_or_update_event",
|
|
1934
|
+
{
|
|
1935
|
+
title: "Add Or Update Event",
|
|
1936
|
+
description: WORKOUT_DOC_HELP,
|
|
1937
|
+
inputSchema: {
|
|
1938
|
+
workout_type: z5.string().describe("Workout type (e.g. Ride, Run, Swim, Walk, Row)"),
|
|
1939
|
+
name: z5.string().describe("Name of the activity"),
|
|
1940
|
+
athlete_id: z5.string().optional(),
|
|
1941
|
+
api_key: z5.string().optional(),
|
|
1942
|
+
event_id: z5.string().optional().describe("The Intervals.icu event ID (optional; if set the event is updated)"),
|
|
1943
|
+
start_date: z5.string().optional().describe("Start date in YYYY-MM-DD format (optional, defaults to today)"),
|
|
1944
|
+
workout_doc: WorkoutDocSchema.optional().describe(
|
|
1945
|
+
"Steps as a list of step objects (optional, needed to define workout steps)"
|
|
1946
|
+
),
|
|
1947
|
+
moving_time: z5.number().nullable().optional().describe("Total expected moving time of the workout in seconds (optional)"),
|
|
1948
|
+
distance: z5.number().nullable().optional().describe("Total expected distance of the workout in meters (optional)")
|
|
1949
|
+
}
|
|
1950
|
+
},
|
|
1951
|
+
async ({
|
|
1952
|
+
workout_type,
|
|
1953
|
+
name,
|
|
1954
|
+
athlete_id,
|
|
1955
|
+
api_key,
|
|
1956
|
+
event_id,
|
|
1957
|
+
start_date,
|
|
1958
|
+
workout_doc,
|
|
1959
|
+
moving_time,
|
|
1960
|
+
distance
|
|
1961
|
+
}) => {
|
|
1962
|
+
const config = getConfig();
|
|
1963
|
+
const { athleteId, error } = resolveAthleteId(athlete_id, config.athleteId);
|
|
1964
|
+
if (error) return textResult(error);
|
|
1965
|
+
const startDate = start_date || getDefaultEndDate();
|
|
1966
|
+
try {
|
|
1967
|
+
const validatedDate = validateDate(startDate);
|
|
1968
|
+
const eventData = prepareEventData(
|
|
1969
|
+
name,
|
|
1970
|
+
workout_type,
|
|
1971
|
+
validatedDate,
|
|
1972
|
+
workout_doc,
|
|
1973
|
+
moving_time ?? null,
|
|
1974
|
+
distance ?? null
|
|
1975
|
+
);
|
|
1976
|
+
return textResult(
|
|
1977
|
+
await createOrUpdateEventRequest(
|
|
1978
|
+
athleteId,
|
|
1979
|
+
api_key,
|
|
1980
|
+
eventData,
|
|
1981
|
+
validatedDate,
|
|
1982
|
+
event_id
|
|
1983
|
+
)
|
|
1984
|
+
);
|
|
1985
|
+
} catch (e) {
|
|
1986
|
+
return textResult(`Error: ${e.message}`);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
);
|
|
1990
|
+
server.registerTool(
|
|
1991
|
+
"add_or_update_note",
|
|
1992
|
+
{
|
|
1993
|
+
title: "Add Or Update Note",
|
|
1994
|
+
description: "Add or update a plain text note (category NOTE) on the Intervals.icu calendar.",
|
|
1995
|
+
inputSchema: {
|
|
1996
|
+
name: z5.string().describe("Title of the note"),
|
|
1997
|
+
description: z5.string().describe("Plain text content of the note"),
|
|
1998
|
+
start_date: z5.string().optional().describe("Date in YYYY-MM-DD format (optional, defaults to today)"),
|
|
1999
|
+
color: z5.string().optional().describe("Color of the note (e.g. green, orange, red, blue)"),
|
|
2000
|
+
athlete_id: z5.string().optional(),
|
|
2001
|
+
api_key: z5.string().optional(),
|
|
2002
|
+
event_id: z5.string().optional().describe("The Intervals.icu event ID (optional, for updates)")
|
|
2003
|
+
}
|
|
2004
|
+
},
|
|
2005
|
+
async ({ name, description, start_date, color, athlete_id, api_key, event_id }) => {
|
|
2006
|
+
const config = getConfig();
|
|
2007
|
+
const { athleteId, error } = resolveAthleteId(athlete_id, config.athleteId);
|
|
2008
|
+
if (error) return textResult(error);
|
|
2009
|
+
const startDate = start_date || getDefaultEndDate();
|
|
2010
|
+
try {
|
|
2011
|
+
const validatedDate = validateDate(startDate);
|
|
2012
|
+
const eventData = {
|
|
2013
|
+
category: "NOTE",
|
|
2014
|
+
name,
|
|
2015
|
+
description,
|
|
2016
|
+
start_date_local: `${validatedDate}T00:00:00`,
|
|
2017
|
+
color: color ?? "green"
|
|
2018
|
+
};
|
|
2019
|
+
return textResult(
|
|
2020
|
+
await createOrUpdateEventRequest(
|
|
2021
|
+
athleteId,
|
|
2022
|
+
api_key,
|
|
2023
|
+
eventData,
|
|
2024
|
+
validatedDate,
|
|
2025
|
+
event_id
|
|
2026
|
+
)
|
|
2027
|
+
);
|
|
2028
|
+
} catch (e) {
|
|
2029
|
+
return textResult(`Error: ${e.message}`);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
);
|
|
2033
|
+
};
|
|
2034
|
+
|
|
2035
|
+
// src/tools/powerCurves.ts
|
|
2036
|
+
import { z as z6 } from "zod";
|
|
2037
|
+
var DEFAULT_DURATIONS = [5, 15, 30, 60, 120, 300, 600, 1200, 3600];
|
|
2038
|
+
function buildCurvesParam(thisSeason, lastSeason, startDate, endDate) {
|
|
2039
|
+
const curves = [];
|
|
2040
|
+
if (thisSeason) curves.push("s0");
|
|
2041
|
+
if (lastSeason) curves.push("s1");
|
|
2042
|
+
if (startDate && endDate) curves.push(`r.${startDate}.${endDate}`);
|
|
2043
|
+
return curves;
|
|
2044
|
+
}
|
|
2045
|
+
function validateDates(startDate, endDate) {
|
|
2046
|
+
if (!startDate !== !endDate) {
|
|
2047
|
+
return "Error: Both start_date and end_date must be provided together for a custom date range.";
|
|
2048
|
+
}
|
|
2049
|
+
if (startDate && endDate) {
|
|
2050
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(startDate) || !/^\d{4}-\d{2}-\d{2}$/.test(endDate)) {
|
|
2051
|
+
return "Error: Dates must be in YYYY-MM-DD format.";
|
|
2052
|
+
}
|
|
2053
|
+
if (startDate >= endDate) return "Error: start_date must be before end_date.";
|
|
2054
|
+
}
|
|
2055
|
+
return null;
|
|
2056
|
+
}
|
|
2057
|
+
function extractCurveData(curve, durations, includeNormalised) {
|
|
2058
|
+
const secs = curve["secs"] ?? [];
|
|
2059
|
+
const values = curve["values"] ?? [];
|
|
2060
|
+
const activityIds = curve["activity_id"] ?? [];
|
|
2061
|
+
const wattsPerKg = curve["watts_per_kg"] ?? [];
|
|
2062
|
+
const wkgActivityIds = curve["wkg_activity_id"] ?? [];
|
|
2063
|
+
const secToIdx = /* @__PURE__ */ new Map();
|
|
2064
|
+
secs.forEach((s, i) => secToIdx.set(s, i));
|
|
2065
|
+
const dataPoints = [];
|
|
2066
|
+
for (const dur of durations) {
|
|
2067
|
+
const idx = secToIdx.get(dur);
|
|
2068
|
+
if (idx === void 0 || idx >= values.length) continue;
|
|
2069
|
+
const point = {
|
|
2070
|
+
secs: dur,
|
|
2071
|
+
watts: values[idx] ?? null,
|
|
2072
|
+
activity_id: idx < activityIds.length && activityIds[idx] != null ? activityIds[idx] : ""
|
|
2073
|
+
};
|
|
2074
|
+
if (includeNormalised && idx < wattsPerKg.length) {
|
|
2075
|
+
point.watts_per_kg = wattsPerKg[idx] != null ? Math.round(wattsPerKg[idx] * 100) / 100 : void 0;
|
|
2076
|
+
point.wkg_activity_id = idx < wkgActivityIds.length && wkgActivityIds[idx] != null ? wkgActivityIds[idx] : "";
|
|
2077
|
+
}
|
|
2078
|
+
dataPoints.push(point);
|
|
2079
|
+
}
|
|
2080
|
+
return {
|
|
2081
|
+
id: String(curve["id"] ?? ""),
|
|
2082
|
+
label: String(curve["label"] ?? curve["id"] ?? ""),
|
|
2083
|
+
start: String(curve["start_date_local"] ?? ""),
|
|
2084
|
+
end: String(curve["end_date_local"] ?? ""),
|
|
2085
|
+
data_points: dataPoints
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
2088
|
+
var registerPowerCurveTools = (server) => {
|
|
2089
|
+
server.registerTool(
|
|
2090
|
+
"get_athlete_power_curves",
|
|
2091
|
+
{
|
|
2092
|
+
title: "Get Athlete Power Curves",
|
|
2093
|
+
description: "Get power curves for an athlete from Intervals.icu.\n\nReturns best power output for selected durations across specified time periods.\nUses FFT power computation. Power values are in watts.",
|
|
2094
|
+
inputSchema: {
|
|
2095
|
+
activity_type: z6.string().default("Ride").describe('Activity type (e.g. "Ride", "Run", "VirtualRide")'),
|
|
2096
|
+
durations: z6.array(z6.number().int()).optional().describe("Durations in seconds to include (defaults to [5, 15, 30, 60, 120, 300, 600, 1200, 3600])"),
|
|
2097
|
+
indoor_outdoor: z6.string().optional().describe('Filter by location \u2014 "indoor" or "outdoor". Omit for no filtering.'),
|
|
2098
|
+
start_date: z6.string().optional().describe("Start date (YYYY-MM-DD) for custom date range curve. Must be used with end_date."),
|
|
2099
|
+
end_date: z6.string().optional().describe("End date (YYYY-MM-DD) for custom date range curve. Must be used with start_date."),
|
|
2100
|
+
this_season: z6.boolean().default(true).describe("Include this season's curve"),
|
|
2101
|
+
last_season: z6.boolean().default(true).describe("Include last season's curve"),
|
|
2102
|
+
include_normalised: z6.boolean().default(true).describe("Include weight-normalised W/kg values"),
|
|
2103
|
+
athlete_id: z6.string().optional(),
|
|
2104
|
+
api_key: z6.string().optional()
|
|
2105
|
+
}
|
|
2106
|
+
},
|
|
2107
|
+
async ({
|
|
2108
|
+
activity_type,
|
|
2109
|
+
durations,
|
|
2110
|
+
indoor_outdoor,
|
|
2111
|
+
start_date,
|
|
2112
|
+
end_date,
|
|
2113
|
+
this_season,
|
|
2114
|
+
last_season,
|
|
2115
|
+
include_normalised,
|
|
2116
|
+
athlete_id,
|
|
2117
|
+
api_key
|
|
2118
|
+
}) => {
|
|
2119
|
+
const config = getConfig();
|
|
2120
|
+
const { athleteId, error } = resolveAthleteId(athlete_id, config.athleteId);
|
|
2121
|
+
if (error) return textResult(error);
|
|
2122
|
+
const durs = durations ?? DEFAULT_DURATIONS;
|
|
2123
|
+
if (indoor_outdoor && !["indoor", "outdoor"].includes(indoor_outdoor)) {
|
|
2124
|
+
return textResult("Error: indoor_outdoor must be 'indoor', 'outdoor', or omitted.");
|
|
2125
|
+
}
|
|
2126
|
+
const dateError = validateDates(start_date, end_date);
|
|
2127
|
+
if (dateError) return textResult(dateError);
|
|
2128
|
+
const curves = buildCurvesParam(this_season, last_season, start_date, end_date);
|
|
2129
|
+
if (!curves.length) {
|
|
2130
|
+
return textResult(
|
|
2131
|
+
"Error: At least one curve must be selected (this_season, last_season, or a date range)."
|
|
2132
|
+
);
|
|
2133
|
+
}
|
|
2134
|
+
const params = {
|
|
2135
|
+
curves,
|
|
2136
|
+
type: activity_type,
|
|
2137
|
+
includeRanks: false
|
|
2138
|
+
};
|
|
2139
|
+
if (indoor_outdoor) {
|
|
2140
|
+
params["filters"] = JSON.stringify([
|
|
2141
|
+
{ field_id: "indoor", value: indoor_outdoor, id: 1 }
|
|
2142
|
+
]);
|
|
2143
|
+
}
|
|
2144
|
+
const result = await makeIntervalsRequest(`/athlete/${athleteId}/power-curves`, {
|
|
2145
|
+
apiKey: api_key,
|
|
2146
|
+
params
|
|
2147
|
+
});
|
|
2148
|
+
if (isApiError(result)) {
|
|
2149
|
+
return textResult(`Error fetching power curves: ${apiErrorMessage(result)}`);
|
|
2150
|
+
}
|
|
2151
|
+
let curveList = [];
|
|
2152
|
+
if (typeof result === "object" && result !== null && !Array.isArray(result)) {
|
|
2153
|
+
curveList = result["list"] ?? [];
|
|
2154
|
+
} else if (Array.isArray(result)) {
|
|
2155
|
+
curveList = result;
|
|
2156
|
+
}
|
|
2157
|
+
if (!curveList.length) {
|
|
2158
|
+
return textResult(`No power curve data found for athlete ${athleteId} (${activity_type}).`);
|
|
2159
|
+
}
|
|
2160
|
+
const extracted = curveList.filter((c) => typeof c === "object" && c !== null).map((c) => extractCurveData(c, durs, include_normalised));
|
|
2161
|
+
if (!extracted.length) {
|
|
2162
|
+
return textResult(`No power curve data found for athlete ${athleteId} (${activity_type}).`);
|
|
2163
|
+
}
|
|
2164
|
+
return textResult(formatPowerCurves(extracted, activity_type, include_normalised));
|
|
2165
|
+
}
|
|
2166
|
+
);
|
|
2167
|
+
};
|
|
2168
|
+
|
|
2169
|
+
// src/tools/wellness.ts
|
|
2170
|
+
import { z as z7 } from "zod";
|
|
2171
|
+
var registerWellnessTools = (server) => {
|
|
2172
|
+
server.registerTool(
|
|
2173
|
+
"get_wellness_data",
|
|
2174
|
+
{
|
|
2175
|
+
title: "Get Wellness Data",
|
|
2176
|
+
description: "Get wellness data for an athlete from Intervals.icu.\n\nBy default returns standard wellness fields (training metrics, vitals, sleep,\nsubjective scores, etc.). Set include_all_fields=true to also include any\nadditional or custom fields configured by the user in Intervals.icu.",
|
|
2177
|
+
inputSchema: {
|
|
2178
|
+
athlete_id: z7.string().optional(),
|
|
2179
|
+
api_key: z7.string().optional(),
|
|
2180
|
+
start_date: z7.string().optional().describe("Start date in YYYY-MM-DD format (defaults to 30 days ago)"),
|
|
2181
|
+
end_date: z7.string().optional().describe("End date in YYYY-MM-DD format (defaults to today)"),
|
|
2182
|
+
include_all_fields: z7.boolean().default(false).describe("Include additional and custom fields beyond the standard set")
|
|
2183
|
+
}
|
|
2184
|
+
},
|
|
2185
|
+
async ({ athlete_id, api_key, start_date, end_date, include_all_fields }) => {
|
|
2186
|
+
const config = getConfig();
|
|
2187
|
+
const { athleteId, error } = resolveAthleteId(athlete_id, config.athleteId);
|
|
2188
|
+
if (error) return textResult(error);
|
|
2189
|
+
const [oldest, newest] = resolveDateParams(start_date, end_date);
|
|
2190
|
+
const result = await makeIntervalsRequest(`/athlete/${athleteId}/wellness`, {
|
|
2191
|
+
apiKey: api_key,
|
|
2192
|
+
params: { oldest, newest }
|
|
2193
|
+
});
|
|
2194
|
+
if (isApiError(result)) {
|
|
2195
|
+
return textResult(`Error fetching wellness data: ${apiErrorMessage(result)}`);
|
|
2196
|
+
}
|
|
2197
|
+
if (result == null || typeof result === "object" && !Array.isArray(result) && Object.keys(result).length === 0) {
|
|
2198
|
+
return textResult(
|
|
2199
|
+
`No wellness data found for athlete ${athleteId} in the specified date range.`
|
|
2200
|
+
);
|
|
2201
|
+
}
|
|
2202
|
+
let summary = "Wellness Data:\n\n";
|
|
2203
|
+
if (typeof result === "object" && !Array.isArray(result)) {
|
|
2204
|
+
for (const [dateStr, data] of Object.entries(result)) {
|
|
2205
|
+
if (typeof data === "object" && data !== null && !("date" in data)) {
|
|
2206
|
+
data["date"] = dateStr;
|
|
2207
|
+
}
|
|
2208
|
+
summary += `${formatWellnessEntry(data, include_all_fields)}
|
|
2209
|
+
|
|
2210
|
+
`;
|
|
2211
|
+
}
|
|
2212
|
+
} else if (Array.isArray(result)) {
|
|
2213
|
+
for (const entry of result) {
|
|
2214
|
+
if (typeof entry === "object" && entry !== null) {
|
|
2215
|
+
summary += `${formatWellnessEntry(entry, include_all_fields)}
|
|
2216
|
+
|
|
2217
|
+
`;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
return textResult(summary);
|
|
2222
|
+
}
|
|
2223
|
+
);
|
|
2224
|
+
};
|
|
2225
|
+
|
|
2226
|
+
// src/server.ts
|
|
2227
|
+
var MCP_PATH = "/mcp";
|
|
2228
|
+
function createServer() {
|
|
2229
|
+
const server = new McpServer({
|
|
2230
|
+
name: "intervals-icu",
|
|
2231
|
+
version: readPackageVersion()
|
|
2232
|
+
});
|
|
2233
|
+
registerActivityTools(server);
|
|
2234
|
+
registerEventTools(server);
|
|
2235
|
+
registerWellnessTools(server);
|
|
2236
|
+
registerGearTools(server);
|
|
2237
|
+
registerPowerCurveTools(server);
|
|
2238
|
+
registerCustomItemTools(server);
|
|
2239
|
+
return server;
|
|
2240
|
+
}
|
|
2241
|
+
async function serveStdio() {
|
|
2242
|
+
const server = createServer();
|
|
2243
|
+
const transport = new StdioServerTransport();
|
|
2244
|
+
await server.connect(transport);
|
|
2245
|
+
}
|
|
2246
|
+
async function serveStreamableHttp(host, port) {
|
|
2247
|
+
const httpServer = http.createServer(async (req, res) => {
|
|
2248
|
+
const url = req.url ?? "/";
|
|
2249
|
+
const isMcpPath = url === MCP_PATH || url.startsWith(`${MCP_PATH}/`);
|
|
2250
|
+
if (!isMcpPath) {
|
|
2251
|
+
res.writeHead(404).end("Not found");
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
if (req.method !== "POST") {
|
|
2255
|
+
res.writeHead(405, { Allow: "POST" }).end("Method Not Allowed");
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
try {
|
|
2259
|
+
const server = createServer();
|
|
2260
|
+
const transport = new StreamableHTTPServerTransport({
|
|
2261
|
+
sessionIdGenerator: void 0,
|
|
2262
|
+
enableJsonResponse: true
|
|
2263
|
+
});
|
|
2264
|
+
res.on("close", () => {
|
|
2265
|
+
transport.close();
|
|
2266
|
+
void server.close();
|
|
2267
|
+
});
|
|
2268
|
+
await server.connect(transport);
|
|
2269
|
+
await transport.handleRequest(req, res);
|
|
2270
|
+
} catch (err) {
|
|
2271
|
+
console.error("[intervals-mcp-server] error handling MCP request:", err);
|
|
2272
|
+
if (!res.headersSent) {
|
|
2273
|
+
res.writeHead(500).end("Internal Server Error");
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
});
|
|
2277
|
+
await new Promise((resolve) => {
|
|
2278
|
+
httpServer.listen(port, host, () => resolve());
|
|
2279
|
+
});
|
|
2280
|
+
console.error(
|
|
2281
|
+
`[intervals-mcp-server] Streamable HTTP transport listening at http://${host}:${port}${MCP_PATH}`
|
|
2282
|
+
);
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
// src/index.ts
|
|
2286
|
+
var NO_CREDENTIALS_HINT = `No Intervals.icu credentials found.
|
|
2287
|
+
|
|
2288
|
+
Run the interactive setup once in your terminal:
|
|
2289
|
+
|
|
2290
|
+
npx intervals-mcp-server auth
|
|
2291
|
+
|
|
2292
|
+
It opens https://intervals.icu/settings where you can copy your API Key
|
|
2293
|
+
and Athlete ID, verifies them, and saves them for future runs.
|
|
2294
|
+
|
|
2295
|
+
Alternatively set the API_KEY and ATHLETE_ID environment variables in your
|
|
2296
|
+
MCP client configuration.`;
|
|
2297
|
+
function isInteractive() {
|
|
2298
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
2299
|
+
}
|
|
2300
|
+
async function startServer(opts) {
|
|
2301
|
+
const transport = opts.transport.toLowerCase();
|
|
2302
|
+
if (transport === "stdio") {
|
|
2303
|
+
await serveStdio();
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
if (transport === "streamable-http" || transport === "http") {
|
|
2307
|
+
const port = Number.parseInt(opts.port, 10);
|
|
2308
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
2309
|
+
console.error(`Invalid port: ${opts.port}`);
|
|
2310
|
+
process.exit(1);
|
|
2311
|
+
}
|
|
2312
|
+
await serveStreamableHttp(opts.host, port);
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
console.error(`Unsupported transport "${opts.transport}". Use "stdio" or "streamable-http".`);
|
|
2316
|
+
process.exit(1);
|
|
2317
|
+
}
|
|
2318
|
+
var program = new Command();
|
|
2319
|
+
program.name("intervals-mcp-server").description("Model Context Protocol server for the Intervals.icu API").version(readPackageVersion()).option("--transport <type>", "transport: stdio or streamable-http", "stdio").option("--host <host>", "HTTP host when using streamable-http", "127.0.0.1").option("--port <port>", "HTTP port when using streamable-http", "8765").action(async (opts) => {
|
|
2320
|
+
if (!hasCredentials()) {
|
|
2321
|
+
if (!isInteractive()) {
|
|
2322
|
+
console.error(NO_CREDENTIALS_HINT);
|
|
2323
|
+
process.exit(1);
|
|
2324
|
+
}
|
|
2325
|
+
const ok = await runOnboarding();
|
|
2326
|
+
process.exit(ok ? 0 : 1);
|
|
2327
|
+
}
|
|
2328
|
+
await startServer(opts);
|
|
2329
|
+
});
|
|
2330
|
+
program.command("auth").description("Run (or re-run) the interactive credential setup wizard").action(async () => {
|
|
2331
|
+
if (!isInteractive()) {
|
|
2332
|
+
console.error("The auth wizard needs an interactive terminal. Run it directly in your shell:\n\n npx intervals-mcp-server auth\n");
|
|
2333
|
+
process.exit(1);
|
|
2334
|
+
}
|
|
2335
|
+
const ok = await runOnboarding();
|
|
2336
|
+
process.exit(ok ? 0 : 1);
|
|
2337
|
+
});
|
|
2338
|
+
program.command("serve").description("Start the MCP server (skips the onboarding check)").option("--transport <type>", "transport: stdio or streamable-http", "stdio").option("--host <host>", "HTTP host when using streamable-http", "127.0.0.1").option("--port <port>", "HTTP port when using streamable-http", "8765").action(async (opts) => {
|
|
2339
|
+
await startServer(opts);
|
|
2340
|
+
});
|
|
2341
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
2342
|
+
console.error("[intervals-mcp-server] fatal:", err);
|
|
2343
|
+
process.exit(1);
|
|
2344
|
+
});
|
|
2345
|
+
//# sourceMappingURL=index.js.map
|