Skip to main content
Glama
sptmru
by sptmru
README.md
# Fitness MCP · Strava

A private MCP server built with Node.js 22, TypeScript, and PostgreSQL. ChatGPT connects to `/mcp` over Streamable HTTP and authenticates through **our OAuth Authorization Code + PKCE S256 flow**. The server uses the official Strava REST API and refreshes Strava tokens automatically. It does not use `mcp.strava.com`.

```text
ChatGPT → HTTPS / Cloudflare Tunnel → Fastify /mcp → Strava REST API
                                      ↕
                                   PostgreSQL
```

The service provides read-only access. Only Strava athlete IDs listed in `STRAVA_ALLOWED_ATHLETE_IDS` can connect; an arbitrary first visitor cannot become the owner. Users and connections are separate database entities, but the initial deployment is intended for one server instance and one owner. Redis is not required.

## Quick start with Docker Compose

```bash
cp .env.example .env
chmod 600 .env
openssl rand -hex 24
openssl rand -hex 32
openssl rand -hex 32
```

Use the first generated value as the PostgreSQL password in **both** `POSTGRES_PASSWORD` and `DATABASE_URL`. Use the second for `SESSION_SECRET` and the third for `TOKEN_ENCRYPTION_KEY`. Fill in the remaining required settings described below. `.env` contains secrets and must not be committed to git.

```bash
docker compose up -d --build
docker compose ps
curl --fail http://127.0.0.1:3000/healthz
curl --fail http://127.0.0.1:3000/readyz
```

Compose starts PostgreSQL, applies SQL migrations through the separate `migrate` service, and then starts the application. `/healthz` checks the process; `/readyz` checks database readiness and connectivity. The application port is published only on `127.0.0.1:3000` on the host; the database port is not published. The application container runs as a non-root user with a read-only filesystem and a `tmpfs` mount for `/tmp`.

## Set up a Strava application

1. Create an application in [Strava API settings](https://www.strava.com/settings/api).
2. Set **Authorization Callback Domain** to `strava-mcp.example.com`, without a scheme or path.
3. Set `STRAVA_CLIENT_ID`, `STRAVA_CLIENT_SECRET`, and `STRAVA_REDIRECT_URI=https://strava-mcp.example.com/oauth/strava/callback` in `.env`.
4. Set `STRAVA_ALLOWED_ATHLETE_IDS` to your numeric athlete ID from your Strava profile URL. Separate multiple allowed IDs with commas.
5. Grant `read,activity:read_all` during authorization. If you need private profile fields and gear, add `profile:read_all` and reconnect your account.

`read` and `activity:read_all` are official read-only scopes; the latter also grants access to the owner's private activities. No write scopes are requested. Field availability depends on the granted permissions and the account's data. See [Strava OAuth](https://developers.strava.com/docs/authentication/).

### Find your athlete ID

Sign in to the Strava website in a browser, click your profile picture, and open your profile. The number after `/athletes/` in the address bar is your athlete ID. For example:

```text
https://www.strava.com/athletes/12345678
```

For this example, set:

```env
STRAVA_ALLOWED_ATHLETE_IDS=12345678
```

Use your own profile's number. The athlete ID is separate from your Strava application Client ID and from an activity ID. Mobile sharing may produce a shortened `strava.app.link` URL; use the profile URL in a web browser to find the numeric ID. See [Strava: Where Do I Find My Profile URL?](https://support.strava.com/en-us/articles/15401872-where-do-i-find-my-profile-url).

## Environment variables

| Variable | Value / purpose |
|---|---|
| `PUBLIC_BASE_URL` | Required public HTTPS origin, such as `https://strava-mcp.example.com`; no path |
| `STRAVA_CLIENT_ID`, `STRAVA_CLIENT_SECRET` | Credentials for your Strava API application |
| `STRAVA_REDIRECT_URI` | Exactly `${PUBLIC_BASE_URL}/oauth/strava/callback` |
| `STRAVA_ALLOWED_ATHLETE_IDS` | Required list of allowed numeric athlete IDs |
| `STRAVA_SCOPES` | Defaults to `read,activity:read_all`; optionally add `profile:read_all` |
| `POSTGRES_PASSWORD` | PostgreSQL password for Compose; use a random hexadecimal value |
| `DATABASE_URL` | In Compose: `postgres://fitness:YOUR_PASSWORD@db:5432/fitness` |
| `SESSION_SECRET` | Random cookie-signing secret, at least 32 characters |
| `TOKEN_ENCRYPTION_KEY` | AES-256-GCM key: exactly 64 hexadecimal characters |
| `OAUTH_REDIRECT_URIS` | Comma-separated list of **exact** allowed MCP client callback URIs |
| `HOST`, `PORT` | Local defaults: `0.0.0.0`, `3000`; in the current Compose configuration, `PORT` sets both the published host port and the container's listening port |
| `LOG_LEVEL` | Defaults to `info`; structured JSON logging |
| `TRUST_PROXY` | Defaults to `false`; if needed, specify the trusted proxy's IP/CIDR |
| `TUNNEL_TOKEN` | Required only for the optional `tunnel` Compose profile |
| `TEST_DATABASE_URL` | Separate integration-test database; never use production |

Keep the encryption key unchanged during normal restarts: existing Strava tokens are encrypted with that key. Keep backups of the database and the key outside the public repository. Changing `POSTGRES_PASSWORD` in `.env` does not change the password of an existing PostgreSQL user in an initialized volume.

## Cloudflare Tunnel

If `cloudflared` already runs on the host, add the public hostname `strava-mcp.example.com` with origin service `http://127.0.0.1:3000`. Configure HTTPS for the public hostname in Cloudflare. Example ingress for a locally managed tunnel:

```yaml
ingress:
  - hostname: strava-mcp.example.com
    service: http://127.0.0.1:3000
  - service: http_status:404
```

For a separate connector container, set `TUNNEL_TOKEN`, configure the Cloudflare service as **`http://app:3000`**, and run:

```bash
docker compose --profile tunnel up -d --build
```

Inside the connector container, `127.0.0.1` refers to the connector itself, so use the `app` service's DNS name. An existing connector in a different Docker project needs a shared Docker network or access to the host port. See the [official Cloudflare Tunnel setup guide](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/).

For the MCP hostname, disable Cloudflare Cache Everything and browser challenges / Access login on `/mcp`, `/oauth/*`, and `/.well-known/*`: machine clients must receive application responses. The server's OAuth protects access to data. Standard Cloudflare filtering and limits can remain enabled as long as they do not block OAuth and MCP requests.

`TRUST_PROXY=false` is sufficient for constructing OAuth URLs: the server uses `PUBLIC_BASE_URL`. To rate-limit by the visitor's actual IP address, configure the trusted address of the immediate proxy and ensure it sets forwarded headers correctly. Otherwise, the rate limiter may count the entire tunnel as a single source.

If you also use nginx, preserve the original path and `Authorization` header, disable buffering for MCP, and allow sufficient request time:

```nginx
location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_buffering off;
    proxy_read_timeout 120s;
}
```

## Connect ChatGPT

Create a custom MCP app/connector in ChatGPT with OAuth and this URL:

```text
https://strava-mcp.example.com/mcp
```

`OAUTH_REDIRECT_URIS` must contain the **exact callback URI shown by ChatGPT**. For flows using `iss`, OpenAI documents `https://chatgpt.com/connector_platform_oauth_redirect`; this is the default in `.env.example`. If the interface shows a different callback, add that exact URI and restart the application. Wildcards, prefix matching, and arbitrary redirect URIs are not allowed. The Strava Client ID and secret stay on the server: ChatGPT registers its own public OAuth client through `/oauth/register`, without a client secret. See [OpenAI: OAuth for MCP](https://developers.openai.com/plugins/build/auth).

Keep the two OAuth callbacks separate:

```env
# ChatGPT receives our authorization code at its own callback URL.
OAUTH_REDIRECT_URIS=https://chatgpt.com/connector_platform_oauth_redirect
# Our server receives Strava's authorization code here.
STRAVA_REDIRECT_URI=https://strava-mcp.example.com/oauth/strava/callback
```

Do not replace the ChatGPT hostname in `OAUTH_REDIRECT_URIS` with your MCP hostname. After changing `.env`, apply it with `docker compose up -d --no-deps app`; `docker compose restart` does not reload environment variables.

The browser completes Strava OAuth, then displays a local consent page for the specific MCP client. After consent, ChatGPT receives our single-use authorization code and exchanges it with the PKCE verifier for our token. Strava tokens are never returned to the client.

Discovery endpoints:

```text
https://strava-mcp.example.com/.well-known/oauth-authorization-server
https://strava-mcp.example.com/.well-known/oauth-protected-resource
https://strava-mcp.example.com/.well-known/oauth-protected-resource/mcp
```

Expected authorization server metadata:

```json
{
  "issuer": "https://strava-mcp.example.com",
  "authorization_endpoint": "https://strava-mcp.example.com/oauth/authorize",
  "token_endpoint": "https://strava-mcp.example.com/oauth/token",
  "registration_endpoint": "https://strava-mcp.example.com/oauth/register",
  "revocation_endpoint": "https://strava-mcp.example.com/oauth/revoke",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_methods_supported": ["none"],
  "revocation_endpoint_auth_methods_supported": ["none"],
  "code_challenge_methods_supported": ["S256"],
  "scopes_supported": ["strava:read"],
  "authorization_response_iss_parameter_supported": true
}
```

Protected Resource Metadata specifies `resource=https://strava-mcp.example.com/mcp`, `authorization_servers=["https://strava-mcp.example.com"]`, and the `strava:read` scope. Requests to `/mcp` without a token receive `401` with a `WWW-Authenticate` header pointing to the resource metadata. Tokens are restricted to this resource and validated on every MCP request. The contract follows [MCP Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).

## Tools

| Tool | Purpose and arguments |
|---|---|
| `get_athlete` | Compact profile of the connected athlete |
| `get_recent_activities` | `limit` (default 20, maximum 100), `sport_type`, `before`, `after` |
| `get_activity` | Activity details by `activity_id` |
| `get_athlete_stats` | Available Strava running / cycling / swimming aggregates |
| `get_activity_streams` | `activity_id`, `streams`, `resolution`: low / medium / high |
| `get_training_summary` | `start_date`, `end_date`, optional `sport_type` |
| `compare_training_periods` | `period_a`, `period_b` with date ranges, optional `sport_type` |
| `get_weekly_training` | `weeks` (default 8, maximum 52), optional `sport_type` |
| `get_gear` | Available bikes / shoes and distance covered |

All distances are in meters, durations in seconds, and speeds in meters per second. Analytics dates in `YYYY-MM-DD` format include both boundaries in UTC; weeks start on Monday UTC. `start_date_local` is preserved for display but does not determine the aggregation timezone. Requests are limited to 366 days and 2,000 activities: narrow the period when the limit is reached to avoid silently incomplete summaries.

`get_athlete_stats` returns Strava's built-in aggregates only for activities with Everyone visibility, according to the [official endpoint description](https://developers.strava.com/swagger/swagger.json). `get_training_summary`, `compare_training_periods`, and `get_weekly_training` calculate metrics from the owner's accessible activities, including private activities when `activity:read_all` is granted; their results may therefore differ from Strava's built-in statistics.

Running pace is calculated from total running duration and distance; cycling speed from distance and duration. Average heart rate includes only activities with heart-rate data. Missing measurements are not converted to zeros; percentage changes with a zero baseline are not reported as infinity. The longest activity is determined by distance.

Streams are normalized and downsampled using shared indices: at most 100 / 300 / 1,000 points per stream for low / medium / high resolution. The stream list and response size are bounded; GPS is included only when `latlng` is requested. Responses do not contain the complete raw Strava JSON.

Profile, stats, gear, and activity-detail caches have bounded TTLs; activity lists use a short TTL. Caches and rate limiting are local to the process. Multiple instances would need shared rate limiting / caching and a review of the combined Strava API load. Strava token refresh is synchronized with PostgreSQL row locks.

## Local development and tests

You need Node.js 22+ and PostgreSQL 17. To start a separate local database:

```bash
docker run -d --name fitness-mcp-dev-db \
  -e POSTGRES_USER=fitness -e POSTGRES_PASSWORD=local-dev-only \
  -e POSTGRES_DB=fitness -p 127.0.0.1:5433:5432 postgres:17-alpine
npm ci
```

Set `DATABASE_URL=postgres://fitness:local-dev-only@127.0.0.1:5433/fitness` in your local `.env`. Then run:

```bash
node --env-file=.env --import tsx src/db/migrate.ts
npm run dev
npm run typecheck
npm test
npm run build
```

Test browser OAuth through a public HTTPS tunnel: authorization cookies require a secure connection. HTTP on localhost is suitable for health/readiness checks and route tests, but the public MCP endpoint must use an HTTPS origin.

PostgreSQL integration tests are enabled by `TEST_DATABASE_URL`. Use a dedicated test database, never the production database: tests create and remove their own isolated schemas. Strava HTTP requests are mocked in tests.

```bash
docker exec fitness-mcp-dev-db createdb -U fitness fitness_test
TEST_DATABASE_URL=postgres://fitness:local-dev-only@127.0.0.1:5433/fitness_test npm test
```

Without `TEST_DATABASE_URL`, the integration suite is skipped. CI runs tests with PostgreSQL, type checking, the TypeScript build, and a Docker build. Successful local validation does not confirm a live ChatGPT connection or Strava authorization.

## Operations

The production command inside the image is `node dist/index.js`. The migration command is `node dist/db/migrate.js` (`npm run migrate:prod`). SQL migrations are included in the Docker image. To run migrations from a local `dist` directory after `npm run build`, copy `src/db/migrations` to `dist/db/migrations` first, or run migrations from source using the command above.

Before updating, back up PostgreSQL, then run:

```bash
docker compose build
docker compose run --rm migrate
docker compose up -d
curl --fail https://strava-mcp.example.com/readyz
curl --fail https://strava-mcp.example.com/.well-known/oauth-authorization-server
```

`docker compose down` preserves the named volume; `docker compose down -v` deletes the database. Migrations are applied explicitly and sequentially; there is no automatic SQL rollback. Back up the database before schema changes.

OAuth access tokens last one hour; refresh tokens last 30 days and rotate on use. Reusing a refresh token revokes its token family. Our OAuth access/refresh tokens are stored as SHA-256 hashes; upstream Strava credentials are encrypted with AES-256-GCM. The browser flow is bound to a signed cookie and protected by CSRF/state checks; authorization codes are short-lived and single-use. Reconnect after revoking access in Strava.

Logs include request IDs, tools, endpoints, status, latency, and cache / refresh events. Do not enable URL query-string or request/response body logging on an external proxy: OAuth callbacks contain authorization codes. Secrets and credential headers must not enter the logging system.

## Troubleshooting

- **`code_challenge_methods_supported containing S256`**: check the public `/.well-known/oauth-authorization-server`; the field must be `["S256"]`. The endpoint must return the application's JSON with HTTP 200, without a Cloudflare login/challenge or stale cached metadata. Recreate the connection after changing the domain.
- **`invalid_redirect_uri` / `invalid_client_metadata`**: compare ChatGPT's callback URI character-for-character with `OAUTH_REDIRECT_URIS`. ChatGPT's callback and `/oauth/strava/callback` are separate addresses with different purposes.
- **`invalid_grant`**: the code has expired, has already been used, or the PKCE verifier is incorrect; restart OAuth. During refresh, this error can also indicate token rotation or family revocation.
- **State / cookie error**: restart the flow on the same HTTPS hostname; check `PUBLIC_BASE_URL`, cookies, and any proxy redirects to a different domain.
- **Athlete access denied**: check your athlete ID in `STRAVA_ALLOWED_ATHLETE_IDS` and restart the application container.
- **`STRAVA_TOKEN_REFRESH_FAILED` / `STRAVA_NOT_CONNECTED`**: reconnect the account and check the Client ID/Secret and granted scopes.
- **`STRAVA_RATE_LIMITED`**: retry after the indicated delay or narrow the period. The client respects upstream rate-limit headers, bounded retries, timeouts, and `Retry-After`.
- **`ACTIVITY_NOT_FOUND`**: check the activity ID, its owner, and the `activity:read_all` scope.
- **Readiness check fails**: check that `db` is running, `migrate` completed successfully, and the password in `DATABASE_URL` matches the actual PostgreSQL user's password.

Before production use, complete these manual steps: create a Strava application, fill in the secrets and athlete allowlist, configure DNS/Tunnel, set the exact ChatGPT callback URI, and complete OAuth. The live ChatGPT → Tunnel → Strava flow must be verified with your account; repository tests do not replace that check.