feat: implement dynamic versioning and add automated Docker CI/CD workflow with enhanced documentation
CI/CD Pipeline / Lint & Check (push) Successful in 55s
CI/CD Pipeline / Build & Push Docker Image (push) Failing after 1m14s

This commit is contained in:
2026-05-09 02:57:41 +02:00
parent 19313a5171
commit 2b061f4791
6 changed files with 132 additions and 218 deletions
+6 -3
View File
@@ -4,6 +4,8 @@ on:
push: push:
branches: branches:
- main - main
tags:
- 'v*'
pull_request: pull_request:
branches: branches:
- main - main
@@ -18,7 +20,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v2 uses: astral-sh/setup-uv@v2
@@ -35,7 +37,7 @@ jobs:
build-and-push: build-and-push:
name: Build & Push Docker Image name: Build & Push Docker Image
needs: lint needs: lint
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
packages: write packages: write
@@ -58,7 +60,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
type=sha type=ref,event=tag
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
@@ -68,6 +70,7 @@ jobs:
with: with:
context: . context: .
push: true push: true
build-args: VERSION=${{ steps.meta.outputs.version }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
+4
View File
@@ -10,6 +10,10 @@ ENV UV_COMPILE_BYTECODE=1
# Copy the lockfile and pyproject.toml # Copy the lockfile and pyproject.toml
COPY uv.lock pyproject.toml /app/ COPY uv.lock pyproject.toml /app/
# Provide the version to hatch-vcs (setuptools-scm) during the build
ARG VERSION=dev
ENV SETUPTOOLS_SCM_PRETEND_VERSION=${VERSION}
# Install dependencies (without the project itself) for caching # Install dependencies (without the project itself) for caching
RUN uv sync --frozen --no-install-project --no-dev RUN uv sync --frozen --no-install-project --no-dev
+111 -211
View File
@@ -10,8 +10,9 @@ Built with [FastMCP](https://github.com/jlowin/fastmcp) and the [MCP Python SDK]
- 🛠️ **25+ MCP Tools** covering all major Strava API read endpoints (including athlete profile & stats) - 🛠️ **25+ MCP Tools** covering all major Strava API read endpoints (including athlete profile & stats)
- 💬 **2 MCP Prompts** for structured AI-driven training analysis - 💬 **2 MCP Prompts** for structured AI-driven training analysis
- 🔄 **Automatic OAuth token rotation** — tokens are refreshed transparently - 🔄 **Fully Automated OAuth** — authentication flow integrated directly as an MCP tool with auto-rotation
- 🌐 **Streamable HTTP transport** for broad client compatibility - 🐳 **Docker-Ready** — highly optimized multi-stage Docker build utilizing `uv`
- 🌐 **Streamable HTTP transport** for broad client compatibility (SSE)
- 🔒 **Read-only** — no write operations, safe to use with AI agents - 🔒 **Read-only** — no write operations, safe to use with AI agents
--- ---
@@ -19,16 +20,14 @@ Built with [FastMCP](https://github.com/jlowin/fastmcp) and the [MCP Python SDK]
## 📋 Table of Contents ## 📋 Table of Contents
- [Requirements](#requirements) - [Requirements](#requirements)
- [Installation](#installation) - [Installation & Deployment](#installation--deployment)
- [Docker (Recommended)](#docker-recommended)
- [Local Python (uv)](#local-python-uv)
- [Strava API Setup](#strava-api-setup) - [Strava API Setup](#strava-api-setup)
- [Configuration](#configuration)
- [Running the Server](#running-the-server)
- [Connecting with MCP Inspector](#connecting-with-mcp-inspector) - [Connecting with MCP Inspector](#connecting-with-mcp-inspector)
- [MCP Primitives](#mcp-primitives) - [MCP Primitives](#mcp-primitives)
- [Tools](#tools)
- [Prompts](#prompts)
- [Project Structure](#project-structure) - [Project Structure](#project-structure)
- [CI/CD (Gitea Actions)](#cicd-gitea-actions)
- [Known Strava API Limitations](#known-strava-api-limitations) - [Known Strava API Limitations](#known-strava-api-limitations)
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
@@ -36,28 +35,51 @@ Built with [FastMCP](https://github.com/jlowin/fastmcp) and the [MCP Python SDK]
## Requirements ## Requirements
- Python 3.10+
- [uv](https://github.com/astral-sh/uv) (recommended) or pip
- A [Strava account](https://www.strava.com) with API access - A [Strava account](https://www.strava.com) with API access
- A [Strava API Application](https://www.strava.com/settings/api) - A [Strava API Application](https://www.strava.com/settings/api)
- **Docker** (for containerized deployment) OR **Python 3.10+** & [uv](https://github.com/astral-sh/uv) (for local execution)
--- ---
## Installation ## Installation & Deployment
### Docker (Recommended)
The project includes a highly optimized, deterministic Dockerfile powered by `uv`.
```bash ```bash
# Clone the repository # Clone the repository
git clone <your-repo-url> git clone https://git.hnrx.net/hnrx/strava-mcp-server.git
cd strava-mcp-server cd strava-mcp-server
# Install dependencies with uv (also installs the package itself) # Build the image
uv sync docker build -t strava-mcp-server:latest .
# Or install directly from PyPI (once published) # Run the container (injecting your .env file)
uvx strava-mcp-server docker run --rm -p 8000:8000 --env-file .env strava-mcp-server:latest
# or: pip install strava-mcp-server
``` ```
### Local Python (uv)
```bash
git clone https://git.hnrx.net/hnrx/strava-mcp-server.git
cd strava-mcp-server
# Install dependencies and start the server
uv run strava-mcp
```
### Run on the fly with `uvx` (No git clone required)
You can run the server directly from the repository without cloning it manually by using `uvx`. `uv` will download it into a temporary isolated environment and execute it:
```bash
# Set up your .env file in the current directory first!
uvx --from git+https://git.hnrx.net/hnrx/strava-mcp-server.git strava-mcp
```
*(If you are already inside the cloned directory, you can also just run `uvx --from . strava-mcp`)*
--- ---
## Strava API Setup ## Strava API Setup
@@ -69,90 +91,36 @@ uvx strava-mcp-server
3. Set **Authorization Callback Domain** to `localhost` 3. Set **Authorization Callback Domain** to `localhost`
4. Note your **Client ID** and **Client Secret** 4. Note your **Client ID** and **Client Secret**
### 2. Generate Your OAuth Tokens ### 2. Configure Environment
The server uses the OAuth 2.0 refresh token flow. Run the token helper script to authorize and retrieve your initial tokens:
```bash
uv run strava-mcp-get-token
```
This will:
1. Open your browser with the Strava authorization page
2. Ask you to authorize the application
3. Automatically capture the authorization code
4. Exchange it for access and refresh tokens
5. Save the `STRAVA_REFRESH_TOKEN` to your `.env` file
> **Required OAuth Scopes:**
> `activity:read_all,profile:read_all,read`
>
> - `activity:read_all` — Access all activities including private ones
> - `profile:read_all` — Access full profile info including heart rate zones
> - `read` — Access public data
---
## Configuration
Copy the example environment file and fill in your credentials:
Copy the example environment file:
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
Edit `.env` and fill in your Client ID and Secret:
Edit `.env`:
```env ```env
STRAVA_CLIENT_ID=your_client_id_here STRAVA_CLIENT_ID=your_client_id_here
STRAVA_CLIENT_SECRET=your_client_secret_here STRAVA_CLIENT_SECRET=your_client_secret_here
STRAVA_REFRESH_TOKEN=your_refresh_token_here
``` ```
> **Note:** The `STRAVA_REFRESH_TOKEN` is written automatically by `get_token.py`. You only need to fill in `STRAVA_CLIENT_ID` and `STRAVA_CLIENT_SECRET` manually. ### 3. Authenticate (The Magic Way ✨)
You **do not** need to manually fiddle with OAuth tokens. The server includes an interactive MCP tool to handle authentication!
1. Start the server (`docker run ...` or `uv run strava-mcp`).
2. Connect to the server via an MCP Client (like Claude Desktop or MCP Inspector).
3. Call the `get_new_oauth_token` MCP tool.
4. Your browser will open for you to authorize the app. The server will intercept the callback locally, generate your tokens, and automatically save the `STRAVA_REFRESH_TOKEN` to your `.env` file!
> **Required OAuth Scopes:**
> `activity:read_all,profile:read_all,read`
--- ---
## Running the Server ## Connecting with MCP Clients
```bash The server listens on **port 8000** by default and exposes an SSE endpoint:
uv run strava-mcp `http://localhost:8000/mcp`
```
Expected output:
```
🚀 Starting Strava MCP Server on http://0.0.0.0:8000
MCP endpoint: http://0.0.0.0:8000/mcp (Streamable HTTP)
INFO: Started server process [12345]
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
```
The server listens on **port 8000** by default and exposes a single endpoint:
```
http://localhost:8000/mcp
```
### Stopping the Server
Press `Ctrl+C` for a clean shutdown. If the port is already in use from a previous run:
```bash
lsof -ti :8000 | xargs kill -9
```
---
## Connecting with MCP Inspector
1. Open [MCP Inspector](https://inspector.modelcontextprotocol.io/) or run it locally
2. Select transport: **Streamable HTTP**
3. Enter URL: `http://localhost:8000/mcp`
4. Click **Connect**
> ⚠️ Make sure to use `http://localhost:8000/mcp` **without a trailing slash** and with the `Accept: application/json, text/event-stream` header (handled automatically by MCP clients).
### Claude Desktop ### Claude Desktop
@@ -169,99 +137,50 @@ Add to your `claude_desktop_config.json`:
} }
``` ```
### MCP Inspector
1. Open [MCP Inspector](https://inspector.modelcontextprotocol.io/)
2. Select transport: **Streamable HTTP**
3. Enter URL: `http://localhost:8000/mcp`
4. Click **Connect**
--- ---
## MCP Primitives ## MCP Primitives
### Tools ### Tools
#### 🏃 Athlete #### 🔐 Authentication
| Tool | Description |
|------|-------------|
| `get_new_oauth_token` | Starts the interactive browser OAuth2 flow to generate and save your initial Refresh Token |
| Tool | Parameters | Description | #### 🏃 Athlete
|------|-----------|-------------| | Tool | Description |
| `get_athlete_profile` | — | Full athlete profile: name, city, country, follower count, gear list | |------|-------------|
| `get_athlete_stats` | — | Training totals: all-time, year-to-date, and last 4 weeks for runs, rides, and swims | | `get_athlete_profile` | Full athlete profile: name, city, country, follower count, gear list |
| `get_athlete_zones` | — | Heart rate and power zones | | `get_athlete_stats` | Training totals: all-time, year-to-date, and last 4 weeks for runs, rides, and swims |
| `get_athlete_zones` | Heart rate and power zones |
#### 🚴 Activities #### 🚴 Activities
| Tool | Description |
|------|-------------|
| `list_activities` | Paginated activity list with optional time range filters |
| `get_activity_details` | Full activity details incl. segment efforts |
| `get_activity_laps` | Lap splits |
| `get_activity_zones` | Heart rate and power zones for a specific activity |
| `get_activity_comments` | Comments on an activity |
| `get_activity_kudoers` | Athletes who gave kudos |
| `get_activity_streams` | Raw GPS/sensor data streams |
| Tool | Parameters | Description | *(Note: Additional tools exist for Clubs, Routes, Segments, Segment Efforts, and Gear. See MCP Inspector for full details.)*
|------|-----------|-------------|
| `list_activities` | `limit`, `page`, `before`*, `after`* | Paginated activity list with optional time range filters |
| `get_activity_details` | `activity_id` | Full activity details incl. segment efforts (IDs returned as strings) |
| `get_activity_laps` | `activity_id` | Lap splits |
| `get_activity_zones` | `activity_id` | Heart rate and power zones for a specific activity |
| `get_activity_comments` | `activity_id`, `limit` | Comments on an activity |
| `get_activity_kudoers` | `activity_id`, `limit` | Athletes who gave kudos |
| `get_activity_streams` | `activity_id`, `keys` | Raw GPS/sensor data streams |
> *`before` and `after` are Unix timestamps. Example: `after=1704067200` = since 2024-01-01.
#### 🏛️ Clubs
| Tool | Parameters | Description |
|------|-----------|-------------|
| `list_athlete_clubs` | `limit` | Clubs the athlete is a member of |
| `get_club` | `club_id` | Club details |
| `get_club_activities` | `club_id`, `limit` | Recent club activity feed |
| `get_club_members` | `club_id`, `limit` | Club members |
#### 🗺️ Routes
| Tool | Parameters | Description |
|------|-----------|-------------|
| `get_routes_by_athlete_id` | `limit` | Athlete's created routes |
| `get_route_by_id` | `route_id: str` | Route details including segments |
| `get_route_streams` | `route_id: str` | Route GPS/elevation streams |
> ⚠️ Route IDs are returned as **strings** to prevent JavaScript float precision loss (IDs exceed `Number.MAX_SAFE_INTEGER`).
#### 📍 Segments
| Tool | Parameters | Description |
|------|-----------|-------------|
| `get_segment` | `segment_id` | Segment details, grade, elevation, effort counts |
| `list_starred_segments` | `limit` | Segments starred by the athlete |
| `explore_segments` | `bounds`, `activity_type` | Discover popular segments in a geographic bounding box |
| `get_segment_streams` | `segment_id`, `keys` | Segment GPS/elevation streams |
#### ⚡ Segment Efforts
| Tool | Parameters | Description |
|------|-----------|-------------|
| `get_segment_effort` | `effort_id: str` | Details for a specific effort — requires Strava subscription |
| `list_segment_efforts` | `segment_id`, `start_date_local`, `end_date_local`, `limit` | Athlete's efforts on a segment — requires Strava subscription |
| `get_segment_effort_streams` | `effort_id: str`, `keys` | Raw streams for a segment effort — requires Strava subscription |
> ️ **Effort IDs** must be copied as strings from `get_activity_details` response. Do not retype the numbers manually — they exceed JavaScript's safe integer range.
#### ⚙️ Gear
| Tool | Parameters | Description |
|------|-----------|-------------|
| `get_gear_by_id` | `gear_id: str` | Bike or shoe details: brand, model, total distance |
> Gear IDs start with `b` for bikes and `g` for shoes (e.g. `b12345678`). Find them in `get_activity_details` under `gear_id`.
### Prompts ### Prompts
Prompts pre-structure AI conversations with the right tool-calling instructions. Prompts pre-structure AI conversations with the right tool-calling instructions.
#### `analyze_activity` - **`analyze_activity`**: Triggers a structured analysis of a specific activity including summary, performance metrics, and key takeaways.
- **`training_summary`**: Generates a training load report for the last N weeks (volume, trends, recommendations).
```
Parameters: activity_id (str)
```
Triggers a structured analysis of a specific activity including summary, performance metrics, segment highlights, gear used, and key takeaways.
#### `training_summary`
```
Parameters: weeks (int, default: 4)
```
Generates a training load report for the last N weeks: volume by sport, highlights, trend vs. year-to-date, and recommendations. Automatically calculates the required Unix timestamp for filtering.
--- ---
@@ -269,33 +188,41 @@ Generates a training load report for the last N weeks: volume by sport, highligh
``` ```
strava-mcp-server/ strava-mcp-server/
├── Dockerfile # Multi-stage optimized uv build
├── src/ ├── src/
│ └── strava_mcp/ # Installable Python package │ └── strava_mcp_server/ # Installable Python package
│ ├── __init__.py │ ├── __init__.py
│ ├── main.py # Server entrypoint → strava-mcp command │ ├── main.py # Server entrypoint → strava-mcp
│ ├── tools.py # All MCP tools, resources, and prompts
│ ├── strava_client.py # Strava API client with auto token rotation │ ├── strava_client.py # Strava API client with auto token rotation
│ └── get_token.py # OAuth2 flow → strava-mcp-get-token command │ └── tools/ # Modularized MCP tools directory
│ ├── __init__.py # Tool registry
│ ├── activities.py
│ ├── athlete.py
│ ├── auth.py # OAuth automation flow
│ └── ...
├── .gitea/
│ └── workflows/ # Gitea Actions CI/CD Pipeline
├── tests/ ├── tests/
│ └── test_strava.py # Manual API integration tests ├── pyproject.toml
── pyproject.toml # Package metadata, dependencies, entry points ── .env
├── .env # Credentials (not committed)
├── .env.example # Credential template
├── TODO.md # Planned next steps
└── README.md # This file
``` ```
--- ---
## CI/CD (Gitea Actions)
This repository includes a pre-configured Gitea Action (`.gitea/workflows/cicd.yml`) that automatically:
1. **Lints** the codebase using `ruff` on every push/PR.
2. **Builds & Pushes** the Docker container to the local Container Registry (`git.hnrx.net`) upon a successful merge to `main`.
---
## Known Strava API Limitations ## Known Strava API Limitations
The following endpoints are **restricted by Strava** and return errors for regular API users:
| Endpoint | Status | Reason | | Endpoint | Status | Reason |
|----------|--------|--------| |----------|--------|--------|
| `GET /segments/{id}/leaderboard` | `403 Forbidden` | Requires Strava API partnership | | `GET /segments/{id}/leaderboard` | `403 Forbidden` | Requires Strava API partnership |
| `GET /segment_efforts/{id}` | `403 Forbidden` | Requires Strava API partnership | | `GET /segment_efforts/{id}` | `403 Forbidden` | Requires Strava API partnership |
| `GET /segment_efforts?segment_id=X` | `200 []` (empty) | Silently restricted since 2018 — data accessible via `get_activity_details` |
| `GET /athlete/zones` | `401 Unauthorized` | Requires `profile:read_all` OAuth scope | | `GET /athlete/zones` | `401 Unauthorized` | Requires `profile:read_all` OAuth scope |
> **Workaround for segment efforts:** Use `get_activity_details` to access segment efforts embedded in activity data. The `segment_efforts[]` array contains effort IDs, times, heart rate, power, and PR/KOM ranks. > **Workaround for segment efforts:** Use `get_activity_details` to access segment efforts embedded in activity data. The `segment_efforts[]` array contains effort IDs, times, heart rate, power, and PR/KOM ranks.
@@ -305,44 +232,17 @@ The following endpoints are **restricted by Strava** and return errors for regul
## Troubleshooting ## Troubleshooting
### `[Errno 48] Address already in use` ### `[Errno 48] Address already in use`
Port 8000 is occupied by a previous server process: Port 8000 is occupied by a previous server process:
```bash ```bash
lsof -ti :8000 | xargs kill -9 lsof -ti :8000 | xargs kill -9
``` ```
### `401 Unauthorized` on token refresh ### ModuleNotFoundError / iCloud Sync Issues (macOS)
If you are developing locally on macOS and your `strava-mcp-server` directory is located inside `Documents/` or `Desktop/`, **iCloud Drive** will constantly sync and delete files inside your virtual environment (`.venv`), leading to missing packages.
**Solution:** Move the project out of iCloud or rename the folder to end in `.nosync` (e.g. `strava-mcp-server.nosync`).
Your refresh token has expired or been rotated. Generate a new one: ### 401 Unauthorized
Your refresh token has expired or been revoked. Simply run the `get_new_oauth_token` MCP tool again to re-authenticate!
```bash
uv run strava-mcp-get-token
```
### Empty results from `list_segment_efforts`
The direct Strava endpoint is restricted. Access segment efforts via `get_activity_details` instead — the full activity response includes all segment efforts.
### `406 Not Acceptable` from MCP endpoint
The MCP client is missing the required `Accept: application/json, text/event-stream` header. Use a proper MCP client (MCP Inspector, Claude Desktop) instead of raw `curl`.
### Route or effort `404 Not Found` with a large ID
The ID was truncated due to JavaScript float precision. Always use the **string IDs** returned by the server — never copy-type large numeric IDs manually.
---
## Tech Stack
| Component | Library |
|-----------|---------|
| MCP Server | [FastMCP](https://github.com/jlowin/fastmcp) |
| HTTP Transport | Uvicorn + Starlette (via FastMCP) |
| Strava API Client | [httpx](https://www.python-httpx.org/) |
| Environment Config | [python-dotenv](https://github.com/theskumar/python-dotenv) |
| Package Manager | [uv](https://github.com/astral-sh/uv) |
--- ---
+5 -2
View File
@@ -1,5 +1,5 @@
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
@@ -7,7 +7,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "strava-mcp-server" name = "strava-mcp-server"
version = "0.1.0" dynamic = ["version"]
description = "A Model Context Protocol (MCP) server that exposes the Strava API v3 as tools, resources, and prompts for AI agents." description = "A Model Context Protocol (MCP) server that exposes the Strava API v3 as tools, resources, and prompts for AI agents."
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
@@ -45,3 +45,6 @@ strava-mcp-get-token = "strava_mcp_server.get_token:main"
dev = [ dev = [
"ruff>=0.15.12", "ruff>=0.15.12",
] ]
[tool.hatch.version]
source = "vcs"
+6 -1
View File
@@ -1,3 +1,4 @@
import importlib.metadata
import os import os
import sys import sys
@@ -43,7 +44,11 @@ def main() -> None:
streamable_http_path="/mcp", streamable_http_path="/mcp",
stateless_http=True, stateless_http=True,
) )
mcp._mcp_server.version = "0.1.0"
try:
mcp._mcp_server.version = importlib.metadata.version("strava-mcp-server")
except importlib.metadata.PackageNotFoundError:
mcp._mcp_server.version = "dev"
register_tools(mcp, strava) register_tools(mcp, strava)
Generated
-1
View File
@@ -813,7 +813,6 @@ wheels = [
[[package]] [[package]]
name = "strava-mcp-server" name = "strava-mcp-server"
version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },