From 578c4b292ac9d873a500587ec3f732d5fd16b596 Mon Sep 17 00:00:00 2001 From: Matthias Hinrichs Date: Sun, 10 May 2026 11:15:00 +0200 Subject: [PATCH] feat: enhance OAuth flow with synchronous token exchange and automatic .env file updates --- pyproject.toml | 2 + src/strava_mcp_server/get_token.py | 145 +++++++++++++++++++++------- src/strava_mcp_server/tools/auth.py | 136 ++++++++++++++++++-------- 3 files changed, 211 insertions(+), 72 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c10f8aa..15a3301 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,8 @@ Repository = "https://git.hnrx.net/hnrx/strava-mcp-server" [project.scripts] strava-mcp = "strava_mcp_server.main:main" strava-mcp-get-token = "strava_mcp_server.get_token:main" +server = "strava_mcp_server.main:main" +auth = "strava_mcp_server.get_token:main" [dependency-groups] dev = [ diff --git a/src/strava_mcp_server/get_token.py b/src/strava_mcp_server/get_token.py index 5c8fa60..12a2bf2 100644 --- a/src/strava_mcp_server/get_token.py +++ b/src/strava_mcp_server/get_token.py @@ -25,33 +25,95 @@ CLIENT_SECRET = os.getenv("STRAVA_CLIENT_SECRET") REDIRECT_URI = "http://localhost:8765/callback" SCOPES = "profile:read_all,activity:read_all,activity:read,profile:write" -# Global to capture the auth code from the callback -auth_code: str | None = None - - class CallbackHandler(BaseHTTPRequestHandler): + client_id: str = "" + client_secret: str = "" + tokens: dict = {} + error: str | None = None + def do_GET(self): - global auth_code parsed = urlparse(self.path) params = parse_qs(parsed.query) if "code" in params: - auth_code = params["code"][0] - self.send_response(200) - self.send_header("Content-Type", "text/html") - self.end_headers() - self.wfile.write(b""" - -

✅ Authorization successful!

-

You can close this window and return to your terminal.

- - """) + code = params["code"][0] + try: + # Exchange code for token synchronously + response = httpx.post( + "https://www.strava.com/oauth/token", + data={ + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "grant_type": "authorization_code", + }, + ) + response.raise_for_status() + self.tokens = response.json() + + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + + refresh_token = self.tokens.get("refresh_token") + + self.wfile.write(f""" + + + + + +
+ +

Authorization successful!

+

You have successfully authenticated with Strava. You can now close this window.

+
+ +

1. Local Setup (.env)

+

Copy the following block into your .env file in the project root:

+
+ Your .env content: +
STRAVA_CLIENT_ID={self.client_id}
+STRAVA_CLIENT_SECRET={self.client_secret}
+STRAVA_REFRESH_TOKEN={refresh_token}
+
+ +

2. Kubernetes Setup (Secret)

+

If you are deploying this server to Kubernetes, run the following command to create the required Secret:

+
+ Kubectl Command: +
kubectl create secret generic strava-mcp-server-secret \\
+  --from-literal=STRAVA_CLIENT_ID={self.client_id} \\
+  --from-literal=STRAVA_CLIENT_SECRET={self.client_secret} \\
+  --from-literal=STRAVA_REFRESH_TOKEN={refresh_token}
+
+ +

+ — Strava MCP Server Authorization Helper — +

+ + + """.encode("utf-8")) + except Exception as e: + self.error = str(e) + self.send_response(500) + self.end_headers() + self.wfile.write(f"Error exchanging token: {e}".encode()) else: - error = params.get("error", ["unknown"])[0] + error_msg = params.get("error", ["unknown"])[0] self.send_response(400) - self.send_header("Content-Type", "text/html") self.end_headers() - self.wfile.write(f"

❌ Error: {error}

".encode()) + self.wfile.write(f"Error: {error_msg}".encode()) def log_message(self, format, *args): pass # Suppress server logs @@ -71,6 +133,12 @@ def main(): f"&scope={SCOPES}" ) + # Configure handler + CallbackHandler.client_id = CLIENT_ID + CallbackHandler.client_secret = CLIENT_SECRET + CallbackHandler.tokens = {} + CallbackHandler.error = None + print("=" * 60) print(" Strava OAuth2 Authorization") print("=" * 60) @@ -85,26 +153,15 @@ def main(): server = HTTPServer(("localhost", 8765), CallbackHandler) server.handle_request() # Handle exactly one request (the callback) - if not auth_code: - print("❌ No authorization code received.") + if CallbackHandler.error: + print(f"❌ Token exchange failed: {CallbackHandler.error}") return - print("\nExchanging authorization code for tokens...") - response = httpx.post( - "https://www.strava.com/oauth/token", - data={ - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "code": auth_code, - "grant_type": "authorization_code", - }, - ) - - if response.status_code != 200: - print(f"❌ Token exchange failed: {response.status_code} {response.text}") + if not CallbackHandler.tokens: + print("❌ No tokens received.") return - data = response.json() + data = CallbackHandler.tokens refresh_token = data["refresh_token"] athlete = data.get("athlete", {}) @@ -119,6 +176,26 @@ def main(): print(f"STRAVA_CLIENT_SECRET={CLIENT_SECRET}") print(f"STRAVA_REFRESH_TOKEN={refresh_token}") print("-" * 40) + + # Optional: Automatically update .env if it exists + try: + env_path = ".env" + if os.path.exists(env_path): + with open(env_path, "r") as f: + lines = f.readlines() + with open(env_path, "w") as f: + found = False + for line in lines: + if line.startswith("STRAVA_REFRESH_TOKEN="): + f.write(f"STRAVA_REFRESH_TOKEN={refresh_token}\n") + found = True + else: + f.write(line) + if not found: + f.write(f"\nSTRAVA_REFRESH_TOKEN={refresh_token}\n") + print("Successfully updated your .env file!") + except Exception as e: + print(f"Could not automatically update .env: {e}") if __name__ == "__main__": diff --git a/src/strava_mcp_server/tools/auth.py b/src/strava_mcp_server/tools/auth.py index 984d13d..8c02a5b 100644 --- a/src/strava_mcp_server/tools/auth.py +++ b/src/strava_mcp_server/tools/auth.py @@ -7,31 +7,96 @@ from urllib.parse import urlparse, parse_qs from mcp.server.fastmcp import FastMCP, Context from mcp.types import TextContent -auth_code: str | None = None - class CallbackHandler(BaseHTTPRequestHandler): + client_id: str = "" + client_secret: str = "" + tokens: dict = {} + error: str | None = None + def do_GET(self): - global auth_code parsed = urlparse(self.path) params = parse_qs(parsed.query) if "code" in params: - auth_code = params["code"][0] - self.send_response(200) - self.send_header("Content-Type", "text/html") - self.end_headers() - self.wfile.write(b""" - -

✅ Authorization successful!

-

You can close this window and return to your terminal/chat.

- - """) + code = params["code"][0] + try: + # Exchange code for token synchronously inside the handler + import httpx + response = httpx.post( + "https://www.strava.com/oauth/token", + data={ + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "grant_type": "authorization_code", + }, + ) + response.raise_for_status() + self.tokens = response.json() + + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + + refresh_token = self.tokens.get("refresh_token") + + self.wfile.write(f""" + + + + + +
+ +

Authorization successful!

+

You have successfully authenticated with Strava. You can now close this window.

+
+ +

1. Local Setup (.env)

+

Copy the following block into your .env file in the project root:

+
+ Your .env content: +
STRAVA_CLIENT_ID={self.client_id}
+STRAVA_CLIENT_SECRET={self.client_secret}
+STRAVA_REFRESH_TOKEN={refresh_token}
+
+ +

2. Kubernetes Setup (Secret)

+

If you are deploying this server to Kubernetes, run the following command to create the required Secret:

+
+ Kubectl Command: +
kubectl create secret generic strava-mcp-server-secret \\
+  --from-literal=STRAVA_CLIENT_ID={self.client_id} \\
+  --from-literal=STRAVA_CLIENT_SECRET={self.client_secret} \\
+  --from-literal=STRAVA_REFRESH_TOKEN={refresh_token}
+
+ +

+ — Strava MCP Server Authorization Helper — +

+ + + """.encode("utf-8")) + except Exception as e: + self.error = str(e) + self.send_response(500) + self.end_headers() + self.wfile.write(f"Error exchanging token: {e}".encode()) else: - error = params.get("error", ["unknown"])[0] + error_msg = params.get("error", ["unknown"])[0] self.send_response(400) - self.send_header("Content-Type", "text/html") self.end_headers() - self.wfile.write(f"

❌ Error: {error}

".encode()) + self.wfile.write(f"Error: {error_msg}".encode()) def log_message(self, format, *args): pass # Suppress server logs @@ -44,14 +109,17 @@ def register(mcp: FastMCP, strava) -> None: This opens a browser window for the user to log in and authorize the app. It then intercepts the redirect locally, obtains the token, and returns the tokens. """ - global auth_code - auth_code = None - client_id = os.getenv("STRAVA_CLIENT_ID") client_secret = os.getenv("STRAVA_CLIENT_SECRET") if not client_id or not client_secret: return [TextContent(type="text", text="Error: Missing STRAVA_CLIENT_ID or STRAVA_CLIENT_SECRET in .env")] + # Configure handler with credentials + CallbackHandler.client_id = client_id + CallbackHandler.client_secret = client_secret + CallbackHandler.tokens = {} + CallbackHandler.error = None + redirect_uri = "http://localhost:8765/callback" scopes = "profile:read_all,activity:read_all,activity:read,profile:write" @@ -73,27 +141,15 @@ def register(mcp: FastMCP, strava) -> None: # Run handle_request in a separate thread so it doesn't block the async event loop await asyncio.to_thread(server.handle_request) - if not auth_code: - return [TextContent(type="text", text="Error: No authorization code received.")] + if CallbackHandler.error: + return [TextContent(type="text", text=f"Error during token exchange: {CallbackHandler.error}")] - await ctx.info("Authorization code received. Exchanging for tokens...") + if not CallbackHandler.tokens: + return [TextContent(type="text", text="Error: No tokens received.")] + + await ctx.info("Tokens received successfully.") - async with httpx.AsyncClient() as client: - response = await client.post( - "https://www.strava.com/oauth/token", - data={ - "client_id": client_id, - "client_secret": client_secret, - "code": auth_code, - "grant_type": "authorization_code", - }, - ) - - if response.status_code != 200: - return [TextContent(type="text", text=f"Error: Token exchange failed: {response.status_code} {response.text}")] - - data = response.json() - refresh_token = data.get("refresh_token") + refresh_token = CallbackHandler.tokens.get("refresh_token") # Update the .env file if it exists env_msg = "" @@ -103,11 +159,15 @@ def register(mcp: FastMCP, strava) -> None: with open(env_path, "r") as f: lines = f.readlines() with open(env_path, "w") as f: + found = False for line in lines: if line.startswith("STRAVA_REFRESH_TOKEN="): f.write(f"STRAVA_REFRESH_TOKEN={refresh_token}\n") + found = True else: f.write(line) + if not found: + f.write(f"\nSTRAVA_REFRESH_TOKEN={refresh_token}\n") env_msg = "\nI have also automatically updated your .env file with the new refresh token!" except Exception as e: env_msg = f"\nFailed to automatically update .env file: {e}"