Strava Planner MCP
# Strava Planner MCP
A personal, read-only MCP server that exposes Strava training data to Claude. It supports:
- Local stdio for MCP clients that can spawn a process.
- Authenticated Streamable HTTP at `/mcp` for Claude Web and other remote MCP clients.
- Docker deployment behind an HTTPS-terminating Nginx reverse proxy.
The server retrieves and reshapes data. Claude performs coaching, planning, and analysis.
## Architecture
```text
Claude Web
-> HTTPS Streamable HTTP + OAuth 2.1
-> Nginx
-> 127.0.0.1:8000
-> Docker: TypeScript MCP server
-> Strava REST API
```
The remote transport is stateless Streamable HTTP. This keeps a single-instance personal deployment simple and avoids sticky-session requirements. Stdio remains available through `npm start`.
## Requirements
- Node.js 20 or newer for local development.
- A Strava API application.
- Docker and Docker Compose for deployment.
- A DNS name and trusted HTTPS certificate for Claude Web.
- Claude Pro, Max, Team, or Enterprise with custom connectors enabled.
## Install And Test
```bash
npm ci
npm run typecheck
npm test
npm run build
```
## Strava Configuration
Create an application at <https://www.strava.com/settings/api>. Local browser authorization uses these scopes:
- `read`
- `activity:read_all`
- `profile:read_all`
Copy the environment example:
```bash
cp .env.example .env
```
Generate independent secrets:
```bash
openssl rand -base64 32
openssl rand -hex 16
openssl rand -base64 48
openssl rand -base64 48
```
Use them for `STRAVA_TOKEN_ENCRYPTION_KEY`, `MCP_OAUTH_CLIENT_ID`, `MCP_OAUTH_CLIENT_SECRET`, and `MCP_OAUTH_TOKEN_SIGNING_KEY` respectively.
There are two ways to seed Strava authorization:
1. Set `STRAVA_REFRESH_TOKEN` from the Strava API application page. On first API use, the server exchanges it and writes the current token set to the encrypted token file.
2. Run `npm run auth` on a machine with a browser, then preserve the generated encrypted token file and the same encryption key.
The encrypted token file takes precedence over `STRAVA_REFRESH_TOKEN`. When Strava rotates the refresh token, the new token is encrypted and persisted. In Docker it lives in the `strava-mcp-data` volume.
### Token Persistence, Backup, And Recovery
The encrypted token file is written **atomically** (temp file, `fsync`, then `rename`) with `0600` permissions, so an interrupted write can never truncate the only good copy. Concurrent refreshes within the process are serialized.
Because Strava rotates refresh tokens, this file is the source of truth once seeded. If it becomes corrupt or is decrypted with the wrong key, the server **fails loudly** with a clear operational error and does **not** overwrite it — so recoverable state is never silently destroyed.
- **Back up** the encrypted token file together with the exact `STRAVA_TOKEN_ENCRYPTION_KEY` (the file is useless without the key). In Docker, back up the `strava-mcp-data` volume.
- **Recover** by restoring the file and key, or by re-seeding: set a fresh `STRAVA_REFRESH_TOKEN` (or run `npm run auth`) and delete the unreadable file so the seed path can run.
- Losing both the file and a valid seed refresh token means re-authorizing Strava from scratch.
## Environment Variables
Required for all modes (`STRAVA_REFRESH_TOKEN` may be omitted when a valid encrypted token file already exists):
| Variable | Purpose |
| --- | --- |
| `STRAVA_CLIENT_ID` | Strava application client ID. |
| `STRAVA_CLIENT_SECRET` | Strava application client secret. |
| `STRAVA_TOKEN_ENCRYPTION_KEY` | Encrypts the persisted Strava token file. |
| `STRAVA_REFRESH_TOKEN` | Seeds Docker/remote authorization if no encrypted token file exists. One of these two sources is required. |
Required for authenticated remote mode:
| Variable | Purpose |
| --- | --- |
| `MCP_PUBLIC_URL` | Exact public endpoint, e.g. `https://mcp-strava.example.com/mcp`. |
| `MCP_OAUTH_CLIENT_ID` | Fixed OAuth client ID entered in Claude. |
| `MCP_OAUTH_CLIENT_SECRET` | Fixed OAuth client secret entered in Claude. |
| `MCP_OAUTH_TOKEN_SIGNING_KEY` | Signs MCP access and refresh tokens. |
Important optional variables:
| Variable | Default |
| --- | --- |
| `MCP_HOST` | `0.0.0.0` (production behind Nginx). Use `127.0.0.1` for authless local dev. |
| `MCP_PORT` | `8000` |
| `MCP_AUTH_ENABLED` | `true` |
| `MCP_ALLOW_INSECURE_BINDING` | `false`. When `true`, permits authless mode on a non-loopback host. |
| `MCP_ALLOWED_HOSTS` | Public hostname plus localhost names. |
| `MCP_TRUST_PROXY` | `true` |
| `MCP_OAUTH_REDIRECT_URIS` | `https://claude.ai/api/mcp/auth_callback` |
| `STRAVA_TOKEN_PATH` | `~/.strava-planner-mcp/tokens.enc.json` outside Docker; `/data/tokens.enc.json` in Compose. |
| `ATHLETE_CONTEXT_PATH` | Unset outside Docker; `/config/athlete-context.json` in Compose. |
| `STRAVA_CACHE_TTL_SECONDS` | `900` |
| `STRAVA_CACHE_MAX_ENTRIES` | `500` (bounded LRU cache size). |
| `STRAVA_MAX_RETRIES` | `4` |
| `STRAVA_REQUEST_TIMEOUT_MS` | `30000` |
| `HALF_MARATHON_TRAINING_START_DATE` | Unset. Optional `YYYY-MM-DD` fallback for `getHalfMarathonTrainingContext`. |
Production-critical secrets (`STRAVA_CLIENT_SECRET`, `STRAVA_TOKEN_ENCRYPTION_KEY`, `MCP_OAUTH_CLIENT_SECRET`, `MCP_OAUTH_TOKEN_SIGNING_KEY`) are validated at startup: the server refuses to boot if they still contain the `.env.example` placeholder text, and the signing/encryption/OAuth secrets must be at least 16 characters.
See [.env.example](./.env.example) for the complete list, grouped by local-dev / Docker / production.
## Local Linux Development
For a quick authless loopback test, set this only in a development `.env`:
```bash
MCP_AUTH_ENABLED=false
MCP_HOST=127.0.0.1
MCP_ALLOWED_HOSTS=localhost,127.0.0.1
```
Authless mode **refuses to start** on a non-loopback host (`MCP_HOST` other than `127.0.0.1`/`::1`/`localhost`) so it cannot accidentally expose unauthenticated tools to the LAN. Override deliberately with `MCP_ALLOW_INSECURE_BINDING=true` only if you know what you are doing.
Start Streamable HTTP:
```bash
npm run dev:http
```
Check liveness and readiness:
```bash
curl http://127.0.0.1:8000/health # cheap liveness, always 200 when running
curl http://127.0.0.1:8000/ready # 200 only when a usable Strava token source exists
```
Run the official MCP Inspector:
```bash
npm run inspect:http
```
Select Streamable HTTP and use `http://127.0.0.1:8000/mcp`.
Do not use `MCP_AUTH_ENABLED=false` on a public interface. To test OAuth locally, use `MCP_PUBLIC_URL=http://localhost:8000/mcp` and configure all `MCP_OAUTH_*` values.
For stdio debugging:
```bash
npm run dev
# or, after npm run build
npm start
```
## Personal Athlete Context
Optional goals, heart-rate zones, constraints, and preferences live outside source code. Create the local file:
```bash
cp config/athlete-context.example.json config/athlete-context.json
```
Edit it as needed. `getAthleteContext` returns it to Claude. The actual file is gitignored and mounted read-only in Docker.
## Docker Deployment
The image uses the multi-architecture `node:22-bookworm-slim` base and works on ARM64/aarch64.
**Production deployment on the VM (GHCR image + central proxy) is documented step-by-step in [DEPLOY.md](./DEPLOY.md).** In short: GitHub Actions ([.github/workflows/build.yml](./.github/workflows/build.yml)) builds and pushes `ghcr.io/mohith1612/strava-planner-mcp` (multi-arch, incl. `linux/arm64`); the VM runs [compose.prod.yaml](./compose.prod.yaml) on the shared `proxy` network with no published ports, and `proxy_nginx` reaches it by container name.
For a **local Docker smoke test** (builds the image locally, publishes on loopback):
```bash
docker compose build
docker compose up -d
docker compose ps # "healthy" once /ready passes
curl http://127.0.0.1:8000/health
curl http://127.0.0.1:8000/ready
docker compose logs -f strava-mcp
docker compose down # add -v ONLY to delete the token volume
```
The container HEALTHCHECK polls `/ready` (which verifies a usable token source without calling Strava). The container runs as the non-root `node` user, drops Linux capabilities, enables `no-new-privileges`, and handles `SIGTERM`.
## Nginx And HTTPS
On the VM, Nginx is the shared central proxy at `/opt/proxy/`. Drop [nginx/strava-mcp.conf.example](./nginx/strava-mcp.conf.example) in as `/opt/proxy/nginx/conf.d/strava.conf` (it already targets `strava.mohith16.com` → `strava_mcp:8000`) and reload:
```bash
docker compose -f /opt/proxy/docker-compose.yml exec nginx nginx -t
docker compose -f /opt/proxy/docker-compose.yml exec nginx nginx -s reload
```
The config proxies all paths (including `/.well-known/oauth-*` and `/mcp`) to the container over the `proxy` network using the `resolver 127.0.0.11` + `set $upstream` pattern, disables buffering, and uses long streaming timeouts. The shared `*.mohith16.com` wildcard cert already covers the subdomain, so no new certificate is required. Do not expose container port 8000 publicly.
After DNS + TLS are live, verify:
```bash
curl https://strava.mohith16.com/health
curl https://strava.mohith16.com/ready
curl -i https://strava.mohith16.com/mcp
curl https://strava.mohith16.com/.well-known/oauth-protected-resource/mcp
curl https://strava.mohith16.com/.well-known/oauth-authorization-server
```
An unauthenticated `/mcp` request should return `401` with a `WWW-Authenticate` resource metadata link.
## Connect Claude Web
1. Open Claude → Settings → Connectors.
2. Click **Add custom connector**.
3. Name it `Strava Planner`.
4. Set the remote MCP server URL to `https://strava.mohith16.com/mcp`.
5. Open Advanced settings.
6. Enter the exact `MCP_OAUTH_CLIENT_ID` and `MCP_OAUTH_CLIENT_SECRET` from the server `.env`.
7. Click Add, then Connect.
8. Enable the desired Strava tools in the Search and tools menu.
The URL entered in Claude must match `MCP_PUBLIC_URL` **exactly**, including the `/mcp` path and with no trailing slash. Access tokens are bound to that exact resource (RFC 8707 audience), so a mismatch is rejected. The redirect URI Claude uses is `https://claude.ai/api/mcp/auth_callback`, which is the default in `MCP_OAUTH_REDIRECT_URIS`.
The OAuth flow uses Claude's callback URL, PKCE (S256), a fixed confidential client authenticated with `client_secret_post`, one-hour signed access tokens, rotating 30-day refresh tokens, and MCP protected-resource + authorization-server metadata discovery. Claude supports this static-client model natively — the OAuth Client ID/Secret fields in **Advanced settings** exist precisely so a server can skip Dynamic Client Registration.
## Tools
Existing tools remain available:
- `getAthleteProfile`
- `getActivities`
- `getActivity`
- `getActivityStreams`
- `getRecentActivities`
- `getActivitiesByType` — paginated: accepts `after`, `before`, `page`, and `limit` (max 200), and returns a `nextPage` hint.
- `getTrainingHistory` — paginated: accepts `after`, `before`, `page`, and `limit` (max 200), returns newest-first with a `nextPage` hint.
- `getAthleteOverview` — bounded to a look-back window (`sinceDays`, default 365) instead of the full history.
- `getHalfMarathonTrainingContext`
Additional detailed-analysis tools:
- `getActivityLaps`
- `getActivityZones`
- `getRecentRuns`
- `getAthleteContext`
**Pagination change (backward-compatible defaults):** `getActivitiesByType` and `getTrainingHistory` previously returned the athlete's entire history in one response. They now return one bounded page and expose `nextPage`; call again with that value to page through older activities. This keeps responses small and predictable.
Tool results are returned as pretty-printed JSON text content. Structured output schemas were intentionally not adopted to keep Claude Web operation maximally reliable; response size is controlled by the pagination above.
### `getHalfMarathonTrainingContext` window
The training-cycle start date is resolved in this order: `trainingStartDate` in the athlete context file, then `HALF_MARATHON_TRAINING_START_DATE`, then a trailing 26-week window. Optional `targetRaceDate`, `targetDistance`, and `targetTime` in the athlete context are surfaced in the report. Weekly buckets use athlete-local time (Strava `start_date_local`) with Monday week starts, and every calendar week in the range is represented so streak and consistency metrics never bridge an inactive week.
`getActivityStreams` accepts `streamTypes`, allowing requests such as:
```json
{
"activityId": 123456789,
"streamTypes": ["time", "distance", "heartrate", "velocity_smooth", "cadence"]
}
```
Supported streams are `time`, `distance`, `latlng`, `altitude`, `velocity_smooth`, `heartrate`, `cadence`, `watts`, `temp`, `moving`, and `grade_smooth`. Strava returns only streams available for that activity.
## Caching And Rate Limits
- Activity responses use a bounded in-memory TTL + LRU cache per process (`STRAVA_CACHE_MAX_ENTRIES`, default 500). Expired entries are pruned lazily and on write, and the least-recently-used entry is evicted past the cap, so memory cannot grow unbounded.
- Historical data is not persisted in a database.
- Multi-page fetches are capped (`getAllActivities` never exceeds 25 pages) and the history tools page explicitly.
- Transient failures and `429`/`5xx` responses use exponential backoff with jitter; `Retry-After` is honored in **both** integer-seconds and HTTP-date forms.
- The Strava OAuth token exchange and refresh now also have a request timeout and transient-failure retry; a permanent 4xx is returned immediately.
- A single 401 triggers one refresh-and-retry; permanent 4xx responses are returned immediately rather than retried.
- Strava token refreshes are serialized (single-flight) to prevent concurrent rotation races.
SQLite was deliberately not added. For a single-user instance, the current cache avoids repeated calls within a session while keeping the ARM64 image and operational model small.
## Security Status
Production protections implemented:
- OAuth-compatible Claude Web authentication with PKCE (S256) and refresh-token rotation.
- MCP access and refresh tokens signed with HS256; access tokens are audience-bound to the exact MCP URL.
- Encrypted Strava tokens at rest with AES-256-GCM, written atomically with `0600` permissions.
- Startup validation rejects placeholder/short production secrets.
- Authless mode fails closed on any non-loopback bind unless explicitly overridden.
- Read-only MCP tools with bounded, paginated responses.
- HTTPS enforced at Nginx with HSTS, `X-Frame-Options`, `X-Content-Type-Options`, and modern TLS ciphers.
- Host-header allowlist and DNS-rebinding protection from the MCP SDK.
- OAuth endpoint rate limiting supplied by the MCP SDK.
- No secrets or Strava payloads in normal logs.
- Non-root container, dropped capabilities, `no-new-privileges`, and localhost-only published application port.
Single-user limitations:
- Dynamic Client Registration is intentionally disabled; enter the fixed client credentials in Claude.
- A valid fixed client authorization request is approved immediately; there is no separate human consent page. Security therefore depends on keeping the OAuth client secret private and serving only over HTTPS.
- Token revocations are remembered in memory. A restart clears the revocation set, although signed tokens still expire normally. Rotate `MCP_OAUTH_TOKEN_SIGNING_KEY` to invalidate every outstanding MCP token immediately.
- The in-memory cache is not shared across replicas. Run one container unless you add shared state.
Never publish the endpoint with `MCP_AUTH_ENABLED=false`. GPS routes and heart-rate data are private and may be returned by tools.
TDQS
Scored across 13 tools
Each tool targets a distinct aspect of Strava data retrieval: activities by various criteria, activity details, laps, streams, zones, athlete profile, overview, context, training history, and a specific half marathon context. Descriptions clearly differentiate overlapping tools like getActivities, getActivitiesByType, getRecentActivities, and getTrainingHistory by specifying paging, filtering, and purpose.
All tool names use consistent camelCase verb+noun pattern (get followed by resource name). This predictable naming helps agents quickly understand the action and target resource without confusion.
With 13 tools, the server is well-scoped for a Strava planner. Each tool serves a specific purpose in retrieving training data and athlete context, without unnecessary duplication or missing essential operations for a read-only planner.
The tool surface covers core data retrieval: activity listing with filters, detailed activity data, laps, streams, zones, athlete info, training history, and half marathon context. Missing tools for updating athlete context or Strava settings, and for retrieving routes or segments, but these are minor gaps for a planner focused on training analysis.