diff --git a/Dockerfile b/Dockerfile index 626107d..d137e2e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,8 +27,10 @@ RUN uv sync --frozen --no-dev # Make the executable available in the 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 -# 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"] diff --git a/README.md b/README.md index c694413..4896d60 100644 --- a/README.md +++ b/README.md @@ -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) - đŦ **2 MCP Prompts** for structured AI-driven training analysis -- đ **Fully Automated OAuth** â authentication flow integrated directly as an MCP tool with auto-rotation -- đŗ **Docker-Ready** â highly optimized multi-stage Docker build utilizing `uv` +- đ **Automated OAuth** â authentication flow via a standalone setup script with auto-rotation +- đŗ **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) - đ **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) - [Local Python (uv)](#local-python-uv) - [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) - [Project Structure](#project-structure) - [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 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) -The project includes a highly optimized, deterministic Dockerfile powered by `uv`. +The project includes a multi-arch Docker build (amd64/arm64). ```bash # Clone the repository git clone https://git.hnrx.net/hnrx/strava-mcp-server.git cd strava-mcp-server -# Build the image +# Build the image locally docker build -t strava-mcp-server:latest . # 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) +We use `uv` for lightning-fast dependency management and task execution. + ```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 +# Start the MCP server +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 @@ -105,27 +101,22 @@ STRAVA_CLIENT_SECRET=your_client_secret_here ### 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`). -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` +1. **Start the server** (it will log a warning that the refresh token is missing, but it will boot!). +2. **Run the Auth Script**: + - Run `uv run auth` in your terminal on your local machine. +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! --- ## Connecting with MCP Clients -The server listens on **port 8000** by default and exposes an SSE endpoint: -`http://localhost:8000/mcp` +The server listens on **port 8000** and exposes an SSE endpoint: `http://localhost:8000/mcp` ### Claude Desktop - -Add to your `claude_desktop_config.json`: - +Add to `claude_desktop_config.json`: ```json { "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 ### Tools -#### đ Authentication -| Tool | Description | -|------|-------------| -| `get_new_oauth_token` | Starts the interactive browser OAuth2 flow to generate and save your initial Refresh Token | - -#### đ 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 -``` +| Category | Tools | +|----------|-------| +| đ **Athlete** | `get_athlete_profile`, `get_athlete_stats`, `get_athlete_zones` | +| đ´ **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` | --- ## 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 - -| 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. +Our pipeline (`.gitea/workflows/cicd.yml`) is fully automated: +- **Linting:** Every push/PR is checked with `ruff`. +- **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. --- ## Troubleshooting ### `[Errno 48] Address already in use` -Port 8000 is occupied by a previous server process: -```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`). +`lsof -ti :8000 | xargs kill -9` ### 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 . +``` --- diff --git a/src/strava_mcp_server/main.py b/src/strava_mcp_server/main.py index 8f0f910..085d1ae 100644 --- a/src/strava_mcp_server/main.py +++ b/src/strava_mcp_server/main.py @@ -27,7 +27,7 @@ def validate_credentials() -> None: if not os.getenv("STRAVA_REFRESH_TOKEN"): 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: @@ -56,12 +56,20 @@ def main() -> None: register_tools(mcp, strava) - 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 + # Check transport mode from environment (Default to stdio for local dev) + transport = os.getenv("MCP_TRANSPORT", "stdio") + + if transport == "http": + # Run in Streamable HTTP mode (standard for Docker, K8s and OpenWebUI) + 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__": diff --git a/src/strava_mcp_server/strava_client.py b/src/strava_mcp_server/strava_client.py index 8c7f2c7..3ec14aa 100644 --- a/src/strava_mcp_server/strava_client.py +++ b/src/strava_mcp_server/strava_client.py @@ -37,7 +37,7 @@ class StravaClient: async def get_valid_token(self) -> str: """Returns a valid access token, refreshing it if necessary.""" 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: await self._refresh_access_token() diff --git a/src/strava_mcp_server/tools/__init__.py b/src/strava_mcp_server/tools/__init__.py index 029cde6..8051a4f 100644 --- a/src/strava_mcp_server/tools/__init__.py +++ b/src/strava_mcp_server/tools/__init__.py @@ -14,7 +14,6 @@ from . import segments from . import segment_efforts from . import gear from . import prompts -from . import auth def register_tools(mcp: FastMCP, strava: StravaClient) -> None: """Register all available tools and prompts.""" @@ -26,4 +25,3 @@ def register_tools(mcp: FastMCP, strava: StravaClient) -> None: segment_efforts.register(mcp, strava) gear.register(mcp, strava) prompts.register(mcp, strava) - auth.register(mcp, strava) diff --git a/src/strava_mcp_server/tools/auth.py b/src/strava_mcp_server/tools/auth.py deleted file mode 100644 index 8c02a5b..0000000 --- a/src/strava_mcp_server/tools/auth.py +++ /dev/null @@ -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""" - -
- - - -You have successfully authenticated with Strava. You can now close this window.
-Copy the following block into your .env file in the project root:
STRAVA_CLIENT_ID={self.client_id}
-STRAVA_CLIENT_SECRET={self.client_secret}
-STRAVA_REFRESH_TOKEN={refresh_token}
- If you are deploying this server to Kubernetes, run the following command to create the required Secret:
-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}
- - — Strava MCP Server Authorization Helper — -
- - - """.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} -""")]