strava-activity-mcp-server 0.2.5__py3-none-any.whl → 0.2.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of strava-activity-mcp-server might be problematic. Click here for more details.

@@ -1,382 +1,415 @@
1
- import sys
2
- import os
3
- from mcp.server.fastmcp import FastMCP # Import FastMCP, the quickstart server base
4
- mcp = FastMCP("Strava") # Initialize an MCP server instance with a descriptive name
5
- sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
6
- import requests
7
- import urllib.parse
8
- import json
9
- from typing import Any, Dict
10
-
11
- TOKEN_STORE_FILENAME = "strava_mcp_tokens.json"
12
-
13
- def _get_token_store_path() -> str:
14
- home_dir = os.path.expanduser("~")
15
- return os.path.join(home_dir, TOKEN_STORE_FILENAME)
16
-
17
- def _save_tokens_to_disk(tokens: Dict[str, Any]) -> dict:
18
- try:
19
- path = _get_token_store_path()
20
- with open(path, "w", encoding="utf-8") as f:
21
- json.dump(tokens, f)
22
- return {"ok": True, "path": path}
23
- except Exception as e:
24
- return {"ok": False, "error": str(e)}
25
-
26
- def _load_tokens_from_disk() -> dict:
27
- try:
28
- path = _get_token_store_path()
29
- if not os.path.exists(path):
30
- return {"ok": False, "error": "token store not found", "path": path}
31
- with open(path, "r", encoding="utf-8") as f:
32
- data = json.load(f)
33
- return {"ok": True, "tokens": data, "path": path}
34
- except Exception as e:
35
- return {"ok": False, "error": str(e)}
36
-
37
- @mcp.tool("strava://auth/url")
38
- async def get_auth_url(client_id: int | None = None):
39
- """Return the Strava OAuth authorization URL. If client_id is not provided,
40
- read it from the STRAVA_CLIENT_ID environment variable."""
41
- if client_id is None:
42
- client_id_env = os.getenv("STRAVA_CLIENT_ID")
43
- if not client_id_env:
44
- return {"error": "STRAVA_CLIENT_ID environment variable is not set"}
45
- try:
46
- client_id = int(client_id_env)
47
- except ValueError:
48
- return {"error": "STRAVA_CLIENT_ID must be an integer"}
49
-
50
- params = {
51
- "client_id": client_id,
52
- "response_type": "code",
53
- "redirect_uri": "https://developers.strava.com/oauth2-redirect/",
54
- "approval_prompt": "force",
55
- "scope": "read,activity:read_all",
56
- }
57
- # Always return whole URL and not part of it
58
- return "https://www.strava.com/oauth/authorize?" + urllib.parse.urlencode(params)
59
-
60
- @mcp.tool("strava://auth/refresh")
61
- async def refresh_access_token(
62
-
63
- refresh_token: str,
64
- client_id: int | None = None,
65
- client_secret: str | None = None,
66
- ) -> dict:
67
- """Refresh an access token using a refresh token."""
68
- if not refresh_token:
69
- return {"error": "refresh token is required"}
70
-
71
- if client_id is None:
72
- client_id_env = os.getenv("STRAVA_CLIENT_ID")
73
- if not client_id_env:
74
- return {"error": "STRAVA_CLIENT_ID environment variable is not set"}
75
- try:
76
- client_id = int(client_id_env)
77
- except ValueError:
78
- return {"error": "STRAVA_CLIENT_ID must be an integer"}
79
-
80
- if client_secret is None:
81
- client_secret_env = os.getenv("STRAVA_CLIENT_SECRET")
82
- if not client_secret_env:
83
- return {"error": "STRAVA_CLIENT_SECRET environment variable is not set"}
84
- try:
85
- client_secret = str(client_secret_env)
86
- except ValueError:
87
- return {"error": "STRAVA_CLIENT_SECRET must be a string"}
88
-
89
- resp = requests.post(
90
- "https://www.strava.com/oauth/token",
91
- data={
92
- "client_id": client_id,
93
- "client_secret": client_secret,
94
- "refresh_token": refresh_token,
95
- "grant_type": "refresh_token",
96
- },
97
- )
98
-
99
- try:
100
- resp.raise_for_status()
101
- except requests.HTTPError:
102
- return {"error": "token refresh failed", "status_code": resp.status_code, "response": resp.text}
103
- except Exception as e:
104
- return {"error": "token refresh failed", "status_code": resp.status_code, "response": resp.text, "error": str(e)}
105
-
106
- tokens = resp.json()
107
- print(tokens) # Print tokens for debugging (optional)
108
-
109
- return {
110
- "access_token": tokens.get("access_token"),
111
- "refresh_token": tokens.get("refresh_token"),
112
- "expires_at": tokens.get("expires_at"),
113
- "expires_in": tokens.get("expires_in")
114
- }
115
-
116
- @mcp.tool("strava://athlete/stats")
117
- async def get_athlete_stats(
118
- code: str,
119
- client_id: int | None = None,
120
- client_secret: str | None = None,
121
- after: int | None = None,
122
- before: int | None = None,
123
- page: int | None = None,
124
- per_page: int | None = None,
125
- ) -> dict:
126
- """Exchange an authorization code for access + refresh tokens and get athlete activities with optional filters.
127
-
128
- Args:
129
- code: Authorization code from Strava OAuth
130
- client_id: Strava client ID
131
- client_secret: Strava client secret
132
- after: An epoch timestamp to use for filtering activities that have taken place after a certain time
133
- before: An epoch timestamp to use for filtering activities that have taken place before a certain time
134
- page: The page of activities (default=1)
135
- per_page: How many activities per page (default=30)
136
- """
137
- if not code:
138
- return {"error": "authorization code is required"}
139
-
140
- if client_id is None:
141
- client_id_env = os.getenv("STRAVA_CLIENT_ID")
142
- if not client_id_env:
143
- return {"error": "STRAVA_CLIENT_ID environment variable is not set"}
144
- try:
145
- client_id = int(client_id_env)
146
- except ValueError:
147
- return {"error": "STRAVA_CLIENT_ID must be an integer"}
148
-
149
- if client_secret is None:
150
- client_secret_env = os.getenv("STRAVA_CLIENT_SECRET")
151
- if not client_secret_env:
152
- return {"error": "STRAVA_CLIENT_SECRET environment variable is not set"}
153
- try:
154
- client_secret = str(client_secret_env)
155
- except ValueError:
156
- return {"error": "STRAVA_CLIENT_SECRET must be a string"}
157
-
158
- resp = requests.post(
159
- "https://www.strava.com/oauth/token",
160
- data={
161
- "client_id": client_id,
162
- "client_secret": client_secret,
163
- "code": code,
164
- "grant_type": "authorization_code",
165
- },
166
- )
167
-
168
- try:
169
- resp.raise_for_status()
170
- except requests.HTTPError:
171
- return {"error": "token request failed", "status_code": resp.status_code, "response": resp.text}
172
- except Exception as e:
173
- return {"error": "token request failed", "status_code": resp.status_code, "response": resp.text, "error": str(e)}
174
-
175
- tokens = resp.json()
176
- # Print tokens for debugging (optional)
177
- print(tokens)
178
-
179
- access_token = tokens.get("access_token")
180
- refresh_token = tokens.get("refresh_token")
181
-
182
- # Persist tokens for later refresh usage via the public save tool
183
- save_result = await save_tokens({
184
- "access_token": access_token,
185
- "refresh_token": refresh_token,
186
- "expires_at": tokens.get("expires_at"),
187
- "expires_in": tokens.get("expires_in"),
188
- "athlete": tokens.get("athlete"),
189
- "token_type": tokens.get("token_type"),
190
- "scope": tokens.get("scope"),
191
- })
192
-
193
- # return {"tokens": tokens, "access_token": access_token, "refresh_token": refresh_token}
194
-
195
- # Build URL with query parameters
196
- params = []
197
- if after is not None:
198
- params.append(f"after={after}")
199
- if before is not None:
200
- params.append(f"before={before}")
201
- if page is not None:
202
- params.append(f"page={page}")
203
- if per_page is not None:
204
- params.append(f"per_page={per_page}")
205
-
206
- # Default per_page to 30 if not specified (Strava API default)
207
- if per_page is None:
208
- params.append("per_page=30")
209
-
210
- query_string = "&".join(params) if params else ""
211
- url = f"https://www.strava.com/api/v3/athlete/activities?{query_string}"
212
-
213
- headers = {
214
- "accept": "application/json",
215
- "authorization": f"Bearer {access_token}"
216
- }
217
-
218
- response = requests.get(url, headers=headers)
219
- return {
220
- "activities": response.json(),
221
- "tokens": {
222
- "access_token": access_token,
223
- "refresh_token": refresh_token,
224
- "expires_at": tokens.get("expires_at"),
225
- "expires_in": tokens.get("expires_in"),
226
- },
227
- "save": save_result
228
- }
229
-
230
- @mcp.tool("strava://athlete/stats-with-token")
231
- async def get_athlete_stats_with_token(
232
- access_token: str,
233
- after: int | None = None,
234
- before: int | None = None,
235
- page: int | None = None,
236
- per_page: int | None = None
237
- ) -> dict:
238
- """Get athlete activities using an existing access token with optional filters.
239
-
240
- Args:
241
- access_token: Strava access token
242
- after: An epoch timestamp to use for filtering activities that have taken place after a certain time
243
- before: An epoch timestamp to use for filtering activities that have taken place before a certain time
244
- page: The page of activities (default=1)
245
- per_page: How many activities per page (default=30)
246
- """
247
- if not access_token:
248
- return {"error": "access token is required"}
249
-
250
- # Build URL with query parameters
251
- params = []
252
- if after is not None:
253
- params.append(f"after={after}")
254
- if before is not None:
255
- params.append(f"before={before}")
256
- if page is not None:
257
- params.append(f"page={page}")
258
- if per_page is not None:
259
- params.append(f"per_page={per_page}")
260
-
261
- # Default per_page to 30 if not specified (Strava API default)
262
- if per_page is None:
263
- params.append("per_page=30")
264
-
265
- query_string = "&".join(params) if params else ""
266
- url = f"https://www.strava.com/api/v3/athlete/activities?{query_string}"
267
-
268
- headers = {
269
- "accept": "application/json",
270
- "authorization": f"Bearer {access_token}"
271
- }
272
-
273
- response = requests.get(url, headers=headers)
274
-
275
- try:
276
- response.raise_for_status()
277
- except requests.HTTPError:
278
- return {"error": "API request failed", "status_code": response.status_code, "response": response.text}
279
- except Exception as e:
280
- return {"error": "API request failed", "status_code": response.status_code, "response": response.text, "error": str(e)}
281
-
282
- activities_data = response.json()
283
- return {
284
- activities_data,
285
- "count": len(activities_data) if isinstance(activities_data, list) else 0,
286
- "status": "success"
287
- }
288
-
289
- @mcp.tool("strava://auth/save")
290
- async def save_tokens(tokens: dict | None = None) -> dict:
291
- """Save tokens to local disk at ~/.strava_mcp_tokens.json. If tokens is not provided, no-op with error."""
292
- if not tokens or not isinstance(tokens, dict):
293
- return {"error": "tokens dict is required"}
294
- result = _save_tokens_to_disk(tokens)
295
- if not result.get("ok"):
296
- return {"error": "failed to save tokens", **result}
297
- return {"ok": True, "path": result.get("path")}
298
-
299
-
300
- @mcp.tool("strava://auth/load")
301
- async def load_tokens() -> dict:
302
- """Load tokens from local disk at ~/.strava_mcp_tokens.json"""
303
- result = _load_tokens_from_disk()
304
- if not result.get("ok"):
305
- return {"error": result.get("error"), "path": result.get("path")}
306
- return {"ok": True, "tokens": result.get("tokens"), "path": result.get("path")}
307
-
308
- @mcp.tool("strava://athlete/refresh-and-stats")
309
- async def refresh_and_get_stats(
310
- client_id: int | None = None,
311
- client_secret: str | None = None,
312
- after: int | None = None,
313
- before: int | None = None,
314
- page: int | None = None,
315
- per_page: int | None = None
316
- ) -> dict:
317
- """Load saved refresh token, refresh access token, save it, then fetch activities with optional filters.
318
-
319
- Args:
320
- client_id: Strava client ID
321
- client_secret: Strava client secret
322
- after: An epoch timestamp to use for filtering activities that have taken place after a certain time
323
- before: An epoch timestamp to use for filtering activities that have taken place before a certain time
324
- page: The page of activities (default=1)
325
- per_page: How many activities per page (default=30)
326
- """
327
- load_result = await load_tokens()
328
- if not load_result.get("ok"):
329
- return {"error": "no saved tokens", "details": load_result}
330
- saved = load_result.get("tokens", {})
331
- refresh_token = saved.get("refresh_token")
332
- if not refresh_token:
333
- return {"error": "refresh_token not found in saved tokens"}
334
-
335
- refreshed = await refresh_access_token(refresh_token=refresh_token, client_id=client_id, client_secret=client_secret)
336
- if "error" in refreshed:
337
- return {"error": "refresh failed", "details": refreshed}
338
-
339
- # Save refreshed tokens
340
- await save_tokens(refreshed)
341
-
342
- access_token = refreshed.get("access_token")
343
- if not access_token:
344
- return {"error": "no access_token after refresh"}
345
-
346
- # Fetch activities with new token and filters
347
- activities = await get_athlete_stats_with_token(
348
- access_token=access_token,
349
- after=after,
350
- before=before,
351
- page=page,
352
- per_page=per_page
353
- )
354
- return {activities, "tokens": refreshed}
355
-
356
- @mcp.tool("strava://session/start")
357
- async def start_session(client_id: int | None = None, client_secret: str | None = None) -> dict:
358
- """Start a session: if a refresh token exists, refresh and fetch; otherwise return auth URL."""
359
- token_path = _get_token_store_path()
360
- if os.path.exists(token_path):
361
- loaded = _load_tokens_from_disk()
362
- if loaded.get("ok"):
363
- saved = loaded.get("tokens", {})
364
- refresh_token = saved.get("refresh_token")
365
- if isinstance(refresh_token, str) and refresh_token.strip():
366
- result = await refresh_and_get_stats(client_id=client_id, client_secret=client_secret)
367
- return {**result, "used_token_file": token_path}
368
- # Fall back to auth URL flow
369
- else:
370
- url = await get_auth_url(client_id=client_id)
371
- return {"auth_url": url, "token_file_checked": token_path}
372
-
373
- #@mcp.prompt
374
- #def greet_user_prompt(question: str) -> str:
375
- #"""Generates a message orchestrating mcp tools"""
376
- #return f"""
377
- #Return a message for a user called '{question}'.
378
- #if the user is asking, use a formal style, else use a street style.
379
- #"""
380
-
381
- if __name__ == "__main__":
1
+ import sys
2
+ import os
3
+ from mcp.server.fastmcp import FastMCP # Import FastMCP, the quickstart server base
4
+ mcp = FastMCP("Strava") # Initialize an MCP server instance with a descriptive name
5
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
6
+ import requests
7
+ import urllib.parse
8
+ import json
9
+ from typing import Any, Dict
10
+
11
+ TOKEN_STORE_FILENAME = "strava_mcp_tokens.json"
12
+
13
+ def _get_token_store_path() -> str:
14
+ home_dir = os.path.expanduser("~")
15
+ return os.path.join(home_dir, TOKEN_STORE_FILENAME)
16
+
17
+ def _save_tokens_to_disk(tokens: Dict[str, Any]) -> dict:
18
+ try:
19
+ path = _get_token_store_path()
20
+ with open(path, "w", encoding="utf-8") as f:
21
+ json.dump(tokens, f)
22
+ return {"ok": True, "path": path}
23
+ except Exception as e:
24
+ return {"ok": False, "error": str(e)}
25
+
26
+ def _load_tokens_from_disk() -> dict:
27
+ try:
28
+ path = _get_token_store_path()
29
+ if not os.path.exists(path):
30
+ return {"ok": False, "error": "token store not found", "path": path}
31
+ with open(path, "r", encoding="utf-8") as f:
32
+ data = json.load(f)
33
+ return {"ok": True, "tokens": data, "path": path}
34
+ except Exception as e:
35
+ return {"ok": False, "error": str(e)}
36
+
37
+ @mcp.tool("strava://auth/url")
38
+ async def get_auth_url(client_id: int | None = None):
39
+ """Return the Strava OAuth authorization URL. If client_id is not provided,
40
+ read it from the STRAVA_CLIENT_ID environment variable."""
41
+ if client_id is None:
42
+ client_id_env = os.getenv("STRAVA_CLIENT_ID")
43
+ if not client_id_env:
44
+ return {"error": "STRAVA_CLIENT_ID environment variable is not set"}
45
+ try:
46
+ client_id = int(client_id_env)
47
+ except ValueError:
48
+ return {"error": "STRAVA_CLIENT_ID must be an integer"}
49
+
50
+ params = {
51
+ "client_id": client_id,
52
+ "response_type": "code",
53
+ "redirect_uri": "https://developers.strava.com/oauth2-redirect/",
54
+ "approval_prompt": "force",
55
+ "scope": "read,activity:read_all",
56
+ }
57
+ # Always return whole URL and not part of it
58
+ return "https://www.strava.com/oauth/authorize?" + urllib.parse.urlencode(params)
59
+
60
+ @mcp.tool("strava://auth/refresh")
61
+ async def refresh_access_token(
62
+
63
+ refresh_token: str,
64
+ client_id: int | None = None,
65
+ client_secret: str | None = None,
66
+ ) -> dict:
67
+ """Refresh an access token using a refresh token."""
68
+ if not refresh_token:
69
+ return {"error": "refresh token is required"}
70
+
71
+ if client_id is None:
72
+ client_id_env = os.getenv("STRAVA_CLIENT_ID")
73
+ if not client_id_env:
74
+ return {"error": "STRAVA_CLIENT_ID environment variable is not set"}
75
+ try:
76
+ client_id = int(client_id_env)
77
+ except ValueError:
78
+ return {"error": "STRAVA_CLIENT_ID must be an integer"}
79
+
80
+ if client_secret is None:
81
+ client_secret_env = os.getenv("STRAVA_CLIENT_SECRET")
82
+ if not client_secret_env:
83
+ return {"error": "STRAVA_CLIENT_SECRET environment variable is not set"}
84
+ try:
85
+ client_secret = str(client_secret_env)
86
+ except ValueError:
87
+ return {"error": "STRAVA_CLIENT_SECRET must be a string"}
88
+
89
+ resp = requests.post(
90
+ "https://www.strava.com/oauth/token",
91
+ data={
92
+ "client_id": client_id,
93
+ "client_secret": client_secret,
94
+ "refresh_token": refresh_token,
95
+ "grant_type": "refresh_token",
96
+ },
97
+ )
98
+
99
+ try:
100
+ resp.raise_for_status()
101
+ except requests.HTTPError:
102
+ return {"error": "token refresh failed", "status_code": resp.status_code, "response": resp.text}
103
+ except Exception as e:
104
+ return {"error": "token refresh failed", "status_code": resp.status_code, "response": resp.text, "error": str(e)}
105
+
106
+ tokens = resp.json()
107
+ print(tokens) # Print tokens for debugging (optional)
108
+
109
+ return {
110
+ "access_token": tokens.get("access_token"),
111
+ "refresh_token": tokens.get("refresh_token"),
112
+ "expires_at": tokens.get("expires_at"),
113
+ "expires_in": tokens.get("expires_in")
114
+ }
115
+
116
+ @mcp.tool("strava://athlete/stats")
117
+ async def get_athlete_stats(
118
+ code: str,
119
+ client_id: int | None = None,
120
+ client_secret: str | None = None,
121
+ after: int | None = None,
122
+ before: int | None = None,
123
+ page: int | None = None,
124
+ per_page: int | None = None,
125
+ ) -> dict:
126
+ """Exchange an authorization code for access + refresh tokens and get athlete activities with optional filters.
127
+
128
+ Args:
129
+ code: Authorization code from Strava OAuth
130
+ client_id: Strava client ID
131
+ client_secret: Strava client secret
132
+ after: An epoch timestamp to use for filtering activities that have taken place after a certain time
133
+ before: An epoch timestamp to use for filtering activities that have taken place before a certain time
134
+ page: The page of activities (default=1)
135
+ per_page: How many activities per page (default=30)
136
+ """
137
+ if not code:
138
+ return {"error": "authorization code is required"}
139
+
140
+ if client_id is None:
141
+ client_id_env = os.getenv("STRAVA_CLIENT_ID")
142
+ if not client_id_env:
143
+ return {"error": "STRAVA_CLIENT_ID environment variable is not set"}
144
+ try:
145
+ client_id = int(client_id_env)
146
+ except ValueError:
147
+ return {"error": "STRAVA_CLIENT_ID must be an integer"}
148
+
149
+ if client_secret is None:
150
+ client_secret_env = os.getenv("STRAVA_CLIENT_SECRET")
151
+ if not client_secret_env:
152
+ return {"error": "STRAVA_CLIENT_SECRET environment variable is not set"}
153
+ try:
154
+ client_secret = str(client_secret_env)
155
+ except ValueError:
156
+ return {"error": "STRAVA_CLIENT_SECRET must be a string"}
157
+
158
+ resp = requests.post(
159
+ "https://www.strava.com/oauth/token",
160
+ data={
161
+ "client_id": client_id,
162
+ "client_secret": client_secret,
163
+ "code": code,
164
+ "grant_type": "authorization_code",
165
+ },
166
+ )
167
+
168
+ try:
169
+ resp.raise_for_status()
170
+ except requests.HTTPError:
171
+ return {"error": "token request failed", "status_code": resp.status_code, "response": resp.text}
172
+ except Exception as e:
173
+ return {"error": "token request failed", "status_code": resp.status_code, "response": resp.text, "error": str(e)}
174
+
175
+ tokens = resp.json()
176
+ # Print tokens for debugging (optional)
177
+ print(tokens)
178
+
179
+ access_token = tokens.get("access_token")
180
+ refresh_token = tokens.get("refresh_token")
181
+
182
+ # Persist tokens for later refresh usage via the public save tool
183
+ save_result = await save_tokens({
184
+ "access_token": access_token,
185
+ "refresh_token": refresh_token,
186
+ "expires_at": tokens.get("expires_at"),
187
+ "expires_in": tokens.get("expires_in"),
188
+ "athlete": tokens.get("athlete"),
189
+ "token_type": tokens.get("token_type"),
190
+ "scope": tokens.get("scope"),
191
+ })
192
+
193
+ # return {"tokens": tokens, "access_token": access_token, "refresh_token": refresh_token}
194
+
195
+ # Build URL with query parameters
196
+ params = []
197
+ if after is not None:
198
+ params.append(f"after={after}")
199
+ if before is not None:
200
+ params.append(f"before={before}")
201
+ if page is not None:
202
+ params.append(f"page={page}")
203
+ if per_page is not None:
204
+ params.append(f"per_page={per_page}")
205
+
206
+ # Default per_page to 30 if not specified (Strava API default)
207
+ if per_page is None:
208
+ params.append("per_page=30")
209
+
210
+ query_string = "&".join(params) if params else ""
211
+ url = f"https://www.strava.com/api/v3/athlete/activities?{query_string}"
212
+
213
+ # Debug output
214
+ print(f"DEBUG: Requesting URL: {url}")
215
+
216
+ headers = {
217
+ "accept": "application/json",
218
+ "authorization": f"Bearer {access_token}"
219
+ }
220
+
221
+ response = requests.get(url, headers=headers)
222
+ activities_data = response.json()
223
+ print(f"DEBUG: Response status: {response.status_code}, Activities count: {len(activities_data) if isinstance(activities_data, list) else 0}")
224
+
225
+ return {
226
+ "activities": activities_data,
227
+ "tokens": {
228
+ "access_token": access_token,
229
+ "refresh_token": refresh_token,
230
+ "expires_at": tokens.get("expires_at"),
231
+ "expires_in": tokens.get("expires_in"),
232
+ },
233
+ "save": save_result
234
+ }
235
+
236
+ @mcp.tool("strava://athlete/stats-with-token")
237
+ async def get_athlete_stats_with_token(
238
+ access_token: str,
239
+ after: int | None = None,
240
+ before: int | None = None,
241
+ page: int | None = None,
242
+ per_page: int | None = None
243
+ ) -> dict:
244
+ """Get athlete activities using an existing access token with optional filters.
245
+
246
+ Args:
247
+ access_token: Strava access token
248
+ after: An epoch timestamp to use for filtering activities that have taken place after a certain time
249
+ before: An epoch timestamp to use for filtering activities that have taken place before a certain time
250
+ page: The page of activities (default=1)
251
+ per_page: How many activities per page (default=30)
252
+ """
253
+ if not access_token:
254
+ return {"error": "access token is required"}
255
+
256
+ # Build URL with query parameters
257
+ params = []
258
+ if after is not None:
259
+ params.append(f"after={after}")
260
+ if before is not None:
261
+ params.append(f"before={before}")
262
+ if page is not None:
263
+ params.append(f"page={page}")
264
+ if per_page is not None:
265
+ params.append(f"per_page={per_page}")
266
+
267
+ # Default per_page to 30 if not specified (Strava API default)
268
+ if per_page is None:
269
+ params.append("per_page=30")
270
+
271
+ query_string = "&".join(params) if params else ""
272
+ url = f"https://www.strava.com/api/v3/athlete/activities?{query_string}"
273
+
274
+ # Debug output
275
+ print(f"DEBUG: Requesting URL: {url}")
276
+
277
+ headers = {
278
+ "accept": "application/json",
279
+ "authorization": f"Bearer {access_token}"
280
+ }
281
+
282
+ response = requests.get(url, headers=headers)
283
+
284
+ try:
285
+ response.raise_for_status()
286
+ except requests.HTTPError:
287
+ return {"error": "API request failed", "status_code": response.status_code, "response": response.text}
288
+ except Exception as e:
289
+ return {"error": "API request failed", "status_code": response.status_code, "response": response.text, "error": str(e)}
290
+
291
+ activities_data = response.json()
292
+ print(f"DEBUG: Response status: {response.status_code}, Activities count: {len(activities_data) if isinstance(activities_data, list) else 0}")
293
+ return {
294
+ "activities": activities_data,
295
+ "count": len(activities_data) if isinstance(activities_data, list) else 0,
296
+ "status": "success"
297
+ }
298
+
299
+ @mcp.tool("strava://auth/save")
300
+ async def save_tokens(tokens: dict | None = None) -> dict:
301
+ """Save tokens to local disk at ~/.strava_mcp_tokens.json. If tokens is not provided, no-op with error."""
302
+ if not tokens or not isinstance(tokens, dict):
303
+ return {"error": "tokens dict is required"}
304
+ result = _save_tokens_to_disk(tokens)
305
+ if not result.get("ok"):
306
+ return {"error": "failed to save tokens", **result}
307
+ return {"ok": True, "path": result.get("path")}
308
+
309
+
310
+ @mcp.tool("strava://auth/load")
311
+ async def load_tokens() -> dict:
312
+ """Load tokens from local disk at ~/.strava_mcp_tokens.json"""
313
+ result = _load_tokens_from_disk()
314
+ if not result.get("ok"):
315
+ return {"error": result.get("error"), "path": result.get("path")}
316
+ return {"ok": True, "tokens": result.get("tokens"), "path": result.get("path")}
317
+
318
+ @mcp.tool("strava://athlete/refresh-and-stats")
319
+ async def refresh_and_get_stats(
320
+ client_id: int | None = None,
321
+ client_secret: str | None = None,
322
+ after: int | None = None,
323
+ before: int | None = None,
324
+ page: int | None = None,
325
+ per_page: int | None = None
326
+ ) -> dict:
327
+ """Load saved refresh token, refresh access token, save it, then fetch activities with optional filters.
328
+
329
+ Args:
330
+ client_id: Strava client ID
331
+ client_secret: Strava client secret
332
+ after: An epoch timestamp to use for filtering activities that have taken place after a certain time
333
+ before: An epoch timestamp to use for filtering activities that have taken place before a certain time
334
+ page: The page of activities (default=1)
335
+ per_page: How many activities per page (default=30)
336
+ """
337
+ load_result = await load_tokens()
338
+ if not load_result.get("ok"):
339
+ return {"error": "no saved tokens", "details": load_result}
340
+ saved = load_result.get("tokens", {})
341
+ refresh_token = saved.get("refresh_token")
342
+ if not refresh_token:
343
+ return {"error": "refresh_token not found in saved tokens"}
344
+
345
+ refreshed = await refresh_access_token(refresh_token=refresh_token, client_id=client_id, client_secret=client_secret)
346
+ if "error" in refreshed:
347
+ return {"error": "refresh failed", "details": refreshed}
348
+
349
+ # Save refreshed tokens
350
+ await save_tokens(refreshed)
351
+
352
+ access_token = refreshed.get("access_token")
353
+ if not access_token:
354
+ return {"error": "no access_token after refresh"}
355
+
356
+ # Fetch activities with new token and filters
357
+ activities = await get_athlete_stats_with_token(
358
+ access_token=access_token,
359
+ after=after,
360
+ before=before,
361
+ page=page,
362
+ per_page=per_page
363
+ )
364
+ return {"activities": activities, "tokens": refreshed}
365
+
366
+ @mcp.tool("strava://session/start")
367
+ async def start_session(
368
+ client_id: int | None = None,
369
+ client_secret: str | None = None,
370
+ after: int | None = None,
371
+ before: int | None = None,
372
+ page: int | None = None,
373
+ per_page: int | None = None
374
+ ) -> dict:
375
+ """Start a session: if a refresh token exists, refresh and fetch; otherwise return auth URL.
376
+
377
+ Args:
378
+ client_id: Strava client ID
379
+ client_secret: Strava client secret
380
+ after: An epoch timestamp to use for filtering activities that have taken place after a certain time
381
+ before: An epoch timestamp to use for filtering activities that have taken place before a certain time
382
+ page: The page of activities (default=1)
383
+ per_page: How many activities per page (default=30)
384
+ """
385
+ token_path = _get_token_store_path()
386
+ if os.path.exists(token_path):
387
+ loaded = _load_tokens_from_disk()
388
+ if loaded.get("ok"):
389
+ saved = loaded.get("tokens", {})
390
+ refresh_token = saved.get("refresh_token")
391
+ if isinstance(refresh_token, str) and refresh_token.strip():
392
+ result = await refresh_and_get_stats(
393
+ client_id=client_id,
394
+ client_secret=client_secret,
395
+ after=after,
396
+ before=before,
397
+ page=page,
398
+ per_page=per_page
399
+ )
400
+ return {**result, "used_token_file": token_path}
401
+ # Fall back to auth URL flow
402
+ else:
403
+ url = await get_auth_url(client_id=client_id)
404
+ return {"auth_url": url, "token_file_checked": token_path}
405
+
406
+ #@mcp.prompt
407
+ #def greet_user_prompt(question: str) -> str:
408
+ #"""Generates a message orchestrating mcp tools"""
409
+ #return f"""
410
+ #Return a message for a user called '{question}'.
411
+ #if the user is asking, use a formal style, else use a street style.
412
+ #"""
413
+
414
+ if __name__ == "__main__":
382
415
  mcp.run(transport="stdio") # Run the server, using standard input/output for communication
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strava-activity-mcp-server
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: Strava MCP server: one-time browser auth, then automatic refresh-token login
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -0,0 +1,8 @@
1
+ strava_activity_mcp_server/__init__.py,sha256=NgXC8CeBg0qFRHdZJJKjQlX9_RwSETJ9O6PNy0leOTI,107
2
+ strava_activity_mcp_server/__main__.py,sha256=SAdVoObdjb5UP4MY-Y2_uCXpnthB6hgxlb1KNVNgOrc,58
3
+ strava_activity_mcp_server/strava_activity_mcp_server.py,sha256=6Ib6jABSRJHkWGSBZMbht1FwpUjlob2REXuBGlaLp3E,15516
4
+ strava_activity_mcp_server-0.2.7.dist-info/METADATA,sha256=kiw0VX3T2b1K48oJ1MUZn6F58D7SkewCXZCosKB90pI,7665
5
+ strava_activity_mcp_server-0.2.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
6
+ strava_activity_mcp_server-0.2.7.dist-info/entry_points.txt,sha256=F6PO_DBSThhtmX2AC-tu2MIiCJkGi31LCaQJxfUzZ5g,79
7
+ strava_activity_mcp_server-0.2.7.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
8
+ strava_activity_mcp_server-0.2.7.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- strava_activity_mcp_server/__init__.py,sha256=NgXC8CeBg0qFRHdZJJKjQlX9_RwSETJ9O6PNy0leOTI,107
2
- strava_activity_mcp_server/__main__.py,sha256=SAdVoObdjb5UP4MY-Y2_uCXpnthB6hgxlb1KNVNgOrc,58
3
- strava_activity_mcp_server/strava_activity_mcp_server.py,sha256=okLyst5OnoFFPzMiJwSAHFFyhcoIGNovBmxrUKt9gMc,14638
4
- strava_activity_mcp_server-0.2.5.dist-info/METADATA,sha256=Btycrm8GjW7S8VIKcNHQ_v2nHaEMFvJudaynisePIGA,7665
5
- strava_activity_mcp_server-0.2.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
6
- strava_activity_mcp_server-0.2.5.dist-info/entry_points.txt,sha256=F6PO_DBSThhtmX2AC-tu2MIiCJkGi31LCaQJxfUzZ5g,79
7
- strava_activity_mcp_server-0.2.5.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
8
- strava_activity_mcp_server-0.2.5.dist-info/RECORD,,