intervals-icu-mcp-server 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,49 +1,33 @@
1
- # intervals-mcp
1
+ # intervals-icu-mcp-server
2
2
 
3
3
  MCP server for [Intervals.icu](https://intervals.icu) — connects Claude / Claude Code to your training data.
4
4
 
5
- ## Tools
6
-
7
- | Tool | Description |
8
- |------|-------------|
9
- | `get_activities` | List activities with power, HR, training load, etc. |
10
- | `get_activity_details` | Full metrics for a specific activity |
11
- | `get_activity_intervals` | Per-interval breakdown (power, HR, speed, elevation) |
12
- | `get_wellness_data` | Weight, HRV, sleep, CTL/ATL, subjective scores |
13
- | `get_events` | Calendar events (workouts, races) |
14
- | `get_event_by_id` | Full details for a specific event |
15
-
16
- ## Setup
17
-
18
- ### 1. Install & build
5
+ ## Installation
19
6
 
20
7
  ```bash
21
- npm install
22
- npm run build
8
+ npx -y intervals-icu-mcp-server
23
9
  ```
24
10
 
25
- ### 2. Configure credentials
11
+ Or install globally:
26
12
 
27
13
  ```bash
28
- cp .env.example .env
29
- # Edit .env and fill in your API_KEY and ATHLETE_ID
14
+ npm install -g intervals-icu-mcp-server
30
15
  ```
31
16
 
32
- - **API_KEY**: Settings → API → Generate key on intervals.icu
33
- - **ATHLETE_ID**: The `i12345` part of your intervals.icu profile URL
17
+ ## Setup
34
18
 
35
- ### 3. Add to Claude Code
19
+ ### Claude Desktop
36
20
 
37
- Add this to your `~/.claude/settings.json` (or project `.claude/settings.json`):
21
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
38
22
 
39
23
  ```json
40
24
  {
41
25
  "mcpServers": {
42
- "intervals": {
43
- "command": "node",
44
- "args": ["/Users/spirohov/work/intervals/dist/index.js"],
26
+ "intervals-icu": {
27
+ "command": "npx",
28
+ "args": ["-y", "intervals-icu-mcp-server"],
45
29
  "env": {
46
- "API_KEY": "your_key_here",
30
+ "API_KEY": "your_api_key",
47
31
  "ATHLETE_ID": "i12345"
48
32
  }
49
33
  }
@@ -51,20 +35,18 @@ Add this to your `~/.claude/settings.json` (or project `.claude/settings.json`):
51
35
  }
52
36
  ```
53
37
 
54
- Or use a `.env` file — the server loads it automatically from the project root.
55
-
56
- ### 4. Add to Claude Desktop
38
+ ### Claude Code
57
39
 
58
- In `~/Library/Application Support/Claude/claude_desktop_config.json`:
40
+ Add to `~/.claude/settings.json`:
59
41
 
60
42
  ```json
61
43
  {
62
44
  "mcpServers": {
63
- "intervals": {
64
- "command": "node",
65
- "args": ["/Users/spirohov/work/intervals/dist/index.js"],
45
+ "intervals-icu": {
46
+ "command": "npx",
47
+ "args": ["-y", "intervals-icu-mcp-server"],
66
48
  "env": {
67
- "API_KEY": "your_key_here",
49
+ "API_KEY": "your_api_key",
68
50
  "ATHLETE_ID": "i12345"
69
51
  }
70
52
  }
@@ -72,9 +54,58 @@ In `~/Library/Application Support/Claude/claude_desktop_config.json`:
72
54
  }
73
55
  ```
74
56
 
57
+ - **API_KEY**: Settings → API → Generate key on intervals.icu
58
+ - **ATHLETE_ID**: The `i12345` part of your intervals.icu profile URL
59
+
60
+ ## Tools
61
+
62
+ ### Activities
63
+ | Tool | Description |
64
+ |------|-------------|
65
+ | `get_activities` | List activities with power, HR, training load, etc. |
66
+ | `get_activity_details` | Full metrics for a specific activity |
67
+ | `get_activity_intervals` | Per-interval breakdown (power, HR, speed, elevation) |
68
+ | `get_activity_streams` | Raw per-second data streams (watts, HR, cadence, speed, altitude) |
69
+ | `get_activity_power_curves` | Best power for all durations (5s, 1min, 5min, 20min, etc.) |
70
+
71
+ ### Wellness & Fitness
72
+ | Tool | Description |
73
+ |------|-------------|
74
+ | `get_wellness_data` | Weight, HRV, sleep, CTL/ATL, subjective scores for a date range |
75
+ | `get_wellness_day` | Wellness data for a single date |
76
+ | `update_wellness` | Log weight, HRV, sleep, mood, soreness, etc. |
77
+
78
+ ### Athlete
79
+ | Tool | Description |
80
+ |------|-------------|
81
+ | `get_athlete_profile` | FTP, LTHR, weight, VO2max, resting HR |
82
+ | `get_athlete_zones` | Power, HR and pace training zones |
83
+ | `get_athlete_summary` | Current CTL, ATL, TSB and ramp rate snapshot |
84
+
85
+ ### Calendar Events
86
+ | Tool | Description |
87
+ |------|-------------|
88
+ | `get_events` | Calendar events (workouts, races) for a date range |
89
+ | `get_event_by_id` | Full details for a specific event |
90
+ | `create_event` | Create a new event (workout, note, race) |
91
+ | `update_event` | Update event fields or move it to a new date |
92
+ | `delete_event` | Delete an event |
93
+ | `bulk_create_events` | Create multiple events at once |
94
+ | `bulk_delete_events` | Delete multiple events at once |
95
+
96
+ ### Workout Library
97
+ | Tool | Description |
98
+ |------|-------------|
99
+ | `list_workouts` | List all workouts in the library |
100
+ | `create_workout` | Save a new structured workout to the library |
101
+
75
102
  ## Development
76
103
 
77
104
  ```bash
105
+ git clone https://github.com/SergeyPirogov/intervals-mcp.git
106
+ cd intervals-mcp
107
+ npm install
108
+ npm run build
78
109
  npm run dev # watch mode
79
110
  npm run inspector # MCP inspector UI
80
111
  ```
package/dist/client.d.ts CHANGED
@@ -202,6 +202,7 @@ export interface EventInput {
202
202
  race?: boolean;
203
203
  distance?: number;
204
204
  sub_type?: string;
205
+ workout_doc?: unknown;
205
206
  }
206
207
  export declare const AthleteSchema: z.ZodObject<{
207
208
  id: z.ZodOptional<z.ZodString>;
@@ -346,6 +347,7 @@ export declare function getEvents(config: ClientConfig, params: {
346
347
  endDate: string;
347
348
  }): Promise<Event[]>;
348
349
  export declare function getEventById(config: ClientConfig, eventId: string): Promise<Event>;
350
+ export declare function getNotesByDate(config: ClientConfig, date: string): Promise<Event[]>;
349
351
  export declare function createEvent(config: ClientConfig, input: EventInput): Promise<Event>;
350
352
  export declare function updateEvent(config: ClientConfig, eventId: string, input: EventInput): Promise<Event>;
351
353
  export declare function deleteEvent(config: ClientConfig, eventId: string): Promise<void>;
@@ -360,3 +362,39 @@ export declare function listWorkouts(config: ClientConfig): Promise<Workout[]>;
360
362
  export declare function createWorkout(config: ClientConfig, input: WorkoutInput): Promise<Workout>;
361
363
  export declare function bulkCreateEvents(config: ClientConfig, events: EventInput[]): Promise<Event[]>;
362
364
  export declare function bulkDeleteEvents(config: ClientConfig, eventIds: (string | number)[]): Promise<void>;
365
+ export declare const AthleteFitnessSchema: z.ZodObject<{
366
+ date: z.ZodOptional<z.ZodString>;
367
+ fitness: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
368
+ fatigue: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
369
+ form: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
370
+ rampRate: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
371
+ training_load: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
372
+ eftp: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
373
+ eftpPerKg: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
374
+ weight: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
375
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
376
+ date: z.ZodOptional<z.ZodString>;
377
+ fitness: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
378
+ fatigue: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
379
+ form: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
380
+ rampRate: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
381
+ training_load: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
382
+ eftp: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
383
+ eftpPerKg: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
384
+ weight: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
385
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
386
+ date: z.ZodOptional<z.ZodString>;
387
+ fitness: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
388
+ fatigue: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
389
+ form: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
390
+ rampRate: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
391
+ training_load: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
392
+ eftp: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
393
+ eftpPerKg: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
394
+ weight: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
395
+ }, z.ZodTypeAny, "passthrough">>;
396
+ export type AthleteFitness = z.infer<typeof AthleteFitnessSchema>;
397
+ export declare function getAthleteFitness(config: ClientConfig, params: {
398
+ startDate: string;
399
+ endDate: string;
400
+ }): Promise<AthleteFitness[]>;
package/dist/client.js CHANGED
@@ -155,9 +155,17 @@ export async function getEvents(config, params) {
155
155
  return z.array(EventSchema).parse(data);
156
156
  }
157
157
  export async function getEventById(config, eventId) {
158
- const data = await request(`/athlete/${config.athleteId}/event/${eventId}`, config);
158
+ const data = await request(`/athlete/${config.athleteId}/events/${eventId}`, config);
159
159
  return EventSchema.parse(data);
160
160
  }
161
+ export async function getNotesByDate(config, date) {
162
+ const data = await request(`/athlete/${config.athleteId}/events`, config, { oldest: date, newest: date });
163
+ const events = z.array(EventSchema).parse(data);
164
+ return events.filter((e) => {
165
+ const raw = e;
166
+ return raw["category"] === "NOTE" || (!e.race && !e.workout);
167
+ });
168
+ }
161
169
  async function mutate(method, path, config, body) {
162
170
  const url = `${BASE_URL}${path}`;
163
171
  const res = await fetch(url, {
@@ -234,3 +242,19 @@ export async function bulkCreateEvents(config, events) {
234
242
  export async function bulkDeleteEvents(config, eventIds) {
235
243
  await mutate("POST", `/athlete/${config.athleteId}/events/bulk-delete`, config, eventIds);
236
244
  }
245
+ export const AthleteFitnessSchema = z.object({
246
+ date: z.string().optional(),
247
+ fitness: z.number().nullable().optional(),
248
+ fatigue: z.number().nullable().optional(),
249
+ form: z.number().nullable().optional(),
250
+ rampRate: z.number().nullable().optional(),
251
+ training_load: z.number().nullable().optional(),
252
+ eftp: z.number().nullable().optional(),
253
+ eftpPerKg: z.number().nullable().optional(),
254
+ weight: z.number().nullable().optional(),
255
+ }).passthrough();
256
+ export async function getAthleteFitness(config, params) {
257
+ const data = await request(`/athlete/${config.athleteId}/athlete-summary.json`, config, { oldest: params.startDate, newest: params.endDate, athleteId: config.athleteId });
258
+ const all = z.array(AthleteFitnessSchema).parse(data);
259
+ return all.filter((e) => e["athlete_id"] === config.athleteId);
260
+ }
package/dist/index.d.ts CHANGED
@@ -1 +1,2 @@
1
+ #!/usr/bin/env node
1
2
  import "dotenv/config";
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
+ #!/usr/bin/env node
1
2
  import "dotenv/config";
2
3
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
5
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
6
  import { z } from "zod";
6
- import { getActivities, getActivityDetails, getActivityIntervals, getWellness, getEvents, getEventById, createEvent, updateEvent, deleteEvent, getAthleteProfile, getAthleteZones, getAthleteSummary, getWellnessDay, updateWellness, getActivityStreams, getActivityPowerCurves, listWorkouts, createWorkout, bulkCreateEvents, bulkDeleteEvents, } from "./client.js";
7
+ import { getActivities, getActivityDetails, getActivityIntervals, getWellness, getEvents, getEventById, getNotesByDate, createEvent, updateEvent, deleteEvent, getAthleteProfile, getAthleteZones, getAthleteSummary, getWellnessDay, updateWellness, getActivityStreams, getActivityPowerCurves, listWorkouts, createWorkout, bulkCreateEvents, bulkDeleteEvents, getAthleteFitness, } from "./client.js";
7
8
  import { formatActivity, formatWellness, formatEvent, formatIntervalRow, } from "./format.js";
8
9
  function getConfig(args) {
9
10
  const apiKey = args["api_key"] ?? process.env.API_KEY ?? "";
@@ -125,6 +126,7 @@ const TOOLS = [
125
126
  race: { type: "boolean", description: "Mark as a race event (default: false)" },
126
127
  distance: { type: "number", description: "Distance in meters (e.g. 100000 for 100km)" },
127
128
  sub_type: { type: "string", description: "Race category/sub-type (e.g. A, B, C)" },
129
+ workout_doc: { type: "object", description: "Structured workout definition (Intervals.icu workout_doc format) to attach steps/intervals to this event" },
128
130
  athlete_id: { type: "string", description: "Athlete ID (defaults to ATHLETE_ID env var)" },
129
131
  api_key: { type: "string", description: "API key (defaults to API_KEY env var)" },
130
132
  },
@@ -146,6 +148,7 @@ const TOOLS = [
146
148
  race: { type: "boolean", description: "Toggle race flag" },
147
149
  distance: { type: "number", description: "Distance in meters (e.g. 100000 for 100km)" },
148
150
  sub_type: { type: "string", description: "Race category/sub-type (e.g. A, B, C)" },
151
+ workout_doc: { type: "object", description: "New structured workout definition (Intervals.icu workout_doc format)" },
149
152
  athlete_id: { type: "string", description: "Athlete ID (defaults to ATHLETE_ID env var)" },
150
153
  api_key: { type: "string", description: "API key (defaults to API_KEY env var)" },
151
154
  },
@@ -298,6 +301,32 @@ const TOOLS = [
298
301
  },
299
302
  },
300
303
  },
304
+ {
305
+ name: "get_athlete_fitness",
306
+ description: "Get Fitness (CTL), Fatigue (ATL), Form (TSB), ramp rate and eFTP from the Fitness chart for a date range. Matches what you see on the Intervals.icu Fitness page.",
307
+ inputSchema: {
308
+ type: "object",
309
+ properties: {
310
+ start_date: { type: "string", description: "Start date YYYY-MM-DD (default: 30 days ago)" },
311
+ end_date: { type: "string", description: "End date YYYY-MM-DD (default: today)" },
312
+ athlete_id: { type: "string", description: "Athlete ID (defaults to ATHLETE_ID env var)" },
313
+ api_key: { type: "string", description: "API key (defaults to API_KEY env var)" },
314
+ },
315
+ },
316
+ },
317
+ {
318
+ name: "get_note",
319
+ description: "Get a specific note/event by ID, or list all notes for a given date.",
320
+ inputSchema: {
321
+ type: "object",
322
+ properties: {
323
+ event_id: { type: "string", description: "Event/note ID to fetch by ID" },
324
+ date: { type: "string", description: "Date YYYY-MM-DD to list all notes for that day" },
325
+ athlete_id: { type: "string", description: "Athlete ID (defaults to ATHLETE_ID env var)" },
326
+ api_key: { type: "string", description: "API key (defaults to API_KEY env var)" },
327
+ },
328
+ },
329
+ },
301
330
  {
302
331
  name: "bulk_create_events",
303
332
  description: "Create multiple calendar events at once.",
@@ -317,6 +346,7 @@ const TOOLS = [
317
346
  category: { type: "string" },
318
347
  type: { type: "string" },
319
348
  race: { type: "boolean" },
349
+ workout_doc: { type: "object", description: "Structured workout definition (Intervals.icu workout_doc format)" },
320
350
  },
321
351
  },
322
352
  },
@@ -432,6 +462,49 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
432
462
  const event = await getEventById(config, eventId);
433
463
  return { content: [{ type: "text", text: formatEvent(event) }] };
434
464
  }
465
+ case "get_athlete_fitness": {
466
+ const config = getConfig(args);
467
+ const startDate = args["start_date"] ?? daysAgo(30);
468
+ const endDate = args["end_date"] ?? today();
469
+ const entries = await getAthleteFitness(config, { startDate, endDate });
470
+ if (!entries.length) {
471
+ return { content: [{ type: "text", text: "No fitness data found for the specified date range." }] };
472
+ }
473
+ const lines = entries.map((e) => {
474
+ const ctl = e.fitness != null ? e.fitness.toFixed(1) : "N/A";
475
+ const atl = e.fatigue != null ? e.fatigue.toFixed(1) : "N/A";
476
+ const tsb = e.form != null ? e.form.toFixed(1) : "N/A";
477
+ const ramp = e.rampRate != null ? e.rampRate.toFixed(1) : "N/A";
478
+ const eftp = e.eftp != null ? `${e.eftp.toFixed(0)}W` : "N/A";
479
+ const eftpKg = e.eftpPerKg != null ? `${e.eftpPerKg.toFixed(2)} W/kg` : "N/A";
480
+ const tl = e.training_load != null ? e.training_load.toFixed(0) : "N/A";
481
+ return [
482
+ `**${e.date ?? "N/A"}**`,
483
+ ` CTL (Fitness): ${ctl} | ATL (Fatigue): ${atl} | TSB (Form): ${tsb}`,
484
+ ` Ramp Rate: ${ramp} | eFTP: ${eftp} (${eftpKg}) | TL: ${tl}`,
485
+ ].join("\n");
486
+ });
487
+ return { content: [{ type: "text", text: lines.join("\n\n") }] };
488
+ }
489
+ case "get_note": {
490
+ const config = getConfig(args);
491
+ const eventId = args["event_id"];
492
+ const date = args["date"];
493
+ if (eventId) {
494
+ const event = await getEventById(config, eventId);
495
+ return { content: [{ type: "text", text: formatEvent(event) }] };
496
+ }
497
+ else if (date) {
498
+ const notes = await getNotesByDate(config, date);
499
+ if (!notes.length)
500
+ return { content: [{ type: "text", text: `No notes found for ${date}.` }] };
501
+ const text = notes.map(formatEvent).join("\n\n---\n\n");
502
+ return { content: [{ type: "text", text }] };
503
+ }
504
+ else {
505
+ return { content: [{ type: "text", text: "Provide either event_id or date." }], isError: true };
506
+ }
507
+ }
435
508
  case "create_event": {
436
509
  const config = getConfig(args);
437
510
  const event = await createEvent(config, {
@@ -443,6 +516,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
443
516
  race: args["race"],
444
517
  distance: args["distance"],
445
518
  sub_type: args["sub_type"],
519
+ workout_doc: args["workout_doc"],
446
520
  });
447
521
  return { content: [{ type: "text", text: `Created event:\n\n${formatEvent(event)}` }] };
448
522
  }
@@ -450,7 +524,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
450
524
  const eventId = z.string().parse(args["event_id"]);
451
525
  const config = getConfig(args);
452
526
  const input = {};
453
- for (const key of ["name", "description", "start_date_local", "category", "type", "race", "distance", "sub_type"]) {
527
+ for (const key of ["name", "description", "start_date_local", "category", "type", "race", "distance", "sub_type", "workout_doc"]) {
454
528
  if (args[key] !== undefined)
455
529
  input[key] = args[key];
456
530
  }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "intervals-icu-mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "MCP server for Intervals.icu — 20 tools for activities, wellness, calendar events, workouts and more",
5
5
  "type": "module",
6
6
  "bin": {
7
- "intervals-icu-mcp": "./dist/index.js"
7
+ "intervals-icu-mcp": "dist/index.js"
8
8
  },
9
9
  "scripts": {
10
- "build": "tsc",
10
+ "build": "tsc && chmod +x dist/index.js",
11
11
  "dev": "tsc --watch",
12
12
  "start": "node dist/index.js",
13
13
  "inspector": "npx @modelcontextprotocol/inspector node dist/index.js",