Skip to main content
Glama
OneMoeLine

Intervals.icu MCP Server

by OneMoeLine
README.md
# Intervals.icu MCP Server

An [MCP](https://modelcontextprotocol.io) server that exposes your
[Intervals.icu](https://intervals.icu) training data — activities, power curves,
wellness, calendar, workouts, gear and routes — to an MCP client such as Claude.

Communicates over stdio. 31 tools — 22 read-only and 9 that write — plus one
resource, the Intervals.icu workout syntax reference. Built on v2 of the MCP
TypeScript SDK (`@modelcontextprotocol/server`).

## Requirements

- Node.js 20 or newer (required by the MCP SDK; the server also uses the
  global `fetch` and `AbortSignal.timeout`)
- An Intervals.icu API key: **Settings → Developer Settings** on intervals.icu

## Configuration

The server reads two required environment variables and exits with a clear
message if either is missing, plus two optional ones:

| Variable | Description |
| --- | --- |
| `INTERVALS_API_KEY` | Your Intervals.icu API key |
| `INTERVALS_ATHLETE_ID` | Your athlete ID, e.g. `i12345` |
| `INTERVALS_BASE_URL` | *Optional.* API root, for pointing the client at a local fake server. Defaults to `https://intervals.icu/api/v1`; the test suite is the only thing that normally sets it. |
| `INTERVALS_DELETE_MODE` | *Optional.* `safe` (default), `full` or `none` — how much deleting the server will do. See [Deleting](#deleting-is-gated) below. An unknown value exits with the list of valid ones. |

> **Note:** there is no `dotenv` dependency, so a `.env` file is **not** loaded
> automatically. `.env.example` is a reference for the values you need — supply
> them through your MCP client's `env` block (below), or export them in your
> shell before `npm start`.

### Keeping your key safe

Your API key grants full read and write access to your Intervals.icu account —
treat it like a password.

- **Never commit it.** `.env` is gitignored; `.env.example` holds placeholders
  only. Your MCP client's config file lives outside this repository, which is
  the right place for the real value.
- **Rotate it** at Settings → Developer Settings if it is ever exposed.
- The server sends the key only to `intervals.icu`, over HTTPS, as an HTTP
  Basic `Authorization` header. It is never logged, never written to disk, and
  never included in an error message.
- Credential-shaped fields in API responses are stripped before anything is
  returned to the model — see *Credentials are withheld* below.

## Install and build

```bash
git clone https://github.com/OneMoeLine/intervals-mcp-server.git
```

Then, from the clone:

```bash
npm install && npm run build
```

`npm install` builds automatically via `prepare`, so installing straight from
the repository also works:

```bash
npm install -g github:OneMoeLine/intervals-mcp-server
```

That puts an `intervals-mcp-server` command on your PATH, which you can use in
place of `node /path/to/dist/index.js` below.

## Running

```bash
npm start
```

More usefully, register it with your MCP client. For Claude Desktop, add this to
`claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "intervals-icu": {
      "command": "node",
      "args": ["/absolute/path/to/Intervals MCP Server/dist/index.js"],
      "env": {
        "INTERVALS_API_KEY": "your_api_key_here",
        "INTERVALS_ATHLETE_ID": "i12345"
      }
    }
  }
}
```

For development without a build step:

```bash
npm run dev
```

## Tests

```bash
npm test
```

85 [vitest](https://vitest.dev) tests, about 1.5 seconds. **No credentials and
no network access are needed** — nothing in the suite contacts Intervals.icu.
`npm test` builds first (via `pretest`), because the protocol tests spawn the
compiled `dist/index.js`. `npm run test:watch` reruns on change, and
`npm run typecheck` checks `src` and `test` together.

Three layers:

- **`test/transform.test.ts`** — the pure helpers, no I/O: `decimate` (including
  the `max_points: 1` case that once divided by zero), `downsampleStreams`,
  `project`/`projectNested` and the date schemas.
- **`test/api.test.ts`** — `IntervalsClient` against a throwaway `node:http`
  server on an ephemeral loopback port: auth header, query building, JSON vs
  text parsing, which statuses retry and which do not, the `Retry-After`
  schedule and its cap, per-attempt timeouts and error-body truncation. The
  backoff is injected as zero, so the retry tests do not sleep.
- **`test/mcp.test.ts`** — the real server binary over stdio: `initialize`, then
  `tools/list` asserting all 31 tools and their input schemas, plus `tools/call`
  for the handler rules (the either/or on `get_activity_best_efforts`, date
  rejection before any HTTP call) and a handful of end-to-end calls against the
  fake API.

## Tools

**Athlete** — `get_athlete`, `get_athlete_profile`

**Activities** — `list_activities`, `get_activity`, `search_activities`,
`get_activity_intervals`, `get_activity_streams`, `get_activity_power_curve`,
`get_activity_best_efforts`, `get_activity_weather`, `update_activity`
*(writes)*

**Power** — `get_power_curves`

**Wellness and fitness** — `list_wellness`, `get_wellness`,
`update_wellness` *(writes)*, `get_fitness_chart`

**Calendar** — `list_events`, `get_event`, `create_event` *(writes)*,
`update_event` *(writes)*, `delete_event` *(writes, gated)*

**Workouts** — `list_workouts`, `get_workout`, `create_workout` *(writes)*,
`update_workout` *(writes)*, `delete_workout` *(writes, gated)*,
`list_folders`, `create_folder` *(writes)*

**Gear and routes** — `list_gear`, `list_routes`, `get_route`

### Resources

`intervals-icu://workout-syntax` — the structured-workout text format, which
is what `description` carries on a workout event or a library workout. Read it
before writing a workout: the syntax fails silently, and the resource lists the
cases where it does.

## Behaviour worth knowing

**Streams are downsampled.** `get_activity_streams` would otherwise return tens
of thousands of samples across every channel — easily megabytes, enough to
exhaust a model's context in one call. Each channel is reduced to 500 evenly
spaced points by default, always keeping the first and last. When a channel is
reduced, the response includes a `downsampled` field reporting the original and
returned point counts. Pass `types` to fetch only the channels you need
(e.g. `["watts","heartrate"]`) and `max_points` to change the resolution.

**Records are trimmed.** The API returns fixed, very wide records — 184 fields
per activity, 84 per interval lap, 162 on the athlete, 62 per calendar event,
47 per wellness day — with much of it null or irrelevant for any given sport.
Nine tools therefore return a compact summary by default and accept a `fields`
parameter. Measured against real data:

| Call | Default | Full | Saving |
| --- | --- | --- | --- |
| `get_athlete` | 0.4 KB | 14.9 KB | 97% |
| `list_events`, 43 events | 14.9 KB | 91.9 KB | 84% |
| `list_activities`, 14 activities | 10.9 KB | 63.8 KB | 83% |
| `get_activity`, one activity | 0.9 KB | 4.8 KB | 82% |
| `get_activity_intervals`, 12 laps | 7.4 KB | 29.0 KB | 74% |
| `get_power_curves`, type=Ride | 5.4 KB | 17.9 KB | 70% |
| `list_wellness`, 15 days | 5.4 KB | 12.9 KB | 58% |

Pass `fields` to choose exactly what you want — `fields: ["id","name",
"icu_training_load"]` returns those three and nothing else — or
`fields: ["*"]` for the complete record. Because the API's key set is fixed
(inapplicable metrics come back null rather than absent), a field name that
matches no record is treated as a typo: the call fails and lists the valid
field names so the mistake can be corrected in one step.

Three cases are worth knowing. On `get_activity_intervals` the bulk is the
nested per-lap list, so `fields` applies to the laps inside `icu_intervals` and
`icu_groups` rather than to the wrapper. On `get_athlete`, `sportSettings`
holds per-sport FTP, thresholds and zones and is 10.2 KB on its own — ask for
it explicitly with `fields: ["sportSettings"]`. On `get_power_curves` the
`activities` lookup map is roughly as large as the curves themselves and is
omitted unless you pass `["*"]`; `secs` and `values` on each curve are the
curve.

**Credentials are withheld.** The athlete record carries a slot for the
account's own API key (`icu_api_key`) and a shareable `icu_friend_invite_token`.
Both are replaced with a visible marker on every athlete response, including
when you ask for `fields: ["*"]`, so neither can reach a model's context, a
transcript or a log. `email` and `icu_date_of_birth` are personal but not
credentials, so they are merely absent from the default field set and can still
be requested by name.

**Dates are validated locally.** Date inputs must be `YYYY-MM-DD`
(`create_event` and `update_event` also accept `YYYY-MM-DDTHH:MM:SS`). Malformed values fail
immediately with an actionable message rather than producing a confusing 422
from the API.

**Requests time out and retry.** Each HTTP attempt is aborted after 30 seconds.
Responses of 429 or 5xx are retried up to 3 times with exponential backoff,
honouring a `Retry-After` header when the server sends one. Intervals.icu rate
limits both per day and per rolling 15-minute window, so this matters in
practice.

**Responses are compact.** Tool results are minified JSON; the indentation in
pretty-printed output roughly doubles token cost for no benefit.

**Structured workouts are plain text.** Workout steps are not JSON: they are
written in Intervals.icu's own workout syntax and passed in `description` on
`create_event`, `update_event`, `create_workout` or `update_workout`.
Intervals.icu parses that text server-side into a structured workout with
training load, zone targets and device sync. A description it cannot parse is
kept verbatim as free text with no load and nothing to push to a head unit —
and nothing in the API response says so. Because that failure is silent, the
syntax is published as the `intervals-icu://workout-syntax` resource, including
the traps: a bare `m` is minutes (`400m` is 400 minutes, not 400 metres —
that is `400mtr`), and an absolute pace target needs the trailing word
`pace`.

<a id="deleting-is-gated"></a>
**Deleting is gated.** `INTERVALS_DELETE_MODE` decides how much deleting the
server will do, and the gate is in the server rather than in a tool argument
because a model can set an argument itself:

| Mode | Effect |
| --- | --- |
| `safe` (default) | The delete tools are registered, but `delete_event` refuses an event dated before today — it reads the event first and compares. Planning mistakes are removable; training history is not. |
| `full` | Any event can be deleted, past included, with no pre-flight read. |
| `none` | The delete tools are not registered at all, so no client can see or call them. |

`delete_workout` is allowed in both `safe` and `full`: a library workout is a
template and carries no training history of its own. Deleting one leaves
calendar events already created from it untouched.

**Reads and writes are labelled.** 22 of the 31 tools only read, and are
annotated `readOnlyHint: true` so a client can auto-approve them. The nine
writers come in three shapes. The updates — `update_activity`,
`update_wellness`, `update_event`, `update_workout` — overwrite fields on an
existing record (`destructiveHint: true`, `idempotentHint: true`: repeating
the call leaves the record in the same state). The creates — `create_event`,
`create_workout`, `create_folder` — only ever add (`destructiveHint: false`,
but `idempotentHint: false`, since a second call adds a second entry). The
deletes — `delete_event`, `delete_workout` — are `destructiveHint: true` and
`idempotentHint: true`, since once a record is gone a second delete changes
nothing further. Every tool is `openWorldHint: true`: they all reach the
Intervals.icu API, whose contents this server does not control. A test asserts
the split, so a new tool cannot be added without a hint.

## Known gaps

- The athlete ID is fixed at startup, so coach/multi-athlete use is not possible
  without restarting with a different ID.
- Athlete zones are only reachable through `get_athlete` with
  `fields: ["sportSettings"]`, and cannot be changed from here — there is no
  sport-settings write, so FTP and threshold updates still mean the website.
- `update_activity`, `update_event`, `delete_event`, `create_workout`,
  `update_workout`, `delete_workout`, `list_folders`, `get_fitness_chart` and
  the workout-syntax resource were exercised against a live Intervals.icu
  account on 2026-10-16 and behave as documented. Still unverified there:
  `create_folder` (the account had a folder already, so none was created), and
  `INTERVALS_DELETE_MODE=full`, which is covered only by the fake-server test —
  running it against a real account risks real history for no new information.
- Changing `category` on an existing event is one-way for some kinds: once an
  event is `TARGET` or `PLAN`, the API answers 422 "Cannot change category"
  and the only route is delete-and-recreate. `SET_EFTP` additionally requires
  `type`, since eFTP is per sport. All fourteen categories in the enum were
  accepted by the live API.
- `get_fitness_chart` reports future days as `projected` by comparing dates
  against the host's local date, which is the athlete's own day only when the
  server runs on their machine — the stdio case, but not a remote one.
- No tool declares an `outputSchema`, so a client cannot validate a result
  against a promised shape before handing it to a model.
- The test suite never contacts Intervals.icu, so it pins down this server's
  behaviour rather than the upstream API's. A change in what Intervals.icu
  actually returns would not be caught by it.
- Of the 31 tools, fourteen are driven end to end against the fake API
  (`list_activities`, `get_activity_streams`, `get_power_curves`,
  `update_wellness`, `list_events`, `get_event`, `create_event`,
  `update_event`, `delete_event`, `update_activity`, `create_workout`,
  `delete_workout`, `list_folders`, `get_fitness_chart`) and two more are
  exercised on their error paths. The remaining fifteen are covered only at the
  schema level, by `tools/list`.
- `list_gear` and `list_routes` return zero records for the configured
  athlete, so they have never been exercised against real data and are
  unverified. (`list_workouts` has since been exercised live, against a
  workout created and then deleted by the verification run.)
- The `intervals-icu://workout-syntax` resource was rewritten from what the
  live parser does, not from the forum documentation, after round-tripping
  each rule through a real event. Six of its claims were wrong: the dashed
  `- 3x` repeat form (never parses), a blank line after the `Nx` marker
  (kills the repeat), clock step lengths `5:00` and `1:30:00` (dropped),
  `lap press` as a manual step (becomes prompt text), and absolute bpm
  targets in every spelling (dropped). An unparseable line is dropped on its
  own, silently, so a wrong rule shortens the workout rather than failing it.
- `get_power_curves` takes exactly one sport `type`; the API rejects a
  comma-separated list, so there is no all-sports view.

## Contributing

Issues and pull requests are welcome. Please run `npm run typecheck` and
`npm test` before opening a PR; the suite needs no credentials and no network.

## License

[MIT](LICENSE) © Travis Moe

TDQS

B3.4/5.0

Scored across 22 tools

Disambiguation4/5

Most tools target distinct resource/action pairs, especially the many activity-specific getters. The main overlap is between get_athlete and get_athlete_profile, which could be confused despite descriptions clarifying public versus full athlete data.

Naming Consistency5/5

Tool names consistently use snake_case verb_noun patterns such as get_event, list_activities, create_event, and update_wellness. The use of search_activities instead of list_activities is semantically appropriate rather than a naming inconsistency.

Tool Count4/5

With 22 tools, the surface is slightly heavy but reasonable for a sports-training API covering activities, streams, intervals, wellness, events, workouts, gear, and routes. Most tools correspond to distinct operations, though some activity-detail tools could feel numerous.

Completeness3/5

The server covers reading and analyzing activities, wellness, events, workouts, gear, and routes, plus a few write operations. However, notable gaps exist: no update/delete for events, and no create/update/delete for workouts, gear, or routes, limiting lifecycle management.

Maintenance

ActivityMaintained
ResponsivenessNo issues