Spaces:
Sleeping
Sleeping
Yorrick Jansen
commited on
Commit
·
af9f1a9
1
Parent(s):
745568f
First implementation
Browse files- README.md +144 -0
- main.py +5 -1
- pyproject.toml +1 -0
- specs/poc.md +7 -1
- strava_mcp/__init__.py +1 -0
- strava_mcp/api.py +233 -0
- strava_mcp/config.py +12 -0
- strava_mcp/models.py +125 -0
- strava_mcp/server.py +190 -0
- strava_mcp/service.py +140 -0
- tests/__init__.py +1 -0
- tests/test_api.py +222 -0
- tests/test_server.py +211 -0
- tests/test_service.py +204 -0
- uv.lock +14 -0
README.md
CHANGED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Strava MCP Server
|
| 2 |
+
|
| 3 |
+
A Model Context Protocol (MCP) server for interacting with the Strava API.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
This MCP server provides tools to access data from the Strava API:
|
| 8 |
+
|
| 9 |
+
- Get user's activities
|
| 10 |
+
- Get a specific activity's details
|
| 11 |
+
- Get the segments of an activity
|
| 12 |
+
- Get the leaderboard of a segment
|
| 13 |
+
|
| 14 |
+
## Installation
|
| 15 |
+
|
| 16 |
+
### Prerequisites
|
| 17 |
+
|
| 18 |
+
- Python 3.13 or later
|
| 19 |
+
- Strava API credentials (client ID, client secret, and refresh token)
|
| 20 |
+
|
| 21 |
+
### Setup
|
| 22 |
+
|
| 23 |
+
1. Clone the repository:
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
git clone <repository-url>
|
| 27 |
+
cd strava
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
2. Install dependencies with [uv](https://docs.astral.sh/uv/):
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
uv install
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
3. Set up environment variables with your Strava API credentials:
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
export STRAVA_CLIENT_ID=your_client_id
|
| 40 |
+
export STRAVA_CLIENT_SECRET=your_client_secret
|
| 41 |
+
export STRAVA_REFRESH_TOKEN=your_refresh_token
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
Alternatively, you can create a `.env` file in the root directory with these variables.
|
| 45 |
+
|
| 46 |
+
## Usage
|
| 47 |
+
|
| 48 |
+
### Running the Server
|
| 49 |
+
|
| 50 |
+
To run the server:
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
python main.py
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
### Development Mode
|
| 57 |
+
|
| 58 |
+
You can run the server in development mode with the MCP CLI:
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
mcp dev main.py
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
### Installing in Claude Desktop
|
| 65 |
+
|
| 66 |
+
To install the server in Claude Desktop:
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
mcp install main.py
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
## Tools
|
| 73 |
+
|
| 74 |
+
### Get User Activities
|
| 75 |
+
|
| 76 |
+
Retrieves a list of activities for the authenticated user.
|
| 77 |
+
|
| 78 |
+
**Parameters:**
|
| 79 |
+
- `before` (optional): Epoch timestamp to filter activities before a certain time
|
| 80 |
+
- `after` (optional): Epoch timestamp to filter activities after a certain time
|
| 81 |
+
- `page` (optional): Page number (default: 1)
|
| 82 |
+
- `per_page` (optional): Number of items per page (default: 30)
|
| 83 |
+
|
| 84 |
+
### Get Activity
|
| 85 |
+
|
| 86 |
+
Gets detailed information about a specific activity.
|
| 87 |
+
|
| 88 |
+
**Parameters:**
|
| 89 |
+
- `activity_id`: The ID of the activity
|
| 90 |
+
- `include_all_efforts` (optional): Whether to include all segment efforts (default: false)
|
| 91 |
+
|
| 92 |
+
### Get Activity Segments
|
| 93 |
+
|
| 94 |
+
Retrieves the segments from a specific activity.
|
| 95 |
+
|
| 96 |
+
**Parameters:**
|
| 97 |
+
- `activity_id`: The ID of the activity
|
| 98 |
+
|
| 99 |
+
### Get Segment Leaderboard
|
| 100 |
+
|
| 101 |
+
Gets the leaderboard for a specific segment.
|
| 102 |
+
|
| 103 |
+
**Parameters:**
|
| 104 |
+
- `segment_id`: The ID of the segment
|
| 105 |
+
- `gender` (optional): Filter by gender ('M' or 'F')
|
| 106 |
+
- `age_group` (optional): Filter by age group
|
| 107 |
+
- `weight_class` (optional): Filter by weight class
|
| 108 |
+
- `following` (optional): Filter by friends of the authenticated athlete
|
| 109 |
+
- `club_id` (optional): Filter by club
|
| 110 |
+
- `date_range` (optional): Filter by date range
|
| 111 |
+
- `context_entries` (optional): Number of context entries
|
| 112 |
+
- `page` (optional): Page number (default: 1)
|
| 113 |
+
- `per_page` (optional): Number of items per page (default: 30)
|
| 114 |
+
|
| 115 |
+
## Development
|
| 116 |
+
|
| 117 |
+
### Project Structure
|
| 118 |
+
|
| 119 |
+
- `strava_mcp/`: Main package directory
|
| 120 |
+
- `__init__.py`: Package initialization
|
| 121 |
+
- `config.py`: Configuration settings using pydantic-settings
|
| 122 |
+
- `models.py`: Pydantic models for Strava API entities
|
| 123 |
+
- `api.py`: Low-level API client for Strava
|
| 124 |
+
- `service.py`: Service layer for business logic
|
| 125 |
+
- `server.py`: MCP server implementation
|
| 126 |
+
- `tests/`: Unit tests
|
| 127 |
+
- `main.py`: Entry point to run the server
|
| 128 |
+
|
| 129 |
+
### Running Tests
|
| 130 |
+
|
| 131 |
+
To run tests:
|
| 132 |
+
|
| 133 |
+
```bash
|
| 134 |
+
pytest
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
## License
|
| 138 |
+
|
| 139 |
+
[MIT License](LICENSE)
|
| 140 |
+
|
| 141 |
+
## Acknowledgements
|
| 142 |
+
|
| 143 |
+
- [Strava API](https://developers.strava.com/)
|
| 144 |
+
- [Model Context Protocol (MCP)](https://modelcontextprotocol.io/)
|
main.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
def main():
|
| 2 |
-
|
|
|
|
| 3 |
|
| 4 |
|
| 5 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
from strava_mcp.server import mcp
|
| 2 |
+
|
| 3 |
+
|
| 4 |
def main():
|
| 5 |
+
"""Run the Strava MCP server."""
|
| 6 |
+
mcp.run()
|
| 7 |
|
| 8 |
|
| 9 |
if __name__ == "__main__":
|
pyproject.toml
CHANGED
|
@@ -17,4 +17,5 @@ dev = [
|
|
| 17 |
"ipykernel>=6.29.5",
|
| 18 |
"pytest>=8.3.5",
|
| 19 |
"pytest-mock>=3.14.0",
|
|
|
|
| 20 |
]
|
|
|
|
| 17 |
"ipykernel>=6.29.5",
|
| 18 |
"pytest>=8.3.5",
|
| 19 |
"pytest-mock>=3.14.0",
|
| 20 |
+
"pytest-asyncio>=0.23.5",
|
| 21 |
]
|
specs/poc.md
CHANGED
|
@@ -26,6 +26,8 @@ No resources are to be exposed for now.
|
|
| 26 |
|
| 27 |
## Details
|
| 28 |
|
|
|
|
|
|
|
| 29 |
1. Read the documentation of the Strava API and MCP in ai-docs directory.
|
| 30 |
2. Do not add any dependencies to the project, except for the ones that are already installed (see pyproject.toml).
|
| 31 |
3. Add relevant comments to the code to explain what it does.
|
|
@@ -36,7 +38,11 @@ No resources are to be exposed for now.
|
|
| 36 |
8. Use pydantic for Model Driven Development.
|
| 37 |
9. Use pydantic-settings for configuration.
|
| 38 |
10. Separate logic, api, and data access layers.
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
|
|
|
|
| 26 |
|
| 27 |
## Details
|
| 28 |
|
| 29 |
+
Be VERY CAREFUL to follow the instructions:
|
| 30 |
+
|
| 31 |
1. Read the documentation of the Strava API and MCP in ai-docs directory.
|
| 32 |
2. Do not add any dependencies to the project, except for the ones that are already installed (see pyproject.toml).
|
| 33 |
3. Add relevant comments to the code to explain what it does.
|
|
|
|
| 38 |
8. Use pydantic for Model Driven Development.
|
| 39 |
9. Use pydantic-settings for configuration.
|
| 40 |
10. Separate logic, api, and data access layers.
|
| 41 |
+
11. Use type hints and docstrings to describe the code.
|
| 42 |
+
12. Write the tests first, and then implement the code
|
| 43 |
+
13. Setup CI/CD pipeline with Github Actions, and make unit tests pass, then that server starts. Upload code coverage to Codecov.
|
| 44 |
+
14. Use uv to manage the project.
|
| 45 |
+
15. Setup pre-commit hooks with Ruff, isort, and pyright.
|
| 46 |
|
| 47 |
|
| 48 |
|
strava_mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Strava MCP package
|
strava_mcp/api.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import Dict, List, Optional, Tuple, Any
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
from httpx import Response
|
| 7 |
+
|
| 8 |
+
from strava_mcp.config import StravaSettings
|
| 9 |
+
from strava_mcp.models import (
|
| 10 |
+
Activity,
|
| 11 |
+
DetailedActivity,
|
| 12 |
+
Segment,
|
| 13 |
+
SegmentEffort,
|
| 14 |
+
Leaderboard,
|
| 15 |
+
ErrorResponse,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class StravaAPI:
|
| 23 |
+
"""Client for the Strava API."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, settings: StravaSettings):
|
| 26 |
+
"""Initialize the Strava API client.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
settings: Strava API settings
|
| 30 |
+
"""
|
| 31 |
+
self.settings = settings
|
| 32 |
+
self.access_token = None
|
| 33 |
+
self.token_expires_at = None
|
| 34 |
+
self._client = httpx.AsyncClient(
|
| 35 |
+
base_url=settings.base_url,
|
| 36 |
+
timeout=30.0,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
async def close(self):
|
| 40 |
+
"""Close the HTTP client."""
|
| 41 |
+
await self._client.aclose()
|
| 42 |
+
|
| 43 |
+
async def _ensure_token(self) -> str:
|
| 44 |
+
"""Ensure we have a valid access token.
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
The access token
|
| 48 |
+
"""
|
| 49 |
+
now = datetime.now().timestamp()
|
| 50 |
+
|
| 51 |
+
# If token is still valid, return it
|
| 52 |
+
if self.access_token and self.token_expires_at and now < self.token_expires_at:
|
| 53 |
+
return self.access_token
|
| 54 |
+
|
| 55 |
+
# Otherwise, refresh the token
|
| 56 |
+
async with httpx.AsyncClient() as client:
|
| 57 |
+
response = await client.post(
|
| 58 |
+
"https://www.strava.com/oauth/token",
|
| 59 |
+
json={
|
| 60 |
+
"client_id": self.settings.client_id,
|
| 61 |
+
"client_secret": self.settings.client_secret,
|
| 62 |
+
"refresh_token": self.settings.refresh_token,
|
| 63 |
+
"grant_type": "refresh_token",
|
| 64 |
+
},
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
if response.status_code != 200:
|
| 68 |
+
error_msg = f"Failed to refresh token: {response.text}"
|
| 69 |
+
logger.error(error_msg)
|
| 70 |
+
raise Exception(error_msg)
|
| 71 |
+
|
| 72 |
+
data = response.json()
|
| 73 |
+
self.access_token = data["access_token"]
|
| 74 |
+
self.token_expires_at = data["expires_at"]
|
| 75 |
+
|
| 76 |
+
logger.info("Successfully refreshed access token")
|
| 77 |
+
return self.access_token
|
| 78 |
+
|
| 79 |
+
async def _request(self, method: str, endpoint: str, **kwargs) -> Response:
|
| 80 |
+
"""Make a request to the Strava API.
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
method: The HTTP method to use
|
| 84 |
+
endpoint: The API endpoint to call
|
| 85 |
+
**kwargs: Additional arguments to pass to the HTTP client
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
The HTTP response
|
| 89 |
+
|
| 90 |
+
Raises:
|
| 91 |
+
Exception: If the request fails
|
| 92 |
+
"""
|
| 93 |
+
token = await self._ensure_token()
|
| 94 |
+
headers = {"Authorization": f"Bearer {token}"}
|
| 95 |
+
if "headers" in kwargs:
|
| 96 |
+
headers.update(kwargs.pop("headers"))
|
| 97 |
+
|
| 98 |
+
url = endpoint if endpoint.startswith("/") else f"/{endpoint}"
|
| 99 |
+
response = await self._client.request(
|
| 100 |
+
method,
|
| 101 |
+
url,
|
| 102 |
+
headers=headers,
|
| 103 |
+
**kwargs
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
if not response.is_success:
|
| 107 |
+
error_msg = f"Strava API request failed: {response.status_code} - {response.text}"
|
| 108 |
+
logger.error(error_msg)
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
error_data = response.json()
|
| 112 |
+
error = ErrorResponse(**error_data)
|
| 113 |
+
raise Exception(f"Strava API error: {error.message} (code: {error.code})")
|
| 114 |
+
except Exception as e:
|
| 115 |
+
raise Exception(f"Strava API request failed: {response.status_code} - {response.text}")
|
| 116 |
+
|
| 117 |
+
return response
|
| 118 |
+
|
| 119 |
+
async def get_activities(
|
| 120 |
+
self,
|
| 121 |
+
before: Optional[int] = None,
|
| 122 |
+
after: Optional[int] = None,
|
| 123 |
+
page: int = 1,
|
| 124 |
+
per_page: int = 30
|
| 125 |
+
) -> List[Activity]:
|
| 126 |
+
"""Get a list of activities for the authenticated athlete.
|
| 127 |
+
|
| 128 |
+
Args:
|
| 129 |
+
before: An epoch timestamp to use for filtering activities that have taken place before a certain time
|
| 130 |
+
after: An epoch timestamp to use for filtering activities that have taken place after a certain time
|
| 131 |
+
page: Page number
|
| 132 |
+
per_page: Number of items per page
|
| 133 |
+
|
| 134 |
+
Returns:
|
| 135 |
+
List of activities
|
| 136 |
+
"""
|
| 137 |
+
params = {"page": page, "per_page": per_page}
|
| 138 |
+
if before:
|
| 139 |
+
params["before"] = before
|
| 140 |
+
if after:
|
| 141 |
+
params["after"] = after
|
| 142 |
+
|
| 143 |
+
response = await self._request("GET", "/athlete/activities", params=params)
|
| 144 |
+
data = response.json()
|
| 145 |
+
|
| 146 |
+
return [Activity(**activity) for activity in data]
|
| 147 |
+
|
| 148 |
+
async def get_activity(self, activity_id: int, include_all_efforts: bool = False) -> DetailedActivity:
|
| 149 |
+
"""Get a specific activity.
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
activity_id: The ID of the activity
|
| 153 |
+
include_all_efforts: Whether to include all segment efforts
|
| 154 |
+
|
| 155 |
+
Returns:
|
| 156 |
+
The activity details
|
| 157 |
+
"""
|
| 158 |
+
params = {}
|
| 159 |
+
if include_all_efforts:
|
| 160 |
+
params["include_all_efforts"] = "true"
|
| 161 |
+
|
| 162 |
+
response = await self._request("GET", f"/activities/{activity_id}", params=params)
|
| 163 |
+
data = response.json()
|
| 164 |
+
|
| 165 |
+
return DetailedActivity(**data)
|
| 166 |
+
|
| 167 |
+
async def get_activity_segments(self, activity_id: int) -> List[SegmentEffort]:
|
| 168 |
+
"""Get segments from a specific activity.
|
| 169 |
+
|
| 170 |
+
Args:
|
| 171 |
+
activity_id: The ID of the activity
|
| 172 |
+
|
| 173 |
+
Returns:
|
| 174 |
+
List of segment efforts for the activity
|
| 175 |
+
"""
|
| 176 |
+
activity = await self.get_activity(activity_id, include_all_efforts=True)
|
| 177 |
+
|
| 178 |
+
if not activity.segment_efforts:
|
| 179 |
+
return []
|
| 180 |
+
|
| 181 |
+
return [SegmentEffort.parse_obj(effort) for effort in activity.segment_efforts]
|
| 182 |
+
|
| 183 |
+
async def get_segment_leaderboard(
|
| 184 |
+
self,
|
| 185 |
+
segment_id: int,
|
| 186 |
+
gender: Optional[str] = None,
|
| 187 |
+
age_group: Optional[str] = None,
|
| 188 |
+
weight_class: Optional[str] = None,
|
| 189 |
+
following: Optional[bool] = None,
|
| 190 |
+
club_id: Optional[int] = None,
|
| 191 |
+
date_range: Optional[str] = None,
|
| 192 |
+
context_entries: Optional[int] = None,
|
| 193 |
+
page: int = 1,
|
| 194 |
+
per_page: int = 30
|
| 195 |
+
) -> Leaderboard:
|
| 196 |
+
"""Get the leaderboard for a given segment.
|
| 197 |
+
|
| 198 |
+
Args:
|
| 199 |
+
segment_id: The ID of the segment
|
| 200 |
+
gender: Filter by gender ('M' or 'F')
|
| 201 |
+
age_group: Filter by age group
|
| 202 |
+
weight_class: Filter by weight class
|
| 203 |
+
following: Filter by friends of the authenticated athlete
|
| 204 |
+
club_id: Filter by club
|
| 205 |
+
date_range: Filter by date range
|
| 206 |
+
context_entries: Number of context entries
|
| 207 |
+
page: Page number
|
| 208 |
+
per_page: Number of items per page
|
| 209 |
+
|
| 210 |
+
Returns:
|
| 211 |
+
The segment leaderboard
|
| 212 |
+
"""
|
| 213 |
+
params = {"page": page, "per_page": per_page}
|
| 214 |
+
|
| 215 |
+
if gender:
|
| 216 |
+
params["gender"] = gender
|
| 217 |
+
if age_group:
|
| 218 |
+
params["age_group"] = age_group
|
| 219 |
+
if weight_class:
|
| 220 |
+
params["weight_class"] = weight_class
|
| 221 |
+
if following is not None:
|
| 222 |
+
params["following"] = "true" if following else "false"
|
| 223 |
+
if club_id:
|
| 224 |
+
params["club_id"] = club_id
|
| 225 |
+
if date_range:
|
| 226 |
+
params["date_range"] = date_range
|
| 227 |
+
if context_entries:
|
| 228 |
+
params["context_entries"] = context_entries
|
| 229 |
+
|
| 230 |
+
response = await self._request("GET", f"/segments/{segment_id}/leaderboard", params=params)
|
| 231 |
+
data = response.json()
|
| 232 |
+
|
| 233 |
+
return Leaderboard(**data)
|
strava_mcp/config.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 2 |
+
from pydantic import Field
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class StravaSettings(BaseSettings):
|
| 6 |
+
"""Strava API settings."""
|
| 7 |
+
client_id: str = Field(..., description="Strava API client ID")
|
| 8 |
+
client_secret: str = Field(..., description="Strava API client secret")
|
| 9 |
+
refresh_token: str = Field(..., description="Strava API refresh token")
|
| 10 |
+
base_url: str = Field("https://www.strava.com/api/v3", description="Strava API base URL")
|
| 11 |
+
|
| 12 |
+
model_config = SettingsConfigDict(env_prefix="STRAVA_")
|
strava_mcp/models.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class Activity(BaseModel):
|
| 7 |
+
"""Represents a Strava activity."""
|
| 8 |
+
id: int = Field(..., description="The unique identifier of the activity")
|
| 9 |
+
name: str = Field(..., description="The name of the activity")
|
| 10 |
+
distance: float = Field(..., description="The distance in meters")
|
| 11 |
+
moving_time: int = Field(..., description="Moving time in seconds")
|
| 12 |
+
elapsed_time: int = Field(..., description="Elapsed time in seconds")
|
| 13 |
+
total_elevation_gain: float = Field(..., description="Total elevation gain in meters")
|
| 14 |
+
type: str = Field(..., description="Type of activity")
|
| 15 |
+
sport_type: str = Field(..., description="Type of sport")
|
| 16 |
+
start_date: datetime = Field(..., description="Start date and time in UTC")
|
| 17 |
+
start_date_local: datetime = Field(..., description="Start date and time in athlete's timezone")
|
| 18 |
+
timezone: str = Field(..., description="The timezone of the activity")
|
| 19 |
+
achievement_count: int = Field(..., description="The number of achievements")
|
| 20 |
+
kudos_count: int = Field(..., description="The number of kudos")
|
| 21 |
+
comment_count: int = Field(..., description="The number of comments")
|
| 22 |
+
athlete_count: int = Field(..., description="The number of athletes")
|
| 23 |
+
photo_count: int = Field(..., description="The number of photos")
|
| 24 |
+
map: Optional[dict] = Field(None, description="The map of the activity")
|
| 25 |
+
trainer: bool = Field(..., description="Whether this activity was recorded on a training machine")
|
| 26 |
+
commute: bool = Field(..., description="Whether this activity is a commute")
|
| 27 |
+
manual: bool = Field(..., description="Whether this activity was created manually")
|
| 28 |
+
private: bool = Field(..., description="Whether this activity is private")
|
| 29 |
+
flagged: bool = Field(..., description="Whether this activity is flagged")
|
| 30 |
+
workout_type: Optional[int] = Field(None, description="The workout type")
|
| 31 |
+
average_speed: float = Field(..., description="Average speed in meters per second")
|
| 32 |
+
max_speed: float = Field(..., description="Maximum speed in meters per second")
|
| 33 |
+
has_heartrate: bool = Field(..., description="Whether the activity has heartrate data")
|
| 34 |
+
average_heartrate: Optional[float] = Field(None, description="Average heartrate during activity")
|
| 35 |
+
max_heartrate: Optional[float] = Field(None, description="Maximum heartrate during activity")
|
| 36 |
+
elev_high: Optional[float] = Field(None, description="The highest elevation")
|
| 37 |
+
elev_low: Optional[float] = Field(None, description="The lowest elevation")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class DetailedActivity(Activity):
|
| 41 |
+
"""Detailed version of a Strava activity."""
|
| 42 |
+
description: Optional[str] = Field(None, description="The description of the activity")
|
| 43 |
+
athlete: dict = Field(..., description="The athlete who performed the activity")
|
| 44 |
+
calories: Optional[float] = Field(None, description="Calories burned during activity")
|
| 45 |
+
segment_efforts: Optional[List[dict]] = Field(None, description="List of segment efforts")
|
| 46 |
+
splits_metric: Optional[List[dict]] = Field(None, description="Splits in metric units")
|
| 47 |
+
splits_standard: Optional[List[dict]] = Field(None, description="Splits in standard units")
|
| 48 |
+
best_efforts: Optional[List[dict]] = Field(None, description="List of best efforts")
|
| 49 |
+
photos: Optional[dict] = Field(None, description="Photos associated with activity")
|
| 50 |
+
gear: Optional[dict] = Field(None, description="Gear used during activity")
|
| 51 |
+
device_name: Optional[str] = Field(None, description="Name of device used to record activity")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class Segment(BaseModel):
|
| 55 |
+
"""Represents a Strava segment."""
|
| 56 |
+
id: int = Field(..., description="The unique identifier of the segment")
|
| 57 |
+
name: str = Field(..., description="The name of the segment")
|
| 58 |
+
activity_type: str = Field(..., description="The activity type of the segment")
|
| 59 |
+
distance: float = Field(..., description="The segment's distance in meters")
|
| 60 |
+
average_grade: float = Field(..., description="The segment's average grade, in percents")
|
| 61 |
+
maximum_grade: float = Field(..., description="The segments's maximum grade, in percents")
|
| 62 |
+
elevation_high: float = Field(..., description="The segments's highest elevation, in meters")
|
| 63 |
+
elevation_low: float = Field(..., description="The segments's lowest elevation, in meters")
|
| 64 |
+
total_elevation_gain: float = Field(..., description="The segments's total elevation gain, in meters")
|
| 65 |
+
start_latlng: List[float] = Field(..., description="Start coordinates [latitude, longitude]")
|
| 66 |
+
end_latlng: List[float] = Field(..., description="End coordinates [latitude, longitude]")
|
| 67 |
+
climb_category: int = Field(..., description="The category of the climb [0, 5]")
|
| 68 |
+
city: Optional[str] = Field(None, description="The city this segment is in")
|
| 69 |
+
state: Optional[str] = Field(None, description="The state this segment is in")
|
| 70 |
+
country: Optional[str] = Field(None, description="The country this segment is in")
|
| 71 |
+
private: bool = Field(..., description="Whether this segment is private")
|
| 72 |
+
starred: bool = Field(..., description="Whether this segment is starred by the authenticated athlete")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class SegmentEffort(BaseModel):
|
| 76 |
+
"""Represents a Strava segment effort."""
|
| 77 |
+
id: int = Field(..., description="The unique identifier of the segment effort")
|
| 78 |
+
activity_id: int = Field(..., description="The ID of the associated activity")
|
| 79 |
+
segment_id: int = Field(..., description="The ID of the associated segment")
|
| 80 |
+
name: str = Field(..., description="The name of the segment")
|
| 81 |
+
elapsed_time: int = Field(..., description="The elapsed time in seconds")
|
| 82 |
+
moving_time: int = Field(..., description="The moving time in seconds")
|
| 83 |
+
start_date: datetime = Field(..., description="Start date and time in UTC")
|
| 84 |
+
start_date_local: datetime = Field(..., description="Start date and time in athlete's timezone")
|
| 85 |
+
distance: float = Field(..., description="The effort's distance in meters")
|
| 86 |
+
average_watts: Optional[float] = Field(None, description="Average wattage")
|
| 87 |
+
device_watts: Optional[bool] = Field(None, description="Whether power data comes from a power meter")
|
| 88 |
+
average_heartrate: Optional[float] = Field(None, description="Average heartrate")
|
| 89 |
+
max_heartrate: Optional[float] = Field(None, description="Maximum heartrate")
|
| 90 |
+
pr_rank: Optional[int] = Field(None, description="Personal record rank (1-3), 0 if not a PR")
|
| 91 |
+
achievements: Optional[List[dict]] = Field(None, description="List of achievements")
|
| 92 |
+
athlete: dict = Field(..., description="The athlete who performed the effort")
|
| 93 |
+
segment: Segment = Field(..., description="The segment")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class Leaderboard(BaseModel):
|
| 97 |
+
"""Represents a Strava segment leaderboard."""
|
| 98 |
+
entry_count: int = Field(..., description="The total number of entries for this leaderboard")
|
| 99 |
+
effort_count: int = Field(..., description="The total number of efforts for this leaderboard")
|
| 100 |
+
kom_type: Optional[str] = Field(None, description="KOM/QOM type")
|
| 101 |
+
entries: List[dict] = Field(..., description="List of leaderboard entries")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class LeaderboardEntry(BaseModel):
|
| 105 |
+
"""Represents a Strava segment leaderboard entry."""
|
| 106 |
+
athlete_name: str = Field(..., description="The name of the athlete")
|
| 107 |
+
athlete_id: int = Field(..., description="The unique identifier of the athlete")
|
| 108 |
+
athlete_gender: str = Field(..., description="The gender of the athlete")
|
| 109 |
+
average_hr: Optional[float] = Field(None, description="The athlete's average heart rate")
|
| 110 |
+
average_watts: Optional[float] = Field(None, description="The athlete's average watts")
|
| 111 |
+
distance: float = Field(..., description="The distance in meters")
|
| 112 |
+
elapsed_time: int = Field(..., description="The elapsed time in seconds")
|
| 113 |
+
moving_time: int = Field(..., description="The moving time in seconds")
|
| 114 |
+
start_date: datetime = Field(..., description="The timestamp of the effort in UTC")
|
| 115 |
+
start_date_local: datetime = Field(..., description="The timestamp of the effort in local time")
|
| 116 |
+
activity_id: int = Field(..., description="The unique identifier of the activity")
|
| 117 |
+
effort_id: int = Field(..., description="The unique identifier of the segment effort")
|
| 118 |
+
rank: int = Field(..., description="The rank of the effort on the segment leaderboard")
|
| 119 |
+
neighborhood_index: Optional[int] = Field(None, description="Neighborhood index")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class ErrorResponse(BaseModel):
|
| 123 |
+
"""Represents an error response from the Strava API."""
|
| 124 |
+
message: str = Field(..., description="Error message")
|
| 125 |
+
code: int = Field(..., description="Error code")
|
strava_mcp/server.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import json
|
| 3 |
+
from typing import List, Optional, Dict, Any
|
| 4 |
+
from contextlib import asynccontextmanager
|
| 5 |
+
from collections.abc import AsyncIterator
|
| 6 |
+
|
| 7 |
+
from mcp.server.fastmcp import FastMCP, Context
|
| 8 |
+
|
| 9 |
+
from strava_mcp.config import StravaSettings
|
| 10 |
+
from strava_mcp.service import StravaService
|
| 11 |
+
from strava_mcp.models import Activity, DetailedActivity, SegmentEffort, Leaderboard
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# Configure logging
|
| 15 |
+
logging.basicConfig(
|
| 16 |
+
level=logging.INFO,
|
| 17 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 18 |
+
)
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@asynccontextmanager
|
| 23 |
+
async def lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
|
| 24 |
+
"""Set up and tear down the Strava service for the MCP server.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
server: The FastMCP server instance
|
| 28 |
+
|
| 29 |
+
Yields:
|
| 30 |
+
The lifespan context containing the Strava service
|
| 31 |
+
"""
|
| 32 |
+
# Load settings from environment variables
|
| 33 |
+
try:
|
| 34 |
+
settings = StravaSettings()
|
| 35 |
+
logger.info("Loaded Strava API settings")
|
| 36 |
+
except Exception as e:
|
| 37 |
+
logger.error(f"Failed to load Strava API settings: {str(e)}")
|
| 38 |
+
raise
|
| 39 |
+
|
| 40 |
+
# Initialize the Strava service
|
| 41 |
+
service = StravaService(settings)
|
| 42 |
+
logger.info("Initialized Strava service")
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
yield {"service": service}
|
| 46 |
+
finally:
|
| 47 |
+
# Clean up resources
|
| 48 |
+
await service.close()
|
| 49 |
+
logger.info("Closed Strava service")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# Create the MCP server
|
| 53 |
+
mcp = FastMCP(
|
| 54 |
+
"Strava",
|
| 55 |
+
description="MCP server for interacting with the Strava API",
|
| 56 |
+
lifespan=lifespan,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@mcp.tool()
|
| 61 |
+
async def get_user_activities(
|
| 62 |
+
ctx: Context,
|
| 63 |
+
before: Optional[int] = None,
|
| 64 |
+
after: Optional[int] = None,
|
| 65 |
+
page: int = 1,
|
| 66 |
+
per_page: int = 30,
|
| 67 |
+
) -> List[Dict]:
|
| 68 |
+
"""Get the authenticated user's activities.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
ctx: The MCP request context
|
| 72 |
+
before: An epoch timestamp to use for filtering activities that have taken place before a certain time
|
| 73 |
+
after: An epoch timestamp to use for filtering activities that have taken place after a certain time
|
| 74 |
+
page: Page number
|
| 75 |
+
per_page: Number of items per page
|
| 76 |
+
|
| 77 |
+
Returns:
|
| 78 |
+
List of activities
|
| 79 |
+
"""
|
| 80 |
+
service = ctx.request_context.lifespan_context["service"]
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
activities = await service.get_activities(before, after, page, per_page)
|
| 84 |
+
return [activity.model_dump() for activity in activities]
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.error(f"Error in get_user_activities tool: {str(e)}")
|
| 87 |
+
raise
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@mcp.tool()
|
| 91 |
+
async def get_activity(
|
| 92 |
+
ctx: Context,
|
| 93 |
+
activity_id: int,
|
| 94 |
+
include_all_efforts: bool = False,
|
| 95 |
+
) -> Dict:
|
| 96 |
+
"""Get details of a specific activity.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
ctx: The MCP request context
|
| 100 |
+
activity_id: The ID of the activity
|
| 101 |
+
include_all_efforts: Whether to include all segment efforts
|
| 102 |
+
|
| 103 |
+
Returns:
|
| 104 |
+
The activity details
|
| 105 |
+
"""
|
| 106 |
+
service = ctx.request_context.lifespan_context["service"]
|
| 107 |
+
|
| 108 |
+
try:
|
| 109 |
+
activity = await service.get_activity(activity_id, include_all_efforts)
|
| 110 |
+
return activity.model_dump()
|
| 111 |
+
except Exception as e:
|
| 112 |
+
logger.error(f"Error in get_activity tool: {str(e)}")
|
| 113 |
+
raise
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@mcp.tool()
|
| 117 |
+
async def get_activity_segments(
|
| 118 |
+
ctx: Context,
|
| 119 |
+
activity_id: int,
|
| 120 |
+
) -> List[Dict]:
|
| 121 |
+
"""Get the segments of a specific activity.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
ctx: The MCP request context
|
| 125 |
+
activity_id: The ID of the activity
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
List of segment efforts for the activity
|
| 129 |
+
"""
|
| 130 |
+
service = ctx.request_context.lifespan_context["service"]
|
| 131 |
+
|
| 132 |
+
try:
|
| 133 |
+
segments = await service.get_activity_segments(activity_id)
|
| 134 |
+
return [segment.model_dump() for segment in segments]
|
| 135 |
+
except Exception as e:
|
| 136 |
+
logger.error(f"Error in get_activity_segments tool: {str(e)}")
|
| 137 |
+
raise
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@mcp.tool()
|
| 141 |
+
async def get_segment_leaderboard(
|
| 142 |
+
ctx: Context,
|
| 143 |
+
segment_id: int,
|
| 144 |
+
gender: Optional[str] = None,
|
| 145 |
+
age_group: Optional[str] = None,
|
| 146 |
+
weight_class: Optional[str] = None,
|
| 147 |
+
following: Optional[bool] = None,
|
| 148 |
+
club_id: Optional[int] = None,
|
| 149 |
+
date_range: Optional[str] = None,
|
| 150 |
+
context_entries: Optional[int] = None,
|
| 151 |
+
page: int = 1,
|
| 152 |
+
per_page: int = 30,
|
| 153 |
+
) -> Dict:
|
| 154 |
+
"""Get the leaderboard for a given segment.
|
| 155 |
+
|
| 156 |
+
Args:
|
| 157 |
+
ctx: The MCP request context
|
| 158 |
+
segment_id: The ID of the segment
|
| 159 |
+
gender: Filter by gender ('M' or 'F')
|
| 160 |
+
age_group: Filter by age group
|
| 161 |
+
weight_class: Filter by weight class
|
| 162 |
+
following: Filter by friends of the authenticated athlete
|
| 163 |
+
club_id: Filter by club
|
| 164 |
+
date_range: Filter by date range
|
| 165 |
+
context_entries: Number of context entries
|
| 166 |
+
page: Page number
|
| 167 |
+
per_page: Number of items per page
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
The segment leaderboard
|
| 171 |
+
"""
|
| 172 |
+
service = ctx.request_context.lifespan_context["service"]
|
| 173 |
+
|
| 174 |
+
try:
|
| 175 |
+
leaderboard = await service.get_segment_leaderboard(
|
| 176 |
+
segment_id,
|
| 177 |
+
gender,
|
| 178 |
+
age_group,
|
| 179 |
+
weight_class,
|
| 180 |
+
following,
|
| 181 |
+
club_id,
|
| 182 |
+
date_range,
|
| 183 |
+
context_entries,
|
| 184 |
+
page,
|
| 185 |
+
per_page,
|
| 186 |
+
)
|
| 187 |
+
return leaderboard.model_dump()
|
| 188 |
+
except Exception as e:
|
| 189 |
+
logger.error(f"Error in get_segment_leaderboard tool: {str(e)}")
|
| 190 |
+
raise
|
strava_mcp/service.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import List, Optional, Any, Dict
|
| 3 |
+
|
| 4 |
+
from strava_mcp.config import StravaSettings
|
| 5 |
+
from strava_mcp.api import StravaAPI
|
| 6 |
+
from strava_mcp.models import Activity, DetailedActivity, SegmentEffort, Leaderboard
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class StravaService:
|
| 13 |
+
"""Service for interacting with the Strava API."""
|
| 14 |
+
|
| 15 |
+
def __init__(self, settings: StravaSettings):
|
| 16 |
+
"""Initialize the Strava service.
|
| 17 |
+
|
| 18 |
+
Args:
|
| 19 |
+
settings: Strava API settings
|
| 20 |
+
"""
|
| 21 |
+
self.settings = settings
|
| 22 |
+
self.api = StravaAPI(settings)
|
| 23 |
+
|
| 24 |
+
async def close(self):
|
| 25 |
+
"""Close the API client."""
|
| 26 |
+
await self.api.close()
|
| 27 |
+
|
| 28 |
+
async def get_activities(
|
| 29 |
+
self,
|
| 30 |
+
before: Optional[int] = None,
|
| 31 |
+
after: Optional[int] = None,
|
| 32 |
+
page: int = 1,
|
| 33 |
+
per_page: int = 30
|
| 34 |
+
) -> List[Activity]:
|
| 35 |
+
"""Get a list of activities for the authenticated athlete.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
before: An epoch timestamp to use for filtering activities that have taken place before a certain time
|
| 39 |
+
after: An epoch timestamp to use for filtering activities that have taken place after a certain time
|
| 40 |
+
page: Page number
|
| 41 |
+
per_page: Number of items per page
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
List of activities
|
| 45 |
+
"""
|
| 46 |
+
try:
|
| 47 |
+
logger.info("Getting activities for authenticated athlete")
|
| 48 |
+
activities = await self.api.get_activities(before, after, page, per_page)
|
| 49 |
+
logger.info(f"Retrieved {len(activities)} activities")
|
| 50 |
+
return activities
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.error(f"Error getting activities: {str(e)}")
|
| 53 |
+
raise
|
| 54 |
+
|
| 55 |
+
async def get_activity(self, activity_id: int, include_all_efforts: bool = False) -> DetailedActivity:
|
| 56 |
+
"""Get a specific activity.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
activity_id: The ID of the activity
|
| 60 |
+
include_all_efforts: Whether to include all segment efforts
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
The activity details
|
| 64 |
+
"""
|
| 65 |
+
try:
|
| 66 |
+
logger.info(f"Getting activity {activity_id}")
|
| 67 |
+
activity = await self.api.get_activity(activity_id, include_all_efforts)
|
| 68 |
+
logger.info(f"Retrieved activity: {activity.name}")
|
| 69 |
+
return activity
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logger.error(f"Error getting activity {activity_id}: {str(e)}")
|
| 72 |
+
raise
|
| 73 |
+
|
| 74 |
+
async def get_activity_segments(self, activity_id: int) -> List[SegmentEffort]:
|
| 75 |
+
"""Get segments from a specific activity.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
activity_id: The ID of the activity
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
List of segment efforts for the activity
|
| 82 |
+
"""
|
| 83 |
+
try:
|
| 84 |
+
logger.info(f"Getting segments for activity {activity_id}")
|
| 85 |
+
segments = await self.api.get_activity_segments(activity_id)
|
| 86 |
+
logger.info(f"Retrieved {len(segments)} segments")
|
| 87 |
+
return segments
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.error(f"Error getting segments for activity {activity_id}: {str(e)}")
|
| 90 |
+
raise
|
| 91 |
+
|
| 92 |
+
async def get_segment_leaderboard(
|
| 93 |
+
self,
|
| 94 |
+
segment_id: int,
|
| 95 |
+
gender: Optional[str] = None,
|
| 96 |
+
age_group: Optional[str] = None,
|
| 97 |
+
weight_class: Optional[str] = None,
|
| 98 |
+
following: Optional[bool] = None,
|
| 99 |
+
club_id: Optional[int] = None,
|
| 100 |
+
date_range: Optional[str] = None,
|
| 101 |
+
context_entries: Optional[int] = None,
|
| 102 |
+
page: int = 1,
|
| 103 |
+
per_page: int = 30
|
| 104 |
+
) -> Leaderboard:
|
| 105 |
+
"""Get the leaderboard for a given segment.
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
segment_id: The ID of the segment
|
| 109 |
+
gender: Filter by gender ('M' or 'F')
|
| 110 |
+
age_group: Filter by age group
|
| 111 |
+
weight_class: Filter by weight class
|
| 112 |
+
following: Filter by friends of the authenticated athlete
|
| 113 |
+
club_id: Filter by club
|
| 114 |
+
date_range: Filter by date range
|
| 115 |
+
context_entries: Number of context entries
|
| 116 |
+
page: Page number
|
| 117 |
+
per_page: Number of items per page
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
The segment leaderboard
|
| 121 |
+
"""
|
| 122 |
+
try:
|
| 123 |
+
logger.info(f"Getting leaderboard for segment {segment_id}")
|
| 124 |
+
leaderboard = await self.api.get_segment_leaderboard(
|
| 125 |
+
segment_id,
|
| 126 |
+
gender,
|
| 127 |
+
age_group,
|
| 128 |
+
weight_class,
|
| 129 |
+
following,
|
| 130 |
+
club_id,
|
| 131 |
+
date_range,
|
| 132 |
+
context_entries,
|
| 133 |
+
page,
|
| 134 |
+
per_page
|
| 135 |
+
)
|
| 136 |
+
logger.info(f"Retrieved leaderboard with {leaderboard.entry_count} entries")
|
| 137 |
+
return leaderboard
|
| 138 |
+
except Exception as e:
|
| 139 |
+
logger.error(f"Error getting leaderboard for segment {segment_id}: {str(e)}")
|
| 140 |
+
raise
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Test package for the Strava MCP server
|
tests/test_api.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 4 |
+
|
| 5 |
+
from strava_mcp.config import StravaSettings
|
| 6 |
+
from strava_mcp.api import StravaAPI
|
| 7 |
+
from strava_mcp.models import Activity, DetailedActivity, Leaderboard
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture
|
| 11 |
+
def settings():
|
| 12 |
+
return StravaSettings(
|
| 13 |
+
client_id="test_client_id",
|
| 14 |
+
client_secret="test_client_secret",
|
| 15 |
+
refresh_token="test_refresh_token",
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.fixture
|
| 20 |
+
def mock_response():
|
| 21 |
+
mock = MagicMock()
|
| 22 |
+
mock.is_success = True
|
| 23 |
+
mock.json = MagicMock(return_value={})
|
| 24 |
+
mock.status_code = 200
|
| 25 |
+
return mock
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@pytest.fixture
|
| 29 |
+
def api(settings):
|
| 30 |
+
api = StravaAPI(settings)
|
| 31 |
+
api._client = AsyncMock()
|
| 32 |
+
api.access_token = "test_access_token"
|
| 33 |
+
api.token_expires_at = datetime.now().timestamp() + 3600
|
| 34 |
+
return api
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@pytest.mark.asyncio
|
| 38 |
+
async def test_ensure_token_valid(api):
|
| 39 |
+
# Token is already valid
|
| 40 |
+
token = await api._ensure_token()
|
| 41 |
+
assert token == "test_access_token"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@pytest.mark.asyncio
|
| 45 |
+
async def test_ensure_token_refresh(settings):
|
| 46 |
+
with patch("httpx.AsyncClient") as mock_client:
|
| 47 |
+
# Setup mock for token refresh
|
| 48 |
+
mock_instance = mock_client.return_value.__aenter__.return_value
|
| 49 |
+
mock_response = MagicMock()
|
| 50 |
+
mock_response.status_code = 200
|
| 51 |
+
mock_response.json.return_value = {
|
| 52 |
+
"access_token": "new_access_token",
|
| 53 |
+
"expires_at": datetime.now().timestamp() + 3600,
|
| 54 |
+
}
|
| 55 |
+
mock_instance.post.return_value = mock_response
|
| 56 |
+
|
| 57 |
+
# Create API with expired token
|
| 58 |
+
api = StravaAPI(settings)
|
| 59 |
+
api.access_token = "old_access_token"
|
| 60 |
+
api.token_expires_at = datetime.now().timestamp() - 3600
|
| 61 |
+
|
| 62 |
+
# Test token refresh
|
| 63 |
+
token = await api._ensure_token()
|
| 64 |
+
assert token == "new_access_token"
|
| 65 |
+
|
| 66 |
+
# Verify correct API call was made
|
| 67 |
+
mock_instance.post.assert_called_once()
|
| 68 |
+
args, kwargs = mock_instance.post.call_args
|
| 69 |
+
assert args[0] == "https://www.strava.com/oauth/token"
|
| 70 |
+
assert kwargs["json"]["client_id"] == "test_client_id"
|
| 71 |
+
assert kwargs["json"]["client_secret"] == "test_client_secret"
|
| 72 |
+
assert kwargs["json"]["refresh_token"] == "test_refresh_token"
|
| 73 |
+
assert kwargs["json"]["grant_type"] == "refresh_token"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@pytest.mark.asyncio
|
| 77 |
+
async def test_get_activities(api, mock_response):
|
| 78 |
+
# Setup mock response
|
| 79 |
+
activity_data = {
|
| 80 |
+
"id": 1234567890,
|
| 81 |
+
"name": "Morning Run",
|
| 82 |
+
"distance": 5000,
|
| 83 |
+
"moving_time": 1200,
|
| 84 |
+
"elapsed_time": 1300,
|
| 85 |
+
"total_elevation_gain": 50,
|
| 86 |
+
"type": "Run",
|
| 87 |
+
"sport_type": "Run",
|
| 88 |
+
"start_date": "2023-01-01T10:00:00Z",
|
| 89 |
+
"start_date_local": "2023-01-01T10:00:00Z",
|
| 90 |
+
"timezone": "Europe/London",
|
| 91 |
+
"achievement_count": 2,
|
| 92 |
+
"kudos_count": 5,
|
| 93 |
+
"comment_count": 0,
|
| 94 |
+
"athlete_count": 1,
|
| 95 |
+
"photo_count": 0,
|
| 96 |
+
"trainer": False,
|
| 97 |
+
"commute": False,
|
| 98 |
+
"manual": False,
|
| 99 |
+
"private": False,
|
| 100 |
+
"flagged": False,
|
| 101 |
+
"average_speed": 4.167,
|
| 102 |
+
"max_speed": 5.3,
|
| 103 |
+
"has_heartrate": True,
|
| 104 |
+
"average_heartrate": 140,
|
| 105 |
+
"max_heartrate": 160,
|
| 106 |
+
}
|
| 107 |
+
mock_response.json.return_value = [activity_data]
|
| 108 |
+
api._client.request.return_value = mock_response
|
| 109 |
+
|
| 110 |
+
# Test get_activities
|
| 111 |
+
activities = await api.get_activities()
|
| 112 |
+
|
| 113 |
+
# Verify request
|
| 114 |
+
api._client.request.assert_called_once()
|
| 115 |
+
args, kwargs = api._client.request.call_args
|
| 116 |
+
assert args[0] == "GET"
|
| 117 |
+
assert args[1] == "/athlete/activities"
|
| 118 |
+
assert kwargs["params"] == {"page": 1, "per_page": 30}
|
| 119 |
+
|
| 120 |
+
# Verify response
|
| 121 |
+
assert len(activities) == 1
|
| 122 |
+
assert isinstance(activities[0], Activity)
|
| 123 |
+
assert activities[0].id == activity_data["id"]
|
| 124 |
+
assert activities[0].name == activity_data["name"]
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@pytest.mark.asyncio
|
| 128 |
+
async def test_get_activity(api, mock_response):
|
| 129 |
+
# Setup mock response
|
| 130 |
+
activity_data = {
|
| 131 |
+
"id": 1234567890,
|
| 132 |
+
"name": "Morning Run",
|
| 133 |
+
"distance": 5000,
|
| 134 |
+
"moving_time": 1200,
|
| 135 |
+
"elapsed_time": 1300,
|
| 136 |
+
"total_elevation_gain": 50,
|
| 137 |
+
"type": "Run",
|
| 138 |
+
"sport_type": "Run",
|
| 139 |
+
"start_date": "2023-01-01T10:00:00Z",
|
| 140 |
+
"start_date_local": "2023-01-01T10:00:00Z",
|
| 141 |
+
"timezone": "Europe/London",
|
| 142 |
+
"achievement_count": 2,
|
| 143 |
+
"kudos_count": 5,
|
| 144 |
+
"comment_count": 0,
|
| 145 |
+
"athlete_count": 1,
|
| 146 |
+
"photo_count": 0,
|
| 147 |
+
"trainer": False,
|
| 148 |
+
"commute": False,
|
| 149 |
+
"manual": False,
|
| 150 |
+
"private": False,
|
| 151 |
+
"flagged": False,
|
| 152 |
+
"average_speed": 4.167,
|
| 153 |
+
"max_speed": 5.3,
|
| 154 |
+
"has_heartrate": True,
|
| 155 |
+
"average_heartrate": 140,
|
| 156 |
+
"max_heartrate": 160,
|
| 157 |
+
"athlete": {"id": 123},
|
| 158 |
+
"description": "Test description",
|
| 159 |
+
}
|
| 160 |
+
mock_response.json.return_value = activity_data
|
| 161 |
+
api._client.request.return_value = mock_response
|
| 162 |
+
|
| 163 |
+
# Test get_activity
|
| 164 |
+
activity = await api.get_activity(1234567890)
|
| 165 |
+
|
| 166 |
+
# Verify request
|
| 167 |
+
api._client.request.assert_called_once()
|
| 168 |
+
args, kwargs = api._client.request.call_args
|
| 169 |
+
assert args[0] == "GET"
|
| 170 |
+
assert args[1] == "/activities/1234567890"
|
| 171 |
+
|
| 172 |
+
# Verify response
|
| 173 |
+
assert isinstance(activity, DetailedActivity)
|
| 174 |
+
assert activity.id == activity_data["id"]
|
| 175 |
+
assert activity.name == activity_data["name"]
|
| 176 |
+
assert activity.description == activity_data["description"]
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@pytest.mark.asyncio
|
| 180 |
+
async def test_get_segment_leaderboard(api, mock_response):
|
| 181 |
+
# Setup mock response
|
| 182 |
+
leaderboard_data = {
|
| 183 |
+
"entry_count": 100,
|
| 184 |
+
"effort_count": 200,
|
| 185 |
+
"kom_type": "kom",
|
| 186 |
+
"entries": [
|
| 187 |
+
{
|
| 188 |
+
"athlete_name": "John Doe",
|
| 189 |
+
"athlete_id": 123,
|
| 190 |
+
"athlete_gender": "M",
|
| 191 |
+
"average_hr": 160,
|
| 192 |
+
"average_watts": 250,
|
| 193 |
+
"distance": 1000,
|
| 194 |
+
"elapsed_time": 180,
|
| 195 |
+
"moving_time": 180,
|
| 196 |
+
"start_date": "2023-01-01T10:00:00Z",
|
| 197 |
+
"start_date_local": "2023-01-01T10:00:00Z",
|
| 198 |
+
"activity_id": 12345,
|
| 199 |
+
"effort_id": 67890,
|
| 200 |
+
"rank": 1,
|
| 201 |
+
}
|
| 202 |
+
]
|
| 203 |
+
}
|
| 204 |
+
mock_response.json.return_value = leaderboard_data
|
| 205 |
+
api._client.request.return_value = mock_response
|
| 206 |
+
|
| 207 |
+
# Test get_segment_leaderboard
|
| 208 |
+
leaderboard = await api.get_segment_leaderboard(12345)
|
| 209 |
+
|
| 210 |
+
# Verify request
|
| 211 |
+
api._client.request.assert_called_once()
|
| 212 |
+
args, kwargs = api._client.request.call_args
|
| 213 |
+
assert args[0] == "GET"
|
| 214 |
+
assert args[1] == "/segments/12345/leaderboard"
|
| 215 |
+
assert kwargs["params"] == {"page": 1, "per_page": 30}
|
| 216 |
+
|
| 217 |
+
# Verify response
|
| 218 |
+
assert isinstance(leaderboard, Leaderboard)
|
| 219 |
+
assert leaderboard.entry_count == leaderboard_data["entry_count"]
|
| 220 |
+
assert leaderboard.effort_count == leaderboard_data["effort_count"]
|
| 221 |
+
assert leaderboard.kom_type == leaderboard_data["kom_type"]
|
| 222 |
+
assert len(leaderboard.entries) == 1
|
tests/test_server.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 3 |
+
|
| 4 |
+
from strava_mcp.server import mcp
|
| 5 |
+
from strava_mcp.models import Activity, DetailedActivity, SegmentEffort, Leaderboard
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class MockContext:
|
| 9 |
+
"""Mock MCP context for testing."""
|
| 10 |
+
|
| 11 |
+
def __init__(self, service):
|
| 12 |
+
self.request_context = MagicMock()
|
| 13 |
+
self.request_context.lifespan_context = {"service": service}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@pytest.fixture
|
| 17 |
+
def mock_service():
|
| 18 |
+
mock = AsyncMock()
|
| 19 |
+
mock.get_activities = AsyncMock()
|
| 20 |
+
mock.get_activity = AsyncMock()
|
| 21 |
+
mock.get_activity_segments = AsyncMock()
|
| 22 |
+
mock.get_segment_leaderboard = AsyncMock()
|
| 23 |
+
return mock
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.fixture
|
| 27 |
+
def mock_ctx(mock_service):
|
| 28 |
+
return MockContext(mock_service)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@pytest.mark.asyncio
|
| 32 |
+
async def test_get_user_activities(mock_ctx, mock_service):
|
| 33 |
+
# Setup mock data
|
| 34 |
+
mock_activity = Activity(
|
| 35 |
+
id=1234567890,
|
| 36 |
+
name="Morning Run",
|
| 37 |
+
distance=5000,
|
| 38 |
+
moving_time=1200,
|
| 39 |
+
elapsed_time=1300,
|
| 40 |
+
total_elevation_gain=50,
|
| 41 |
+
type="Run",
|
| 42 |
+
sport_type="Run",
|
| 43 |
+
start_date="2023-01-01T10:00:00Z",
|
| 44 |
+
start_date_local="2023-01-01T10:00:00Z",
|
| 45 |
+
timezone="Europe/London",
|
| 46 |
+
achievement_count=2,
|
| 47 |
+
kudos_count=5,
|
| 48 |
+
comment_count=0,
|
| 49 |
+
athlete_count=1,
|
| 50 |
+
photo_count=0,
|
| 51 |
+
trainer=False,
|
| 52 |
+
commute=False,
|
| 53 |
+
manual=False,
|
| 54 |
+
private=False,
|
| 55 |
+
flagged=False,
|
| 56 |
+
average_speed=4.167,
|
| 57 |
+
max_speed=5.3,
|
| 58 |
+
has_heartrate=True,
|
| 59 |
+
average_heartrate=140,
|
| 60 |
+
max_heartrate=160,
|
| 61 |
+
)
|
| 62 |
+
mock_service.get_activities.return_value = [mock_activity]
|
| 63 |
+
|
| 64 |
+
# Test tool
|
| 65 |
+
from strava_mcp.server import get_user_activities
|
| 66 |
+
result = await get_user_activities(mock_ctx)
|
| 67 |
+
|
| 68 |
+
# Verify service call
|
| 69 |
+
mock_service.get_activities.assert_called_once_with(None, None, 1, 30)
|
| 70 |
+
|
| 71 |
+
# Verify result
|
| 72 |
+
assert len(result) == 1
|
| 73 |
+
assert result[0]["id"] == mock_activity.id
|
| 74 |
+
assert result[0]["name"] == mock_activity.name
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@pytest.mark.asyncio
|
| 78 |
+
async def test_get_activity(mock_ctx, mock_service):
|
| 79 |
+
# Setup mock data
|
| 80 |
+
mock_activity = DetailedActivity(
|
| 81 |
+
id=1234567890,
|
| 82 |
+
name="Morning Run",
|
| 83 |
+
distance=5000,
|
| 84 |
+
moving_time=1200,
|
| 85 |
+
elapsed_time=1300,
|
| 86 |
+
total_elevation_gain=50,
|
| 87 |
+
type="Run",
|
| 88 |
+
sport_type="Run",
|
| 89 |
+
start_date="2023-01-01T10:00:00Z",
|
| 90 |
+
start_date_local="2023-01-01T10:00:00Z",
|
| 91 |
+
timezone="Europe/London",
|
| 92 |
+
achievement_count=2,
|
| 93 |
+
kudos_count=5,
|
| 94 |
+
comment_count=0,
|
| 95 |
+
athlete_count=1,
|
| 96 |
+
photo_count=0,
|
| 97 |
+
trainer=False,
|
| 98 |
+
commute=False,
|
| 99 |
+
manual=False,
|
| 100 |
+
private=False,
|
| 101 |
+
flagged=False,
|
| 102 |
+
average_speed=4.167,
|
| 103 |
+
max_speed=5.3,
|
| 104 |
+
has_heartrate=True,
|
| 105 |
+
average_heartrate=140,
|
| 106 |
+
max_heartrate=160,
|
| 107 |
+
athlete={"id": 123},
|
| 108 |
+
description="Test description",
|
| 109 |
+
)
|
| 110 |
+
mock_service.get_activity.return_value = mock_activity
|
| 111 |
+
|
| 112 |
+
# Test tool
|
| 113 |
+
from strava_mcp.server import get_activity
|
| 114 |
+
result = await get_activity(mock_ctx, 1234567890)
|
| 115 |
+
|
| 116 |
+
# Verify service call
|
| 117 |
+
mock_service.get_activity.assert_called_once_with(1234567890, False)
|
| 118 |
+
|
| 119 |
+
# Verify result
|
| 120 |
+
assert result["id"] == mock_activity.id
|
| 121 |
+
assert result["name"] == mock_activity.name
|
| 122 |
+
assert result["description"] == mock_activity.description
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@pytest.mark.asyncio
|
| 126 |
+
async def test_get_activity_segments(mock_ctx, mock_service):
|
| 127 |
+
# Setup mock data
|
| 128 |
+
mock_segment = SegmentEffort(
|
| 129 |
+
id=67890,
|
| 130 |
+
activity_id=1234567890,
|
| 131 |
+
segment_id=12345,
|
| 132 |
+
name="Test Segment",
|
| 133 |
+
elapsed_time=180,
|
| 134 |
+
moving_time=180,
|
| 135 |
+
start_date="2023-01-01T10:05:00Z",
|
| 136 |
+
start_date_local="2023-01-01T10:05:00Z",
|
| 137 |
+
distance=1000,
|
| 138 |
+
athlete={"id": 123},
|
| 139 |
+
segment={
|
| 140 |
+
"id": 12345,
|
| 141 |
+
"name": "Test Segment",
|
| 142 |
+
"activity_type": "Run",
|
| 143 |
+
"distance": 1000,
|
| 144 |
+
"average_grade": 5.0,
|
| 145 |
+
"maximum_grade": 10.0,
|
| 146 |
+
"elevation_high": 200,
|
| 147 |
+
"elevation_low": 150,
|
| 148 |
+
"total_elevation_gain": 50,
|
| 149 |
+
"start_latlng": [51.5, -0.1],
|
| 150 |
+
"end_latlng": [51.5, -0.2],
|
| 151 |
+
"climb_category": 0,
|
| 152 |
+
"private": False,
|
| 153 |
+
"starred": False,
|
| 154 |
+
},
|
| 155 |
+
)
|
| 156 |
+
mock_service.get_activity_segments.return_value = [mock_segment]
|
| 157 |
+
|
| 158 |
+
# Test tool
|
| 159 |
+
from strava_mcp.server import get_activity_segments
|
| 160 |
+
result = await get_activity_segments(mock_ctx, 1234567890)
|
| 161 |
+
|
| 162 |
+
# Verify service call
|
| 163 |
+
mock_service.get_activity_segments.assert_called_once_with(1234567890)
|
| 164 |
+
|
| 165 |
+
# Verify result
|
| 166 |
+
assert len(result) == 1
|
| 167 |
+
assert result[0]["id"] == mock_segment.id
|
| 168 |
+
assert result[0]["name"] == mock_segment.name
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@pytest.mark.asyncio
|
| 172 |
+
async def test_get_segment_leaderboard(mock_ctx, mock_service):
|
| 173 |
+
# Setup mock data
|
| 174 |
+
mock_leaderboard = Leaderboard(
|
| 175 |
+
entry_count=100,
|
| 176 |
+
effort_count=200,
|
| 177 |
+
kom_type="kom",
|
| 178 |
+
entries=[
|
| 179 |
+
{
|
| 180 |
+
"athlete_name": "John Doe",
|
| 181 |
+
"athlete_id": 123,
|
| 182 |
+
"athlete_gender": "M",
|
| 183 |
+
"average_hr": 160,
|
| 184 |
+
"average_watts": 250,
|
| 185 |
+
"distance": 1000,
|
| 186 |
+
"elapsed_time": 180,
|
| 187 |
+
"moving_time": 180,
|
| 188 |
+
"start_date": "2023-01-01T10:00:00Z",
|
| 189 |
+
"start_date_local": "2023-01-01T10:00:00Z",
|
| 190 |
+
"activity_id": 12345,
|
| 191 |
+
"effort_id": 67890,
|
| 192 |
+
"rank": 1,
|
| 193 |
+
}
|
| 194 |
+
],
|
| 195 |
+
)
|
| 196 |
+
mock_service.get_segment_leaderboard.return_value = mock_leaderboard
|
| 197 |
+
|
| 198 |
+
# Test tool
|
| 199 |
+
from strava_mcp.server import get_segment_leaderboard
|
| 200 |
+
result = await get_segment_leaderboard(mock_ctx, 12345)
|
| 201 |
+
|
| 202 |
+
# Verify service call
|
| 203 |
+
mock_service.get_segment_leaderboard.assert_called_once_with(
|
| 204 |
+
12345, None, None, None, None, None, None, None, 1, 30
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
# Verify result
|
| 208 |
+
assert result["entry_count"] == mock_leaderboard.entry_count
|
| 209 |
+
assert result["effort_count"] == mock_leaderboard.effort_count
|
| 210 |
+
assert result["kom_type"] == mock_leaderboard.kom_type
|
| 211 |
+
assert len(result["entries"]) == 1
|
tests/test_service.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 3 |
+
|
| 4 |
+
from strava_mcp.config import StravaSettings
|
| 5 |
+
from strava_mcp.service import StravaService
|
| 6 |
+
from strava_mcp.models import Activity, DetailedActivity, SegmentEffort, Leaderboard
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.fixture
|
| 10 |
+
def settings():
|
| 11 |
+
return StravaSettings(
|
| 12 |
+
client_id="test_client_id",
|
| 13 |
+
client_secret="test_client_secret",
|
| 14 |
+
refresh_token="test_refresh_token",
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@pytest.fixture
|
| 19 |
+
def mock_api():
|
| 20 |
+
mock = AsyncMock()
|
| 21 |
+
mock.get_activities = AsyncMock()
|
| 22 |
+
mock.get_activity = AsyncMock()
|
| 23 |
+
mock.get_activity_segments = AsyncMock()
|
| 24 |
+
mock.get_segment_leaderboard = AsyncMock()
|
| 25 |
+
return mock
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@pytest.fixture
|
| 29 |
+
def service(settings, mock_api):
|
| 30 |
+
with patch("strava_mcp.service.StravaAPI", return_value=mock_api):
|
| 31 |
+
service = StravaService(settings)
|
| 32 |
+
yield service
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@pytest.mark.asyncio
|
| 36 |
+
async def test_get_activities(service, mock_api):
|
| 37 |
+
# Setup mocked data
|
| 38 |
+
mock_activity = Activity(
|
| 39 |
+
id=1234567890,
|
| 40 |
+
name="Morning Run",
|
| 41 |
+
distance=5000,
|
| 42 |
+
moving_time=1200,
|
| 43 |
+
elapsed_time=1300,
|
| 44 |
+
total_elevation_gain=50,
|
| 45 |
+
type="Run",
|
| 46 |
+
sport_type="Run",
|
| 47 |
+
start_date="2023-01-01T10:00:00Z",
|
| 48 |
+
start_date_local="2023-01-01T10:00:00Z",
|
| 49 |
+
timezone="Europe/London",
|
| 50 |
+
achievement_count=2,
|
| 51 |
+
kudos_count=5,
|
| 52 |
+
comment_count=0,
|
| 53 |
+
athlete_count=1,
|
| 54 |
+
photo_count=0,
|
| 55 |
+
trainer=False,
|
| 56 |
+
commute=False,
|
| 57 |
+
manual=False,
|
| 58 |
+
private=False,
|
| 59 |
+
flagged=False,
|
| 60 |
+
average_speed=4.167,
|
| 61 |
+
max_speed=5.3,
|
| 62 |
+
has_heartrate=True,
|
| 63 |
+
average_heartrate=140,
|
| 64 |
+
max_heartrate=160,
|
| 65 |
+
)
|
| 66 |
+
mock_api.get_activities.return_value = [mock_activity]
|
| 67 |
+
|
| 68 |
+
# Test get_activities
|
| 69 |
+
activities = await service.get_activities()
|
| 70 |
+
|
| 71 |
+
# Verify API call
|
| 72 |
+
mock_api.get_activities.assert_called_once_with(None, None, 1, 30)
|
| 73 |
+
|
| 74 |
+
# Verify response
|
| 75 |
+
assert len(activities) == 1
|
| 76 |
+
assert activities[0] == mock_activity
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@pytest.mark.asyncio
|
| 80 |
+
async def test_get_activity(service, mock_api):
|
| 81 |
+
# Setup mocked data
|
| 82 |
+
mock_activity = DetailedActivity(
|
| 83 |
+
id=1234567890,
|
| 84 |
+
name="Morning Run",
|
| 85 |
+
distance=5000,
|
| 86 |
+
moving_time=1200,
|
| 87 |
+
elapsed_time=1300,
|
| 88 |
+
total_elevation_gain=50,
|
| 89 |
+
type="Run",
|
| 90 |
+
sport_type="Run",
|
| 91 |
+
start_date="2023-01-01T10:00:00Z",
|
| 92 |
+
start_date_local="2023-01-01T10:00:00Z",
|
| 93 |
+
timezone="Europe/London",
|
| 94 |
+
achievement_count=2,
|
| 95 |
+
kudos_count=5,
|
| 96 |
+
comment_count=0,
|
| 97 |
+
athlete_count=1,
|
| 98 |
+
photo_count=0,
|
| 99 |
+
trainer=False,
|
| 100 |
+
commute=False,
|
| 101 |
+
manual=False,
|
| 102 |
+
private=False,
|
| 103 |
+
flagged=False,
|
| 104 |
+
average_speed=4.167,
|
| 105 |
+
max_speed=5.3,
|
| 106 |
+
has_heartrate=True,
|
| 107 |
+
average_heartrate=140,
|
| 108 |
+
max_heartrate=160,
|
| 109 |
+
athlete={"id": 123},
|
| 110 |
+
description="Test description",
|
| 111 |
+
)
|
| 112 |
+
mock_api.get_activity.return_value = mock_activity
|
| 113 |
+
|
| 114 |
+
# Test get_activity
|
| 115 |
+
activity = await service.get_activity(1234567890)
|
| 116 |
+
|
| 117 |
+
# Verify API call
|
| 118 |
+
mock_api.get_activity.assert_called_once_with(1234567890, False)
|
| 119 |
+
|
| 120 |
+
# Verify response
|
| 121 |
+
assert activity == mock_activity
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@pytest.mark.asyncio
|
| 125 |
+
async def test_get_activity_segments(service, mock_api):
|
| 126 |
+
# Setup mocked data
|
| 127 |
+
mock_segment = SegmentEffort(
|
| 128 |
+
id=67890,
|
| 129 |
+
activity_id=1234567890,
|
| 130 |
+
segment_id=12345,
|
| 131 |
+
name="Test Segment",
|
| 132 |
+
elapsed_time=180,
|
| 133 |
+
moving_time=180,
|
| 134 |
+
start_date="2023-01-01T10:05:00Z",
|
| 135 |
+
start_date_local="2023-01-01T10:05:00Z",
|
| 136 |
+
distance=1000,
|
| 137 |
+
athlete={"id": 123},
|
| 138 |
+
segment={
|
| 139 |
+
"id": 12345,
|
| 140 |
+
"name": "Test Segment",
|
| 141 |
+
"activity_type": "Run",
|
| 142 |
+
"distance": 1000,
|
| 143 |
+
"average_grade": 5.0,
|
| 144 |
+
"maximum_grade": 10.0,
|
| 145 |
+
"elevation_high": 200,
|
| 146 |
+
"elevation_low": 150,
|
| 147 |
+
"total_elevation_gain": 50,
|
| 148 |
+
"start_latlng": [51.5, -0.1],
|
| 149 |
+
"end_latlng": [51.5, -0.2],
|
| 150 |
+
"climb_category": 0,
|
| 151 |
+
"private": False,
|
| 152 |
+
"starred": False,
|
| 153 |
+
},
|
| 154 |
+
)
|
| 155 |
+
mock_api.get_activity_segments.return_value = [mock_segment]
|
| 156 |
+
|
| 157 |
+
# Test get_activity_segments
|
| 158 |
+
segments = await service.get_activity_segments(1234567890)
|
| 159 |
+
|
| 160 |
+
# Verify API call
|
| 161 |
+
mock_api.get_activity_segments.assert_called_once_with(1234567890)
|
| 162 |
+
|
| 163 |
+
# Verify response
|
| 164 |
+
assert len(segments) == 1
|
| 165 |
+
assert segments[0] == mock_segment
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@pytest.mark.asyncio
|
| 169 |
+
async def test_get_segment_leaderboard(service, mock_api):
|
| 170 |
+
# Setup mocked data
|
| 171 |
+
mock_leaderboard = Leaderboard(
|
| 172 |
+
entry_count=100,
|
| 173 |
+
effort_count=200,
|
| 174 |
+
kom_type="kom",
|
| 175 |
+
entries=[
|
| 176 |
+
{
|
| 177 |
+
"athlete_name": "John Doe",
|
| 178 |
+
"athlete_id": 123,
|
| 179 |
+
"athlete_gender": "M",
|
| 180 |
+
"average_hr": 160,
|
| 181 |
+
"average_watts": 250,
|
| 182 |
+
"distance": 1000,
|
| 183 |
+
"elapsed_time": 180,
|
| 184 |
+
"moving_time": 180,
|
| 185 |
+
"start_date": "2023-01-01T10:00:00Z",
|
| 186 |
+
"start_date_local": "2023-01-01T10:00:00Z",
|
| 187 |
+
"activity_id": 12345,
|
| 188 |
+
"effort_id": 67890,
|
| 189 |
+
"rank": 1,
|
| 190 |
+
}
|
| 191 |
+
],
|
| 192 |
+
)
|
| 193 |
+
mock_api.get_segment_leaderboard.return_value = mock_leaderboard
|
| 194 |
+
|
| 195 |
+
# Test get_segment_leaderboard
|
| 196 |
+
leaderboard = await service.get_segment_leaderboard(12345)
|
| 197 |
+
|
| 198 |
+
# Verify API call
|
| 199 |
+
mock_api.get_segment_leaderboard.assert_called_once_with(
|
| 200 |
+
12345, None, None, None, None, None, None, None, 1, 30
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Verify response
|
| 204 |
+
assert leaderboard == mock_leaderboard
|
uv.lock
CHANGED
|
@@ -562,6 +562,18 @@ wheels = [
|
|
| 562 |
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 },
|
| 563 |
]
|
| 564 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
[[package]]
|
| 566 |
name = "pytest-mock"
|
| 567 |
version = "3.14.0"
|
|
@@ -730,6 +742,7 @@ dev = [
|
|
| 730 |
{ name = "devtools" },
|
| 731 |
{ name = "ipykernel" },
|
| 732 |
{ name = "pytest" },
|
|
|
|
| 733 |
{ name = "pytest-mock" },
|
| 734 |
]
|
| 735 |
|
|
@@ -746,6 +759,7 @@ dev = [
|
|
| 746 |
{ name = "devtools", specifier = ">=0.12.2" },
|
| 747 |
{ name = "ipykernel", specifier = ">=6.29.5" },
|
| 748 |
{ name = "pytest", specifier = ">=8.3.5" },
|
|
|
|
| 749 |
{ name = "pytest-mock", specifier = ">=3.14.0" },
|
| 750 |
]
|
| 751 |
|
|
|
|
| 562 |
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 },
|
| 563 |
]
|
| 564 |
|
| 565 |
+
[[package]]
|
| 566 |
+
name = "pytest-asyncio"
|
| 567 |
+
version = "0.25.3"
|
| 568 |
+
source = { registry = "https://pypi.org/simple" }
|
| 569 |
+
dependencies = [
|
| 570 |
+
{ name = "pytest" },
|
| 571 |
+
]
|
| 572 |
+
sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239 }
|
| 573 |
+
wheels = [
|
| 574 |
+
{ url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467 },
|
| 575 |
+
]
|
| 576 |
+
|
| 577 |
[[package]]
|
| 578 |
name = "pytest-mock"
|
| 579 |
version = "3.14.0"
|
|
|
|
| 742 |
{ name = "devtools" },
|
| 743 |
{ name = "ipykernel" },
|
| 744 |
{ name = "pytest" },
|
| 745 |
+
{ name = "pytest-asyncio" },
|
| 746 |
{ name = "pytest-mock" },
|
| 747 |
]
|
| 748 |
|
|
|
|
| 759 |
{ name = "devtools", specifier = ">=0.12.2" },
|
| 760 |
{ name = "ipykernel", specifier = ">=6.29.5" },
|
| 761 |
{ name = "pytest", specifier = ">=8.3.5" },
|
| 762 |
+
{ name = "pytest-asyncio", specifier = ">=0.23.5" },
|
| 763 |
{ name = "pytest-mock", specifier = ">=3.14.0" },
|
| 764 |
]
|
| 765 |
|