Skip to main content
Glama
sptmru
by sptmru

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.

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

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.

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.

Related MCP server: Strava MCP Server

Set up a Strava application

  1. Create an application in Strava API settings.

  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.

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:

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

For this example, set:

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?.

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:

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:

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.

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:

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:

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.

Keep the two OAuth callbacks separate:

# 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:

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:

{
  "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.

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. 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:

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:

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.

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:

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.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    MCP server to fetch Strava activities using OAuth authentication with automatic token refresh. Allows retrieving recent activities, activities by date range, activity details, and athlete stats.
    -
  • A
    license
    B
    quality
    B
    maintenance
    Enables MCP clients to interact with the Strava REST API through tools generated from Strava's official Swagger spec, supporting OAuth, activity and athlete data retrieval, and configurable write/delete operations.
    34
    MIT