Spaces:
Sleeping
Sleeping
Merge pull request #1 from yorrickjansen/feature/ci-cd-pipeline
Browse files- .github/workflows/ci.yml +99 -0
- .pre-commit-config.yaml +0 -5
- README.md +3 -1
- codecov.yml +21 -0
- get_token.py +8 -7
- main.py +6 -1
- pyproject.toml +11 -2
- standalone_server.py +0 -167
- strava_mcp/api.py +30 -120
- strava_mcp/auth.py +36 -47
- strava_mcp/config.py +26 -13
- strava_mcp/models.py +20 -62
- strava_mcp/oauth_server.py +36 -21
- strava_mcp/server.py +49 -15
- strava_mcp/service.py +5 -12
- tests/test_api.py +1 -2
- tests/test_auth.py +268 -0
- tests/test_config.py +81 -0
- tests/test_models.py +247 -0
- tests/test_oauth_server.py +272 -0
- tests/test_server.py +59 -26
- tests/test_service.py +53 -25
- uv.lock +60 -1
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI/CD Pipeline
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [ main, master ]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [ main, master ]
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
lint:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
steps:
|
| 13 |
+
- uses: actions/checkout@v4
|
| 14 |
+
- name: Set up Python
|
| 15 |
+
uses: actions/setup-python@v5
|
| 16 |
+
with:
|
| 17 |
+
python-version: "3.13"
|
| 18 |
+
- name: Install uv
|
| 19 |
+
run: |
|
| 20 |
+
pip install --upgrade pip
|
| 21 |
+
pip install uv
|
| 22 |
+
- name: Install dependencies
|
| 23 |
+
run: |
|
| 24 |
+
uv sync
|
| 25 |
+
- name: Lint with ruff
|
| 26 |
+
run: |
|
| 27 |
+
uv run ruff check .
|
| 28 |
+
uv run ruff format --check .
|
| 29 |
+
uv run pyright
|
| 30 |
+
|
| 31 |
+
test:
|
| 32 |
+
runs-on: ubuntu-latest
|
| 33 |
+
steps:
|
| 34 |
+
- uses: actions/checkout@v4
|
| 35 |
+
- name: Set up Python
|
| 36 |
+
uses: actions/setup-python@v5
|
| 37 |
+
with:
|
| 38 |
+
python-version: "3.13"
|
| 39 |
+
- name: Install uv
|
| 40 |
+
run: |
|
| 41 |
+
pip install --upgrade pip
|
| 42 |
+
pip install uv
|
| 43 |
+
- name: Install dependencies
|
| 44 |
+
run: |
|
| 45 |
+
uv sync
|
| 46 |
+
- name: Create dummy .env file for tests
|
| 47 |
+
run: |
|
| 48 |
+
echo "STRAVA_CLIENT_ID=test_client_id" > .env
|
| 49 |
+
echo "STRAVA_CLIENT_SECRET=test_client_secret" >> .env
|
| 50 |
+
echo "STRAVA_REFRESH_TOKEN=test_refresh_token" >> .env
|
| 51 |
+
|
| 52 |
+
- name: Run tests with coverage
|
| 53 |
+
run: |
|
| 54 |
+
uv run pytest --cov=strava_mcp && uv run coverage xml
|
| 55 |
+
|
| 56 |
+
- name: Upload coverage to Codecov
|
| 57 |
+
uses: codecov/codecov-action@v4
|
| 58 |
+
with:
|
| 59 |
+
token: ${{ secrets.CODECOV_TOKEN }}
|
| 60 |
+
file: ./coverage.xml
|
| 61 |
+
|
| 62 |
+
server-startup:
|
| 63 |
+
runs-on: ubuntu-latest
|
| 64 |
+
needs: test
|
| 65 |
+
|
| 66 |
+
steps:
|
| 67 |
+
- uses: actions/checkout@v4
|
| 68 |
+
|
| 69 |
+
- name: Set up Python
|
| 70 |
+
uses: actions/setup-python@v5
|
| 71 |
+
with:
|
| 72 |
+
python-version: "3.13"
|
| 73 |
+
|
| 74 |
+
- name: Install uv
|
| 75 |
+
run: |
|
| 76 |
+
pip install --upgrade pip
|
| 77 |
+
pip install uv
|
| 78 |
+
|
| 79 |
+
- name: Install dependencies
|
| 80 |
+
run: |
|
| 81 |
+
uv sync
|
| 82 |
+
|
| 83 |
+
- name: Create dummy .env file for server
|
| 84 |
+
run: |
|
| 85 |
+
echo "STRAVA_CLIENT_ID=test_client_id" > .env
|
| 86 |
+
echo "STRAVA_CLIENT_SECRET=test_client_secret" >> .env
|
| 87 |
+
echo "STRAVA_REFRESH_TOKEN=test_refresh_token" >> .env
|
| 88 |
+
|
| 89 |
+
- name: Test server startup
|
| 90 |
+
run: |
|
| 91 |
+
# Start the server in the background and kill it after 5 seconds
|
| 92 |
+
timeout 5s uv run mcp dev main.py || code=$?
|
| 93 |
+
if [ $code -eq 124 ]; then
|
| 94 |
+
echo "Server started successfully and was terminated after timeout"
|
| 95 |
+
exit 0
|
| 96 |
+
else
|
| 97 |
+
echo "Server failed to start with exit code $code"
|
| 98 |
+
exit 1
|
| 99 |
+
fi
|
.pre-commit-config.yaml
CHANGED
|
@@ -6,11 +6,6 @@ repos:
|
|
| 6 |
args: [--fix]
|
| 7 |
- id: ruff-format
|
| 8 |
|
| 9 |
-
- repo: https://github.com/pycqa/isort
|
| 10 |
-
rev: 5.13.2
|
| 11 |
-
hooks:
|
| 12 |
-
- id: isort
|
| 13 |
-
|
| 14 |
- repo: https://github.com/RobertCraigie/pyright-python
|
| 15 |
rev: v1.1.397
|
| 16 |
hooks:
|
|
|
|
| 6 |
args: [--fix]
|
| 7 |
- id: ruff-format
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
- repo: https://github.com/RobertCraigie/pyright-python
|
| 10 |
rev: v1.1.397
|
| 11 |
hooks:
|
README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
| 1 |
# Strava MCP Server
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
A Model Context Protocol (MCP) server for interacting with the Strava API.
|
| 4 |
|
| 5 |
## Features
|
|
@@ -167,7 +170,6 @@ Gets the leaderboard for a specific segment.
|
|
| 167 |
- `tests/`: Unit tests
|
| 168 |
- `main.py`: Entry point to run the server
|
| 169 |
- `get_token.py`: Utility script to get a refresh token manually
|
| 170 |
-
- `standalone_server.py`: Utility web server for testing OAuth flow
|
| 171 |
|
| 172 |
### Running Tests
|
| 173 |
|
|
|
|
| 1 |
# Strava MCP Server
|
| 2 |
|
| 3 |
+
[](https://github.com/yorrickjansen/strava-mcp/actions/workflows/ci.yml)
|
| 4 |
+
[](https://codecov.io/gh/yorrickjansen/strava-mcp)
|
| 5 |
+
|
| 6 |
A Model Context Protocol (MCP) server for interacting with the Strava API.
|
| 7 |
|
| 8 |
## Features
|
|
|
|
| 170 |
- `tests/`: Unit tests
|
| 171 |
- `main.py`: Entry point to run the server
|
| 172 |
- `get_token.py`: Utility script to get a refresh token manually
|
|
|
|
| 173 |
|
| 174 |
### Running Tests
|
| 175 |
|
codecov.yml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
codecov:
|
| 2 |
+
require_ci_to_pass: yes
|
| 3 |
+
|
| 4 |
+
coverage:
|
| 5 |
+
precision: 2
|
| 6 |
+
round: down
|
| 7 |
+
range: "70...100"
|
| 8 |
+
status:
|
| 9 |
+
project:
|
| 10 |
+
default:
|
| 11 |
+
target: auto
|
| 12 |
+
threshold: 1%
|
| 13 |
+
patch:
|
| 14 |
+
default:
|
| 15 |
+
target: auto
|
| 16 |
+
threshold: 1%
|
| 17 |
+
|
| 18 |
+
comment:
|
| 19 |
+
layout: "reach,diff,flags,files,footer"
|
| 20 |
+
behavior: default
|
| 21 |
+
require_changes: no
|
get_token.py
CHANGED
|
@@ -5,6 +5,7 @@ import asyncio
|
|
| 5 |
import logging
|
| 6 |
import os
|
| 7 |
import sys
|
|
|
|
| 8 |
from strava_mcp.oauth_server import get_refresh_token_from_oauth
|
| 9 |
|
| 10 |
# Configure logging
|
|
@@ -20,7 +21,7 @@ async def main():
|
|
| 20 |
# Check if client_id and client_secret are provided as env vars
|
| 21 |
client_id = os.environ.get("STRAVA_CLIENT_ID")
|
| 22 |
client_secret = os.environ.get("STRAVA_CLIENT_SECRET")
|
| 23 |
-
|
| 24 |
# If not provided as env vars, check command line args
|
| 25 |
if not client_id or not client_secret:
|
| 26 |
if len(sys.argv) != 3:
|
|
@@ -29,21 +30,21 @@ async def main():
|
|
| 29 |
sys.exit(1)
|
| 30 |
client_id = sys.argv[1]
|
| 31 |
client_secret = sys.argv[2]
|
| 32 |
-
|
| 33 |
print("\nStarting Strava OAuth flow to get a refresh token...")
|
| 34 |
-
|
| 35 |
try:
|
| 36 |
token = await get_refresh_token_from_oauth(client_id, client_secret)
|
| 37 |
print("\n=================================================================")
|
| 38 |
print("SUCCESS! Add this to your environment variables:")
|
| 39 |
print(f"export STRAVA_REFRESH_TOKEN={token}")
|
| 40 |
print("=================================================================\n")
|
| 41 |
-
|
| 42 |
# Also write to a file for easy access
|
| 43 |
with open("strava_token.txt", "w") as f:
|
| 44 |
f.write(f"STRAVA_REFRESH_TOKEN={token}\n")
|
| 45 |
-
print(
|
| 46 |
-
|
| 47 |
except Exception as e:
|
| 48 |
logger.exception("Error getting refresh token")
|
| 49 |
print(f"Error: {e}")
|
|
@@ -51,4 +52,4 @@ async def main():
|
|
| 51 |
|
| 52 |
|
| 53 |
if __name__ == "__main__":
|
| 54 |
-
asyncio.run(main())
|
|
|
|
| 5 |
import logging
|
| 6 |
import os
|
| 7 |
import sys
|
| 8 |
+
|
| 9 |
from strava_mcp.oauth_server import get_refresh_token_from_oauth
|
| 10 |
|
| 11 |
# Configure logging
|
|
|
|
| 21 |
# Check if client_id and client_secret are provided as env vars
|
| 22 |
client_id = os.environ.get("STRAVA_CLIENT_ID")
|
| 23 |
client_secret = os.environ.get("STRAVA_CLIENT_SECRET")
|
| 24 |
+
|
| 25 |
# If not provided as env vars, check command line args
|
| 26 |
if not client_id or not client_secret:
|
| 27 |
if len(sys.argv) != 3:
|
|
|
|
| 30 |
sys.exit(1)
|
| 31 |
client_id = sys.argv[1]
|
| 32 |
client_secret = sys.argv[2]
|
| 33 |
+
|
| 34 |
print("\nStarting Strava OAuth flow to get a refresh token...")
|
| 35 |
+
|
| 36 |
try:
|
| 37 |
token = await get_refresh_token_from_oauth(client_id, client_secret)
|
| 38 |
print("\n=================================================================")
|
| 39 |
print("SUCCESS! Add this to your environment variables:")
|
| 40 |
print(f"export STRAVA_REFRESH_TOKEN={token}")
|
| 41 |
print("=================================================================\n")
|
| 42 |
+
|
| 43 |
# Also write to a file for easy access
|
| 44 |
with open("strava_token.txt", "w") as f:
|
| 45 |
f.write(f"STRAVA_REFRESH_TOKEN={token}\n")
|
| 46 |
+
print("Token also saved to strava_token.txt\n")
|
| 47 |
+
|
| 48 |
except Exception as e:
|
| 49 |
logger.exception("Error getting refresh token")
|
| 50 |
print(f"Error: {e}")
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
if __name__ == "__main__":
|
| 55 |
+
asyncio.run(main())
|
main.py
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
|
|
|
|
|
| 1 |
from strava_mcp.server import mcp
|
| 2 |
|
|
|
|
|
|
|
| 3 |
|
| 4 |
def main():
|
| 5 |
"""Run the Strava MCP server."""
|
| 6 |
-
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
from strava_mcp.server import mcp
|
| 4 |
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
|
| 8 |
def main():
|
| 9 |
"""Run the Strava MCP server."""
|
| 10 |
+
logger.info("Starting MCP server")
|
| 11 |
+
mcp.run(transport="stdio")
|
| 12 |
|
| 13 |
|
| 14 |
if __name__ == "__main__":
|
pyproject.toml
CHANGED
|
@@ -21,10 +21,19 @@ dev = [
|
|
| 21 |
"pytest-mock>=3.14.0",
|
| 22 |
"pytest-asyncio>=0.23.5",
|
| 23 |
"ruff>=0.5.1",
|
|
|
|
|
|
|
| 24 |
]
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
[tool.ruff]
|
| 27 |
-
line-length =
|
| 28 |
target-version = "py313"
|
| 29 |
|
| 30 |
[tool.ruff.lint]
|
|
@@ -38,7 +47,7 @@ line-ending = "auto"
|
|
| 38 |
|
| 39 |
[tool.isort]
|
| 40 |
profile = "black"
|
| 41 |
-
line_length =
|
| 42 |
|
| 43 |
[tool.pyright]
|
| 44 |
typeCheckingMode = "standard"
|
|
|
|
| 21 |
"pytest-mock>=3.14.0",
|
| 22 |
"pytest-asyncio>=0.23.5",
|
| 23 |
"ruff>=0.5.1",
|
| 24 |
+
"pytest-cov>=6.0.0",
|
| 25 |
+
"pyright>=1.1.385",
|
| 26 |
]
|
| 27 |
|
| 28 |
+
[build-system]
|
| 29 |
+
requires = ["hatchling"]
|
| 30 |
+
build-backend = "hatchling.build"
|
| 31 |
+
|
| 32 |
+
[tool.hatch.build.targets.wheel]
|
| 33 |
+
packages = ["strava_mcp"]
|
| 34 |
+
|
| 35 |
[tool.ruff]
|
| 36 |
+
line-length = 120
|
| 37 |
target-version = "py313"
|
| 38 |
|
| 39 |
[tool.ruff.lint]
|
|
|
|
| 47 |
|
| 48 |
[tool.isort]
|
| 49 |
profile = "black"
|
| 50 |
+
line_length = 120
|
| 51 |
|
| 52 |
[tool.pyright]
|
| 53 |
typeCheckingMode = "standard"
|
standalone_server.py
DELETED
|
@@ -1,167 +0,0 @@
|
|
| 1 |
-
"""A standalone web server for testing the Strava OAuth flow."""
|
| 2 |
-
|
| 3 |
-
import asyncio
|
| 4 |
-
import logging
|
| 5 |
-
import webbrowser
|
| 6 |
-
from contextlib import asynccontextmanager
|
| 7 |
-
|
| 8 |
-
import uvicorn
|
| 9 |
-
from fastapi import FastAPI, Request
|
| 10 |
-
from fastapi.responses import HTMLResponse, RedirectResponse
|
| 11 |
-
|
| 12 |
-
from strava_mcp.auth import StravaAuthenticator
|
| 13 |
-
from strava_mcp.config import StravaSettings
|
| 14 |
-
|
| 15 |
-
# Configure logging
|
| 16 |
-
logging.basicConfig(
|
| 17 |
-
level=logging.INFO,
|
| 18 |
-
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 19 |
-
)
|
| 20 |
-
logger = logging.getLogger(__name__)
|
| 21 |
-
|
| 22 |
-
@asynccontextmanager
|
| 23 |
-
async def lifespan(app: FastAPI):
|
| 24 |
-
"""Set up and tear down the application.
|
| 25 |
-
|
| 26 |
-
Args:
|
| 27 |
-
app: The FastAPI app
|
| 28 |
-
"""
|
| 29 |
-
# Load settings from environment variables
|
| 30 |
-
try:
|
| 31 |
-
settings = StravaSettings()
|
| 32 |
-
logger.info("Loaded Strava API settings")
|
| 33 |
-
except Exception as e:
|
| 34 |
-
logger.error(f"Failed to load Strava API settings: {str(e)}")
|
| 35 |
-
raise
|
| 36 |
-
|
| 37 |
-
# Set up the Strava authenticator
|
| 38 |
-
authenticator = StravaAuthenticator(
|
| 39 |
-
client_id=settings.client_id,
|
| 40 |
-
client_secret=settings.client_secret,
|
| 41 |
-
app=app,
|
| 42 |
-
)
|
| 43 |
-
authenticator.setup_routes(app)
|
| 44 |
-
|
| 45 |
-
# Store settings and authenticator in app state
|
| 46 |
-
app.state.settings = settings
|
| 47 |
-
app.state.authenticator = authenticator
|
| 48 |
-
|
| 49 |
-
# Log the current state of the refresh token
|
| 50 |
-
if settings.refresh_token:
|
| 51 |
-
logger.info("Refresh token is already set")
|
| 52 |
-
else:
|
| 53 |
-
logger.info("No refresh token set, you will need to authenticate")
|
| 54 |
-
|
| 55 |
-
yield
|
| 56 |
-
|
| 57 |
-
# Clean up resources
|
| 58 |
-
logger.info("Shutting down")
|
| 59 |
-
|
| 60 |
-
# Create the FastAPI app
|
| 61 |
-
app = FastAPI(
|
| 62 |
-
title="Strava OAuth Tester",
|
| 63 |
-
description="A standalone web server for testing the Strava OAuth flow",
|
| 64 |
-
lifespan=lifespan,
|
| 65 |
-
)
|
| 66 |
-
|
| 67 |
-
# Define the root route
|
| 68 |
-
@app.get("/", response_class=HTMLResponse)
|
| 69 |
-
async def root(request: Request):
|
| 70 |
-
"""Root endpoint."""
|
| 71 |
-
settings = request.app.state.settings
|
| 72 |
-
|
| 73 |
-
# Check if we have a refresh token
|
| 74 |
-
if settings.refresh_token:
|
| 75 |
-
return HTMLResponse(
|
| 76 |
-
"""
|
| 77 |
-
<html>
|
| 78 |
-
<head>
|
| 79 |
-
<title>Strava OAuth Tester</title>
|
| 80 |
-
</head>
|
| 81 |
-
<body>
|
| 82 |
-
<h1>Strava OAuth Tester</h1>
|
| 83 |
-
<p>You already have a refresh token.</p>
|
| 84 |
-
<p>Your refresh token: <code>{refresh_token}</code></p>
|
| 85 |
-
<p><a href="/auth">Reauthenticate</a></p>
|
| 86 |
-
</body>
|
| 87 |
-
</html>
|
| 88 |
-
""".format(refresh_token=settings.refresh_token)
|
| 89 |
-
)
|
| 90 |
-
else:
|
| 91 |
-
return HTMLResponse(
|
| 92 |
-
"""
|
| 93 |
-
<html>
|
| 94 |
-
<head>
|
| 95 |
-
<title>Strava OAuth Tester</title>
|
| 96 |
-
</head>
|
| 97 |
-
<body>
|
| 98 |
-
<h1>Strava OAuth Tester</h1>
|
| 99 |
-
<p>You need to authenticate with Strava.</p>
|
| 100 |
-
<p><a href="/auth">Authenticate</a></p>
|
| 101 |
-
</body>
|
| 102 |
-
</html>
|
| 103 |
-
"""
|
| 104 |
-
)
|
| 105 |
-
|
| 106 |
-
# Define a route to get the refresh token manually
|
| 107 |
-
@app.get("/get_token", response_class=HTMLResponse)
|
| 108 |
-
async def get_token(request: Request):
|
| 109 |
-
"""Get a refresh token manually."""
|
| 110 |
-
settings = request.app.state.settings
|
| 111 |
-
authenticator = request.app.state.authenticator
|
| 112 |
-
|
| 113 |
-
# Start the auth flow
|
| 114 |
-
auth_future = asyncio.Future()
|
| 115 |
-
|
| 116 |
-
# Store the old token future and replace it with our own
|
| 117 |
-
old_token_future = authenticator.token_future
|
| 118 |
-
authenticator.token_future = auth_future
|
| 119 |
-
|
| 120 |
-
try:
|
| 121 |
-
# Open the browser for authorization
|
| 122 |
-
auth_url = authenticator.get_authorization_url()
|
| 123 |
-
logger.info(f"Opening browser to authorize: {auth_url}")
|
| 124 |
-
webbrowser.open(auth_url)
|
| 125 |
-
|
| 126 |
-
# Wait for the token with a timeout
|
| 127 |
-
try:
|
| 128 |
-
refresh_token = await asyncio.wait_for(auth_future, timeout=300) # 5 minutes timeout
|
| 129 |
-
settings.refresh_token = refresh_token
|
| 130 |
-
return HTMLResponse(
|
| 131 |
-
"""
|
| 132 |
-
<html>
|
| 133 |
-
<head>
|
| 134 |
-
<title>Strava OAuth Tester</title>
|
| 135 |
-
<meta http-equiv="refresh" content="5;url=/" />
|
| 136 |
-
</head>
|
| 137 |
-
<body>
|
| 138 |
-
<h1>Strava OAuth Tester</h1>
|
| 139 |
-
<p>Successfully obtained refresh token!</p>
|
| 140 |
-
<p>Your refresh token: <code>{refresh_token}</code></p>
|
| 141 |
-
<p>You will be redirected to the home page in 5 seconds...</p>
|
| 142 |
-
</body>
|
| 143 |
-
</html>
|
| 144 |
-
""".format(refresh_token=refresh_token)
|
| 145 |
-
)
|
| 146 |
-
except asyncio.TimeoutError:
|
| 147 |
-
return HTMLResponse(
|
| 148 |
-
"""
|
| 149 |
-
<html>
|
| 150 |
-
<head>
|
| 151 |
-
<title>Strava OAuth Tester</title>
|
| 152 |
-
</head>
|
| 153 |
-
<body>
|
| 154 |
-
<h1>Strava OAuth Tester</h1>
|
| 155 |
-
<p>Timed out waiting for authentication.</p>
|
| 156 |
-
<p><a href="/get_token">Try again</a></p>
|
| 157 |
-
</body>
|
| 158 |
-
</html>
|
| 159 |
-
"""
|
| 160 |
-
)
|
| 161 |
-
finally:
|
| 162 |
-
# Restore the old token future
|
| 163 |
-
authenticator.token_future = old_token_future
|
| 164 |
-
|
| 165 |
-
# Run the server
|
| 166 |
-
if __name__ == "__main__":
|
| 167 |
-
uvicorn.run(app, host="127.0.0.1", port=8000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
strava_mcp/api.py
CHANGED
|
@@ -1,18 +1,12 @@
|
|
| 1 |
import logging
|
| 2 |
from datetime import datetime
|
| 3 |
-
from typing import Optional
|
| 4 |
|
| 5 |
import httpx
|
| 6 |
from fastapi import FastAPI
|
| 7 |
from httpx import Response
|
| 8 |
|
| 9 |
from strava_mcp.config import StravaSettings
|
| 10 |
-
from strava_mcp.models import
|
| 11 |
-
Activity,
|
| 12 |
-
DetailedActivity,
|
| 13 |
-
ErrorResponse,
|
| 14 |
-
SegmentEffort,
|
| 15 |
-
)
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
|
|
@@ -20,17 +14,16 @@ logger = logging.getLogger(__name__)
|
|
| 20 |
class StravaAPI:
|
| 21 |
"""Client for the Strava API."""
|
| 22 |
|
| 23 |
-
def __init__(self, settings: StravaSettings, app:
|
| 24 |
"""Initialize the Strava API client.
|
| 25 |
|
| 26 |
Args:
|
| 27 |
settings: Strava API settings
|
| 28 |
-
app: FastAPI app for
|
| 29 |
"""
|
| 30 |
self.settings = settings
|
| 31 |
self.access_token = None
|
| 32 |
self.token_expires_at = None
|
| 33 |
-
self.app = app
|
| 34 |
self.auth_flow_in_progress = False
|
| 35 |
self._client = httpx.AsyncClient(
|
| 36 |
base_url=settings.base_url,
|
|
@@ -42,94 +35,33 @@ class StravaAPI:
|
|
| 42 |
await self._client.aclose()
|
| 43 |
|
| 44 |
async def setup_auth_routes(self):
|
| 45 |
-
"""
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
from strava_mcp.auth import StravaAuthenticator
|
| 51 |
-
|
| 52 |
-
# Check if we have a FastAPI app or a FastMCP server
|
| 53 |
-
fastapi_app = None
|
| 54 |
-
|
| 55 |
-
# If it's a FastAPI app, use it directly
|
| 56 |
-
if hasattr(self.app, "add_api_route"):
|
| 57 |
-
fastapi_app = self.app
|
| 58 |
-
# If it's a FastMCP server, try to get its FastAPI app
|
| 59 |
-
elif hasattr(self.app, "_app"):
|
| 60 |
-
fastapi_app = self.app._app
|
| 61 |
-
|
| 62 |
-
if not fastapi_app:
|
| 63 |
-
logger.warning("Could not get FastAPI app from the provided object, auth flow will not be available")
|
| 64 |
-
return
|
| 65 |
-
|
| 66 |
-
# Create authenticator and set up routes
|
| 67 |
-
try:
|
| 68 |
-
authenticator = StravaAuthenticator(
|
| 69 |
-
self.settings.client_id,
|
| 70 |
-
self.settings.client_secret,
|
| 71 |
-
fastapi_app
|
| 72 |
-
)
|
| 73 |
-
authenticator.setup_routes(fastapi_app)
|
| 74 |
-
|
| 75 |
-
# Store authenticator for later use
|
| 76 |
-
self._authenticator = authenticator
|
| 77 |
-
logger.info("Successfully set up Strava auth routes")
|
| 78 |
-
except Exception as e:
|
| 79 |
-
logger.error(f"Error setting up auth routes: {e}")
|
| 80 |
-
return
|
| 81 |
|
| 82 |
async def start_auth_flow(self) -> str:
|
| 83 |
-
"""
|
|
|
|
| 84 |
|
| 85 |
Returns:
|
| 86 |
The refresh token
|
| 87 |
|
| 88 |
Raises:
|
| 89 |
-
Exception:
|
| 90 |
"""
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
)
|
| 96 |
-
|
| 97 |
-
authenticator = getattr(self, '_authenticator', None)
|
| 98 |
-
if not authenticator:
|
| 99 |
-
raise Exception(
|
| 100 |
-
"Auth routes not set up or setup failed. "
|
| 101 |
-
"Please set STRAVA_REFRESH_TOKEN manually in your environment variables."
|
| 102 |
-
)
|
| 103 |
-
|
| 104 |
-
if self.auth_flow_in_progress:
|
| 105 |
-
raise Exception("Auth flow already in progress")
|
| 106 |
-
|
| 107 |
-
self.auth_flow_in_progress = True
|
| 108 |
-
try:
|
| 109 |
-
# Display instructions to the user and open browser
|
| 110 |
-
auth_url = self._authenticator.get_authorization_url()
|
| 111 |
-
logger.info(
|
| 112 |
-
f"\nNo refresh token available. Opening browser for authorization. "
|
| 113 |
-
f"If browser doesn't open, please visit this URL manually: {auth_url}"
|
| 114 |
-
)
|
| 115 |
-
|
| 116 |
-
# Get the refresh token and open browser automatically
|
| 117 |
-
refresh_token = await self._authenticator.get_refresh_token(open_browser=True)
|
| 118 |
-
|
| 119 |
-
# Store it in settings
|
| 120 |
-
self.settings.refresh_token = refresh_token
|
| 121 |
-
|
| 122 |
-
logger.info("Successfully obtained refresh token")
|
| 123 |
-
return refresh_token
|
| 124 |
-
finally:
|
| 125 |
-
self.auth_flow_in_progress = False
|
| 126 |
|
| 127 |
async def _ensure_token(self) -> str:
|
| 128 |
"""Ensure we have a valid access token.
|
| 129 |
|
| 130 |
Returns:
|
| 131 |
The access token
|
| 132 |
-
|
| 133 |
Raises:
|
| 134 |
Exception: If unable to obtain a valid token
|
| 135 |
"""
|
|
@@ -145,32 +77,21 @@ class StravaAPI:
|
|
| 145 |
try:
|
| 146 |
# Import here to avoid circular import
|
| 147 |
from strava_mcp.oauth_server import get_refresh_token_from_oauth
|
| 148 |
-
|
| 149 |
logger.info("Starting OAuth flow to get refresh token")
|
| 150 |
self.settings.refresh_token = await get_refresh_token_from_oauth(
|
| 151 |
-
self.settings.client_id,
|
| 152 |
-
self.settings.client_secret
|
| 153 |
)
|
| 154 |
logger.info("Successfully obtained refresh token from OAuth flow")
|
| 155 |
except Exception as e:
|
| 156 |
error_msg = f"Failed to get refresh token through OAuth flow: {e}"
|
| 157 |
logger.error(error_msg)
|
| 158 |
-
|
| 159 |
-
#
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
except Exception as fallback_error:
|
| 165 |
-
raise Exception(
|
| 166 |
-
"No refresh token available and all auth flows failed. "
|
| 167 |
-
"Please set STRAVA_REFRESH_TOKEN manually in your environment variables."
|
| 168 |
-
) from fallback_error
|
| 169 |
-
else:
|
| 170 |
-
raise Exception(
|
| 171 |
-
"No refresh token available and OAuth flow failed. "
|
| 172 |
-
"Please set STRAVA_REFRESH_TOKEN manually in your environment variables."
|
| 173 |
-
) from e
|
| 174 |
|
| 175 |
# Now that we have a refresh token, refresh the access token
|
| 176 |
async with httpx.AsyncClient() as client:
|
|
@@ -192,7 +113,7 @@ class StravaAPI:
|
|
| 192 |
data = response.json()
|
| 193 |
self.access_token = data["access_token"]
|
| 194 |
self.token_expires_at = data["expires_at"]
|
| 195 |
-
|
| 196 |
# Update the refresh token if it changed
|
| 197 |
if "refresh_token" in data:
|
| 198 |
self.settings.refresh_token = data["refresh_token"]
|
|
@@ -223,21 +144,15 @@ class StravaAPI:
|
|
| 223 |
response = await self._client.request(method, url, headers=headers, **kwargs)
|
| 224 |
|
| 225 |
if not response.is_success:
|
| 226 |
-
error_msg =
|
| 227 |
-
f"Strava API request failed: {response.status_code} - {response.text}"
|
| 228 |
-
)
|
| 229 |
logger.error(error_msg)
|
| 230 |
|
| 231 |
try:
|
| 232 |
error_data = response.json()
|
| 233 |
error = ErrorResponse(**error_data)
|
| 234 |
-
raise Exception(
|
| 235 |
-
f"Strava API error: {error.message} (code: {error.code})"
|
| 236 |
-
)
|
| 237 |
except Exception as err:
|
| 238 |
-
msg =
|
| 239 |
-
f"Strava API failed: {response.status_code} - {response.text[:50]}"
|
| 240 |
-
)
|
| 241 |
raise Exception(msg) from err
|
| 242 |
|
| 243 |
return response
|
|
@@ -271,9 +186,7 @@ class StravaAPI:
|
|
| 271 |
|
| 272 |
return [Activity(**activity) for activity in data]
|
| 273 |
|
| 274 |
-
async def get_activity(
|
| 275 |
-
self, activity_id: int, include_all_efforts: bool = False
|
| 276 |
-
) -> DetailedActivity:
|
| 277 |
"""Get a specific activity.
|
| 278 |
|
| 279 |
Args:
|
|
@@ -287,9 +200,7 @@ class StravaAPI:
|
|
| 287 |
if include_all_efforts:
|
| 288 |
params["include_all_efforts"] = "true"
|
| 289 |
|
| 290 |
-
response = await self._request(
|
| 291 |
-
"GET", f"/activities/{activity_id}", params=params
|
| 292 |
-
)
|
| 293 |
data = response.json()
|
| 294 |
|
| 295 |
return DetailedActivity(**data)
|
|
@@ -325,4 +236,3 @@ class StravaAPI:
|
|
| 325 |
segment_efforts.append(SegmentEffort.model_validate(effort))
|
| 326 |
|
| 327 |
return segment_efforts
|
| 328 |
-
|
|
|
|
| 1 |
import logging
|
| 2 |
from datetime import datetime
|
|
|
|
| 3 |
|
| 4 |
import httpx
|
| 5 |
from fastapi import FastAPI
|
| 6 |
from httpx import Response
|
| 7 |
|
| 8 |
from strava_mcp.config import StravaSettings
|
| 9 |
+
from strava_mcp.models import Activity, DetailedActivity, ErrorResponse, SegmentEffort
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
|
|
|
|
| 14 |
class StravaAPI:
|
| 15 |
"""Client for the Strava API."""
|
| 16 |
|
| 17 |
+
def __init__(self, settings: StravaSettings, app: FastAPI | None = None):
|
| 18 |
"""Initialize the Strava API client.
|
| 19 |
|
| 20 |
Args:
|
| 21 |
settings: Strava API settings
|
| 22 |
+
app: FastAPI app (not used, kept for backward compatibility)
|
| 23 |
"""
|
| 24 |
self.settings = settings
|
| 25 |
self.access_token = None
|
| 26 |
self.token_expires_at = None
|
|
|
|
| 27 |
self.auth_flow_in_progress = False
|
| 28 |
self._client = httpx.AsyncClient(
|
| 29 |
base_url=settings.base_url,
|
|
|
|
| 35 |
await self._client.aclose()
|
| 36 |
|
| 37 |
async def setup_auth_routes(self):
|
| 38 |
+
"""This method is deprecated and does nothing now.
|
| 39 |
+
Standalone OAuth server is used instead.
|
| 40 |
+
"""
|
| 41 |
+
logger.info("Using standalone OAuth server instead of integrated auth routes")
|
| 42 |
+
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
async def start_auth_flow(self) -> str:
|
| 45 |
+
"""This method is deprecated.
|
| 46 |
+
The standalone OAuth server is used instead via _ensure_token().
|
| 47 |
|
| 48 |
Returns:
|
| 49 |
The refresh token
|
| 50 |
|
| 51 |
Raises:
|
| 52 |
+
Exception: Always raises exception directing to use standalone flow
|
| 53 |
"""
|
| 54 |
+
raise Exception(
|
| 55 |
+
"Integrated auth flow is no longer supported. "
|
| 56 |
+
"The standalone OAuth server will be used automatically when needed."
|
| 57 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
async def _ensure_token(self) -> str:
|
| 60 |
"""Ensure we have a valid access token.
|
| 61 |
|
| 62 |
Returns:
|
| 63 |
The access token
|
| 64 |
+
|
| 65 |
Raises:
|
| 66 |
Exception: If unable to obtain a valid token
|
| 67 |
"""
|
|
|
|
| 77 |
try:
|
| 78 |
# Import here to avoid circular import
|
| 79 |
from strava_mcp.oauth_server import get_refresh_token_from_oauth
|
| 80 |
+
|
| 81 |
logger.info("Starting OAuth flow to get refresh token")
|
| 82 |
self.settings.refresh_token = await get_refresh_token_from_oauth(
|
| 83 |
+
self.settings.client_id, self.settings.client_secret
|
|
|
|
| 84 |
)
|
| 85 |
logger.info("Successfully obtained refresh token from OAuth flow")
|
| 86 |
except Exception as e:
|
| 87 |
error_msg = f"Failed to get refresh token through OAuth flow: {e}"
|
| 88 |
logger.error(error_msg)
|
| 89 |
+
|
| 90 |
+
# No fallback to MCP-integrated auth flow anymore
|
| 91 |
+
raise Exception(
|
| 92 |
+
"No refresh token available and OAuth flow failed. "
|
| 93 |
+
"Please set STRAVA_REFRESH_TOKEN manually in your environment variables."
|
| 94 |
+
) from e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
# Now that we have a refresh token, refresh the access token
|
| 97 |
async with httpx.AsyncClient() as client:
|
|
|
|
| 113 |
data = response.json()
|
| 114 |
self.access_token = data["access_token"]
|
| 115 |
self.token_expires_at = data["expires_at"]
|
| 116 |
+
|
| 117 |
# Update the refresh token if it changed
|
| 118 |
if "refresh_token" in data:
|
| 119 |
self.settings.refresh_token = data["refresh_token"]
|
|
|
|
| 144 |
response = await self._client.request(method, url, headers=headers, **kwargs)
|
| 145 |
|
| 146 |
if not response.is_success:
|
| 147 |
+
error_msg = f"Strava API request failed: {response.status_code} - {response.text}"
|
|
|
|
|
|
|
| 148 |
logger.error(error_msg)
|
| 149 |
|
| 150 |
try:
|
| 151 |
error_data = response.json()
|
| 152 |
error = ErrorResponse(**error_data)
|
| 153 |
+
raise Exception(f"Strava API error: {error.message} (code: {error.code})")
|
|
|
|
|
|
|
| 154 |
except Exception as err:
|
| 155 |
+
msg = f"Strava API failed: {response.status_code} - {response.text[:50]}"
|
|
|
|
|
|
|
| 156 |
raise Exception(msg) from err
|
| 157 |
|
| 158 |
return response
|
|
|
|
| 186 |
|
| 187 |
return [Activity(**activity) for activity in data]
|
| 188 |
|
| 189 |
+
async def get_activity(self, activity_id: int, include_all_efforts: bool = False) -> DetailedActivity:
|
|
|
|
|
|
|
| 190 |
"""Get a specific activity.
|
| 191 |
|
| 192 |
Args:
|
|
|
|
| 200 |
if include_all_efforts:
|
| 201 |
params["include_all_efforts"] = "true"
|
| 202 |
|
| 203 |
+
response = await self._request("GET", f"/activities/{activity_id}", params=params)
|
|
|
|
|
|
|
| 204 |
data = response.json()
|
| 205 |
|
| 206 |
return DetailedActivity(**data)
|
|
|
|
| 236 |
segment_efforts.append(SegmentEffort.model_validate(effort))
|
| 237 |
|
| 238 |
return segment_efforts
|
|
|
strava_mcp/auth.py
CHANGED
|
@@ -2,7 +2,6 @@ import asyncio
|
|
| 2 |
import logging
|
| 3 |
import os
|
| 4 |
import webbrowser
|
| 5 |
-
from typing import Optional
|
| 6 |
from urllib.parse import urlencode
|
| 7 |
|
| 8 |
import httpx
|
|
@@ -21,6 +20,7 @@ REDIRECT_HOST = "127.0.0.1"
|
|
| 21 |
|
| 22 |
class TokenResponse(BaseModel):
|
| 23 |
"""Response model for Strava token exchange."""
|
|
|
|
| 24 |
access_token: str
|
| 25 |
refresh_token: str
|
| 26 |
expires_at: int
|
|
@@ -32,13 +32,13 @@ class StravaAuthenticator:
|
|
| 32 |
"""Helper class to get a Strava refresh token via OAuth flow."""
|
| 33 |
|
| 34 |
def __init__(
|
| 35 |
-
self,
|
| 36 |
-
client_id: str,
|
| 37 |
client_secret: str,
|
| 38 |
-
app:
|
| 39 |
redirect_path: str = "/exchange_token",
|
| 40 |
host: str = REDIRECT_HOST,
|
| 41 |
-
port: int = REDIRECT_PORT
|
| 42 |
):
|
| 43 |
"""Initialize the authenticator.
|
| 44 |
|
|
@@ -72,26 +72,22 @@ class StravaAuthenticator:
|
|
| 72 |
try:
|
| 73 |
# Exchange the code for tokens
|
| 74 |
token_data = await self._exchange_code_for_token(code)
|
| 75 |
-
|
| 76 |
# If we have a token future (waiting for token), set the result
|
| 77 |
if self.token_future and not self.token_future.done():
|
| 78 |
self.token_future.set_result(token_data.refresh_token)
|
| 79 |
-
|
| 80 |
return HTMLResponse(
|
| 81 |
-
"<h1>Authorization successful!</h1>"
|
| 82 |
-
"<p>You can close this tab and return to the application.</p>"
|
| 83 |
)
|
| 84 |
except Exception as e:
|
| 85 |
logger.exception("Error during token exchange")
|
| 86 |
-
|
| 87 |
# If we have a token future (waiting for token), set the exception
|
| 88 |
if self.token_future and not self.token_future.done():
|
| 89 |
self.token_future.set_exception(e)
|
| 90 |
-
|
| 91 |
-
return HTMLResponse(
|
| 92 |
-
"<h1>Authorization failed!</h1>"
|
| 93 |
-
"<p>An error occurred. Please check the logs.</p>"
|
| 94 |
-
)
|
| 95 |
|
| 96 |
async def _exchange_code_for_token(self, code: str) -> TokenResponse:
|
| 97 |
"""Exchange the authorization code for tokens.
|
|
@@ -115,12 +111,12 @@ class StravaAuthenticator:
|
|
| 115 |
"grant_type": "authorization_code",
|
| 116 |
},
|
| 117 |
)
|
| 118 |
-
|
| 119 |
if response.status_code != 200:
|
| 120 |
error_msg = f"Failed to exchange token: {response.text}"
|
| 121 |
logger.error(error_msg)
|
| 122 |
raise Exception(error_msg)
|
| 123 |
-
|
| 124 |
data = response.json()
|
| 125 |
token_data = TokenResponse(**data)
|
| 126 |
self.refresh_token = token_data.refresh_token
|
|
@@ -141,7 +137,7 @@ class StravaAuthenticator:
|
|
| 141 |
}
|
| 142 |
return f"{AUTHORIZE_URL}?{urlencode(params)}"
|
| 143 |
|
| 144 |
-
def setup_routes(self, app: FastAPI = None):
|
| 145 |
"""Set up the routes for authentication.
|
| 146 |
|
| 147 |
Args:
|
|
@@ -150,21 +146,17 @@ class StravaAuthenticator:
|
|
| 150 |
target_app = app or self.app
|
| 151 |
if not target_app:
|
| 152 |
raise ValueError("No FastAPI app provided")
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
# Add route for the token exchange
|
| 155 |
-
target_app.add_api_route(
|
| 156 |
-
|
| 157 |
-
self.exchange_token,
|
| 158 |
-
methods=["GET"]
|
| 159 |
-
)
|
| 160 |
-
|
| 161 |
# Add route to start the auth flow
|
| 162 |
-
target_app.add_api_route(
|
| 163 |
-
|
| 164 |
-
self.start_auth_flow,
|
| 165 |
-
methods=["GET"]
|
| 166 |
-
)
|
| 167 |
-
|
| 168 |
async def start_auth_flow(self):
|
| 169 |
"""Start the OAuth flow by redirecting to Strava.
|
| 170 |
|
|
@@ -189,7 +181,7 @@ class StravaAuthenticator:
|
|
| 189 |
"""
|
| 190 |
# Create a future to wait for the token
|
| 191 |
self.token_future = asyncio.Future()
|
| 192 |
-
|
| 193 |
# Open the browser for authorization if requested
|
| 194 |
auth_url = self.get_authorization_url()
|
| 195 |
if open_browser:
|
|
@@ -200,16 +192,12 @@ class StravaAuthenticator:
|
|
| 200 |
logger.info(f"Authorization URL: {auth_url}")
|
| 201 |
else:
|
| 202 |
logger.info(f"Please open this URL to authorize: {auth_url}")
|
| 203 |
-
|
| 204 |
# Wait for the token
|
| 205 |
return await self.token_future
|
| 206 |
|
| 207 |
|
| 208 |
-
async def get_strava_refresh_token(
|
| 209 |
-
client_id: str,
|
| 210 |
-
client_secret: str,
|
| 211 |
-
app: Optional[FastAPI] = None
|
| 212 |
-
) -> str:
|
| 213 |
"""Get a Strava refresh token via OAuth flow.
|
| 214 |
|
| 215 |
Args:
|
|
@@ -232,14 +220,15 @@ async def get_strava_refresh_token(
|
|
| 232 |
if __name__ == "__main__":
|
| 233 |
# This allows running this file directly to get a refresh token
|
| 234 |
import sys
|
|
|
|
| 235 |
import uvicorn
|
| 236 |
-
|
| 237 |
logging.basicConfig(level=logging.INFO)
|
| 238 |
-
|
| 239 |
# Check if client_id and client_secret are provided as env vars
|
| 240 |
client_id = os.environ.get("STRAVA_CLIENT_ID")
|
| 241 |
client_secret = os.environ.get("STRAVA_CLIENT_SECRET")
|
| 242 |
-
|
| 243 |
# If not provided as env vars, check command line args
|
| 244 |
if not client_id or not client_secret:
|
| 245 |
if len(sys.argv) != 3:
|
|
@@ -248,17 +237,17 @@ if __name__ == "__main__":
|
|
| 248 |
sys.exit(1)
|
| 249 |
client_id = sys.argv[1]
|
| 250 |
client_secret = sys.argv[2]
|
| 251 |
-
|
| 252 |
# Create a FastAPI app for standalone operation
|
| 253 |
app = FastAPI(title="Strava Auth")
|
| 254 |
authenticator = StravaAuthenticator(client_id, client_secret, app)
|
| 255 |
authenticator.setup_routes(app)
|
| 256 |
-
|
| 257 |
# Add a root route that redirects to the auth flow
|
| 258 |
@app.get("/")
|
| 259 |
async def root():
|
| 260 |
return RedirectResponse("/auth")
|
| 261 |
-
|
| 262 |
async def main():
|
| 263 |
# Start the server
|
| 264 |
server = uvicorn.Server(
|
|
@@ -269,12 +258,12 @@ if __name__ == "__main__":
|
|
| 269 |
log_level="info",
|
| 270 |
)
|
| 271 |
)
|
| 272 |
-
|
| 273 |
# For standalone operation, we'll print instructions
|
| 274 |
print("\nStrava Authentication Server")
|
| 275 |
print(f"Open http://{REDIRECT_HOST}:{REDIRECT_PORT}/ in your browser to start authentication")
|
| 276 |
-
|
| 277 |
# Run the server (this will block until stopped)
|
| 278 |
await server.serve()
|
| 279 |
|
| 280 |
-
asyncio.run(main())
|
|
|
|
| 2 |
import logging
|
| 3 |
import os
|
| 4 |
import webbrowser
|
|
|
|
| 5 |
from urllib.parse import urlencode
|
| 6 |
|
| 7 |
import httpx
|
|
|
|
| 20 |
|
| 21 |
class TokenResponse(BaseModel):
|
| 22 |
"""Response model for Strava token exchange."""
|
| 23 |
+
|
| 24 |
access_token: str
|
| 25 |
refresh_token: str
|
| 26 |
expires_at: int
|
|
|
|
| 32 |
"""Helper class to get a Strava refresh token via OAuth flow."""
|
| 33 |
|
| 34 |
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
client_id: str,
|
| 37 |
client_secret: str,
|
| 38 |
+
app: FastAPI | None = None,
|
| 39 |
redirect_path: str = "/exchange_token",
|
| 40 |
host: str = REDIRECT_HOST,
|
| 41 |
+
port: int = REDIRECT_PORT,
|
| 42 |
):
|
| 43 |
"""Initialize the authenticator.
|
| 44 |
|
|
|
|
| 72 |
try:
|
| 73 |
# Exchange the code for tokens
|
| 74 |
token_data = await self._exchange_code_for_token(code)
|
| 75 |
+
|
| 76 |
# If we have a token future (waiting for token), set the result
|
| 77 |
if self.token_future and not self.token_future.done():
|
| 78 |
self.token_future.set_result(token_data.refresh_token)
|
| 79 |
+
|
| 80 |
return HTMLResponse(
|
| 81 |
+
"<h1>Authorization successful!</h1><p>You can close this tab and return to the application.</p>"
|
|
|
|
| 82 |
)
|
| 83 |
except Exception as e:
|
| 84 |
logger.exception("Error during token exchange")
|
| 85 |
+
|
| 86 |
# If we have a token future (waiting for token), set the exception
|
| 87 |
if self.token_future and not self.token_future.done():
|
| 88 |
self.token_future.set_exception(e)
|
| 89 |
+
|
| 90 |
+
return HTMLResponse("<h1>Authorization failed!</h1><p>An error occurred. Please check the logs.</p>")
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
async def _exchange_code_for_token(self, code: str) -> TokenResponse:
|
| 93 |
"""Exchange the authorization code for tokens.
|
|
|
|
| 111 |
"grant_type": "authorization_code",
|
| 112 |
},
|
| 113 |
)
|
| 114 |
+
|
| 115 |
if response.status_code != 200:
|
| 116 |
error_msg = f"Failed to exchange token: {response.text}"
|
| 117 |
logger.error(error_msg)
|
| 118 |
raise Exception(error_msg)
|
| 119 |
+
|
| 120 |
data = response.json()
|
| 121 |
token_data = TokenResponse(**data)
|
| 122 |
self.refresh_token = token_data.refresh_token
|
|
|
|
| 137 |
}
|
| 138 |
return f"{AUTHORIZE_URL}?{urlencode(params)}"
|
| 139 |
|
| 140 |
+
def setup_routes(self, app: FastAPI | None = None):
|
| 141 |
"""Set up the routes for authentication.
|
| 142 |
|
| 143 |
Args:
|
|
|
|
| 146 |
target_app = app or self.app
|
| 147 |
if not target_app:
|
| 148 |
raise ValueError("No FastAPI app provided")
|
| 149 |
+
|
| 150 |
+
# Make sure we have a valid FastAPI app
|
| 151 |
+
if not hasattr(target_app, "add_api_route"):
|
| 152 |
+
raise ValueError("Provided app does not appear to be a valid FastAPI instance")
|
| 153 |
+
|
| 154 |
# Add route for the token exchange
|
| 155 |
+
target_app.add_api_route(self.redirect_path, self.exchange_token, methods=["GET"])
|
| 156 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
# Add route to start the auth flow
|
| 158 |
+
target_app.add_api_route("/auth", self.start_auth_flow, methods=["GET"])
|
| 159 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
async def start_auth_flow(self):
|
| 161 |
"""Start the OAuth flow by redirecting to Strava.
|
| 162 |
|
|
|
|
| 181 |
"""
|
| 182 |
# Create a future to wait for the token
|
| 183 |
self.token_future = asyncio.Future()
|
| 184 |
+
|
| 185 |
# Open the browser for authorization if requested
|
| 186 |
auth_url = self.get_authorization_url()
|
| 187 |
if open_browser:
|
|
|
|
| 192 |
logger.info(f"Authorization URL: {auth_url}")
|
| 193 |
else:
|
| 194 |
logger.info(f"Please open this URL to authorize: {auth_url}")
|
| 195 |
+
|
| 196 |
# Wait for the token
|
| 197 |
return await self.token_future
|
| 198 |
|
| 199 |
|
| 200 |
+
async def get_strava_refresh_token(client_id: str, client_secret: str, app: FastAPI | None = None) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
"""Get a Strava refresh token via OAuth flow.
|
| 202 |
|
| 203 |
Args:
|
|
|
|
| 220 |
if __name__ == "__main__":
|
| 221 |
# This allows running this file directly to get a refresh token
|
| 222 |
import sys
|
| 223 |
+
|
| 224 |
import uvicorn
|
| 225 |
+
|
| 226 |
logging.basicConfig(level=logging.INFO)
|
| 227 |
+
|
| 228 |
# Check if client_id and client_secret are provided as env vars
|
| 229 |
client_id = os.environ.get("STRAVA_CLIENT_ID")
|
| 230 |
client_secret = os.environ.get("STRAVA_CLIENT_SECRET")
|
| 231 |
+
|
| 232 |
# If not provided as env vars, check command line args
|
| 233 |
if not client_id or not client_secret:
|
| 234 |
if len(sys.argv) != 3:
|
|
|
|
| 237 |
sys.exit(1)
|
| 238 |
client_id = sys.argv[1]
|
| 239 |
client_secret = sys.argv[2]
|
| 240 |
+
|
| 241 |
# Create a FastAPI app for standalone operation
|
| 242 |
app = FastAPI(title="Strava Auth")
|
| 243 |
authenticator = StravaAuthenticator(client_id, client_secret, app)
|
| 244 |
authenticator.setup_routes(app)
|
| 245 |
+
|
| 246 |
# Add a root route that redirects to the auth flow
|
| 247 |
@app.get("/")
|
| 248 |
async def root():
|
| 249 |
return RedirectResponse("/auth")
|
| 250 |
+
|
| 251 |
async def main():
|
| 252 |
# Start the server
|
| 253 |
server = uvicorn.Server(
|
|
|
|
| 258 |
log_level="info",
|
| 259 |
)
|
| 260 |
)
|
| 261 |
+
|
| 262 |
# For standalone operation, we'll print instructions
|
| 263 |
print("\nStrava Authentication Server")
|
| 264 |
print(f"Open http://{REDIRECT_HOST}:{REDIRECT_PORT}/ in your browser to start authentication")
|
| 265 |
+
|
| 266 |
# Run the server (this will block until stopped)
|
| 267 |
await server.serve()
|
| 268 |
|
| 269 |
+
asyncio.run(main())
|
strava_mcp/config.py
CHANGED
|
@@ -1,6 +1,4 @@
|
|
| 1 |
-
from
|
| 2 |
-
|
| 3 |
-
from pydantic import Field
|
| 4 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
|
| 6 |
|
|
@@ -9,15 +7,30 @@ class StravaSettings(BaseSettings):
|
|
| 9 |
|
| 10 |
client_id: str = Field(..., description="Strava API client ID")
|
| 11 |
client_secret: str = Field(..., description="Strava API client secret")
|
| 12 |
-
refresh_token:
|
| 13 |
-
None,
|
| 14 |
-
|
| 15 |
-
base_url: str = Field(
|
| 16 |
-
"https://www.strava.com/api/v3", description="Strava API base URL"
|
| 17 |
)
|
|
|
|
| 18 |
|
| 19 |
-
model_config = SettingsConfigDict(
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import Field, model_validator
|
|
|
|
|
|
|
| 2 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 3 |
|
| 4 |
|
|
|
|
| 7 |
|
| 8 |
client_id: str = Field(..., description="Strava API client ID")
|
| 9 |
client_secret: str = Field(..., description="Strava API client secret")
|
| 10 |
+
refresh_token: str | None = Field(
|
| 11 |
+
default=None,
|
| 12 |
+
description="Strava API refresh token (can be generated through auth flow)",
|
|
|
|
|
|
|
| 13 |
)
|
| 14 |
+
base_url: str = Field("https://www.strava.com/api/v3", description="Strava API base URL")
|
| 15 |
|
| 16 |
+
model_config = SettingsConfigDict(env_prefix="STRAVA_", env_file=".env", env_file_encoding="utf-8")
|
| 17 |
+
|
| 18 |
+
@model_validator(mode="after")
|
| 19 |
+
def load_from_env(self):
|
| 20 |
+
"""Load values from environment variables if not directly provided."""
|
| 21 |
+
import os
|
| 22 |
+
|
| 23 |
+
# Only override empty values with environment values
|
| 24 |
+
if not self.client_id and os.environ.get("STRAVA_CLIENT_ID"):
|
| 25 |
+
self.client_id = os.environ["STRAVA_CLIENT_ID"]
|
| 26 |
+
|
| 27 |
+
if not self.client_secret and os.environ.get("STRAVA_CLIENT_SECRET"):
|
| 28 |
+
self.client_secret = os.environ["STRAVA_CLIENT_SECRET"]
|
| 29 |
+
|
| 30 |
+
if not self.refresh_token and os.environ.get("STRAVA_REFRESH_TOKEN"):
|
| 31 |
+
self.refresh_token = os.environ["STRAVA_REFRESH_TOKEN"]
|
| 32 |
+
|
| 33 |
+
if not self.base_url and os.environ.get("STRAVA_BASE_URL"):
|
| 34 |
+
self.base_url = os.environ["STRAVA_BASE_URL"]
|
| 35 |
+
|
| 36 |
+
return self
|
strava_mcp/models.py
CHANGED
|
@@ -11,15 +11,11 @@ class Activity(BaseModel):
|
|
| 11 |
distance: float = Field(..., description="The distance in meters")
|
| 12 |
moving_time: int = Field(..., description="Moving time in seconds")
|
| 13 |
elapsed_time: int = Field(..., description="Elapsed time in seconds")
|
| 14 |
-
total_elevation_gain: float = Field(
|
| 15 |
-
..., description="Total elevation gain in meters"
|
| 16 |
-
)
|
| 17 |
type: str = Field(..., description="Type of activity")
|
| 18 |
sport_type: str = Field(..., description="Type of sport")
|
| 19 |
start_date: datetime = Field(..., description="Start date and time in UTC")
|
| 20 |
-
start_date_local: datetime = Field(
|
| 21 |
-
..., description="Start date and time in athlete's timezone"
|
| 22 |
-
)
|
| 23 |
timezone: str = Field(..., description="The timezone of the activity")
|
| 24 |
achievement_count: int = Field(..., description="The number of achievements")
|
| 25 |
kudos_count: int = Field(..., description="The number of kudos")
|
|
@@ -27,9 +23,7 @@ class Activity(BaseModel):
|
|
| 27 |
athlete_count: int = Field(..., description="The number of athletes")
|
| 28 |
photo_count: int = Field(..., description="The number of photos")
|
| 29 |
map: dict | None = Field(None, description="The map of the activity")
|
| 30 |
-
trainer: bool = Field(
|
| 31 |
-
..., description="Whether this activity was recorded on a training machine"
|
| 32 |
-
)
|
| 33 |
commute: bool = Field(..., description="Whether this activity is a commute")
|
| 34 |
manual: bool = Field(..., description="Whether this activity was created manually")
|
| 35 |
private: bool = Field(..., description="Whether this activity is private")
|
|
@@ -37,15 +31,9 @@ class Activity(BaseModel):
|
|
| 37 |
workout_type: int | None = Field(None, description="The workout type")
|
| 38 |
average_speed: float = Field(..., description="Average speed in meters per second")
|
| 39 |
max_speed: float = Field(..., description="Maximum speed in meters per second")
|
| 40 |
-
has_heartrate: bool = Field(
|
| 41 |
-
|
| 42 |
-
)
|
| 43 |
-
average_heartrate: float | None = Field(
|
| 44 |
-
None, description="Average heartrate during activity"
|
| 45 |
-
)
|
| 46 |
-
max_heartrate: float | None = Field(
|
| 47 |
-
None, description="Maximum heartrate during activity"
|
| 48 |
-
)
|
| 49 |
elev_high: float | None = Field(None, description="The highest elevation")
|
| 50 |
elev_low: float | None = Field(None, description="The lowest elevation")
|
| 51 |
|
|
@@ -56,19 +44,13 @@ class DetailedActivity(Activity):
|
|
| 56 |
description: str | None = Field(None, description="The description of the activity")
|
| 57 |
athlete: dict = Field(..., description="The athlete who performed the activity")
|
| 58 |
calories: float | None = Field(None, description="Calories burned during activity")
|
| 59 |
-
segment_efforts: list[dict] | None = Field(
|
| 60 |
-
None, description="List of segment efforts"
|
| 61 |
-
)
|
| 62 |
splits_metric: list[dict] | None = Field(None, description="Splits in metric units")
|
| 63 |
-
splits_standard: list[dict] | None = Field(
|
| 64 |
-
None, description="Splits in standard units"
|
| 65 |
-
)
|
| 66 |
best_efforts: list[dict] | None = Field(None, description="List of best efforts")
|
| 67 |
photos: dict | None = Field(None, description="Photos associated with activity")
|
| 68 |
gear: dict | None = Field(None, description="Gear used during activity")
|
| 69 |
-
device_name: str | None = Field(
|
| 70 |
-
None, description="Name of device used to record activity"
|
| 71 |
-
)
|
| 72 |
|
| 73 |
|
| 74 |
class Segment(BaseModel):
|
|
@@ -78,35 +60,19 @@ class Segment(BaseModel):
|
|
| 78 |
name: str = Field(..., description="The name of the segment")
|
| 79 |
activity_type: str = Field(..., description="The activity type of the segment")
|
| 80 |
distance: float = Field(..., description="The segment's distance in meters")
|
| 81 |
-
average_grade: float = Field(
|
| 82 |
-
|
| 83 |
-
)
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
)
|
| 87 |
-
|
| 88 |
-
..., description="The segments's highest elevation, in meters"
|
| 89 |
-
)
|
| 90 |
-
elevation_low: float = Field(
|
| 91 |
-
..., description="The segments's lowest elevation, in meters"
|
| 92 |
-
)
|
| 93 |
-
total_elevation_gain: float = Field(
|
| 94 |
-
..., description="The segments's total elevation gain, in meters"
|
| 95 |
-
)
|
| 96 |
-
start_latlng: list[float] = Field(
|
| 97 |
-
..., description="Start coordinates [latitude, longitude]"
|
| 98 |
-
)
|
| 99 |
-
end_latlng: list[float] = Field(
|
| 100 |
-
..., description="End coordinates [latitude, longitude]"
|
| 101 |
-
)
|
| 102 |
climb_category: int = Field(..., description="The category of the climb [0, 5]")
|
| 103 |
city: str | None = Field(None, description="The city this segment is in")
|
| 104 |
state: str | None = Field(None, description="The state this segment is in")
|
| 105 |
country: str | None = Field(None, description="The country this segment is in")
|
| 106 |
private: bool = Field(..., description="Whether this segment is private")
|
| 107 |
-
starred: bool = Field(
|
| 108 |
-
..., description="Whether this segment is starred by the authenticated athlete"
|
| 109 |
-
)
|
| 110 |
|
| 111 |
|
| 112 |
class SegmentEffort(BaseModel):
|
|
@@ -119,26 +85,18 @@ class SegmentEffort(BaseModel):
|
|
| 119 |
elapsed_time: int = Field(..., description="The elapsed time in seconds")
|
| 120 |
moving_time: int = Field(..., description="The moving time in seconds")
|
| 121 |
start_date: datetime = Field(..., description="Start date and time in UTC")
|
| 122 |
-
start_date_local: datetime = Field(
|
| 123 |
-
..., description="Start date and time in athlete's timezone"
|
| 124 |
-
)
|
| 125 |
distance: float = Field(..., description="The effort's distance in meters")
|
| 126 |
average_watts: float | None = Field(None, description="Average wattage")
|
| 127 |
-
device_watts: bool | None = Field(
|
| 128 |
-
None, description="Whether power data comes from a power meter"
|
| 129 |
-
)
|
| 130 |
average_heartrate: float | None = Field(None, description="Average heartrate")
|
| 131 |
max_heartrate: float | None = Field(None, description="Maximum heartrate")
|
| 132 |
-
pr_rank: int | None = Field(
|
| 133 |
-
None, description="Personal record rank (1-3), 0 if not a PR"
|
| 134 |
-
)
|
| 135 |
achievements: list[dict] | None = Field(None, description="List of achievements")
|
| 136 |
athlete: dict = Field(..., description="The athlete who performed the effort")
|
| 137 |
segment: Segment = Field(..., description="The segment")
|
| 138 |
|
| 139 |
|
| 140 |
-
|
| 141 |
-
|
| 142 |
class ErrorResponse(BaseModel):
|
| 143 |
"""Represents an error response from the Strava API."""
|
| 144 |
|
|
|
|
| 11 |
distance: float = Field(..., description="The distance in meters")
|
| 12 |
moving_time: int = Field(..., description="Moving time in seconds")
|
| 13 |
elapsed_time: int = Field(..., description="Elapsed time in seconds")
|
| 14 |
+
total_elevation_gain: float = Field(..., description="Total elevation gain in meters")
|
|
|
|
|
|
|
| 15 |
type: str = Field(..., description="Type of activity")
|
| 16 |
sport_type: str = Field(..., description="Type of sport")
|
| 17 |
start_date: datetime = Field(..., description="Start date and time in UTC")
|
| 18 |
+
start_date_local: datetime = Field(..., description="Start date and time in athlete's timezone")
|
|
|
|
|
|
|
| 19 |
timezone: str = Field(..., description="The timezone of the activity")
|
| 20 |
achievement_count: int = Field(..., description="The number of achievements")
|
| 21 |
kudos_count: int = Field(..., description="The number of kudos")
|
|
|
|
| 23 |
athlete_count: int = Field(..., description="The number of athletes")
|
| 24 |
photo_count: int = Field(..., description="The number of photos")
|
| 25 |
map: dict | None = Field(None, description="The map of the activity")
|
| 26 |
+
trainer: bool = Field(..., description="Whether this activity was recorded on a training machine")
|
|
|
|
|
|
|
| 27 |
commute: bool = Field(..., description="Whether this activity is a commute")
|
| 28 |
manual: bool = Field(..., description="Whether this activity was created manually")
|
| 29 |
private: bool = Field(..., description="Whether this activity is private")
|
|
|
|
| 31 |
workout_type: int | None = Field(None, description="The workout type")
|
| 32 |
average_speed: float = Field(..., description="Average speed in meters per second")
|
| 33 |
max_speed: float = Field(..., description="Maximum speed in meters per second")
|
| 34 |
+
has_heartrate: bool = Field(..., description="Whether the activity has heartrate data")
|
| 35 |
+
average_heartrate: float | None = Field(None, description="Average heartrate during activity")
|
| 36 |
+
max_heartrate: float | None = Field(None, description="Maximum heartrate during activity")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
elev_high: float | None = Field(None, description="The highest elevation")
|
| 38 |
elev_low: float | None = Field(None, description="The lowest elevation")
|
| 39 |
|
|
|
|
| 44 |
description: str | None = Field(None, description="The description of the activity")
|
| 45 |
athlete: dict = Field(..., description="The athlete who performed the activity")
|
| 46 |
calories: float | None = Field(None, description="Calories burned during activity")
|
| 47 |
+
segment_efforts: list[dict] | None = Field(None, description="List of segment efforts")
|
|
|
|
|
|
|
| 48 |
splits_metric: list[dict] | None = Field(None, description="Splits in metric units")
|
| 49 |
+
splits_standard: list[dict] | None = Field(None, description="Splits in standard units")
|
|
|
|
|
|
|
| 50 |
best_efforts: list[dict] | None = Field(None, description="List of best efforts")
|
| 51 |
photos: dict | None = Field(None, description="Photos associated with activity")
|
| 52 |
gear: dict | None = Field(None, description="Gear used during activity")
|
| 53 |
+
device_name: str | None = Field(None, description="Name of device used to record activity")
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
class Segment(BaseModel):
|
|
|
|
| 60 |
name: str = Field(..., description="The name of the segment")
|
| 61 |
activity_type: str = Field(..., description="The activity type of the segment")
|
| 62 |
distance: float = Field(..., description="The segment's distance in meters")
|
| 63 |
+
average_grade: float = Field(..., description="The segment's average grade, in percents")
|
| 64 |
+
maximum_grade: float = Field(..., description="The segments's maximum grade, in percents")
|
| 65 |
+
elevation_high: float = Field(..., description="The segments's highest elevation, in meters")
|
| 66 |
+
elevation_low: float = Field(..., description="The segments's lowest elevation, in meters")
|
| 67 |
+
total_elevation_gain: float = Field(..., description="The segments's total elevation gain, in meters")
|
| 68 |
+
start_latlng: list[float] = Field(..., description="Start coordinates [latitude, longitude]")
|
| 69 |
+
end_latlng: list[float] = Field(..., description="End coordinates [latitude, longitude]")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
climb_category: int = Field(..., description="The category of the climb [0, 5]")
|
| 71 |
city: str | None = Field(None, description="The city this segment is in")
|
| 72 |
state: str | None = Field(None, description="The state this segment is in")
|
| 73 |
country: str | None = Field(None, description="The country this segment is in")
|
| 74 |
private: bool = Field(..., description="Whether this segment is private")
|
| 75 |
+
starred: bool = Field(..., description="Whether this segment is starred by the authenticated athlete")
|
|
|
|
|
|
|
| 76 |
|
| 77 |
|
| 78 |
class SegmentEffort(BaseModel):
|
|
|
|
| 85 |
elapsed_time: int = Field(..., description="The elapsed time in seconds")
|
| 86 |
moving_time: int = Field(..., description="The moving time in seconds")
|
| 87 |
start_date: datetime = Field(..., description="Start date and time in UTC")
|
| 88 |
+
start_date_local: datetime = Field(..., description="Start date and time in athlete's timezone")
|
|
|
|
|
|
|
| 89 |
distance: float = Field(..., description="The effort's distance in meters")
|
| 90 |
average_watts: float | None = Field(None, description="Average wattage")
|
| 91 |
+
device_watts: bool | None = Field(None, description="Whether power data comes from a power meter")
|
|
|
|
|
|
|
| 92 |
average_heartrate: float | None = Field(None, description="Average heartrate")
|
| 93 |
max_heartrate: float | None = Field(None, description="Maximum heartrate")
|
| 94 |
+
pr_rank: int | None = Field(None, description="Personal record rank (1-3), 0 if not a PR")
|
|
|
|
|
|
|
| 95 |
achievements: list[dict] | None = Field(None, description="List of achievements")
|
| 96 |
athlete: dict = Field(..., description="The athlete who performed the effort")
|
| 97 |
segment: Segment = Field(..., description="The segment")
|
| 98 |
|
| 99 |
|
|
|
|
|
|
|
| 100 |
class ErrorResponse(BaseModel):
|
| 101 |
"""Represents an error response from the Strava API."""
|
| 102 |
|
strava_mcp/oauth_server.py
CHANGED
|
@@ -3,14 +3,13 @@
|
|
| 3 |
import asyncio
|
| 4 |
import logging
|
| 5 |
import os
|
| 6 |
-
import threading
|
| 7 |
import webbrowser
|
| 8 |
from contextlib import asynccontextmanager
|
| 9 |
|
| 10 |
import uvicorn
|
| 11 |
from fastapi import FastAPI
|
| 12 |
|
| 13 |
-
from strava_mcp.auth import
|
| 14 |
|
| 15 |
# Configure logging
|
| 16 |
logging.basicConfig(
|
|
@@ -24,11 +23,11 @@ class StravaOAuthServer:
|
|
| 24 |
"""A standalone server for handling Strava OAuth flow."""
|
| 25 |
|
| 26 |
def __init__(
|
| 27 |
-
self,
|
| 28 |
-
client_id: str,
|
| 29 |
client_secret: str,
|
| 30 |
host: str = REDIRECT_HOST,
|
| 31 |
-
port: int = REDIRECT_PORT
|
| 32 |
):
|
| 33 |
"""Initialize the OAuth server.
|
| 34 |
|
|
@@ -63,6 +62,8 @@ class StravaOAuthServer:
|
|
| 63 |
await self._initialize_server()
|
| 64 |
|
| 65 |
# Open browser to start authorization
|
|
|
|
|
|
|
| 66 |
auth_url = self.authenticator.get_authorization_url()
|
| 67 |
logger.info(f"Opening browser to authorize with Strava: {auth_url}")
|
| 68 |
webbrowser.open(auth_url)
|
|
@@ -72,18 +73,19 @@ class StravaOAuthServer:
|
|
| 72 |
refresh_token = await self.token_future
|
| 73 |
logger.info("Successfully obtained refresh token")
|
| 74 |
return refresh_token
|
| 75 |
-
except asyncio.CancelledError:
|
| 76 |
logger.error("Token request was cancelled")
|
| 77 |
-
raise Exception("OAuth flow was cancelled")
|
| 78 |
except Exception as e:
|
| 79 |
logger.exception("Error during OAuth flow")
|
| 80 |
-
raise Exception(f"OAuth flow failed: {str(e)}")
|
| 81 |
finally:
|
| 82 |
# Stop the server once we have the token
|
| 83 |
await self._stop_server()
|
| 84 |
|
| 85 |
async def _initialize_server(self):
|
| 86 |
"""Initialize the FastAPI server for OAuth flow."""
|
|
|
|
| 87 |
@asynccontextmanager
|
| 88 |
async def lifespan(app: FastAPI):
|
| 89 |
yield
|
|
@@ -114,20 +116,26 @@ class StravaOAuthServer:
|
|
| 114 |
|
| 115 |
# Start server in a separate task
|
| 116 |
self.server_task = asyncio.create_task(self._run_server())
|
| 117 |
-
|
| 118 |
# Wait a moment for the server to start
|
| 119 |
await asyncio.sleep(0.5)
|
| 120 |
|
| 121 |
async def _run_server(self):
|
| 122 |
"""Run the uvicorn server."""
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
)
|
| 129 |
-
self.server = uvicorn.Server(config)
|
| 130 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
await self.server.serve()
|
| 132 |
except Exception as e:
|
| 133 |
logger.exception("Error running OAuth server")
|
|
@@ -141,7 +149,7 @@ class StravaOAuthServer:
|
|
| 141 |
if self.server_task:
|
| 142 |
try:
|
| 143 |
await asyncio.wait_for(self.server_task, timeout=5.0)
|
| 144 |
-
except
|
| 145 |
logger.warning("Server shutdown timed out")
|
| 146 |
|
| 147 |
|
|
@@ -165,11 +173,11 @@ async def get_refresh_token_from_oauth(client_id: str, client_secret: str) -> st
|
|
| 165 |
if __name__ == "__main__":
|
| 166 |
# This allows running this file directly to get a refresh token
|
| 167 |
import sys
|
| 168 |
-
|
| 169 |
# Check if client_id and client_secret are provided as env vars
|
| 170 |
client_id = os.environ.get("STRAVA_CLIENT_ID")
|
| 171 |
client_secret = os.environ.get("STRAVA_CLIENT_SECRET")
|
| 172 |
-
|
| 173 |
# If not provided as env vars, check command line args
|
| 174 |
if not client_id or not client_secret:
|
| 175 |
if len(sys.argv) != 3:
|
|
@@ -178,9 +186,16 @@ if __name__ == "__main__":
|
|
| 178 |
sys.exit(1)
|
| 179 |
client_id = sys.argv[1]
|
| 180 |
client_secret = sys.argv[2]
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
async def main():
|
| 183 |
try:
|
|
|
|
|
|
|
| 184 |
token = await get_refresh_token_from_oauth(client_id, client_secret)
|
| 185 |
print(f"\nSuccessfully obtained refresh token: {token}")
|
| 186 |
print("\nYou can add this to your environment variables:")
|
|
@@ -189,4 +204,4 @@ if __name__ == "__main__":
|
|
| 189 |
logger.exception("Error getting refresh token")
|
| 190 |
print(f"Error: {str(e)}")
|
| 191 |
|
| 192 |
-
asyncio.run(main())
|
|
|
|
| 3 |
import asyncio
|
| 4 |
import logging
|
| 5 |
import os
|
|
|
|
| 6 |
import webbrowser
|
| 7 |
from contextlib import asynccontextmanager
|
| 8 |
|
| 9 |
import uvicorn
|
| 10 |
from fastapi import FastAPI
|
| 11 |
|
| 12 |
+
from strava_mcp.auth import REDIRECT_HOST, REDIRECT_PORT, StravaAuthenticator
|
| 13 |
|
| 14 |
# Configure logging
|
| 15 |
logging.basicConfig(
|
|
|
|
| 23 |
"""A standalone server for handling Strava OAuth flow."""
|
| 24 |
|
| 25 |
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
client_id: str,
|
| 28 |
client_secret: str,
|
| 29 |
host: str = REDIRECT_HOST,
|
| 30 |
+
port: int = REDIRECT_PORT,
|
| 31 |
):
|
| 32 |
"""Initialize the OAuth server.
|
| 33 |
|
|
|
|
| 62 |
await self._initialize_server()
|
| 63 |
|
| 64 |
# Open browser to start authorization
|
| 65 |
+
if self.authenticator is None:
|
| 66 |
+
raise Exception("Authenticator not initialized")
|
| 67 |
auth_url = self.authenticator.get_authorization_url()
|
| 68 |
logger.info(f"Opening browser to authorize with Strava: {auth_url}")
|
| 69 |
webbrowser.open(auth_url)
|
|
|
|
| 73 |
refresh_token = await self.token_future
|
| 74 |
logger.info("Successfully obtained refresh token")
|
| 75 |
return refresh_token
|
| 76 |
+
except asyncio.CancelledError as err:
|
| 77 |
logger.error("Token request was cancelled")
|
| 78 |
+
raise Exception("OAuth flow was cancelled") from err
|
| 79 |
except Exception as e:
|
| 80 |
logger.exception("Error during OAuth flow")
|
| 81 |
+
raise Exception(f"OAuth flow failed: {str(e)}") from e
|
| 82 |
finally:
|
| 83 |
# Stop the server once we have the token
|
| 84 |
await self._stop_server()
|
| 85 |
|
| 86 |
async def _initialize_server(self):
|
| 87 |
"""Initialize the FastAPI server for OAuth flow."""
|
| 88 |
+
|
| 89 |
@asynccontextmanager
|
| 90 |
async def lifespan(app: FastAPI):
|
| 91 |
yield
|
|
|
|
| 116 |
|
| 117 |
# Start server in a separate task
|
| 118 |
self.server_task = asyncio.create_task(self._run_server())
|
| 119 |
+
|
| 120 |
# Wait a moment for the server to start
|
| 121 |
await asyncio.sleep(0.5)
|
| 122 |
|
| 123 |
async def _run_server(self):
|
| 124 |
"""Run the uvicorn server."""
|
| 125 |
+
# Ensure app is not None before passing to uvicorn
|
| 126 |
+
if not self.app:
|
| 127 |
+
raise ValueError("FastAPI app not initialized")
|
| 128 |
+
|
| 129 |
+
# Use fixed port 3008
|
|
|
|
|
|
|
| 130 |
try:
|
| 131 |
+
config = uvicorn.Config(
|
| 132 |
+
app=self.app,
|
| 133 |
+
host=self.host,
|
| 134 |
+
port=self.port,
|
| 135 |
+
log_level="info",
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
self.server = uvicorn.Server(config)
|
| 139 |
await self.server.serve()
|
| 140 |
except Exception as e:
|
| 141 |
logger.exception("Error running OAuth server")
|
|
|
|
| 149 |
if self.server_task:
|
| 150 |
try:
|
| 151 |
await asyncio.wait_for(self.server_task, timeout=5.0)
|
| 152 |
+
except TimeoutError:
|
| 153 |
logger.warning("Server shutdown timed out")
|
| 154 |
|
| 155 |
|
|
|
|
| 173 |
if __name__ == "__main__":
|
| 174 |
# This allows running this file directly to get a refresh token
|
| 175 |
import sys
|
| 176 |
+
|
| 177 |
# Check if client_id and client_secret are provided as env vars
|
| 178 |
client_id = os.environ.get("STRAVA_CLIENT_ID")
|
| 179 |
client_secret = os.environ.get("STRAVA_CLIENT_SECRET")
|
| 180 |
+
|
| 181 |
# If not provided as env vars, check command line args
|
| 182 |
if not client_id or not client_secret:
|
| 183 |
if len(sys.argv) != 3:
|
|
|
|
| 186 |
sys.exit(1)
|
| 187 |
client_id = sys.argv[1]
|
| 188 |
client_secret = sys.argv[2]
|
| 189 |
+
|
| 190 |
+
# Ensure we have non-None values
|
| 191 |
+
if client_id is None or client_secret is None:
|
| 192 |
+
print("Error: Missing client_id or client_secret")
|
| 193 |
+
sys.exit(1)
|
| 194 |
+
|
| 195 |
async def main():
|
| 196 |
try:
|
| 197 |
+
# We've verified these aren't None above
|
| 198 |
+
assert client_id is not None and client_secret is not None
|
| 199 |
token = await get_refresh_token_from_oauth(client_id, client_secret)
|
| 200 |
print(f"\nSuccessfully obtained refresh token: {token}")
|
| 201 |
print("\nYou can add this to your environment variables:")
|
|
|
|
| 204 |
logger.exception("Error getting refresh token")
|
| 205 |
print(f"Error: {str(e)}")
|
| 206 |
|
| 207 |
+
asyncio.run(main())
|
strava_mcp/server.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
import logging
|
| 2 |
from collections.abc import AsyncIterator
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
-
from typing import Any
|
| 5 |
|
|
|
|
| 6 |
from mcp.server.fastmcp import Context, FastMCP
|
| 7 |
|
| 8 |
from strava_mcp.config import StravaSettings
|
|
@@ -28,19 +29,30 @@ async def lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]:
|
|
| 28 |
"""
|
| 29 |
# Load settings from environment variables
|
| 30 |
try:
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
logger.info("Loaded Strava API settings")
|
| 33 |
except Exception as e:
|
| 34 |
logger.error(f"Failed to load Strava API settings: {str(e)}")
|
| 35 |
raise
|
| 36 |
|
| 37 |
-
#
|
| 38 |
-
fastapi_app = server
|
| 39 |
-
|
| 40 |
-
# Initialize the Strava service with the FastAPI app
|
| 41 |
service = StravaService(settings, fastapi_app)
|
| 42 |
logger.info("Initialized Strava service")
|
| 43 |
-
|
| 44 |
# Set up authentication routes and initialize
|
| 45 |
await service.initialize()
|
| 46 |
logger.info("Service initialization completed")
|
|
@@ -58,6 +70,9 @@ mcp = FastMCP(
|
|
| 58 |
"Strava",
|
| 59 |
description="MCP server for interacting with the Strava API",
|
| 60 |
lifespan=lifespan,
|
|
|
|
|
|
|
|
|
|
| 61 |
)
|
| 62 |
|
| 63 |
|
|
@@ -81,9 +96,16 @@ async def get_user_activities(
|
|
| 81 |
Returns:
|
| 82 |
List of activities
|
| 83 |
"""
|
| 84 |
-
service = ctx.request_context.lifespan_context["service"]
|
| 85 |
-
|
| 86 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
activities = await service.get_activities(before, after, page, per_page)
|
| 88 |
return [activity.model_dump() for activity in activities]
|
| 89 |
except Exception as e:
|
|
@@ -107,9 +129,16 @@ async def get_activity(
|
|
| 107 |
Returns:
|
| 108 |
The activity details
|
| 109 |
"""
|
| 110 |
-
service = ctx.request_context.lifespan_context["service"]
|
| 111 |
-
|
| 112 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
activity = await service.get_activity(activity_id, include_all_efforts)
|
| 114 |
return activity.model_dump()
|
| 115 |
except Exception as e:
|
|
@@ -131,13 +160,18 @@ async def get_activity_segments(
|
|
| 131 |
Returns:
|
| 132 |
List of segment efforts for the activity
|
| 133 |
"""
|
| 134 |
-
service = ctx.request_context.lifespan_context["service"]
|
| 135 |
-
|
| 136 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
segments = await service.get_activity_segments(activity_id)
|
| 138 |
return [segment.model_dump() for segment in segments]
|
| 139 |
except Exception as e:
|
| 140 |
logger.error(f"Error in get_activity_segments tool: {str(e)}")
|
| 141 |
raise
|
| 142 |
-
|
| 143 |
-
|
|
|
|
| 1 |
import logging
|
| 2 |
from collections.abc import AsyncIterator
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
+
from typing import Any, cast
|
| 5 |
|
| 6 |
+
from fastapi import FastAPI
|
| 7 |
from mcp.server.fastmcp import Context, FastMCP
|
| 8 |
|
| 9 |
from strava_mcp.config import StravaSettings
|
|
|
|
| 29 |
"""
|
| 30 |
# Load settings from environment variables
|
| 31 |
try:
|
| 32 |
+
# Let StravaSettings load values directly from env vars
|
| 33 |
+
settings = StravaSettings(
|
| 34 |
+
client_id="", # Will be loaded from environment variables
|
| 35 |
+
client_secret="", # Will be loaded from environment variables
|
| 36 |
+
base_url="https://www.strava.com/api/v3", # Default value
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
if not settings.client_id:
|
| 40 |
+
raise ValueError("STRAVA_CLIENT_ID environment variable is not set")
|
| 41 |
+
if not settings.client_secret:
|
| 42 |
+
raise ValueError("STRAVA_CLIENT_SECRET environment variable is not set")
|
| 43 |
+
|
| 44 |
logger.info("Loaded Strava API settings")
|
| 45 |
except Exception as e:
|
| 46 |
logger.error(f"Failed to load Strava API settings: {str(e)}")
|
| 47 |
raise
|
| 48 |
|
| 49 |
+
# FastMCP extends FastAPI, so we can safely cast it for type checking
|
| 50 |
+
fastapi_app = cast(FastAPI, server)
|
| 51 |
+
|
| 52 |
+
# Initialize the Strava service with the FastAPI app
|
| 53 |
service = StravaService(settings, fastapi_app)
|
| 54 |
logger.info("Initialized Strava service")
|
| 55 |
+
|
| 56 |
# Set up authentication routes and initialize
|
| 57 |
await service.initialize()
|
| 58 |
logger.info("Service initialization completed")
|
|
|
|
| 70 |
"Strava",
|
| 71 |
description="MCP server for interacting with the Strava API",
|
| 72 |
lifespan=lifespan,
|
| 73 |
+
client_id="", # Will be loaded from environment variables
|
| 74 |
+
client_secret="", # Will be loaded from environment variables
|
| 75 |
+
base_url="", # Will be loaded from environment variables
|
| 76 |
)
|
| 77 |
|
| 78 |
|
|
|
|
| 96 |
Returns:
|
| 97 |
List of activities
|
| 98 |
"""
|
|
|
|
|
|
|
| 99 |
try:
|
| 100 |
+
# Safely access service from context
|
| 101 |
+
if not ctx.request_context.lifespan_context:
|
| 102 |
+
raise ValueError("Lifespan context not available")
|
| 103 |
+
|
| 104 |
+
# Cast service to StravaService to satisfy type checker
|
| 105 |
+
service = cast(StravaService, ctx.request_context.lifespan_context.get("service"))
|
| 106 |
+
if not service:
|
| 107 |
+
raise ValueError("Service not available in context")
|
| 108 |
+
|
| 109 |
activities = await service.get_activities(before, after, page, per_page)
|
| 110 |
return [activity.model_dump() for activity in activities]
|
| 111 |
except Exception as e:
|
|
|
|
| 129 |
Returns:
|
| 130 |
The activity details
|
| 131 |
"""
|
|
|
|
|
|
|
| 132 |
try:
|
| 133 |
+
# Safely access service from context
|
| 134 |
+
if not ctx.request_context.lifespan_context:
|
| 135 |
+
raise ValueError("Lifespan context not available")
|
| 136 |
+
|
| 137 |
+
# Cast service to StravaService to satisfy type checker
|
| 138 |
+
service = cast(StravaService, ctx.request_context.lifespan_context.get("service"))
|
| 139 |
+
if not service:
|
| 140 |
+
raise ValueError("Service not available in context")
|
| 141 |
+
|
| 142 |
activity = await service.get_activity(activity_id, include_all_efforts)
|
| 143 |
return activity.model_dump()
|
| 144 |
except Exception as e:
|
|
|
|
| 160 |
Returns:
|
| 161 |
List of segment efforts for the activity
|
| 162 |
"""
|
|
|
|
|
|
|
| 163 |
try:
|
| 164 |
+
# Safely access service from context
|
| 165 |
+
if not ctx.request_context.lifespan_context:
|
| 166 |
+
raise ValueError("Lifespan context not available")
|
| 167 |
+
|
| 168 |
+
# Cast service to StravaService to satisfy type checker
|
| 169 |
+
service = cast(StravaService, ctx.request_context.lifespan_context.get("service"))
|
| 170 |
+
if not service:
|
| 171 |
+
raise ValueError("Service not available in context")
|
| 172 |
+
|
| 173 |
segments = await service.get_activity_segments(activity_id)
|
| 174 |
return [segment.model_dump() for segment in segments]
|
| 175 |
except Exception as e:
|
| 176 |
logger.error(f"Error in get_activity_segments tool: {str(e)}")
|
| 177 |
raise
|
|
|
|
|
|
strava_mcp/service.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
import logging
|
| 2 |
-
from typing import Optional
|
| 3 |
|
| 4 |
from fastapi import FastAPI
|
| 5 |
|
|
@@ -13,7 +12,7 @@ logger = logging.getLogger(__name__)
|
|
| 13 |
class StravaService:
|
| 14 |
"""Service for interacting with the Strava API."""
|
| 15 |
|
| 16 |
-
def __init__(self, settings: StravaSettings, app:
|
| 17 |
"""Initialize the Strava service.
|
| 18 |
|
| 19 |
Args:
|
|
@@ -24,15 +23,12 @@ class StravaService:
|
|
| 24 |
self.api = StravaAPI(settings, app)
|
| 25 |
|
| 26 |
async def initialize(self):
|
| 27 |
-
"""Initialize the service
|
| 28 |
-
#
|
| 29 |
-
await self.api.setup_auth_routes()
|
| 30 |
-
|
| 31 |
-
# If we don't have a refresh token, log info about OAuth flow
|
| 32 |
if not self.settings.refresh_token:
|
| 33 |
logger.info(
|
| 34 |
"No STRAVA_REFRESH_TOKEN found in environment. "
|
| 35 |
-
"The OAuth flow will be triggered automatically when needed."
|
| 36 |
)
|
| 37 |
|
| 38 |
async def close(self):
|
|
@@ -66,9 +62,7 @@ class StravaService:
|
|
| 66 |
logger.error(f"Error getting activities: {str(e)}")
|
| 67 |
raise
|
| 68 |
|
| 69 |
-
async def get_activity(
|
| 70 |
-
self, activity_id: int, include_all_efforts: bool = False
|
| 71 |
-
) -> DetailedActivity:
|
| 72 |
"""Get a specific activity.
|
| 73 |
|
| 74 |
Args:
|
|
@@ -104,4 +98,3 @@ class StravaService:
|
|
| 104 |
except Exception as e:
|
| 105 |
logger.error(f"Error getting segments for activity {activity_id}: {str(e)}")
|
| 106 |
raise
|
| 107 |
-
|
|
|
|
| 1 |
import logging
|
|
|
|
| 2 |
|
| 3 |
from fastapi import FastAPI
|
| 4 |
|
|
|
|
| 12 |
class StravaService:
|
| 13 |
"""Service for interacting with the Strava API."""
|
| 14 |
|
| 15 |
+
def __init__(self, settings: StravaSettings, app: FastAPI | None = None):
|
| 16 |
"""Initialize the Strava service.
|
| 17 |
|
| 18 |
Args:
|
|
|
|
| 23 |
self.api = StravaAPI(settings, app)
|
| 24 |
|
| 25 |
async def initialize(self):
|
| 26 |
+
"""Initialize the service."""
|
| 27 |
+
# Log info about OAuth flow if no refresh token
|
|
|
|
|
|
|
|
|
|
| 28 |
if not self.settings.refresh_token:
|
| 29 |
logger.info(
|
| 30 |
"No STRAVA_REFRESH_TOKEN found in environment. "
|
| 31 |
+
"The standalone OAuth flow will be triggered automatically when needed."
|
| 32 |
)
|
| 33 |
|
| 34 |
async def close(self):
|
|
|
|
| 62 |
logger.error(f"Error getting activities: {str(e)}")
|
| 63 |
raise
|
| 64 |
|
| 65 |
+
async def get_activity(self, activity_id: int, include_all_efforts: bool = False) -> DetailedActivity:
|
|
|
|
|
|
|
| 66 |
"""Get a specific activity.
|
| 67 |
|
| 68 |
Args:
|
|
|
|
| 98 |
except Exception as e:
|
| 99 |
logger.error(f"Error getting segments for activity {activity_id}: {str(e)}")
|
| 100 |
raise
|
|
|
tests/test_api.py
CHANGED
|
@@ -14,6 +14,7 @@ def settings():
|
|
| 14 |
client_id="test_client_id",
|
| 15 |
client_secret="test_client_secret",
|
| 16 |
refresh_token="test_refresh_token",
|
|
|
|
| 17 |
)
|
| 18 |
|
| 19 |
|
|
@@ -175,5 +176,3 @@ async def test_get_activity(api, mock_response):
|
|
| 175 |
assert activity.id == activity_data["id"]
|
| 176 |
assert activity.name == activity_data["name"]
|
| 177 |
assert activity.description == activity_data["description"]
|
| 178 |
-
|
| 179 |
-
|
|
|
|
| 14 |
client_id="test_client_id",
|
| 15 |
client_secret="test_client_secret",
|
| 16 |
refresh_token="test_refresh_token",
|
| 17 |
+
base_url="https://www.strava.com/api/v3",
|
| 18 |
)
|
| 19 |
|
| 20 |
|
|
|
|
| 176 |
assert activity.id == activity_data["id"]
|
| 177 |
assert activity.name == activity_data["name"]
|
| 178 |
assert activity.description == activity_data["description"]
|
|
|
|
|
|
tests/test_auth.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the Strava authentication module."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from fastapi import FastAPI
|
| 8 |
+
from httpx import Response
|
| 9 |
+
|
| 10 |
+
from strava_mcp.auth import StravaAuthenticator, get_strava_refresh_token
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@pytest.fixture
|
| 14 |
+
def client_credentials():
|
| 15 |
+
"""Fixture for client credentials."""
|
| 16 |
+
return {
|
| 17 |
+
"client_id": "test_client_id",
|
| 18 |
+
"client_secret": "test_client_secret",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@pytest.fixture
|
| 23 |
+
def mock_token_response():
|
| 24 |
+
"""Fixture for token response."""
|
| 25 |
+
return {
|
| 26 |
+
"access_token": "test_access_token",
|
| 27 |
+
"refresh_token": "test_refresh_token",
|
| 28 |
+
"expires_at": 1609459200,
|
| 29 |
+
"expires_in": 21600,
|
| 30 |
+
"token_type": "Bearer",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.fixture
|
| 35 |
+
def fastapi_app():
|
| 36 |
+
"""Fixture for FastAPI app."""
|
| 37 |
+
return FastAPI()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@pytest.fixture
|
| 41 |
+
def authenticator(client_credentials, fastapi_app):
|
| 42 |
+
"""Fixture for StravaAuthenticator."""
|
| 43 |
+
return StravaAuthenticator(
|
| 44 |
+
client_id=client_credentials["client_id"],
|
| 45 |
+
client_secret=client_credentials["client_secret"],
|
| 46 |
+
app=fastapi_app,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_get_authorization_url(authenticator):
|
| 51 |
+
"""Test getting the authorization URL."""
|
| 52 |
+
url = authenticator.get_authorization_url()
|
| 53 |
+
|
| 54 |
+
# Check that the URL contains the expected parameters
|
| 55 |
+
assert "https://www.strava.com/oauth/authorize" in url
|
| 56 |
+
assert f"client_id={authenticator.client_id}" in url
|
| 57 |
+
# URL is encoded, so we need to check the non-encoded parts
|
| 58 |
+
assert "redirect_uri=http%3A%2F%2F127.0.0.1%3A3008%2Fexchange_token" in url
|
| 59 |
+
assert "response_type=code" in url
|
| 60 |
+
assert "scope=" in url
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_setup_routes(authenticator, fastapi_app):
|
| 64 |
+
"""Test setting up routes."""
|
| 65 |
+
authenticator.setup_routes(fastapi_app)
|
| 66 |
+
|
| 67 |
+
# Check that the routes were added
|
| 68 |
+
routes = [route.path for route in fastapi_app.routes]
|
| 69 |
+
assert authenticator.redirect_path in routes
|
| 70 |
+
assert "/auth" in routes
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_setup_routes_no_app(authenticator):
|
| 74 |
+
"""Test setting up routes with no app."""
|
| 75 |
+
authenticator.app = None
|
| 76 |
+
with pytest.raises(ValueError, match="No FastAPI app provided"):
|
| 77 |
+
authenticator.setup_routes()
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@pytest.mark.asyncio
|
| 81 |
+
async def test_exchange_token_success(authenticator, mock_token_response):
|
| 82 |
+
"""Test exchanging token successfully."""
|
| 83 |
+
# Setup mock
|
| 84 |
+
with patch("httpx.AsyncClient") as mock_client:
|
| 85 |
+
mock_response = MagicMock(spec=Response)
|
| 86 |
+
mock_response.status_code = 200
|
| 87 |
+
mock_response.json.return_value = mock_token_response
|
| 88 |
+
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
| 89 |
+
|
| 90 |
+
# Set up a future to receive the token
|
| 91 |
+
authenticator.token_future = asyncio.Future()
|
| 92 |
+
|
| 93 |
+
# Call the handler
|
| 94 |
+
response = await authenticator.exchange_token(code="test_code")
|
| 95 |
+
|
| 96 |
+
# Check response
|
| 97 |
+
assert response.status_code == 200
|
| 98 |
+
assert "Authorization successful" in response.body.decode()
|
| 99 |
+
|
| 100 |
+
# Check token future
|
| 101 |
+
assert authenticator.token_future.done()
|
| 102 |
+
assert await authenticator.token_future == "test_refresh_token"
|
| 103 |
+
|
| 104 |
+
# Check token was saved
|
| 105 |
+
assert authenticator.refresh_token == "test_refresh_token"
|
| 106 |
+
|
| 107 |
+
# Verify correct API call
|
| 108 |
+
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
|
| 109 |
+
args, kwargs = mock_client.return_value.__aenter__.return_value.post.call_args
|
| 110 |
+
assert args[0] == "https://www.strava.com/oauth/token"
|
| 111 |
+
assert kwargs["data"]["client_id"] == authenticator.client_id
|
| 112 |
+
assert kwargs["data"]["client_secret"] == authenticator.client_secret
|
| 113 |
+
assert kwargs["data"]["code"] == "test_code"
|
| 114 |
+
assert kwargs["data"]["grant_type"] == "authorization_code"
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@pytest.mark.asyncio
|
| 118 |
+
async def test_exchange_token_failure(authenticator):
|
| 119 |
+
"""Test exchanging token with failure."""
|
| 120 |
+
# Setup mock
|
| 121 |
+
with patch("httpx.AsyncClient") as mock_client:
|
| 122 |
+
mock_response = MagicMock(spec=Response)
|
| 123 |
+
mock_response.status_code = 400
|
| 124 |
+
mock_response.text = "Invalid code"
|
| 125 |
+
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
| 126 |
+
|
| 127 |
+
# Set up a future to receive the token
|
| 128 |
+
authenticator.token_future = asyncio.Future()
|
| 129 |
+
|
| 130 |
+
# Call the handler
|
| 131 |
+
response = await authenticator.exchange_token(code="invalid_code")
|
| 132 |
+
|
| 133 |
+
# Check response
|
| 134 |
+
assert response.status_code == 200
|
| 135 |
+
assert "Authorization failed" in response.body.decode()
|
| 136 |
+
|
| 137 |
+
# Check token future
|
| 138 |
+
assert authenticator.token_future.done()
|
| 139 |
+
# We expect a specific exception here, so using pytest.raises is appropriate
|
| 140 |
+
with pytest.raises(Exception): # noqa: B017
|
| 141 |
+
await authenticator.token_future
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@pytest.mark.asyncio
|
| 145 |
+
async def test_start_auth_flow(authenticator):
|
| 146 |
+
"""Test starting auth flow."""
|
| 147 |
+
with patch.object(authenticator, "get_authorization_url", return_value="https://example.com/auth"):
|
| 148 |
+
response = await authenticator.start_auth_flow()
|
| 149 |
+
assert response.status_code == 307
|
| 150 |
+
assert response.headers["location"] == "https://example.com/auth"
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@pytest.mark.asyncio
|
| 154 |
+
async def test_get_refresh_token(authenticator):
|
| 155 |
+
"""Test getting refresh token."""
|
| 156 |
+
# Mock the webbrowser.open call
|
| 157 |
+
with patch("webbrowser.open", return_value=True) as mock_open:
|
| 158 |
+
with patch.object(authenticator, "get_authorization_url", return_value="https://example.com/auth"):
|
| 159 |
+
# Set the future result after a delay
|
| 160 |
+
authenticator.token_future = None # Reset it so a new one is created
|
| 161 |
+
|
| 162 |
+
# Start the token request in background
|
| 163 |
+
task = asyncio.create_task(authenticator.get_refresh_token())
|
| 164 |
+
|
| 165 |
+
# Wait a bit and set the result
|
| 166 |
+
await asyncio.sleep(0.1)
|
| 167 |
+
# Initialize the token_future before setting result
|
| 168 |
+
if not authenticator.token_future:
|
| 169 |
+
authenticator.token_future = asyncio.Future()
|
| 170 |
+
authenticator.token_future.set_result("test_refresh_token")
|
| 171 |
+
|
| 172 |
+
# Get the result
|
| 173 |
+
token = await task
|
| 174 |
+
|
| 175 |
+
# Verify
|
| 176 |
+
assert token == "test_refresh_token"
|
| 177 |
+
mock_open.assert_called_once_with("https://example.com/auth")
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@pytest.mark.asyncio
|
| 181 |
+
async def test_get_refresh_token_no_browser(authenticator):
|
| 182 |
+
"""Test getting refresh token without opening browser."""
|
| 183 |
+
with patch("webbrowser.open") as mock_open:
|
| 184 |
+
with patch.object(authenticator, "get_authorization_url", return_value="https://example.com/auth"):
|
| 185 |
+
# Set the future result after a delay
|
| 186 |
+
authenticator.token_future = None # Reset it so a new one is created
|
| 187 |
+
|
| 188 |
+
# Start the token request in background
|
| 189 |
+
task = asyncio.create_task(authenticator.get_refresh_token(open_browser=False))
|
| 190 |
+
|
| 191 |
+
# Wait a bit and set the result
|
| 192 |
+
await asyncio.sleep(0.1)
|
| 193 |
+
# Initialize the token_future before setting result
|
| 194 |
+
if not authenticator.token_future:
|
| 195 |
+
authenticator.token_future = asyncio.Future()
|
| 196 |
+
authenticator.token_future.set_result("test_refresh_token")
|
| 197 |
+
|
| 198 |
+
# Get the result
|
| 199 |
+
token = await task
|
| 200 |
+
|
| 201 |
+
# Verify
|
| 202 |
+
assert token == "test_refresh_token"
|
| 203 |
+
mock_open.assert_not_called()
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
@pytest.mark.asyncio
|
| 207 |
+
async def test_get_refresh_token_browser_fails(authenticator):
|
| 208 |
+
"""Test getting refresh token with browser opening failing."""
|
| 209 |
+
with patch("webbrowser.open", return_value=False) as mock_open:
|
| 210 |
+
with patch.object(authenticator, "get_authorization_url", return_value="https://example.com/auth"):
|
| 211 |
+
# Set the future result after a delay
|
| 212 |
+
authenticator.token_future = None # Reset it so a new one is created
|
| 213 |
+
|
| 214 |
+
# Start the token request in background
|
| 215 |
+
task = asyncio.create_task(authenticator.get_refresh_token())
|
| 216 |
+
|
| 217 |
+
# Wait a bit and set the result
|
| 218 |
+
await asyncio.sleep(0.1)
|
| 219 |
+
# Initialize the token_future before setting result
|
| 220 |
+
if not authenticator.token_future:
|
| 221 |
+
authenticator.token_future = asyncio.Future()
|
| 222 |
+
authenticator.token_future.set_result("test_refresh_token")
|
| 223 |
+
|
| 224 |
+
# Get the result
|
| 225 |
+
token = await task
|
| 226 |
+
|
| 227 |
+
# Verify
|
| 228 |
+
assert token == "test_refresh_token"
|
| 229 |
+
mock_open.assert_called_once_with("https://example.com/auth")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
@pytest.mark.asyncio
|
| 233 |
+
async def test_get_strava_refresh_token(client_credentials):
|
| 234 |
+
"""Test get_strava_refresh_token function."""
|
| 235 |
+
with patch("strava_mcp.auth.StravaAuthenticator") as mock_authenticator_class:
|
| 236 |
+
# Setup mock
|
| 237 |
+
mock_authenticator = MagicMock()
|
| 238 |
+
mock_authenticator.get_refresh_token = AsyncMock(return_value="test_refresh_token")
|
| 239 |
+
mock_authenticator.setup_routes = MagicMock()
|
| 240 |
+
mock_authenticator_class.return_value = mock_authenticator
|
| 241 |
+
|
| 242 |
+
# Test without app
|
| 243 |
+
token = await get_strava_refresh_token(client_credentials["client_id"], client_credentials["client_secret"])
|
| 244 |
+
|
| 245 |
+
# Verify
|
| 246 |
+
assert token == "test_refresh_token"
|
| 247 |
+
mock_authenticator_class.assert_called_once_with(
|
| 248 |
+
client_credentials["client_id"], client_credentials["client_secret"], None
|
| 249 |
+
)
|
| 250 |
+
mock_authenticator.setup_routes.assert_not_called()
|
| 251 |
+
|
| 252 |
+
# Reset mocks
|
| 253 |
+
mock_authenticator_class.reset_mock()
|
| 254 |
+
mock_authenticator.get_refresh_token.reset_mock()
|
| 255 |
+
mock_authenticator.setup_routes.reset_mock()
|
| 256 |
+
|
| 257 |
+
# Test with app
|
| 258 |
+
app = FastAPI()
|
| 259 |
+
token = await get_strava_refresh_token(
|
| 260 |
+
client_credentials["client_id"], client_credentials["client_secret"], app
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
# Verify
|
| 264 |
+
assert token == "test_refresh_token"
|
| 265 |
+
mock_authenticator_class.assert_called_once_with(
|
| 266 |
+
client_credentials["client_id"], client_credentials["client_secret"], app
|
| 267 |
+
)
|
| 268 |
+
mock_authenticator.setup_routes.assert_called_once_with(app)
|
tests/test_config.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for configuration module."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from unittest import mock
|
| 5 |
+
|
| 6 |
+
from strava_mcp.config import StravaSettings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_strava_settings_defaults():
|
| 10 |
+
"""Test default settings for StravaSettings."""
|
| 11 |
+
# Use required parameters only
|
| 12 |
+
with mock.patch.dict(os.environ, {}, clear=True):
|
| 13 |
+
# Explicitly ensure we're not using STRAVA_REFRESH_TOKEN from environment
|
| 14 |
+
settings = StravaSettings(
|
| 15 |
+
client_id="test_client_id",
|
| 16 |
+
client_secret="test_client_secret",
|
| 17 |
+
refresh_token=None,
|
| 18 |
+
base_url="https://www.strava.com/api/v3",
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
assert settings.client_id == "test_client_id"
|
| 22 |
+
assert settings.client_secret == "test_client_secret"
|
| 23 |
+
assert settings.refresh_token is None
|
| 24 |
+
assert settings.base_url == "https://www.strava.com/api/v3"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_strava_settings_from_env():
|
| 28 |
+
"""Test loading settings from environment variables."""
|
| 29 |
+
with mock.patch.dict(
|
| 30 |
+
os.environ,
|
| 31 |
+
{
|
| 32 |
+
"STRAVA_CLIENT_ID": "env_client_id",
|
| 33 |
+
"STRAVA_CLIENT_SECRET": "env_client_secret",
|
| 34 |
+
"STRAVA_REFRESH_TOKEN": "env_refresh_token",
|
| 35 |
+
"STRAVA_BASE_URL": "https://custom.strava.api/v3",
|
| 36 |
+
},
|
| 37 |
+
):
|
| 38 |
+
# Even with env vars, we need to provide required params for type checking
|
| 39 |
+
settings = StravaSettings(
|
| 40 |
+
client_id="", # Will be overridden by env vars
|
| 41 |
+
client_secret="", # Will be overridden by env vars
|
| 42 |
+
base_url="", # Will be overridden by env vars
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
assert settings.client_id == "env_client_id"
|
| 46 |
+
assert settings.client_secret == "env_client_secret"
|
| 47 |
+
assert settings.refresh_token == "env_refresh_token"
|
| 48 |
+
assert settings.base_url == "https://custom.strava.api/v3"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_strava_settings_override():
|
| 52 |
+
"""Test overriding environment settings with direct values."""
|
| 53 |
+
with mock.patch.dict(
|
| 54 |
+
os.environ,
|
| 55 |
+
{
|
| 56 |
+
"STRAVA_CLIENT_ID": "env_client_id",
|
| 57 |
+
"STRAVA_CLIENT_SECRET": "env_client_secret",
|
| 58 |
+
"STRAVA_REFRESH_TOKEN": "env_refresh_token",
|
| 59 |
+
},
|
| 60 |
+
):
|
| 61 |
+
settings = StravaSettings(
|
| 62 |
+
client_id="direct_client_id",
|
| 63 |
+
client_secret="", # Will be taken from env vars
|
| 64 |
+
refresh_token="direct_refresh_token",
|
| 65 |
+
base_url="https://www.strava.com/api/v3",
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# Direct values should override environment variables
|
| 69 |
+
assert settings.client_id == "direct_client_id"
|
| 70 |
+
assert settings.client_secret == "env_client_secret"
|
| 71 |
+
assert settings.refresh_token == "direct_refresh_token"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_strava_settings_model_config():
|
| 75 |
+
"""Test model configuration for StravaSettings."""
|
| 76 |
+
# Access model_config safely, with type handling
|
| 77 |
+
model_config = StravaSettings.model_config
|
| 78 |
+
# We can safely access these fields as we know they exist in our configuration
|
| 79 |
+
assert model_config.get("env_prefix") == "STRAVA_"
|
| 80 |
+
assert model_config.get("env_file") == ".env"
|
| 81 |
+
assert model_config.get("env_file_encoding") == "utf-8"
|
tests/test_models.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the Strava models."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from pydantic import ValidationError
|
| 7 |
+
|
| 8 |
+
from strava_mcp.models import Activity, DetailedActivity, ErrorResponse, Segment, SegmentEffort
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@pytest.fixture
|
| 12 |
+
def activity_data():
|
| 13 |
+
"""Fixture with valid activity data."""
|
| 14 |
+
return {
|
| 15 |
+
"id": 1234567890,
|
| 16 |
+
"name": "Morning Run",
|
| 17 |
+
"distance": 5000.0,
|
| 18 |
+
"moving_time": 1200,
|
| 19 |
+
"elapsed_time": 1300,
|
| 20 |
+
"total_elevation_gain": 50.0,
|
| 21 |
+
"type": "Run",
|
| 22 |
+
"sport_type": "Run",
|
| 23 |
+
"start_date": "2023-01-01T10:00:00Z",
|
| 24 |
+
"start_date_local": "2023-01-01T10:00:00Z",
|
| 25 |
+
"timezone": "Europe/London",
|
| 26 |
+
"achievement_count": 2,
|
| 27 |
+
"kudos_count": 5,
|
| 28 |
+
"comment_count": 0,
|
| 29 |
+
"athlete_count": 1,
|
| 30 |
+
"photo_count": 0,
|
| 31 |
+
"trainer": False,
|
| 32 |
+
"commute": False,
|
| 33 |
+
"manual": False,
|
| 34 |
+
"private": False,
|
| 35 |
+
"flagged": False,
|
| 36 |
+
"average_speed": 4.167,
|
| 37 |
+
"max_speed": 5.3,
|
| 38 |
+
"has_heartrate": True,
|
| 39 |
+
"average_heartrate": 140.0,
|
| 40 |
+
"max_heartrate": 160.0,
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@pytest.fixture
|
| 45 |
+
def detailed_activity_data(activity_data):
|
| 46 |
+
"""Fixture with valid detailed activity data."""
|
| 47 |
+
return {
|
| 48 |
+
**activity_data,
|
| 49 |
+
"description": "Test description",
|
| 50 |
+
"athlete": {"id": 123},
|
| 51 |
+
"calories": 500.0,
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@pytest.fixture
|
| 56 |
+
def segment_data():
|
| 57 |
+
"""Fixture with valid segment data."""
|
| 58 |
+
return {
|
| 59 |
+
"id": 12345,
|
| 60 |
+
"name": "Test Segment",
|
| 61 |
+
"activity_type": "Run",
|
| 62 |
+
"distance": 1000.0,
|
| 63 |
+
"average_grade": 5.0,
|
| 64 |
+
"maximum_grade": 10.0,
|
| 65 |
+
"elevation_high": 200.0,
|
| 66 |
+
"elevation_low": 150.0,
|
| 67 |
+
"total_elevation_gain": 50.0,
|
| 68 |
+
"start_latlng": [51.5, -0.1],
|
| 69 |
+
"end_latlng": [51.5, -0.2],
|
| 70 |
+
"climb_category": 0,
|
| 71 |
+
"private": False,
|
| 72 |
+
"starred": False,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@pytest.fixture
|
| 77 |
+
def segment_effort_data(segment_data):
|
| 78 |
+
"""Fixture with valid segment effort data."""
|
| 79 |
+
return {
|
| 80 |
+
"id": 67890,
|
| 81 |
+
"activity_id": 1234567890,
|
| 82 |
+
"segment_id": 12345,
|
| 83 |
+
"name": "Test Segment",
|
| 84 |
+
"elapsed_time": 180,
|
| 85 |
+
"moving_time": 180,
|
| 86 |
+
"start_date": "2023-01-01T10:05:00Z",
|
| 87 |
+
"start_date_local": "2023-01-01T10:05:00Z",
|
| 88 |
+
"distance": 1000.0,
|
| 89 |
+
"athlete": {"id": 123},
|
| 90 |
+
"segment": segment_data,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_activity_model(activity_data):
|
| 95 |
+
"""Test the Activity model."""
|
| 96 |
+
activity = Activity(**activity_data)
|
| 97 |
+
|
| 98 |
+
assert activity.id == activity_data["id"]
|
| 99 |
+
assert activity.name == activity_data["name"]
|
| 100 |
+
assert activity.distance == activity_data["distance"]
|
| 101 |
+
assert activity.start_date == datetime.fromisoformat(activity_data["start_date"].replace("Z", "+00:00"))
|
| 102 |
+
assert activity.start_date_local == datetime.fromisoformat(activity_data["start_date_local"].replace("Z", "+00:00"))
|
| 103 |
+
assert activity.average_heartrate == activity_data["average_heartrate"]
|
| 104 |
+
assert activity.max_heartrate == activity_data["max_heartrate"]
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_activity_model_optional_fields(activity_data):
|
| 108 |
+
"""Test the Activity model with optional fields."""
|
| 109 |
+
# Remove some optional fields
|
| 110 |
+
data = activity_data.copy()
|
| 111 |
+
data.pop("average_heartrate")
|
| 112 |
+
data.pop("max_heartrate")
|
| 113 |
+
|
| 114 |
+
activity = Activity(**data)
|
| 115 |
+
|
| 116 |
+
assert activity.average_heartrate is None
|
| 117 |
+
assert activity.max_heartrate is None
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_activity_model_missing_required_fields(activity_data):
|
| 121 |
+
"""Test the Activity model with missing required fields."""
|
| 122 |
+
data = activity_data.copy()
|
| 123 |
+
data.pop("id") # Remove a required field
|
| 124 |
+
|
| 125 |
+
with pytest.raises(ValidationError):
|
| 126 |
+
Activity(**data)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_detailed_activity_model(detailed_activity_data):
|
| 130 |
+
"""Test the DetailedActivity model."""
|
| 131 |
+
activity = DetailedActivity(**detailed_activity_data)
|
| 132 |
+
|
| 133 |
+
assert activity.id == detailed_activity_data["id"]
|
| 134 |
+
assert activity.name == detailed_activity_data["name"]
|
| 135 |
+
assert activity.description == detailed_activity_data["description"]
|
| 136 |
+
assert activity.athlete == detailed_activity_data["athlete"]
|
| 137 |
+
assert activity.calories == detailed_activity_data["calories"]
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_detailed_activity_optional_fields(detailed_activity_data):
|
| 141 |
+
"""Test the DetailedActivity model with optional fields."""
|
| 142 |
+
data = detailed_activity_data.copy()
|
| 143 |
+
data.pop("description")
|
| 144 |
+
data.pop("calories")
|
| 145 |
+
|
| 146 |
+
activity = DetailedActivity(**data)
|
| 147 |
+
|
| 148 |
+
assert activity.description is None
|
| 149 |
+
assert activity.calories is None
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def test_segment_model(segment_data):
|
| 153 |
+
"""Test the Segment model."""
|
| 154 |
+
segment = Segment(**segment_data)
|
| 155 |
+
|
| 156 |
+
assert segment.id == segment_data["id"]
|
| 157 |
+
assert segment.name == segment_data["name"]
|
| 158 |
+
assert segment.activity_type == segment_data["activity_type"]
|
| 159 |
+
assert segment.distance == segment_data["distance"]
|
| 160 |
+
assert segment.start_latlng == segment_data["start_latlng"]
|
| 161 |
+
assert segment.end_latlng == segment_data["end_latlng"]
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_segment_optional_fields(segment_data):
|
| 165 |
+
"""Test the Segment model with optional fields."""
|
| 166 |
+
# Add some optional fields
|
| 167 |
+
data = segment_data.copy()
|
| 168 |
+
data["city"] = "London"
|
| 169 |
+
data["state"] = "Greater London"
|
| 170 |
+
data["country"] = "United Kingdom"
|
| 171 |
+
|
| 172 |
+
segment = Segment(**data)
|
| 173 |
+
|
| 174 |
+
assert segment.city == "London"
|
| 175 |
+
assert segment.state == "Greater London"
|
| 176 |
+
assert segment.country == "United Kingdom"
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_segment_missing_fields(segment_data):
|
| 180 |
+
"""Test the Segment model with missing required fields."""
|
| 181 |
+
data = segment_data.copy()
|
| 182 |
+
data.pop("id") # Remove a required field
|
| 183 |
+
|
| 184 |
+
with pytest.raises(ValidationError):
|
| 185 |
+
Segment(**data)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def test_segment_effort_model(segment_effort_data):
|
| 189 |
+
"""Test the SegmentEffort model."""
|
| 190 |
+
effort = SegmentEffort(**segment_effort_data)
|
| 191 |
+
|
| 192 |
+
assert effort.id == segment_effort_data["id"]
|
| 193 |
+
assert effort.activity_id == segment_effort_data["activity_id"]
|
| 194 |
+
assert effort.segment_id == segment_effort_data["segment_id"]
|
| 195 |
+
assert effort.name == segment_effort_data["name"]
|
| 196 |
+
assert effort.elapsed_time == segment_effort_data["elapsed_time"]
|
| 197 |
+
assert effort.moving_time == segment_effort_data["moving_time"]
|
| 198 |
+
assert effort.start_date == datetime.fromisoformat(segment_effort_data["start_date"].replace("Z", "+00:00"))
|
| 199 |
+
assert effort.start_date_local == datetime.fromisoformat(
|
| 200 |
+
segment_effort_data["start_date_local"].replace("Z", "+00:00")
|
| 201 |
+
)
|
| 202 |
+
assert effort.distance == segment_effort_data["distance"]
|
| 203 |
+
assert effort.athlete == segment_effort_data["athlete"]
|
| 204 |
+
|
| 205 |
+
# Test nested segment object
|
| 206 |
+
assert effort.segment.id == segment_effort_data["segment"]["id"]
|
| 207 |
+
assert effort.segment.name == segment_effort_data["segment"]["name"]
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def test_segment_effort_optional_fields(segment_effort_data):
|
| 211 |
+
"""Test the SegmentEffort model with optional fields."""
|
| 212 |
+
# Add some optional fields
|
| 213 |
+
data = segment_effort_data.copy()
|
| 214 |
+
data["average_watts"] = 200.0
|
| 215 |
+
data["device_watts"] = True
|
| 216 |
+
data["average_heartrate"] = 150.0
|
| 217 |
+
data["max_heartrate"] = 170.0
|
| 218 |
+
data["pr_rank"] = 1
|
| 219 |
+
data["achievements"] = [{"type": "overall", "rank": 1}]
|
| 220 |
+
|
| 221 |
+
effort = SegmentEffort(**data)
|
| 222 |
+
|
| 223 |
+
assert effort.average_watts == 200.0
|
| 224 |
+
assert effort.device_watts is True
|
| 225 |
+
assert effort.average_heartrate == 150.0
|
| 226 |
+
assert effort.max_heartrate == 170.0
|
| 227 |
+
assert effort.pr_rank == 1
|
| 228 |
+
assert effort.achievements == [{"type": "overall", "rank": 1}]
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def test_segment_effort_missing_fields(segment_effort_data):
|
| 232 |
+
"""Test the SegmentEffort model with missing required fields."""
|
| 233 |
+
data = segment_effort_data.copy()
|
| 234 |
+
data.pop("segment") # Remove a required field
|
| 235 |
+
|
| 236 |
+
with pytest.raises(ValidationError):
|
| 237 |
+
SegmentEffort(**data)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def test_error_response():
|
| 241 |
+
"""Test the ErrorResponse model."""
|
| 242 |
+
data = {"message": "Resource not found", "code": 404}
|
| 243 |
+
|
| 244 |
+
error = ErrorResponse(**data)
|
| 245 |
+
|
| 246 |
+
assert error.message == "Resource not found"
|
| 247 |
+
assert error.code == 404
|
tests/test_oauth_server.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the Strava OAuth server module."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from fastapi import FastAPI
|
| 8 |
+
|
| 9 |
+
from strava_mcp.oauth_server import StravaOAuthServer, get_refresh_token_from_oauth
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture
|
| 13 |
+
def client_credentials():
|
| 14 |
+
"""Fixture for client credentials."""
|
| 15 |
+
return {
|
| 16 |
+
"client_id": "test_client_id",
|
| 17 |
+
"client_secret": "test_client_secret",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture
|
| 22 |
+
def oauth_server(client_credentials):
|
| 23 |
+
"""Fixture for StravaOAuthServer."""
|
| 24 |
+
return StravaOAuthServer(
|
| 25 |
+
client_id=client_credentials["client_id"],
|
| 26 |
+
client_secret=client_credentials["client_secret"],
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@pytest.mark.asyncio
|
| 31 |
+
async def test_initialize_server(oauth_server):
|
| 32 |
+
"""Test initializing the server."""
|
| 33 |
+
# Mock the OAuth server's dependencies directly
|
| 34 |
+
with patch("strava_mcp.oauth_server.StravaAuthenticator") as mock_authenticator_class:
|
| 35 |
+
with patch("asyncio.create_task") as mock_create_task:
|
| 36 |
+
# Setup mocks
|
| 37 |
+
mock_authenticator = MagicMock()
|
| 38 |
+
mock_authenticator_class.return_value = mock_authenticator
|
| 39 |
+
mock_task = MagicMock()
|
| 40 |
+
mock_create_task.return_value = mock_task
|
| 41 |
+
|
| 42 |
+
# Test method
|
| 43 |
+
await oauth_server._initialize_server()
|
| 44 |
+
|
| 45 |
+
# Verify FastAPI app was created
|
| 46 |
+
assert oauth_server.app is not None
|
| 47 |
+
assert oauth_server.app.title == "Strava OAuth"
|
| 48 |
+
|
| 49 |
+
# Verify authenticator was created and configured
|
| 50 |
+
mock_authenticator_class.assert_called_once_with(
|
| 51 |
+
client_id=oauth_server.client_id,
|
| 52 |
+
client_secret=oauth_server.client_secret,
|
| 53 |
+
app=oauth_server.app,
|
| 54 |
+
host=oauth_server.host,
|
| 55 |
+
port=oauth_server.port,
|
| 56 |
+
)
|
| 57 |
+
assert oauth_server.authenticator == mock_authenticator
|
| 58 |
+
|
| 59 |
+
# Verify token future was stored in authenticator
|
| 60 |
+
assert mock_authenticator.token_future is oauth_server.token_future
|
| 61 |
+
|
| 62 |
+
# Verify routes were set up
|
| 63 |
+
mock_authenticator.setup_routes.assert_called_once_with(oauth_server.app)
|
| 64 |
+
|
| 65 |
+
# Verify server task was created
|
| 66 |
+
mock_create_task.assert_called_once()
|
| 67 |
+
assert oauth_server.server_task == mock_task
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@pytest.mark.asyncio
|
| 71 |
+
async def test_run_server(oauth_server):
|
| 72 |
+
"""Test running the server."""
|
| 73 |
+
with patch("uvicorn.Server") as mock_server_class:
|
| 74 |
+
with patch("uvicorn.Config") as mock_config_class:
|
| 75 |
+
# Setup mocks
|
| 76 |
+
mock_server = AsyncMock()
|
| 77 |
+
mock_server_class.return_value = mock_server
|
| 78 |
+
mock_config = MagicMock()
|
| 79 |
+
mock_config_class.return_value = mock_config
|
| 80 |
+
|
| 81 |
+
# Create app
|
| 82 |
+
oauth_server.app = FastAPI()
|
| 83 |
+
|
| 84 |
+
# Test method
|
| 85 |
+
await oauth_server._run_server()
|
| 86 |
+
|
| 87 |
+
# Verify config was created correctly
|
| 88 |
+
mock_config_class.assert_called_once_with(
|
| 89 |
+
app=oauth_server.app,
|
| 90 |
+
host=oauth_server.host,
|
| 91 |
+
port=oauth_server.port,
|
| 92 |
+
log_level="info",
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Verify server was created and run
|
| 96 |
+
mock_server_class.assert_called_once_with(mock_config)
|
| 97 |
+
mock_server.serve.assert_called_once()
|
| 98 |
+
assert oauth_server.server == mock_server
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@pytest.mark.asyncio
|
| 102 |
+
async def test_run_server_exception(oauth_server):
|
| 103 |
+
"""Test running the server with an exception."""
|
| 104 |
+
with patch("uvicorn.Server") as mock_server_class:
|
| 105 |
+
with patch("uvicorn.Config") as mock_config_class:
|
| 106 |
+
# Setup mocks
|
| 107 |
+
mock_server = AsyncMock()
|
| 108 |
+
mock_server.serve = AsyncMock(side_effect=Exception("Test error"))
|
| 109 |
+
mock_server_class.return_value = mock_server
|
| 110 |
+
mock_config = MagicMock()
|
| 111 |
+
mock_config_class.return_value = mock_config
|
| 112 |
+
|
| 113 |
+
# Create app and token future
|
| 114 |
+
oauth_server.app = FastAPI()
|
| 115 |
+
oauth_server.token_future = asyncio.Future()
|
| 116 |
+
|
| 117 |
+
# Test method
|
| 118 |
+
await oauth_server._run_server()
|
| 119 |
+
|
| 120 |
+
# Verify token future has exception
|
| 121 |
+
assert oauth_server.token_future.done()
|
| 122 |
+
with pytest.raises(Exception, match="Test error"):
|
| 123 |
+
await oauth_server.token_future
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@pytest.mark.asyncio
|
| 127 |
+
async def test_stop_server(oauth_server):
|
| 128 |
+
"""Test stopping the server."""
|
| 129 |
+
# Setup server and task
|
| 130 |
+
oauth_server.server = MagicMock()
|
| 131 |
+
oauth_server.server_task = MagicMock()
|
| 132 |
+
oauth_server.server_task.done = MagicMock(return_value=False)
|
| 133 |
+
|
| 134 |
+
# Make asyncio.wait_for return immediately
|
| 135 |
+
with patch("asyncio.wait_for", new=AsyncMock()) as mock_wait_for:
|
| 136 |
+
# Test method
|
| 137 |
+
await oauth_server._stop_server()
|
| 138 |
+
|
| 139 |
+
# Verify server was stopped
|
| 140 |
+
assert oauth_server.server.should_exit is True
|
| 141 |
+
mock_wait_for.assert_called_once_with(oauth_server.server_task, timeout=5.0)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@pytest.mark.asyncio
|
| 145 |
+
async def test_stop_server_timeout(oauth_server):
|
| 146 |
+
"""Test stopping the server with timeout."""
|
| 147 |
+
# Setup server and task
|
| 148 |
+
oauth_server.server = MagicMock()
|
| 149 |
+
oauth_server.server_task = MagicMock()
|
| 150 |
+
oauth_server.server_task.done = MagicMock(return_value=False)
|
| 151 |
+
|
| 152 |
+
# Make asyncio.wait_for raise TimeoutError
|
| 153 |
+
with patch("asyncio.wait_for", new=AsyncMock(side_effect=TimeoutError())) as mock_wait_for:
|
| 154 |
+
# Test method
|
| 155 |
+
await oauth_server._stop_server()
|
| 156 |
+
|
| 157 |
+
# Verify server was stopped
|
| 158 |
+
assert oauth_server.server.should_exit is True
|
| 159 |
+
mock_wait_for.assert_called_once_with(oauth_server.server_task, timeout=5.0)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@pytest.mark.asyncio
|
| 163 |
+
async def test_get_token(oauth_server):
|
| 164 |
+
"""Test getting a token."""
|
| 165 |
+
# Setup mocks
|
| 166 |
+
oauth_server._initialize_server = AsyncMock()
|
| 167 |
+
oauth_server._stop_server = AsyncMock()
|
| 168 |
+
oauth_server.authenticator = MagicMock()
|
| 169 |
+
oauth_server.authenticator.get_authorization_url = MagicMock(return_value="https://example.com/auth")
|
| 170 |
+
|
| 171 |
+
with patch("webbrowser.open") as mock_open:
|
| 172 |
+
# Prepare token future
|
| 173 |
+
oauth_server.token_future = asyncio.Future()
|
| 174 |
+
oauth_server.token_future.set_result("test_refresh_token")
|
| 175 |
+
|
| 176 |
+
# Test method
|
| 177 |
+
token = await oauth_server.get_token()
|
| 178 |
+
|
| 179 |
+
# Verify
|
| 180 |
+
assert token == "test_refresh_token"
|
| 181 |
+
oauth_server._initialize_server.assert_called_once()
|
| 182 |
+
oauth_server.authenticator.get_authorization_url.assert_called_once()
|
| 183 |
+
mock_open.assert_called_once_with("https://example.com/auth")
|
| 184 |
+
oauth_server._stop_server.assert_called_once()
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
@pytest.mark.asyncio
|
| 188 |
+
async def test_get_token_no_authenticator(oauth_server):
|
| 189 |
+
"""Test getting a token with no authenticator."""
|
| 190 |
+
# Setup mocks
|
| 191 |
+
oauth_server._initialize_server = AsyncMock()
|
| 192 |
+
oauth_server._stop_server = AsyncMock()
|
| 193 |
+
oauth_server.authenticator = None
|
| 194 |
+
|
| 195 |
+
# Test method
|
| 196 |
+
with pytest.raises(Exception, match="Authenticator not initialized"):
|
| 197 |
+
await oauth_server.get_token()
|
| 198 |
+
|
| 199 |
+
# Verify
|
| 200 |
+
oauth_server._initialize_server.assert_called_once()
|
| 201 |
+
# The stop server is not called because we exit with exception before getting there
|
| 202 |
+
# oauth_server._stop_server.assert_called_once()
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
@pytest.mark.asyncio
|
| 206 |
+
async def test_get_token_cancelled(oauth_server):
|
| 207 |
+
"""Test getting a token that is cancelled."""
|
| 208 |
+
# Setup mocks
|
| 209 |
+
oauth_server._initialize_server = AsyncMock()
|
| 210 |
+
oauth_server._stop_server = AsyncMock()
|
| 211 |
+
oauth_server.authenticator = MagicMock()
|
| 212 |
+
oauth_server.authenticator.get_authorization_url = MagicMock(return_value="https://example.com/auth")
|
| 213 |
+
|
| 214 |
+
with patch("webbrowser.open") as mock_open:
|
| 215 |
+
# Prepare token future with cancellation
|
| 216 |
+
oauth_server.token_future = asyncio.Future()
|
| 217 |
+
oauth_server.token_future.cancel()
|
| 218 |
+
|
| 219 |
+
# Test method
|
| 220 |
+
with pytest.raises(Exception, match="OAuth flow was cancelled"):
|
| 221 |
+
await oauth_server.get_token()
|
| 222 |
+
|
| 223 |
+
# Verify
|
| 224 |
+
oauth_server._initialize_server.assert_called_once()
|
| 225 |
+
oauth_server.authenticator.get_authorization_url.assert_called_once()
|
| 226 |
+
mock_open.assert_called_once_with("https://example.com/auth")
|
| 227 |
+
oauth_server._stop_server.assert_called_once()
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@pytest.mark.asyncio
|
| 231 |
+
async def test_get_token_exception(oauth_server):
|
| 232 |
+
"""Test getting a token with exception."""
|
| 233 |
+
# Setup mocks
|
| 234 |
+
oauth_server._initialize_server = AsyncMock()
|
| 235 |
+
oauth_server._stop_server = AsyncMock()
|
| 236 |
+
oauth_server.authenticator = MagicMock()
|
| 237 |
+
oauth_server.authenticator.get_authorization_url = MagicMock(return_value="https://example.com/auth")
|
| 238 |
+
|
| 239 |
+
with patch("webbrowser.open") as mock_open:
|
| 240 |
+
# Prepare token future with exception
|
| 241 |
+
oauth_server.token_future = asyncio.Future()
|
| 242 |
+
oauth_server.token_future.set_exception(Exception("Test error"))
|
| 243 |
+
|
| 244 |
+
# Test method
|
| 245 |
+
with pytest.raises(Exception, match="OAuth flow failed: Test error"):
|
| 246 |
+
await oauth_server.get_token()
|
| 247 |
+
|
| 248 |
+
# Verify
|
| 249 |
+
oauth_server._initialize_server.assert_called_once()
|
| 250 |
+
oauth_server.authenticator.get_authorization_url.assert_called_once()
|
| 251 |
+
mock_open.assert_called_once_with("https://example.com/auth")
|
| 252 |
+
oauth_server._stop_server.assert_called_once()
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@pytest.mark.asyncio
|
| 256 |
+
async def test_get_refresh_token_from_oauth(client_credentials):
|
| 257 |
+
"""Test get_refresh_token_from_oauth function."""
|
| 258 |
+
with patch("strava_mcp.oauth_server.StravaOAuthServer") as mock_oauth_server_class:
|
| 259 |
+
# Setup mock
|
| 260 |
+
mock_server = MagicMock()
|
| 261 |
+
mock_server.get_token = AsyncMock(return_value="test_refresh_token")
|
| 262 |
+
mock_oauth_server_class.return_value = mock_server
|
| 263 |
+
|
| 264 |
+
# Test function
|
| 265 |
+
token = await get_refresh_token_from_oauth(client_credentials["client_id"], client_credentials["client_secret"])
|
| 266 |
+
|
| 267 |
+
# Verify
|
| 268 |
+
assert token == "test_refresh_token"
|
| 269 |
+
mock_oauth_server_class.assert_called_once_with(
|
| 270 |
+
client_credentials["client_id"], client_credentials["client_secret"]
|
| 271 |
+
)
|
| 272 |
+
mock_server.get_token.assert_called_once()
|
tests/test_server.py
CHANGED
|
@@ -1,8 +1,15 @@
|
|
| 1 |
-
from
|
|
|
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
-
from strava_mcp.models import Activity, DetailedActivity, SegmentEffort
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
class MockContext:
|
|
@@ -39,8 +46,8 @@ async def test_get_user_activities(mock_ctx, mock_service):
|
|
| 39 |
total_elevation_gain=50,
|
| 40 |
type="Run",
|
| 41 |
sport_type="Run",
|
| 42 |
-
start_date="2023-01-01T10:00:
|
| 43 |
-
start_date_local="2023-01-01T10:00:
|
| 44 |
timezone="Europe/London",
|
| 45 |
achievement_count=2,
|
| 46 |
kudos_count=5,
|
|
@@ -57,6 +64,11 @@ async def test_get_user_activities(mock_ctx, mock_service):
|
|
| 57 |
has_heartrate=True,
|
| 58 |
average_heartrate=140,
|
| 59 |
max_heartrate=160,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
)
|
| 61 |
mock_service.get_activities.return_value = [mock_activity]
|
| 62 |
|
|
@@ -86,8 +98,8 @@ async def test_get_activity(mock_ctx, mock_service):
|
|
| 86 |
total_elevation_gain=50,
|
| 87 |
type="Run",
|
| 88 |
sport_type="Run",
|
| 89 |
-
start_date="2023-01-01T10:00:
|
| 90 |
-
start_date_local="2023-01-01T10:00:
|
| 91 |
timezone="Europe/London",
|
| 92 |
achievement_count=2,
|
| 93 |
kudos_count=5,
|
|
@@ -106,6 +118,19 @@ async def test_get_activity(mock_ctx, mock_service):
|
|
| 106 |
max_heartrate=160,
|
| 107 |
athlete={"id": 123},
|
| 108 |
description="Test description",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
)
|
| 110 |
mock_service.get_activity.return_value = mock_activity
|
| 111 |
|
|
@@ -133,26 +158,36 @@ async def test_get_activity_segments(mock_ctx, mock_service):
|
|
| 133 |
name="Test Segment",
|
| 134 |
elapsed_time=180,
|
| 135 |
moving_time=180,
|
| 136 |
-
start_date="2023-01-01T10:05:
|
| 137 |
-
start_date_local="2023-01-01T10:05:
|
| 138 |
distance=1000,
|
| 139 |
athlete={"id": 123},
|
| 140 |
-
segment=
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
)
|
| 157 |
mock_service.get_activity_segments.return_value = [mock_segment]
|
| 158 |
|
|
@@ -168,5 +203,3 @@ async def test_get_activity_segments(mock_ctx, mock_service):
|
|
| 168 |
assert len(result) == 1
|
| 169 |
assert result[0]["id"] == mock_segment.id
|
| 170 |
assert result[0]["name"] == mock_segment.name
|
| 171 |
-
|
| 172 |
-
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 3 |
|
| 4 |
import pytest
|
| 5 |
|
| 6 |
+
from strava_mcp.models import Activity, DetailedActivity, Segment, SegmentEffort
|
| 7 |
+
|
| 8 |
+
# Patch the StravaOAuthServer._run_server method to prevent coroutine warnings
|
| 9 |
+
# This must be at the module level before any imports that might create the coroutine
|
| 10 |
+
with patch("strava_mcp.oauth_server.StravaOAuthServer._run_server", new_callable=AsyncMock):
|
| 11 |
+
# Now imports will use the patched version
|
| 12 |
+
pass
|
| 13 |
|
| 14 |
|
| 15 |
class MockContext:
|
|
|
|
| 46 |
total_elevation_gain=50,
|
| 47 |
type="Run",
|
| 48 |
sport_type="Run",
|
| 49 |
+
start_date=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 50 |
+
start_date_local=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 51 |
timezone="Europe/London",
|
| 52 |
achievement_count=2,
|
| 53 |
kudos_count=5,
|
|
|
|
| 64 |
has_heartrate=True,
|
| 65 |
average_heartrate=140,
|
| 66 |
max_heartrate=160,
|
| 67 |
+
# Add required fields with default values
|
| 68 |
+
map=None,
|
| 69 |
+
workout_type=None,
|
| 70 |
+
elev_high=None,
|
| 71 |
+
elev_low=None,
|
| 72 |
)
|
| 73 |
mock_service.get_activities.return_value = [mock_activity]
|
| 74 |
|
|
|
|
| 98 |
total_elevation_gain=50,
|
| 99 |
type="Run",
|
| 100 |
sport_type="Run",
|
| 101 |
+
start_date=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 102 |
+
start_date_local=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 103 |
timezone="Europe/London",
|
| 104 |
achievement_count=2,
|
| 105 |
kudos_count=5,
|
|
|
|
| 118 |
max_heartrate=160,
|
| 119 |
athlete={"id": 123},
|
| 120 |
description="Test description",
|
| 121 |
+
# Add required fields with default values
|
| 122 |
+
map=None,
|
| 123 |
+
workout_type=None,
|
| 124 |
+
elev_high=None,
|
| 125 |
+
elev_low=None,
|
| 126 |
+
calories=None,
|
| 127 |
+
segment_efforts=None,
|
| 128 |
+
splits_metric=None,
|
| 129 |
+
splits_standard=None,
|
| 130 |
+
best_efforts=None,
|
| 131 |
+
photos=None,
|
| 132 |
+
gear=None,
|
| 133 |
+
device_name=None,
|
| 134 |
)
|
| 135 |
mock_service.get_activity.return_value = mock_activity
|
| 136 |
|
|
|
|
| 158 |
name="Test Segment",
|
| 159 |
elapsed_time=180,
|
| 160 |
moving_time=180,
|
| 161 |
+
start_date=datetime.fromisoformat("2023-01-01T10:05:00+00:00"),
|
| 162 |
+
start_date_local=datetime.fromisoformat("2023-01-01T10:05:00+00:00"),
|
| 163 |
distance=1000,
|
| 164 |
athlete={"id": 123},
|
| 165 |
+
segment=Segment(
|
| 166 |
+
id=12345,
|
| 167 |
+
name="Test Segment",
|
| 168 |
+
activity_type="Run",
|
| 169 |
+
distance=1000,
|
| 170 |
+
average_grade=5.0,
|
| 171 |
+
maximum_grade=10.0,
|
| 172 |
+
elevation_high=200,
|
| 173 |
+
elevation_low=150,
|
| 174 |
+
total_elevation_gain=50,
|
| 175 |
+
start_latlng=[51.5, -0.1],
|
| 176 |
+
end_latlng=[51.5, -0.2],
|
| 177 |
+
climb_category=0,
|
| 178 |
+
private=False,
|
| 179 |
+
starred=False,
|
| 180 |
+
city=None,
|
| 181 |
+
state=None,
|
| 182 |
+
country=None,
|
| 183 |
+
),
|
| 184 |
+
# Add required fields with default values
|
| 185 |
+
average_watts=None,
|
| 186 |
+
device_watts=None,
|
| 187 |
+
average_heartrate=None,
|
| 188 |
+
max_heartrate=None,
|
| 189 |
+
pr_rank=None,
|
| 190 |
+
achievements=None,
|
| 191 |
)
|
| 192 |
mock_service.get_activity_segments.return_value = [mock_segment]
|
| 193 |
|
|
|
|
| 203 |
assert len(result) == 1
|
| 204 |
assert result[0]["id"] == mock_segment.id
|
| 205 |
assert result[0]["name"] == mock_segment.name
|
|
|
|
|
|
tests/test_service.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
|
|
| 1 |
from unittest.mock import AsyncMock, patch
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
from strava_mcp.config import StravaSettings
|
| 6 |
-
from strava_mcp.models import Activity, DetailedActivity, SegmentEffort
|
| 7 |
from strava_mcp.service import StravaService
|
| 8 |
|
| 9 |
|
|
@@ -13,6 +14,7 @@ def settings():
|
|
| 13 |
client_id="test_client_id",
|
| 14 |
client_secret="test_client_secret",
|
| 15 |
refresh_token="test_refresh_token",
|
|
|
|
| 16 |
)
|
| 17 |
|
| 18 |
|
|
@@ -44,8 +46,8 @@ async def test_get_activities(service, mock_api):
|
|
| 44 |
total_elevation_gain=50,
|
| 45 |
type="Run",
|
| 46 |
sport_type="Run",
|
| 47 |
-
start_date="2023-01-01T10:00:
|
| 48 |
-
start_date_local="2023-01-01T10:00:
|
| 49 |
timezone="Europe/London",
|
| 50 |
achievement_count=2,
|
| 51 |
kudos_count=5,
|
|
@@ -62,6 +64,11 @@ async def test_get_activities(service, mock_api):
|
|
| 62 |
has_heartrate=True,
|
| 63 |
average_heartrate=140,
|
| 64 |
max_heartrate=160,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
)
|
| 66 |
mock_api.get_activities.return_value = [mock_activity]
|
| 67 |
|
|
@@ -88,8 +95,8 @@ async def test_get_activity(service, mock_api):
|
|
| 88 |
total_elevation_gain=50,
|
| 89 |
type="Run",
|
| 90 |
sport_type="Run",
|
| 91 |
-
start_date="2023-01-01T10:00:
|
| 92 |
-
start_date_local="2023-01-01T10:00:
|
| 93 |
timezone="Europe/London",
|
| 94 |
achievement_count=2,
|
| 95 |
kudos_count=5,
|
|
@@ -108,6 +115,19 @@ async def test_get_activity(service, mock_api):
|
|
| 108 |
max_heartrate=160,
|
| 109 |
athlete={"id": 123},
|
| 110 |
description="Test description",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
)
|
| 112 |
mock_api.get_activity.return_value = mock_activity
|
| 113 |
|
|
@@ -131,26 +151,36 @@ async def test_get_activity_segments(service, mock_api):
|
|
| 131 |
name="Test Segment",
|
| 132 |
elapsed_time=180,
|
| 133 |
moving_time=180,
|
| 134 |
-
start_date="2023-01-01T10:05:
|
| 135 |
-
start_date_local="2023-01-01T10:05:
|
| 136 |
distance=1000,
|
| 137 |
athlete={"id": 123},
|
| 138 |
-
segment=
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
)
|
| 155 |
mock_api.get_activity_segments.return_value = [mock_segment]
|
| 156 |
|
|
@@ -163,5 +193,3 @@ async def test_get_activity_segments(service, mock_api):
|
|
| 163 |
# Verify response
|
| 164 |
assert len(segments) == 1
|
| 165 |
assert segments[0] == mock_segment
|
| 166 |
-
|
| 167 |
-
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
from unittest.mock import AsyncMock, patch
|
| 3 |
|
| 4 |
import pytest
|
| 5 |
|
| 6 |
from strava_mcp.config import StravaSettings
|
| 7 |
+
from strava_mcp.models import Activity, DetailedActivity, Segment, SegmentEffort
|
| 8 |
from strava_mcp.service import StravaService
|
| 9 |
|
| 10 |
|
|
|
|
| 14 |
client_id="test_client_id",
|
| 15 |
client_secret="test_client_secret",
|
| 16 |
refresh_token="test_refresh_token",
|
| 17 |
+
base_url="https://www.strava.com/api/v3",
|
| 18 |
)
|
| 19 |
|
| 20 |
|
|
|
|
| 46 |
total_elevation_gain=50,
|
| 47 |
type="Run",
|
| 48 |
sport_type="Run",
|
| 49 |
+
start_date=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 50 |
+
start_date_local=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 51 |
timezone="Europe/London",
|
| 52 |
achievement_count=2,
|
| 53 |
kudos_count=5,
|
|
|
|
| 64 |
has_heartrate=True,
|
| 65 |
average_heartrate=140,
|
| 66 |
max_heartrate=160,
|
| 67 |
+
# Add required fields with default values
|
| 68 |
+
map=None,
|
| 69 |
+
workout_type=None,
|
| 70 |
+
elev_high=None,
|
| 71 |
+
elev_low=None,
|
| 72 |
)
|
| 73 |
mock_api.get_activities.return_value = [mock_activity]
|
| 74 |
|
|
|
|
| 95 |
total_elevation_gain=50,
|
| 96 |
type="Run",
|
| 97 |
sport_type="Run",
|
| 98 |
+
start_date=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 99 |
+
start_date_local=datetime.fromisoformat("2023-01-01T10:00:00+00:00"),
|
| 100 |
timezone="Europe/London",
|
| 101 |
achievement_count=2,
|
| 102 |
kudos_count=5,
|
|
|
|
| 115 |
max_heartrate=160,
|
| 116 |
athlete={"id": 123},
|
| 117 |
description="Test description",
|
| 118 |
+
# Add required fields with default values
|
| 119 |
+
map=None,
|
| 120 |
+
workout_type=None,
|
| 121 |
+
elev_high=None,
|
| 122 |
+
elev_low=None,
|
| 123 |
+
calories=None,
|
| 124 |
+
segment_efforts=None,
|
| 125 |
+
splits_metric=None,
|
| 126 |
+
splits_standard=None,
|
| 127 |
+
best_efforts=None,
|
| 128 |
+
photos=None,
|
| 129 |
+
gear=None,
|
| 130 |
+
device_name=None,
|
| 131 |
)
|
| 132 |
mock_api.get_activity.return_value = mock_activity
|
| 133 |
|
|
|
|
| 151 |
name="Test Segment",
|
| 152 |
elapsed_time=180,
|
| 153 |
moving_time=180,
|
| 154 |
+
start_date=datetime.fromisoformat("2023-01-01T10:05:00+00:00"),
|
| 155 |
+
start_date_local=datetime.fromisoformat("2023-01-01T10:05:00+00:00"),
|
| 156 |
distance=1000,
|
| 157 |
athlete={"id": 123},
|
| 158 |
+
segment=Segment(
|
| 159 |
+
id=12345,
|
| 160 |
+
name="Test Segment",
|
| 161 |
+
activity_type="Run",
|
| 162 |
+
distance=1000,
|
| 163 |
+
average_grade=5.0,
|
| 164 |
+
maximum_grade=10.0,
|
| 165 |
+
elevation_high=200,
|
| 166 |
+
elevation_low=150,
|
| 167 |
+
total_elevation_gain=50,
|
| 168 |
+
start_latlng=[51.5, -0.1],
|
| 169 |
+
end_latlng=[51.5, -0.2],
|
| 170 |
+
climb_category=0,
|
| 171 |
+
private=False,
|
| 172 |
+
starred=False,
|
| 173 |
+
city=None,
|
| 174 |
+
state=None,
|
| 175 |
+
country=None,
|
| 176 |
+
),
|
| 177 |
+
# Add required fields with default values
|
| 178 |
+
average_watts=None,
|
| 179 |
+
device_watts=None,
|
| 180 |
+
average_heartrate=None,
|
| 181 |
+
max_heartrate=None,
|
| 182 |
+
pr_rank=None,
|
| 183 |
+
achievements=None,
|
| 184 |
)
|
| 185 |
mock_api.get_activity_segments.return_value = [mock_segment]
|
| 186 |
|
|
|
|
| 193 |
# Verify response
|
| 194 |
assert len(segments) == 1
|
| 195 |
assert segments[0] == mock_segment
|
|
|
|
|
|
uv.lock
CHANGED
|
@@ -118,6 +118,35 @@ wheels = [
|
|
| 118 |
{ url = "https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3", size = 7180 },
|
| 119 |
]
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
[[package]]
|
| 122 |
name = "debugpy"
|
| 123 |
version = "1.8.13"
|
|
@@ -622,6 +651,19 @@ wheels = [
|
|
| 622 |
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
|
| 623 |
]
|
| 624 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
[[package]]
|
| 626 |
name = "pytest"
|
| 627 |
version = "8.3.5"
|
|
@@ -649,6 +691,19 @@ wheels = [
|
|
| 649 |
{ url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467 },
|
| 650 |
]
|
| 651 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
[[package]]
|
| 653 |
name = "pytest-mock"
|
| 654 |
version = "3.14.0"
|
|
@@ -846,7 +901,7 @@ wheels = [
|
|
| 846 |
[[package]]
|
| 847 |
name = "strava"
|
| 848 |
version = "0.1.0"
|
| 849 |
-
source = {
|
| 850 |
dependencies = [
|
| 851 |
{ name = "fastapi" },
|
| 852 |
{ name = "httpx" },
|
|
@@ -860,8 +915,10 @@ dev = [
|
|
| 860 |
{ name = "devtools" },
|
| 861 |
{ name = "ipykernel" },
|
| 862 |
{ name = "pre-commit" },
|
|
|
|
| 863 |
{ name = "pytest" },
|
| 864 |
{ name = "pytest-asyncio" },
|
|
|
|
| 865 |
{ name = "pytest-mock" },
|
| 866 |
{ name = "ruff" },
|
| 867 |
]
|
|
@@ -880,8 +937,10 @@ dev = [
|
|
| 880 |
{ name = "devtools", specifier = ">=0.12.2" },
|
| 881 |
{ name = "ipykernel", specifier = ">=6.29.5" },
|
| 882 |
{ name = "pre-commit", specifier = ">=3.7.1" },
|
|
|
|
| 883 |
{ name = "pytest", specifier = ">=8.3.5" },
|
| 884 |
{ name = "pytest-asyncio", specifier = ">=0.23.5" },
|
|
|
|
| 885 |
{ name = "pytest-mock", specifier = ">=3.14.0" },
|
| 886 |
{ name = "ruff", specifier = ">=0.5.1" },
|
| 887 |
]
|
|
|
|
| 118 |
{ url = "https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3", size = 7180 },
|
| 119 |
]
|
| 120 |
|
| 121 |
+
[[package]]
|
| 122 |
+
name = "coverage"
|
| 123 |
+
version = "7.7.1"
|
| 124 |
+
source = { registry = "https://pypi.org/simple" }
|
| 125 |
+
sdist = { url = "https://files.pythonhosted.org/packages/6b/bf/3effb7453498de9c14a81ca21e1f92e6723ce7ebdc5402ae30e4dcc490ac/coverage-7.7.1.tar.gz", hash = "sha256:199a1272e642266b90c9f40dec7fd3d307b51bf639fa0d15980dc0b3246c1393", size = 810332 }
|
| 126 |
+
wheels = [
|
| 127 |
+
{ url = "https://files.pythonhosted.org/packages/ec/2a/1a254eaadb01c163b29d6ce742aa380fc5cfe74a82138ce6eb944c42effa/coverage-7.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:eebd927b86761a7068a06d3699fd6c20129becf15bb44282db085921ea0f1585", size = 211277 },
|
| 128 |
+
{ url = "https://files.pythonhosted.org/packages/cf/00/9636028365efd4eb6db71cdd01d99e59f25cf0d47a59943dbee32dd1573b/coverage-7.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a79c4a09765d18311c35975ad2eb1ac613c0401afdd9cb1ca4110aeb5dd3c4c", size = 211551 },
|
| 129 |
+
{ url = "https://files.pythonhosted.org/packages/6f/c8/14aed97f80363f055b6cd91e62986492d9fe3b55e06b4b5c82627ae18744/coverage-7.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b1c65a739447c5ddce5b96c0a388fd82e4bbdff7251396a70182b1d83631019", size = 245068 },
|
| 130 |
+
{ url = "https://files.pythonhosted.org/packages/d6/76/9c5fe3f900e01d7995b0cda08fc8bf9773b4b1be58bdd626f319c7d4ec11/coverage-7.7.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:392cc8fd2b1b010ca36840735e2a526fcbd76795a5d44006065e79868cc76ccf", size = 242109 },
|
| 131 |
+
{ url = "https://files.pythonhosted.org/packages/c0/81/760993bb536fb674d3a059f718145dcd409ed6d00ae4e3cbf380019fdfd0/coverage-7.7.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9bb47cc9f07a59a451361a850cb06d20633e77a9118d05fd0f77b1864439461b", size = 244129 },
|
| 132 |
+
{ url = "https://files.pythonhosted.org/packages/00/be/1114a19f93eae0b6cd955dabb5bee80397bd420d846e63cd0ebffc134e3d/coverage-7.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b4c144c129343416a49378e05c9451c34aae5ccf00221e4fa4f487db0816ee2f", size = 244201 },
|
| 133 |
+
{ url = "https://files.pythonhosted.org/packages/06/8d/9128fd283c660474c7dc2b1ea5c66761bc776b970c1724989ed70e9d6eee/coverage-7.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bc96441c9d9ca12a790b5ae17d2fa6654da4b3962ea15e0eabb1b1caed094777", size = 242282 },
|
| 134 |
+
{ url = "https://files.pythonhosted.org/packages/d4/2a/6d7dbfe9c1f82e2cdc28d48f4a0c93190cf58f057fa91ba2391b92437fe6/coverage-7.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d03287eb03186256999539d98818c425c33546ab4901028c8fa933b62c35c3a", size = 243570 },
|
| 135 |
+
{ url = "https://files.pythonhosted.org/packages/cf/3e/29f1e4ce3bb951bcf74b2037a82d94c5064b3334304a3809a95805628838/coverage-7.7.1-cp313-cp313-win32.whl", hash = "sha256:8fed429c26b99641dc1f3a79179860122b22745dd9af36f29b141e178925070a", size = 213772 },
|
| 136 |
+
{ url = "https://files.pythonhosted.org/packages/bc/3a/cf029bf34aefd22ad34f0e808eba8d5830f297a1acb483a2124f097ff769/coverage-7.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:092b134129a8bb940c08b2d9ceb4459af5fb3faea77888af63182e17d89e1cf1", size = 214575 },
|
| 137 |
+
{ url = "https://files.pythonhosted.org/packages/92/4c/fb8b35f186a2519126209dce91ab8644c9a901cf04f8dfa65576ca2dd9e8/coverage-7.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3154b369141c3169b8133973ac00f63fcf8d6dbcc297d788d36afbb7811e511", size = 212113 },
|
| 138 |
+
{ url = "https://files.pythonhosted.org/packages/59/90/e834ffc86fd811c5b570a64ee1895b20404a247ec18a896b9ba543b12097/coverage-7.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:264ff2bcce27a7f455b64ac0dfe097680b65d9a1a293ef902675fa8158d20b24", size = 212333 },
|
| 139 |
+
{ url = "https://files.pythonhosted.org/packages/a5/a1/27f0ad39569b3b02410b881c42e58ab403df13fcd465b475db514b83d3d3/coverage-7.7.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba8480ebe401c2f094d10a8c4209b800a9b77215b6c796d16b6ecdf665048950", size = 256566 },
|
| 140 |
+
{ url = "https://files.pythonhosted.org/packages/9f/3b/21fa66a1db1b90a0633e771a32754f7c02d60236a251afb1b86d7e15d83a/coverage-7.7.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:520af84febb6bb54453e7fbb730afa58c7178fd018c398a8fcd8e269a79bf96d", size = 252276 },
|
| 141 |
+
{ url = "https://files.pythonhosted.org/packages/d6/e5/4ab83a59b0f8ac4f0029018559fc4c7d042e1b4552a722e2bfb04f652296/coverage-7.7.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88d96127ae01ff571d465d4b0be25c123789cef88ba0879194d673fdea52f54e", size = 254616 },
|
| 142 |
+
{ url = "https://files.pythonhosted.org/packages/db/7a/4224417c0ccdb16a5ba4d8d1fcfaa18439be1624c29435bb9bc88ccabdfb/coverage-7.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0ce92c5a9d7007d838456f4b77ea159cb628187a137e1895331e530973dcf862", size = 255707 },
|
| 143 |
+
{ url = "https://files.pythonhosted.org/packages/51/20/ff18a329ccaa3d035e2134ecf3a2e92a52d3be6704c76e74ca5589ece260/coverage-7.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0dab4ef76d7b14f432057fdb7a0477e8bffca0ad39ace308be6e74864e632271", size = 253876 },
|
| 144 |
+
{ url = "https://files.pythonhosted.org/packages/e4/e8/1d6f1a6651672c64f45ffad05306dad9c4c189bec694270822508049b2cb/coverage-7.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7e688010581dbac9cab72800e9076e16f7cccd0d89af5785b70daa11174e94de", size = 254687 },
|
| 145 |
+
{ url = "https://files.pythonhosted.org/packages/6b/ea/1b9a14cf3e2bc3fd9de23a336a8082091711c5f480b500782d59e84a8fe5/coverage-7.7.1-cp313-cp313t-win32.whl", hash = "sha256:e52eb31ae3afacdacfe50705a15b75ded67935770c460d88c215a9c0c40d0e9c", size = 214486 },
|
| 146 |
+
{ url = "https://files.pythonhosted.org/packages/cc/bb/faa6bcf769cb7b3b660532a30d77c440289b40636c7f80e498b961295d07/coverage-7.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a6b6b3bd121ee2ec4bd35039319f3423d0be282b9752a5ae9f18724bc93ebe7c", size = 215647 },
|
| 147 |
+
{ url = "https://files.pythonhosted.org/packages/52/26/9f53293ff4cc1d47d98367ce045ca2e62746d6be74a5c6851a474eabf59b/coverage-7.7.1-py3-none-any.whl", hash = "sha256:822fa99dd1ac686061e1219b67868e25d9757989cf2259f735a4802497d6da31", size = 203006 },
|
| 148 |
+
]
|
| 149 |
+
|
| 150 |
[[package]]
|
| 151 |
name = "debugpy"
|
| 152 |
version = "1.8.13"
|
|
|
|
| 651 |
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
|
| 652 |
]
|
| 653 |
|
| 654 |
+
[[package]]
|
| 655 |
+
name = "pyright"
|
| 656 |
+
version = "1.1.397"
|
| 657 |
+
source = { registry = "https://pypi.org/simple" }
|
| 658 |
+
dependencies = [
|
| 659 |
+
{ name = "nodeenv" },
|
| 660 |
+
{ name = "typing-extensions" },
|
| 661 |
+
]
|
| 662 |
+
sdist = { url = "https://files.pythonhosted.org/packages/92/23/cefa10c9cb198e0858ed0b9233371d62bca880337f628e58f50dfdfb12f0/pyright-1.1.397.tar.gz", hash = "sha256:07530fd65a449e4b0b28dceef14be0d8e0995a7a5b1bb2f3f897c3e548451ce3", size = 3818998 }
|
| 663 |
+
wheels = [
|
| 664 |
+
{ url = "https://files.pythonhosted.org/packages/01/b5/98ec41e1e0ad5576ecd42c90ec363560f7b389a441722ea3c7207682dec7/pyright-1.1.397-py3-none-any.whl", hash = "sha256:2e93fba776e714a82b085d68f8345b01f91ba43e1ab9d513e79b70fc85906257", size = 5693631 },
|
| 665 |
+
]
|
| 666 |
+
|
| 667 |
[[package]]
|
| 668 |
name = "pytest"
|
| 669 |
version = "8.3.5"
|
|
|
|
| 691 |
{ url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467 },
|
| 692 |
]
|
| 693 |
|
| 694 |
+
[[package]]
|
| 695 |
+
name = "pytest-cov"
|
| 696 |
+
version = "6.0.0"
|
| 697 |
+
source = { registry = "https://pypi.org/simple" }
|
| 698 |
+
dependencies = [
|
| 699 |
+
{ name = "coverage" },
|
| 700 |
+
{ name = "pytest" },
|
| 701 |
+
]
|
| 702 |
+
sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 }
|
| 703 |
+
wheels = [
|
| 704 |
+
{ url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 },
|
| 705 |
+
]
|
| 706 |
+
|
| 707 |
[[package]]
|
| 708 |
name = "pytest-mock"
|
| 709 |
version = "3.14.0"
|
|
|
|
| 901 |
[[package]]
|
| 902 |
name = "strava"
|
| 903 |
version = "0.1.0"
|
| 904 |
+
source = { editable = "." }
|
| 905 |
dependencies = [
|
| 906 |
{ name = "fastapi" },
|
| 907 |
{ name = "httpx" },
|
|
|
|
| 915 |
{ name = "devtools" },
|
| 916 |
{ name = "ipykernel" },
|
| 917 |
{ name = "pre-commit" },
|
| 918 |
+
{ name = "pyright" },
|
| 919 |
{ name = "pytest" },
|
| 920 |
{ name = "pytest-asyncio" },
|
| 921 |
+
{ name = "pytest-cov" },
|
| 922 |
{ name = "pytest-mock" },
|
| 923 |
{ name = "ruff" },
|
| 924 |
]
|
|
|
|
| 937 |
{ name = "devtools", specifier = ">=0.12.2" },
|
| 938 |
{ name = "ipykernel", specifier = ">=6.29.5" },
|
| 939 |
{ name = "pre-commit", specifier = ">=3.7.1" },
|
| 940 |
+
{ name = "pyright", specifier = ">=1.1.385" },
|
| 941 |
{ name = "pytest", specifier = ">=8.3.5" },
|
| 942 |
{ name = "pytest-asyncio", specifier = ">=0.23.5" },
|
| 943 |
+
{ name = "pytest-cov", specifier = ">=6.0.0" },
|
| 944 |
{ name = "pytest-mock", specifier = ">=3.14.0" },
|
| 945 |
{ name = "ruff", specifier = ">=0.5.1" },
|
| 946 |
]
|