refactor: remove interactive OAuth tool and update Docker/README configurations
CI/CD Pipeline / Lint & Check (push) Successful in 10s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m19s

This commit is contained in:
2026-05-10 11:44:39 +02:00
parent 578c4b292a
commit c56f7ad7b4
6 changed files with 100 additions and 313 deletions
+5 -3
View File
@@ -27,8 +27,10 @@ RUN uv sync --frozen --no-dev
# Make the executable available in the path # Make the executable available in the path
ENV PATH="/app/.venv/bin:$PATH" ENV PATH="/app/.venv/bin:$PATH"
# Default environment variables for the container
ENV MCP_TRANSPORT=http
ENV PORT=8000
ENV HOST=0.0.0.0
# Run the MCP server # Run the MCP server
# By default, strava-mcp uses fastmcp.run() which exposes stdio.
# If you want to run it as an SSE server, you might need to adjust the command.
# For now, we just call the main entrypoint.
ENTRYPOINT ["strava-mcp"] ENTRYPOINT ["strava-mcp"]
+79 -119
View File
@@ -10,8 +10,10 @@ 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
- 🔄 **Fully Automated OAuth** — authentication flow integrated directly as an MCP tool with auto-rotation - 🔄 **Automated OAuth** — authentication flow via a standalone setup script with auto-rotation
- 🐳 **Docker-Ready** highly optimized multi-stage Docker build utilizing `uv` - 🐳 **Multi-Arch Docker** — optimized builds for `linux/amd64` and `linux/arm64` powered by `uv`
- 🏷️ **Dynamic Versioning** — versions are automatically derived from Git tags (powered by `hatch-vcs`)
- 🤖 **Agent-First Design** — includes specific instructions for LLMs on handling European date formats (DD.MM.YYYY)
- 🌐 **Streamable HTTP transport** for broad client compatibility (SSE) - 🌐 **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
@@ -24,7 +26,7 @@ Built with [FastMCP](https://github.com/jlowin/fastmcp) and the [MCP Python SDK]
- [Docker (Recommended)](#docker-recommended) - [Docker (Recommended)](#docker-recommended)
- [Local Python (uv)](#local-python-uv) - [Local Python (uv)](#local-python-uv)
- [Strava API Setup](#strava-api-setup) - [Strava API Setup](#strava-api-setup)
- [Connecting with MCP Inspector](#connecting-with-mcp-inspector) - [Connecting with MCP Clients](#connecting-with-mcp-clients)
- [MCP Primitives](#mcp-primitives) - [MCP Primitives](#mcp-primitives)
- [Project Structure](#project-structure) - [Project Structure](#project-structure)
- [CI/CD (Gitea Actions)](#cicd-gitea-actions) - [CI/CD (Gitea Actions)](#cicd-gitea-actions)
@@ -37,7 +39,7 @@ Built with [FastMCP](https://github.com/jlowin/fastmcp) and the [MCP Python SDK]
- 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) - **Docker** (for containerized deployment) OR **Python 3.10+** & [uv](https://github.com/astral-sh/uv)
--- ---
@@ -45,14 +47,14 @@ Built with [FastMCP](https://github.com/jlowin/fastmcp) and the [MCP Python SDK]
### Docker (Recommended) ### Docker (Recommended)
The project includes a highly optimized, deterministic Dockerfile powered by `uv`. The project includes a multi-arch Docker build (amd64/arm64).
```bash ```bash
# Clone the repository # Clone the repository
git clone https://git.hnrx.net/hnrx/strava-mcp-server.git git clone https://git.hnrx.net/hnrx/strava-mcp-server.git
cd strava-mcp-server cd strava-mcp-server
# Build the image # Build the image locally
docker build -t strava-mcp-server:latest . docker build -t strava-mcp-server:latest .
# Run the container (injecting your .env file) # Run the container (injecting your .env file)
@@ -61,25 +63,19 @@ docker run --rm -p 8000:8000 --env-file .env strava-mcp-server:latest
### Local Python (uv) ### Local Python (uv)
We use `uv` for lightning-fast dependency management and task execution.
```bash ```bash
git clone https://git.hnrx.net/hnrx/strava-mcp-server.git git clone https://git.hnrx.net/hnrx/strava-mcp-server.git
cd strava-mcp-server cd strava-mcp-server
# Install dependencies and start the server # Start the MCP server
uv run strava-mcp uv run server
# Run the OAuth setup script
uv run auth
``` ```
### 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
@@ -105,27 +101,22 @@ STRAVA_CLIENT_SECRET=your_client_secret_here
### 3. Authenticate (The Magic Way ✨) ### 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! The server is designed for zero-touch deployment. You can authorize it **after** it has started.
1. Start the server (`docker run ...` or `uv run strava-mcp`). 1. **Start the server** (it will log a warning that the refresh token is missing, but it will boot!).
2. Connect to the server via an MCP Client (like Claude Desktop or MCP Inspector). 2. **Run the Auth Script**:
3. Call the `get_new_oauth_token` MCP tool. - Run `uv run auth` in your terminal on your local machine.
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! 3. Your browser will open. Log in and authorize.
4. **Success:** The browser will show you the exact values for your `.env` (or Kubernetes Secret). The script will also automatically update your local `.env` file!
> **Required OAuth Scopes:**
> `activity:read_all,profile:read_all,read`
--- ---
## Connecting with MCP Clients ## Connecting with MCP Clients
The server listens on **port 8000** by default and exposes an SSE endpoint: The server listens on **port 8000** and exposes an SSE endpoint: `http://localhost:8000/mcp`
`http://localhost:8000/mcp`
### Claude Desktop ### Claude Desktop
Add to `claude_desktop_config.json`:
Add to your `claude_desktop_config.json`:
```json ```json
{ {
"mcpServers": { "mcpServers": {
@@ -137,112 +128,81 @@ 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
#### 🔐 Authentication | Category | Tools |
| Tool | Description | |----------|-------|
|------|-------------| | 🏃 **Athlete** | `get_athlete_profile`, `get_athlete_stats`, `get_athlete_zones` |
| `get_new_oauth_token` | Starts the interactive browser OAuth2 flow to generate and save your initial Refresh Token | | 🚴 **Activities** | `list_activities`, `get_activity_details`, `get_activity_laps`, `get_activity_zones`, `get_activity_streams` |
| 🏘️ **Clubs** | `get_athlete_clubs`, `get_club_activities`, `get_club_members` |
#### 🏃 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 |
*(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`**: 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).
---
## Project Structure
```
strava-mcp-server/
├── Dockerfile # Multi-stage optimized uv build
├── src/
│ └── strava_mcp_server/ # Installable Python package
│ ├── __init__.py
│ ├── main.py # Server entrypoint → strava-mcp
│ ├── strava_client.py # Strava API client with auto token rotation
│ └── 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/
├── pyproject.toml
└── .env
```
--- ---
## CI/CD (Gitea Actions) ## CI/CD (Gitea Actions)
This repository includes a pre-configured Gitea Action (`.gitea/workflows/cicd.yml`) that automatically: Our pipeline (`.gitea/workflows/cicd.yml`) is fully automated:
1. **Lints** the codebase using `ruff` on every push/PR. - **Linting:** Every push/PR is checked with `ruff`.
2. **Builds & Pushes** the Docker container to the local Container Registry (`git.hnrx.net`) upon a successful merge to `main`. - **Multi-Arch Builds:** Builds `amd64` and `arm64` images simultaneously using QEMU and DinD.
- **Smart Tagging:**
--- - Pushes to `main` are tagged as `:latest`.
- Git Tags (e.g., `v1.2.0`) trigger a versioned build and **automatically update the Gitea Release description** with the correct `docker pull` command.
## Known Strava API Limitations
| Endpoint | Status | Reason |
|----------|--------|--------|
| `GET /segments/{id}/leaderboard` | `403 Forbidden` | Requires Strava API partnership |
| `GET /segment_efforts/{id}` | `403 Forbidden` | Requires Strava API partnership |
| `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.
--- ---
## Troubleshooting ## Troubleshooting
### `[Errno 48] Address already in use` ### `[Errno 48] Address already in use`
Port 8000 is occupied by a previous server process: `lsof -ti :8000 | xargs kill -9`
```bash
lsof -ti :8000 | xargs kill -9
```
### 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`).
### 401 Unauthorized ### 401 Unauthorized
Your refresh token has expired or been revoked. Simply run the `get_new_oauth_token` MCP tool again to re-authenticate! Your token expired. Run `uv run auth` to refresh.
---
## 🛠️ Development & Testing
### 1. Local Testing with MCP Inspector
The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is the best way to test the server without a full LLM client.
**Option A: Test via STDIO (Fastest)**
This runs the server directly in your terminal (perfect for local debugging):
```bash
npx @modelcontextprotocol/inspector uv run server
```
**Option B: Test via SSE (Remote/Docker)**
If the server is already running (e.g., at `http://localhost:8000`):
1. Open [https://inspector.modelcontextprotocol.io/](https://inspector.modelcontextprotocol.io/)
2. Transport: **Streamable HTTP**
3. URL: `http://localhost:8000/mcp`
### 2. Manual SSE Health Check
You can verify if the server is responding to SSE requests using `curl`:
```bash
curl -v -X POST http://localhost:8000/mcp
```
*(It should return an SSE stream starting with `event: endpoint`)*
### 3. Linting & Formatting
We use `ruff` for code quality:
```bash
# Run the check
uv run ruff check src
# Run the formatter
uv run ruff format src
```
### 4. Build Multi-Arch Images
To test if the multi-arch Docker build works locally (requires Docker Buildx):
```bash
docker buildx build --platform linux/amd64,linux/arm64 -t strava-mcp-server:test .
```
--- ---
+15 -7
View File
@@ -27,7 +27,7 @@ def validate_credentials() -> None:
if not os.getenv("STRAVA_REFRESH_TOKEN"): if not os.getenv("STRAVA_REFRESH_TOKEN"):
print("️ No STRAVA_REFRESH_TOKEN found. Server starting in unauthenticated mode.") print("️ No STRAVA_REFRESH_TOKEN found. Server starting in unauthenticated mode.")
print(" Use the 'get_new_oauth_token' tool via MCP to authenticate.") print(" Run 'uv run auth' on your local machine to authenticate.")
def main() -> None: def main() -> None:
@@ -56,12 +56,20 @@ def main() -> None:
register_tools(mcp, strava) register_tools(mcp, strava)
print(f"🚀 Starting Strava MCP Server on http://{host}:{port}") # Check transport mode from environment (Default to stdio for local dev)
print(f" MCP endpoint: http://{host}:{port}/mcp (Streamable HTTP)") transport = os.getenv("MCP_TRANSPORT", "stdio")
try:
mcp.run(transport="streamable-http") if transport == "http":
except KeyboardInterrupt: # Run in Streamable HTTP mode (standard for Docker, K8s and OpenWebUI)
pass print(f"🚀 Starting Strava MCP Server on http://{host}:{port}")
print(f" MCP endpoint: http://{host}:{port}/mcp (Streamable HTTP)")
try:
mcp.run(transport="streamable-http")
except KeyboardInterrupt:
pass
else:
# Run in STDIO mode (default for local testing and Claude Desktop)
mcp.run(transport="stdio")
if __name__ == "__main__": if __name__ == "__main__":
+1 -1
View File
@@ -37,7 +37,7 @@ class StravaClient:
async def get_valid_token(self) -> str: async def get_valid_token(self) -> str:
"""Returns a valid access token, refreshing it if necessary.""" """Returns a valid access token, refreshing it if necessary."""
if not self.refresh_token: if not self.refresh_token:
raise ValueError("No Strava refresh token found. Please run the 'get_new_oauth_token' MCP tool to authenticate first.") raise ValueError("No Strava refresh token found. Please run 'uv run auth' on your local machine to authenticate first.")
if not self.access_token or time.time() > self.expires_at - 60: if not self.access_token or time.time() > self.expires_at - 60:
await self._refresh_access_token() await self._refresh_access_token()
-2
View File
@@ -14,7 +14,6 @@ from . import segments
from . import segment_efforts from . import segment_efforts
from . import gear from . import gear
from . import prompts from . import prompts
from . import auth
def register_tools(mcp: FastMCP, strava: StravaClient) -> None: def register_tools(mcp: FastMCP, strava: StravaClient) -> None:
"""Register all available tools and prompts.""" """Register all available tools and prompts."""
@@ -26,4 +25,3 @@ def register_tools(mcp: FastMCP, strava: StravaClient) -> None:
segment_efforts.register(mcp, strava) segment_efforts.register(mcp, strava)
gear.register(mcp, strava) gear.register(mcp, strava)
prompts.register(mcp, strava) prompts.register(mcp, strava)
auth.register(mcp, strava)
-181
View File
@@ -1,181 +0,0 @@
import os
import webbrowser
import httpx
import asyncio
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from mcp.server.fastmcp import FastMCP, Context
from mcp.types import TextContent
class CallbackHandler(BaseHTTPRequestHandler):
client_id: str = ""
client_secret: str = ""
tokens: dict = {}
error: str | None = None
def do_GET(self):
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
if "code" in params:
code = params["code"][0]
try:
# Exchange code for token synchronously inside the handler
import httpx
response = httpx.post(
"https://www.strava.com/oauth/token",
data={
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"grant_type": "authorization_code",
},
)
response.raise_for_status()
self.tokens = response.json()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
refresh_token = self.tokens.get("refresh_token")
self.wfile.write(f"""
<html>
<head>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 800px; margin: 40px auto; padding: 20px; text-align: left; }}
.card {{ background: #f4f7f6; border-radius: 8px; padding: 20px; border-left: 5px solid #fc4c02; margin-top: 20px; }}
pre {{ background: #222; color: #fff; padding: 15px; border-radius: 5px; overflow-x: auto; font-family: "SF Mono", Monaco, Consolas, monospace; font-size: 13px; }}
.success-header {{ text-align: center; margin-bottom: 40px; }}
.success-icon {{ color: #2ecc71; font-size: 48px; display: block; margin-bottom: 10px; }}
h2, h3 {{ color: #fc4c02; }}
.env-label {{ font-weight: bold; color: #666; font-size: 12px; text-transform: uppercase; margin-bottom: 5px; display: block; }}
.copy-hint {{ font-size: 12px; color: #666; font-style: italic; }}
code {{ background: #eee; padding: 2px 4px; border-radius: 4px; }}
</style>
</head>
<body>
<div class="success-header">
<span class="success-icon">&#x2705;</span>
<h2>Authorization successful!</h2>
<p>You have successfully authenticated with Strava. You can now close this window.</p>
</div>
<h3>1. Local Setup (.env)</h3>
<p>Copy the following block into your <code>.env</code> file in the project root:</p>
<div class="card">
<span class="env-label">Your .env content:</span>
<pre>STRAVA_CLIENT_ID={self.client_id}
STRAVA_CLIENT_SECRET={self.client_secret}
STRAVA_REFRESH_TOKEN={refresh_token}</pre>
</div>
<h3>2. Kubernetes Setup (Secret)</h3>
<p>If you are deploying this server to Kubernetes, run the following command to create the required Secret:</p>
<div class="card">
<span class="env-label">Kubectl Command:</span>
<pre>kubectl create secret generic strava-mcp-server-secret \\
--from-literal=STRAVA_CLIENT_ID={self.client_id} \\
--from-literal=STRAVA_CLIENT_SECRET={self.client_secret} \\
--from-literal=STRAVA_REFRESH_TOKEN={refresh_token}</pre>
</div>
<p style="margin-top: 40px; font-size: 14px; color: #666; text-align: center;">
&mdash; Strava MCP Server Authorization Helper &mdash;
</p>
</body>
</html>
""".encode("utf-8"))
except Exception as e:
self.error = str(e)
self.send_response(500)
self.end_headers()
self.wfile.write(f"Error exchanging token: {e}".encode())
else:
error_msg = params.get("error", ["unknown"])[0]
self.send_response(400)
self.end_headers()
self.wfile.write(f"Error: {error_msg}".encode())
def log_message(self, format, *args):
pass # Suppress server logs
def register(mcp: FastMCP, strava) -> None:
@mcp.tool()
async def get_new_oauth_token(ctx: Context) -> list[TextContent]:
"""
Start the interactive Strava OAuth2 authorization flow.
This opens a browser window for the user to log in and authorize the app.
It then intercepts the redirect locally, obtains the token, and returns the tokens.
"""
client_id = os.getenv("STRAVA_CLIENT_ID")
client_secret = os.getenv("STRAVA_CLIENT_SECRET")
if not client_id or not client_secret:
return [TextContent(type="text", text="Error: Missing STRAVA_CLIENT_ID or STRAVA_CLIENT_SECRET in .env")]
# Configure handler with credentials
CallbackHandler.client_id = client_id
CallbackHandler.client_secret = client_secret
CallbackHandler.tokens = {}
CallbackHandler.error = None
redirect_uri = "http://localhost:8765/callback"
scopes = "profile:read_all,activity:read_all,activity:read,profile:write"
auth_url = (
f"https://www.strava.com/oauth/authorize"
f"?client_id={client_id}"
f"&redirect_uri={redirect_uri}"
f"&response_type=code"
f"&approval_prompt=force"
f"&scope={scopes}"
)
await ctx.info("Opening browser for Strava Authorization...")
webbrowser.open(auth_url)
await ctx.info("Waiting for you to log in and authorize (Browser opened on your computer)...")
server = HTTPServer(("localhost", 8765), CallbackHandler)
# Run handle_request in a separate thread so it doesn't block the async event loop
await asyncio.to_thread(server.handle_request)
if CallbackHandler.error:
return [TextContent(type="text", text=f"Error during token exchange: {CallbackHandler.error}")]
if not CallbackHandler.tokens:
return [TextContent(type="text", text="Error: No tokens received.")]
await ctx.info("Tokens received successfully.")
refresh_token = CallbackHandler.tokens.get("refresh_token")
# Update the .env file if it exists
env_msg = ""
try:
env_path = ".env"
if os.path.exists(env_path):
with open(env_path, "r") as f:
lines = f.readlines()
with open(env_path, "w") as f:
found = False
for line in lines:
if line.startswith("STRAVA_REFRESH_TOKEN="):
f.write(f"STRAVA_REFRESH_TOKEN={refresh_token}\n")
found = True
else:
f.write(line)
if not found:
f.write(f"\nSTRAVA_REFRESH_TOKEN={refresh_token}\n")
env_msg = "\nI have also automatically updated your .env file with the new refresh token!"
except Exception as e:
env_msg = f"\nFailed to automatically update .env file: {e}"
return [TextContent(type="text", text=f"""
✅ Authorization successful!
You have successfully authenticated with Strava.
Your new Refresh Token is: `{refresh_token}`
{env_msg}
""")]