Skip to main content
Glama
intervals-icu-chatgpt-mcp

intervals-icu-chatgpt-mcp

by sthoelscher
README.md
# intervals-icu-chatgpt-mcp

Let ChatGPT read your training data, without screenshots.

A small, **read-only** [MCP](https://modelcontextprotocol.io) server that connects ChatGPT to your
[intervals.icu](https://intervals.icu) account, and through it to everything your Garmin watch records:
activities with laps and streams, heart rate and pace zones, fitness and fatigue, sleep, HRV, resting heart
rate and your training calendar. It brings its own OAuth login, so you can host it on the public internet and
use it from ChatGPT on the web, on macOS and on your phone.

```
Garmin watch ─► Garmin Connect ─► intervals.icu ─► this server (fly.io) ─► ChatGPT
                 (official sync)    (API key)        (OAuth, read-only)
```

Ask things like *"How was my long run on Saturday?"*, *"Compare this week's easy runs"* or *"Am I recovered
enough for intervals tomorrow?"* and ChatGPT fetches the data itself.

> Not affiliated with Garmin, intervals.icu or OpenAI. A hobby project, maintained when time allows.

## Why this one

There are several good MCP servers for intervals.icu already. This one is built for a narrower case:

- **Read-only by construction.** No tool writes, and the API client can only send `GET`. Your calendar,
  activities and settings cannot be changed through ChatGPT.
- **Safe to expose.** It ships a complete OAuth 2.1 flow (dynamic client registration, PKCE, short-lived
  tokens) with a password login page. Without it, anyone who learns the URL could read your health data.
- **Stateless.** No database. It runs on the smallest fly.io machine that stops when idle, which typically
  costs nothing or close to it.
- **Answers sized for a chat model.** Empty fields are dropped, streams are downsampled, and aerobic
  decoupling is computed from the raw data, so it also works for runs without a power meter.

Garmin is only reached through intervals.icu's official Garmin sync. There is no scraping and no Garmin
login involved.

## What you need

- An intervals.icu account (free) connected to Garmin Connect
- A ChatGPT plan that allows custom connectors (developer mode)
- A [fly.io](https://fly.io) account and `flyctl`, or any other host that runs a Docker image behind HTTPS
- Node.js 22 or newer, only for local development

## Setup

### 1. intervals.icu

1. Create an account and connect Garmin Connect in the settings. Enable activities and wellness. Older
   data may need a one-off import.
2. Near the bottom of the settings page (*Developer Settings*), note your **athlete id** (starts with `i`)
   and create an **API key**. Treat the key like a password: it allows full access to your intervals.icu account, even though
   this server only reads.

### 2. Deploy to fly.io

```bash
git clone https://github.com/sthoelscher/intervals-icu-chatgpt-mcp.git
cd intervals-icu-chatgpt-mcp

cp fly.example.toml fly.toml           # then replace YOUR-APP-NAME (twice)
fly apps create YOUR-APP-NAME

fly secrets set --stage -a YOUR-APP-NAME \
  INTERVALS_API_KEY='your-api-key' \
  INTERVALS_ATHLETE_ID='i123456' \
  JWT_SECRET="$(openssl rand -hex 32)" \
  LOGIN_PASSWORD="$(openssl rand -base64 18)"    # or your own long password

fly deploy --ha=false
```

`curl https://YOUR-APP-NAME.fly.dev/health` should answer `ok`. If you let fly generate the password,
read it once with `fly ssh console -C 'printenv LOGIN_PASSWORD'` and store it in your password manager.

`--ha=false` keeps it to one machine. Two machines would double the login rate limit, which is counted per
machine.

Set the secrets from your own terminal and keep them out of chats, issues and shell history you share.

### 3. Connect ChatGPT

On chatgpt.com, open *Settings → Apps*, enable developer mode under *Advanced*, and create a new app or
connector (the labels change from time to time):

- URL: `https://YOUR-APP-NAME.fly.dev/mcp`
- Authentication: OAuth

ChatGPT opens a login page. Enter your `LOGIN_PASSWORD` and allow access. A connector created on chatgpt.com
is available in the ChatGPT apps as well. The macOS app can also add it directly under *Settings → Plugins →
MCPs → Add → Streamable HTTP*.

Start with: *"Get my training overview."*

After a deploy that renames or removes tools, start a new chat. Existing chats keep the old tool list.

## Tools

27 tools, all read-only.

| Area | Tools |
|---|---|
| Overview | `get_overview`: today's date in your timezone, last 7 days of activities and wellness, next 7 days of the calendar |
| Athlete | `get_athlete` (thresholds and zones per sport), `get_period_summary` |
| Activities | `list_activities`, `search_activities`, `get_activity`, `get_activity_intervals`, `get_activity_streams`, `get_activity_histogram`, `get_activity_curve`, `get_activity_best_efforts`, `get_activity_decoupling`, `get_activity_weather`, `get_activity_segments`, `get_activity_notes`, `search_intervals` |
| Wellness | `get_wellness`: fitness (CTL), fatigue (ATL), resting HR, HRV, sleep, readiness, weight, steps and more |
| Performance | `get_best_curves` (power, pace, HR), `get_power_model`, `get_power_hr_curve` |
| Calendar | `list_events`, `get_event`, `get_training_plan`, `list_workouts`, `get_workout` |
| Other | `list_gear`, `get_weather_forecast` |

Answers drop null and empty fields and are capped at 120,000 characters.

## Security model

- **One user.** The server serves exactly one intervals.icu account, configured through secrets. The login
  page asks for `LOGIN_PASSWORD`. Failed attempts are limited to 10 per 15 minutes per IP and 30 per hour in
  total. Use a generated password (`openssl rand -base64 18`), not a memorable one.
- **Known clients only.** Dynamic client registration accepts only redirect targets on ChatGPT's origins
  (`https://chatgpt.com`, `https://chat.openai.com`) and loopback addresses. This stops anyone from
  registering their own client and sending you a link to your real login page (consent phishing). Change the
  list with `ALLOWED_REDIRECT_ORIGINS`. The login page shows where access will go.
- **OAuth 2.1.** Public clients with PKCE (S256). Authorization codes are valid for 5 minutes and single use.
  Access tokens last 1 hour and are bound to this server's `/mcp` URL. Refresh tokens last 30 days, and no
  chain of refreshes outlives 180 days after you entered the password.
- **Stateless tokens.** Client ids, codes and tokens are JWTs signed with `JWT_SECRET` (HS256). Nothing is
  stored, so an auto-stopped machine keeps working. Because anyone can obtain a signed client id, the server
  refuses to start unless `JWT_SECRET` is random and at least 32 bytes (`openssl rand -hex 32`).
  **Rotating `JWT_SECRET` revokes every token and client at once.** That is the emergency switch, and the
  only way to revoke.
- **Read-only.** The intervals.icu client has no code path for anything but `GET`.
- **Headers.** The login page cannot be framed, is not cached and sends no referrer. HSTS is on for HTTPS.
- **Logs** contain paths, status codes, durations, tool names, registration origins and login outcomes.
  Never query strings, tokens, passwords or training data.
- **Proxy.** `TRUST_PROXY=1` (default) is right on fly.io. Without a proxy in front, set it to `0`, otherwise
  clients can fake their IP and get around the per-IP login limit.

If you find a security problem, please open a private security advisory on GitHub instead of an issue.

## Local development

```bash
npm install
cp .env.example .env    # INTERVALS_API_KEY and INTERVALS_ATHLETE_ID
set -a; . ./.env; set +a

npm run smoke           # calls every tool once against your account (stdio)
npm run inspect         # MCP Inspector
npm run dev:stdio       # stdio server, e.g. for the ChatGPT macOS app's STDIO option
```

To run the HTTP server with OAuth locally:

```bash
PUBLIC_URL=http://localhost:8787 LOGIN_PASSWORD=local-test-password-123 JWT_SECRET=$(openssl rand -hex 32) \
  npm run dev:http
# in a second shell
BASE=http://localhost:8787 LOGIN_PASSWORD=local-test-password-123 npm run oauth-e2e
```

`oauth-e2e` walks the whole flow the way ChatGPT does: discovery, registration (including refused foreign
redirect targets), login, wrong password, foreign resource, PKCE, single-use codes, tampered tokens and
refresh.

| Variable | Needed for | Meaning |
|---|---|---|
| `INTERVALS_API_KEY` | both | intervals.icu API key |
| `INTERVALS_ATHLETE_ID` | both | intervals.icu athlete id, e.g. `i123456` |
| `PUBLIC_URL` | HTTP | public base URL, e.g. `https://YOUR-APP-NAME.fly.dev` |
| `LOGIN_PASSWORD` | HTTP | password on the login page, at least 12 characters |
| `JWT_SECRET` | HTTP | signing key, random, at least 32 bytes (`openssl rand -hex 32`) |
| `ALLOWED_REDIRECT_ORIGINS` | HTTP | optional, comma separated, default ChatGPT's origins; loopback is always allowed |
| `TRUST_PROXY` | HTTP | optional, proxies in front, default `1` (fly.io) |
| `PORT` | HTTP | default `8787` |

## Limitations

- Only reads. Planning workouts into your calendar is deliberately not supported.
- One athlete per deployment.
- Tested with ChatGPT (web and macOS). Other MCP clients with OAuth support should work but are untested.
- Pace based decoupling is distorted by hills, wind and stops. Compare it between similar routes.

## License

MIT