strava-activity-mcp-server 0.2.6__tar.gz → 0.2.7__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.
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/PKG-INFO +1 -1
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/pyproject.toml +1 -1
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/src/strava_activity_mcp_server/strava_activity_mcp_server.py +414 -381
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/uv.lock +1 -1
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/.github/workflows/python-publish.yml +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/.gitignore +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/.python-version +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/.vscode/settings.json +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/LICENSE +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/README.md +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/ref/auth.jpg +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/ref/chat_1.jpg +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/ref/chat_2.jpg +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/ref/chat_3.jpg +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/ref/chat_4.jpg +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/ref/code.jpg +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/ref/image.jpg +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/ref/mcp_pypi_example.md +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/ref/prompts.md +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/requirements.txt +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/src/strava_activity_mcp_server/__init__.py +0 -0
- {strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/src/strava_activity_mcp_server/__main__.py +0 -0
|
@@ -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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
"
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if
|
|
259
|
-
params.append(f"
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if
|
|
263
|
-
params.append("
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
"
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
)
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{strava_activity_mcp_server-0.2.6 → strava_activity_mcp_server-0.2.7}/ref/mcp_pypi_example.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|