Yorrick Jansen commited on
Commit
b9ca428
·
unverified ·
2 Parent(s): 303c282 e117e66

Merge pull request #1 from yorrickjansen/feature/ci-cd-pipeline

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