intervals-mcp
by ale7canna
README.md
# intervals-mcp
Server MCP per [intervals.icu](https://intervals.icu): legge i dati di allenamento e crea
allenamenti strutturati sul calendario, da cui intervals.icu li spinge sull'orologio
(Garmin, Wahoo, Zwift, Coros, Suunto).
Usa l'API pubblica ufficiale (`https://intervals.icu/api/v1`, Basic auth con API key).
## Setup
```bash
npm install
npm run build
cp .env.example .env # e incolla la tua API key
```
L'API key si trova su intervals.icu → **Settings** → **Developer Settings** → *API Key*.
L'athlete id (`i123456`) viene rilevato automaticamente dalla key; se serve forzarlo, c'è
`INTERVALS_ATHLETE_ID` nel `.env`.
Verifica che tutto risponda:
```bash
npm run smoke # solo lettura
npm run smoke -- --write # crea un allenamento di test domani, lo verifica e lo cancella
```
### Registrazione in Claude Code
```bash
claude mcp add intervals -- node /percorso/assoluto/intervals-mcp/dist/stdio.js
```
La key non va nella config del client: il server legge il `.env` accanto al package.
In alternativa, per tenerla nella config: `claude mcp add intervals --env INTERVALS_API_KEY=... -- node .../dist/stdio.js`.
Per Claude Desktop, in `claude_desktop_config.json`:
```json
{
"mcpServers": {
"intervals": {
"command": "node",
"args": ["/percorso/assoluto/intervals-mcp/dist/stdio.js"]
}
}
}
```
In sviluppo: `npm run dev` (tsx, senza build).
## Deploy remoto su Vercel (opzionale)
Serve solo se vuoi usarlo **senza il Mac acceso** — da claude.ai, dal telefono, o per condividerlo.
In locale lo stdio resta più semplice e la chiave non si muove dalla tua macchina.
`api/mcp.ts` è una Vercel Node function che espone lo stesso server su Streamable HTTP, in
modalità **stateless** (server e transport costruiti per richiesta, `enableJsonResponse`): l'unica
che funziona su serverless, senza Redis né sessioni in memoria. Nessun framework: niente Next.js,
solo la function e l'SDK ufficiale.
Non serve creare nulla dalla dashboard: `vercel link` crea il progetto. L'ordine conta, perché
`env add` funziona solo su una directory già linkata.
```bash
npm i -g vercel
vercel login
vercel link # crea/collega il progetto (scrive .vercel/, gitignorato)
npm run token # genera il segreto, copialo
vercel env add MCP_AUTH_TOKEN production
vercel env add INTERVALS_API_KEY production
vercel --prod # primo deploy
```
Se vuoi che funzionino anche i deploy di preview, ripeti gli `env add` per `preview`: le variabili
sono per-ambiente, e un preview senza di esse risponde 500.
`vercel.json` dichiara `outputDirectory: "public"` con una pagina statica minima: senza una
directory di output, un progetto con un `buildCommand` fa fallire il build
("No Output Directory named 'public' found").
Env var richieste sul server: `INTERVALS_API_KEY`, `MCP_AUTH_TOKEN`, opzionale
`INTERVALS_ATHLETE_ID`. **La API key di intervals.icu resta lato server**: nel client finisce solo
il token.
### Due modi per autenticarsi, perché i client differiscono
```bash
# Claude Code: header custom (supportati)
claude mcp add --transport http intervals-remote https://<deploy>/mcp \
--header "Authorization: Bearer <token>"
```
Per i client che non mandano header custom — su claude.ai l'auth via request header esiste ma è in
beta/rollout — il token va nel path, che è solo un URL:
```
https://<deploy>/mcp/<token>
```
Le rewrite in `vercel.json` mappano `/mcp` e `/mcp/:token` sulla function. Il server accetta il
token da `Authorization: Bearer`, `x-api-key`, `x-auth-token`, dal path o da `?token=`.
**Preferisci l'header:** un segreto nel path finisce nei log di richiesta. Il confronto è a tempo
costante su digest SHA-256, il 401 risponde con `WWW-Authenticate` e non rivela nulla, e il server
**si rifiuta di partire** senza `MCP_AUTH_TOKEN` (o con un token più corto di 24 caratteri):
meglio un 500 che un endpoint aperto che scrive sul tuo account.
Il token vale quanto la API key — chi lo ha legge e scrive sul tuo intervals.icu. Rotazione =
cambio env var + redeploy. Per condividere il server con altri servirebbe OAuth vero, con il
client_id da richiedere a david@intervals.icu.
`npm test` copre i casi di autenticazione (token valido/errato/assente, le varie vie, rotta non
confusa per token).
## Il coach
Le regole operative (soglie da verificare prima di scrivere, sintassi, conferma prima di scrivere
in calendario) stanno nelle `instructions` del server in `src/server.ts`: vengono inviate a
**qualunque** client all'`initialize`, quindi valgono anche da claude.ai e dal telefono senza
ripeterle da nessuna parte.
Sopra ci va il "personaggio", che è per-client:
- **claude.ai** — crea un Project, attiva il connector e incolla nelle istruzioni il contenuto di
[`docs/coach-project-instructions.md`](docs/coach-project-instructions.md). È in italiano perché
fissa anche la lingua delle risposte.
- **Claude Code** — `.claude/agents/coach.md`, subagent con `model: opus`.
Entrambi definiscono un coach che prende l'iniziativa: legge form, storico e calendario da sé,
propone con il motivo di ogni seduta, e scrive sul calendario solo dopo conferma.
**Nessun vincolo di metodo, per scelta.** I prompt contengono solo ciò che l'agent non può dedurre
— i fatti sull'atleta (rientro da infortunio, caldo, carico calcolato da HR per scelta, soglia di
passo non verificata), le regole tecniche di scrittura e il limite di sicurezza sul dolore. Volumi,
intensità e progressione le decide l'agent, e se gli serve un vincolo lo chiede. I numeri li
rilegge ogni volta dai tool, così non invecchiano nel prompt.
Modello: Opus per pianificare e analizzare, Sonnet per le domande di tutti i giorni.
## Tool disponibili
### Lettura
| Tool | Cosa fa |
|---|---|
| `get_athlete` | Profilo + soglie per sport (FTP, indoor FTP, LTHR, max HR, threshold pace, zone) + configurazione push Garmin |
| `list_activities` | Attività completate in un intervallo di date (default: ultimi 30 giorni) |
| `get_activity` | Dettaglio di una attività: load, intensità, tempo in zona, decoupling, EF, running dynamics |
| `get_activity_intervals` | Intervalli/lap rilevati, con potenza, HR, passo, cadenza, load per ripetuta |
| `search_activities` | Ricerca per nome o `#tag` |
| `get_wellness` | Fitness (CTL), Fatigue (ATL), Form (TSB), peso, HRV, sonno, readiness… (default: 14 giorni) |
| `list_calendar_events` | Allenamenti pianificati e altri eventi; con `resolve=true` mostra i target già convertiti in watt/bpm/m·s⁻¹ |
| `get_event` | Un singolo evento del calendario |
| `workout_syntax_guide` | Riferimento completo della sintassi workout di intervals.icu |
### Scrittura
| Tool | Cosa fa |
|---|---|
| `create_workout` | Crea un allenamento strutturato sul calendario e (default) lo rilegge con i target risolti per verifica |
| `create_workouts` | Versione bulk: una settimana o un blocco in una sola chiamata, con `upsert` su `external_id` |
| `update_workout` | Modifica un evento esistente (solo i campi passati) |
| `delete_event` | Cancella un evento |
| `set_wellness` | Aggiorna i valori wellness di una data |
| `check_garmin_push` | Diagnostica del push: upload attivo?, ultimo upload, range applicati, `push_errors` sugli allenamenti in arrivo |
## Come arrivano gli allenamenti sul Garmin
1. `create_workout` scrive un evento `category: "WORKOUT"` sul calendario; il campo
`description` contiene l'allenamento nella sintassi testuale di intervals.icu.
2. intervals.icu lo interpreta e ne costruisce la struttura (`workout_doc`).
3. Se su intervals.icu → Settings, nel box Garmin, è attivo **Upload planned workouts**
(con Garmin Connect autorizzato), i prossimi giorni di calendario vengono caricati su
Garmin Connect e da lì arrivano all'orologio.
4. Eventuali problemi finiscono in `push_errors` sull'evento → `check_garmin_push`.
Scrivendo i target in **percentuale** (di FTP / LTHR / max HR / threshold pace) l'allenamento
segue automaticamente le soglie correnti dell'atleta: la conversione in watt/bpm/passo avviene
al momento del push, e Garmin riceve un *range* (ampiezza configurabile nelle impostazioni
Garmin di intervals.icu).
Esempio di `description` per una sessione di corsa:
```
Warmup
- 12m 70% Pace
Main set 5x
- 1km 100% Pace
- 90s 60% Pace
Cooldown
- 8m 65% Pace
```
Attenzione: `m` significa **minuti**, i metri si scrivono `mtr` (`400mtr`).
La sintassi completa è in `src/workout-syntax.ts`, esposta anche come tool
(`workout_syntax_guide`) e come risorsa MCP (`intervals://workout-syntax`).
## Troubleshooting: l'allenamento arriva sul Garmin senza target
Sintomo: sull'orologio gli step ci sono, con distanze e nomi giusti, ma ognuno mostra
"No Target". Su intervals.icu il `workout_doc` sembra perfetto.
Causa: **manca la soglia del tipo di target usato** nelle sport settings di quello sport
(threshold pace per il passo, FTP per la potenza). In quel caso intervals.icu elimina i target
dal file che manda al dispositivo — **anche quando l'allenamento usa valori assoluti**, dove
la soglia non servirebbe a niente. L'HR non ne soffre se LTHR o max HR sono impostate.
Verificato decodificando il FIT generato:
```bash
curl -s -u "API_KEY:$INTERVALS_API_KEY" \
"https://intervals.icu/api/v1/athlete/0/events/<eventId>/download.fit" -o w.fit
python3 scripts/fitdump.py w.fit
```
Senza threshold pace ogni step esce `target=open`; impostata la soglia, lo stesso evento
esporta `target=speed` con i range corretti. `create_workout` ora fa questo controllo da sé e
restituisce un `device_export_warning` prima che il problema arrivi all'orologio.
Nota: l'upload parte a fronte di una **modifica dell'evento**. Sistemare la soglia non
ricarica necessariamente un allenamento già spinto — va toccato l'evento (basta riscriverne la
description) per far ripartire il push.
## Struttura
```
src/
index.ts bootstrap del server MCP (stdio) + risorsa sintassi
client.ts client HTTP: Basic auth, athlete id, errori parlanti
format.ts riduzione dei payload (Activity ha 183 campi) e formattazione
dates.ts date locali dell'atleta
workout-syntax.ts cheat sheet + guida completa
tools/ athlete, activities, wellness, events
scripts/smoke.ts verifica end-to-end sull'API reale
```
## Note sull'API
- Auth: Basic, username letterale `API_KEY`, password = la tua key.
- Le date sono sempre **locali** dell'atleta, senza timezone (`2026-08-20`, `2026-08-20T18:30:00`).
- Le liste accettano `fields=` per ridurre il payload: qui è già usato con set di campi curati.
- Spec OpenAPI completa (117 endpoint): <https://intervals.icu/api/v1/docs/>.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues