intervals-icu-mcp-server 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,10 +78,16 @@ Add to `~/.claude/settings.json`:
78
78
  ### Athlete
79
79
  | Tool | Description |
80
80
  |------|-------------|
81
+ | `list_athletes` | List athletes you follow or coach (including yourself) — find athlete IDs for a coach account |
81
82
  | `get_athlete_profile` | FTP, LTHR, weight, VO2max, resting HR |
82
83
  | `get_athlete_zones` | Power, HR and pace training zones |
83
84
  | `get_athlete_summary` | Current CTL, ATL, TSB and ramp rate snapshot |
84
85
 
86
+ **Coach accounts:** every tool accepts an optional `athlete_id` argument that overrides the
87
+ `ATHLETE_ID` env var for that call. Log in with a coach's `API_KEY`, call `list_athletes` to get
88
+ IDs for the athletes they coach, then pass `athlete_id: "iXXXXX"` on any tool call to work with
89
+ that specific athlete — no need to swap accounts or `.env` files.
90
+
85
91
  ### Calendar Events
86
92
  | Tool | Description |
87
93
  |------|-------------|
package/dist/client.d.ts CHANGED
@@ -86,6 +86,19 @@ export declare const ActivitySchema: z.ZodObject<{
86
86
  source: z.ZodOptional<z.ZodNullable<z.ZodString>>;
87
87
  compliance: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
88
88
  icu_chat_id: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
89
+ coasting_time: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
90
+ ss_cp: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
91
+ ss_w_prime: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
92
+ ss_p_max: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
93
+ icu_pm_ftp: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
94
+ icu_w_prime: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
95
+ p_max: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
96
+ icu_max_wbal_depletion: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
97
+ icu_joules_above_ftp: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
98
+ icu_weight: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
99
+ carbs_used: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
100
+ carbs_ingested: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
101
+ avg_lr_balance: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
89
102
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
90
103
  id: z.ZodString;
91
104
  name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -169,6 +182,19 @@ export declare const ActivitySchema: z.ZodObject<{
169
182
  source: z.ZodOptional<z.ZodNullable<z.ZodString>>;
170
183
  compliance: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
171
184
  icu_chat_id: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
185
+ coasting_time: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
186
+ ss_cp: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
187
+ ss_w_prime: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
188
+ ss_p_max: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
189
+ icu_pm_ftp: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
190
+ icu_w_prime: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
191
+ p_max: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
192
+ icu_max_wbal_depletion: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
193
+ icu_joules_above_ftp: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
194
+ icu_weight: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
195
+ carbs_used: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
196
+ carbs_ingested: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
197
+ avg_lr_balance: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
172
198
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
173
199
  id: z.ZodString;
174
200
  name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -252,6 +278,19 @@ export declare const ActivitySchema: z.ZodObject<{
252
278
  source: z.ZodOptional<z.ZodNullable<z.ZodString>>;
253
279
  compliance: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
254
280
  icu_chat_id: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
281
+ coasting_time: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
282
+ ss_cp: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
283
+ ss_w_prime: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
284
+ ss_p_max: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
285
+ icu_pm_ftp: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
286
+ icu_w_prime: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
287
+ p_max: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
288
+ icu_max_wbal_depletion: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
289
+ icu_joules_above_ftp: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
290
+ icu_weight: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
291
+ carbs_used: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
292
+ carbs_ingested: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
293
+ avg_lr_balance: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
255
294
  }, z.ZodTypeAny, "passthrough">>;
256
295
  export type Activity = z.infer<typeof ActivitySchema>;
257
296
  export declare const WellnessSchema: z.ZodObject<{
@@ -459,6 +498,8 @@ export declare const AthleteSchema: z.ZodObject<{
459
498
  country: z.ZodOptional<z.ZodNullable<z.ZodString>>;
460
499
  icu_resting_hr: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
461
500
  icu_weight: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
501
+ icu_coach: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
502
+ icu_permission: z.ZodOptional<z.ZodNullable<z.ZodString>>;
462
503
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
463
504
  id: z.ZodOptional<z.ZodString>;
464
505
  name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -470,6 +511,8 @@ export declare const AthleteSchema: z.ZodObject<{
470
511
  country: z.ZodOptional<z.ZodNullable<z.ZodString>>;
471
512
  icu_resting_hr: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
472
513
  icu_weight: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
514
+ icu_coach: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
515
+ icu_permission: z.ZodOptional<z.ZodNullable<z.ZodString>>;
473
516
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
474
517
  id: z.ZodOptional<z.ZodString>;
475
518
  name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -481,6 +524,8 @@ export declare const AthleteSchema: z.ZodObject<{
481
524
  country: z.ZodOptional<z.ZodNullable<z.ZodString>>;
482
525
  icu_resting_hr: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
483
526
  icu_weight: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
527
+ icu_coach: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
528
+ icu_permission: z.ZodOptional<z.ZodNullable<z.ZodString>>;
484
529
  }, z.ZodTypeAny, "passthrough">>;
485
530
  export type Athlete = z.infer<typeof AthleteSchema>;
486
531
  export declare const SportSettingsSchema: z.ZodObject<{
@@ -639,6 +684,7 @@ export declare function createEvent(config: ClientConfig, input: EventInput): Pr
639
684
  export declare function updateEvent(config: ClientConfig, eventId: string, input: EventInput): Promise<Event>;
640
685
  export declare function deleteEvent(config: ClientConfig, eventId: string): Promise<void>;
641
686
  export declare function getAthleteProfile(config: ClientConfig): Promise<Athlete>;
687
+ export declare function listAthletes(config: ClientConfig): Promise<Athlete[]>;
642
688
  export declare function getAthleteZones(config: ClientConfig): Promise<SportSettings[]>;
643
689
  export declare function getAthleteSummary(config: ClientConfig): Promise<AthleteFitness | undefined>;
644
690
  export declare function getWellnessDay(config: ClientConfig, date: string): Promise<Wellness>;
package/dist/client.js CHANGED
@@ -84,6 +84,19 @@ export const ActivitySchema = z.object({
84
84
  source: z.string().nullable().optional(),
85
85
  compliance: z.number().nullable().optional(),
86
86
  icu_chat_id: z.number().nullable().optional(),
87
+ coasting_time: z.number().nullable().optional(),
88
+ ss_cp: z.number().nullable().optional(),
89
+ ss_w_prime: z.number().nullable().optional(),
90
+ ss_p_max: z.number().nullable().optional(),
91
+ icu_pm_ftp: z.number().nullable().optional(),
92
+ icu_w_prime: z.number().nullable().optional(),
93
+ p_max: z.number().nullable().optional(),
94
+ icu_max_wbal_depletion: z.number().nullable().optional(),
95
+ icu_joules_above_ftp: z.number().nullable().optional(),
96
+ icu_weight: z.number().nullable().optional(),
97
+ carbs_used: z.number().nullable().optional(),
98
+ carbs_ingested: z.number().nullable().optional(),
99
+ avg_lr_balance: z.number().nullable().optional(),
87
100
  }).passthrough();
88
101
  export const WellnessSchema = z.object({
89
102
  id: z.string().optional(),
@@ -148,6 +161,10 @@ export const AthleteSchema = z.object({
148
161
  country: z.string().nullable().optional(),
149
162
  icu_resting_hr: z.number().nullable().optional(),
150
163
  icu_weight: z.number().nullable().optional(),
164
+ icu_coach: z.boolean().nullable().optional(),
165
+ // Present only on entries returned by /athletes: the caller's access level on this athlete
166
+ // (NONE/READ/WRITE for someone they follow/coach, absent for their own profile).
167
+ icu_permission: z.string().nullable().optional(),
151
168
  }).passthrough();
152
169
  export const SportSettingsSchema = z.object({
153
170
  types: z.array(z.string()).nullable().optional(),
@@ -280,6 +297,12 @@ export async function getAthleteProfile(config) {
280
297
  const data = await request(`/athlete/${config.athleteId}`, config);
281
298
  return AthleteSchema.parse(data);
282
299
  }
300
+ // Requires an API_KEY (not a bearer token). Athlete ID in `config` is ignored — this call
301
+ // is not athlete-scoped, it lists everyone the key's account follows or coaches.
302
+ export async function listAthletes(config) {
303
+ const data = await request(`/athletes`, config);
304
+ return z.array(AthleteSchema).parse(data);
305
+ }
283
306
  export async function getAthleteZones(config) {
284
307
  const data = await request(`/athlete/${config.athleteId}/sport-settings`, config);
285
308
  return z.array(SportSettingsSchema).parse(data);
package/dist/format.js CHANGED
@@ -23,7 +23,9 @@ export function formatActivity(a) {
23
23
  "Power:",
24
24
  ` Avg: ${n(a.icu_average_watts, " W")} | Weighted Avg: ${n(a.icu_weighted_avg_watts, " W")} | FTP: ${n(a.icu_ftp, " W")}`,
25
25
  ` Training Load: ${n(a.icu_training_load)} | Intensity: ${n(a.icu_intensity)} | VI: ${n(a.icu_variability_index)}`,
26
- ` Critical Power: ${n(a.icu_pm_cp, " W")} | Rolling FTP: ${n(a.icu_rolling_ftp, " W")} | Energy: ${a.icu_joules != null ? `${Math.round(a.icu_joules / 1000)} kJ` : "N/A"}`,
26
+ ` Critical Power: ${n(a.icu_pm_cp, " W")} | Rolling FTP: ${n(a.icu_rolling_ftp, " W")} | Activity eFTP: ${n(a.icu_pm_ftp, " W")}`,
27
+ ` Energy: ${a.icu_joules != null ? `${Math.round(a.icu_joules / 1000)} kJ` : "N/A"} | Above FTP: ${a.icu_joules_above_ftp != null ? `${Math.round(a.icu_joules_above_ftp / 1000)} kJ` : "N/A"}`,
28
+ ` W': ${n(a.icu_w_prime, " J")} | Pmax: ${n(a.p_max, " W")} | W'bal Drop: ${a.icu_max_wbal_depletion != null ? `${(a.icu_max_wbal_depletion / 1000).toFixed(1)} kJ` : "N/A"}`,
27
29
  "",
28
30
  "Heart Rate:",
29
31
  ` Avg: ${n(a.average_heartrate, " bpm")} | Max: ${n(a.max_heartrate, " bpm")}`,
@@ -41,7 +43,7 @@ export function formatActivity(a) {
41
43
  lines.push(` HR: ${a.icu_hr_zone_times.map((secs, i) => `Z${i + 1} ${fmtDuration(secs)}`).join(" | ")}`);
42
44
  }
43
45
  }
44
- lines.push("", "Other:", ` Cadence: ${n(a.average_cadence, " rpm")} | Calories: ${n(a.calories)} | Speed: ${n(a.average_speed, " m/s")} (max ${n(a.max_speed, " m/s")})`, ` Temp: ${n(a.average_temp, "°C")} | Trainer: ${a.trainer ? "Yes" : "No"}`, ` RPE: ${n(a.icu_rpe ?? a.perceived_exertion)}/10 | Feel: ${n(a.feel)}/5`, "", "Fitness:", ` CTL: ${n(a.icu_ctl)} | ATL: ${n(a.icu_atl)} | TRIMP: ${n(a.trimp)} | Polarization Index: ${n(a.polarization_index)}`, ` Power Load: ${n(a.power_load)} | HR Load: ${n(a.hr_load)} | Strain: ${n(a.strain_score)}`, "", "Equipment:", ` Gear: ${n(a.gear?.name)} | Device: ${n(a.device_name)} | Power Meter: ${n(a.power_meter)}`, ` Source: ${n(a.source)} | Compliance: ${a.compliance != null ? `${a.compliance}%` : "N/A"}`);
46
+ lines.push("", "Other:", ` Cadence: ${n(a.average_cadence, " rpm")} | Calories: ${n(a.calories)} | Speed: ${n(a.average_speed, " m/s")} (max ${n(a.max_speed, " m/s")})`, ` Temp: ${n(a.average_temp, "°C")} | Trainer: ${a.trainer ? "Yes" : "No"} | Coasting: ${fmtDuration(a.coasting_time)}`, ` RPE: ${n(a.icu_rpe ?? a.perceived_exertion)}/10 | Feel: ${n(a.feel)}/5 | S-RPE: ${n(a.session_rpe)}`, ` L/R Balance: ${a.avg_lr_balance != null ? `${(100 - a.avg_lr_balance).toFixed(0)}/${a.avg_lr_balance.toFixed(0)}` : "N/A"}`, "", "Fitness:", ` CTL: ${n(a.icu_ctl)} | ATL: ${n(a.icu_atl)} | TRIMP: ${n(a.trimp)} | Polarization Index: ${n(a.polarization_index)}`, ` Power Load: ${n(a.power_load)} | HR Load: ${n(a.hr_load)} | Strain: ${n(a.strain_score)}`, ` SS CP: ${n(a.ss_cp, " W")} | SS W': ${n(a.ss_w_prime)} | SS Pmax: ${n(a.ss_p_max)}`, ` Weight: ${n(a.icu_weight, " kg")} | CHO Used: ${n(a.carbs_used, " g")} | CHO In: ${n(a.carbs_ingested, " g")}`, "", "Equipment:", ` Gear: ${n(a.gear?.name)} | Device: ${n(a.device_name)} | Power Meter: ${n(a.power_meter)}`, ` Source: ${n(a.source)} | Compliance: ${a.compliance != null ? `${a.compliance}%` : "N/A"}`);
45
47
  if (a.description)
46
48
  lines.push("", `Description: ${a.description}`);
47
49
  return lines.join("\n");
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
6
6
  import { z } from "zod";
7
- import { getActivities, getActivityDetails, getActivityIntervals, getWellness, getEvents, getEventById, getNotesByDate, createEvent, updateEvent, deleteEvent, getAthleteProfile, getAthleteZones, getAthleteSummary, getWellnessDay, updateWellness, getActivityStreams, getActivityPowerCurves, listWorkouts, createWorkout, bulkCreateEvents, bulkDeleteEvents, getAthleteFitness, getActivityMessages, addActivityMessage, deleteActivityMessage, updateActivityMessage, } from "./client.js";
7
+ import { getActivities, getActivityDetails, getActivityIntervals, getWellness, getEvents, getEventById, getNotesByDate, createEvent, updateEvent, deleteEvent, getAthleteProfile, getAthleteZones, getAthleteSummary, listAthletes, getWellnessDay, updateWellness, getActivityStreams, getActivityPowerCurves, listWorkouts, createWorkout, bulkCreateEvents, bulkDeleteEvents, getAthleteFitness, getActivityMessages, addActivityMessage, deleteActivityMessage, updateActivityMessage, } from "./client.js";
8
8
  import { formatActivity, formatWellness, formatEvent, formatIntervalRow, formatSportZones, formatMessage, } from "./format.js";
9
9
  function getConfig(args) {
10
10
  const apiKey = args["api_key"] ?? process.env.API_KEY ?? "";
@@ -17,6 +17,14 @@ function getConfig(args) {
17
17
  throw new Error("No athlete ID provided. Set ATHLETE_ID in .env or pass athlete_id.");
18
18
  return { apiKey, athleteId };
19
19
  }
20
+ // list_athletes isn't athlete-scoped (it lists everyone the key's account can access), so it
21
+ // only needs the API key — requiring athlete_id here would break coaches with no default athlete.
22
+ function getApiKey(args) {
23
+ const apiKey = args["api_key"] ?? process.env.API_KEY ?? "";
24
+ if (!apiKey)
25
+ throw new Error("No API key provided. Set API_KEY in .env or pass api_key.");
26
+ return apiKey;
27
+ }
20
28
  function toDateStr(d) {
21
29
  const y = d.getFullYear();
22
30
  const m = String(d.getMonth() + 1).padStart(2, "0");
@@ -175,6 +183,16 @@ const TOOLS = [
175
183
  },
176
184
  },
177
185
  },
186
+ {
187
+ name: "list_athletes",
188
+ description: "List athletes you follow or coach, including yourself. Use this with a coach account to find athlete IDs, then pass athlete_id on other tools (get_activities, get_wellness_data, get_events, etc.) to work with a specific athlete under that same account.",
189
+ inputSchema: {
190
+ type: "object",
191
+ properties: {
192
+ api_key: { type: "string", description: "API key (defaults to API_KEY env var)" },
193
+ },
194
+ },
195
+ },
178
196
  {
179
197
  name: "get_athlete_profile",
180
198
  description: "Get athlete profile: name, sex, DOB, weight, resting HR, city/country. For FTP/LTHR/zones use get_athlete_zones.",
@@ -602,6 +620,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
602
620
  await deleteEvent(config, eventId);
603
621
  return { content: [{ type: "text", text: `Event ${eventId} deleted.` }] };
604
622
  }
623
+ case "list_athletes": {
624
+ const apiKey = getApiKey(args);
625
+ const athletes = await listAthletes({ apiKey, athleteId: "" });
626
+ if (!athletes.length)
627
+ return { content: [{ type: "text", text: "No athletes found." }] };
628
+ const lines = athletes.map((a) => {
629
+ const perm = a.icu_permission ? ` [${a.icu_permission}]` : " (self)";
630
+ const coach = a.icu_coach ? " · coach" : "";
631
+ const email = a.email ? ` · ${a.email}` : "";
632
+ return `- **${a.name ?? "Unnamed"}** — id: ${a.id}${perm}${coach}${email}`;
633
+ });
634
+ return { content: [{ type: "text", text: lines.join("\n") }] };
635
+ }
605
636
  case "get_athlete_profile": {
606
637
  const config = getConfig(args);
607
638
  const athlete = await getAthleteProfile(config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "intervals-icu-mcp-server",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server for Intervals.icu — 26 tools for activities, wellness, calendar events, workouts and more",
5
5
  "type": "module",
6
6
  "bin": {