stravamcp
# strava-server
Two servers over one Strava client:
- an **Express HTTP server** that runs the OAuth 2.0 flow and exposes the
[Strava v3 API](https://developers.strava.com/docs/reference/) as JSON endpoints;
- an **MCP server** (stdio) that exposes the same data as tools for an AI agent.
Both share `src/strava/*` and the same token file, so you authorize once in the browser and the
MCP server picks the tokens up automatically.
## 1. Create a Strava API application
1. Sign in at <https://www.strava.com/settings/api> and create an app (a Strava subscription is required).
2. Set **Authorization Callback Domain** to `localhost`.
3. Copy the **Client ID** and **Client Secret**.
New apps start in *single-player mode* — only your own account can authorize them, which is exactly
what this server needs.
## 2. Configure
```powershell
Copy-Item .env.example .env
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # paste into SESSION_SECRET
```
Fill in `STRAVA_CLIENT_ID` and `STRAVA_CLIENT_SECRET` in `.env`.
`.env` and `.tokens.json` are gitignored — never commit them.
## 3. Run
```powershell
npm install
npm run dev # HTTP server, watch mode
# or
npm run build; npm start
```
Then open <http://localhost:3000/auth/login> and click **Authorize**. Strava redirects back to
`/auth/callback`, the server exchanges the one-time code for a refresh token + access token, and
persists them to `.tokens.json` (mode `600`). Access tokens expire every 6 hours; the server
refreshes them automatically before each request, so you only authorize once.
## 4. MCP server
```powershell
npm run mcp # stdio, via tsx
# or
npm run build; npm run mcp:start
```
VS Code picks up [.vscode/mcp.json](.vscode/mcp.json) automatically — open the Chat view, switch to
Agent mode and start the `strava` server. For other clients:
```json
{
"mcpServers": {
"strava": {
"command": "node",
"args": ["C:/path/to/stravamcp/dist/mcp/index.js"],
"env": {
"STRAVA_CLIENT_ID": "...",
"STRAVA_CLIENT_SECRET": "..."
}
}
}
}
```
`.env` and the token file are resolved relative to the package directory, not the working
directory, so the MCP server works no matter where the client launches it from.
### Tools
| Tool | Strava endpoint |
| --- | --- |
| `strava_auth_status` | Connection state, granted scopes, token expiry |
| `strava_get_athlete_summary` | Profile + totals + recent activities in one call |
| `strava_get_athlete` | `GET /athlete` |
| `strava_get_athlete_stats` | `GET /athletes/{id}/stats` |
| `strava_get_athlete_zones` | `GET /athlete/zones` |
| `strava_list_activities` | `GET /athlete/activities` (`before`/`after` accept ISO dates) |
| `strava_get_activity` | `GET /activities/{id}` |
| `strava_get_activity_laps` | `GET /activities/{id}/laps` |
| `strava_get_activity_zones` | `GET /activities/{id}/zones` |
| `strava_get_activity_streams` | `GET /activities/{id}/streams` (downsampled to `max_points`) |
| `strava_get_activity_comments` | `GET /activities/{id}/comments` |
| `strava_get_activity_kudoers` | `GET /activities/{id}/kudos` |
| `strava_list_clubs` / `strava_get_club` | `GET /athlete/clubs`, `GET /clubs/{id}` |
| `strava_get_gear` | `GET /gear/{id}` |
| `strava_list_routes` / `strava_get_route` | `GET /athletes/{id}/routes`, `GET /routes/{id}` |
| `strava_list_starred_segments` / `strava_get_segment` | `GET /segments/starred`, `GET /segments/{id}` |
| `strava_get_rate_limit` | Last-seen rate-limit headers |
All tools are read-only and annotated as such. Responses that would blow up an agent's context are
trimmed by default: segment efforts and full-resolution polylines are opt-in, and streams are
evenly downsampled (200 points by default).
If a tool reports "Not connected to Strava", run the HTTP server once and complete
`/auth/login` — the MCP server reads the same `.tokens.json`.
## HTTP endpoints
### Auth
| Method | Path | Description |
| --- | --- | --- |
| GET | `/auth/login` | Redirect to Strava's consent screen (CSRF-protected via a signed `state` cookie) |
| GET | `/auth/callback` | Code → token exchange |
| GET | `/auth/status` | Connection state, granted scope, token expiry |
| POST | `/auth/logout` | Deauthorize with Strava and delete the local tokens |
### Data
| Method | Path | Strava endpoint |
| --- | --- | --- |
| GET | `/api/me` | Aggregated profile + totals + last 10 activities (formatted in km/min) |
| GET | `/api/athlete` | `GET /athlete` |
| GET | `/api/athlete/stats` | `GET /athletes/{id}/stats` |
| GET | `/api/athlete/zones` | `GET /athlete/zones` |
| GET | `/api/athlete/clubs` | `GET /athlete/clubs` |
| GET | `/api/athlete/routes` | `GET /athletes/{id}/routes` |
| GET | `/api/activities` | `GET /athlete/activities` (`page`, `per_page`, `before`, `after`) |
| GET | `/api/activities/:id` | `GET /activities/{id}` (`include_all_efforts=true`) |
| GET | `/api/activities/:id/laps` | `GET /activities/{id}/laps` |
| GET | `/api/activities/:id/zones` | `GET /activities/{id}/zones` |
| GET | `/api/activities/:id/comments` | `GET /activities/{id}/comments` |
| GET | `/api/activities/:id/kudos` | `GET /activities/{id}/kudos` |
| GET | `/api/activities/:id/streams` | `GET /activities/{id}/streams` (`keys=time,distance,heartrate`) |
| GET | `/api/clubs/:id` | `GET /clubs/{id}` |
| GET | `/api/gear/:id` | `GET /gear/{id}` |
| GET | `/api/routes/:id` | `GET /routes/{id}` |
| GET | `/api/segments/starred` | `GET /segments/starred` |
| GET | `/api/segments/:id` | `GET /segments/{id}` |
| GET | `/api/rate-limit` | Last-seen `X-RateLimit-*` / `X-ReadRateLimit-*` values |
Example:
```powershell
curl http://localhost:3000/api/me
curl "http://localhost:3000/api/activities?per_page=50&after=1704067200"
curl "http://localhost:3000/api/activities/123456789/streams?keys=time,heartrate,watts"
```
## Scopes
`STRAVA_SCOPE` defaults to `read,read_all,profile:read_all,activity:read_all`:
- `profile:read_all` — detailed athlete representation and `/athlete/zones`
- `activity:read_all` — "Only Me" activities, otherwise they are filtered out
- `read_all` — private routes and segments
If you change the scope you must re-run `/auth/login`; `/auth/callback` reports any scope the
athlete declined under `missing_scope`.
## Rate limits
Default limits are 200 requests / 15 min and 2,000 / day. The client records the limit headers
from every response; a `429` is surfaced as a JSON error with the current usage. From the MCP
server, call `strava_get_rate_limit` before issuing a burst of requests.
## Notes
- `/activities/{id}/zones` and `/segment_efforts` require a Strava subscription.
- Tokens are stored on disk in plain JSON. Keep the file local; for a multi-user deployment
replace `src/strava/token-store.ts` with an encrypted, per-user store.
TDQS
Scored across 20 tools
Each tool targets a distinct resource (athlete, activity, club, gear, route, segment) and action (get/list), with utility tools (rate limit, auth status) clearly separated. Even similar tools like get_athlete_summary and get_athlete_stats differ in granularity and purpose, making misselection unlikely.
Most tools follow a consistent verb_noun pattern (get_*, list_*), but strava_auth_status breaks the pattern by using a noun phrase instead of a verb. Additionally, get_athlete_zones vs get_activity_zones could be mistaken without careful reading, though the resource is explicit in each name.
With 20 tools, the server is on the heavy side, exceeding the 15-tool threshold for a well-scoped set. However, each tool maps to a specific Strava API endpoint, and the count is justified by the wide read-only surface of the Strava API, so it is borderline rather than excessive.
The server covers the primary Strava resources (athlete, activities, clubs, gear, routes, segments) with both list and detail views, plus utility tools. Obvious gaps like activity upload or a dedicated segment efforts tool are absent, but agents can obtain segment efforts via activity detail and upload is not a standard read-only operation.