Yorrick Jansen commited on
Commit
af9f1a9
·
1 Parent(s): 745568f

First implementation

Browse files
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
- print("Hello from strava!")
 
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