strava-activity-mcp-server 0.1.3__tar.gz → 0.1.4__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.
- {strava_activity_mcp_server-0.1.3 → strava_activity_mcp_server-0.1.4}/PKG-INFO +1 -1
- {strava_activity_mcp_server-0.1.3 → strava_activity_mcp_server-0.1.4}/pyproject.toml +1 -1
- strava_activity_mcp_server-0.1.4/src/strava_activity_mcp_server/strava_activity_mcp_server.py +206 -0
- {strava_activity_mcp_server-0.1.3 → strava_activity_mcp_server-0.1.4}/uv.lock +1 -1
- strava_activity_mcp_server-0.1.3/src/strava_activity_mcp_server/strava_activity_mcp_server.py +0 -113
- {strava_activity_mcp_server-0.1.3 → strava_activity_mcp_server-0.1.4}/.gitignore +0 -0
- {strava_activity_mcp_server-0.1.3 → strava_activity_mcp_server-0.1.4}/.python-version +0 -0
- {strava_activity_mcp_server-0.1.3 → strava_activity_mcp_server-0.1.4}/.vscode/settings.json +0 -0
- {strava_activity_mcp_server-0.1.3 → strava_activity_mcp_server-0.1.4}/LICENSE +0 -0
- {strava_activity_mcp_server-0.1.3 → strava_activity_mcp_server-0.1.4}/README.md +0 -0
- {strava_activity_mcp_server-0.1.3 → strava_activity_mcp_server-0.1.4}/ref/mcp_pypi_example.md +0 -0
- {strava_activity_mcp_server-0.1.3 → strava_activity_mcp_server-0.1.4}/requirements.txt +0 -0
- {strava_activity_mcp_server-0.1.3 → strava_activity_mcp_server-0.1.4}/src/strava_activity_mcp_server/__init__.py +0 -0
- {strava_activity_mcp_server-0.1.3 → strava_activity_mcp_server-0.1.4}/src/strava_activity_mcp_server/__main__.py +0 -0
@@ -0,0 +1,206 @@
|
|
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
|
+
|
9
|
+
@mcp.tool("strava://auth/url")
|
10
|
+
|
11
|
+
def get_auth_url(client_id: int | None = None):
|
12
|
+
"""Return the Strava OAuth authorization URL. If client_id is not provided,
|
13
|
+
read it from the STRAVA_CLIENT_ID environment variable."""
|
14
|
+
if client_id is None:
|
15
|
+
client_id_env = os.getenv("STRAVA_CLIENT_ID")
|
16
|
+
if not client_id_env:
|
17
|
+
return {"error": "STRAVA_CLIENT_ID environment variable is not set"}
|
18
|
+
try:
|
19
|
+
client_id = int(client_id_env)
|
20
|
+
except ValueError:
|
21
|
+
return {"error": "STRAVA_CLIENT_ID must be an integer"}
|
22
|
+
|
23
|
+
params = {
|
24
|
+
"client_id": client_id,
|
25
|
+
"response_type": "code",
|
26
|
+
"redirect_uri": "https://developers.strava.com/oauth2-redirect/",
|
27
|
+
"approval_prompt": "force",
|
28
|
+
"scope": "read,activity:read_all",
|
29
|
+
}
|
30
|
+
return "https://www.strava.com/oauth/authorize?" + urllib.parse.urlencode(params)
|
31
|
+
|
32
|
+
|
33
|
+
|
34
|
+
@mcp.tool("strava://auth/token")
|
35
|
+
def exchange_code_for_token(
|
36
|
+
code: str,
|
37
|
+
client_id: int,
|
38
|
+
client_secret: str,
|
39
|
+
) -> dict:
|
40
|
+
"""Exchange an authorization code for access + refresh tokens."""
|
41
|
+
if not code:
|
42
|
+
return {"error": "authorization code is required"}
|
43
|
+
if not client_secret:
|
44
|
+
return {"error": "client_secret is required"}
|
45
|
+
|
46
|
+
resp = requests.post(
|
47
|
+
"https://www.strava.com/oauth/token",
|
48
|
+
data={
|
49
|
+
"client_id": client_id,
|
50
|
+
"client_secret": client_secret,
|
51
|
+
"code": code,
|
52
|
+
"grant_type": "authorization_code",
|
53
|
+
},
|
54
|
+
)
|
55
|
+
try:
|
56
|
+
resp.raise_for_status()
|
57
|
+
except requests.HTTPError:
|
58
|
+
return {"error": "token request failed", "status_code": resp.status_code, "response": resp.text}
|
59
|
+
|
60
|
+
tokens = resp.json()
|
61
|
+
# Print tokens for debugging (optional)
|
62
|
+
print(tokens)
|
63
|
+
|
64
|
+
access_token = tokens.get("access_token")
|
65
|
+
refresh_token = tokens.get("refresh_token")
|
66
|
+
|
67
|
+
return {"tokens": tokens, "access_token": access_token, "refresh_token": refresh_token}
|
68
|
+
|
69
|
+
|
70
|
+
def refresh_access_token(
|
71
|
+
refresh_token: str,
|
72
|
+
client_id: int,
|
73
|
+
client_secret: str,
|
74
|
+
) -> dict:
|
75
|
+
"""Refresh an access token using a refresh token."""
|
76
|
+
if not refresh_token:
|
77
|
+
return {"error": "refresh_token is required"}
|
78
|
+
|
79
|
+
resp = requests.post(
|
80
|
+
"https://www.strava.com/oauth/token",
|
81
|
+
data={
|
82
|
+
"client_id": client_id,
|
83
|
+
"client_secret": client_secret,
|
84
|
+
"grant_type": "refresh_token",
|
85
|
+
"refresh_token": refresh_token,
|
86
|
+
},
|
87
|
+
)
|
88
|
+
try:
|
89
|
+
resp.raise_for_status()
|
90
|
+
except requests.HTTPError:
|
91
|
+
return {"error": "refresh request failed", "status_code": resp.status_code, "response": resp.text}
|
92
|
+
|
93
|
+
new_tokens = resp.json()
|
94
|
+
# Print new tokens for debugging (optional)
|
95
|
+
print(new_tokens)
|
96
|
+
return new_tokens
|
97
|
+
|
98
|
+
|
99
|
+
@mcp.tool("strava://athlete/stats")
|
100
|
+
def _get_env_client_credentials() -> tuple[int | None, str | None]:
|
101
|
+
"""Read client id and secret from environment and return (client_id, client_secret).
|
102
|
+
|
103
|
+
client_id will be returned as int if present and valid, otherwise None.
|
104
|
+
"""
|
105
|
+
client_id = None
|
106
|
+
client_secret = os.getenv("STRAVA_CLIENT_SECRET")
|
107
|
+
client_id_env = os.getenv("STRAVA_CLIENT_ID")
|
108
|
+
if client_id_env:
|
109
|
+
try:
|
110
|
+
client_id = int(client_id_env)
|
111
|
+
except ValueError:
|
112
|
+
client_id = None
|
113
|
+
return client_id, client_secret
|
114
|
+
|
115
|
+
|
116
|
+
def _ensure_access_token(token_or_tokens: object) -> tuple[str | None, dict | None]:
|
117
|
+
"""Given either an access token string or the token dict returned by the token endpoints,
|
118
|
+
return a tuple (access_token, tokens_dict).
|
119
|
+
|
120
|
+
If a dict is provided and contains no valid access_token but has a refresh_token,
|
121
|
+
attempt to refresh using env client credentials. Returns (access_token, tokens_dict) or (None, None)
|
122
|
+
on failure.
|
123
|
+
"""
|
124
|
+
# If token_or_tokens is a string, assume it's an access token.
|
125
|
+
if isinstance(token_or_tokens, str):
|
126
|
+
return token_or_tokens, None
|
127
|
+
|
128
|
+
# If it's a dict-like object, try to find access_token
|
129
|
+
if isinstance(token_or_tokens, dict):
|
130
|
+
access_token = token_or_tokens.get("access_token")
|
131
|
+
if access_token:
|
132
|
+
return access_token, token_or_tokens
|
133
|
+
|
134
|
+
# try refresh flow
|
135
|
+
refresh_token = token_or_tokens.get("refresh_token")
|
136
|
+
client_id = token_or_tokens.get("client_id")
|
137
|
+
client_secret = token_or_tokens.get("client_secret")
|
138
|
+
|
139
|
+
# fallback to env vars if client id/secret not in the dict
|
140
|
+
if not client_id or not client_secret:
|
141
|
+
env_client_id, env_client_secret = _get_env_client_credentials()
|
142
|
+
if not client_id:
|
143
|
+
client_id = env_client_id
|
144
|
+
if not client_secret:
|
145
|
+
client_secret = env_client_secret
|
146
|
+
|
147
|
+
if refresh_token and client_id and client_secret:
|
148
|
+
try:
|
149
|
+
new_tokens = refresh_access_token(refresh_token, int(client_id), client_secret)
|
150
|
+
except Exception as e:
|
151
|
+
print(f"refresh failed: {e}")
|
152
|
+
return None, None
|
153
|
+
|
154
|
+
access_token = new_tokens.get("access_token")
|
155
|
+
return access_token, new_tokens
|
156
|
+
|
157
|
+
return None, None
|
158
|
+
|
159
|
+
|
160
|
+
@mcp.tool("strava://athlete/stats")
|
161
|
+
def get_athlete_stats(token: object) -> object:
|
162
|
+
"""Retrieve athlete activities using either an access token string or a token dict.
|
163
|
+
|
164
|
+
If a token dict is provided and the access token is missing/expired, the function will
|
165
|
+
attempt to refresh it (one attempt) using provided refresh token and client credentials
|
166
|
+
(falling back to STRAVA_CLIENT_ID/STRAVA_CLIENT_SECRET environment variables).
|
167
|
+
"""
|
168
|
+
access_token, tokens_dict = _ensure_access_token(token)
|
169
|
+
if not access_token:
|
170
|
+
return {"error": "Could not obtain an access token"}
|
171
|
+
|
172
|
+
url = "https://www.strava.com/api/v3/athlete/activities?per_page=60"
|
173
|
+
headers = {
|
174
|
+
"accept": "application/json",
|
175
|
+
"authorization": f"Bearer {access_token}"
|
176
|
+
}
|
177
|
+
|
178
|
+
response = requests.get(url, headers=headers)
|
179
|
+
|
180
|
+
# If unauthorized, try one refresh if we have a refresh token available
|
181
|
+
if response.status_code == 401 and isinstance(token, dict):
|
182
|
+
refresh_token = token.get("refresh_token") or (tokens_dict or {}).get("refresh_token")
|
183
|
+
if refresh_token:
|
184
|
+
client_id = token.get("client_id") or (tokens_dict or {}).get("client_id")
|
185
|
+
client_secret = token.get("client_secret") or (tokens_dict or {}).get("client_secret")
|
186
|
+
if not client_id or not client_secret:
|
187
|
+
env_client_id, env_client_secret = _get_env_client_credentials()
|
188
|
+
client_id = client_id or env_client_id
|
189
|
+
client_secret = client_secret or env_client_secret
|
190
|
+
|
191
|
+
if client_id and client_secret:
|
192
|
+
new_tokens = refresh_access_token(refresh_token, int(client_id), client_secret)
|
193
|
+
new_access = new_tokens.get("access_token")
|
194
|
+
if new_access:
|
195
|
+
headers["authorization"] = f"Bearer {new_access}"
|
196
|
+
response = requests.get(url, headers=headers)
|
197
|
+
|
198
|
+
try:
|
199
|
+
response.raise_for_status()
|
200
|
+
except requests.HTTPError:
|
201
|
+
return {"error": "request failed", "status_code": response.status_code, "response": response.text}
|
202
|
+
|
203
|
+
return response.json()
|
204
|
+
|
205
|
+
if __name__ == "__main__":
|
206
|
+
mcp.run(transport="stdio") # Run the server, using standard input/output for communication
|
strava_activity_mcp_server-0.1.3/src/strava_activity_mcp_server/strava_activity_mcp_server.py
DELETED
@@ -1,113 +0,0 @@
|
|
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
|
-
|
9
|
-
@mcp.tool("strava://auth/url")
|
10
|
-
|
11
|
-
def get_auth_url(client_id: int | None = None):
|
12
|
-
"""Return the Strava OAuth authorization URL. If client_id is not provided,
|
13
|
-
read it from the STRAVA_CLIENT_ID environment variable."""
|
14
|
-
if client_id is None:
|
15
|
-
client_id_env = os.getenv("STRAVA_CLIENT_ID")
|
16
|
-
if not client_id_env:
|
17
|
-
return {"error": "STRAVA_CLIENT_ID environment variable is not set"}
|
18
|
-
try:
|
19
|
-
client_id = int(client_id_env)
|
20
|
-
except ValueError:
|
21
|
-
return {"error": "STRAVA_CLIENT_ID must be an integer"}
|
22
|
-
|
23
|
-
params = {
|
24
|
-
"client_id": client_id,
|
25
|
-
"response_type": "code",
|
26
|
-
"redirect_uri": "https://developers.strava.com/oauth2-redirect/",
|
27
|
-
"approval_prompt": "force",
|
28
|
-
"scope": "read,activity:read_all",
|
29
|
-
}
|
30
|
-
return "https://www.strava.com/oauth/authorize?" + urllib.parse.urlencode(params)
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
@mcp.tool("strava://auth/token")
|
35
|
-
def exchange_code_for_token(
|
36
|
-
code: str,
|
37
|
-
client_id: int,
|
38
|
-
client_secret: str,
|
39
|
-
) -> dict:
|
40
|
-
"""Exchange an authorization code for access + refresh tokens."""
|
41
|
-
if not code:
|
42
|
-
return {"error": "authorization code is required"}
|
43
|
-
if not client_secret:
|
44
|
-
return {"error": "client_secret is required"}
|
45
|
-
|
46
|
-
resp = requests.post(
|
47
|
-
"https://www.strava.com/oauth/token",
|
48
|
-
data={
|
49
|
-
"client_id": client_id,
|
50
|
-
"client_secret": client_secret,
|
51
|
-
"code": code,
|
52
|
-
"grant_type": "authorization_code",
|
53
|
-
},
|
54
|
-
)
|
55
|
-
try:
|
56
|
-
resp.raise_for_status()
|
57
|
-
except requests.HTTPError:
|
58
|
-
return {"error": "token request failed", "status_code": resp.status_code, "response": resp.text}
|
59
|
-
|
60
|
-
tokens = resp.json()
|
61
|
-
# Print tokens for debugging (optional)
|
62
|
-
print(tokens)
|
63
|
-
|
64
|
-
access_token = tokens.get("access_token")
|
65
|
-
refresh_token = tokens.get("refresh_token")
|
66
|
-
|
67
|
-
return {"tokens": tokens, "access_token": access_token, "refresh_token": refresh_token}
|
68
|
-
|
69
|
-
|
70
|
-
def refresh_access_token(
|
71
|
-
refresh_token: str,
|
72
|
-
client_id: int,
|
73
|
-
client_secret: str,
|
74
|
-
) -> dict:
|
75
|
-
"""Refresh an access token using a refresh token."""
|
76
|
-
if not refresh_token:
|
77
|
-
return {"error": "refresh_token is required"}
|
78
|
-
|
79
|
-
resp = requests.post(
|
80
|
-
"https://www.strava.com/oauth/token",
|
81
|
-
data={
|
82
|
-
"client_id": client_id,
|
83
|
-
"client_secret": client_secret,
|
84
|
-
"grant_type": "refresh_token",
|
85
|
-
"refresh_token": refresh_token,
|
86
|
-
},
|
87
|
-
)
|
88
|
-
try:
|
89
|
-
resp.raise_for_status()
|
90
|
-
except requests.HTTPError:
|
91
|
-
return {"error": "refresh request failed", "status_code": resp.status_code, "response": resp.text}
|
92
|
-
|
93
|
-
new_tokens = resp.json()
|
94
|
-
# Print new tokens for debugging (optional)
|
95
|
-
print(new_tokens)
|
96
|
-
return new_tokens
|
97
|
-
|
98
|
-
|
99
|
-
@mcp.tool("strava://athlete/stats")
|
100
|
-
def get_athlete_stats(token: str) -> object:
|
101
|
-
"""Retrieve athlete activities using an access token."""
|
102
|
-
url = "https://www.strava.com/api/v3/athlete/activities?per_page=60"
|
103
|
-
headers = {
|
104
|
-
"accept": "application/json",
|
105
|
-
"authorization": f"Bearer {token}"
|
106
|
-
}
|
107
|
-
response = requests.get(url, headers=headers)
|
108
|
-
response.raise_for_status()
|
109
|
-
# Return the parsed JSON (dict or list) instead of a JSON string so the return type matches.
|
110
|
-
return response.json()
|
111
|
-
|
112
|
-
if __name__ == "__main__":
|
113
|
-
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
|
{strava_activity_mcp_server-0.1.3 → strava_activity_mcp_server-0.1.4}/ref/mcp_pypi_example.md
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|