Yorrick Jansen commited on
Commit
99c4f8c
·
1 Parent(s): ddcbc09

Automatically trigger oauth flow to get a refresh token when necessary

Browse files
Files changed (5) hide show
  1. README.md +16 -6
  2. get_token.py +54 -0
  3. strava_mcp/api.py +28 -8
  4. strava_mcp/oauth_server.py +192 -0
  5. strava_mcp/service.py +5 -11
README.md CHANGED
@@ -46,20 +46,26 @@ Alternatively, you can create a `.env` file in the root directory with these var
46
 
47
  ### Authentication
48
 
49
- The server now includes an automatic OAuth flow:
50
 
51
- 1. The first time you make a request to the Strava API, the server will check if you have a refresh token.
52
- 2. If no refresh token is found, it will automatically open your browser to the Strava authorization page.
53
  3. After authorizing the application in your browser, you'll be redirected to a local callback page.
54
- 4. The server will automatically obtain and store the refresh token for future use.
55
 
56
- You can also set the refresh token manually if preferred:
 
 
 
 
 
 
57
 
58
  ```bash
59
  export STRAVA_REFRESH_TOKEN=your_refresh_token
60
  ```
61
 
62
- This approach eliminates the need to manually go through the authorization flow and copy/paste tokens.
63
 
64
  ## Usage
65
 
@@ -139,10 +145,14 @@ Gets the leaderboard for a specific segment.
139
  - `config.py`: Configuration settings using pydantic-settings
140
  - `models.py`: Pydantic models for Strava API entities
141
  - `api.py`: Low-level API client for Strava
 
 
142
  - `service.py`: Service layer for business logic
143
  - `server.py`: MCP server implementation
144
  - `tests/`: Unit tests
145
  - `main.py`: Entry point to run the server
 
 
146
 
147
  ### Running Tests
148
 
 
46
 
47
  ### Authentication
48
 
49
+ The server includes an automatic OAuth flow using a separate local web server:
50
 
51
+ 1. The first time you make a request to the Strava API, the system checks if you have a refresh token.
52
+ 2. If no refresh token is found, it automatically starts a standalone OAuth server and opens your browser to the Strava authorization page.
53
  3. After authorizing the application in your browser, you'll be redirected to a local callback page.
54
+ 4. The server automatically obtains and stores the refresh token for future use.
55
 
56
+ You can also get a refresh token manually by running:
57
+
58
+ ```bash
59
+ python get_token.py
60
+ ```
61
+
62
+ Or set the refresh token directly if you already have one:
63
 
64
  ```bash
65
  export STRAVA_REFRESH_TOKEN=your_refresh_token
66
  ```
67
 
68
+ This approach eliminates the need to manually go through the authorization flow and copy/paste tokens. The OAuth flow uses your `STRAVA_CLIENT_ID` and `STRAVA_CLIENT_SECRET` environment variables.
69
 
70
  ## Usage
71
 
 
145
  - `config.py`: Configuration settings using pydantic-settings
146
  - `models.py`: Pydantic models for Strava API entities
147
  - `api.py`: Low-level API client for Strava
148
+ - `auth.py`: Strava OAuth authentication implementation
149
+ - `oauth_server.py`: Standalone OAuth server implementation
150
  - `service.py`: Service layer for business logic
151
  - `server.py`: MCP server implementation
152
  - `tests/`: Unit tests
153
  - `main.py`: Entry point to run the server
154
+ - `get_token.py`: Utility script to get a refresh token manually
155
+ - `standalone_server.py`: Utility web server for testing OAuth flow
156
 
157
  ### Running Tests
158
 
get_token.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Script to get a Strava refresh token through OAuth flow."""
3
+
4
+ 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
11
+ logging.basicConfig(
12
+ level=logging.INFO,
13
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
14
+ )
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ async def main():
19
+ """Get a Strava refresh token."""
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:
27
+ print("Usage: python get_token.py <client_id> <client_secret>")
28
+ print("Or set STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET environment variables")
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}")
50
+ sys.exit(1)
51
+
52
+
53
+ if __name__ == "__main__":
54
+ asyncio.run(main())
strava_mcp/api.py CHANGED
@@ -139,18 +139,38 @@ class StravaAPI:
139
  if self.access_token and self.token_expires_at and now < self.token_expires_at:
140
  return self.access_token
141
 
142
- # If we don't have a refresh token, try to get one through auth flow
143
  if not self.settings.refresh_token:
144
- logger.warning("No refresh token available, attempting to start auth flow")
145
  try:
146
- self.settings.refresh_token = await self.start_auth_flow()
 
 
 
 
 
 
 
 
147
  except Exception as e:
148
- error_msg = f"Failed to start auth flow: {e}"
149
  logger.error(error_msg)
150
- raise Exception(
151
- "No refresh token available and could not start auth flow. "
152
- "Please set STRAVA_REFRESH_TOKEN manually in your environment variables."
153
- ) from e
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
  # Now that we have a refresh token, refresh the access token
156
  async with httpx.AsyncClient() as client:
 
139
  if self.access_token and self.token_expires_at and now < self.token_expires_at:
140
  return self.access_token
141
 
142
+ # If we don't have a refresh token, try to get one through standalone OAuth flow
143
  if not self.settings.refresh_token:
144
+ logger.warning("No refresh token available, launching standalone OAuth server")
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:
strava_mcp/oauth_server.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A standalone local server for handling Strava OAuth flow."""
2
+
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(
17
+ level=logging.INFO,
18
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
19
+ )
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ 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
+
35
+ Args:
36
+ client_id: Strava API client ID
37
+ client_secret: Strava API client secret
38
+ host: Host for the server
39
+ port: Port for the server
40
+ """
41
+ self.client_id = client_id
42
+ self.client_secret = client_secret
43
+ self.host = host
44
+ self.port = port
45
+ self.authenticator = None
46
+ self.app = None
47
+ self.server_thread = None
48
+ self.token_future = asyncio.Future()
49
+ self.server_task = None
50
+ self.server = None
51
+
52
+ async def get_token(self) -> str:
53
+ """Get a refresh token by starting the OAuth flow.
54
+
55
+ Returns:
56
+ The refresh token
57
+
58
+ Raises:
59
+ Exception: If the OAuth flow fails
60
+ """
61
+ # Initialize the server if it hasn't been done yet
62
+ if not self.app:
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)
69
+
70
+ # Wait for the token
71
+ try:
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
90
+ # Cleanup resources if needed
91
+ logger.info("OAuth server shutting down")
92
+
93
+ # Create FastAPI app
94
+ self.app = FastAPI(
95
+ title="Strava OAuth",
96
+ description="OAuth server for Strava authentication",
97
+ lifespan=lifespan,
98
+ )
99
+
100
+ # Initialize authenticator
101
+ self.authenticator = StravaAuthenticator(
102
+ client_id=self.client_id,
103
+ client_secret=self.client_secret,
104
+ app=self.app,
105
+ host=self.host,
106
+ port=self.port,
107
+ )
108
+
109
+ # Store our token future in the authenticator
110
+ self.authenticator.token_future = self.token_future
111
+
112
+ # Set up routes
113
+ self.authenticator.setup_routes(self.app)
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")
134
+ if not self.token_future.done():
135
+ self.token_future.set_exception(e)
136
+
137
+ async def _stop_server(self):
138
+ """Stop the uvicorn server."""
139
+ if self.server:
140
+ self.server.should_exit = True
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
+
148
+ async def get_refresh_token_from_oauth(client_id: str, client_secret: str) -> str:
149
+ """Get a refresh token by starting a standalone OAuth server.
150
+
151
+ Args:
152
+ client_id: Strava API client ID
153
+ client_secret: Strava API client secret
154
+
155
+ Returns:
156
+ The refresh token
157
+
158
+ Raises:
159
+ Exception: If the OAuth flow fails
160
+ """
161
+ server = StravaOAuthServer(client_id, client_secret)
162
+ return await server.get_token()
163
+
164
+
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:
176
+ print("Usage: python -m strava_mcp.oauth_server <client_id> <client_secret>")
177
+ print("Or set STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET environment variables")
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:")
187
+ print(f"export STRAVA_REFRESH_TOKEN={token}")
188
+ except Exception as e:
189
+ logger.exception("Error getting refresh token")
190
+ print(f"Error: {str(e)}")
191
+
192
+ asyncio.run(main())
strava_mcp/service.py CHANGED
@@ -28,18 +28,12 @@ class StravaService:
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 instructions for manual auth
32
  if not self.settings.refresh_token:
33
- if self.api.app:
34
- logger.info(
35
- "No STRAVA_REFRESH_TOKEN found in environment. "
36
- "The authentication flow will be triggered automatically when needed."
37
- )
38
- else:
39
- logger.warning(
40
- "No STRAVA_REFRESH_TOKEN found in environment and no FastAPI app is available. "
41
- "You'll need to set STRAVA_REFRESH_TOKEN manually for authentication to work."
42
- )
43
 
44
  async def close(self):
45
  """Close the API client."""
 
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):
39
  """Close the API client."""