From 2b061f47911388262aec34ea0c6896d888f6eacc Mon Sep 17 00:00:00 2001 From: Matthias Hinrichs Date: Sat, 9 May 2026 02:57:41 +0200 Subject: [PATCH] feat: implement dynamic versioning and add automated Docker CI/CD workflow with enhanced documentation --- .gitea/workflows/cicd.yml | 9 +- Dockerfile | 4 + README.md | 322 ++++++++++++---------------------- pyproject.toml | 7 +- src/strava_mcp_server/main.py | 7 +- uv.lock | 1 - 6 files changed, 132 insertions(+), 218 deletions(-) diff --git a/.gitea/workflows/cicd.yml b/.gitea/workflows/cicd.yml index 1743b38..42d8f65 100644 --- a/.gitea/workflows/cicd.yml +++ b/.gitea/workflows/cicd.yml @@ -4,6 +4,8 @@ on: push: branches: - main + tags: + - 'v*' pull_request: branches: - main @@ -18,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v2 @@ -35,7 +37,7 @@ jobs: build-and-push: name: Build & Push Docker Image needs: lint - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: packages: write @@ -58,7 +60,7 @@ jobs: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=latest,enable={{is_default_branch}} - type=sha + type=ref,event=tag - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -68,6 +70,7 @@ jobs: with: context: . push: true + build-args: VERSION=${{ steps.meta.outputs.version }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache diff --git a/Dockerfile b/Dockerfile index e1dcb65..626107d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,10 @@ ENV UV_COMPILE_BYTECODE=1 # Copy the lockfile and pyproject.toml 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 RUN uv sync --frozen --no-install-project --no-dev diff --git a/README.md b/README.md index 862aaf1..c694413 100644 --- a/README.md +++ b/README.md @@ -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) - πŸ’¬ **2 MCP Prompts** for structured AI-driven training analysis -- πŸ”„ **Automatic OAuth token rotation** β€” tokens are refreshed transparently -- 🌐 **Streamable HTTP transport** for broad client compatibility +- πŸ”„ **Fully Automated OAuth** β€” authentication flow integrated directly as an MCP tool with auto-rotation +- 🐳 **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 --- @@ -19,16 +20,14 @@ Built with [FastMCP](https://github.com/jlowin/fastmcp) and the [MCP Python SDK] ## πŸ“‹ Table of Contents - [Requirements](#requirements) -- [Installation](#installation) +- [Installation & Deployment](#installation--deployment) + - [Docker (Recommended)](#docker-recommended) + - [Local Python (uv)](#local-python-uv) - [Strava API Setup](#strava-api-setup) -- [Configuration](#configuration) -- [Running the Server](#running-the-server) - [Connecting with MCP Inspector](#connecting-with-mcp-inspector) - [MCP Primitives](#mcp-primitives) - - - [Tools](#tools) - - [Prompts](#prompts) - [Project Structure](#project-structure) +- [CI/CD (Gitea Actions)](#cicd-gitea-actions) - [Known Strava API Limitations](#known-strava-api-limitations) - [Troubleshooting](#troubleshooting) @@ -36,28 +35,51 @@ Built with [FastMCP](https://github.com/jlowin/fastmcp) and the [MCP Python SDK] ## 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 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 # Clone the repository -git clone +git clone https://git.hnrx.net/hnrx/strava-mcp-server.git cd strava-mcp-server -# Install dependencies with uv (also installs the package itself) -uv sync +# Build the image +docker build -t strava-mcp-server:latest . -# Or install directly from PyPI (once published) -uvx strava-mcp-server -# or: pip install strava-mcp-server +# Run the container (injecting your .env file) +docker run --rm -p 8000:8000 --env-file .env strava-mcp-server:latest ``` +### 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 @@ -69,90 +91,36 @@ uvx strava-mcp-server 3. Set **Authorization Callback Domain** to `localhost` 4. Note your **Client ID** and **Client Secret** -### 2. Generate Your OAuth Tokens - -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: +### 2. Configure Environment +Copy the example environment file: ```bash cp .env.example .env ``` - -Edit `.env`: - +Edit `.env` and fill in your Client ID and Secret: ```env STRAVA_CLIENT_ID=your_client_id_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 -uv run strava-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). +The server listens on **port 8000** by default and exposes an SSE endpoint: +`http://localhost:8000/mcp` ### 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 ### 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 | -|------|-----------|-------------| -| `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_zones` | β€” | Heart rate and power zones | +#### πŸƒ 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_zones` | Heart rate and power zones | #### 🚴 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 | -|------|-----------|-------------| -| `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`. +*(Note: Additional tools exist for Clubs, Routes, Segments, Segment Efforts, and Gear. See MCP Inspector for full details.)* ### Prompts Prompts pre-structure AI conversations with the right tool-calling instructions. -#### `analyze_activity` - -``` -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. +- **`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). --- @@ -269,33 +188,41 @@ Generates a training load report for the last N weeks: volume by sport, highligh ``` strava-mcp-server/ +β”œβ”€β”€ Dockerfile # Multi-stage optimized uv build β”œβ”€β”€ src/ -β”‚ └── strava_mcp/ # Installable Python package +β”‚ └── strava_mcp_server/ # Installable Python package β”‚ β”œβ”€β”€ __init__.py -β”‚ β”œβ”€β”€ main.py # Server entrypoint β†’ strava-mcp command -β”‚ β”œβ”€β”€ tools.py # All MCP tools, resources, and prompts +β”‚ β”œβ”€β”€ main.py # Server entrypoint β†’ strava-mcp β”‚ β”œβ”€β”€ 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/ -β”‚ └── test_strava.py # Manual API integration tests -β”œβ”€β”€ pyproject.toml # Package metadata, dependencies, entry points -β”œβ”€β”€ .env # Credentials (not committed) -β”œβ”€β”€ .env.example # Credential template -β”œβ”€β”€ TODO.md # Planned next steps -└── README.md # This file +β”œβ”€β”€ pyproject.toml +└── .env ``` --- +## 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 -The following endpoints are **restricted by Strava** and return errors for regular API users: - | Endpoint | Status | Reason | |----------|--------|--------| | `GET /segments/{id}/leaderboard` | `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 | > **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 ### `[Errno 48] Address already in use` - Port 8000 is occupied by a previous server process: - ```bash 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: - -```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) | +### 401 Unauthorized +Your refresh token has expired or been revoked. Simply run the `get_new_oauth_token` MCP tool again to re-authenticate! --- diff --git a/pyproject.toml b/pyproject.toml index 2772722..c10f8aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" @@ -7,7 +7,7 @@ build-backend = "hatchling.build" [project] 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." readme = "README.md" license = { text = "MIT" } @@ -45,3 +45,6 @@ strava-mcp-get-token = "strava_mcp_server.get_token:main" dev = [ "ruff>=0.15.12", ] + +[tool.hatch.version] +source = "vcs" diff --git a/src/strava_mcp_server/main.py b/src/strava_mcp_server/main.py index f0ab3d7..52c2a3b 100644 --- a/src/strava_mcp_server/main.py +++ b/src/strava_mcp_server/main.py @@ -1,3 +1,4 @@ +import importlib.metadata import os import sys @@ -43,7 +44,11 @@ def main() -> None: streamable_http_path="/mcp", 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) diff --git a/uv.lock b/uv.lock index ecc2928..5d1ddb8 100644 --- a/uv.lock +++ b/uv.lock @@ -813,7 +813,6 @@ wheels = [ [[package]] name = "strava-mcp-server" -version = "0.1.0" source = { editable = "." } dependencies = [ { name = "fastapi" },