strava-activity-mcp-server 0.2.6__tar.gz → 0.2.8__tar.gz

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.

Files changed (22) hide show
  1. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/PKG-INFO +1 -1
  2. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/pyproject.toml +21 -21
  3. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/src/strava_activity_mcp_server/strava_activity_mcp_server.py +516 -381
  4. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/uv.lock +1 -1
  5. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/.github/workflows/python-publish.yml +0 -0
  6. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/.gitignore +0 -0
  7. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/.python-version +0 -0
  8. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/.vscode/settings.json +0 -0
  9. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/LICENSE +0 -0
  10. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/README.md +0 -0
  11. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/ref/auth.jpg +0 -0
  12. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/ref/chat_1.jpg +0 -0
  13. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/ref/chat_2.jpg +0 -0
  14. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/ref/chat_3.jpg +0 -0
  15. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/ref/chat_4.jpg +0 -0
  16. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/ref/code.jpg +0 -0
  17. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/ref/image.jpg +0 -0
  18. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/ref/mcp_pypi_example.md +0 -0
  19. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/ref/prompts.md +0 -0
  20. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/requirements.txt +0 -0
  21. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/src/strava_activity_mcp_server/__init__.py +0 -0
  22. {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.8}/src/strava_activity_mcp_server/__main__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strava-activity-mcp-server
3
- Version: 0.2.6
3
+ Version: 0.2.8
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
@@ -1,21 +1,21 @@
1
- [project]
2
- name = "strava-activity-mcp-server"
3
- version = "0.2.6"
4
- description = "Strava MCP server: one-time browser auth, then automatic refresh-token login"
5
- readme = "README.md"
6
- requires-python = ">=3.10"
7
- dependencies = [
8
- "build>=1.3.0",
9
- "mcp[cli]>=1.16.0",
10
- "twine>=6.2.0",
11
- ]
12
-
13
- [project.scripts]
14
- strava-activity-mcp-server = "strava_activity_mcp_server:main"
15
-
16
- [build-system]
17
- requires = ["hatchling"]
18
- build-backend = "hatchling.build"
19
-
20
- [tool.hatch.build.targets.wheel]
21
- packages = ["src/strava_activity_mcp_server"]
1
+ [project]
2
+ name = "strava-activity-mcp-server"
3
+ version = "0.2.8"
4
+ description = "Strava MCP server: one-time browser auth, then automatic refresh-token login"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "build>=1.3.0",
9
+ "mcp[cli]>=1.16.0",
10
+ "twine>=6.2.0",
11
+ ]
12
+
13
+ [project.scripts]
14
+ strava-activity-mcp-server = "strava_activity_mcp_server:main"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/strava_activity_mcp_server"]
@@ -1,382 +1,517 @@
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": 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": 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
+ headers = {
275
+ "accept": "application/json",
276
+ "authorization": f"Bearer {access_token}"
277
+ }
278
+
279
+ try:
280
+ response = requests.get(url, headers=headers)
281
+
282
+ # Include debug information in the response
283
+ debug_info = {
284
+ "request_url": url,
285
+ "response_status": response.status_code,
286
+ "response_headers": dict(response.headers),
287
+ "filters_applied": {
288
+ "after": after,
289
+ "before": before,
290
+ "page": page,
291
+ "per_page": per_page
292
+ }
293
+ }
294
+
295
+ response.raise_for_status()
296
+
297
+ activities_data = response.json()
298
+
299
+ return {
300
+ "activities": activities_data,
301
+ "count": len(activities_data) if isinstance(activities_data, list) else 0,
302
+ "status": "success",
303
+ "debug": debug_info
304
+ }
305
+
306
+ except requests.HTTPError as e:
307
+ return {
308
+ "error": "API request failed",
309
+ "status_code": response.status_code,
310
+ "response": response.text,
311
+ "debug": {
312
+ "request_url": url,
313
+ "response_status": response.status_code,
314
+ "response_headers": dict(response.headers),
315
+ "filters_applied": {
316
+ "after": after,
317
+ "before": before,
318
+ "page": page,
319
+ "per_page": per_page
320
+ }
321
+ }
322
+ }
323
+ except Exception as e:
324
+ return {
325
+ "error": "API request failed",
326
+ "error_message": str(e),
327
+ "debug": {
328
+ "request_url": url,
329
+ "filters_applied": {
330
+ "after": after,
331
+ "before": before,
332
+ "page": page,
333
+ "per_page": per_page
334
+ }
335
+ }
336
+ }
337
+
338
+ @mcp.tool("strava://debug/test-connection")
339
+ async def test_strava_connection(access_token: str) -> dict:
340
+ """Test the Strava API connection and token validity with debug information."""
341
+ if not access_token:
342
+ return {"error": "access token is required"}
343
+
344
+ # Test 1: Simple request without filters
345
+ url_no_filters = "https://www.strava.com/api/v3/athlete/activities?per_page=5"
346
+ headers = {
347
+ "accept": "application/json",
348
+ "authorization": f"Bearer {access_token}"
349
+ }
350
+
351
+ try:
352
+ response = requests.get(url_no_filters, headers=headers)
353
+
354
+ debug_info = {
355
+ "test_url": url_no_filters,
356
+ "response_status": response.status_code,
357
+ "response_headers": dict(response.headers),
358
+ "content_length": len(response.content) if response.content else 0
359
+ }
360
+
361
+ if response.status_code == 200:
362
+ activities_data = response.json()
363
+ debug_info["activities_count"] = len(activities_data) if isinstance(activities_data, list) else 0
364
+ debug_info["sample_activity"] = activities_data[0] if activities_data and len(activities_data) > 0 else None
365
+
366
+ return {
367
+ "status": "success",
368
+ "message": "Connection successful",
369
+ "activities": activities_data,
370
+ "debug": debug_info
371
+ }
372
+ else:
373
+ debug_info["response_text"] = response.text
374
+ return {
375
+ "status": "error",
376
+ "message": f"API returned status {response.status_code}",
377
+ "debug": debug_info
378
+ }
379
+
380
+ except Exception as e:
381
+ return {
382
+ "status": "error",
383
+ "message": f"Request failed: {str(e)}",
384
+ "debug": {
385
+ "test_url": url_no_filters,
386
+ "error": str(e)
387
+ }
388
+ }
389
+
390
+ @mcp.tool("strava://auth/save")
391
+ async def save_tokens(tokens: dict | None = None) -> dict:
392
+ """Save tokens to local disk at ~/.strava_mcp_tokens.json. If tokens is not provided, no-op with error."""
393
+ if not tokens or not isinstance(tokens, dict):
394
+ return {"error": "tokens dict is required"}
395
+ result = _save_tokens_to_disk(tokens)
396
+ if not result.get("ok"):
397
+ return {"error": "failed to save tokens", **result}
398
+ return {"ok": True, "path": result.get("path")}
399
+
400
+
401
+ @mcp.tool("strava://auth/load")
402
+ async def load_tokens() -> dict:
403
+ """Load tokens from local disk at ~/.strava_mcp_tokens.json"""
404
+ result = _load_tokens_from_disk()
405
+ if not result.get("ok"):
406
+ return {"error": result.get("error"), "path": result.get("path")}
407
+ return {"ok": True, "tokens": result.get("tokens"), "path": result.get("path")}
408
+
409
+ @mcp.tool("strava://athlete/refresh-and-stats")
410
+ async def refresh_and_get_stats(
411
+ client_id: int | None = None,
412
+ client_secret: str | None = None,
413
+ after: int | None = None,
414
+ before: int | None = None,
415
+ page: int | None = None,
416
+ per_page: int | None = None
417
+ ) -> dict:
418
+ """Load saved refresh token, refresh access token, save it, then fetch activities with optional filters.
419
+
420
+ Args:
421
+ client_id: Strava client ID
422
+ client_secret: Strava client secret
423
+ after: An epoch timestamp to use for filtering activities that have taken place after a certain time
424
+ before: An epoch timestamp to use for filtering activities that have taken place before a certain time
425
+ page: The page of activities (default=1)
426
+ per_page: How many activities per page (default=30)
427
+ """
428
+ load_result = await load_tokens()
429
+ if not load_result.get("ok"):
430
+ return {"error": "no saved tokens", "details": load_result}
431
+ saved = load_result.get("tokens", {})
432
+ refresh_token = saved.get("refresh_token")
433
+ if not refresh_token:
434
+ return {"error": "refresh_token not found in saved tokens"}
435
+
436
+ refreshed = await refresh_access_token(refresh_token=refresh_token, client_id=client_id, client_secret=client_secret)
437
+ if "error" in refreshed:
438
+ return {"error": "refresh failed", "details": refreshed}
439
+
440
+ # Save refreshed tokens
441
+ await save_tokens(refreshed)
442
+
443
+ access_token = refreshed.get("access_token")
444
+ if not access_token:
445
+ return {"error": "no access_token after refresh"}
446
+
447
+ # Fetch activities with new token and filters
448
+ activities = await get_athlete_stats_with_token(
449
+ access_token=access_token,
450
+ after=after,
451
+ before=before,
452
+ page=page,
453
+ per_page=per_page
454
+ )
455
+ return {
456
+ "activities": activities,
457
+ "tokens": refreshed,
458
+ "debug": {
459
+ "filters_applied": {
460
+ "after": after,
461
+ "before": before,
462
+ "page": page,
463
+ "per_page": per_page
464
+ }
465
+ }
466
+ }
467
+
468
+ @mcp.tool("strava://session/start")
469
+ async def start_session(
470
+ client_id: int | None = None,
471
+ client_secret: str | None = None,
472
+ after: int | None = None,
473
+ before: int | None = None,
474
+ page: int | None = None,
475
+ per_page: int | None = None
476
+ ) -> dict:
477
+ """Start a session: if a refresh token exists, refresh and fetch; otherwise return auth URL.
478
+
479
+ Args:
480
+ client_id: Strava client ID
481
+ client_secret: Strava client secret
482
+ after: An epoch timestamp to use for filtering activities that have taken place after a certain time
483
+ before: An epoch timestamp to use for filtering activities that have taken place before a certain time
484
+ page: The page of activities (default=1)
485
+ per_page: How many activities per page (default=30)
486
+ """
487
+ token_path = _get_token_store_path()
488
+ if os.path.exists(token_path):
489
+ loaded = _load_tokens_from_disk()
490
+ if loaded.get("ok"):
491
+ saved = loaded.get("tokens", {})
492
+ refresh_token = saved.get("refresh_token")
493
+ if isinstance(refresh_token, str) and refresh_token.strip():
494
+ result = await refresh_and_get_stats(
495
+ client_id=client_id,
496
+ client_secret=client_secret,
497
+ after=after,
498
+ before=before,
499
+ page=page,
500
+ per_page=per_page
501
+ )
502
+ return {**result, "used_token_file": token_path}
503
+ # Fall back to auth URL flow
504
+ else:
505
+ url = await get_auth_url(client_id=client_id)
506
+ return {"auth_url": url, "token_file_checked": token_path}
507
+
508
+ #@mcp.prompt
509
+ #def greet_user_prompt(question: str) -> str:
510
+ #"""Generates a message orchestrating mcp tools"""
511
+ #return f"""
512
+ #Return a message for a user called '{question}'.
513
+ #if the user is asking, use a formal style, else use a street style.
514
+ #"""
515
+
516
+ if __name__ == "__main__":
382
517
  mcp.run(transport="stdio") # Run the server, using standard input/output for communication
@@ -1031,7 +1031,7 @@ wheels = [
1031
1031
 
1032
1032
  [[package]]
1033
1033
  name = "strava-activity-mcp-server"
1034
- version = "0.2.6"
1034
+ version = "0.2.8"
1035
1035
  source = { editable = "." }
1036
1036
  dependencies = [
1037
1037
  { name = "build" },