{"id":"076519b1-543a-4cb1-b1ee-ce01390ce92d","slug":"christhonie-intervals-mcp","kind":"mcp","name":"intervals-mcp","summary":"Remote MCP server for the Intervals.icu training API — a claude.ai custom connector with coaching-specific tools (load rules, readiness flags, week-row notes, Plan Builder integration, signal analysis","owner":"christhonie","repoUrl":"https://github.com/christhonie/intervals-mcp","license":"MIT","licenseTier":"permissive","trust":"community","stars":0,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-06-02T11:24:38.000Z","addedAt":"2026-10-21T06:11:04.009Z","logoUrl":"https://avatars.githubusercontent.com/u/12381915?u=c8a9435016defb708a395c7bedc07d5fb4227a9d&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["productivity","communication","cloud-infra"],"tags":["claude","cycling","intervals-icu","oauth","training","typescript"],"runtime":"binary","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-23T17:32:05.169Z","sample":false,"url":"https://conduits.dev/tools/christhonie-intervals-mcp","language":"TypeScript","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# Intervals.icu MCP\n\n![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)\n![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)\n![TypeScript](https://img.shields.io/badge/TypeScript-5.7-blue.svg)\n![MCP](https://img.shields.io/badge/Model_Context_Protocol-1.27-purple.svg)\n\nA remote [Model Context Protocol](https://modelcontextprotocol.io) server for the\n[Intervals.icu](https://intervals.icu) training API, built as a **claude.ai custom\nconnector**. It exposes coaching-focused tools (fitness/PMC metrics, training\nhistory, calendar workouts, wellness, week-row notes, Plan Builder targets, raw\nactivity streams, and a signal-analysis toolkit) with the coaching business logic\nembedded server-side, so the assistant gets a complete result from a single tool\ncall.\n\nIt's built as an alternative to existing Intervals.icu MCP servers, adding\ncapabilities they typically don't expose: week-row calendar notes, writes to\ncompleted activities, and Plan Builder `TARGET`/`PLAN`/`apply-plan` integration.\n\n> **Status — personal deployment.** This is a single-athlete deployment built\n> around one coaching workflow (the athlete ID and OAuth client are configured\n> per-instance), not a turn-key multi-tenant service. The source is shared as a\n> reference for others building Intervals.icu MCP servers — self-hosting is\n> possible but assumes you're comfortable with the OAuth 2.1 + Kubernetes setup\n> described below. There is no public hosted endpoint.\n\n## Credits\n\nThis project was **built from scratch in TypeScript**, but it stands on the\nshoulders of the Intervals.icu MCP community. Credit and thanks to:\n\n- **[hhopke/intervals-icu-mcp](https://github.com/hhopke/intervals-icu-mcp)** (MIT)\n  — the most mature community server (Python/FastMCP). Used as the reference for\n  endpoint coverage, tool naming, and parameter shapes.\n- **[VSidhArt/intervals-mcp](https://github.com/VSidhArt/intervals-mcp)** — for\n  the OpenAPI-spec exploration approach.\n\nWe built fresh rather than forking because claude.ai remote connectors require\nthe server to be an **OAuth 2.1 + PKCE authorization server** (no bearer-token\noption in the connector UI), which the community servers do not implement — that\nlayer is reused from our own `fatsecret-mcp` / `hevy-mcp` deployments. See\n[the decision log](docs/modules/ROOT/pages/decision-log.adoc) for the full rationale.\n\n## Architecture\n\n```\n                  OAuth 2.1 + PKCE              HTTP Basic (API_KEY:key)\nclaude.ai ─────► /authorize, /token ──────► /mcp ─────────► intervals.icu API\n                 (this server is the                │\n                  authorization server)             │\n                                          Deployment (1 replica, mcp namespace)\n                                          envFrom: intervals-mcp-secrets\n```\n\n- **Transport:** Streamable HTTP (`src/http-server.ts`). A stdio entry\n  (`src/index.ts`) is kept for local debugging.\n- **Auth to claude.ai:** OAuth 2.1 + PKCE via the MCP SDK auth router and a\n  minimal single-client provider (`src/oauth-provider.ts`).\n- **Auth to Intervals.icu:** HTTP Basic — username literal `API_KEY`, password is\n  the personal API key.\n- **API client:** a small hand-written typed client (`src/intervals-client.ts`).\n  The OpenAPI spec is committed as `openapi-spec.json` (the field-name source of\n  truth); see the decision log for why we don't generate the client.\n\n## Tools\n\n<!-- TOOLS:START — generated check: `npm test` fails if this table drifts from the registered tools (test/readme-tools.test.ts). Edit the rows by hand; keep them in sync with src/server.ts. -->\n\n| Tool | Phase | Notes |\n|---|---|---|\n| `get_fitness_metrics` | 1 | CTL/ATL/TSB/ramp/eFTP + overreachRisk/fatigueSpike flags |\n| `get_training_history` | 1 | rides vs gymSessions, Strava stubs filtered |\n| `get_activity_detail` | 1 | non-null fields only |\n| `get_events` | 1 | category filter; `completed` flag |\n| `get_wellness` | 1 | + `hrvTrendDown` flag |\n| `push_workout` | 1 | applies Rule 1 (WeightTraining load) |\n| `update_event` | 1 | applies Rule 1; infers type from existing event |\n| `push_note` | 1 | week-row notes via `for_week` + exclusive `end_date_local` |\n| `update_note` | 1 | in-place PUT update of an existing NOTE event |\n| `get_power_curves` | 1 | best power efforts over standard durations; ⚠ endpoint params need live validation |\n| `get_training_summary` | 1 | volume/load aggregates |\n| `update_activity` | 2 | write load/description to a completed activity |\n| `create_activity` | 2 | manual activity, `source=MANUAL` |\n| `get_weekly_targets` | 3 | Plan Builder `TARGET` + compliance calc |\n| `push_weekly_target` | 3 | write/update a weekly `TARGET` |\n| `push_sport_targets` | 3 | per-sport weekly `TARGET`s (delete-then-recreate); empty array clears the week |\n| `list_plan_folders` | 3 | discover `folder_id` for `apply_plan` |\n| `apply_plan` | 3 | apply a plan folder to the calendar (Monday start) |\n| `update_sport_settings` | 3 | FTP/LTHR/max-HR/zones — ⚠ preview-by-default (needs `confirm:true`) |\n| `update_activity_type` | 3 | CTL/ATL contribution multipliers — ⚠ preview-by-default |\n| `push_plan_block` | 9 | PLAN phase bar spanning the Plan page (bare-hex colour) |\n| `get_activity_streams` | 10 | raw 1 Hz time-series (watts/hr/smo2/…), positionally aligned |\n| `detect_threshold_crossings` | 11 | times a stream crosses a threshold value/direction |\n| `detect_peaks_nadirs` | 11 | prominence-filtered local maxima/minima |\n| `compute_epoch_stats` | 11 | per-epoch summary stats; `exclude_windows` |\n| `compute_correlation_window` | 11 | Pearson `r` over a window, with optional lag scan |\n| `detect_plateaus` | 11 | sustained stable/elevated periods |\n| `smooth_stream` | 11 | trailing rolling-mean derived stream (returns a handle) |\n| `compute_derivative` | 11 | 1st/2nd derivative derived stream |\n| `extract_segment` | 11 | materialise raw/smoothed values over a window |\n| `align_events_to_stream` | 11 | event-locked windows + mean response shape |\n\n<!-- TOOLS:END -->\n\nPhases 4–8 had no net new tools (covered by RELEASE.md and the decision log).\nPhase 11 tools accept either a raw stream name or a derived handle (e.g.\n`smo2~mean:10`), keeping the high-fidelity series server-side between calls.\n\n### Business rules (server-side)\n\n- **Rule 1 — WeightTraining load:** when `rpe` and `duration_minutes` are supplied\n  without an explicit `icu_training_load`, load = `round(rpe × minutes × 0.15)`.\n  Never auto-computed for power/HR sports (it would corrupt TSS).\n- **Tags** pass through unchanged.\n- **Preview-by-default** on the riskiest writes (`update_sport_settings`,\n  `update_activity_type`): without `confirm:true` they return a diff and write\n  nothing.\n- **Event colours:** PLAN events require **bare hex** (the leading `#` is\n  stripped); NOTE events tolerate `#`.\n\n## Development\n\n```bash\nnpm install\ncp .env.sample .env   # fill in INTERVALS_API_KEY, INTERVALS_ATHLETE_ID, OAUTH_*\nnpm run type-check\nnpm run build\nnpm run start:stdio   # local stdio server\nnpm run inspect       # MCP Inspector\n```\n\n## Deployment\n\nContainer + Kubernetes (mcp namespace, ArgoCD/cert-manager/nginx). See\n`k8s/`, `deploy/argocd/`, and the deployment runbook in the docs. Target\nendpoint: `https://icu-mcp.christhonie.co.za/mcp`.\n\n## Documentation\n\nRequirements, design, and the decision log live under [`docs/`](docs/) as an\nAntora ROOT module.\n\n## OAuth state persistence (optional, Redis)\n\nBy default the OAuth provider keeps codes/tokens in memory, so a pod rollout\nforces claude.ai to re-authenticate. Set `REDIS_URL` to persist them in Redis\ninstead — tokens then survive rollouts (no re-auth after a deploy). Unset =\nin-memory. If Redis is unreachable the store degrades gracefully (behaves as\nin-memory) rather than failing auth. See `src/oauth-store.ts`.\n\n### Reusing this in other MCP servers (template)\n\n`src/oauth-store.ts` is generic and the `MinimalOAuthProvider` changes carry no\nIntervals-specific logic, so the pattern ports directly to the sibling MCP\nservers (`fatsecret-mcp`, `hevy-mcp`):\n\n1. Copy `src/oauth-store.ts` verbatim.\n2. `npm install ioredis`.\n3. In the provider, replace the in-memory `Map`s with the injected `OAuthStore`\n   (codes/access/refresh) and make the methods `await` the store (see this\n   repo's `src/oauth-provider.ts`).\n4. In the HTTP entry point: `const store = createOAuthStore(\"<app>:oauth\")` and\n   pass `store` into the provider. **Use an app-unique prefix** so multiple\n   servers can share one Redis without key collisions.\n5. Add `REDIS_URL` to the app's K8s Secret (a distinct Redis DB number per app\n   is recommended). `envFrom: secretRef` injects it automatically.\n\nThe MCP transport/session remains in-memory by design — this template persists\nonly the OAuth layer (the part that otherwise forces re-authentication).\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n"}