refactor: remove interactive OAuth tool and update Docker/README configurations
This commit is contained in:
+5
-3
@@ -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"]
|
||||||
|
|||||||
@@ -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 .
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
# 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"🚀 Starting Strava MCP Server on http://{host}:{port}")
|
||||||
print(f" MCP endpoint: http://{host}:{port}/mcp (Streamable HTTP)")
|
print(f" MCP endpoint: http://{host}:{port}/mcp (Streamable HTTP)")
|
||||||
try:
|
try:
|
||||||
mcp.run(transport="streamable-http")
|
mcp.run(transport="streamable-http")
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
|
else:
|
||||||
|
# Run in STDIO mode (default for local testing and Claude Desktop)
|
||||||
|
mcp.run(transport="stdio")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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">✅</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;">
|
|
||||||
— Strava MCP Server Authorization Helper —
|
|
||||||
</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}
|
|
||||||
""")]
|
|
||||||
Reference in New Issue
Block a user